legionio 1.4.44 → 1.4.47
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 +4 -0
- data/CHANGELOG.md +23 -0
- data/lib/legion/api/auth_human.rb +134 -0
- data/lib/legion/api/middleware/auth.rb +2 -1
- data/lib/legion/api.rb +2 -0
- data/lib/legion/extensions/builders/actors.rb +4 -0
- data/lib/legion/extensions/core.rb +9 -3
- data/lib/legion/extensions.rb +4 -3
- data/lib/legion/telemetry.rb +57 -0
- data/lib/legion/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 61d1b58d973cadaa88f3236a2d191452324ce20721fb6a41bf9b08488e077754
|
|
4
|
+
data.tar.gz: '09129066576304957d34a58a8c54731e8228b3d5a45603592ee9b9ce10f72c55'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4e9bfacc58941fb83fe9725779d8a500b514e483dfd84dc759a7184e548b43379252c638007ced10de99164ee749cc9dd7a33134e31e4c0bd8605e6307648d8f
|
|
7
|
+
data.tar.gz: 989f282c012adb51747e8bbb143e29eabf1c213fd0ad7b531737f98cb948791a6e163c13fa5c1a73d0a5da20efa4cfabad237d90e0e3adbaa21d05c2a8cbc5dc
|
data/.rubocop.yml
CHANGED
|
@@ -38,6 +38,7 @@ Metrics/BlockLength:
|
|
|
38
38
|
- 'lib/legion/cli/update_command.rb'
|
|
39
39
|
- 'lib/legion/api/auth.rb'
|
|
40
40
|
- 'lib/legion/api/auth_worker.rb'
|
|
41
|
+
- 'lib/legion/api/auth_human.rb'
|
|
41
42
|
|
|
42
43
|
Metrics/AbcSize:
|
|
43
44
|
Max: 60
|
|
@@ -48,9 +49,12 @@ Metrics/CyclomaticComplexity:
|
|
|
48
49
|
Max: 15
|
|
49
50
|
Exclude:
|
|
50
51
|
- 'lib/legion/cli/chat_command.rb'
|
|
52
|
+
- 'lib/legion/api/auth_human.rb'
|
|
51
53
|
|
|
52
54
|
Metrics/PerceivedComplexity:
|
|
53
55
|
Max: 17
|
|
56
|
+
Exclude:
|
|
57
|
+
- 'lib/legion/api/auth_human.rb'
|
|
54
58
|
|
|
55
59
|
Style/Documentation:
|
|
56
60
|
Enabled: false
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Legion Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.47] - 2026-03-17
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- `gem_load` rescue block referenced undefined `gem_path` variable, causing secondary NameError that masked original LoadError
|
|
7
|
+
- `meta_actors` type guard checked `is_a?(Array)` but called `each_value` (Hash method), so meta actors were never hooked
|
|
8
|
+
- `build_actor_list` crashed entire extension load when actor file didn't define expected constant (now skips gracefully)
|
|
9
|
+
- `build_transport` raised NoMethodError on extensions with custom Transport modules missing `build` (now falls back to auto-generate)
|
|
10
|
+
|
|
11
|
+
## [1.4.46] - 2026-03-17
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `Legion::Telemetry.configure_exporter`: OTLP and console span exporters
|
|
15
|
+
- OTLP exporter uses BatchSpanProcessor for production performance
|
|
16
|
+
- Settings: `telemetry.tracing.exporter`, `endpoint`, `headers`, `batch_size`
|
|
17
|
+
- Graceful fallback when opentelemetry-exporter-otlp gem absent
|
|
18
|
+
|
|
19
|
+
## [1.4.45] - 2026-03-17
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- `GET /api/auth/authorize`: redirects to Entra authorization endpoint for browser-based OAuth2 login
|
|
23
|
+
- `GET /api/auth/callback`: exchanges authorization code for tokens, validates id_token via JWKS, maps claims, issues Legion human JWT
|
|
24
|
+
- Auth middleware SKIP_PATHS now includes `/api/auth/authorize` and `/api/auth/callback`
|
|
25
|
+
|
|
3
26
|
## [1.4.44] - 2026-03-17
|
|
4
27
|
|
|
5
28
|
### Added
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
class API < Sinatra::Base
|
|
8
|
+
module Routes
|
|
9
|
+
module AuthHuman
|
|
10
|
+
def self.registered(app)
|
|
11
|
+
register_authorize(app)
|
|
12
|
+
register_callback(app)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.resolve_entra_settings
|
|
16
|
+
return {} unless defined?(Legion::Settings)
|
|
17
|
+
|
|
18
|
+
rbac = Legion::Settings[:rbac]
|
|
19
|
+
entra = rbac.is_a?(Hash) ? rbac[:entra] : nil
|
|
20
|
+
return entra if entra.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
{}
|
|
23
|
+
rescue StandardError
|
|
24
|
+
{}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.exchange_code(entra, code)
|
|
28
|
+
uri = URI("https://login.microsoftonline.com/#{entra[:tenant_id]}/oauth2/v2.0/token")
|
|
29
|
+
response = Net::HTTP.post_form(uri, {
|
|
30
|
+
'client_id' => entra[:client_id],
|
|
31
|
+
'client_secret' => entra[:client_secret],
|
|
32
|
+
'code' => code,
|
|
33
|
+
'redirect_uri' => entra[:redirect_uri],
|
|
34
|
+
'grant_type' => 'authorization_code'
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
38
|
+
|
|
39
|
+
Legion::JSON.load(response.body)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.register_authorize(app)
|
|
45
|
+
app.get '/api/auth/authorize' do
|
|
46
|
+
entra = Routes::AuthHuman.resolve_entra_settings
|
|
47
|
+
halt 500, json_error('entra_not_configured', 'Entra OAuth settings are missing', status_code: 500) unless entra[:tenant_id] && entra[:client_id]
|
|
48
|
+
|
|
49
|
+
state = Legion::Crypt::JWT.issue(
|
|
50
|
+
{ nonce: SecureRandom.hex(16), purpose: 'oauth_state' },
|
|
51
|
+
ttl: 300
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
query = URI.encode_www_form({
|
|
55
|
+
'client_id' => entra[:client_id],
|
|
56
|
+
'redirect_uri' => entra[:redirect_uri],
|
|
57
|
+
'response_type' => 'code',
|
|
58
|
+
'scope' => 'openid profile',
|
|
59
|
+
'state' => state
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
redirect "https://login.microsoftonline.com/#{entra[:tenant_id]}/oauth2/v2.0/authorize?#{query}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.register_callback(app) # rubocop:disable Metrics/AbcSize
|
|
67
|
+
app.get '/api/auth/callback' do
|
|
68
|
+
entra = Routes::AuthHuman.resolve_entra_settings
|
|
69
|
+
halt 500, json_error('entra_not_configured', 'Entra OAuth settings are missing', status_code: 500) unless entra[:tenant_id] && entra[:client_id]
|
|
70
|
+
|
|
71
|
+
halt 400, json_error('oauth_error', params[:error_description] || params[:error], status_code: 400) if params[:error]
|
|
72
|
+
halt 400, json_error('missing_code', 'authorization code is required', status_code: 400) unless params[:code]
|
|
73
|
+
|
|
74
|
+
if params[:state]
|
|
75
|
+
begin
|
|
76
|
+
Legion::Crypt::JWT.verify(params[:state])
|
|
77
|
+
rescue Legion::Crypt::JWT::Error
|
|
78
|
+
halt 400, json_error('invalid_state', 'CSRF state token is invalid or expired', status_code: 400)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
token_response = Routes::AuthHuman.exchange_code(entra, params[:code])
|
|
83
|
+
halt 502, json_error('token_exchange_failed', 'Failed to exchange code for tokens', status_code: 502) unless token_response
|
|
84
|
+
|
|
85
|
+
id_token = token_response[:id_token] || token_response['id_token']
|
|
86
|
+
halt 502, json_error('no_id_token', 'Entra did not return an id_token', status_code: 502) unless id_token
|
|
87
|
+
|
|
88
|
+
jwks_url = "https://login.microsoftonline.com/#{entra[:tenant_id]}/discovery/v2.0/keys"
|
|
89
|
+
issuer = "https://login.microsoftonline.com/#{entra[:tenant_id]}/v2.0"
|
|
90
|
+
|
|
91
|
+
begin
|
|
92
|
+
claims = Legion::Crypt::JWT.verify_with_jwks(id_token, jwks_url: jwks_url, issuers: [issuer])
|
|
93
|
+
rescue Legion::Crypt::JWT::Error => e
|
|
94
|
+
halt 401, json_error('invalid_id_token', e.message, status_code: 401)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
unless defined?(Legion::Rbac::EntraClaimsMapper)
|
|
98
|
+
halt 501, json_error('claims_mapper_not_available', 'EntraClaimsMapper is not loaded', status_code: 501)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
mapped = Legion::Rbac::EntraClaimsMapper.map_claims(
|
|
102
|
+
claims,
|
|
103
|
+
role_map: entra[:role_map] || Legion::Rbac::EntraClaimsMapper::DEFAULT_ROLE_MAP,
|
|
104
|
+
group_map: entra[:group_map] || {},
|
|
105
|
+
default_role: entra[:default_role] || 'worker'
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
ttl = 28_800
|
|
109
|
+
token = Legion::API::Token.issue_human_token(
|
|
110
|
+
msid: mapped[:sub], name: mapped[:name], roles: mapped[:roles], ttl: ttl
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if request.env['HTTP_ACCEPT']&.include?('application/json')
|
|
114
|
+
json_response({
|
|
115
|
+
access_token: token,
|
|
116
|
+
token_type: 'Bearer',
|
|
117
|
+
expires_in: ttl,
|
|
118
|
+
roles: mapped[:roles],
|
|
119
|
+
name: mapped[:name]
|
|
120
|
+
})
|
|
121
|
+
else
|
|
122
|
+
redirect_url = entra[:success_redirect] || '/api/health'
|
|
123
|
+
redirect "#{redirect_url}#access_token=#{token}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
class << self
|
|
129
|
+
private :register_authorize, :register_callback
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -4,7 +4,8 @@ module Legion
|
|
|
4
4
|
class API < Sinatra::Base
|
|
5
5
|
module Middleware
|
|
6
6
|
class Auth
|
|
7
|
-
SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics /api/auth/token /api/auth/worker-token
|
|
7
|
+
SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics /api/auth/token /api/auth/worker-token
|
|
8
|
+
/api/auth/authorize /api/auth/callback].freeze
|
|
8
9
|
AUTH_HEADER = 'HTTP_AUTHORIZATION'
|
|
9
10
|
BEARER_PATTERN = /\ABearer\s+(.+)\z/i
|
|
10
11
|
API_KEY_HEADER = 'HTTP_X_API_KEY'
|
data/lib/legion/api.rb
CHANGED
|
@@ -28,6 +28,7 @@ require_relative 'api/openapi'
|
|
|
28
28
|
require_relative 'api/rbac'
|
|
29
29
|
require_relative 'api/auth'
|
|
30
30
|
require_relative 'api/auth_worker'
|
|
31
|
+
require_relative 'api/auth_human'
|
|
31
32
|
require_relative 'api/audit'
|
|
32
33
|
require_relative 'api/metrics'
|
|
33
34
|
|
|
@@ -100,6 +101,7 @@ module Legion
|
|
|
100
101
|
register Routes::Rbac
|
|
101
102
|
register Routes::Auth
|
|
102
103
|
register Routes::AuthWorker
|
|
104
|
+
register Routes::AuthHuman
|
|
103
105
|
register Routes::Audit
|
|
104
106
|
register Routes::Metrics
|
|
105
107
|
|
|
@@ -21,6 +21,10 @@ module Legion
|
|
|
21
21
|
actor_files.each do |file|
|
|
22
22
|
actor_name = file.split('/').last.sub('.rb', '')
|
|
23
23
|
actor_class = "#{lex_class}::Actor::#{actor_name.split('_').collect(&:capitalize).join}"
|
|
24
|
+
unless Kernel.const_defined?(actor_class)
|
|
25
|
+
Legion::Logging.warn "Actor constant #{actor_class} not defined, skipping"
|
|
26
|
+
next
|
|
27
|
+
end
|
|
24
28
|
@actors[actor_name.to_sym] = {
|
|
25
29
|
extension: lex_class.to_s.downcase,
|
|
26
30
|
extension_name: extension_name,
|
|
@@ -92,13 +92,19 @@ module Legion
|
|
|
92
92
|
require "#{extension_path}/transport/autobuild"
|
|
93
93
|
extension_class::Transport::AutoBuild.build
|
|
94
94
|
log.warn 'still using transport::autobuild, please upgrade'
|
|
95
|
-
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if File.exist? "#{extension_path}/transport.rb"
|
|
96
99
|
require "#{extension_path}/transport"
|
|
97
|
-
extension_class::Transport.build
|
|
100
|
+
unless extension_class::Transport.respond_to?(:build)
|
|
101
|
+
log.warn "#{extension_class}::Transport does not respond to build, auto-generating"
|
|
102
|
+
auto_generate_transport
|
|
103
|
+
end
|
|
98
104
|
else
|
|
99
105
|
auto_generate_transport
|
|
100
|
-
extension_class::Transport.build
|
|
101
106
|
end
|
|
107
|
+
extension_class::Transport.build
|
|
102
108
|
end
|
|
103
109
|
|
|
104
110
|
def build_settings
|
data/lib/legion/extensions.rb
CHANGED
|
@@ -108,7 +108,7 @@ module Legion
|
|
|
108
108
|
require 'legion/transport/messages/lex_register'
|
|
109
109
|
Legion::Transport::Messages::LexRegister.new(function: 'save', opts: extension.runners).publish
|
|
110
110
|
|
|
111
|
-
if extension.respond_to?(:meta_actors) && extension.meta_actors.is_a?(
|
|
111
|
+
if extension.respond_to?(:meta_actors) && extension.meta_actors.is_a?(Hash)
|
|
112
112
|
extension.meta_actors.each_value do |actor|
|
|
113
113
|
extension.log.debug("hooking meta actor: #{actor}") if has_logger
|
|
114
114
|
hook_actor(**actor)
|
|
@@ -199,12 +199,13 @@ module Legion
|
|
|
199
199
|
end
|
|
200
200
|
|
|
201
201
|
def gem_load(gem_name, name)
|
|
202
|
-
|
|
202
|
+
gem_dir = Gem::Specification.find_by_name(gem_name).gem_dir
|
|
203
|
+
require "#{gem_dir}/lib/legion/extensions/#{name}"
|
|
203
204
|
true
|
|
204
205
|
rescue LoadError => e
|
|
205
206
|
Legion::Logging.error e.message
|
|
206
207
|
Legion::Logging.error e.backtrace
|
|
207
|
-
Legion::Logging.error "gem_path: #{
|
|
208
|
+
Legion::Logging.error "gem_path: #{gem_dir}" if defined?(gem_dir) && gem_dir
|
|
208
209
|
false
|
|
209
210
|
end
|
|
210
211
|
|
data/lib/legion/telemetry.rb
CHANGED
|
@@ -56,10 +56,67 @@ module Legion
|
|
|
56
56
|
{}
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
def configure_exporter
|
|
60
|
+
backend = tracing_settings[:exporter]&.to_sym || :none
|
|
61
|
+
|
|
62
|
+
case backend
|
|
63
|
+
when :otlp
|
|
64
|
+
configure_otlp
|
|
65
|
+
when :console
|
|
66
|
+
configure_console
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def tracing_settings
|
|
71
|
+
telemetry = Legion::Settings[:telemetry]
|
|
72
|
+
return {} unless telemetry.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
tracing = telemetry[:tracing]
|
|
75
|
+
tracing.is_a?(Hash) ? tracing : {}
|
|
76
|
+
rescue StandardError
|
|
77
|
+
{}
|
|
78
|
+
end
|
|
79
|
+
|
|
59
80
|
def otel_init_error?(error)
|
|
60
81
|
error.message.include?('OpenTelemetry') || error.message.include?('tracer')
|
|
61
82
|
rescue StandardError
|
|
62
83
|
false
|
|
63
84
|
end
|
|
85
|
+
|
|
86
|
+
def configure_otlp
|
|
87
|
+
require 'opentelemetry-exporter-otlp'
|
|
88
|
+
|
|
89
|
+
endpoint = tracing_settings[:endpoint] || 'http://localhost:4318/v1/traces'
|
|
90
|
+
headers = tracing_settings[:headers] || {}
|
|
91
|
+
|
|
92
|
+
exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(
|
|
93
|
+
endpoint: endpoint,
|
|
94
|
+
headers: headers
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
processor = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
|
|
98
|
+
exporter,
|
|
99
|
+
max_queue_size: 2048,
|
|
100
|
+
max_export_batch_size: tracing_settings[:batch_size] || 512
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
OpenTelemetry.tracer_provider.add_span_processor(processor)
|
|
104
|
+
Legion::Logging.info "OTLP exporter configured: #{endpoint}"
|
|
105
|
+
true
|
|
106
|
+
rescue LoadError
|
|
107
|
+
Legion::Logging.warn 'opentelemetry-exporter-otlp gem not available'
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def configure_console
|
|
112
|
+
return false unless defined?(OpenTelemetry::SDK::Trace::Export::ConsoleSpanExporter)
|
|
113
|
+
|
|
114
|
+
exporter = OpenTelemetry::SDK::Trace::Export::ConsoleSpanExporter.new
|
|
115
|
+
processor = OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exporter)
|
|
116
|
+
OpenTelemetry.tracer_provider.add_span_processor(processor)
|
|
117
|
+
true
|
|
118
|
+
rescue StandardError
|
|
119
|
+
false
|
|
120
|
+
end
|
|
64
121
|
end
|
|
65
122
|
end
|
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.
|
|
4
|
+
version: 1.4.47
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -318,6 +318,7 @@ files:
|
|
|
318
318
|
- lib/legion/api.rb
|
|
319
319
|
- lib/legion/api/audit.rb
|
|
320
320
|
- lib/legion/api/auth.rb
|
|
321
|
+
- lib/legion/api/auth_human.rb
|
|
321
322
|
- lib/legion/api/auth_worker.rb
|
|
322
323
|
- lib/legion/api/chains.rb
|
|
323
324
|
- lib/legion/api/coldstart.rb
|