legionio 1.4.104 → 1.4.105
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 +1 -0
- data/CHANGELOG.md +21 -0
- data/lib/legion/api/auth_saml.rb +181 -0
- data/lib/legion/api/marketplace.rb +124 -0
- data/lib/legion/api.rb +4 -0
- data/lib/legion/cli/marketplace_command.rb +239 -24
- data/lib/legion/registry.rb +84 -3
- data/lib/legion/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc07c77ea1337b17cf9a4be55e6469e9273beaa182e11aa7afac08405fe70275
|
|
4
|
+
data.tar.gz: e1666f8f6255c3a5cfd418357435fc407d5261be55621ae2cd875e01590c8fb0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 893094d33f99257ec0c2066a77cb680bc44ad6bb952e0ac7fa8a2099729b2a9643043e02bbb07bf7f638e9e0f22f101c35527e3c15578c241aa796ec241b85d8
|
|
7
|
+
data.tar.gz: 59400eb852207387b9f7df6d22f2f3668ae52a2d5b578e935a11277622029096f51184f8212b7a13cb18a7f733067c652c57d6b09f0e3f6bd1ffc53cbce283f8
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Legion Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.105] - 2026-03-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `Legion::API::Routes::AuthSaml` — SAML 2.0 SP authentication flow
|
|
7
|
+
- `GET /api/auth/saml/metadata` — generates SP metadata XML (delegates to `OneLogin::RubySaml::Metadata`)
|
|
8
|
+
- `GET /api/auth/saml/login` — initiates IdP redirect via `OneLogin::RubySaml::Authrequest`
|
|
9
|
+
- `POST /api/auth/saml/acs` — validates SAML assertion, extracts claims (nameid, email, displayName, groups), maps groups to Legion RBAC roles, and issues a Legion JWT
|
|
10
|
+
- Routes are only registered when `OneLogin::RubySaml` is defined and `auth.saml.enabled` is true
|
|
11
|
+
- Claims mapping delegates to `Legion::Rbac::ClaimsMapper.groups_to_roles` when available, falls back to `['worker']`
|
|
12
|
+
- Configuration via `Legion::Settings.dig(:auth, :saml)` — keys: `idp_sso_url`, `idp_cert`, `sp_entity_id`, `sp_acs_url`, `group_map`, `default_role`, `want_assertions_signed`, `want_assertions_encrypted`
|
|
13
|
+
- `Legion::Registry` review workflow: `submit_for_review`, `approve`, `reject`, `deprecate`, `pending_reviews`, `usage_stats` class methods
|
|
14
|
+
- `Legion::Registry::Entry` gains `status`, `review_notes`, `reject_reason`, `successor`, `sunset_date`, and timestamp fields; `deprecated?` and `pending_review?` predicates
|
|
15
|
+
- `legion marketplace submit NAME` — submit extension for review
|
|
16
|
+
- `legion marketplace review` — list extensions pending review
|
|
17
|
+
- `legion marketplace approve NAME [--notes TEXT]` — approve an extension
|
|
18
|
+
- `legion marketplace reject NAME [--reason TEXT]` — reject an extension
|
|
19
|
+
- `legion marketplace deprecate NAME [--successor NAME] [--sunset-date DATE]` — mark extension as deprecated
|
|
20
|
+
- `legion marketplace stats NAME` — show usage statistics (install count, active instances, downloads)
|
|
21
|
+
- `Legion::API::Routes::Marketplace` — full REST API: `GET /api/marketplace`, `GET /api/marketplace/:name`, `POST /api/marketplace/:name/submit`, `POST /api/marketplace/:name/approve`, `POST /api/marketplace/:name/reject`, `POST /api/marketplace/:name/deprecate`, `GET /api/marketplace/:name/stats`
|
|
22
|
+
- 123 new specs across registry, CLI, and API layers
|
|
23
|
+
|
|
3
24
|
## [1.4.104] - 2026-03-21
|
|
4
25
|
|
|
5
26
|
### Added
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
class API < Sinatra::Base
|
|
5
|
+
module Routes
|
|
6
|
+
module AuthSaml
|
|
7
|
+
def self.registered(app)
|
|
8
|
+
return unless saml_enabled?
|
|
9
|
+
|
|
10
|
+
app.helpers do
|
|
11
|
+
define_method(:saml_settings) { Routes::AuthSaml.build_saml_settings }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
register_metadata(app)
|
|
15
|
+
register_login(app)
|
|
16
|
+
register_acs(app)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.saml_enabled?
|
|
20
|
+
return false unless defined?(OneLogin::RubySaml)
|
|
21
|
+
|
|
22
|
+
cfg = resolve_saml_config
|
|
23
|
+
cfg.is_a?(Hash) && cfg[:enabled]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.resolve_saml_config
|
|
27
|
+
return {} unless defined?(Legion::Settings)
|
|
28
|
+
|
|
29
|
+
auth = Legion::Settings[:auth]
|
|
30
|
+
saml = auth.is_a?(Hash) ? auth[:saml] : nil
|
|
31
|
+
return saml if saml.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
{}
|
|
34
|
+
rescue StandardError
|
|
35
|
+
{}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.build_saml_settings
|
|
39
|
+
cfg = resolve_saml_config
|
|
40
|
+
|
|
41
|
+
settings = OneLogin::RubySaml::Settings.new
|
|
42
|
+
settings.idp_sso_service_url = cfg[:idp_sso_url]
|
|
43
|
+
settings.idp_cert = cfg[:idp_cert]
|
|
44
|
+
settings.sp_entity_id = cfg[:sp_entity_id]
|
|
45
|
+
settings.assertion_consumer_service_url = cfg[:sp_acs_url]
|
|
46
|
+
settings.name_identifier_format = cfg[:name_id_format] ||
|
|
47
|
+
'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
|
|
48
|
+
settings.security[:authn_requests_signed] = false
|
|
49
|
+
settings.security[:want_assertions_signed] = cfg.fetch(:want_assertions_signed, true)
|
|
50
|
+
settings.security[:want_assertions_encrypted] = cfg.fetch(:want_assertions_encrypted, false)
|
|
51
|
+
settings
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.register_metadata(app)
|
|
55
|
+
app.get '/api/auth/saml/metadata' do
|
|
56
|
+
halt 503, json_error('saml_not_configured', 'SAML SP is not configured', status_code: 503) unless defined?(OneLogin::RubySaml)
|
|
57
|
+
|
|
58
|
+
meta = OneLogin::RubySaml::Metadata.new
|
|
59
|
+
content_type 'application/xml'
|
|
60
|
+
meta.generate(saml_settings, true)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.register_login(app)
|
|
65
|
+
app.get '/api/auth/saml/login' do
|
|
66
|
+
halt 503, json_error('saml_not_configured', 'SAML SP is not configured', status_code: 503) unless defined?(OneLogin::RubySaml)
|
|
67
|
+
|
|
68
|
+
cfg = Routes::AuthSaml.resolve_saml_config
|
|
69
|
+
unless cfg[:idp_sso_url] && cfg[:sp_entity_id]
|
|
70
|
+
halt 500, json_error('saml_misconfigured', 'auth.saml.idp_sso_url and sp_entity_id are required',
|
|
71
|
+
status_code: 500)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
auth_request = OneLogin::RubySaml::Authrequest.new
|
|
75
|
+
redirect auth_request.create(saml_settings)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.register_acs(app)
|
|
80
|
+
app.post '/api/auth/saml/acs' do
|
|
81
|
+
halt 503, json_error('saml_not_configured', 'SAML SP is not configured', status_code: 503) unless defined?(OneLogin::RubySaml)
|
|
82
|
+
|
|
83
|
+
unless params['SAMLResponse']
|
|
84
|
+
halt 400, json_error('missing_saml_response', 'SAMLResponse parameter is required',
|
|
85
|
+
status_code: 400)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
response = OneLogin::RubySaml::Response.new(
|
|
89
|
+
params['SAMLResponse'],
|
|
90
|
+
settings: saml_settings
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
unless response.is_valid?
|
|
94
|
+
errors = response.errors.join(', ')
|
|
95
|
+
halt 401, json_error('saml_invalid', "SAML assertion is invalid: #{errors}", status_code: 401)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
claims = Routes::AuthSaml.extract_claims(response)
|
|
99
|
+
roles = Routes::AuthSaml.map_roles(claims[:groups])
|
|
100
|
+
|
|
101
|
+
ttl = 28_800
|
|
102
|
+
token = Legion::API::Token.issue_human_token(
|
|
103
|
+
msid: claims[:nameid],
|
|
104
|
+
name: claims[:display_name],
|
|
105
|
+
roles: roles,
|
|
106
|
+
ttl: ttl
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
json_response({
|
|
110
|
+
access_token: token,
|
|
111
|
+
token_type: 'Bearer',
|
|
112
|
+
expires_in: ttl,
|
|
113
|
+
roles: roles,
|
|
114
|
+
name: claims[:display_name]
|
|
115
|
+
})
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.extract_claims(response)
|
|
120
|
+
attrs = response.attributes
|
|
121
|
+
|
|
122
|
+
email = first_attr(attrs, 'email', 'mail', 'emailAddress',
|
|
123
|
+
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress')
|
|
124
|
+
display_name = first_attr(attrs, 'displayName', 'name',
|
|
125
|
+
'http://schemas.microsoft.com/identity/claims/displayname',
|
|
126
|
+
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name')
|
|
127
|
+
groups = multi_attr(attrs, 'groups',
|
|
128
|
+
'http://schemas.microsoft.com/ws/2008/06/identity/claims/groups')
|
|
129
|
+
|
|
130
|
+
{
|
|
131
|
+
nameid: response.nameid,
|
|
132
|
+
email: email,
|
|
133
|
+
display_name: display_name || email,
|
|
134
|
+
groups: groups
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.map_roles(groups)
|
|
139
|
+
if defined?(Legion::Rbac::ClaimsMapper) && Legion::Rbac::ClaimsMapper.respond_to?(:groups_to_roles)
|
|
140
|
+
cfg = resolve_saml_config
|
|
141
|
+
group_map = cfg[:group_map] || {}
|
|
142
|
+
default_role = cfg[:default_role] || 'worker'
|
|
143
|
+
Legion::Rbac::ClaimsMapper.groups_to_roles(groups, group_map: group_map, default_role: default_role)
|
|
144
|
+
else
|
|
145
|
+
['worker']
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
class << self
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def first_attr(attrs, *names)
|
|
153
|
+
names.each do |n|
|
|
154
|
+
v = safe_attr(attrs, n)
|
|
155
|
+
return v if v
|
|
156
|
+
end
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def multi_attr(attrs, *names)
|
|
161
|
+
names.each do |n|
|
|
162
|
+
v = attrs.multi(n)
|
|
163
|
+
return Array(v) if v
|
|
164
|
+
rescue StandardError
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
[]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def safe_attr(attrs, name)
|
|
171
|
+
attrs[name]
|
|
172
|
+
rescue StandardError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private :register_metadata, :register_login, :register_acs
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'legion/registry'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
class API < Sinatra::Base
|
|
8
|
+
module Routes
|
|
9
|
+
module Marketplace
|
|
10
|
+
module Helpers
|
|
11
|
+
def parse_sunset_date(date_str)
|
|
12
|
+
return nil if date_str.nil? || date_str.empty?
|
|
13
|
+
|
|
14
|
+
Date.parse(date_str.to_s)
|
|
15
|
+
rescue ArgumentError
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.registered(app)
|
|
21
|
+
app.helpers Helpers
|
|
22
|
+
register_collection(app)
|
|
23
|
+
register_member(app)
|
|
24
|
+
register_review_actions(app)
|
|
25
|
+
register_stats(app)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.register_collection(app)
|
|
29
|
+
app.get '/api/marketplace' do
|
|
30
|
+
query = params[:q] || params[:query]
|
|
31
|
+
entries = query ? Legion::Registry.search(query) : Legion::Registry.all
|
|
32
|
+
entries = entries.select { |e| e.status.to_s == params[:status] } if params[:status]
|
|
33
|
+
entries = entries.select { |e| e.risk_tier == params[:tier] } if params[:tier]
|
|
34
|
+
|
|
35
|
+
paginated = entries.slice((page_offset)..(page_offset + page_limit - 1)) || []
|
|
36
|
+
content_type :json
|
|
37
|
+
status 200
|
|
38
|
+
Legion::JSON.dump({
|
|
39
|
+
data: paginated.map(&:to_h),
|
|
40
|
+
meta: response_meta.merge(total: entries.size, limit: page_limit, offset: page_offset)
|
|
41
|
+
})
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.register_member(app)
|
|
46
|
+
app.get '/api/marketplace/:name' do
|
|
47
|
+
entry = Legion::Registry.lookup(params[:name])
|
|
48
|
+
unless entry
|
|
49
|
+
halt 404, { 'Content-Type' => 'application/json' },
|
|
50
|
+
Legion::JSON.dump({ error: { code: 'not_found', message: "Extension #{params[:name]} not found" } })
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
json_response(entry.to_h.merge(stats: Legion::Registry.usage_stats(params[:name])))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.register_review_actions(app) # rubocop:disable Metrics/AbcSize
|
|
58
|
+
app.post '/api/marketplace/:name/submit' do
|
|
59
|
+
begin
|
|
60
|
+
Legion::Registry.submit_for_review(params[:name])
|
|
61
|
+
rescue ArgumentError => e
|
|
62
|
+
halt 404, { 'Content-Type' => 'application/json' },
|
|
63
|
+
Legion::JSON.dump({ error: { code: 'not_found', message: e.message } })
|
|
64
|
+
end
|
|
65
|
+
json_response({ name: params[:name], status: 'pending_review' }, status_code: 202)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
app.post '/api/marketplace/:name/approve' do
|
|
69
|
+
body = parse_request_body
|
|
70
|
+
begin
|
|
71
|
+
Legion::Registry.approve(params[:name], notes: body[:notes])
|
|
72
|
+
rescue ArgumentError => e
|
|
73
|
+
halt 404, { 'Content-Type' => 'application/json' },
|
|
74
|
+
Legion::JSON.dump({ error: { code: 'not_found', message: e.message } })
|
|
75
|
+
end
|
|
76
|
+
entry = Legion::Registry.lookup(params[:name])
|
|
77
|
+
json_response({ name: params[:name], status: 'approved', entry: entry.to_h })
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
app.post '/api/marketplace/:name/reject' do
|
|
81
|
+
body = parse_request_body
|
|
82
|
+
begin
|
|
83
|
+
Legion::Registry.reject(params[:name], reason: body[:reason])
|
|
84
|
+
rescue ArgumentError => e
|
|
85
|
+
halt 404, { 'Content-Type' => 'application/json' },
|
|
86
|
+
Legion::JSON.dump({ error: { code: 'not_found', message: e.message } })
|
|
87
|
+
end
|
|
88
|
+
entry = Legion::Registry.lookup(params[:name])
|
|
89
|
+
json_response({ name: params[:name], status: 'rejected', entry: entry.to_h })
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
app.post '/api/marketplace/:name/deprecate' do
|
|
93
|
+
body = parse_request_body
|
|
94
|
+
sunset = begin
|
|
95
|
+
body[:sunset_date] ? Date.parse(body[:sunset_date].to_s) : nil
|
|
96
|
+
rescue ArgumentError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
begin
|
|
100
|
+
Legion::Registry.deprecate(params[:name], successor: body[:successor], sunset_date: sunset)
|
|
101
|
+
rescue ArgumentError => e
|
|
102
|
+
halt 404, { 'Content-Type' => 'application/json' },
|
|
103
|
+
Legion::JSON.dump({ error: { code: 'not_found', message: e.message } })
|
|
104
|
+
end
|
|
105
|
+
entry = Legion::Registry.lookup(params[:name])
|
|
106
|
+
json_response({ name: params[:name], status: 'deprecated', entry: entry.to_h })
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.register_stats(app)
|
|
111
|
+
app.get '/api/marketplace/:name/stats' do
|
|
112
|
+
data = Legion::Registry.usage_stats(params[:name])
|
|
113
|
+
unless data
|
|
114
|
+
halt 404, { 'Content-Type' => 'application/json' },
|
|
115
|
+
Legion::JSON.dump({ error: { code: 'not_found', message: "Extension #{params[:name]} not found" } })
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
json_response(data)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
data/lib/legion/api.rb
CHANGED
|
@@ -30,6 +30,7 @@ require_relative 'api/rbac'
|
|
|
30
30
|
require_relative 'api/auth'
|
|
31
31
|
require_relative 'api/auth_worker'
|
|
32
32
|
require_relative 'api/auth_human'
|
|
33
|
+
require_relative 'api/auth_saml'
|
|
33
34
|
require_relative 'api/capacity'
|
|
34
35
|
require_relative 'api/audit'
|
|
35
36
|
require_relative 'api/metrics'
|
|
@@ -40,6 +41,7 @@ require_relative 'api/workflow'
|
|
|
40
41
|
require_relative 'api/governance'
|
|
41
42
|
require_relative 'api/acp'
|
|
42
43
|
require_relative 'api/prompts'
|
|
44
|
+
require_relative 'api/marketplace'
|
|
43
45
|
|
|
44
46
|
module Legion
|
|
45
47
|
class API < Sinatra::Base
|
|
@@ -114,6 +116,7 @@ module Legion
|
|
|
114
116
|
register Routes::Auth
|
|
115
117
|
register Routes::AuthWorker
|
|
116
118
|
register Routes::AuthHuman
|
|
119
|
+
register Routes::AuthSaml
|
|
117
120
|
register Routes::Capacity
|
|
118
121
|
register Routes::Audit
|
|
119
122
|
register Routes::Metrics
|
|
@@ -123,6 +126,7 @@ module Legion
|
|
|
123
126
|
register Routes::Governance
|
|
124
127
|
register Routes::Acp
|
|
125
128
|
register Routes::Prompts
|
|
129
|
+
register Routes::Marketplace
|
|
126
130
|
|
|
127
131
|
use Legion::API::Middleware::RequestLogger
|
|
128
132
|
use Legion::Rbac::Middleware if defined?(Legion::Rbac::Middleware)
|
|
@@ -9,70 +9,285 @@ module Legion
|
|
|
9
9
|
true
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
class_option :json, type: :boolean, default: false, desc: 'Output as JSON'
|
|
13
|
+
class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
|
|
14
|
+
|
|
15
|
+
# ──────────────────────────────────────────────────────────
|
|
16
|
+
# search
|
|
17
|
+
# ──────────────────────────────────────────────────────────
|
|
18
|
+
|
|
12
19
|
desc 'search QUERY', 'Search extension registry'
|
|
13
20
|
def search(query)
|
|
14
21
|
require 'legion/registry'
|
|
22
|
+
out = formatter
|
|
15
23
|
results = Legion::Registry.search(query)
|
|
24
|
+
|
|
16
25
|
if results.empty?
|
|
17
|
-
|
|
26
|
+
out.warn("No extensions found matching '#{query}'")
|
|
18
27
|
return
|
|
19
28
|
end
|
|
20
29
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
if options[:json]
|
|
31
|
+
out.json(results.map(&:to_h))
|
|
32
|
+
else
|
|
33
|
+
rows = results.map do |e|
|
|
34
|
+
status_label = e.approved? ? 'approved' : (e.status || e.airb_status).to_s
|
|
35
|
+
[e.name, e.version.to_s, status_label, (e.description || '')[0..60]]
|
|
36
|
+
end
|
|
37
|
+
out.table(%w[Name Version Status Description], rows)
|
|
25
38
|
end
|
|
26
39
|
end
|
|
27
40
|
|
|
41
|
+
# ──────────────────────────────────────────────────────────
|
|
42
|
+
# info
|
|
43
|
+
# ──────────────────────────────────────────────────────────
|
|
44
|
+
|
|
28
45
|
desc 'info NAME', 'Show extension details'
|
|
29
46
|
def info(name)
|
|
30
47
|
require 'legion/registry'
|
|
48
|
+
out = formatter
|
|
31
49
|
entry = Legion::Registry.lookup(name)
|
|
50
|
+
|
|
32
51
|
unless entry
|
|
33
|
-
|
|
52
|
+
out.error("Extension '#{name}' not found")
|
|
34
53
|
return
|
|
35
54
|
end
|
|
36
55
|
|
|
37
|
-
|
|
56
|
+
if options[:json]
|
|
57
|
+
out.json(entry.to_h)
|
|
58
|
+
else
|
|
59
|
+
out.header("Extension: #{entry.name}")
|
|
60
|
+
out.spacer
|
|
61
|
+
out.detail(entry.to_h.compact)
|
|
62
|
+
end
|
|
38
63
|
end
|
|
39
64
|
|
|
65
|
+
# ──────────────────────────────────────────────────────────
|
|
66
|
+
# list
|
|
67
|
+
# ──────────────────────────────────────────────────────────
|
|
68
|
+
|
|
40
69
|
desc 'list', 'List all registered extensions'
|
|
41
70
|
option :approved, type: :boolean, desc: 'Show only approved extensions'
|
|
42
|
-
option :tier,
|
|
71
|
+
option :tier, type: :string, desc: 'Filter by risk tier'
|
|
72
|
+
option :status, type: :string, desc: 'Filter by review status'
|
|
43
73
|
def list
|
|
44
74
|
require 'legion/registry'
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
elsif options[:tier]
|
|
48
|
-
Legion::Registry.by_risk_tier(options[:tier])
|
|
49
|
-
else
|
|
50
|
-
Legion::Registry.all
|
|
51
|
-
end
|
|
75
|
+
out = formatter
|
|
76
|
+
extensions = build_extension_list
|
|
52
77
|
|
|
53
78
|
if extensions.empty?
|
|
54
|
-
|
|
79
|
+
out.warn('No extensions registered')
|
|
55
80
|
return
|
|
56
81
|
end
|
|
57
82
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
83
|
+
if options[:json]
|
|
84
|
+
out.json(extensions.map(&:to_h))
|
|
85
|
+
else
|
|
86
|
+
rows = extensions.map { |e| [e.name, e.version.to_s, e.status.to_s, e.risk_tier] }
|
|
87
|
+
out.table(%w[Name Version Status Tier], rows)
|
|
88
|
+
puts " #{extensions.size} extension(s)"
|
|
61
89
|
end
|
|
62
90
|
end
|
|
63
91
|
|
|
92
|
+
# ──────────────────────────────────────────────────────────
|
|
93
|
+
# scan
|
|
94
|
+
# ──────────────────────────────────────────────────────────
|
|
95
|
+
|
|
64
96
|
desc 'scan NAME', 'Run security scan on extension'
|
|
65
97
|
def scan(name)
|
|
66
98
|
require 'legion/registry/security_scanner'
|
|
99
|
+
out = formatter
|
|
67
100
|
scanner = Legion::Registry::SecurityScanner.new
|
|
68
|
-
result
|
|
101
|
+
result = scanner.scan(name: name)
|
|
69
102
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
103
|
+
if options[:json]
|
|
104
|
+
out.json(result)
|
|
105
|
+
else
|
|
106
|
+
result[:checks].each do |check|
|
|
107
|
+
color = check[:status] == :fail ? :critical : :nominal
|
|
108
|
+
puts " #{out.colorize(check[:check].to_s.ljust(25), color)} #{check[:status]} - #{check[:details]}"
|
|
109
|
+
end
|
|
110
|
+
if result[:passed]
|
|
111
|
+
out.success('Scan PASSED')
|
|
112
|
+
else
|
|
113
|
+
out.error('Scan FAILED')
|
|
114
|
+
end
|
|
73
115
|
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# ──────────────────────────────────────────────────────────
|
|
119
|
+
# submit
|
|
120
|
+
# ──────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
desc 'submit NAME', 'Submit extension for review'
|
|
123
|
+
def submit(name)
|
|
124
|
+
require 'legion/registry'
|
|
125
|
+
out = formatter
|
|
126
|
+
|
|
127
|
+
Legion::Registry.submit_for_review(name)
|
|
128
|
+
|
|
129
|
+
if options[:json]
|
|
130
|
+
out.json(success: true, name: name, status: 'pending_review')
|
|
131
|
+
else
|
|
132
|
+
out.success("'#{name}' submitted for review")
|
|
133
|
+
end
|
|
134
|
+
rescue ArgumentError => e
|
|
135
|
+
out.error(e.message)
|
|
136
|
+
end
|
|
74
137
|
|
|
75
|
-
|
|
138
|
+
# ──────────────────────────────────────────────────────────
|
|
139
|
+
# review
|
|
140
|
+
# ──────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
desc 'review', 'List extensions pending review'
|
|
143
|
+
def review
|
|
144
|
+
require 'legion/registry'
|
|
145
|
+
out = formatter
|
|
146
|
+
pending = Legion::Registry.pending_reviews
|
|
147
|
+
|
|
148
|
+
if pending.empty?
|
|
149
|
+
out.warn('No extensions pending review')
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if options[:json]
|
|
154
|
+
out.json(pending.map(&:to_h))
|
|
155
|
+
else
|
|
156
|
+
rows = pending.map { |e| [e.name, e.version.to_s, e.author.to_s, e.submitted_at.to_s] }
|
|
157
|
+
out.table(%w[Name Version Author Submitted], rows)
|
|
158
|
+
puts " #{pending.size} pending review(s)"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# ──────────────────────────────────────────────────────────
|
|
163
|
+
# approve
|
|
164
|
+
# ──────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
desc 'approve NAME', 'Approve an extension'
|
|
167
|
+
option :notes, type: :string, desc: 'Reviewer notes'
|
|
168
|
+
def approve(name)
|
|
169
|
+
require 'legion/registry'
|
|
170
|
+
out = formatter
|
|
171
|
+
|
|
172
|
+
Legion::Registry.approve(name, notes: options[:notes])
|
|
173
|
+
|
|
174
|
+
if options[:json]
|
|
175
|
+
out.json(success: true, name: name, status: 'approved')
|
|
176
|
+
else
|
|
177
|
+
out.success("'#{name}' approved")
|
|
178
|
+
out.detail({ 'Notes' => options[:notes] }) if options[:notes]
|
|
179
|
+
end
|
|
180
|
+
rescue ArgumentError => e
|
|
181
|
+
out.error(e.message)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# ──────────────────────────────────────────────────────────
|
|
185
|
+
# reject
|
|
186
|
+
# ──────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
desc 'reject NAME', 'Reject an extension'
|
|
189
|
+
option :reason, type: :string, desc: 'Rejection reason'
|
|
190
|
+
def reject(name)
|
|
191
|
+
require 'legion/registry'
|
|
192
|
+
out = formatter
|
|
193
|
+
|
|
194
|
+
Legion::Registry.reject(name, reason: options[:reason])
|
|
195
|
+
|
|
196
|
+
if options[:json]
|
|
197
|
+
out.json(success: true, name: name, status: 'rejected')
|
|
198
|
+
else
|
|
199
|
+
out.success("'#{name}' rejected")
|
|
200
|
+
out.detail({ 'Reason' => options[:reason] }) if options[:reason]
|
|
201
|
+
end
|
|
202
|
+
rescue ArgumentError => e
|
|
203
|
+
out.error(e.message)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# ──────────────────────────────────────────────────────────
|
|
207
|
+
# deprecate
|
|
208
|
+
# ──────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
desc 'deprecate NAME', 'Mark an extension as deprecated'
|
|
211
|
+
option :successor, type: :string, desc: 'Replacement extension name'
|
|
212
|
+
option :sunset_date, type: :string, desc: 'Sunset date (YYYY-MM-DD)'
|
|
213
|
+
def deprecate(name)
|
|
214
|
+
require 'legion/registry'
|
|
215
|
+
out = formatter
|
|
216
|
+
|
|
217
|
+
sunset = parse_sunset_date(options[:sunset_date])
|
|
218
|
+
Legion::Registry.deprecate(name, successor: options[:successor], sunset_date: sunset)
|
|
219
|
+
|
|
220
|
+
if options[:json]
|
|
221
|
+
out.json(success: true, name: name, status: 'deprecated',
|
|
222
|
+
successor: options[:successor], sunset_date: options[:sunset_date])
|
|
223
|
+
else
|
|
224
|
+
out.success("'#{name}' marked as deprecated")
|
|
225
|
+
detail_hash = {}
|
|
226
|
+
detail_hash['Successor'] = options[:successor] if options[:successor]
|
|
227
|
+
detail_hash['Sunset Date'] = options[:sunset_date] if options[:sunset_date]
|
|
228
|
+
out.detail(detail_hash) unless detail_hash.empty?
|
|
229
|
+
end
|
|
230
|
+
rescue ArgumentError => e
|
|
231
|
+
out.error(e.message)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# ──────────────────────────────────────────────────────────
|
|
235
|
+
# stats
|
|
236
|
+
# ──────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
desc 'stats NAME', 'Show usage statistics for an extension'
|
|
239
|
+
def stats(name)
|
|
240
|
+
require 'legion/registry'
|
|
241
|
+
out = formatter
|
|
242
|
+
data = Legion::Registry.usage_stats(name)
|
|
243
|
+
|
|
244
|
+
unless data
|
|
245
|
+
out.error("Extension '#{name}' not found")
|
|
246
|
+
return
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
if options[:json]
|
|
250
|
+
out.json(data)
|
|
251
|
+
else
|
|
252
|
+
out.header("Usage Stats: #{name}")
|
|
253
|
+
out.spacer
|
|
254
|
+
out.detail({
|
|
255
|
+
'Install Count' => data[:install_count].to_s,
|
|
256
|
+
'Active Instances' => data[:active_instances].to_s,
|
|
257
|
+
'Downloads (7d)' => data[:downloads_7d].to_s,
|
|
258
|
+
'Downloads (30d)' => data[:downloads_30d].to_s,
|
|
259
|
+
'Last Updated' => data[:last_updated].to_s
|
|
260
|
+
})
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
no_commands do
|
|
265
|
+
def formatter
|
|
266
|
+
@formatter ||= Output::Formatter.new(
|
|
267
|
+
json: options[:json],
|
|
268
|
+
color: !options[:no_color]
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def build_extension_list
|
|
273
|
+
if options[:approved]
|
|
274
|
+
Legion::Registry.approved
|
|
275
|
+
elsif options[:tier]
|
|
276
|
+
Legion::Registry.by_risk_tier(options[:tier])
|
|
277
|
+
elsif options[:status]
|
|
278
|
+
Legion::Registry.all.select { |e| e.status.to_s == options[:status] }
|
|
279
|
+
else
|
|
280
|
+
Legion::Registry.all
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def parse_sunset_date(date_str)
|
|
285
|
+
return nil if date_str.nil? || date_str.empty?
|
|
286
|
+
|
|
287
|
+
Date.parse(date_str)
|
|
288
|
+
rescue ArgumentError
|
|
289
|
+
nil
|
|
290
|
+
end
|
|
76
291
|
end
|
|
77
292
|
end
|
|
78
293
|
end
|
data/lib/legion/registry.rb
CHANGED
|
@@ -2,24 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
module Legion
|
|
4
4
|
module Registry
|
|
5
|
+
VALID_STATUSES = %i[pending_review approved rejected deprecated sunset active].freeze
|
|
6
|
+
|
|
5
7
|
class Entry
|
|
6
8
|
ATTRS = %i[name version author risk_tier permissions airb_status
|
|
7
|
-
description homepage checksum capabilities
|
|
9
|
+
description homepage checksum capabilities
|
|
10
|
+
status review_notes reject_reason successor sunset_date
|
|
11
|
+
submitted_at approved_at rejected_at deprecated_at].freeze
|
|
8
12
|
|
|
9
13
|
attr_reader(*ATTRS)
|
|
10
14
|
|
|
11
15
|
def initialize(**attrs)
|
|
12
16
|
ATTRS.each { |a| instance_variable_set(:"@#{a}", attrs[a]) }
|
|
13
|
-
@risk_tier
|
|
17
|
+
@risk_tier ||= 'low'
|
|
14
18
|
@airb_status ||= 'pending'
|
|
15
19
|
@capabilities ||= []
|
|
16
|
-
@permissions
|
|
20
|
+
@permissions ||= []
|
|
21
|
+
@status ||= :active
|
|
17
22
|
end
|
|
18
23
|
|
|
19
24
|
def approved?
|
|
20
25
|
airb_status == 'approved'
|
|
21
26
|
end
|
|
22
27
|
|
|
28
|
+
def deprecated?
|
|
29
|
+
%i[deprecated sunset].include?(status)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def pending_review?
|
|
33
|
+
status == :pending_review
|
|
34
|
+
end
|
|
35
|
+
|
|
23
36
|
def to_h
|
|
24
37
|
ATTRS.to_h { |a| [a, send(a)] }
|
|
25
38
|
end
|
|
@@ -62,11 +75,79 @@ module Legion
|
|
|
62
75
|
@store = {}
|
|
63
76
|
end
|
|
64
77
|
|
|
78
|
+
# Review workflow
|
|
79
|
+
|
|
80
|
+
def submit_for_review(name)
|
|
81
|
+
entry = find_or_raise(name)
|
|
82
|
+
update_entry(name, entry, status: :pending_review, submitted_at: Time.now.utc)
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def approve(name, notes: nil)
|
|
87
|
+
entry = find_or_raise(name)
|
|
88
|
+
update_entry(name, entry,
|
|
89
|
+
status: :approved,
|
|
90
|
+
airb_status: 'approved',
|
|
91
|
+
review_notes: notes,
|
|
92
|
+
approved_at: Time.now.utc)
|
|
93
|
+
true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def reject(name, reason: nil)
|
|
97
|
+
entry = find_or_raise(name)
|
|
98
|
+
update_entry(name, entry,
|
|
99
|
+
status: :rejected,
|
|
100
|
+
reject_reason: reason,
|
|
101
|
+
rejected_at: Time.now.utc)
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def deprecate(name, successor: nil, sunset_date: nil)
|
|
106
|
+
entry = find_or_raise(name)
|
|
107
|
+
update_entry(name, entry,
|
|
108
|
+
status: :deprecated,
|
|
109
|
+
successor: successor,
|
|
110
|
+
sunset_date: sunset_date,
|
|
111
|
+
deprecated_at: Time.now.utc)
|
|
112
|
+
true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def pending_reviews
|
|
116
|
+
store.values.select(&:pending_review?)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def usage_stats(name)
|
|
120
|
+
entry = lookup(name.to_s)
|
|
121
|
+
return nil unless entry
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
name: entry.name,
|
|
125
|
+
version: entry.version,
|
|
126
|
+
install_count: 0,
|
|
127
|
+
active_instances: 0,
|
|
128
|
+
last_updated: nil,
|
|
129
|
+
downloads_7d: 0,
|
|
130
|
+
downloads_30d: 0
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
65
134
|
private
|
|
66
135
|
|
|
67
136
|
def store
|
|
68
137
|
@store ||= {}
|
|
69
138
|
end
|
|
139
|
+
|
|
140
|
+
def find_or_raise(name)
|
|
141
|
+
entry = lookup(name.to_s)
|
|
142
|
+
raise ArgumentError, "Extension '#{name}' not found in registry" unless entry
|
|
143
|
+
|
|
144
|
+
entry
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def update_entry(name, entry, **overrides)
|
|
148
|
+
attrs = entry.to_h.merge(overrides)
|
|
149
|
+
store[name.to_s] = Entry.new(**attrs)
|
|
150
|
+
end
|
|
70
151
|
end
|
|
71
152
|
end
|
|
72
153
|
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.105
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -357,6 +357,7 @@ files:
|
|
|
357
357
|
- lib/legion/api/audit.rb
|
|
358
358
|
- lib/legion/api/auth.rb
|
|
359
359
|
- lib/legion/api/auth_human.rb
|
|
360
|
+
- lib/legion/api/auth_saml.rb
|
|
360
361
|
- lib/legion/api/auth_worker.rb
|
|
361
362
|
- lib/legion/api/capacity.rb
|
|
362
363
|
- lib/legion/api/catalog.rb
|
|
@@ -370,6 +371,7 @@ files:
|
|
|
370
371
|
- lib/legion/api/hooks.rb
|
|
371
372
|
- lib/legion/api/lex.rb
|
|
372
373
|
- lib/legion/api/llm.rb
|
|
374
|
+
- lib/legion/api/marketplace.rb
|
|
373
375
|
- lib/legion/api/metrics.rb
|
|
374
376
|
- lib/legion/api/middleware/api_version.rb
|
|
375
377
|
- lib/legion/api/middleware/auth.rb
|