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 +4 -4
- data/.rubocop.yml +3 -0
- data/CHANGELOG.md +25 -0
- data/lib/legion/api/default_settings.rb +1 -1
- data/lib/legion/api/identity_audit.rb +46 -0
- data/lib/legion/api/settings.rb +1 -1
- data/lib/legion/api.rb +2 -0
- data/lib/legion/cli/doctor/api_bind_check.rb +56 -0
- data/lib/legion/cli/doctor/mode_check.rb +40 -0
- data/lib/legion/cli/doctor_command.rb +4 -0
- data/lib/legion/extensions.rb +57 -1
- data/lib/legion/identity/broker.rb +159 -0
- data/lib/legion/identity/lease.rb +63 -0
- data/lib/legion/identity/lease_renewer.rb +82 -0
- data/lib/legion/identity/middleware.rb +79 -0
- data/lib/legion/identity/process.rb +121 -0
- data/lib/legion/identity/request.rb +71 -0
- data/lib/legion/mode.rb +76 -0
- data/lib/legion/process_role.rb +4 -2
- data/lib/legion/readiness.rb +5 -3
- data/lib/legion/service.rb +132 -3
- data/lib/legion/version.rb +1 -1
- data/lib/legion.rb +7 -0
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba8e945b1c999e0c815583388817c35bc019134994745682a774afb6e645db1f
|
|
4
|
+
data.tar.gz: d21bf7aea3328dda51f7139ed36632b31f16e5c57bef938a9eb2f9f000c2c21e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c5ef0de86e4ea0a8e92fc7433af4e36bb8b7088737142abcfa5edcaad3769ede8acf0205e2b8478e80cc4376052e6cb3ef3df384b2cf2c39abdc8205fd965a68
|
|
7
|
+
data.tar.gz: 389cf7d22ac2db9e44489c4118cbef323d0d92d9242b4ec0e8a4c7ed141425497249a6832b2cf1bbecdc9e6666d65251978fac36f299175195d2eefa8fb6e190
|
data/.rubocop.yml
CHANGED
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
|
|
@@ -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
|
data/lib/legion/api/settings.rb
CHANGED
|
@@ -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
|
data/lib/legion/extensions.rb
CHANGED
|
@@ -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)
|
|
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
|
data/lib/legion/mode.rb
ADDED
|
@@ -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
|
data/lib/legion/process_role.rb
CHANGED
|
@@ -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
|
data/lib/legion/readiness.rb
CHANGED
|
@@ -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
|
data/lib/legion/service.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/legion/version.rb
CHANGED
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.
|
|
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
|