legion-rbac 0.2.6 → 0.2.8

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: 4520154d312609d4efb9a4ecd822ac6fad916593c96b56c36562fa8d7b820d11
4
- data.tar.gz: 17d138847a7bf5d90fb6fb8a6173ba4dda84fde6e69f8ff5cddf70ca6bfa5034
3
+ metadata.gz: 59a7e721e473fdb1e65fe31d2da68e7fc7fc0a65e2e077f199f62c98aaf78fb5
4
+ data.tar.gz: b59c16e31ab6f63b1f93947a6c47ba59c891c2275a4f73463f892f4c0a71d9a8
5
5
  SHA512:
6
- metadata.gz: 44c8e72ea7dabf96f95772a7bcaec376d24a9af5b0402328e4c5f1c34aa1ad8a0bb8da74d1a635edc8fc6d26c29c66457af2f7f1ab772a59ec21003a1435c612
7
- data.tar.gz: a352e948c5fd391f28980fd12f59f2b5430394ae0074f7dc24d57d1f1377d73c0c99d3952752ff80835b290ab6b2994af3df36afdd43813cdff2035c2e548f27
6
+ metadata.gz: c8431ff82ad752660da99454b4170999572d2c37259fe33605238fc4faa6fdabe3d5c50a29dfc0d40c2fb45225107e457f15ac1e14aa0d8c41f6985cb39aa855
7
+ data.tar.gz: aafee7d9c8e9a3a07974aae8b890a8120dda0a10906a45ec1e967745563db0c2eed90ae39d2e14603d383df900ef450add66484d00cd4254288c573d976a47f2
@@ -0,0 +1,7 @@
1
+ # Auto-generated from team-config.yml
2
+ # Team: core
3
+ #
4
+ # To apply: scripts/apply-codeowners.sh legion-rbac
5
+
6
+ * @LegionIO/maintainers
7
+ * @LegionIO/core
@@ -0,0 +1,18 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: bundler
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ day: monday
8
+ open-pull-requests-limit: 5
9
+ labels:
10
+ - "type:dependencies"
11
+ - package-ecosystem: github-actions
12
+ directory: /
13
+ schedule:
14
+ interval: weekly
15
+ day: monday
16
+ open-pull-requests-limit: 5
17
+ labels:
18
+ - "type:dependencies"
@@ -3,14 +3,32 @@ on:
3
3
  push:
4
4
  branches: [main]
5
5
  pull_request:
6
+ schedule:
7
+ - cron: '0 9 * * 1'
6
8
 
7
9
  jobs:
8
10
  ci:
9
11
  uses: LegionIO/.github/.github/workflows/ci.yml@main
10
12
 
13
+ lint:
14
+ uses: LegionIO/.github/.github/workflows/lint-patterns.yml@main
15
+
16
+ security:
17
+ uses: LegionIO/.github/.github/workflows/security-scan.yml@main
18
+
19
+ version-changelog:
20
+ uses: LegionIO/.github/.github/workflows/version-changelog.yml@main
21
+
22
+ dependency-review:
23
+ uses: LegionIO/.github/.github/workflows/dependency-review.yml@main
24
+
25
+ stale:
26
+ if: github.event_name == 'schedule'
27
+ uses: LegionIO/.github/.github/workflows/stale.yml@main
28
+
11
29
  release:
12
- needs: ci
30
+ needs: [ci, lint]
13
31
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
14
32
  uses: LegionIO/.github/.github/workflows/release.yml@main
15
33
  secrets:
16
- rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }}
34
+ rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.8] - 2026-03-28
4
+
5
+ ### Added
6
+ - `Legion::Rbac::Routes` self-registering Sinatra route module (`lib/legion/rbac/routes.rb`): extracts all `/api/rbac/*` route handlers from LegionIO. Self-registers with `Legion::API.register_library_routes('rbac', Routes)` during boot. Includes fallback helpers for standalone mounting.
7
+
8
+ ## [0.2.7] - 2026-03-22
9
+
10
+ ### Changed
11
+ - Corrected legion-settings version constraint to `>= 1.3.12`
12
+
3
13
  ## [0.2.6] - 2026-03-22
4
14
 
5
15
  ### Changed
data/legion-rbac.gemspec CHANGED
@@ -27,5 +27,5 @@ Gem::Specification.new do |spec|
27
27
  }
28
28
 
29
29
  spec.add_dependency 'legion-json', '>= 1.2.0'
30
- spec.add_dependency 'legion-settings', '>= 1.3.9'
30
+ spec.add_dependency 'legion-settings', '>= 1.3.12'
31
31
  end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Self-registering route module for legion-rbac.
4
+ # All routes previously defined in LegionIO/lib/legion/api/rbac.rb now live here
5
+ # and are mounted via Legion::API.register_library_routes when legion-rbac boots.
6
+ #
7
+ # LegionIO/lib/legion/api/rbac.rb is preserved for backward compatibility but guards
8
+ # its registration with defined?(Legion::Rbac::Routes) so double-registration is avoided.
9
+
10
+ module Legion
11
+ module Rbac
12
+ module Routes
13
+ def self.registered(app)
14
+ app.helpers do # rubocop:disable Metrics/BlockLength
15
+ unless method_defined?(:parse_request_body)
16
+ define_method(:parse_request_body) do
17
+ raw = request.body.read
18
+ return {} if raw.nil? || raw.empty?
19
+
20
+ begin
21
+ parsed = Legion::JSON.load(raw)
22
+ rescue StandardError
23
+ halt 400, { 'Content-Type' => 'application/json' },
24
+ Legion::JSON.dump({ error: { code: 'invalid_json', message: 'request body is not valid JSON' } })
25
+ end
26
+
27
+ unless parsed.respond_to?(:transform_keys)
28
+ halt 400, { 'Content-Type' => 'application/json' },
29
+ Legion::JSON.dump({ error: { code: 'invalid_request_body',
30
+ message: 'request body must be a JSON object' } })
31
+ end
32
+
33
+ parsed.transform_keys(&:to_sym)
34
+ end
35
+ end
36
+
37
+ unless method_defined?(:json_response)
38
+ define_method(:json_response) do |data, status_code: 200|
39
+ content_type :json
40
+ status status_code
41
+ Legion::JSON.dump({ data: data })
42
+ end
43
+ end
44
+
45
+ unless method_defined?(:json_error)
46
+ define_method(:json_error) do |code, message, status_code: 400|
47
+ content_type :json
48
+ status status_code
49
+ Legion::JSON.dump({ error: { code: code, message: message } })
50
+ end
51
+ end
52
+
53
+ unless method_defined?(:json_collection)
54
+ define_method(:json_collection) do |dataset|
55
+ content_type :json
56
+ Legion::JSON.dump({ data: dataset.all.map(&:values) })
57
+ end
58
+ end
59
+
60
+ unless method_defined?(:current_owner_msid)
61
+ define_method(:current_owner_msid) do
62
+ env['legion.owner_msid']
63
+ end
64
+ end
65
+ end
66
+
67
+ register_roles(app)
68
+ register_check(app)
69
+ register_assignments(app)
70
+ register_grants(app)
71
+ register_cross_team_grants(app)
72
+ end
73
+
74
+ def self.register_roles(app)
75
+ app.get '/api/rbac/roles' do
76
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
77
+
78
+ roles = Legion::Rbac.role_index.transform_values do |role|
79
+ { name: role.name, description: role.description, cross_team: role.cross_team? }
80
+ end
81
+ json_response(roles)
82
+ end
83
+
84
+ app.get '/api/rbac/roles/:name' do
85
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
86
+
87
+ role = Legion::Rbac.role_index[params[:name].to_sym]
88
+ halt 404, json_error('not_found', "Role #{params[:name]} not found", status_code: 404) unless role
89
+
90
+ json_response({
91
+ name: role.name,
92
+ description: role.description,
93
+ cross_team: role.cross_team?,
94
+ permissions: role.permissions.map { |p| { resource: p.resource_pattern, actions: p.actions } },
95
+ deny_rules: role.deny_rules.map { |d| { resource: d.resource_pattern, above_level: d.above_level } }
96
+ })
97
+ end
98
+ end
99
+
100
+ def self.register_check(app)
101
+ app.post '/api/rbac/check' do
102
+ Legion::Logging.debug "API: POST /api/rbac/check params=#{params.keys}" if defined?(Legion::Logging)
103
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
104
+
105
+ body = parse_request_body
106
+ principal = Legion::Rbac::Principal.new(
107
+ id: body[:principal] || 'anonymous',
108
+ roles: body[:roles] || [],
109
+ team: body[:team]
110
+ )
111
+ result = Legion::Rbac::PolicyEngine.evaluate(
112
+ principal: principal,
113
+ action: body[:action] || 'read',
114
+ resource: body[:resource] || '*',
115
+ enforce: false
116
+ )
117
+ json_response(result)
118
+ rescue StandardError => e
119
+ Legion::Logging.error "API POST /api/rbac/check: #{e.class} — #{e.message}" if defined?(Legion::Logging)
120
+ json_error('rbac_error', e.message, status_code: 500)
121
+ end
122
+ end
123
+
124
+ def self.register_assignments(app) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
125
+ app.get '/api/rbac/assignments' do
126
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
127
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
128
+
129
+ dataset = Legion::Data::Model::RbacRoleAssignment.order(:id)
130
+ dataset = dataset.where(team: params[:team]) if params[:team]
131
+ dataset = dataset.where(role: params[:role]) if params[:role]
132
+ dataset = dataset.where(principal_id: params[:principal]) if params[:principal]
133
+ json_collection(dataset)
134
+ end
135
+
136
+ app.post '/api/rbac/assignments' do
137
+ Legion::Logging.debug "API: POST /api/rbac/assignments params=#{params.keys}" if defined?(Legion::Logging)
138
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
139
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
140
+
141
+ body = parse_request_body
142
+ record = Legion::Data::Model::RbacRoleAssignment.create(
143
+ principal_type: body[:principal_type] || 'human',
144
+ principal_id: body[:principal_id],
145
+ role: body[:role],
146
+ team: body[:team],
147
+ granted_by: current_owner_msid || 'api',
148
+ expires_at: body[:expires_at] ? Time.parse(body[:expires_at]) : nil
149
+ )
150
+ Legion::Logging.info "API: created RBAC assignment #{record.id} role=#{body[:role]} principal=#{body[:principal_id]}" if defined?(Legion::Logging)
151
+ json_response(record.values, status_code: 201)
152
+ rescue Sequel::ValidationFailed => e
153
+ Legion::Logging.warn "API POST /api/rbac/assignments returned 422: #{e.message}" if defined?(Legion::Logging)
154
+ json_error('validation_error', e.message, status_code: 422)
155
+ end
156
+
157
+ app.delete '/api/rbac/assignments/:id' do
158
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
159
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
160
+
161
+ record = Legion::Data::Model::RbacRoleAssignment[params[:id].to_i]
162
+ halt 404, json_error('not_found', 'Assignment not found', status_code: 404) unless record
163
+
164
+ record.destroy
165
+ Legion::Logging.info "API: deleted RBAC assignment #{params[:id]}" if defined?(Legion::Logging)
166
+ json_response({ deleted: true })
167
+ end
168
+ end
169
+
170
+ def self.register_grants(app)
171
+ app.get '/api/rbac/grants' do
172
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
173
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
174
+
175
+ dataset = Legion::Data::Model::RbacRunnerGrant.order(:id)
176
+ dataset = dataset.where(team: params[:team]) if params[:team]
177
+ json_collection(dataset)
178
+ end
179
+
180
+ app.post '/api/rbac/grants' do
181
+ Legion::Logging.debug "API: POST /api/rbac/grants params=#{params.keys}" if defined?(Legion::Logging)
182
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
183
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
184
+
185
+ body = parse_request_body
186
+ record = Legion::Data::Model::RbacRunnerGrant.create(
187
+ team: body[:team],
188
+ runner_pattern: body[:runner_pattern],
189
+ actions: Array(body[:actions]).join(','),
190
+ granted_by: current_owner_msid || 'api'
191
+ )
192
+ Legion::Logging.info "API: created RBAC grant #{record.id} team=#{body[:team]} pattern=#{body[:runner_pattern]}" if defined?(Legion::Logging)
193
+ json_response(record.values, status_code: 201)
194
+ rescue Sequel::ValidationFailed => e
195
+ Legion::Logging.warn "API POST /api/rbac/grants returned 422: #{e.message}" if defined?(Legion::Logging)
196
+ json_error('validation_error', e.message, status_code: 422)
197
+ end
198
+
199
+ app.delete '/api/rbac/grants/:id' do
200
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
201
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
202
+
203
+ record = Legion::Data::Model::RbacRunnerGrant[params[:id].to_i]
204
+ halt 404, json_error('not_found', 'Grant not found', status_code: 404) unless record
205
+
206
+ record.destroy
207
+ Legion::Logging.info "API: deleted RBAC grant #{params[:id]}" if defined?(Legion::Logging)
208
+ json_response({ deleted: true })
209
+ end
210
+ end
211
+
212
+ def self.register_cross_team_grants(app)
213
+ app.get '/api/rbac/grants/cross-team' do
214
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
215
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
216
+
217
+ dataset = Legion::Data::Model::RbacCrossTeamGrant.order(:id)
218
+ json_collection(dataset)
219
+ end
220
+
221
+ app.post '/api/rbac/grants/cross-team' do
222
+ Legion::Logging.debug "API: POST /api/rbac/grants/cross-team params=#{params.keys}" if defined?(Legion::Logging)
223
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
224
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
225
+
226
+ body = parse_request_body
227
+ record = Legion::Data::Model::RbacCrossTeamGrant.create(
228
+ source_team: body[:source_team],
229
+ target_team: body[:target_team],
230
+ runner_pattern: body[:runner_pattern],
231
+ actions: Array(body[:actions]).join(','),
232
+ granted_by: current_owner_msid || 'api',
233
+ expires_at: body[:expires_at] ? Time.parse(body[:expires_at]) : nil
234
+ )
235
+ Legion::Logging.info "API: created cross-team RBAC grant #{record.id} #{body[:source_team]}->#{body[:target_team]}" if defined?(Legion::Logging)
236
+ json_response(record.values, status_code: 201)
237
+ rescue Sequel::ValidationFailed => e
238
+ Legion::Logging.warn "API POST /api/rbac/grants/cross-team returned 422: #{e.message}" if defined?(Legion::Logging)
239
+ json_error('validation_error', e.message, status_code: 422)
240
+ end
241
+
242
+ app.delete '/api/rbac/grants/cross-team/:id' do
243
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
244
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
245
+
246
+ record = Legion::Data::Model::RbacCrossTeamGrant[params[:id].to_i]
247
+ halt 404, json_error('not_found', 'Grant not found', status_code: 404) unless record
248
+
249
+ record.destroy
250
+ Legion::Logging.info "API: deleted cross-team RBAC grant #{params[:id]}" if defined?(Legion::Logging)
251
+ json_response({ deleted: true })
252
+ end
253
+ end
254
+
255
+ class << self
256
+ private :register_roles, :register_check, :register_assignments, :register_grants, :register_cross_team_grants
257
+ end
258
+ end
259
+ end
260
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Rbac
5
- VERSION = '0.2.6'
5
+ VERSION = '0.2.8'
6
6
  end
7
7
  end
data/lib/legion/rbac.rb CHANGED
@@ -11,6 +11,7 @@ require 'legion/rbac/team_scope'
11
11
  require 'legion/rbac/store'
12
12
  require 'legion/rbac/entra_claims_mapper'
13
13
  require 'legion/rbac/middleware'
14
+ require 'legion/rbac/routes'
14
15
 
15
16
  module Legion
16
17
  module Rbac
@@ -26,10 +27,20 @@ module Legion
26
27
  class << self
27
28
  attr_reader :role_index
28
29
 
30
+ def register_routes
31
+ return unless defined?(Legion::API) && Legion::API.respond_to?(:register_library_routes)
32
+
33
+ Legion::API.register_library_routes('rbac', Legion::Rbac::Routes)
34
+ Legion::Logging.debug 'Legion::Rbac routes registered with API' if defined?(Legion::Logging)
35
+ rescue StandardError => e
36
+ Legion::Logging.warn "Legion::Rbac route registration failed: #{e.message}" if defined?(Legion::Logging)
37
+ end
38
+
29
39
  def setup
30
40
  Legion::Settings.merge_settings(:rbac, Legion::Rbac::Settings.default)
31
41
  @role_index = ConfigLoader.load_roles
32
42
  Legion::Settings[:rbac][:connected] = true
43
+ register_routes
33
44
  end
34
45
 
35
46
  def shutdown
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-rbac
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.6
4
+ version: 0.2.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 1.3.9
32
+ version: 1.3.12
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: 1.3.9
39
+ version: 1.3.12
40
40
  description: Role-based access control for LegionIO with team scoping and policy enforcement
41
41
  email:
42
42
  - matthewdiverson@gmail.com
@@ -47,6 +47,8 @@ extra_rdoc_files:
47
47
  - LICENSE
48
48
  - README.md
49
49
  files:
50
+ - ".github/CODEOWNERS"
51
+ - ".github/dependabot.yml"
50
52
  - ".github/workflows/ci.yml"
51
53
  - ".gitignore"
52
54
  - ".rspec"
@@ -67,6 +69,7 @@ files:
67
69
  - lib/legion/rbac/policy_engine.rb
68
70
  - lib/legion/rbac/principal.rb
69
71
  - lib/legion/rbac/role.rb
72
+ - lib/legion/rbac/routes.rb
70
73
  - lib/legion/rbac/settings.rb
71
74
  - lib/legion/rbac/store.rb
72
75
  - lib/legion/rbac/team_scope.rb