legionio 1.7.19 → 1.7.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2114c2b32ae4dbc1650bef546391f6abb1499a0b9d96d577739503c47caba79
4
- data.tar.gz: 1e95267b23de0b635ffb32c9f745f58fe0bac63aa7d806a6581e3557b9b1aad6
3
+ metadata.gz: ba8e945b1c999e0c815583388817c35bc019134994745682a774afb6e645db1f
4
+ data.tar.gz: d21bf7aea3328dda51f7139ed36632b31f16e5c57bef938a9eb2f9f000c2c21e
5
5
  SHA512:
6
- metadata.gz: 56c140c38d73a16c97574b405c4f27bdb91de199758692e76aecd0ec55afad66acab08fc011cf31a658df408cfcf8506e5e2971f4fbe92a4276e8856153a7481
7
- data.tar.gz: 903faf2b78e2f27e1176e03558138f58ee9e6bdfcc34ac3f61c8c933424a0f7a0c641ba97d38d27c527b41cad93c9d8f69bffb64fed8a032a5d1aeb2bd6cb5ff
6
+ metadata.gz: c5ef0de86e4ea0a8e92fc7433af4e36bb8b7088737142abcfa5edcaad3769ede8acf0205e2b8478e80cc4376052e6cb3ef3df384b2cf2c39abdc8205fd965a68
7
+ data.tar.gz: 389cf7d22ac2db9e44489c4118cbef323d0d92d9242b4ec0e8a4c7ed141425497249a6832b2cf1bbecdc9e6666d65251978fac36f299175195d2eefa8fb6e190
data/.rubocop.yml CHANGED
@@ -3,6 +3,9 @@ AllCops:
3
3
  NewCops: enable
4
4
  SuggestExtensions: false
5
5
 
6
+ Style/RedundantConstantBase:
7
+ Enabled: false
8
+
6
9
  Layout/LineLength:
7
10
  Max: 160
8
11
  Exclude:
data/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.7.20] - 2026-04-06
6
+ ### Added
7
+ - `Legion::Mode` module with `LEGACY_MAP`, ENV/Settings fallback chain, `agent?`/`worker?`/`infra?`/`lite?` predicates
8
+ - `Legion.instance_id` — UUID computed at load time, ENV override via `LEGIONIO_INSTANCE_ID`
9
+ - `Legion::Identity::Process` singleton — process identity with `bind!`, `bind_fallback!`, `queue_prefix` per-mode, `AtomicReference` thread safety
10
+ - `Legion::Identity::Request` — per-request immutable identity with `from_env`, `from_auth_context`, `to_caller_hash`, `to_rbac_principal`
11
+ - `Legion::Identity::Lease` — credential lease value object with `expired?`, `stale?` (50% TTL), `ttl_seconds`, `valid?`
12
+ - `Legion::Identity::LeaseRenewer` — background thread per provider, 50% TTL renewal, cooperative shutdown (no `Thread#kill`)
13
+ - `Legion::Identity::Broker` — provider management with groups cache (60s TTL, single-flight CAS), `token_for`, `credentials_for`, `shutdown`
14
+ - `Legion::Identity::Middleware` — Rack middleware bridging `legion.auth` to `legion.principal` (`Identity::Request`)
15
+ - `setup_identity` boot step 9 — parallel provider resolution via `Concurrent::Promises`, fallback to `ENV['USER']`
16
+ - Extension publish suppression — defers `LexRegister.publish` until identity resolves, `flush_pending_registrations!`
17
+ - Identity provider auto-registration during phased extension load (`identity_provider?` duck-type check)
18
+ - `GET /api/identity/audit` route with principal and duration filtering
19
+ - `legion doctor` checks: `ApiBindCheck` (non-loopback without auth), `ModeCheck` (no explicit process.mode)
20
+
21
+ ### Changed
22
+ - `Readiness.status` upgraded to `Concurrent::Hash` for thread safety; `:identity` added to `COMPONENTS`
23
+ - `READONLY_SECTIONS` extended with `:identity`, `:rbac`, `:api`
24
+ - Default API bind changed from `0.0.0.0` to `127.0.0.1`
25
+ - `ProcessRole` delegates `.current` to `Mode.current`; added `:agent` and `:infra` role entries
26
+ - `lite_mode?` delegates to `Mode.lite?`
27
+ - Reload path adds `Identity::Process.refresh_credentials` after transport reconnect
28
+ - Shutdown adds cooperative `Identity::Broker.shutdown` and JWKS background refresh stop
29
+
5
30
  ## [1.7.19] - 2026-04-06
6
31
 
7
32
  ### Added
@@ -9,7 +9,7 @@ module Legion
9
9
  {
10
10
  enabled: true,
11
11
  port: 4567,
12
- bind: '0.0.0.0',
12
+ bind: '127.0.0.1',
13
13
  puma: puma_defaults,
14
14
  bind_retries: 3,
15
15
  bind_retry_wait: 2,
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module IdentityAudit
7
+ def self.registered(app)
8
+ app.helpers IdentityAuditHelpers
9
+
10
+ app.get '/api/identity/audit' do
11
+ halt 503, json_error('unavailable', 'audit records not available') unless defined?(Legion::Data::Model::AuditRecord)
12
+
13
+ dataset = Legion::Data::Model::AuditRecord.where(entity_type: 'identity')
14
+
15
+ principal = params[:principal]
16
+ dataset = dataset.where(Sequel.lit("metadata->>'principal' = ?", principal)) if principal
17
+
18
+ since = params[:since]
19
+ if since
20
+ duration = parse_since_duration(since)
21
+ dataset = dataset.where { created_at >= Time.now - duration } if duration
22
+ end
23
+
24
+ records = dataset.order(Sequel.desc(:created_at)).limit(100).all
25
+ json_collection(records.map do |r|
26
+ { id: r.id, action: r.action, entity_type: r.entity_type, metadata: r.parsed_metadata, created_at: r.created_at }
27
+ end)
28
+ end
29
+ end
30
+
31
+ module IdentityAuditHelpers
32
+ def parse_since_duration(value)
33
+ return nil unless value.is_a?(String)
34
+
35
+ case value
36
+ when /\A(\d+)h\z/ then Regexp.last_match(1).to_i * 3600
37
+ when /\A(\d+)m\z/ then Regexp.last_match(1).to_i * 60
38
+ when /\A(\d+)s\z/ then Regexp.last_match(1).to_i
39
+ when /\A(\d+)d\z/ then Regexp.last_match(1).to_i * 86_400
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -5,7 +5,7 @@ module Legion
5
5
  module Routes
6
6
  module Settings
7
7
  SENSITIVE_KEYS = %i[password secret token key cert private_key api_key].freeze
8
- READONLY_SECTIONS = %i[crypt transport].freeze
8
+ READONLY_SECTIONS = %i[crypt transport identity rbac api].freeze
9
9
 
10
10
  def self.registered(app)
11
11
  app.get '/api/settings' do
data/lib/legion/api.rb CHANGED
@@ -60,6 +60,7 @@ require_relative 'api/tbi_patterns'
60
60
  require_relative 'api/webhooks'
61
61
  require_relative 'api/tenants'
62
62
  require_relative 'api/inbound_webhooks'
63
+ require_relative 'api/identity_audit'
63
64
  require_relative 'api/graphql' if defined?(GraphQL)
64
65
 
65
66
  module Legion
@@ -216,6 +217,7 @@ module Legion
216
217
  register Routes::Webhooks
217
218
  register Routes::Tenants
218
219
  register Routes::InboundWebhooks
220
+ register Routes::IdentityAudit
219
221
  register Routes::GraphQL if defined?(Routes::GraphQL)
220
222
 
221
223
  use Legion::API::Middleware::RequestLogger
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module CLI
5
+ class Doctor
6
+ class ApiBindCheck
7
+ LOOPBACK_BINDS = %w[127.0.0.1 ::1 localhost].freeze
8
+
9
+ def name
10
+ 'API bind address'
11
+ end
12
+
13
+ def run
14
+ return skip_result unless defined?(Legion::Settings)
15
+
16
+ api_settings = Legion::Settings[:api]
17
+ return skip_result unless api_settings.is_a?(Hash)
18
+
19
+ bind = api_settings[:bind]
20
+ return skip_result if bind.nil?
21
+
22
+ if LOOPBACK_BINDS.include?(bind)
23
+ Result.new(
24
+ name: name,
25
+ status: :pass,
26
+ message: "API bound to loopback (#{bind})"
27
+ )
28
+ elsif api_settings.dig(:auth, :enabled) == true
29
+ Result.new(
30
+ name: name,
31
+ status: :pass,
32
+ message: "API bound to #{bind} with auth enabled"
33
+ )
34
+ else
35
+ Result.new(
36
+ name: name,
37
+ status: :warn,
38
+ message: "API bound to non-loopback address (#{bind}) without explicit auth configuration",
39
+ prescription: "Set api.auth.enabled: true or change api.bind to '127.0.0.1'"
40
+ )
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def skip_result
47
+ Result.new(
48
+ name: name,
49
+ status: :pass,
50
+ message: 'API settings not loaded'
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module CLI
5
+ class Doctor
6
+ class ModeCheck
7
+ def name
8
+ 'Process mode'
9
+ end
10
+
11
+ def run
12
+ unless defined?(Legion::Settings)
13
+ return Result.new(
14
+ name: name,
15
+ status: :pass,
16
+ message: 'Settings not loaded'
17
+ )
18
+ end
19
+
20
+ explicit_mode = Legion::Settings.dig(:process, :mode) || Legion::Settings[:mode]
21
+
22
+ if explicit_mode
23
+ Result.new(
24
+ name: name,
25
+ status: :pass,
26
+ message: "Explicit process mode configured: #{explicit_mode}"
27
+ )
28
+ else
29
+ Result.new(
30
+ name: name,
31
+ status: :warn,
32
+ message: 'No explicit process.mode configured (defaulting to agent)',
33
+ prescription: 'Set {"process": {"mode": "agent"}} in settings to prepare for Phase 9 default change to worker'
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -17,6 +17,8 @@ module Legion
17
17
  autoload :PidCheck, 'legion/cli/doctor/pid_check'
18
18
  autoload :PermissionsCheck, 'legion/cli/doctor/permissions_check'
19
19
  autoload :TlsCheck, 'legion/cli/doctor/tls_check'
20
+ autoload :ApiBindCheck, 'legion/cli/doctor/api_bind_check'
21
+ autoload :ModeCheck, 'legion/cli/doctor/mode_check'
20
22
 
21
23
  def self.exit_on_failure?
22
24
  true
@@ -37,6 +39,8 @@ module Legion
37
39
  PidCheck
38
40
  PermissionsCheck
39
41
  TlsCheck
42
+ ApiBindCheck
43
+ ModeCheck
40
44
  ].freeze
41
45
 
42
46
  # Weights: security > connectivity > convenience
@@ -22,6 +22,7 @@ module Legion
22
22
  @actors = []
23
23
  @running_instances = Concurrent::Array.new
24
24
  @loaded_extensions = []
25
+ @pending_registrations = Concurrent::Array.new
25
26
 
26
27
  find_extensions
27
28
 
@@ -106,6 +107,21 @@ module Legion
106
107
  Legion::Logging.info "Successfully shut down all actors (#{(Time.now - shutdown_start).round(1)}s)"
107
108
  end
108
109
 
110
+ def flush_pending_registrations!
111
+ return if @pending_registrations.nil? || @pending_registrations.empty?
112
+
113
+ registrations = @pending_registrations
114
+ count = registrations.size
115
+ @pending_registrations = nil
116
+
117
+ registrations.each do |registration|
118
+ registration.publish
119
+ rescue StandardError => e
120
+ Legion::Logging.warn "[Extensions] flush registration failed: #{e.message}" if defined?(Legion::Logging)
121
+ end
122
+ Legion::Logging.info "[Extensions] flushed #{count} pending registrations" if defined?(Legion::Logging)
123
+ end
124
+
109
125
  def pause_actors
110
126
  @running_instances&.each do |inst|
111
127
  timer = inst.instance_variable_get(:@timer)
@@ -253,8 +269,15 @@ module Legion
253
269
  has_logger = extension.respond_to?(:log)
254
270
  extension.autobuild
255
271
 
272
+ register_identity_provider(extension, entry) if identity_provider?(extension)
273
+
256
274
  require 'legion/transport/messages/lex_register'
257
- Legion::Transport::Messages::LexRegister.new(function: 'save', opts: extension.runners).publish
275
+ registration = Legion::Transport::Messages::LexRegister.new(function: 'save', opts: extension.runners)
276
+ if @pending_registrations
277
+ @pending_registrations << registration
278
+ else
279
+ registration.publish
280
+ end
258
281
 
259
282
  register_capabilities(entry[:gem_name], extension.runners) if extension.respond_to?(:runners)
260
283
  write_lex_cli_manifest(entry, extension)
@@ -405,6 +428,39 @@ module Legion
405
428
 
406
429
  private
407
430
 
431
+ def identity_provider?(extension)
432
+ extension.respond_to?(:provider_name) &&
433
+ extension.respond_to?(:provider_type) &&
434
+ extension.respond_to?(:facing)
435
+ end
436
+
437
+ def register_identity_provider(extension, entry)
438
+ return unless defined?(Legion::Data) && Legion::Data.connected?
439
+ return unless defined?(Legion::Data::Model::IdentityProvider)
440
+
441
+ name = extension.provider_name.to_s
442
+ attrs = {
443
+ provider_type: extension.provider_type.to_s,
444
+ facing: extension.facing.to_s,
445
+ priority: extension.respond_to?(:priority) ? extension.priority : 100,
446
+ trust_weight: extension.respond_to?(:trust_weight) ? extension.trust_weight : 50,
447
+ capabilities: extension.respond_to?(:capabilities) ? Array(extension.capabilities).map(&:to_s) : [],
448
+ source: 'gem',
449
+ enabled: true
450
+ }
451
+
452
+ existing = Legion::Data::Model::IdentityProvider.where(name: name).first
453
+ if existing
454
+ diverged = attrs.any? { |k, v| existing.send(k).to_s != v.to_s }
455
+ Legion::Logging.info "[identity][provider] name=#{name} source=db/gem diverged=#{diverged}" if defined?(Legion::Logging)
456
+ else
457
+ Legion::Data::Model::IdentityProvider.insert_conflict(target: :name, update: attrs).insert(attrs.merge(name: name))
458
+ Legion::Logging.info "[identity][provider] name=#{name} registered" if defined?(Legion::Logging)
459
+ end
460
+ rescue StandardError => e
461
+ Legion::Logging.warn "[identity][provider] registration failed for #{entry[:gem_name]}: #{e.message}" if defined?(Legion::Logging)
462
+ end
463
+
408
464
  def write_lex_cli_manifest(entry, extension)
409
465
  require 'legion/cli/lex_cli_manifest'
410
466
 
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+
5
+ module Legion
6
+ module Identity
7
+ module Broker
8
+ GROUPS_CACHE_TTL = 60
9
+
10
+ class << self
11
+ def token_for(provider_name)
12
+ renewer = renewers[provider_name.to_sym]
13
+ return nil unless renewer
14
+
15
+ lease = renewer.current_lease
16
+ lease&.valid? ? lease.token : nil
17
+ end
18
+
19
+ def credentials_for(provider_name, service: nil)
20
+ renewer = renewers[provider_name.to_sym]
21
+ return nil unless renewer
22
+
23
+ lease = renewer.current_lease
24
+ return nil unless lease&.valid?
25
+
26
+ { token: lease.token, provider: provider_name.to_sym, service: service, lease: lease }
27
+ end
28
+
29
+ def register_provider(provider_name, provider:, lease:)
30
+ name = provider_name.to_sym
31
+ renewers[name]&.stop!
32
+ renewers[name] = LeaseRenewer.new(
33
+ provider_name: name,
34
+ provider: provider,
35
+ lease: lease
36
+ )
37
+ end
38
+
39
+ def authenticated?
40
+ Identity::Process.resolved?
41
+ end
42
+
43
+ def groups
44
+ cached = @groups_cache&.get
45
+ return cached[:groups] if cached && (Time.now - cached[:fetched_at]) < GROUPS_CACHE_TTL
46
+
47
+ if @groups_fetch_in_progress.make_true
48
+ begin
49
+ fetched = fetch_groups
50
+ @groups_cache.set({ groups: fetched, fetched_at: Time.now })
51
+ fetched
52
+ ensure
53
+ @groups_fetch_in_progress.make_false
54
+ end
55
+ else
56
+ loop do
57
+ current = @groups_cache&.get
58
+ return current[:groups] if current
59
+
60
+ break unless @groups_fetch_in_progress.true?
61
+
62
+ sleep(0.01)
63
+ end
64
+
65
+ cached ? cached[:groups] : []
66
+ end
67
+ end
68
+
69
+ def invalidate_groups_cache!
70
+ @groups_cache.set(nil)
71
+ end
72
+
73
+ def emails
74
+ process_state = Identity::Process.identity_hash
75
+ metadata = process_state[:metadata] || {}
76
+ Array(metadata[:emails])
77
+ end
78
+
79
+ def providers
80
+ renewers.keys
81
+ end
82
+
83
+ def leases
84
+ renewers.transform_values { |r| r.current_lease&.to_h }
85
+ end
86
+
87
+ def shutdown
88
+ renewers.each_value do |r|
89
+ r.stop!
90
+ rescue Exception # rubocop:disable Lint/RescueException
91
+ nil
92
+ end
93
+ renewers.clear
94
+ end
95
+
96
+ def reset!
97
+ shutdown
98
+ @groups_cache = Concurrent::AtomicReference.new(nil)
99
+ @groups_fetch_in_progress = Concurrent::AtomicBoolean.new(false)
100
+ end
101
+
102
+ private
103
+
104
+ def renewers
105
+ @renewers ||= Concurrent::Hash.new
106
+ end
107
+
108
+ def fetch_groups
109
+ process_groups = Identity::Process.identity_hash[:groups]
110
+ return process_groups if process_groups && !process_groups.empty?
111
+
112
+ return db_groups if db_available?
113
+
114
+ []
115
+ end
116
+
117
+ def db_groups
118
+ return [] unless defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && Legion::Data.connected?
119
+
120
+ model = begin
121
+ Legion::Data::Model::IdentityGroupMembership
122
+ rescue StandardError
123
+ nil
124
+ end
125
+ return [] unless model
126
+
127
+ principal_id = Identity::Process.id
128
+ memberships = model.where(principal_id: principal_id, status: 'active').all
129
+ memberships.filter_map do |m|
130
+ m.group.name
131
+ rescue StandardError
132
+ nil
133
+ end
134
+ rescue StandardError => e
135
+ log_warn("Broker.db_groups failed: #{e.message}")
136
+ []
137
+ end
138
+
139
+ def db_available?
140
+ defined?(Legion::Data) &&
141
+ Legion::Data.respond_to?(:connected?) &&
142
+ Legion::Data.connected?
143
+ end
144
+
145
+ def log_warn(message)
146
+ if defined?(Legion::Logging) && Legion::Logging.respond_to?(:warn)
147
+ Legion::Logging.warn("[Identity::Broker] #{message}")
148
+ else
149
+ $stderr.puts "[Identity::Broker] #{message}" # rubocop:disable Style/StderrPuts
150
+ end
151
+ end
152
+ end
153
+
154
+ # Initialize atomics at module definition time
155
+ @groups_cache = Concurrent::AtomicReference.new(nil)
156
+ @groups_fetch_in_progress = Concurrent::AtomicBoolean.new(false)
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Identity
5
+ class Lease
6
+ attr_reader :provider, :credential, :lease_id, :expires_at, :renewable, :issued_at, :metadata
7
+
8
+ def initialize(provider:, credential:, lease_id: nil, expires_at: nil, renewable: false, issued_at: nil, metadata: {}) # rubocop:disable Metrics/ParameterLists
9
+ @provider = provider
10
+ @credential = credential
11
+ @lease_id = lease_id
12
+ @expires_at = expires_at
13
+ @renewable = renewable
14
+ @issued_at = issued_at || Time.now
15
+ @metadata = metadata.freeze
16
+ end
17
+
18
+ def token
19
+ credential
20
+ end
21
+
22
+ def expired?
23
+ return false if expires_at.nil?
24
+
25
+ Time.now >= expires_at
26
+ end
27
+
28
+ def stale?
29
+ return false if expires_at.nil? || issued_at.nil?
30
+
31
+ elapsed = Time.now - issued_at
32
+ total = expires_at - issued_at
33
+ return false if total <= 0
34
+
35
+ elapsed >= (total * 0.5)
36
+ end
37
+
38
+ def ttl_seconds
39
+ return nil if expires_at.nil?
40
+
41
+ remaining = expires_at - Time.now
42
+ remaining.negative? ? 0 : remaining.to_i
43
+ end
44
+
45
+ def valid?
46
+ !credential.nil? && !expired?
47
+ end
48
+
49
+ def to_h
50
+ {
51
+ provider: provider,
52
+ lease_id: lease_id,
53
+ expires_at: expires_at&.iso8601,
54
+ renewable: renewable,
55
+ issued_at: issued_at&.iso8601,
56
+ ttl: ttl_seconds,
57
+ valid: valid?,
58
+ metadata: metadata
59
+ }
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+
5
+ module Legion
6
+ module Identity
7
+ class LeaseRenewer
8
+ attr_reader :provider_name
9
+
10
+ BACKOFF_SLEEP = 5
11
+ MIN_SLEEP = 1
12
+ DEFAULT_SLEEP = 60
13
+
14
+ def initialize(provider_name:, provider:, lease:)
15
+ @provider_name = provider_name
16
+ @provider = provider
17
+ @lease = Concurrent::AtomicReference.new(lease)
18
+ @stop = Concurrent::AtomicBoolean.new(false)
19
+ @thread = Thread.new { run_loop }
20
+ @thread.name = "lease-renewer-#{provider_name}"
21
+ @thread.abort_on_exception = false
22
+ end
23
+
24
+ def current_lease
25
+ @lease.get
26
+ end
27
+
28
+ def stop!
29
+ @stop.make_true
30
+ @thread&.wakeup rescue nil # rubocop:disable Style/RescueModifier
31
+ @thread&.join(5)
32
+ end
33
+
34
+ def alive?
35
+ @thread&.alive? || false
36
+ end
37
+
38
+ private
39
+
40
+ def run_loop
41
+ until @stop.true?
42
+ lease = @lease.get
43
+ sleep_time = compute_sleep(lease)
44
+ interruptible_sleep(sleep_time)
45
+ break if @stop.true?
46
+
47
+ renew
48
+ end
49
+ end
50
+
51
+ def renew
52
+ new_lease = @provider.provide_token
53
+ @lease.set(new_lease) if new_lease&.valid?
54
+ rescue StandardError => e
55
+ log_renewal_failure(e)
56
+ interruptible_sleep(BACKOFF_SLEEP)
57
+ end
58
+
59
+ def compute_sleep(lease)
60
+ return DEFAULT_SLEEP if lease.nil? || lease.expires_at.nil? || lease.issued_at.nil?
61
+
62
+ remaining = lease.expires_at - Time.now
63
+ half_remaining = remaining / 2.0
64
+ [half_remaining, MIN_SLEEP].max
65
+ end
66
+
67
+ def interruptible_sleep(seconds)
68
+ deadline = Time.now + seconds
69
+ sleep([1, deadline - Time.now].min) while Time.now < deadline && !@stop.true?
70
+ end
71
+
72
+ def log_renewal_failure(error)
73
+ message = "[LeaseRenewer][#{@provider_name}] renewal failed: #{error.message}"
74
+ if defined?(Legion::Logging) && Legion::Logging.respond_to?(:warn)
75
+ Legion::Logging.warn(message)
76
+ else
77
+ $stderr.puts message # rubocop:disable Style/StderrPuts
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Identity
5
+ class Middleware
6
+ SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics].freeze
7
+ LOOPBACK_BINDS = %w[127.0.0.1 ::1 localhost].freeze
8
+
9
+ def initialize(app, require_auth: false)
10
+ @app = app
11
+ @require_auth = require_auth
12
+ end
13
+
14
+ def call(env)
15
+ return @app.call(env) if skip_path?(env['PATH_INFO'])
16
+
17
+ # Bridge from existing auth middleware
18
+ auth_claims = env['legion.auth']
19
+ auth_method = env['legion.auth_method']
20
+
21
+ env['legion.principal'] = if auth_claims
22
+ build_request(auth_claims, auth_method)
23
+ elsif @require_auth
24
+ # Auth middleware already handled 401 for protected paths;
25
+ # this is a safety net for any path that slipped through.
26
+ nil
27
+ else
28
+ # No auth required (loopback bind, lite mode, etc.).
29
+ # Set a system-level principal so audit trails always have an identity.
30
+ system_principal
31
+ end
32
+
33
+ @app.call(env)
34
+ end
35
+
36
+ # Returns whether the API should require authentication.
37
+ # Skips auth for lite mode and loopback binds (local dev / CI).
38
+ def self.require_auth?(bind:, mode:)
39
+ return false if mode == :lite
40
+ return false if LOOPBACK_BINDS.include?(bind)
41
+
42
+ true
43
+ end
44
+
45
+ private
46
+
47
+ def skip_path?(path)
48
+ SKIP_PATHS.any? { |p| path.start_with?(p) }
49
+ end
50
+
51
+ def build_request(claims, method)
52
+ Identity::Request.from_auth_context({
53
+ sub: claims[:sub] || claims[:worker_id] || claims[:owner_msid],
54
+ name: claims[:name] || claims[:sub],
55
+ kind: determine_kind(claims, method),
56
+ groups: Array(claims[:roles] || claims[:groups]),
57
+ source: method&.to_sym
58
+ })
59
+ end
60
+
61
+ def determine_kind(claims, method)
62
+ return :service if claims[:scope] == 'worker' || claims[:worker_id]
63
+ return :human if method == 'kerberos' || claims[:scope] == 'human'
64
+
65
+ :human
66
+ end
67
+
68
+ def system_principal
69
+ @system_principal ||= Identity::Request.new(
70
+ principal_id: 'system:local',
71
+ canonical_name: 'system',
72
+ kind: :service,
73
+ groups: [],
74
+ source: :local
75
+ )
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'concurrent/atomic/atomic_reference'
5
+ require 'concurrent/atomic/atomic_boolean'
6
+
7
+ module Legion
8
+ module Identity
9
+ module Process
10
+ EMPTY_STATE = {
11
+ id: nil,
12
+ canonical_name: nil,
13
+ kind: nil,
14
+ persistent: false,
15
+ groups: [].freeze,
16
+ metadata: {}.freeze
17
+ }.freeze
18
+
19
+ class << self
20
+ def id
21
+ state = @state.get
22
+ state[:id] || Legion.instance_id
23
+ end
24
+
25
+ def canonical_name
26
+ state = @state.get
27
+ state[:canonical_name] || 'anonymous'
28
+ end
29
+
30
+ def kind
31
+ @state.get[:kind]
32
+ end
33
+
34
+ def mode
35
+ Legion::Mode.current
36
+ end
37
+
38
+ def queue_prefix
39
+ name = canonical_name
40
+ case mode
41
+ when :worker then "worker.#{name}.#{Legion.instance_id}"
42
+ when :infra then "infra.#{name}.#{safe_hostname}"
43
+ when :lite then "lite.#{name}.#{Legion.instance_id}"
44
+ else "agent.#{name}.#{safe_hostname}"
45
+ end
46
+ end
47
+
48
+ def resolved?
49
+ @resolved.true?
50
+ end
51
+
52
+ def persistent?
53
+ @state.get[:persistent] == true
54
+ end
55
+
56
+ def identity_hash
57
+ {
58
+ id: id,
59
+ canonical_name: canonical_name,
60
+ kind: kind,
61
+ mode: mode,
62
+ queue_prefix: queue_prefix,
63
+ resolved: resolved?,
64
+ persistent: persistent?,
65
+ groups: @state.get[:groups] || [],
66
+ metadata: @state.get[:metadata] || {}
67
+ }
68
+ end
69
+
70
+ def bind!(provider, identity_hash)
71
+ @provider = provider
72
+ @state.set({
73
+ id: identity_hash[:id],
74
+ canonical_name: identity_hash[:canonical_name],
75
+ kind: identity_hash[:kind],
76
+ persistent: identity_hash.fetch(:persistent, true),
77
+ groups: Array(identity_hash[:groups]).compact.freeze,
78
+ metadata: identity_hash[:metadata].is_a?(Hash) ? identity_hash[:metadata].dup.freeze : {}.freeze
79
+ })
80
+ @resolved.make_true
81
+ end
82
+
83
+ def bind_fallback!
84
+ user = ENV.fetch('USER', 'anonymous')
85
+ @state.set({
86
+ id: nil,
87
+ canonical_name: user,
88
+ kind: :human,
89
+ persistent: false,
90
+ groups: [].freeze,
91
+ metadata: {}.freeze
92
+ })
93
+ @resolved.make_false
94
+ end
95
+
96
+ def refresh_credentials
97
+ return unless defined?(@provider) && @provider.respond_to?(:refresh)
98
+
99
+ @provider.refresh
100
+ end
101
+
102
+ def reset!
103
+ @state = Concurrent::AtomicReference.new(EMPTY_STATE.dup)
104
+ @resolved = Concurrent::AtomicBoolean.new(false)
105
+ @provider = nil
106
+ end
107
+
108
+ private
109
+
110
+ def safe_hostname
111
+ ::Socket.gethostname.downcase
112
+ .gsub(/[^a-z0-9]+/, '-')
113
+ .gsub(/\A-+|-+\z/, '')
114
+ end
115
+ end
116
+
117
+ # Initialize atomics at module definition time
118
+ reset!
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Identity
5
+ class Request
6
+ attr_reader :principal_id, :canonical_name, :kind, :groups, :source, :metadata
7
+
8
+ def initialize(principal_id:, canonical_name:, kind:, groups: [], source: nil, metadata: {}) # rubocop:disable Metrics/ParameterLists
9
+ @principal_id = principal_id
10
+ @canonical_name = canonical_name
11
+ @kind = kind
12
+ @groups = groups.freeze
13
+ @source = source
14
+ @metadata = metadata.freeze
15
+ freeze
16
+ end
17
+
18
+ # Reads the already-resolved identity from the Rack env (set by middleware).
19
+ # Returns nil when the key is absent.
20
+ def self.from_env(env)
21
+ env['legion.principal']
22
+ end
23
+
24
+ # Builds a Request from a parsed auth claims hash with symbol keys:
25
+ # { sub:, name:, preferred_username:, kind:, groups:, source: }
26
+ def self.from_auth_context(claims_hash)
27
+ raw_name = claims_hash[:name] || claims_hash[:preferred_username] || ''
28
+ canonical = raw_name.to_s.strip.downcase.gsub('.', '-')
29
+
30
+ new(
31
+ principal_id: claims_hash[:sub],
32
+ canonical_name: canonical,
33
+ kind: claims_hash[:kind] || :human,
34
+ groups: claims_hash[:groups] || [],
35
+ source: claims_hash[:source]
36
+ )
37
+ end
38
+
39
+ def identity_hash
40
+ {
41
+ principal_id: principal_id,
42
+ canonical_name: canonical_name,
43
+ kind: kind,
44
+ groups: groups,
45
+ source: source
46
+ }
47
+ end
48
+
49
+ # Maps to RBAC principal format.
50
+ # :service workers are represented as :worker in RBAC.
51
+ def to_rbac_principal
52
+ {
53
+ identity: canonical_name,
54
+ type: kind == :service ? :worker : kind
55
+ }
56
+ end
57
+
58
+ # Pipeline-compatible caller hash (matches legion-llm pipeline format).
59
+ def to_caller_hash
60
+ {
61
+ requested_by: {
62
+ id: principal_id,
63
+ identity: canonical_name,
64
+ type: kind,
65
+ credential: source
66
+ }
67
+ }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Mode
5
+ LEGACY_MAP = { full: :agent, api: :worker, router: :worker, worker: :worker, lite: :lite }.freeze
6
+
7
+ class << self
8
+ def current
9
+ raw = ENV['LEGION_MODE'] ||
10
+ settings_dig(:mode) ||
11
+ settings_dig(:process, :mode) ||
12
+ legacy_role
13
+ normalize(raw)
14
+ end
15
+
16
+ def agent?
17
+ current == :agent
18
+ end
19
+
20
+ def worker?
21
+ current == :worker
22
+ end
23
+
24
+ def infra?
25
+ current == :infra
26
+ end
27
+
28
+ def lite?
29
+ current == :lite
30
+ end
31
+
32
+ private
33
+
34
+ def normalize(raw)
35
+ return :agent if raw.nil?
36
+
37
+ sym = raw.to_s.downcase.strip.to_sym
38
+ return sym if %i[agent worker infra lite].include?(sym)
39
+
40
+ LEGACY_MAP.fetch(sym, :agent)
41
+ end
42
+
43
+ def legacy_role
44
+ settings_dig(:process, :role)
45
+ end
46
+
47
+ def fetch_setting_value(container, key)
48
+ value = container[key]
49
+ return value unless value.nil?
50
+
51
+ alternate_key = case key
52
+ when Symbol then key.to_s
53
+ when String then key.to_sym
54
+ end
55
+ return value if alternate_key.nil?
56
+
57
+ container[alternate_key]
58
+ end
59
+
60
+ def settings_dig(*keys)
61
+ return nil unless defined?(Legion::Settings) && Legion::Settings.respond_to?(:[])
62
+
63
+ result = Legion::Settings
64
+ keys.each do |k|
65
+ return nil unless result.respond_to?(:[])
66
+
67
+ result = fetch_setting_value(result, k)
68
+ return nil if result.nil? && keys.last != k
69
+ end
70
+ result
71
+ rescue StandardError
72
+ nil
73
+ end
74
+ end
75
+ end
76
+ end
@@ -4,10 +4,12 @@ module Legion
4
4
  module ProcessRole
5
5
  ROLES = {
6
6
  full: { transport: true, cache: true, data: true, extensions: true, api: true, llm: true, gaia: true, crypt: true, supervision: true },
7
+ agent: { transport: true, cache: true, data: true, extensions: true, api: true, llm: true, gaia: true, crypt: true, supervision: true },
7
8
  api: { transport: true, cache: true, data: true, extensions: false, api: true, llm: false, gaia: false, crypt: true, supervision: false },
8
9
  worker: { transport: true, cache: true, data: true, extensions: true, api: false, llm: true, gaia: true, crypt: true, supervision: true },
9
10
  router: { transport: true, cache: true, data: false, extensions: true, api: false, llm: false, gaia: false, crypt: true, supervision: false },
10
- lite: { transport: true, cache: true, data: true, extensions: true, api: true, llm: true, gaia: true, crypt: false, supervision: true }
11
+ lite: { transport: true, cache: true, data: true, extensions: true, api: true, llm: true, gaia: true, crypt: false, supervision: true },
12
+ infra: { transport: true, cache: true, data: true, extensions: true, api: true, llm: true, gaia: true, crypt: true, supervision: true }
11
13
  }.freeze
12
14
 
13
15
  def self.resolve(role_name)
@@ -21,7 +23,7 @@ module Legion
21
23
 
22
24
  def self.current
23
25
  settings = begin
24
- Legion::Settings[:process]
26
+ defined?(Legion::Settings) ? Legion::Settings[:process] : nil
25
27
  rescue StandardError => e
26
28
  Legion::Logging.debug "ProcessRole#current failed to read process settings: #{e.message}" if defined?(Legion::Logging)
27
29
  nil
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'concurrent'
4
+
3
5
  module Legion
4
6
  module Readiness
5
- COMPONENTS = %i[settings crypt transport cache data rbac llm apollo gaia extensions api].freeze
7
+ COMPONENTS = %i[settings crypt transport cache data rbac llm apollo gaia identity extensions api].freeze
6
8
  DRAIN_TIMEOUT = 5
7
9
 
8
10
  class << self
9
11
  def status
10
- @status ||= {}
12
+ @status ||= Concurrent::Hash.new
11
13
  end
12
14
 
13
15
  def mark_ready(component)
@@ -43,7 +45,7 @@ module Legion
43
45
  end
44
46
 
45
47
  def reset
46
- @status = {}
48
+ @status = nil
47
49
  end
48
50
 
49
51
  def to_h
@@ -3,6 +3,7 @@
3
3
  require 'timeout'
4
4
  require 'legion/logging'
5
5
  require_relative 'readiness'
6
+ require_relative 'mode'
6
7
  require_relative 'process_role'
7
8
 
8
9
  module Legion
@@ -149,6 +150,9 @@ module Legion
149
150
  setup_generated_functions
150
151
  end
151
152
 
153
+ # Identity resolution — after extensions so lex-identity-* providers are loaded
154
+ setup_identity if transport
155
+
152
156
  register_core_tools
153
157
 
154
158
  Legion::Gaia.registry&.rediscover if gaia && defined?(Legion::Gaia) && Legion::Gaia.started?
@@ -204,8 +208,7 @@ module Legion
204
208
  end
205
209
 
206
210
  def lite_mode?
207
- ENV['LEGION_MODE'] == 'lite' ||
208
- Legion::Settings[:mode].to_s == 'lite'
211
+ Legion::Mode.lite?
209
212
  end
210
213
 
211
214
  def setup_data
@@ -344,6 +347,12 @@ module Legion
344
347
  log.info "Starting Legion API on #{bind}:#{port}"
345
348
  end
346
349
 
350
+ # Mount identity middleware — bridges legion.auth to legion.principal
351
+ if defined?(Legion::Identity::Middleware)
352
+ require_auth = Legion::Identity::Middleware.require_auth?(bind: bind, mode: Legion::Mode.current)
353
+ Legion::API.use Legion::Identity::Middleware, require_auth: require_auth
354
+ end
355
+
347
356
  @api_thread = Thread.new do
348
357
  retries = 0
349
358
  max_retries = api_settings[:bind_retries]
@@ -427,6 +436,45 @@ module Legion
427
436
  log.info 'Legion::Transport connected'
428
437
  end
429
438
 
439
+ def setup_identity
440
+ require_relative 'identity/process'
441
+ require_relative 'identity/broker'
442
+ require_relative 'identity/lease'
443
+ require_relative 'identity/lease_renewer'
444
+ require_relative 'identity/request'
445
+ require_relative 'identity/middleware'
446
+
447
+ # Resolve identity from available providers (Phase 4 adds real providers)
448
+ resolved = resolve_identity_providers
449
+ unless resolved
450
+ Legion::Identity::Process.bind_fallback!
451
+ log.info "[Identity] fallback identity: #{Legion::Identity::Process.canonical_name}"
452
+ end
453
+
454
+ # Re-resolve secrets for any identity-scoped lease:// refs (task 2.25)
455
+ Legion::Settings.resolve_secrets! if Legion::Settings.respond_to?(:resolve_secrets!)
456
+
457
+ # Fire-and-forget JWKS prefetch
458
+ jwks_url = Legion::Settings.dig(:identity, :jwks_endpoint) || Legion::Settings.dig(:crypt, :jwt, :jwks_endpoint)
459
+ if jwks_url && defined?(Legion::Crypt::JwksClient)
460
+ Legion::Crypt::JwksClient.prefetch!(jwks_url)
461
+ Legion::Crypt::JwksClient.start_background_refresh!(jwks_url)
462
+ end
463
+
464
+ log.info "[Identity] resolved=#{Legion::Identity::Process.resolved?} mode=#{Legion::Mode.current} queue_prefix=#{Legion::Identity::Process.queue_prefix}"
465
+ rescue StandardError => e
466
+ handle_exception(e, level: :warn, operation: 'service.setup_identity')
467
+ Legion::Identity::Process.bind_fallback! if defined?(Legion::Identity::Process) && !Legion::Identity::Process.resolved?
468
+ ensure
469
+ Legion::Readiness.mark_ready(:identity)
470
+ begin
471
+ Legion::Extensions.flush_pending_registrations! if defined?(Legion::Extensions) &&
472
+ Legion::Extensions.respond_to?(:flush_pending_registrations!)
473
+ rescue StandardError => e
474
+ handle_exception(e, level: :warn, operation: 'service.setup_identity.flush_pending_registrations')
475
+ end
476
+ end
477
+
430
478
  def setup_logging_transport # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
431
479
  return unless defined?(Legion::Transport::Connection)
432
480
  return unless Legion::Transport::Connection.session_open?
@@ -610,7 +658,7 @@ module Legion
610
658
  handle_exception(e, level: :warn, operation: 'service.shutdown_api')
611
659
  end
612
660
 
613
- def shutdown # rubocop:disable Metrics/CyclomaticComplexity
661
+ def shutdown # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
614
662
  log.info('Legion::Service.shutdown was called')
615
663
  @shutdown = true
616
664
  Legion::Settings[:client][:shutting_down] = true
@@ -658,6 +706,17 @@ module Legion
658
706
  shutdown_component('Cache') { Legion::Cache.shutdown }
659
707
  Legion::Readiness.mark_not_ready(:cache)
660
708
 
709
+ # Identity: cooperative shutdown of Broker (stops all LeaseRenewer threads)
710
+ if defined?(Legion::Identity::Broker)
711
+ shutdown_component('Identity::Broker') { Legion::Identity::Broker.shutdown }
712
+ Legion::Readiness.mark_not_ready(:identity)
713
+ end
714
+
715
+ # Stop JWKS background refresh
716
+ if defined?(Legion::Crypt::JwksClient) && Legion::Crypt::JwksClient.respond_to?(:stop_background_refresh!)
717
+ Legion::Crypt::JwksClient.stop_background_refresh!
718
+ end
719
+
661
720
  teardown_logging_transport
662
721
  shutdown_component('Transport') { Legion::Transport::Connection.shutdown }
663
722
  Legion::Readiness.mark_not_ready(:transport)
@@ -718,6 +777,8 @@ module Legion
718
777
  teardown_logging_transport
719
778
  setup_logging_transport
720
779
 
780
+ Legion::Identity::Process.refresh_credentials if defined?(Legion::Identity::Process)
781
+
721
782
  require 'legion/cache' unless defined?(Legion::Cache)
722
783
  Legion::Cache.setup
723
784
  Legion::Readiness.mark_ready(:cache)
@@ -735,6 +796,8 @@ module Legion
735
796
  load_extensions
736
797
  Legion::Readiness.mark_ready(:extensions)
737
798
 
799
+ Legion::Extensions.flush_pending_registrations! if defined?(Legion::Extensions) && Legion::Extensions.respond_to?(:flush_pending_registrations!)
800
+
738
801
  register_core_tools
739
802
 
740
803
  Legion::Crypt.cs
@@ -895,6 +958,72 @@ module Legion
895
958
 
896
959
  private
897
960
 
961
+ def resolve_identity_providers
962
+ # Phase 4 adds lex-identity-* providers. For now, check if any are loaded.
963
+ return false unless defined?(Legion::Extensions)
964
+
965
+ providers = find_identity_providers
966
+ return false if providers.empty?
967
+
968
+ # Parallel resolution with 5s per-provider timeout (NO Timeout.timeout — uses future.value)
969
+ pool = Concurrent::FixedThreadPool.new([providers.size, 4].min)
970
+ futures = providers.map do |provider|
971
+ Concurrent::Promises.future_on(pool, provider, &:resolve)
972
+ end
973
+
974
+ winner_pair = providers.zip(futures).find do |_provider, future|
975
+ result = begin
976
+ future.value(5) # 5s timeout per provider
977
+ rescue StandardError => e
978
+ handle_exception(e, level: :debug, operation: 'service.resolve_identity_providers.future')
979
+ nil
980
+ end
981
+ result.is_a?(Hash) && result[:canonical_name]
982
+ end
983
+
984
+ if winner_pair
985
+ provider, future = winner_pair
986
+ identity = future.value
987
+ Legion::Identity::Process.bind!(provider, identity)
988
+ log.info "[Identity] resolved via #{provider.class.name}: #{identity[:canonical_name]}"
989
+ true
990
+ else
991
+ false
992
+ end
993
+ rescue StandardError => e
994
+ handle_exception(e, level: :warn, operation: 'service.resolve_identity_providers')
995
+ false
996
+ ensure
997
+ pool&.shutdown
998
+ pool&.kill unless pool&.wait_for_termination(2)
999
+ end
1000
+
1001
+ def find_identity_providers
1002
+ return [] unless defined?(Legion::Extensions)
1003
+
1004
+ collect_identity_providers(Legion::Extensions)
1005
+ end
1006
+
1007
+ def collect_identity_providers(namespace, visited = Set.new)
1008
+ return [] unless namespace.is_a?(Module)
1009
+ return [] if visited.include?(namespace.object_id)
1010
+
1011
+ visited.add(namespace.object_id)
1012
+ providers = []
1013
+
1014
+ namespace.constants(false).each do |const_name|
1015
+ mod = namespace.const_get(const_name, false)
1016
+ next unless mod.is_a?(Module)
1017
+
1018
+ providers << mod if mod.respond_to?(:resolve) && mod.respond_to?(:provider_name)
1019
+ providers.concat(collect_identity_providers(mod, visited))
1020
+ rescue StandardError
1021
+ next
1022
+ end
1023
+
1024
+ providers
1025
+ end
1026
+
898
1027
  def bootstrap_log_level(cli_level)
899
1028
  cli_level = nil if cli_level.respond_to?(:empty?) && cli_level.empty?
900
1029
  return cli_level if cli_level
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.7.19'
4
+ VERSION = '1.7.20'
5
5
  end
data/lib/legion.rb CHANGED
@@ -6,6 +6,7 @@ require 'securerandom'
6
6
  require 'legion/version'
7
7
  require 'legion/logging'
8
8
  require 'legion/events'
9
+ require 'legion/mode'
9
10
  require 'legion/ingress'
10
11
  require 'legion/process'
11
12
  require 'legion/service'
@@ -18,6 +19,12 @@ module Legion
18
19
  autoload :Leader, 'legion/leader'
19
20
  autoload :Prompts, 'legion/prompts'
20
21
 
22
+ @instance_id = ENV.fetch('LEGIONIO_INSTANCE_ID') { SecureRandom.uuid }.downcase.strip.gsub(/[^a-z0-9-]/, '')
23
+
24
+ def self.instance_id
25
+ @instance_id
26
+ end
27
+
21
28
  attr_reader :service
22
29
 
23
30
  def self.start
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.19
4
+ version: 1.7.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -490,6 +490,7 @@ files:
490
490
  - lib/legion/api/graphql/types/task_type.rb
491
491
  - lib/legion/api/graphql/types/worker_type.rb
492
492
  - lib/legion/api/helpers.rb
493
+ - lib/legion/api/identity_audit.rb
493
494
  - lib/legion/api/inbound_webhooks.rb
494
495
  - lib/legion/api/knowledge.rb
495
496
  - lib/legion/api/lex_dispatch.rb
@@ -641,11 +642,13 @@ files:
641
642
  - lib/legion/cli/do_command.rb
642
643
  - lib/legion/cli/docs_command.rb
643
644
  - lib/legion/cli/doctor.rb
645
+ - lib/legion/cli/doctor/api_bind_check.rb
644
646
  - lib/legion/cli/doctor/bundle_check.rb
645
647
  - lib/legion/cli/doctor/cache_check.rb
646
648
  - lib/legion/cli/doctor/config_check.rb
647
649
  - lib/legion/cli/doctor/database_check.rb
648
650
  - lib/legion/cli/doctor/extensions_check.rb
651
+ - lib/legion/cli/doctor/mode_check.rb
649
652
  - lib/legion/cli/doctor/permissions_check.rb
650
653
  - lib/legion/cli/doctor/pid_check.rb
651
654
  - lib/legion/cli/doctor/rabbitmq_check.rb
@@ -841,6 +844,12 @@ files:
841
844
  - lib/legion/graph/exporter.rb
842
845
  - lib/legion/guardrails.rb
843
846
  - lib/legion/helpers/context.rb
847
+ - lib/legion/identity/broker.rb
848
+ - lib/legion/identity/lease.rb
849
+ - lib/legion/identity/lease_renewer.rb
850
+ - lib/legion/identity/middleware.rb
851
+ - lib/legion/identity/process.rb
852
+ - lib/legion/identity/request.rb
844
853
  - lib/legion/ingress.rb
845
854
  - lib/legion/isolation.rb
846
855
  - lib/legion/leader.rb
@@ -848,6 +857,7 @@ files:
848
857
  - lib/legion/lock.rb
849
858
  - lib/legion/memory/consolidator.rb
850
859
  - lib/legion/metrics.rb
860
+ - lib/legion/mode.rb
851
861
  - lib/legion/notebook/generator.rb
852
862
  - lib/legion/notebook/parser.rb
853
863
  - lib/legion/notebook/renderer.rb