legionio 1.7.19 → 1.7.21
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 +32 -0
- data/CLAUDE.md +1 -1
- data/README.md +4 -4
- 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 +16 -6
- data/lib/legion/service.rb +215 -8
- 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: f1c44047319bdcf02eb061d73517101f695f380d976cb65ad9e2a0d2afb824b2
|
|
4
|
+
data.tar.gz: e756182d3fdfea35882961412f7d3093884d7477954c3b67d2037039ad310189
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1178b2efaadc7801e37a68fd556add983eede7acd3f01dd1c744b029c8e048dc7ac681db0b578fc545419ca8d4f447cc518d3f7d5c2b351323350c1ae9ea908c
|
|
7
|
+
data.tar.gz: c9f34008bef206a7108cbc4013213342c4bc25fa6396884fec1e471b3b6d46d7110027214a0829c05a0d59d59c348fad8726d089c03ffbb1b40ce5f7d9de10a7
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.7.21] - 2026-04-06
|
|
6
|
+
### Fixed
|
|
7
|
+
- Optional components (rbac, llm, apollo, gaia) no longer block readiness when not installed
|
|
8
|
+
- Split `Readiness::COMPONENTS` into `REQUIRED_COMPONENTS` and `OPTIONAL_COMPONENTS`
|
|
9
|
+
- Added `Readiness.mark_skipped` for components that are absent or disabled
|
|
10
|
+
- Reload path now correctly marks optional components as skipped when not loaded
|
|
11
|
+
|
|
12
|
+
## [1.7.20] - 2026-04-06
|
|
13
|
+
### Added
|
|
14
|
+
- `Legion::Mode` module with `LEGACY_MAP`, ENV/Settings fallback chain, `agent?`/`worker?`/`infra?`/`lite?` predicates
|
|
15
|
+
- `Legion.instance_id` — UUID computed at load time, ENV override via `LEGIONIO_INSTANCE_ID`
|
|
16
|
+
- `Legion::Identity::Process` singleton — process identity with `bind!`, `bind_fallback!`, `queue_prefix` per-mode, `AtomicReference` thread safety
|
|
17
|
+
- `Legion::Identity::Request` — per-request immutable identity with `from_env`, `from_auth_context`, `to_caller_hash`, `to_rbac_principal`
|
|
18
|
+
- `Legion::Identity::Lease` — credential lease value object with `expired?`, `stale?` (50% TTL), `ttl_seconds`, `valid?`
|
|
19
|
+
- `Legion::Identity::LeaseRenewer` — background thread per provider, 50% TTL renewal, cooperative shutdown (no `Thread#kill`)
|
|
20
|
+
- `Legion::Identity::Broker` — provider management with groups cache (60s TTL, single-flight CAS), `token_for`, `credentials_for`, `shutdown`
|
|
21
|
+
- `Legion::Identity::Middleware` — Rack middleware bridging `legion.auth` to `legion.principal` (`Identity::Request`)
|
|
22
|
+
- `setup_identity` boot step 9 — parallel provider resolution via `Concurrent::Promises`, fallback to `ENV['USER']`
|
|
23
|
+
- Extension publish suppression — defers `LexRegister.publish` until identity resolves, `flush_pending_registrations!`
|
|
24
|
+
- Identity provider auto-registration during phased extension load (`identity_provider?` duck-type check)
|
|
25
|
+
- `GET /api/identity/audit` route with principal and duration filtering
|
|
26
|
+
- `legion doctor` checks: `ApiBindCheck` (non-loopback without auth), `ModeCheck` (no explicit process.mode)
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- `Readiness.status` upgraded to `Concurrent::Hash` for thread safety; `:identity` added to `COMPONENTS`
|
|
30
|
+
- `READONLY_SECTIONS` extended with `:identity`, `:rbac`, `:api`
|
|
31
|
+
- Default API bind changed from `0.0.0.0` to `127.0.0.1`
|
|
32
|
+
- `ProcessRole` delegates `.current` to `Mode.current`; added `:agent` and `:infra` role entries
|
|
33
|
+
- `lite_mode?` delegates to `Mode.lite?`
|
|
34
|
+
- Reload path adds `Identity::Process.refresh_credentials` after transport reconnect
|
|
35
|
+
- Shutdown adds cooperative `Identity::Broker.shutdown` and JWKS background refresh stop
|
|
36
|
+
|
|
5
37
|
## [1.7.19] - 2026-04-06
|
|
6
38
|
|
|
7
39
|
### Added
|
data/CLAUDE.md
CHANGED
|
@@ -9,7 +9,7 @@ The primary gem for the LegionIO framework. An extensible async job engine for s
|
|
|
9
9
|
|
|
10
10
|
**GitHub**: https://github.com/LegionIO/LegionIO
|
|
11
11
|
**Gem**: `legionio`
|
|
12
|
-
**Version**: 1.7.
|
|
12
|
+
**Version**: 1.7.21
|
|
13
13
|
**License**: Apache-2.0
|
|
14
14
|
**Docker**: `legionio/legion`
|
|
15
15
|
**Ruby**: >= 3.4
|
data/README.md
CHANGED
|
@@ -8,13 +8,13 @@ Schedule tasks, chain services into dependency graphs, run them concurrently via
|
|
|
8
8
|
╭──────────────────────────────────────╮
|
|
9
9
|
│ L E G I O N I O │
|
|
10
10
|
│ │
|
|
11
|
-
│ 280+ extensions ·
|
|
11
|
+
│ 280+ extensions · 60 MCP tools │
|
|
12
12
|
│ AI chat CLI · REST API · HA │
|
|
13
13
|
│ cognitive architecture · Vault │
|
|
14
14
|
╰──────────────────────────────────────╯
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
**Ruby >= 3.4** | **v1.
|
|
17
|
+
**Ruby >= 3.4** | **v1.7.21** | **Apache-2.0** | [@Esity](https://github.com/Esity)
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
|
@@ -33,7 +33,7 @@ When A completes, B runs. B triggers C, D, and E in parallel. Conditions gate ex
|
|
|
33
33
|
But that's just the foundation. LegionIO is also:
|
|
34
34
|
|
|
35
35
|
- **An AI coding assistant** — interactive chat with tools, code review, commit messages, PR generation, and multi-agent workflows
|
|
36
|
-
- **An MCP server** —
|
|
36
|
+
- **An MCP server** — 60 tools that let any AI agent run tasks, manage extensions, and query your infrastructure
|
|
37
37
|
- **A cognitive computing platform** — 242 brain-modeled extensions across 18 cognitive domains
|
|
38
38
|
- **A digital worker platform** — AI-as-labor with governance, risk tiers, and cost tracking
|
|
39
39
|
|
|
@@ -359,7 +359,7 @@ legion mcp http # streamable HTTP on localhost:9393
|
|
|
359
359
|
legion mcp http --port 8080 --host 0.0.0.0
|
|
360
360
|
```
|
|
361
361
|
|
|
362
|
-
**
|
|
362
|
+
**60 tools** in the `legion.*` namespace:
|
|
363
363
|
|
|
364
364
|
| Category | Tools |
|
|
365
365
|
|----------|-------|
|
|
@@ -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
|