legionio 1.7.29 → 1.7.31
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 +26 -0
- data/lib/legion/api/llm.rb +80 -8
- data/lib/legion/api.rb +0 -1
- data/lib/legion/cli/admin_command.rb +12 -8
- data/lib/legion/cli/chat/daemon_chat.rb +26 -8
- data/lib/legion/cli/groups/admin_group.rb +14 -0
- data/lib/legion/identity/middleware.rb +65 -16
- data/lib/legion/identity/request.rb +10 -3
- data/lib/legion/service.rb +15 -2
- data/lib/legion/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 962639f3a96de99418ba1cb7feb2ba06b488776df2c2c3a1ec7dfd0d353e6759
|
|
4
|
+
data.tar.gz: 87f2e206fab455fac86e36efdc497ff67d4614bb0699be00eb146c764ef43797
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ef4a6788ec894fe2107c834c470fc469d866d36e445618bee17d6149738e2b31ce49c708619f0aaff12d140e799a92562e5383e9515f6d88e1ce15bbaeb9834c
|
|
7
|
+
data.tar.gz: 3a8a85d88ba670d683c196de7e60c32f37650e3ce420869a5e7b781f0741dcadce758f6c42b6cdb71b61c32ca68ac23d05d1571b00f2b974423da8779d975b82
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Legion Changelog
|
|
2
2
|
|
|
3
|
+
## [1.7.31] - 2026-04-08
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Phase 7 RBAC enrichment: `Identity::Request` gains `roles:` constructor kwarg, `#roles` reader, `#id` alias for `principal_id`, and `roles:` in `identity_hash`
|
|
7
|
+
- `Identity::Middleware#build_request` now separates `claims[:groups]` (group OIDs/names) from `claims[:roles]` (Entra app roles), fixing the pre-existing conflation via `||`
|
|
8
|
+
- Worker token principal_id now correctly uses `claims[:worker_id]` when present, preventing worker tokens owned by a human from sharing the human's RBAC identity
|
|
9
|
+
- `Identity::Middleware` enriches resolved roles via `Legion::Rbac::GroupRoleMapper` when legion-rbac is loaded and enabled (including audit mode)
|
|
10
|
+
- `Identity::Middleware` builds `env['legion.rbac_principal']` (a `Legion::Rbac::Principal`) after setting `env['legion.principal']`, bridging identity to RBAC
|
|
11
|
+
- Middleware mount order fix: `Legion::Rbac::Middleware` removed from class-level `use` in `api.rb`; both `Identity::Middleware` and `Rbac::Middleware` now registered in `service.rb#setup_api` in the correct order (Identity first, then RBAC)
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- `Legion::Identity::Request.from_auth_context` now reads `claims[:resolved_roles]` to populate `roles`
|
|
15
|
+
|
|
16
|
+
## [1.7.30] - 2026-04-08
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- SSE streaming inference now emits real-time `tool-call`, `tool-result`, `tool-error`, and `model-fallback` events via `executor.tool_event_handler` as tools execute (with wall-clock `startedAt`/`finishedAt`/`durationMs` timing)
|
|
20
|
+
- `event: done` payload extended with `conversation_id`, `stop_reason`, `cache_read_tokens`, and `cache_write_tokens` fields (nil values compacted out)
|
|
21
|
+
- Post-hoc `model-fallback` events emitted from `pipeline_response.warnings` for non-streaming tool paths
|
|
22
|
+
- `admin purge-topology` CLI command to remove stale v2.0 `legion.*` AMQP exchanges that have `lex.*` counterparts
|
|
23
|
+
- Parallel tool execution in `CLI::Chat::DaemonChat`: all tools in a response now run concurrently via `Thread.new`, preserving original order for message replay
|
|
24
|
+
- `build_tool_result_object` now carries `tool_call_id`/`id` so the Interlink frontend can match results to tool calls by ID rather than name (fixes parallel same-type tool matching)
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- SSE tool-call events now use camelCase keys (`toolCallId`, `toolName`, `args`) matching the Interlink wire protocol
|
|
28
|
+
|
|
3
29
|
## [1.7.29] - 2026-04-07
|
|
4
30
|
|
|
5
31
|
### Changed
|
data/lib/legion/api/llm.rb
CHANGED
|
@@ -306,6 +306,51 @@ module Legion
|
|
|
306
306
|
'X-Accel-Buffering' => 'no'
|
|
307
307
|
|
|
308
308
|
stream do |out|
|
|
309
|
+
# Wire up real-time tool-call / tool-result / tool-error / model-fallback SSE events.
|
|
310
|
+
# The executor fires tool_event_handler for each event as it happens,
|
|
311
|
+
# including accurate wall-clock startedAt/finishedAt/durationMs timing.
|
|
312
|
+
emitted_tool_call_ids = Set.new
|
|
313
|
+
executor.tool_event_handler = lambda do |event|
|
|
314
|
+
case event[:type]
|
|
315
|
+
when :tool_call
|
|
316
|
+
emitted_tool_call_ids << event[:tool_call_id] if event[:tool_call_id]
|
|
317
|
+
out << "event: tool-call\ndata: #{Legion::JSON.dump({
|
|
318
|
+
toolCallId: event[:tool_call_id],
|
|
319
|
+
toolName: event[:tool_name],
|
|
320
|
+
args: event[:arguments] || {},
|
|
321
|
+
startedAt: event[:started_at]&.iso8601(3),
|
|
322
|
+
timestamp: event[:started_at]&.iso8601(3) || Time.now.iso8601(3)
|
|
323
|
+
})}\n\n"
|
|
324
|
+
when :tool_result
|
|
325
|
+
out << "event: tool-result\ndata: #{Legion::JSON.dump({
|
|
326
|
+
toolCallId: event[:tool_call_id],
|
|
327
|
+
toolName: event[:tool_name],
|
|
328
|
+
result: event[:result],
|
|
329
|
+
startedAt: event[:started_at]&.iso8601(3),
|
|
330
|
+
finishedAt: event[:finished_at]&.iso8601(3) || Time.now.iso8601(3),
|
|
331
|
+
durationMs: event[:duration_ms],
|
|
332
|
+
timestamp: event[:finished_at]&.iso8601(3) || Time.now.iso8601(3)
|
|
333
|
+
})}\n\n"
|
|
334
|
+
when :tool_error
|
|
335
|
+
out << "event: tool-error\ndata: #{Legion::JSON.dump({
|
|
336
|
+
toolCallId: event[:tool_call_id],
|
|
337
|
+
toolName: event[:tool_name],
|
|
338
|
+
error: (event[:error] || event[:result]).to_s,
|
|
339
|
+
startedAt: event[:started_at]&.iso8601(3),
|
|
340
|
+
finishedAt: Time.now.iso8601(3),
|
|
341
|
+
timestamp: Time.now.iso8601(3)
|
|
342
|
+
})}\n\n"
|
|
343
|
+
when :model_fallback
|
|
344
|
+
out << "event: model-fallback\ndata: #{Legion::JSON.dump({
|
|
345
|
+
fromModel: event[:from_model],
|
|
346
|
+
toModel: event[:to_model],
|
|
347
|
+
toModelKey: event[:to_model],
|
|
348
|
+
error: event[:error] || 'Provider unavailable',
|
|
349
|
+
reason: event[:reason] || 'provider_fallback'
|
|
350
|
+
})}\n\n"
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
309
354
|
full_text = +''
|
|
310
355
|
pipeline_response = executor.call_stream do |chunk|
|
|
311
356
|
text = chunk.respond_to?(:content) ? chunk.content.to_s : chunk.to_s
|
|
@@ -315,26 +360,53 @@ module Legion
|
|
|
315
360
|
out << "event: text-delta\ndata: #{Legion::JSON.dump({ delta: text })}\n\n"
|
|
316
361
|
end
|
|
317
362
|
|
|
363
|
+
# Post-hoc safety net: emit any tool-calls that weren't fired in real-time
|
|
364
|
+
# (e.g. non-streaming tool paths). Skip IDs already sent via tool_event_handler.
|
|
318
365
|
if pipeline_response.tools.is_a?(Array) && !pipeline_response.tools.empty?
|
|
319
366
|
pipeline_response.tools.each do |tc|
|
|
367
|
+
tc_id = tc.respond_to?(:id) ? tc.id : nil
|
|
368
|
+
next if tc_id && emitted_tool_call_ids.include?(tc_id)
|
|
369
|
+
|
|
320
370
|
out << "event: tool-call\ndata: #{Legion::JSON.dump({
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
371
|
+
toolCallId: tc_id,
|
|
372
|
+
toolName: tc.respond_to?(:name) ? tc.name : tc.to_s,
|
|
373
|
+
args: tc.respond_to?(:arguments) ? tc.arguments : {}
|
|
324
374
|
})}\n\n"
|
|
325
375
|
end
|
|
326
376
|
end
|
|
327
377
|
|
|
378
|
+
# Emit any model-fallback warnings collected post-hoc
|
|
379
|
+
Array(pipeline_response.warnings).each do |w|
|
|
380
|
+
next unless w.is_a?(Hash) && w[:type] == :provider_fallback
|
|
381
|
+
|
|
382
|
+
fallback = w[:fallback].to_s
|
|
383
|
+
provider, model = fallback.split(':', 2)
|
|
384
|
+
resolved_model = (model || provider).to_s.strip
|
|
385
|
+
next if resolved_model.empty?
|
|
386
|
+
|
|
387
|
+
out << "event: model-fallback\ndata: #{Legion::JSON.dump({
|
|
388
|
+
fromModel: pipeline_response.routing&.dig(:model),
|
|
389
|
+
toModel: resolved_model,
|
|
390
|
+
toModelKey: resolved_model,
|
|
391
|
+
error: w[:original_error] || 'Provider unavailable',
|
|
392
|
+
reason: 'provider_fallback'
|
|
393
|
+
})}\n\n"
|
|
394
|
+
end
|
|
395
|
+
|
|
328
396
|
enrichments = pipeline_response.enrichments
|
|
329
397
|
out << "event: enrichment\ndata: #{Legion::JSON.dump(enrichments)}\n\n" if enrichments.is_a?(Hash) && !enrichments.empty?
|
|
330
398
|
|
|
331
399
|
tokens = pipeline_response.tokens
|
|
332
400
|
out << "event: done\ndata: #{Legion::JSON.dump({
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
401
|
+
content: full_text,
|
|
402
|
+
model: pipeline_response.routing&.dig(:model),
|
|
403
|
+
conversation_id: pipeline_response.conversation_id,
|
|
404
|
+
stop_reason: pipeline_response.stop&.dig(:reason)&.to_s,
|
|
405
|
+
input_tokens: tokens.respond_to?(:input_tokens) ? tokens.input_tokens : nil,
|
|
406
|
+
output_tokens: tokens.respond_to?(:output_tokens) ? tokens.output_tokens : nil,
|
|
407
|
+
cache_read_tokens: tokens.respond_to?(:cache_read_tokens) ? tokens.cache_read_tokens : nil,
|
|
408
|
+
cache_write_tokens: tokens.respond_to?(:cache_write_tokens) ? tokens.cache_write_tokens : nil
|
|
409
|
+
}.compact)}\n\n"
|
|
338
410
|
rescue StandardError => e
|
|
339
411
|
Legion::Logging.log_exception(e, payload_summary: 'api/llm/inference stream failed', component_type: :api)
|
|
340
412
|
out << "event: error\ndata: #{Legion::JSON.dump({ code: 'stream_error', message: e.message })}\n\n"
|
data/lib/legion/api.rb
CHANGED
|
@@ -221,7 +221,6 @@ module Legion
|
|
|
221
221
|
register Routes::GraphQL if defined?(Routes::GraphQL)
|
|
222
222
|
|
|
223
223
|
use Legion::API::Middleware::RequestLogger
|
|
224
|
-
use Legion::Rbac::Middleware if defined?(Legion::Rbac::Middleware)
|
|
225
224
|
use ElasticAPM::Middleware if defined?(ElasticAPM::Middleware) &&
|
|
226
225
|
Legion::Settings.dig(:api, :elastic_apm, :enabled)
|
|
227
226
|
end
|
|
@@ -9,13 +9,15 @@ module Legion
|
|
|
9
9
|
namespace :admin
|
|
10
10
|
|
|
11
11
|
desc 'purge-topology', 'Remove old v2.0 AMQP exchanges (legion.* that have lex.* counterparts)'
|
|
12
|
-
method_option :dry_run,
|
|
13
|
-
method_option :execute,
|
|
14
|
-
method_option :host,
|
|
15
|
-
method_option :port,
|
|
16
|
-
method_option :user,
|
|
17
|
-
method_option :password,
|
|
18
|
-
method_option :vhost,
|
|
12
|
+
method_option :dry_run, type: :boolean, default: true, desc: 'List without deleting'
|
|
13
|
+
method_option :execute, type: :boolean, default: false, desc: 'Actually delete exchanges'
|
|
14
|
+
method_option :host, type: :string, default: 'localhost', desc: 'RabbitMQ management host'
|
|
15
|
+
method_option :port, type: :numeric, default: 15_672, desc: 'RabbitMQ management port'
|
|
16
|
+
method_option :user, type: :string, default: 'guest', desc: 'RabbitMQ management user'
|
|
17
|
+
method_option :password, type: :string, default: 'guest', desc: 'RabbitMQ management password'
|
|
18
|
+
method_option :vhost, type: :string, default: '/', desc: 'RabbitMQ vhost'
|
|
19
|
+
method_option :open_timeout, type: :numeric, default: 5, desc: 'HTTP open timeout in seconds'
|
|
20
|
+
method_option :read_timeout, type: :numeric, default: 30, desc: 'HTTP read timeout in seconds'
|
|
19
21
|
def purge_topology
|
|
20
22
|
exchanges = fetch_exchanges
|
|
21
23
|
candidates = self.class.detect_old_exchanges(exchanges)
|
|
@@ -76,7 +78,9 @@ module Legion
|
|
|
76
78
|
end
|
|
77
79
|
|
|
78
80
|
def management_request(uri, method_class)
|
|
79
|
-
Net::HTTP.start(uri.host, uri.port
|
|
81
|
+
Net::HTTP.start(uri.host, uri.port,
|
|
82
|
+
open_timeout: options[:open_timeout],
|
|
83
|
+
read_timeout: options[:read_timeout]) do |http|
|
|
80
84
|
req = method_class.new(uri)
|
|
81
85
|
req.basic_auth(options[:user], options[:password])
|
|
82
86
|
http.request(req)
|
|
@@ -32,6 +32,10 @@ module Legion
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# Single shared struct class for tool result objects; avoids allocating
|
|
36
|
+
# an anonymous Struct class on every build_tool_result_object call.
|
|
37
|
+
ToolResult = Struct.new(:content, :tool_call_id, :id)
|
|
38
|
+
|
|
35
39
|
attr_reader :model, :conversation_id, :caller_context
|
|
36
40
|
|
|
37
41
|
def initialize(model: nil, provider: nil)
|
|
@@ -168,15 +172,24 @@ module Legion
|
|
|
168
172
|
# Record the assistant turn with tool_calls before appending results.
|
|
169
173
|
@messages << { role: 'assistant', content: assistant_content, tool_calls: tool_calls }
|
|
170
174
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
175
|
+
# Normalize all tool calls upfront so threads don't mutate shared state
|
|
176
|
+
normalized = tool_calls.map do |tc|
|
|
177
|
+
tc.respond_to?(:transform_keys) ? tc.transform_keys(&:to_sym) : tc
|
|
178
|
+
end
|
|
174
179
|
|
|
175
|
-
|
|
180
|
+
# Fire on_tool_call callbacks immediately (serial — fast, just event emission)
|
|
181
|
+
normalized.each do |tc|
|
|
182
|
+
@on_tool_call&.call(build_tool_call_object(tc))
|
|
183
|
+
end
|
|
176
184
|
|
|
177
|
-
|
|
185
|
+
# Execute all tools in parallel, preserving original order for message replay
|
|
186
|
+
results = normalized.map do |tc|
|
|
187
|
+
Thread.new { [tc, run_tool(tc)] }
|
|
188
|
+
end.map(&:value)
|
|
178
189
|
|
|
179
|
-
|
|
190
|
+
# Collect results serially: fire callbacks and append messages in order
|
|
191
|
+
results.each do |tc, result_text|
|
|
192
|
+
result_obj = build_tool_result_object(result_text, tc[:id] || tc[:tool_call_id])
|
|
180
193
|
@on_tool_result&.call(result_obj)
|
|
181
194
|
|
|
182
195
|
@messages << {
|
|
@@ -195,8 +208,13 @@ module Legion
|
|
|
195
208
|
)
|
|
196
209
|
end
|
|
197
210
|
|
|
198
|
-
|
|
199
|
-
|
|
211
|
+
# Carries both the result content AND the originating tool_call_id so the
|
|
212
|
+
# daemon-bridge-script serializer can include it in the tool-result event,
|
|
213
|
+
# allowing the Interlink frontend to match results back to the correct
|
|
214
|
+
# tool call by ID (rather than falling back to name-based matching which
|
|
215
|
+
# breaks when multiple tools of the same type run in parallel).
|
|
216
|
+
def build_tool_result_object(text, tool_call_id = nil)
|
|
217
|
+
ToolResult.new(text.to_s, tool_call_id, tool_call_id)
|
|
200
218
|
end
|
|
201
219
|
|
|
202
220
|
def run_tool(tool_call)
|
|
@@ -23,6 +23,20 @@ module Legion
|
|
|
23
23
|
|
|
24
24
|
desc 'team SUBCOMMAND', 'Team and multi-user management'
|
|
25
25
|
subcommand 'team', Legion::CLI::Team
|
|
26
|
+
|
|
27
|
+
desc 'purge-topology', 'Remove old v2.0 AMQP exchanges (legion.* that have lex.* counterparts)'
|
|
28
|
+
method_option :dry_run, type: :boolean, default: true, desc: 'List without deleting'
|
|
29
|
+
method_option :execute, type: :boolean, default: false, desc: 'Actually delete exchanges'
|
|
30
|
+
method_option :host, type: :string, default: 'localhost', desc: 'RabbitMQ management host'
|
|
31
|
+
method_option :port, type: :numeric, default: 15_672, desc: 'RabbitMQ management port'
|
|
32
|
+
method_option :user, type: :string, default: 'guest', desc: 'RabbitMQ management user'
|
|
33
|
+
method_option :password, type: :string, default: 'guest', desc: 'RabbitMQ management password'
|
|
34
|
+
method_option :vhost, type: :string, default: '/', desc: 'RabbitMQ vhost'
|
|
35
|
+
method_option :open_timeout, type: :numeric, default: 5, desc: 'HTTP open timeout in seconds'
|
|
36
|
+
method_option :read_timeout, type: :numeric, default: 30, desc: 'HTTP read timeout in seconds'
|
|
37
|
+
def purge_topology
|
|
38
|
+
Legion::CLI::AdminCommand.new([], options).purge_topology
|
|
39
|
+
end
|
|
26
40
|
end
|
|
27
41
|
end
|
|
28
42
|
end
|
|
@@ -18,17 +18,39 @@ module Legion
|
|
|
18
18
|
auth_claims = env['legion.auth']
|
|
19
19
|
auth_method = env['legion.auth_method']
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
21
|
+
request = 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
|
+
env['legion.principal'] = request
|
|
34
|
+
|
|
35
|
+
# Bridge to RBAC principal if legion-rbac is loaded.
|
|
36
|
+
# This is a data bridge — set regardless of enforce/audit mode so
|
|
37
|
+
# the RBAC middleware always has a typed principal to evaluate.
|
|
38
|
+
# Guard: require Legion::Rbac.enabled? to confirm the real gem is loaded
|
|
39
|
+
# (not a minimal test stub), and rescue construction errors defensively.
|
|
40
|
+
if request && defined?(Legion::Rbac::Principal) &&
|
|
41
|
+
defined?(Legion::Rbac) && Legion::Rbac.respond_to?(:enabled?) &&
|
|
42
|
+
Legion::Rbac.enabled?
|
|
43
|
+
begin
|
|
44
|
+
env['legion.rbac_principal'] = Legion::Rbac::Principal.new(
|
|
45
|
+
id: request.principal_id,
|
|
46
|
+
type: request.kind == :service ? :worker : request.kind,
|
|
47
|
+
roles: request.roles,
|
|
48
|
+
team: request.metadata&.dig(:team)
|
|
49
|
+
)
|
|
50
|
+
rescue StandardError
|
|
51
|
+
# Best-effort bridge: leave legion.rbac_principal unset on construction errors.
|
|
52
|
+
end
|
|
53
|
+
end
|
|
32
54
|
|
|
33
55
|
@app.call(env)
|
|
34
56
|
end
|
|
@@ -49,12 +71,39 @@ module Legion
|
|
|
49
71
|
end
|
|
50
72
|
|
|
51
73
|
def build_request(claims, method)
|
|
74
|
+
# Use worker_id as principal_id when present — worker tokens encode both
|
|
75
|
+
# worker_id and sub=owner_msid, and we want the worker's identity, not the owner's.
|
|
76
|
+
principal_id = claims[:worker_id] || claims[:sub] || claims[:owner_msid]
|
|
77
|
+
|
|
78
|
+
# For worker tokens (scope: 'worker' or worker_id present), derive canonical_name
|
|
79
|
+
# from the worker's own identity. Production worker JWTs omit :name and carry
|
|
80
|
+
# sub=owner_msid, so falling back to claims[:sub] would inherit the owner's identity.
|
|
81
|
+
worker_token = claims[:scope] == 'worker' || claims[:worker_id]
|
|
82
|
+
display_name = claims[:name] || (worker_token ? principal_id : claims[:sub])
|
|
83
|
+
|
|
84
|
+
# Separate group OIDs/names from Entra app roles — they are NOT equivalent.
|
|
85
|
+
# claims[:groups] = group OIDs/names (for GroupRoleMapper)
|
|
86
|
+
# claims[:roles] = Entra app roles (pre-assigned at token-exchange time)
|
|
87
|
+
groups = Array(claims[:groups])
|
|
88
|
+
roles = Array(claims[:roles])
|
|
89
|
+
|
|
90
|
+
# Enrich with group-derived RBAC roles when legion-rbac is loaded (including audit mode).
|
|
91
|
+
resolved_roles = if defined?(Legion::Rbac::GroupRoleMapper) &&
|
|
92
|
+
Legion::Rbac.respond_to?(:enabled?) &&
|
|
93
|
+
Legion::Rbac.enabled?
|
|
94
|
+
group_roles = Legion::Rbac::GroupRoleMapper.resolve_roles(groups: groups)
|
|
95
|
+
(roles + group_roles).uniq
|
|
96
|
+
else
|
|
97
|
+
roles
|
|
98
|
+
end
|
|
99
|
+
|
|
52
100
|
Identity::Request.from_auth_context({
|
|
53
|
-
sub:
|
|
54
|
-
name:
|
|
55
|
-
kind:
|
|
56
|
-
groups:
|
|
57
|
-
|
|
101
|
+
sub: principal_id,
|
|
102
|
+
name: display_name,
|
|
103
|
+
kind: determine_kind(claims, method),
|
|
104
|
+
groups: groups,
|
|
105
|
+
resolved_roles: resolved_roles,
|
|
106
|
+
source: method&.to_sym
|
|
58
107
|
})
|
|
59
108
|
end
|
|
60
109
|
|
|
@@ -16,13 +16,16 @@ module Legion
|
|
|
16
16
|
system: :system
|
|
17
17
|
}.freeze
|
|
18
18
|
|
|
19
|
-
attr_reader :principal_id, :canonical_name, :kind, :groups, :source, :metadata
|
|
19
|
+
attr_reader :principal_id, :canonical_name, :kind, :groups, :roles, :source, :metadata
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
alias id principal_id
|
|
22
|
+
|
|
23
|
+
def initialize(principal_id:, canonical_name:, kind:, groups: [], roles: [], source: nil, metadata: {}) # rubocop:disable Metrics/ParameterLists
|
|
22
24
|
@principal_id = principal_id
|
|
23
25
|
@canonical_name = canonical_name
|
|
24
26
|
@kind = kind
|
|
25
27
|
@groups = groups.freeze
|
|
28
|
+
@roles = roles.freeze
|
|
26
29
|
@source = SOURCE_NORMALIZATION.fetch(source&.to_sym, source)
|
|
27
30
|
@metadata = metadata.freeze
|
|
28
31
|
freeze
|
|
@@ -35,7 +38,9 @@ module Legion
|
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
# Builds a Request from a parsed auth claims hash with symbol keys:
|
|
38
|
-
# { sub:, name:, preferred_username:, kind:, groups:, source: }
|
|
41
|
+
# { sub:, name:, preferred_username:, kind:, groups:, resolved_roles:, source: }
|
|
42
|
+
# resolved_roles is the final merged set of Entra app roles + group-derived RBAC
|
|
43
|
+
# roles (populated by Identity::Middleware before calling this method).
|
|
39
44
|
# The source value is normalized via SOURCE_NORMALIZATION at construction time.
|
|
40
45
|
def self.from_auth_context(claims_hash)
|
|
41
46
|
raw_name = claims_hash[:name] || claims_hash[:preferred_username] || ''
|
|
@@ -48,6 +53,7 @@ module Legion
|
|
|
48
53
|
canonical_name: canonical,
|
|
49
54
|
kind: claims_hash[:kind] || :human,
|
|
50
55
|
groups: claims_hash[:groups] || [],
|
|
56
|
+
roles: Array(claims_hash[:resolved_roles]),
|
|
51
57
|
source: normalized_source
|
|
52
58
|
)
|
|
53
59
|
end
|
|
@@ -58,6 +64,7 @@ module Legion
|
|
|
58
64
|
canonical_name: canonical_name,
|
|
59
65
|
kind: kind,
|
|
60
66
|
groups: groups,
|
|
67
|
+
roles: roles,
|
|
61
68
|
source: source
|
|
62
69
|
}
|
|
63
70
|
end
|
data/lib/legion/service.rb
CHANGED
|
@@ -356,7 +356,7 @@ module Legion
|
|
|
356
356
|
handle_exception(e, level: :warn, operation: 'service.shutdown_apm')
|
|
357
357
|
end
|
|
358
358
|
|
|
359
|
-
def setup_api # rubocop:disable Metrics/MethodLength
|
|
359
|
+
def setup_api # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
360
360
|
if @api_thread&.alive?
|
|
361
361
|
log.warn 'API already running, skipping duplicate setup_api call'
|
|
362
362
|
return
|
|
@@ -394,12 +394,25 @@ module Legion
|
|
|
394
394
|
log.info "Starting Legion API on #{bind}:#{port}"
|
|
395
395
|
end
|
|
396
396
|
|
|
397
|
-
# Mount identity middleware — bridges legion.auth to legion.principal
|
|
397
|
+
# Mount identity middleware — bridges legion.auth to legion.principal.
|
|
398
|
+
# Identity MUST be mounted before RBAC so env['legion.rbac_principal'] is
|
|
399
|
+
# populated before the RBAC middleware reads it.
|
|
398
400
|
if defined?(Legion::Identity::Middleware)
|
|
399
401
|
require_auth = Legion::Identity::Middleware.require_auth?(bind: bind, mode: Legion::Mode.current)
|
|
400
402
|
Legion::API.use Legion::Identity::Middleware, require_auth: require_auth
|
|
401
403
|
end
|
|
402
404
|
|
|
405
|
+
# Mount RBAC middleware after Identity — reads env['legion.rbac_principal']
|
|
406
|
+
# set by Identity::Middleware above. Only mount when a compatible RBAC
|
|
407
|
+
# integration is present and enabled to avoid mixed-version request
|
|
408
|
+
# failures.
|
|
409
|
+
if defined?(Legion::Rbac::Middleware) &&
|
|
410
|
+
defined?(Legion::Rbac::Principal) &&
|
|
411
|
+
Legion::Rbac.respond_to?(:enabled?) &&
|
|
412
|
+
Legion::Rbac.enabled?
|
|
413
|
+
Legion::API.use Legion::Rbac::Middleware
|
|
414
|
+
end
|
|
415
|
+
|
|
403
416
|
@api_thread = Thread.new do
|
|
404
417
|
retries = 0
|
|
405
418
|
max_retries = api_settings[:bind_retries]
|
data/lib/legion/version.rb
CHANGED