legionio 1.7.24 → 1.7.26

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: 8e469923d32f8ead43a892e473c93737139885622f40683a63aa0a757a0c282d
4
- data.tar.gz: 2e88c8468007f617c075178410da534337931e37a3c50fb11a67fa3801807689
3
+ metadata.gz: 010545b8c47b9d866f1f5da8597e82f2e94bcd7c2a065f8aa97a9383f34325df
4
+ data.tar.gz: 7e75584a9fdf08247ec2e8834584a25499f1150814d6888701adea1689a85c37
5
5
  SHA512:
6
- metadata.gz: 4d7cd5a0513e26ca92bf5b909ac924a7041bbe2b74602d6a552357d155f9101c0d81b75c6d4db0c06b9748b08fd174363ad7d26ffa021f82001923d428ecd5ec
7
- data.tar.gz: 252dc15217cf3d320fa9f96703967d9c9320f2d3e26d18d91c992a8819dbf34ced50eca926ddcba0a3c2be1a74619d3f6106d20ca9f09919ba6c32901b8d5197
6
+ metadata.gz: 582fd272567d6bcca36b3d9fa8eff39b123f8c664f557a3a26d832bde3756e9c5d32d94693533d31d6231ff9d3f77201d067c3b8e26c9fd416888245993c0dc7
7
+ data.tar.gz: fc1f7f556c6f7cb5c4a24b8733fc23032a3cc54561ccb0d40fba8189190a424e58c84412824d5e195c870b05015ecc93f398fd74948f8fce837f842a63641e41
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.7.26] - 2026-04-07
4
+
5
+ ### Added
6
+ - Phase 5 Credential Scoping — service.rb integration (§8 of `docs/plans/2026-04-07-credential-scoping-design.md`)
7
+ - Boot: call `Legion::Crypt.fetch_bootstrap_rmq_creds` after `Crypt.start` to acquire short-lived bootstrap RMQ credentials from Vault before transport connects (no-op when `dynamic_rmq_creds: false`)
8
+ - `setup_identity`: after identity resolves, call `Legion::Crypt.swap_to_identity_creds(mode:)` to swap from bootstrap to identity-scoped RMQ credentials — gated on `vault_connected? && dynamic_rmq_creds? && !lite?`; fallback identity still gets scoped creds
9
+ - `shutdown`: call `Legion::Crypt.revoke_bootstrap_lease` before Crypt shutdown for defense-in-depth lease cleanup
10
+ - `reload`: call `fetch_bootstrap_rmq_creds` after Crypt.start, `resolve_secrets!` after settings reload, and `setup_identity` (replacing static `mark_ready(:identity)`) so reloaded processes acquire identity-scoped credentials
11
+ - Specs for all Phase 5 service.rb integration paths: boot credential fetch, identity swap per mode, vault/flag/lite guards, swap failure recovery, shutdown revocation, reload credential flow
12
+
13
+ ## [1.7.25] - 2026-04-06
14
+
15
+ ### Added
16
+ - Wire Format Phase 3 Group 2: `Identity::Request::SOURCE_NORMALIZATION` constant — maps middleware-emitted source values (`:api_key`, `:local`, `:jwt`, `:kerberos`, `:system`) to canonical credential enum at `from_auth_context` construction time
17
+ - Wire Format Phase 3 Group 2: `response_meta` in `API::Helpers` now includes `caller` block (`canonical_name`, `kind`, `source`) when the request is authenticated and `env['legion.principal']` is set by `Identity::Middleware`
18
+ - Wire Format Phase 3 Group 2: `POST /api/llm/inference` wires `to_caller_hash` from the authenticated principal into the pipeline `caller:` field, replacing the hardcoded `{ type: :user, credential: :api }` fallback
19
+
3
20
  ## [1.7.24] - 2026-04-06
4
21
 
5
22
  ### Fixed
@@ -13,7 +13,8 @@ module Legion
13
13
  puma: puma_defaults,
14
14
  bind_retries: 3,
15
15
  bind_retry_wait: 2,
16
- tls: tls_defaults
16
+ tls: tls_defaults,
17
+ elastic_apm: elastic_apm_defaults
17
18
  }
18
19
  end
19
20
 
@@ -31,6 +32,33 @@ module Legion
31
32
  enabled: false
32
33
  }
33
34
  end
35
+
36
+ def self.elastic_apm_defaults
37
+ {
38
+ enabled: false,
39
+ server_url: 'http://localhost:8200',
40
+ api_key: nil,
41
+ secret_token: nil,
42
+ api_buffer_size: 256,
43
+ api_request_size: '750kb',
44
+ api_request_time: '10s',
45
+ capture_body: 'off',
46
+ capture_headers: true,
47
+ capture_env: true,
48
+ disable_send: false,
49
+ environment: nil,
50
+ hostname: nil,
51
+ ignore_url_patterns: %w[/api/health /api/ready],
52
+ pool_size: 1,
53
+ service_name: 'LegionIO',
54
+ service_node_name: nil,
55
+ service_version: nil,
56
+ sample_rate: 1.0,
57
+ verify_server_cert: true,
58
+ central_config: true,
59
+ span_frames_min_duration: '5ms'
60
+ }
61
+ end
34
62
  end
35
63
  end
36
64
  end
@@ -173,10 +173,23 @@ module Legion
173
173
  private
174
174
 
175
175
  def response_meta
176
- {
176
+ meta = {
177
177
  timestamp: Time.now.utc.iso8601,
178
178
  node: Legion::Settings[:client][:name]
179
179
  }
180
+
181
+ if authenticated? && defined?(Legion::Identity::Request)
182
+ req = env['legion.principal']
183
+ if req
184
+ meta[:caller] = {
185
+ canonical_name: req.canonical_name,
186
+ kind: req.kind,
187
+ source: req.source
188
+ }
189
+ end
190
+ end
191
+
192
+ meta
180
193
  end
181
194
 
182
195
  def page_limit
@@ -280,12 +280,19 @@ module Legion
280
280
  require 'legion/llm/pipeline/request' unless defined?(Legion::LLM::Pipeline::Request)
281
281
  require 'legion/llm/pipeline/executor' unless defined?(Legion::LLM::Pipeline::Executor)
282
282
 
283
+ principal = defined?(Legion::Identity::Request) && env['legion.principal']
284
+ caller_ctx = if principal
285
+ principal.to_caller_hash
286
+ else
287
+ { requested_by: { identity: caller_identity, type: :user, credential: :api } }
288
+ end
289
+
283
290
  req = Legion::LLM::Pipeline::Request.build(
284
291
  messages: messages,
285
292
  system: body[:system],
286
293
  routing: { provider: provider, model: model },
287
294
  tools: tool_classes,
288
- caller: { requested_by: { identity: caller_identity, type: :user, credential: :api } },
295
+ caller: caller_ctx,
289
296
  conversation_id: body[:conversation_id],
290
297
  metadata: { requested_tools: requested_tools },
291
298
  stream: streaming,
@@ -49,6 +49,25 @@ module Legion
49
49
  parts << "query=#{query}" if query
50
50
  parts.join(' ')
51
51
  end
52
+
53
+ def peek_body(env)
54
+ input = env['rack.input']
55
+ return '-' unless input.respond_to?(:read) && input.respond_to?(:rewind)
56
+
57
+ begin
58
+ input.rewind
59
+ raw = input.read(1024)
60
+ raw.to_s.gsub(/\s+/, ' ')[0, 512]
61
+ rescue StandardError
62
+ '-'
63
+ ensure
64
+ begin
65
+ input.rewind
66
+ rescue StandardError
67
+ nil
68
+ end
69
+ end
70
+ end
52
71
  end
53
72
  end
54
73
  end
data/lib/legion/api.rb CHANGED
@@ -222,5 +222,7 @@ module Legion
222
222
 
223
223
  use Legion::API::Middleware::RequestLogger
224
224
  use Legion::Rbac::Middleware if defined?(Legion::Rbac::Middleware)
225
+ use ElasticAPM::Middleware if defined?(ElasticAPM::Middleware) &&
226
+ Legion::Settings.dig(:api, :elastic_apm, :enabled)
225
227
  end
226
228
  end
@@ -3,6 +3,19 @@
3
3
  module Legion
4
4
  module Identity
5
5
  class Request
6
+ # Maps middleware-emitted source values to the canonical credential enum.
7
+ # :local is emitted by Middleware#system_principal for unauthenticated loopback
8
+ # requests and must normalize to :system to maintain audit trail consistency.
9
+ # :jwt is intentionally kept distinct — JWT is the transport, not the provider.
10
+ # Entra-specific identification requires issuer inspection (Phase 7 concern).
11
+ SOURCE_NORMALIZATION = {
12
+ api_key: :api,
13
+ jwt: :jwt,
14
+ kerberos: :kerberos,
15
+ local: :system,
16
+ system: :system
17
+ }.freeze
18
+
6
19
  attr_reader :principal_id, :canonical_name, :kind, :groups, :source, :metadata
7
20
 
8
21
  def initialize(principal_id:, canonical_name:, kind:, groups: [], source: nil, metadata: {}) # rubocop:disable Metrics/ParameterLists
@@ -10,7 +23,7 @@ module Legion
10
23
  @canonical_name = canonical_name
11
24
  @kind = kind
12
25
  @groups = groups.freeze
13
- @source = source
26
+ @source = SOURCE_NORMALIZATION.fetch(source&.to_sym, source)
14
27
  @metadata = metadata.freeze
15
28
  freeze
16
29
  end
@@ -23,16 +36,19 @@ module Legion
23
36
 
24
37
  # Builds a Request from a parsed auth claims hash with symbol keys:
25
38
  # { sub:, name:, preferred_username:, kind:, groups:, source: }
39
+ # The source value is normalized via SOURCE_NORMALIZATION at construction time.
26
40
  def self.from_auth_context(claims_hash)
27
41
  raw_name = claims_hash[:name] || claims_hash[:preferred_username] || ''
28
42
  canonical = raw_name.to_s.strip.downcase.gsub('.', '-')
43
+ raw_source = claims_hash[:source]&.to_sym
44
+ normalized_source = SOURCE_NORMALIZATION.fetch(raw_source, raw_source)
29
45
 
30
46
  new(
31
47
  principal_id: claims_hash[:sub],
32
48
  canonical_name: canonical,
33
49
  kind: claims_hash[:kind] || :human,
34
50
  groups: claims_hash[:groups] || [],
35
- source: claims_hash[:source]
51
+ source: normalized_source
36
52
  )
37
53
  end
38
54
 
@@ -56,6 +56,9 @@ module Legion
56
56
  Legion::Crypt.start
57
57
  Legion::Readiness.mark_ready(:crypt)
58
58
  setup_mtls_rotation
59
+ # Phase 5: fetch short-lived bootstrap RMQ creds from Vault before transport connects.
60
+ # Service is the authoritative gate (vault_connected? + dynamic_rmq_creds?).
61
+ fetch_phase5_bootstrap_creds unless Legion::Mode.respond_to?(:lite?) && Legion::Mode.lite?
59
62
  end
60
63
 
61
64
  Legion::Settings.resolve_secrets!
@@ -327,21 +330,12 @@ module Legion
327
330
  end
328
331
 
329
332
  def setup_apm
330
- apm_settings = Legion::Settings[:apm] || {}
333
+ apm_settings = Legion::Settings.dig(:api, :elastic_apm) || {}
331
334
  return unless apm_settings[:enabled]
332
335
 
333
336
  require 'elastic-apm'
334
337
 
335
- config = {
336
- service_name: apm_settings[:service_name] || "legion-#{Legion::Settings[:client][:name]}",
337
- server_url: apm_settings[:server_url] || 'http://localhost:8200',
338
- environment: apm_settings[:environment] || Legion::Settings[:environment] || 'development',
339
- secret_token: apm_settings[:secret_token],
340
- api_key: apm_settings[:api_key],
341
- log_level: apm_settings[:log_level]&.to_sym || Logger::WARN,
342
- transaction_sample_rate: apm_settings[:sample_rate] || 1.0
343
- }.compact
344
-
338
+ config = build_apm_config(apm_settings)
345
339
  ElasticAPM.start(**config)
346
340
  @apm_running = true
347
341
  log.info "Elastic APM started: server=#{config[:server_url]} service=#{config[:service_name]}"
@@ -489,7 +483,7 @@ module Legion
489
483
  log.info 'Legion::Transport connected'
490
484
  end
491
485
 
492
- def setup_identity
486
+ def setup_identity # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
493
487
  require_relative 'identity/process'
494
488
  require_relative 'identity/broker'
495
489
  require_relative 'identity/lease'
@@ -504,6 +498,18 @@ module Legion
504
498
  log.info "[Identity] fallback identity: #{Legion::Identity::Process.canonical_name}"
505
499
  end
506
500
 
501
+ # Phase 5: Swap from bootstrap RMQ credentials to identity-scoped credentials.
502
+ # Gate on vault_connected? + dynamic_rmq_creds? — NOT on resolved? (fallback identity
503
+ # still needs scoped creds via the mode-based role).
504
+ if defined?(Legion::Crypt) &&
505
+ Legion::Crypt.respond_to?(:vault_connected?) && Legion::Crypt.vault_connected? &&
506
+ Legion::Crypt.respond_to?(:dynamic_rmq_creds?) && Legion::Crypt.dynamic_rmq_creds? &&
507
+ Legion::Crypt.respond_to?(:swap_to_identity_creds) &&
508
+ !Legion::Mode.lite?
509
+ log.info '[Identity] swapping to identity-scoped RMQ credentials'
510
+ Legion::Crypt.swap_to_identity_creds(mode: Legion::Mode.current)
511
+ end
512
+
507
513
  # Re-resolve secrets for any identity-scoped lease:// refs (task 2.25)
508
514
  Legion::Settings.resolve_secrets! if Legion::Settings.respond_to?(:resolve_secrets!)
509
515
 
@@ -711,7 +717,7 @@ module Legion
711
717
  handle_exception(e, level: :warn, operation: 'service.shutdown_api')
712
718
  end
713
719
 
714
- def shutdown # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
720
+ def shutdown # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/MethodLength
715
721
  log.info('Legion::Service.shutdown was called')
716
722
  @shutdown = true
717
723
  Legion::Settings[:client][:shutting_down] = true
@@ -776,7 +782,12 @@ module Legion
776
782
  Legion::Readiness.mark_not_ready(:transport)
777
783
 
778
784
  shutdown_mtls_rotation
779
- shutdown_component('Crypt') { Legion::Crypt.shutdown }
785
+ # Phase 5: Revoke bootstrap RMQ lease on clean shutdown (defense-in-depth;
786
+ # lease expires naturally if process crashes before identity swap).
787
+ shutdown_component('Crypt bootstrap lease') do
788
+ Legion::Crypt.revoke_bootstrap_lease if defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:revoke_bootstrap_lease)
789
+ end
790
+ shutdown_component('Crypt') { Legion::Crypt.shutdown if defined?(Legion::Crypt) }
780
791
  Legion::Readiness.mark_not_ready(:crypt)
781
792
 
782
793
  Legion::Settings[:client][:ready] = false
@@ -816,7 +827,7 @@ module Legion
816
827
  shutdown_component('Transport') { Legion::Transport::Connection.shutdown }
817
828
  Legion::Readiness.mark_not_ready(:transport)
818
829
 
819
- shutdown_component('Crypt') { Legion::Crypt.shutdown }
830
+ shutdown_component('Crypt') { Legion::Crypt.shutdown if defined?(Legion::Crypt) }
820
831
  Legion::Readiness.mark_not_ready(:crypt)
821
832
 
822
833
  Legion::Readiness.wait_until_not_ready(:transport, :data, :cache, :crypt)
@@ -825,7 +836,12 @@ module Legion
825
836
  Legion::Readiness.mark_ready(:settings)
826
837
 
827
838
  Legion::Crypt.start if defined?(Legion::Crypt)
828
- Legion::Readiness.mark_ready(:crypt)
839
+ Legion::Readiness.mark_ready(:crypt) if defined?(Legion::Crypt)
840
+ # Phase 5: fetch bootstrap RMQ creds after Vault reconnects on reload.
841
+ fetch_phase5_bootstrap_creds unless Legion::Mode.lite?
842
+
843
+ # Resolve lease:// URIs with freshly loaded settings + new Vault token.
844
+ Legion::Settings.resolve_secrets! if Legion::Settings.respond_to?(:resolve_secrets!)
829
845
 
830
846
  setup_transport
831
847
  Legion::Readiness.mark_ready(:transport)
@@ -867,7 +883,9 @@ module Legion
867
883
  Legion::Readiness.mark_skipped(:gaia)
868
884
  end
869
885
 
870
- Legion::Readiness.mark_ready(:identity)
886
+ # Phase 5: re-run identity resolution + credential swap so the reloaded
887
+ # process gets identity-scoped RMQ creds (not stale bootstrap creds).
888
+ setup_identity
871
889
 
872
890
  setup_supervision
873
891
  load_extensions
@@ -877,7 +895,7 @@ module Legion
877
895
 
878
896
  register_core_tools
879
897
 
880
- Legion::Crypt.cs
898
+ Legion::Crypt.cs if defined?(Legion::Crypt)
881
899
  setup_apm if @api_enabled
882
900
  setup_api if @api_enabled
883
901
 
@@ -1036,6 +1054,18 @@ module Legion
1036
1054
 
1037
1055
  private
1038
1056
 
1057
+ # Phase 5: fetch short-lived bootstrap RMQ credentials from Vault.
1058
+ # Called after Crypt.start (boot) and after Crypt.start (reload).
1059
+ # Service owns the gate so Crypt.fetch_bootstrap_rmq_creds can be unconditional.
1060
+ def fetch_phase5_bootstrap_creds
1061
+ return unless defined?(Legion::Crypt)
1062
+ return unless Legion::Crypt.respond_to?(:fetch_bootstrap_rmq_creds)
1063
+ return unless Legion::Crypt.respond_to?(:vault_connected?) && Legion::Crypt.vault_connected?
1064
+ return unless Legion::Crypt.respond_to?(:dynamic_rmq_creds?) && Legion::Crypt.dynamic_rmq_creds?
1065
+
1066
+ Legion::Crypt.fetch_bootstrap_rmq_creds
1067
+ end
1068
+
1039
1069
  def resolve_identity_providers
1040
1070
  # Phase 4 adds lex-identity-* providers. For now, check if any are loaded.
1041
1071
  return false unless defined?(Legion::Extensions)
@@ -1145,6 +1175,35 @@ module Legion
1145
1175
  }.compact
1146
1176
  end
1147
1177
 
1178
+ def build_apm_config(apm)
1179
+ {
1180
+ server_url: apm[:server_url] || 'http://localhost:8200',
1181
+ api_key: apm[:api_key],
1182
+ secret_token: apm[:secret_token],
1183
+ api_buffer_size: apm[:api_buffer_size] || 256,
1184
+ api_request_size: apm[:api_request_size] || '750kb',
1185
+ api_request_time: apm[:api_request_time] || '10s',
1186
+ capture_body: apm.fetch(:capture_body, 'off'),
1187
+ capture_headers: apm.fetch(:capture_headers, true),
1188
+ capture_env: apm.fetch(:capture_env, true),
1189
+ disable_send: apm.fetch(:disable_send, false),
1190
+ environment: apm[:environment] || Legion::Settings[:environment] || 'development',
1191
+ framework_name: 'LegionIO',
1192
+ framework_version: Legion::VERSION,
1193
+ hostname: apm[:hostname] || Legion::Settings[:client][:name],
1194
+ ignore_url_patterns: apm[:ignore_url_patterns] || %w[/api/health /api/ready],
1195
+ logger: Legion::Logging.log,
1196
+ pool_size: apm[:pool_size] || 1,
1197
+ service_name: apm[:service_name] || 'LegionIO',
1198
+ service_node_name: apm[:service_node_name] || Legion::Settings[:client][:name],
1199
+ service_version: apm[:service_version] || Legion::VERSION,
1200
+ transaction_sample_rate: apm[:sample_rate] || 1.0,
1201
+ verify_server_cert: apm.fetch(:verify_server_cert, true),
1202
+ central_config: apm.fetch(:central_config, true),
1203
+ span_frames_min_duration: apm[:span_frames_min_duration]
1204
+ }.compact
1205
+ end
1206
+
1148
1207
  def ssl_server_settings(tls_cfg, bind, port)
1149
1208
  return {} unless tls_cfg
1150
1209
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.7.24'
4
+ VERSION = '1.7.26'
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.24
4
+ version: 1.7.26
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity