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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5af5e876210c79ae9fcc876fad3b8086a605f3a841c450793b4759711c5b0101
4
- data.tar.gz: 43d1c51c4a52121654e1a1e426f5bbe9e6e1771a5a95a095eb12e87474632eec
3
+ metadata.gz: 962639f3a96de99418ba1cb7feb2ba06b488776df2c2c3a1ec7dfd0d353e6759
4
+ data.tar.gz: 87f2e206fab455fac86e36efdc497ff67d4614bb0699be00eb146c764ef43797
5
5
  SHA512:
6
- metadata.gz: af956fad95ae9de341f700fa1e03997bed9b889983dc69b96322ba72cb7871f584224b4d00112e17b2e580a9f37e1afff3fa96e9b5bb5e83a38cf487fad26b77
7
- data.tar.gz: 6545603e7df04444096ae7763fcfde3ee4b970603554388ab04be50a088bd36ac1f77c0fc5ce02f04eeaf5396bb5620d38fc6416f5125b900225a3f79ecc773a
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
@@ -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
- id: tc.respond_to?(:id) ? tc.id : nil,
322
- name: tc.respond_to?(:name) ? tc.name : tc.to_s,
323
- arguments: tc.respond_to?(:arguments) ? tc.arguments : {}
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
- content: full_text,
334
- model: pipeline_response.routing&.dig(:model),
335
- input_tokens: tokens.respond_to?(:input_tokens) ? tokens.input_tokens : nil,
336
- output_tokens: tokens.respond_to?(:output_tokens) ? tokens.output_tokens : nil
337
- })}\n\n"
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, 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'
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) do |http|
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
- tool_calls.each do |tc|
172
- tc = tc.transform_keys(&:to_sym) if tc.respond_to?(:transform_keys)
173
- tc_obj = build_tool_call_object(tc)
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
- @on_tool_call&.call(tc_obj)
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
- result_text = run_tool(tc)
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
- result_obj = build_tool_result_object(result_text)
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
- def build_tool_result_object(text)
199
- Struct.new(:content).new(content: text.to_s)
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
- env['legion.principal'] = if auth_claims
22
- build_request(auth_claims, auth_method)
23
- elsif @require_auth
24
- # Auth middleware already handled 401 for protected paths;
25
- # this is a safety net for any path that slipped through.
26
- nil
27
- else
28
- # No auth required (loopback bind, lite mode, etc.).
29
- # Set a system-level principal so audit trails always have an identity.
30
- system_principal
31
- end
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: claims[:sub] || claims[:worker_id] || claims[:owner_msid],
54
- name: claims[:name] || claims[:sub],
55
- kind: determine_kind(claims, method),
56
- groups: Array(claims[:roles] || claims[:groups]),
57
- source: method&.to_sym
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
- def initialize(principal_id:, canonical_name:, kind:, groups: [], source: nil, metadata: {}) # rubocop:disable Metrics/ParameterLists
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
@@ -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]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.7.29'
4
+ VERSION = '1.7.31'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.29
4
+ version: 1.7.31
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity