legionio 1.8.15 → 1.9.0
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/CHANGELOG.md +33 -0
- data/lib/legion/api/identity_audit.rb +17 -4
- data/lib/legion/cli/mind_growth_command.rb +22 -0
- data/lib/legion/cli.rb +3 -0
- data/lib/legion/extensions.rb +0 -35
- data/lib/legion/identity/broker.rb +145 -28
- data/lib/legion/identity/grant.rb +24 -0
- data/lib/legion/identity/middleware.rb +16 -7
- data/lib/legion/identity/process.rb +36 -4
- data/lib/legion/identity/request.rb +3 -1
- data/lib/legion/identity/resolver.rb +442 -0
- data/lib/legion/identity/trust.rb +36 -0
- data/lib/legion/identity.rb +15 -0
- data/lib/legion/service.rb +19 -93
- data/lib/legion/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 69a0d25a5f8ac2e2327e09ca2185099bd87e7b8575d8c941abc807a0b6c03dfb
|
|
4
|
+
data.tar.gz: f9cb3bcdb322e670a235cc7761e43377d11ed8fed8e7e34b235b8ff77773ef8a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2d7fe093823d8a62b3bbf31f1cb9733b18d56a208990f314e7b9c9ec9ec1b51af2faf18716b48032fd57039b71aec7f32629d0d2218a9500f4746c0525da432f
|
|
7
|
+
data.tar.gz: 51b33926ea63e730ee6407e61da81808bb058030fcb1f1f2d3cd87bcafe25b4c82b21d8a914dabb02771795329e601cc1d14f2b147efccc274895da78c11cfc0
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.9.0] - 2026-04-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `Legion::Identity::Resolver` — composite identity resolution chain with parallel provider execution, DB persistence, and transport event publishing
|
|
9
|
+
- `Legion::Identity::Trust` — trust level enum (verified, authenticated, configured, cached, unverified)
|
|
10
|
+
- `Legion::Identity::Grant` — frozen value object for credential access auditing
|
|
11
|
+
- `Identity::Process` extended with trust, aliases, providers, profile composite state
|
|
12
|
+
- `Identity::Broker` upgraded to `[provider, qualifier]` tuple-keyed multi-instance storage with `for_context` routing and bounded async audit queue
|
|
13
|
+
- `Resolver.upgrade!` for post-boot identity trust escalation with canonical_name change support
|
|
14
|
+
- Settings client name updated from resolved identity for correct queue naming
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- `setup_identity` gate relaxed to run with DB-only nodes (not just transport)
|
|
18
|
+
- `register_credential_providers` gate relaxed for DB-only nodes
|
|
19
|
+
- Reload lifecycle: `Resolver.reset!` preserves providers, re-resolves with existing registrations
|
|
20
|
+
- Middleware `system_principal` uses Resolver identity when available
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- `Request.from_auth_context` canonical normalization now matches DB constraint `^[a-z0-9][a-z0-9_-]*$`
|
|
24
|
+
- `/api/identity/audit` reads from `identity_audit_log` table instead of `AuditRecord`
|
|
25
|
+
|
|
26
|
+
### Removed
|
|
27
|
+
- Legacy tree-walk identity discovery (`resolve_identity_providers`, `find_identity_providers`, `collect_identity_providers`)
|
|
28
|
+
- `identity_provider?` and `register_identity_provider` from extensions.rb
|
|
29
|
+
|
|
30
|
+
## [1.8.16] - 2026-04-22
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- `legion mind-growth wire ID` CLI command — wires a built extension into the cognitive tick cycle via `Orchestrator.post_build_pipeline`; accepts `--phase` override option
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- `MindGrowth#wire` rescue block now logs errors via `Legion::Logging.error` before displaying them, ensuring errors are captured in the daemon log and not only printed to the terminal
|
|
37
|
+
|
|
5
38
|
## [1.8.15] - 2026-04-22
|
|
6
39
|
|
|
7
40
|
### Fixed
|
|
@@ -8,12 +8,23 @@ module Legion
|
|
|
8
8
|
app.helpers IdentityAuditHelpers
|
|
9
9
|
|
|
10
10
|
app.get '/api/identity/audit' do
|
|
11
|
-
|
|
11
|
+
require_data!
|
|
12
|
+
halt 503, json_error('unavailable', 'identity audit log not available') unless defined?(Legion::Data::Model::IdentityAuditLog)
|
|
12
13
|
|
|
13
|
-
dataset = Legion::Data::Model::
|
|
14
|
+
dataset = Legion::Data::Model::IdentityAuditLog.dataset
|
|
14
15
|
|
|
15
16
|
principal = params[:principal]
|
|
16
|
-
|
|
17
|
+
if principal && defined?(Legion::Data::Model::Principal)
|
|
18
|
+
principal_record = Legion::Data::Model::Principal.where(canonical_name: principal).first
|
|
19
|
+
halt 404, json_error('not_found', "principal '#{principal}' not found") unless principal_record
|
|
20
|
+
dataset = dataset.where(principal_id: principal_record.id)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
provider = params[:provider]
|
|
24
|
+
dataset = dataset.where(provider_name: provider) if provider
|
|
25
|
+
|
|
26
|
+
event_type = params[:event_type]
|
|
27
|
+
dataset = dataset.where(event_type: event_type) if event_type
|
|
17
28
|
|
|
18
29
|
since = params[:since]
|
|
19
30
|
if since
|
|
@@ -23,7 +34,9 @@ module Legion
|
|
|
23
34
|
|
|
24
35
|
records = dataset.order(Sequel.desc(:created_at)).limit(100).all
|
|
25
36
|
json_collection(records.map do |r|
|
|
26
|
-
{ id: r.id,
|
|
37
|
+
{ id: r.id, event_type: r.event_type, provider_name: r.provider_name,
|
|
38
|
+
trust_level: r.trust_level, detail: r.detail,
|
|
39
|
+
node_id: r.node_id, session_id: r.session_id, created_at: r.created_at }
|
|
27
40
|
end)
|
|
28
41
|
end
|
|
29
42
|
end
|
|
@@ -179,6 +179,28 @@ module Legion
|
|
|
179
179
|
end
|
|
180
180
|
end
|
|
181
181
|
|
|
182
|
+
desc 'wire ID', 'Wire a built extension into the cognitive tick cycle'
|
|
183
|
+
option :phase, type: :string, desc: 'Override phase (auto-detected if omitted)'
|
|
184
|
+
def wire(proposal_id)
|
|
185
|
+
require_mind_growth!
|
|
186
|
+
result = Legion::Extensions::MindGrowth::Runners::Orchestrator.post_build_pipeline(
|
|
187
|
+
proposal_id: proposal_id
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if result[:skipped]
|
|
191
|
+
say_status :skipped, result[:reason], :yellow
|
|
192
|
+
elsif result[:activated]
|
|
193
|
+
say_status :activated, "#{proposal_id} wired and activated", :green
|
|
194
|
+
elsif result[:error]
|
|
195
|
+
say_status :error, result[:error], :red
|
|
196
|
+
else
|
|
197
|
+
say_status :partial, "Wire: #{result[:wire]}, Test: #{result[:integration_test]}", :yellow
|
|
198
|
+
end
|
|
199
|
+
rescue StandardError => e
|
|
200
|
+
Legion::Logging.error(e.message) if defined?(Legion::Logging)
|
|
201
|
+
say_status :error, e.message, :red
|
|
202
|
+
end
|
|
203
|
+
|
|
182
204
|
desc 'history', 'Show recent proposal history'
|
|
183
205
|
option :limit, type: :numeric, default: 50, desc: 'Max results'
|
|
184
206
|
def history
|
data/lib/legion/cli.rb
CHANGED
|
@@ -267,6 +267,9 @@ module Legion
|
|
|
267
267
|
desc 'init', 'Initialize a new Legion workspace'
|
|
268
268
|
subcommand 'init', Legion::CLI::Init
|
|
269
269
|
|
|
270
|
+
desc 'detect SUBCOMMAND', 'Scan environment and recommend extensions'
|
|
271
|
+
subcommand 'detect', Legion::CLI::Detect
|
|
272
|
+
|
|
270
273
|
# --- Interactive & shortcuts ---
|
|
271
274
|
desc 'knowledge SUBCOMMAND', 'Search and manage the document knowledge base'
|
|
272
275
|
subcommand 'knowledge', Legion::CLI::Knowledge
|
data/lib/legion/extensions.rb
CHANGED
|
@@ -275,8 +275,6 @@ module Legion
|
|
|
275
275
|
has_logger = extension.respond_to?(:log)
|
|
276
276
|
extension.autobuild
|
|
277
277
|
|
|
278
|
-
register_identity_provider(extension, entry) if identity_provider?(extension)
|
|
279
|
-
|
|
280
278
|
require 'legion/transport/messages/lex_register'
|
|
281
279
|
registration = Legion::Transport::Messages::LexRegister.new(function: 'save', opts: extension.runners)
|
|
282
280
|
if @pending_registrations
|
|
@@ -434,39 +432,6 @@ module Legion
|
|
|
434
432
|
|
|
435
433
|
private
|
|
436
434
|
|
|
437
|
-
def identity_provider?(extension)
|
|
438
|
-
extension.respond_to?(:provider_name) &&
|
|
439
|
-
extension.respond_to?(:provider_type) &&
|
|
440
|
-
extension.respond_to?(:facing)
|
|
441
|
-
end
|
|
442
|
-
|
|
443
|
-
def register_identity_provider(extension, entry)
|
|
444
|
-
return unless defined?(Legion::Data) && Legion::Data.connected?
|
|
445
|
-
return unless defined?(Legion::Data::Model::IdentityProvider)
|
|
446
|
-
|
|
447
|
-
name = extension.provider_name.to_s
|
|
448
|
-
attrs = {
|
|
449
|
-
provider_type: extension.provider_type.to_s,
|
|
450
|
-
facing: extension.facing.to_s,
|
|
451
|
-
priority: extension.respond_to?(:priority) ? extension.priority : 100,
|
|
452
|
-
trust_weight: extension.respond_to?(:trust_weight) ? extension.trust_weight : 50,
|
|
453
|
-
capabilities: extension.respond_to?(:capabilities) ? Array(extension.capabilities).map(&:to_s) : [],
|
|
454
|
-
source: 'gem',
|
|
455
|
-
enabled: true
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
existing = Legion::Data::Model::IdentityProvider.where(name: name).first
|
|
459
|
-
if existing
|
|
460
|
-
diverged = attrs.any? { |k, v| existing.send(k).to_s != v.to_s }
|
|
461
|
-
Legion::Logging.info "[identity][provider] name=#{name} source=db/gem diverged=#{diverged}" if defined?(Legion::Logging)
|
|
462
|
-
else
|
|
463
|
-
Legion::Data::Model::IdentityProvider.insert_conflict(target: :name, update: attrs).insert(attrs.merge(name: name))
|
|
464
|
-
Legion::Logging.info "[identity][provider] name=#{name} registered" if defined?(Legion::Logging)
|
|
465
|
-
end
|
|
466
|
-
rescue StandardError => e
|
|
467
|
-
Legion::Logging.warn "[identity][provider] registration failed for #{entry[:gem_name]}: #{e.message}" if defined?(Legion::Logging)
|
|
468
|
-
end
|
|
469
|
-
|
|
470
435
|
def write_lex_cli_manifest(entry, extension)
|
|
471
436
|
require 'legion/cli/lex_cli_manifest'
|
|
472
437
|
|
|
@@ -6,46 +6,68 @@ module Legion
|
|
|
6
6
|
module Identity
|
|
7
7
|
module Broker
|
|
8
8
|
GROUPS_CACHE_TTL = 60
|
|
9
|
+
AUDIT_QUEUE_MAX = 1000
|
|
10
|
+
AUDIT_DROP_LOG_INTERVAL = 100
|
|
9
11
|
|
|
10
12
|
class << self
|
|
11
|
-
def token_for(provider_name)
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
def token_for(provider_name, qualifier: nil, for_context: nil, purpose: nil, context: nil)
|
|
14
|
+
name = provider_name.to_sym
|
|
15
|
+
resolved = resolve_qualifier(name, qualifier: qualifier, for_context: for_context)
|
|
16
|
+
lease = lease_for(name, qualifier: resolved)
|
|
17
|
+
token = lease&.valid? ? lease.token : nil
|
|
18
|
+
emit_audit(provider: name, qualifier: resolved, purpose: purpose, context: context, granted: !token.nil?)
|
|
19
|
+
token
|
|
14
20
|
end
|
|
15
21
|
|
|
16
|
-
def lease_for(provider_name)
|
|
22
|
+
def lease_for(provider_name, qualifier: nil)
|
|
17
23
|
name = provider_name.to_sym
|
|
18
|
-
|
|
24
|
+
resolved = qualifier || default_qualifier_for(name)
|
|
25
|
+
key = [name, resolved].freeze
|
|
26
|
+
|
|
27
|
+
renewer = renewers[key]
|
|
19
28
|
return renewer.current_lease if renewer
|
|
20
29
|
|
|
21
|
-
static_ref = static_leases[
|
|
30
|
+
static_ref = static_leases[key]
|
|
22
31
|
static_ref&.get
|
|
23
32
|
end
|
|
24
33
|
|
|
25
|
-
def renewer_for(provider_name)
|
|
26
|
-
|
|
34
|
+
def renewer_for(provider_name, qualifier: nil)
|
|
35
|
+
name = provider_name.to_sym
|
|
36
|
+
resolved = qualifier || default_qualifier_for(name)
|
|
37
|
+
renewers[[name, resolved].freeze]
|
|
27
38
|
end
|
|
28
39
|
|
|
29
|
-
def credentials_for(provider_name, service: nil)
|
|
30
|
-
|
|
40
|
+
def credentials_for(provider_name, qualifier: nil, service: nil)
|
|
41
|
+
name = provider_name.to_sym
|
|
42
|
+
resolved = qualifier || default_qualifier_for(name)
|
|
43
|
+
lease = lease_for(name, qualifier: resolved)
|
|
31
44
|
return nil unless lease&.valid?
|
|
32
45
|
|
|
33
|
-
{ token: lease.token, provider:
|
|
46
|
+
{ token: lease.token, provider: name, service: service, lease: lease }
|
|
34
47
|
end
|
|
35
48
|
|
|
36
|
-
def register_provider(provider_name, provider:, lease:)
|
|
49
|
+
def register_provider(provider_name, provider:, lease:, qualifier: :default, default: false)
|
|
37
50
|
name = provider_name.to_sym
|
|
51
|
+
qual = qualifier
|
|
52
|
+
key = [name, qual].freeze
|
|
53
|
+
|
|
54
|
+
# Set default qualifier: first registration or explicit default: true
|
|
55
|
+
default_qualifiers[name] = qual if default || !default_qualifiers.key?(name)
|
|
56
|
+
|
|
57
|
+
# Store provider instance (first-write-wins per provider name)
|
|
58
|
+
provider_instances[name] ||= provider
|
|
59
|
+
|
|
60
|
+
# Stop existing renewer for this specific tuple key
|
|
61
|
+
renewers[key]&.stop!
|
|
38
62
|
|
|
39
|
-
renewers[name]&.stop!
|
|
40
63
|
if lease&.expires_at.nil? && !lease&.renewable
|
|
41
64
|
# Static credential — store without a background renewal thread
|
|
42
|
-
renewers.delete(
|
|
43
|
-
static_leases[
|
|
44
|
-
providers_map[name] = provider
|
|
65
|
+
renewers.delete(key)
|
|
66
|
+
static_leases[key] = Concurrent::AtomicReference.new(lease)
|
|
45
67
|
else
|
|
46
68
|
# Dynamic credential — create LeaseRenewer
|
|
47
|
-
static_leases.delete(
|
|
48
|
-
renewers[
|
|
69
|
+
static_leases.delete(key)
|
|
70
|
+
renewers[key] = LeaseRenewer.new(
|
|
49
71
|
provider_name: name,
|
|
50
72
|
provider: provider,
|
|
51
73
|
lease: lease
|
|
@@ -53,12 +75,15 @@ module Legion
|
|
|
53
75
|
end
|
|
54
76
|
end
|
|
55
77
|
|
|
56
|
-
def refresh_credential(provider_name)
|
|
78
|
+
def refresh_credential(provider_name, qualifier: nil)
|
|
57
79
|
name = provider_name.to_sym
|
|
58
|
-
|
|
80
|
+
resolved = qualifier || default_qualifier_for(name)
|
|
81
|
+
key = [name, resolved].freeze
|
|
82
|
+
|
|
83
|
+
ref = static_leases[key]
|
|
59
84
|
return false unless ref
|
|
60
85
|
|
|
61
|
-
provider =
|
|
86
|
+
provider = provider_instances[name]
|
|
62
87
|
return false unless provider.respond_to?(:provide_token)
|
|
63
88
|
|
|
64
89
|
new_lease = provider.provide_token
|
|
@@ -109,13 +134,29 @@ module Legion
|
|
|
109
134
|
end
|
|
110
135
|
|
|
111
136
|
def providers
|
|
112
|
-
(renewers.keys + static_leases.keys)
|
|
137
|
+
all_keys = (renewers.keys + static_leases.keys)
|
|
138
|
+
all_keys.map(&:first).uniq
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def credentials_available(provider_name)
|
|
142
|
+
name = provider_name.to_sym
|
|
143
|
+
all_keys = (renewers.keys + static_leases.keys)
|
|
144
|
+
all_keys.select { |k| k.first == name }.map(&:last).uniq
|
|
113
145
|
end
|
|
114
146
|
|
|
115
147
|
def leases
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
148
|
+
result = {}
|
|
149
|
+
renewers.each do |key, renewer|
|
|
150
|
+
provider_name, qualifier = key
|
|
151
|
+
result[provider_name] ||= {}
|
|
152
|
+
result[provider_name][qualifier] = renewer.current_lease&.to_h
|
|
153
|
+
end
|
|
154
|
+
static_leases.each do |key, ref|
|
|
155
|
+
provider_name, qualifier = key
|
|
156
|
+
result[provider_name] ||= {}
|
|
157
|
+
result[provider_name][qualifier] = ref.get&.to_h unless result[provider_name].key?(qualifier)
|
|
158
|
+
end
|
|
159
|
+
result
|
|
119
160
|
end
|
|
120
161
|
|
|
121
162
|
def shutdown
|
|
@@ -126,17 +167,41 @@ module Legion
|
|
|
126
167
|
end
|
|
127
168
|
renewers.clear
|
|
128
169
|
static_leases.clear
|
|
129
|
-
|
|
170
|
+
provider_instances.clear
|
|
171
|
+
default_qualifiers.clear
|
|
172
|
+
stop_audit_drainer
|
|
130
173
|
end
|
|
131
174
|
|
|
132
175
|
def reset!
|
|
133
176
|
shutdown
|
|
134
177
|
@groups_cache = Concurrent::AtomicReference.new(nil)
|
|
135
178
|
@groups_fetch_in_progress = Concurrent::AtomicBoolean.new(false)
|
|
179
|
+
@audit_queue = Concurrent::Array.new
|
|
180
|
+
@audit_drops = Concurrent::AtomicFixnum.new(0)
|
|
181
|
+
@audit_drainer = nil
|
|
182
|
+
@audit_drainer_started = Concurrent::AtomicBoolean.new(false)
|
|
136
183
|
end
|
|
137
184
|
|
|
138
185
|
private
|
|
139
186
|
|
|
187
|
+
def resolve_qualifier(provider_name, qualifier: nil, for_context: nil)
|
|
188
|
+
return qualifier if qualifier
|
|
189
|
+
|
|
190
|
+
if for_context
|
|
191
|
+
provider = provider_instances[provider_name]
|
|
192
|
+
if provider.respond_to?(:resolve_qualifier)
|
|
193
|
+
resolved = provider.resolve_qualifier(for_context)
|
|
194
|
+
return resolved if resolved
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
default_qualifier_for(provider_name)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def default_qualifier_for(provider_name)
|
|
202
|
+
default_qualifiers[provider_name] || :default
|
|
203
|
+
end
|
|
204
|
+
|
|
140
205
|
def renewers
|
|
141
206
|
@renewers ||= Concurrent::Hash.new
|
|
142
207
|
end
|
|
@@ -145,8 +210,56 @@ module Legion
|
|
|
145
210
|
@static_leases ||= Concurrent::Hash.new
|
|
146
211
|
end
|
|
147
212
|
|
|
148
|
-
def
|
|
149
|
-
@
|
|
213
|
+
def provider_instances
|
|
214
|
+
@provider_instances ||= Concurrent::Hash.new
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def default_qualifiers
|
|
218
|
+
@default_qualifiers ||= Concurrent::Hash.new
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def audit_queue
|
|
222
|
+
@audit_queue ||= Concurrent::Array.new
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def emit_audit(provider:, qualifier:, purpose:, context:, granted:)
|
|
226
|
+
ensure_audit_drainer_started
|
|
227
|
+
event = {
|
|
228
|
+
provider: provider,
|
|
229
|
+
qualifier: qualifier,
|
|
230
|
+
purpose: purpose,
|
|
231
|
+
context: context,
|
|
232
|
+
granted: granted,
|
|
233
|
+
timestamp: Time.now
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if audit_queue.size >= AUDIT_QUEUE_MAX
|
|
237
|
+
drops = (@audit_drops ||= Concurrent::AtomicFixnum.new(0)).increment
|
|
238
|
+
log_warn("Audit queue full, dropping event (total drops: #{drops})") if (drops % AUDIT_DROP_LOG_INTERVAL).zero?
|
|
239
|
+
else
|
|
240
|
+
audit_queue.push(event)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def ensure_audit_drainer_started
|
|
245
|
+
# Intentionally a no-op until publish_audit_event has a real
|
|
246
|
+
# implementation. Starting a drainer before a durable sink exists
|
|
247
|
+
# causes queued audit events to be silently discarded.
|
|
248
|
+
@ensure_audit_drainer_started ||= Concurrent::AtomicBoolean.new(false)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def stop_audit_drainer
|
|
252
|
+
# No background drainer is started until publish_audit_event has a
|
|
253
|
+
# real implementation. Keep this method for API compatibility.
|
|
254
|
+
@audit_drainer = nil
|
|
255
|
+
@audit_drainer_started = Concurrent::AtomicBoolean.new(false)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def publish_audit_event(event)
|
|
259
|
+
# Future: publish to transport / log store.
|
|
260
|
+
# Until then, events remain in the queue for inspection and are not
|
|
261
|
+
# drained by a background thread.
|
|
262
|
+
event
|
|
150
263
|
end
|
|
151
264
|
|
|
152
265
|
def fetch_groups
|
|
@@ -198,6 +311,10 @@ module Legion
|
|
|
198
311
|
# Initialize atomics at module definition time
|
|
199
312
|
@groups_cache = Concurrent::AtomicReference.new(nil)
|
|
200
313
|
@groups_fetch_in_progress = Concurrent::AtomicBoolean.new(false)
|
|
314
|
+
@audit_queue = Concurrent::Array.new
|
|
315
|
+
@audit_drops = Concurrent::AtomicFixnum.new(0)
|
|
316
|
+
@audit_drainer = nil
|
|
317
|
+
@audit_drainer_started = Concurrent::AtomicBoolean.new(false)
|
|
201
318
|
end
|
|
202
319
|
end
|
|
203
320
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Identity
|
|
5
|
+
class Grant
|
|
6
|
+
attr_reader :grant_id, :token, :provider, :qualifier, :purpose, :result, :reason, :expires_at
|
|
7
|
+
|
|
8
|
+
def initialize(grant_id:, token:, provider:, result:, qualifier: :default, purpose: nil, reason: nil, expires_at: nil) # rubocop:disable Metrics/ParameterLists
|
|
9
|
+
@grant_id = grant_id
|
|
10
|
+
@token = token
|
|
11
|
+
@provider = provider
|
|
12
|
+
@qualifier = qualifier
|
|
13
|
+
@purpose = purpose
|
|
14
|
+
@result = result
|
|
15
|
+
@reason = reason
|
|
16
|
+
@expires_at = expires_at
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def granted? = result == :granted
|
|
21
|
+
def denied? = result == :denied
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -115,13 +115,22 @@ module Legion
|
|
|
115
115
|
end
|
|
116
116
|
|
|
117
117
|
def system_principal
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
118
|
+
canonical = if defined?(Legion::Identity::Process) && Legion::Identity::Process.resolved?
|
|
119
|
+
Legion::Identity::Process.canonical_name
|
|
120
|
+
else
|
|
121
|
+
'system'
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
if @system_principal&.canonical_name != canonical
|
|
125
|
+
@system_principal = Identity::Request.new(
|
|
126
|
+
principal_id: "system:#{canonical}",
|
|
127
|
+
canonical_name: canonical,
|
|
128
|
+
kind: :service,
|
|
129
|
+
groups: [],
|
|
130
|
+
source: :local
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
@system_principal
|
|
125
134
|
end
|
|
126
135
|
end
|
|
127
136
|
end
|
|
@@ -14,7 +14,11 @@ module Legion
|
|
|
14
14
|
source: nil,
|
|
15
15
|
persistent: false,
|
|
16
16
|
groups: [].freeze,
|
|
17
|
-
metadata: {}.freeze
|
|
17
|
+
metadata: {}.freeze,
|
|
18
|
+
trust: nil,
|
|
19
|
+
aliases: {}.freeze,
|
|
20
|
+
providers: {}.freeze,
|
|
21
|
+
profile: {}.freeze
|
|
18
22
|
}.freeze
|
|
19
23
|
|
|
20
24
|
class << self
|
|
@@ -58,6 +62,22 @@ module Legion
|
|
|
58
62
|
@state.get[:source]
|
|
59
63
|
end
|
|
60
64
|
|
|
65
|
+
def trust
|
|
66
|
+
@state.get[:trust]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def aliases
|
|
70
|
+
@state.get[:aliases] || {}.freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def providers
|
|
74
|
+
@state.get[:providers] || {}.freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def profile
|
|
78
|
+
@state.get[:profile] || {}.freeze
|
|
79
|
+
end
|
|
80
|
+
|
|
61
81
|
def identity_hash
|
|
62
82
|
{
|
|
63
83
|
id: id,
|
|
@@ -69,7 +89,11 @@ module Legion
|
|
|
69
89
|
resolved: resolved?,
|
|
70
90
|
persistent: persistent?,
|
|
71
91
|
groups: @state.get[:groups] || [],
|
|
72
|
-
metadata: @state.get[:metadata] || {}
|
|
92
|
+
metadata: @state.get[:metadata] || {},
|
|
93
|
+
trust: trust,
|
|
94
|
+
aliases: aliases,
|
|
95
|
+
providers: providers,
|
|
96
|
+
profile: profile
|
|
73
97
|
}
|
|
74
98
|
end
|
|
75
99
|
|
|
@@ -83,7 +107,11 @@ module Legion
|
|
|
83
107
|
source: identity_hash.key?(:source) ? identity_hash[:source] : provider_source,
|
|
84
108
|
persistent: identity_hash.fetch(:persistent, true),
|
|
85
109
|
groups: Array(identity_hash[:groups]).compact.freeze,
|
|
86
|
-
metadata: identity_hash[:metadata].is_a?(Hash) ? identity_hash[:metadata].dup.freeze : {}.freeze
|
|
110
|
+
metadata: identity_hash[:metadata].is_a?(Hash) ? identity_hash[:metadata].dup.freeze : {}.freeze,
|
|
111
|
+
trust: identity_hash[:trust],
|
|
112
|
+
aliases: identity_hash[:aliases].is_a?(Hash) ? identity_hash[:aliases].dup.freeze : {}.freeze,
|
|
113
|
+
providers: identity_hash[:providers].is_a?(Hash) ? identity_hash[:providers].dup.freeze : {}.freeze,
|
|
114
|
+
profile: identity_hash[:profile].is_a?(Hash) ? identity_hash[:profile].dup.freeze : {}.freeze
|
|
87
115
|
})
|
|
88
116
|
@resolved.make_true
|
|
89
117
|
end
|
|
@@ -97,7 +125,11 @@ module Legion
|
|
|
97
125
|
source: :system,
|
|
98
126
|
persistent: false,
|
|
99
127
|
groups: [].freeze,
|
|
100
|
-
metadata: {}.freeze
|
|
128
|
+
metadata: {}.freeze,
|
|
129
|
+
trust: nil,
|
|
130
|
+
aliases: {}.freeze,
|
|
131
|
+
providers: {}.freeze,
|
|
132
|
+
profile: {}.freeze
|
|
101
133
|
})
|
|
102
134
|
@resolved.make_false
|
|
103
135
|
end
|
|
@@ -44,7 +44,9 @@ module Legion
|
|
|
44
44
|
# The source value is normalized via SOURCE_NORMALIZATION at construction time.
|
|
45
45
|
def self.from_auth_context(claims_hash)
|
|
46
46
|
raw_name = claims_hash[:name] || claims_hash[:preferred_username] || ''
|
|
47
|
-
|
|
47
|
+
stripped = raw_name.to_s.strip.downcase
|
|
48
|
+
stripped = stripped.split('@', 2).first if stripped.include?('@')
|
|
49
|
+
canonical = stripped.gsub('.', '-').gsub(/[^a-z0-9_-]/, '')
|
|
48
50
|
raw_source = claims_hash[:source]&.to_sym
|
|
49
51
|
normalized_source = SOURCE_NORMALIZATION.fetch(raw_source, raw_source)
|
|
50
52
|
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'concurrent/array'
|
|
6
|
+
require 'concurrent/atomic/atomic_reference'
|
|
7
|
+
require 'concurrent/atomic/atomic_boolean'
|
|
8
|
+
require 'concurrent/promises'
|
|
9
|
+
|
|
10
|
+
module Legion
|
|
11
|
+
module Identity
|
|
12
|
+
module Resolver
|
|
13
|
+
TIMEOUT_SECONDS = 5
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def register(provider)
|
|
17
|
+
return if @providers.any? { |p| p.provider_name == provider.provider_name }
|
|
18
|
+
|
|
19
|
+
@providers << provider
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def resolve!(timeout: TIMEOUT_SECONDS)
|
|
23
|
+
drain_pending_registrations
|
|
24
|
+
|
|
25
|
+
auth_providers, profile_providers, fallback_providers = partition_providers
|
|
26
|
+
|
|
27
|
+
winning_provider, winning_result, provider_results = resolve_auth(auth_providers, timeout: timeout)
|
|
28
|
+
|
|
29
|
+
if winning_provider.nil?
|
|
30
|
+
winning_provider, winning_result, fallback_results = resolve_auth(fallback_providers, timeout: timeout)
|
|
31
|
+
provider_results.merge!(fallback_results) if fallback_results
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
unless winning_provider
|
|
35
|
+
@resolved.make_false
|
|
36
|
+
@composite.set(nil)
|
|
37
|
+
return nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
canonical = winning_result[:canonical_name]
|
|
41
|
+
trust_level = winning_provider.trust_level
|
|
42
|
+
source = winning_provider.provider_name
|
|
43
|
+
|
|
44
|
+
profile_data = resolve_profiles(profile_providers, canonical, timeout: timeout)
|
|
45
|
+
|
|
46
|
+
composite = assemble_composite(
|
|
47
|
+
provider_results, profile_data,
|
|
48
|
+
winning_result: winning_result,
|
|
49
|
+
trust_level: trust_level,
|
|
50
|
+
source: source
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
bind_and_persist(winning_provider, composite, trust_level)
|
|
54
|
+
composite
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def upgrade!(provider, result)
|
|
58
|
+
current = @composite.get
|
|
59
|
+
return unless current
|
|
60
|
+
|
|
61
|
+
new_trust = provider.trust_level
|
|
62
|
+
new_canonical = result[:canonical_name] || current[:canonical_name]
|
|
63
|
+
canonical_changed = new_canonical != current[:canonical_name]
|
|
64
|
+
|
|
65
|
+
# Only promote the composite trust level when the new provider's trust
|
|
66
|
+
# is strictly higher (lower rank index) than the current level.
|
|
67
|
+
# This prevents an accidental downgrade if upgrade! is called with a
|
|
68
|
+
# lower-trust provider such as one with :unverified trust.
|
|
69
|
+
current_trust = current[:trust]
|
|
70
|
+
effective_trust = if defined?(Legion::Identity::Trust) &&
|
|
71
|
+
Legion::Identity::Trust.respond_to?(:above?) &&
|
|
72
|
+
Legion::Identity::Trust.above?(new_trust, current_trust)
|
|
73
|
+
new_trust
|
|
74
|
+
else
|
|
75
|
+
current_trust
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
new_aliases = current[:aliases].dup
|
|
79
|
+
provider_identity = result[:provider_identity]
|
|
80
|
+
if provider_identity
|
|
81
|
+
existing = Array(new_aliases[provider.provider_name])
|
|
82
|
+
new_aliases[provider.provider_name] = (existing + [provider_identity]).uniq
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
new_providers = current[:providers].dup
|
|
86
|
+
new_providers[provider.provider_name] = {
|
|
87
|
+
status: :resolved,
|
|
88
|
+
trust: new_trust,
|
|
89
|
+
resolved_at: Time.now
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
updated = current.merge(
|
|
93
|
+
canonical_name: new_canonical,
|
|
94
|
+
trust: effective_trust,
|
|
95
|
+
source: provider.provider_name,
|
|
96
|
+
aliases: new_aliases,
|
|
97
|
+
providers: new_providers
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
handle_canonical_change(current[:canonical_name], new_canonical, updated) if canonical_changed
|
|
101
|
+
|
|
102
|
+
@composite.set(updated)
|
|
103
|
+
Legion::Identity::Process.bind!(provider, updated) if defined?(Legion::Identity::Process)
|
|
104
|
+
|
|
105
|
+
if defined?(Legion::Settings) && Legion::Settings.respond_to?(:loader) && Legion::Settings.loader.respond_to?(:settings)
|
|
106
|
+
Legion::Settings.loader.settings[:client] ||= {}
|
|
107
|
+
Legion::Settings.loader.settings[:client][:name] = Legion::Identity::Process.queue_prefix
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
persist_identity_json(new_canonical, updated[:kind]) unless new_trust == :unverified
|
|
111
|
+
|
|
112
|
+
updated
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def resolved?
|
|
116
|
+
@resolved.true?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def composite
|
|
120
|
+
@composite.get
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def providers
|
|
124
|
+
@providers.dup
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
attr_reader :session_id
|
|
128
|
+
|
|
129
|
+
def reset!
|
|
130
|
+
@composite = Concurrent::AtomicReference.new(nil)
|
|
131
|
+
@resolved = Concurrent::AtomicBoolean.new(false)
|
|
132
|
+
@session_id = SecureRandom.uuid
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def reset_all!
|
|
136
|
+
reset!
|
|
137
|
+
@providers = Concurrent::Array.new
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def drain_pending_registrations
|
|
143
|
+
return unless defined?(Legion::Identity) && Legion::Identity.respond_to?(:pending_registrations)
|
|
144
|
+
|
|
145
|
+
pending = Legion::Identity.pending_registrations
|
|
146
|
+
return if pending.nil? || pending.empty?
|
|
147
|
+
|
|
148
|
+
drained = []
|
|
149
|
+
drained << pending.shift until pending.empty?
|
|
150
|
+
drained.each { |p| register(p) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def partition_providers
|
|
154
|
+
auth = []
|
|
155
|
+
profile = []
|
|
156
|
+
fallback = []
|
|
157
|
+
|
|
158
|
+
@providers.each do |p|
|
|
159
|
+
case p.provider_type
|
|
160
|
+
when :auth then auth << p
|
|
161
|
+
when :profile then profile << p
|
|
162
|
+
when :fallback then fallback << p
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
auth.sort_by! { |p| [-p.priority, p.trust_weight] }
|
|
167
|
+
fallback.sort_by! { |p| [-p.priority, p.trust_weight] }
|
|
168
|
+
|
|
169
|
+
[auth, profile, fallback]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def resolve_auth(auth_providers, timeout:)
|
|
173
|
+
return [nil, nil, {}] if auth_providers.empty?
|
|
174
|
+
|
|
175
|
+
futures = auth_providers.map do |provider|
|
|
176
|
+
Concurrent::Promises.future { provider.resolve }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout
|
|
180
|
+
provider_results = {}
|
|
181
|
+
auth_providers.zip(futures).each do |provider, future|
|
|
182
|
+
remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
183
|
+
future.wait(remaining.positive? ? remaining : 0)
|
|
184
|
+
result = future.value(0) if future.resolved?
|
|
185
|
+
status = auth_future_status(future, result)
|
|
186
|
+
|
|
187
|
+
provider_results[provider.provider_name] = {
|
|
188
|
+
status: status,
|
|
189
|
+
trust: (status == :resolved ? provider.trust_level : nil),
|
|
190
|
+
resolved_at: (status == :resolved ? Time.now : nil),
|
|
191
|
+
provider: provider,
|
|
192
|
+
result: (status == :resolved ? result : nil)
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
resolved_entries = provider_results.select { |_, v| v[:status] == :resolved }
|
|
197
|
+
if resolved_entries.empty?
|
|
198
|
+
[nil, nil, provider_results]
|
|
199
|
+
else
|
|
200
|
+
winner_name = resolved_entries.min_by do |_, v|
|
|
201
|
+
p = v[:provider]
|
|
202
|
+
[-p.priority, p.trust_weight]
|
|
203
|
+
end&.first
|
|
204
|
+
|
|
205
|
+
winner_info = provider_results[winner_name]
|
|
206
|
+
[winner_info[:provider], winner_info[:result], provider_results]
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def auth_future_status(future, result)
|
|
211
|
+
if future.rejected?
|
|
212
|
+
:failed
|
|
213
|
+
elsif !future.resolved?
|
|
214
|
+
:timeout
|
|
215
|
+
elsif result.is_a?(Hash) && result[:canonical_name]
|
|
216
|
+
:resolved
|
|
217
|
+
else
|
|
218
|
+
:no_identity
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def resolve_profiles(profile_providers, canonical, timeout:)
|
|
223
|
+
return { groups: [], profile: {}, provider_results: {} } if profile_providers.empty?
|
|
224
|
+
|
|
225
|
+
futures = profile_providers.map do |provider|
|
|
226
|
+
Concurrent::Promises.future { resolve_profile_provider(provider, canonical) }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout
|
|
230
|
+
groups = []
|
|
231
|
+
profile = {}
|
|
232
|
+
pr = {}
|
|
233
|
+
|
|
234
|
+
profile_providers.zip(futures).each do |provider, future|
|
|
235
|
+
remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
236
|
+
future.wait(remaining.positive? ? remaining : 0)
|
|
237
|
+
result = future.resolved? ? future.value(0) : nil
|
|
238
|
+
|
|
239
|
+
if future.fulfilled? && result.is_a?(Hash)
|
|
240
|
+
groups.concat(Array(result[:groups])) if result[:groups]
|
|
241
|
+
profile.merge!(result[:profile]) if result[:profile].is_a?(Hash)
|
|
242
|
+
pr[provider.provider_name] = { status: :resolved, trust: provider.trust_level, resolved_at: Time.now }
|
|
243
|
+
else
|
|
244
|
+
pr[provider.provider_name] = { status: (future.rejected? ? :failed : :timeout), trust: nil, resolved_at: nil }
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
{ groups: groups.uniq, profile: profile, provider_results: pr }
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def resolve_profile_provider(provider, canonical)
|
|
252
|
+
if provider.respond_to?(:resolve_all)
|
|
253
|
+
provider.resolve_all(canonical_name: canonical)
|
|
254
|
+
else
|
|
255
|
+
provider.resolve(canonical_name: canonical)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def assemble_composite(provider_results, profile_data, winning_result:, trust_level:, source:)
|
|
260
|
+
aliases = build_aliases(provider_results)
|
|
261
|
+
providers_map = build_providers_map(provider_results, profile_data)
|
|
262
|
+
|
|
263
|
+
{
|
|
264
|
+
id: nil,
|
|
265
|
+
canonical_name: winning_result[:canonical_name],
|
|
266
|
+
kind: winning_result[:kind] || :human,
|
|
267
|
+
trust: trust_level,
|
|
268
|
+
source: source,
|
|
269
|
+
persistent: true,
|
|
270
|
+
aliases: aliases,
|
|
271
|
+
groups: profile_data[:groups],
|
|
272
|
+
profile: profile_data[:profile],
|
|
273
|
+
providers: providers_map,
|
|
274
|
+
metadata: {}
|
|
275
|
+
}
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def build_aliases(provider_results)
|
|
279
|
+
aliases = {}
|
|
280
|
+
provider_results.each do |name, info|
|
|
281
|
+
next unless info[:status] == :resolved && info[:result]
|
|
282
|
+
|
|
283
|
+
pi = info[:result][:provider_identity]
|
|
284
|
+
aliases[name] = [pi] if pi
|
|
285
|
+
end
|
|
286
|
+
aliases
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def build_providers_map(provider_results, profile_data)
|
|
290
|
+
providers_map = {}
|
|
291
|
+
provider_results.each do |name, info|
|
|
292
|
+
providers_map[name] = {
|
|
293
|
+
status: info[:status],
|
|
294
|
+
trust: info[:trust],
|
|
295
|
+
resolved_at: info[:resolved_at]
|
|
296
|
+
}
|
|
297
|
+
end
|
|
298
|
+
profile_data[:provider_results].each do |name, info|
|
|
299
|
+
providers_map[name] = info
|
|
300
|
+
end
|
|
301
|
+
providers_map
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def bind_and_persist(winning_provider, composite, trust_level)
|
|
305
|
+
Legion::Identity::Process.bind!(winning_provider, composite) if defined?(Legion::Identity::Process)
|
|
306
|
+
|
|
307
|
+
if defined?(Legion::Settings) && Legion::Settings.respond_to?(:loader) && Legion::Settings.loader.respond_to?(:settings)
|
|
308
|
+
Legion::Settings.loader.settings[:client] ||= {}
|
|
309
|
+
Legion::Settings.loader.settings[:client][:name] = Legion::Identity::Process.queue_prefix
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
persist_to_db(composite)
|
|
313
|
+
persist_identity_json(composite[:canonical_name], composite[:kind]) unless trust_level == :unverified
|
|
314
|
+
|
|
315
|
+
@composite.set(composite)
|
|
316
|
+
@resolved.make_true
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def persist_to_db(composite) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
320
|
+
return unless defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && Legion::Data.connected?
|
|
321
|
+
return unless defined?(Legion::Data::Connection) &&
|
|
322
|
+
Legion::Data::Connection.respond_to?(:adapter) &&
|
|
323
|
+
Legion::Data::Connection.adapter == :postgres
|
|
324
|
+
|
|
325
|
+
# upsert identity_providers
|
|
326
|
+
composite[:providers]&.each do |name, info|
|
|
327
|
+
Legion::Data.db[:identity_providers].insert_conflict(
|
|
328
|
+
target: :name,
|
|
329
|
+
update: { status: info[:status].to_s, trust_level: info[:trust]&.to_s, last_seen_at: Time.now }
|
|
330
|
+
).insert(name: name.to_s, status: info[:status].to_s, trust_level: info[:trust]&.to_s, last_seen_at: Time.now)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# upsert principals
|
|
334
|
+
Legion::Data.db[:principals].insert_conflict(
|
|
335
|
+
target: :canonical_name,
|
|
336
|
+
update: { kind: composite[:kind].to_s, updated_at: Time.now }
|
|
337
|
+
).insert(
|
|
338
|
+
canonical_name: composite[:canonical_name],
|
|
339
|
+
kind: composite[:kind].to_s,
|
|
340
|
+
created_at: Time.now,
|
|
341
|
+
updated_at: Time.now
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
principal_row = Legion::Data.db[:principals].where(canonical_name: composite[:canonical_name]).first
|
|
345
|
+
principal_id = principal_row[:id] if principal_row
|
|
346
|
+
|
|
347
|
+
# upsert identities per provider alias
|
|
348
|
+
composite[:aliases]&.each do |provider_name, identities|
|
|
349
|
+
Array(identities).each do |ident|
|
|
350
|
+
Legion::Data.db[:identities].insert_conflict(
|
|
351
|
+
target: %i[principal_id provider_name provider_identity],
|
|
352
|
+
update: { updated_at: Time.now }
|
|
353
|
+
).insert(
|
|
354
|
+
principal_id: principal_id,
|
|
355
|
+
provider_name: provider_name.to_s,
|
|
356
|
+
provider_identity: ident,
|
|
357
|
+
created_at: Time.now,
|
|
358
|
+
updated_at: Time.now
|
|
359
|
+
)
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# insert audit log
|
|
364
|
+
Legion::Data.db[:identity_audit_log].insert(
|
|
365
|
+
principal_id: principal_id,
|
|
366
|
+
event_type: 'identity.resolved',
|
|
367
|
+
provider_name: composite[:source].to_s,
|
|
368
|
+
trust_level: composite[:trust]&.to_s,
|
|
369
|
+
detail: Legion::JSON.dump(
|
|
370
|
+
{
|
|
371
|
+
source: composite[:source],
|
|
372
|
+
trust: composite[:trust],
|
|
373
|
+
node_id: composite[:node_id],
|
|
374
|
+
session_id: @session_id
|
|
375
|
+
}
|
|
376
|
+
),
|
|
377
|
+
node_id: composite[:node_id],
|
|
378
|
+
session_id: @session_id,
|
|
379
|
+
created_at: Time.now
|
|
380
|
+
)
|
|
381
|
+
rescue StandardError => e
|
|
382
|
+
log_warn("DB persistence failed: #{e.message}")
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def persist_identity_json(canonical_name, kind)
|
|
386
|
+
dir = File.expand_path('~/.legionio/settings')
|
|
387
|
+
FileUtils.mkdir_p(dir)
|
|
388
|
+
path = File.join(dir, 'identity.json')
|
|
389
|
+
payload = { canonical_name: canonical_name, kind: kind }
|
|
390
|
+
json = if defined?(Legion::JSON)
|
|
391
|
+
Legion::JSON.dump(payload)
|
|
392
|
+
else
|
|
393
|
+
require 'json'
|
|
394
|
+
::JSON.generate(payload)
|
|
395
|
+
end
|
|
396
|
+
File.write(path, json)
|
|
397
|
+
rescue StandardError => e
|
|
398
|
+
log_warn("identity.json write failed: #{e.message}")
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def handle_canonical_change(old_canonical, new_canonical, _composite)
|
|
402
|
+
if defined?(Legion::Settings) && Legion::Settings.respond_to?(:loader)
|
|
403
|
+
settings = Legion::Settings.loader.settings
|
|
404
|
+
settings[:client] ||= {}
|
|
405
|
+
settings[:client][:name] = new_canonical
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
return unless defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && Legion::Data.connected?
|
|
409
|
+
return unless defined?(Legion::Data::Connection) &&
|
|
410
|
+
Legion::Data::Connection.respond_to?(:adapter) &&
|
|
411
|
+
Legion::Data::Connection.adapter == :postgres
|
|
412
|
+
|
|
413
|
+
# Audit the canonical change
|
|
414
|
+
old_row = Legion::Data.db[:principals].where(canonical_name: old_canonical).first
|
|
415
|
+
Legion::Data.db[:identity_audit_log].insert(
|
|
416
|
+
principal_id: old_row&.dig(:id),
|
|
417
|
+
event_type: 'identity.canonical_changed',
|
|
418
|
+
provider_name: '',
|
|
419
|
+
detail: Legion::JSON.dump({ old: old_canonical, new: new_canonical }),
|
|
420
|
+
created_at: Time.now
|
|
421
|
+
)
|
|
422
|
+
rescue StandardError => e
|
|
423
|
+
log_warn("canonical change handling failed: #{e.message}")
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def log_warn(message)
|
|
427
|
+
if defined?(Legion::Logging) && Legion::Logging.respond_to?(:warn)
|
|
428
|
+
Legion::Logging.warn("[Identity::Resolver] #{message}")
|
|
429
|
+
else
|
|
430
|
+
$stderr.puts "[Identity::Resolver] #{message}" # rubocop:disable Style/StderrPuts
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Initialize atomics at module definition time
|
|
436
|
+
@providers = Concurrent::Array.new
|
|
437
|
+
@composite = Concurrent::AtomicReference.new(nil)
|
|
438
|
+
@resolved = Concurrent::AtomicBoolean.new(false)
|
|
439
|
+
@session_id = SecureRandom.uuid
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Identity
|
|
5
|
+
module Trust
|
|
6
|
+
LEVELS = %i[verified authenticated configured cached unverified].freeze
|
|
7
|
+
RANK = LEVELS.each_with_index.to_h.freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def levels
|
|
12
|
+
LEVELS
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def rank(level)
|
|
16
|
+
RANK[level]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def above?(level_a, level_b)
|
|
20
|
+
rank_a = RANK[level_a]
|
|
21
|
+
rank_b = RANK[level_b]
|
|
22
|
+
return false if rank_a.nil? || rank_b.nil?
|
|
23
|
+
|
|
24
|
+
rank_a < rank_b
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def at_least?(level, minimum)
|
|
28
|
+
rank_level = RANK[level]
|
|
29
|
+
rank_min = RANK[minimum]
|
|
30
|
+
return false if rank_level.nil? || rank_min.nil?
|
|
31
|
+
|
|
32
|
+
rank_level <= rank_min
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent/array'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Identity
|
|
7
|
+
class << self
|
|
8
|
+
attr_accessor :pending_registrations
|
|
9
|
+
end
|
|
10
|
+
self.pending_registrations = Concurrent::Array.new
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require_relative 'identity/trust'
|
|
15
|
+
require_relative 'identity/resolver'
|
data/lib/legion/service.rb
CHANGED
|
@@ -161,6 +161,8 @@ module Legion
|
|
|
161
161
|
setup_safety_metrics
|
|
162
162
|
setup_supervision if supervision
|
|
163
163
|
|
|
164
|
+
require_relative 'identity' if File.exist?(File.expand_path('identity.rb', __dir__))
|
|
165
|
+
|
|
164
166
|
if extensions
|
|
165
167
|
load_extensions
|
|
166
168
|
Legion::Readiness.mark_ready(:extensions)
|
|
@@ -168,8 +170,9 @@ module Legion
|
|
|
168
170
|
end
|
|
169
171
|
|
|
170
172
|
# Identity resolution — after extensions so lex-identity-* providers are loaded
|
|
171
|
-
|
|
172
|
-
|
|
173
|
+
db_available = defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && Legion::Data.connected?
|
|
174
|
+
setup_identity if transport || db_available
|
|
175
|
+
register_credential_providers if extensions && (transport || db_available)
|
|
173
176
|
|
|
174
177
|
register_core_tools
|
|
175
178
|
|
|
@@ -506,8 +509,11 @@ module Legion
|
|
|
506
509
|
require_relative 'identity/middleware'
|
|
507
510
|
|
|
508
511
|
# Resolve identity from available providers (Phase 4 adds real providers)
|
|
509
|
-
|
|
510
|
-
|
|
512
|
+
require_relative 'identity' unless defined?(Legion::Identity::Resolver)
|
|
513
|
+
|
|
514
|
+
Legion::Identity::Resolver.resolve!
|
|
515
|
+
|
|
516
|
+
unless Legion::Identity::Resolver.resolved?
|
|
511
517
|
Legion::Identity::Process.bind_fallback!
|
|
512
518
|
log.info "[Identity] fallback identity: #{Legion::Identity::Process.canonical_name}"
|
|
513
519
|
end
|
|
@@ -897,15 +903,19 @@ module Legion
|
|
|
897
903
|
Legion::Readiness.mark_skipped(:gaia)
|
|
898
904
|
end
|
|
899
905
|
|
|
900
|
-
# Phase 5: re-run identity resolution + credential swap so the reloaded
|
|
901
|
-
# process gets identity-scoped RMQ creds (not stale bootstrap creds).
|
|
902
|
-
setup_identity
|
|
903
|
-
|
|
904
906
|
setup_supervision
|
|
905
907
|
load_extensions
|
|
906
908
|
Legion::Readiness.mark_ready(:extensions)
|
|
907
909
|
|
|
908
|
-
|
|
910
|
+
# Phase 5: re-run identity resolution after extensions are loaded so that
|
|
911
|
+
# any identity providers registered by lex-identity-* extensions are
|
|
912
|
+
# available to the resolver (mirrors the boot-time ordering).
|
|
913
|
+
Legion::Identity::Resolver.reset! if defined?(Legion::Identity::Resolver)
|
|
914
|
+
setup_identity
|
|
915
|
+
|
|
916
|
+
db_available = defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && Legion::Data.connected?
|
|
917
|
+
transport_available = defined?(Legion::Transport::Connection) && Legion::Transport::Connection.respond_to?(:session_open?) && Legion::Transport::Connection.session_open?
|
|
918
|
+
register_credential_providers if transport_available || db_available
|
|
909
919
|
Legion::Extensions.flush_pending_registrations! if defined?(Legion::Extensions) && Legion::Extensions.respond_to?(:flush_pending_registrations!)
|
|
910
920
|
|
|
911
921
|
register_core_tools
|
|
@@ -1088,64 +1098,6 @@ module Legion
|
|
|
1088
1098
|
Legion::Crypt.fetch_bootstrap_rmq_creds
|
|
1089
1099
|
end
|
|
1090
1100
|
|
|
1091
|
-
def resolve_identity_providers
|
|
1092
|
-
# Phase 4 adds lex-identity-* providers. For now, check if any are loaded.
|
|
1093
|
-
return false unless defined?(Legion::Extensions)
|
|
1094
|
-
|
|
1095
|
-
providers = find_identity_providers
|
|
1096
|
-
return false if providers.empty?
|
|
1097
|
-
|
|
1098
|
-
# Parallel resolution with 5s per-provider timeout (NO Timeout.timeout — uses future.value)
|
|
1099
|
-
pool = Concurrent::FixedThreadPool.new([providers.size, 4].min)
|
|
1100
|
-
futures = providers.map do |provider|
|
|
1101
|
-
Concurrent::Promises.future_on(pool, provider, &:resolve)
|
|
1102
|
-
end
|
|
1103
|
-
|
|
1104
|
-
winner_pair = providers.zip(futures).find do |_provider, future|
|
|
1105
|
-
result = begin
|
|
1106
|
-
future.value(5) # 5s timeout per provider
|
|
1107
|
-
rescue StandardError => e
|
|
1108
|
-
handle_exception(e, level: :debug, operation: 'service.resolve_identity_providers.future')
|
|
1109
|
-
nil
|
|
1110
|
-
end
|
|
1111
|
-
result.is_a?(Hash) && result[:canonical_name]
|
|
1112
|
-
end
|
|
1113
|
-
|
|
1114
|
-
if winner_pair
|
|
1115
|
-
provider, future = winner_pair
|
|
1116
|
-
identity = future.value
|
|
1117
|
-
Legion::Identity::Process.bind!(provider, identity)
|
|
1118
|
-
log.info "[Identity] resolved via #{provider.class.name}: #{identity[:canonical_name]}"
|
|
1119
|
-
|
|
1120
|
-
# Phase 8: Register winning auth provider with Broker so extensions can
|
|
1121
|
-
# call Broker.token_for(:provider_name) without managing tokens themselves.
|
|
1122
|
-
register_provider_with_broker(provider)
|
|
1123
|
-
|
|
1124
|
-
true
|
|
1125
|
-
else
|
|
1126
|
-
false
|
|
1127
|
-
end
|
|
1128
|
-
rescue StandardError => e
|
|
1129
|
-
handle_exception(e, level: :warn, operation: 'service.resolve_identity_providers')
|
|
1130
|
-
false
|
|
1131
|
-
ensure
|
|
1132
|
-
pool&.shutdown
|
|
1133
|
-
pool&.kill unless pool&.wait_for_termination(2)
|
|
1134
|
-
end
|
|
1135
|
-
|
|
1136
|
-
def register_provider_with_broker(provider)
|
|
1137
|
-
return unless provider.respond_to?(:provide_token) && defined?(Legion::Identity::Broker)
|
|
1138
|
-
|
|
1139
|
-
lease = provider.provide_token
|
|
1140
|
-
return unless lease
|
|
1141
|
-
|
|
1142
|
-
provider_name = provider.respond_to?(:provider_name) ? provider.provider_name : provider.class.name.to_sym
|
|
1143
|
-
Legion::Identity::Broker.register_provider(provider_name, provider: provider, lease: lease)
|
|
1144
|
-
log.info "[Identity] registered provider #{provider_name} with Broker"
|
|
1145
|
-
rescue StandardError => e
|
|
1146
|
-
handle_exception(e, level: :warn, operation: 'service.register_provider_with_broker')
|
|
1147
|
-
end
|
|
1148
|
-
|
|
1149
1101
|
def register_credential_providers
|
|
1150
1102
|
return unless defined?(Legion::Identity::Broker) && defined?(Legion::Extensions)
|
|
1151
1103
|
|
|
@@ -1176,32 +1128,6 @@ module Legion
|
|
|
1176
1128
|
identity
|
|
1177
1129
|
end
|
|
1178
1130
|
|
|
1179
|
-
def find_identity_providers
|
|
1180
|
-
return [] unless defined?(Legion::Extensions)
|
|
1181
|
-
|
|
1182
|
-
collect_identity_providers(Legion::Extensions)
|
|
1183
|
-
end
|
|
1184
|
-
|
|
1185
|
-
def collect_identity_providers(namespace, visited = Set.new)
|
|
1186
|
-
return [] unless namespace.is_a?(Module)
|
|
1187
|
-
return [] if visited.include?(namespace.object_id)
|
|
1188
|
-
|
|
1189
|
-
visited.add(namespace.object_id)
|
|
1190
|
-
providers = []
|
|
1191
|
-
|
|
1192
|
-
namespace.constants(false).each do |const_name|
|
|
1193
|
-
mod = namespace.const_get(const_name, false)
|
|
1194
|
-
next unless mod.is_a?(Module)
|
|
1195
|
-
|
|
1196
|
-
providers << mod if mod.respond_to?(:resolve) && mod.respond_to?(:provider_name)
|
|
1197
|
-
providers.concat(collect_identity_providers(mod, visited))
|
|
1198
|
-
rescue StandardError
|
|
1199
|
-
next
|
|
1200
|
-
end
|
|
1201
|
-
|
|
1202
|
-
providers
|
|
1203
|
-
end
|
|
1204
|
-
|
|
1205
1131
|
def bootstrap_log_level(cli_level)
|
|
1206
1132
|
cli_level = nil if cli_level.respond_to?(:empty?) && cli_level.empty?
|
|
1207
1133
|
return cli_level if cli_level
|
data/lib/legion/version.rb
CHANGED
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.
|
|
4
|
+
version: 1.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -853,12 +853,16 @@ files:
|
|
|
853
853
|
- lib/legion/graph/exporter.rb
|
|
854
854
|
- lib/legion/guardrails.rb
|
|
855
855
|
- lib/legion/helpers/context.rb
|
|
856
|
+
- lib/legion/identity.rb
|
|
856
857
|
- lib/legion/identity/broker.rb
|
|
858
|
+
- lib/legion/identity/grant.rb
|
|
857
859
|
- lib/legion/identity/lease.rb
|
|
858
860
|
- lib/legion/identity/lease_renewer.rb
|
|
859
861
|
- lib/legion/identity/middleware.rb
|
|
860
862
|
- lib/legion/identity/process.rb
|
|
861
863
|
- lib/legion/identity/request.rb
|
|
864
|
+
- lib/legion/identity/resolver.rb
|
|
865
|
+
- lib/legion/identity/trust.rb
|
|
862
866
|
- lib/legion/ingress.rb
|
|
863
867
|
- lib/legion/isolation.rb
|
|
864
868
|
- lib/legion/leader.rb
|