legion-rbac 0.3.2 → 0.3.4

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: 6971b76fa8052ff121e686629a6abcf800d6819afbffe4fc0adcf146f9b00b1d
4
- data.tar.gz: 97c808593590d1416dc799b3711fa0353d86f55307c16b71cde4ab5e704a093c
3
+ metadata.gz: edda9eb7ccd62c65b56a7a5cb8238553bf71eee38854090d5013a307f14072c7
4
+ data.tar.gz: 51d314a59df53ba27de952d355255829d6b8b3079980ad438094e72c323f1ce2
5
5
  SHA512:
6
- metadata.gz: e6ef4dce9750b6de9544a49f12df5311f3a312a35ee46c89465f724b5ea0d02822bd9a8c23954665df498985a143913c4012f83401e8b0b12f776fd71e624eb2
7
- data.tar.gz: 97bb68fae5dc33ac682461a12ef626ec8e5e392250ef11391381a9ad3212f587385648dcda982f6bcf2d9fc2bb601cdde9d699b58924da8bf4f7c73950164f54
6
+ metadata.gz: 5160cd7e44e30646efe4cbbb7de37598db334adbc9c4c26a55071146df85871743744da9e5ece3c9980a1dfe0ab7069dd968532bb56ded5b914f9b3ceef25300
7
+ data.tar.gz: 69d5006b1aaedbea32bacda5b4a071d79c3eac0090f62e66f095789d12f7310a956f92e9ba8ef3641673b4346a207a0e587650a27091b50d3bca785592a5cebc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.4] - 2026-05-09
4
+
5
+ ### Removed
6
+ - Unnecessary `defined?(Legion::Logging)` guards from route handlers — legion-logging is a hard gemspec dependency and always available
7
+
8
+ ## [0.3.3] - 2026-04-08
9
+
10
+ ### Added
11
+ - `GroupRoleMapper` module: resolves RBAC roles from identity group memberships via exact-string `group_role_map` lookup; `enrich_principal` additive role enrichment; gated behind `Legion::Rbac.enabled?`
12
+ - `group_role_map: {}` default added to `Settings.default` for Phase 7 configuration surface
13
+
14
+ ### Changed
15
+ - RBAC middleware audit mode fix: `enabled=false` now full-bypasses (unchanged); `enabled=true, enforce=false` now runs `PolicyEngine` in dry-run mode and logs `[RBAC audit] would_deny` for any policy-denied request instead of bypassing entirely
16
+ - Middleware principal resolution now reads `env['legion.rbac_principal']` first, falling back to `env['legion.principal']`, bridging Phase 7 Identity middleware handoff
17
+ - Removed dead private `enforce?` method from middleware; callers now use `Legion::Rbac.enabled?` and `Legion::Rbac.enforcing?` directly to eliminate parallel implementations
18
+
3
19
  ## [0.3.2] - 2026-04-08
4
20
 
5
21
  ### Added
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Rbac
5
+ module GroupRoleMapper
6
+ # Resolve RBAC roles from group memberships using a configurable map.
7
+ #
8
+ # @param groups [Array<String>] group names or OIDs from identity provider
9
+ # @param group_role_map [Hash, nil] { group_name => role_name }; reads default_map when nil
10
+ # @return [Array<String>] resolved role names (may be empty)
11
+ #
12
+ # NOTE: v1 supports exact string match only. Regexp keys in group_role_map are NOT supported —
13
+ # JSON settings cannot represent Regexp objects. All map keys are compared via `to_s == to_s`.
14
+ # Pattern matching is deferred to Phase 9.
15
+ def self.resolve_roles(groups:, group_role_map: nil)
16
+ return [] unless Legion::Rbac.enabled?
17
+
18
+ map = group_role_map || default_map
19
+ return [] if groups.nil? || groups.empty? || map.empty?
20
+
21
+ normalized_map = {}
22
+ map.each do |key, role|
23
+ normalized_map[key.to_s] = role.to_s
24
+ end
25
+
26
+ roles = Set.new
27
+ groups.each do |group|
28
+ role = normalized_map[group.to_s]
29
+ roles << role if role
30
+ end
31
+ roles.to_a
32
+ end
33
+
34
+ # Enrich an RBAC principal hash with group-derived roles (additive, never removes).
35
+ #
36
+ # @param principal [Hash] from Identity::Request#to_rbac_principal
37
+ # @param groups [Array<String>] from identity provider
38
+ # @return [Hash] principal with :roles enriched
39
+ def self.enrich_principal(principal:, groups:)
40
+ return principal unless Legion::Rbac.enabled?
41
+
42
+ additional_roles = resolve_roles(groups: groups)
43
+ return principal if additional_roles.empty?
44
+
45
+ existing_roles = principal[:roles] || []
46
+ principal.merge(roles: (existing_roles + additional_roles).uniq)
47
+ end
48
+
49
+ def self.default_map
50
+ return {} unless defined?(Legion::Settings)
51
+
52
+ rbac_settings = Legion::Settings[:rbac]
53
+ rbac_settings&.dig(:group_role_map) || {}
54
+ end
55
+
56
+ private_class_method :default_map
57
+ end
58
+ end
59
+ end
@@ -47,47 +47,19 @@ module Legion
47
47
  end
48
48
 
49
49
  def call(env)
50
- return @app.call(env) unless enforce?
50
+ return @app.call(env) unless Legion::Rbac.enabled?
51
51
 
52
52
  path = env['PATH_INFO']
53
- if skip_path?(path)
54
- log.debug("RBAC middleware bypass path=#{path} reason=skip_path")
55
- return @app.call(env)
56
- end
57
- if invoke_route?(path)
58
- log.debug("RBAC middleware bypass path=#{path} reason=invoke_route")
59
- return @app.call(env)
60
- end
53
+ return bypass(env, path, :skip_path) if skip_path?(path)
54
+ return bypass(env, path, :invoke_route) if invoke_route?(path)
61
55
 
62
- principal = env['legion.principal']
63
- unless principal
64
- log.warn("RBAC middleware denied method=#{env['REQUEST_METHOD']} path=#{path} reason=unauthenticated")
65
- return denied_response('unauthenticated')
66
- end
56
+ principal = env['legion.rbac_principal'] || env['legion.principal']
57
+ return guard_missing(env, path, 'unauthenticated') unless principal
67
58
 
68
59
  perm = find_permission(env['REQUEST_METHOD'], path)
69
- unless perm
70
- log.warn("RBAC middleware denied method=#{env['REQUEST_METHOD']} path=#{path} reason=unmapped_route")
71
- return denied_response('unmapped route')
72
- end
73
- perm = effective_permission(env, perm)
74
- result = policy_result(env, principal, perm)
60
+ return guard_missing(env, path, 'unmapped route') unless perm
75
61
 
76
- if result[:allowed]
77
- log.info(
78
- "RBAC middleware allowed principal=#{principal.id} method=#{env['REQUEST_METHOD']} " \
79
- "path=#{path} resource=#{perm[:resource]} action=#{perm[:action]} " \
80
- "target_team=#{env['legion.rbac.target_team'] || 'none'}"
81
- )
82
- @app.call(env)
83
- else
84
- log.warn(
85
- "RBAC middleware denied principal=#{principal.id} method=#{env['REQUEST_METHOD']} " \
86
- "path=#{path} resource=#{perm[:resource]} action=#{perm[:action]} " \
87
- "target_team=#{env['legion.rbac.target_team'] || 'none'} reason=#{result[:reason]}"
88
- )
89
- denied_response(result[:reason])
90
- end
62
+ dispatch_policy(env, principal, effective_permission(env, perm))
91
63
  rescue StandardError => e
92
64
  handle_exception(
93
65
  e,
@@ -128,14 +100,52 @@ module Legion
128
100
  nil
129
101
  end
130
102
 
131
- def enforce?
132
- return false unless defined?(Legion::Settings)
133
- return false if Legion::Settings[:rbac]&.fetch(:enabled, true) == false
103
+ def bypass(env, path, reason)
104
+ log.debug("RBAC middleware bypass path=#{path} reason=#{reason}")
105
+ @app.call(env)
106
+ end
134
107
 
135
- Legion::Settings[:rbac][:enforce]
136
- rescue StandardError => e
137
- handle_exception(e, level: :warn, operation: 'rbac.middleware.enforce')
138
- true
108
+ def guard_missing(env, path, reason)
109
+ if Legion::Rbac.enforcing?
110
+ log.warn("RBAC middleware denied method=#{env['REQUEST_METHOD']} path=#{path} reason=#{reason.tr(' ', '_')}")
111
+ denied_response(reason)
112
+ else
113
+ audit_and_proceed(env, reason)
114
+ end
115
+ end
116
+
117
+ def dispatch_policy(env, principal, perm)
118
+ result = policy_result(env, principal, perm)
119
+ path = env['PATH_INFO']
120
+
121
+ if result[:would_deny]
122
+ log.info(
123
+ "[RBAC audit] would_deny: #{result[:reason]} principal=#{result[:principal_id]} " \
124
+ "action=#{result[:action]} resource=#{result[:resource]}"
125
+ )
126
+ @app.call(env)
127
+ elsif result[:allowed]
128
+ log.info(
129
+ "RBAC middleware allowed principal=#{principal.id} method=#{env['REQUEST_METHOD']} " \
130
+ "path=#{path} resource=#{perm[:resource]} action=#{perm[:action]} " \
131
+ "target_team=#{env['legion.rbac.target_team'] || 'none'}"
132
+ )
133
+ @app.call(env)
134
+ else
135
+ log.warn(
136
+ "RBAC middleware denied principal=#{principal.id} method=#{env['REQUEST_METHOD']} " \
137
+ "path=#{path} resource=#{perm[:resource]} action=#{perm[:action]} " \
138
+ "target_team=#{env['legion.rbac.target_team'] || 'none'} reason=#{result[:reason]}"
139
+ )
140
+ denied_response(result[:reason])
141
+ end
142
+ end
143
+
144
+ def audit_and_proceed(env, reason)
145
+ log.info(
146
+ "[RBAC audit] would_deny: #{reason} method=#{env['REQUEST_METHOD']} path=#{env['PATH_INFO']}"
147
+ )
148
+ @app.call(env)
139
149
  end
140
150
 
141
151
  def denied_response(reason)
@@ -146,7 +146,7 @@ module Legion
146
146
 
147
147
  def self.register_check(app)
148
148
  app.post '/api/rbac/check' do
149
- Legion::Logging.debug "API: POST /api/rbac/check params=#{params.keys}" if defined?(Legion::Logging)
149
+ Legion::Logging.debug "API: POST /api/rbac/check params=#{params.keys}"
150
150
  return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
151
151
 
152
152
  body = parse_request_body
@@ -163,12 +163,12 @@ module Legion
163
163
  )
164
164
  json_response(result)
165
165
  rescue StandardError => e
166
- Legion::Logging.error "API POST /api/rbac/check: #{e.class} — #{e.message}" if defined?(Legion::Logging)
166
+ Legion::Logging.error "API POST /api/rbac/check: #{e.class} — #{e.message}"
167
167
  json_error('rbac_error', e.message, status_code: 500)
168
168
  end
169
169
  end
170
170
 
171
- def self.register_assignments(app) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
171
+ def self.register_assignments(app) # rubocop:disable Metrics/AbcSize
172
172
  app.get '/api/rbac/assignments' do
173
173
  return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
174
174
  return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
@@ -181,7 +181,7 @@ module Legion
181
181
  end
182
182
 
183
183
  app.post '/api/rbac/assignments' do
184
- Legion::Logging.debug "API: POST /api/rbac/assignments params=#{params.keys}" if defined?(Legion::Logging)
184
+ Legion::Logging.debug "API: POST /api/rbac/assignments params=#{params.keys}"
185
185
  return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
186
186
  return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
187
187
 
@@ -194,13 +194,13 @@ module Legion
194
194
  granted_by: current_owner_msid || 'api',
195
195
  expires_at: parse_optional_time(body[:expires_at], field: 'expires_at')
196
196
  )
197
- Legion::Logging.info "API: created RBAC assignment #{record.id} role=#{body[:role]} principal=#{body[:principal_id]}" if defined?(Legion::Logging)
197
+ Legion::Logging.info "API: created RBAC assignment #{record.id} role=#{body[:role]} principal=#{body[:principal_id]}"
198
198
  emit_rbac_policy_changed('assignment.created', 'role_assignment', record.values)
199
199
  json_response(record.values, status_code: 201)
200
200
  rescue Legion::Rbac::Routes::InvalidTimestamp => e
201
201
  json_error('validation_error', e.message, status_code: 422)
202
202
  rescue Sequel::ValidationFailed => e
203
- Legion::Logging.warn "API POST /api/rbac/assignments returned 422: #{e.message}" if defined?(Legion::Logging)
203
+ Legion::Logging.warn "API POST /api/rbac/assignments returned 422: #{e.message}"
204
204
  json_error('validation_error', e.message, status_code: 422)
205
205
  end
206
206
 
@@ -213,7 +213,7 @@ module Legion
213
213
 
214
214
  snapshot = record.values.dup
215
215
  record.destroy
216
- Legion::Logging.info "API: deleted RBAC assignment #{params[:id]}" if defined?(Legion::Logging)
216
+ Legion::Logging.info "API: deleted RBAC assignment #{params[:id]}"
217
217
  emit_rbac_policy_changed('assignment.deleted', 'role_assignment', snapshot)
218
218
  json_response({ deleted: true })
219
219
  end
@@ -230,7 +230,7 @@ module Legion
230
230
  end
231
231
 
232
232
  app.post '/api/rbac/grants' do
233
- Legion::Logging.debug "API: POST /api/rbac/grants params=#{params.keys}" if defined?(Legion::Logging)
233
+ Legion::Logging.debug "API: POST /api/rbac/grants params=#{params.keys}"
234
234
  return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
235
235
  return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
236
236
 
@@ -241,11 +241,11 @@ module Legion
241
241
  actions: Array(body[:actions]).join(','),
242
242
  granted_by: current_owner_msid || 'api'
243
243
  )
244
- Legion::Logging.info "API: created RBAC grant #{record.id} team=#{body[:team]} pattern=#{body[:runner_pattern]}" if defined?(Legion::Logging)
244
+ Legion::Logging.info "API: created RBAC grant #{record.id} team=#{body[:team]} pattern=#{body[:runner_pattern]}"
245
245
  emit_rbac_policy_changed('runner_grant.created', 'runner_grant', record.values)
246
246
  json_response(record.values, status_code: 201)
247
247
  rescue Sequel::ValidationFailed => e
248
- Legion::Logging.warn "API POST /api/rbac/grants returned 422: #{e.message}" if defined?(Legion::Logging)
248
+ Legion::Logging.warn "API POST /api/rbac/grants returned 422: #{e.message}"
249
249
  json_error('validation_error', e.message, status_code: 422)
250
250
  end
251
251
 
@@ -258,7 +258,7 @@ module Legion
258
258
 
259
259
  snapshot = record.values.dup
260
260
  record.destroy
261
- Legion::Logging.info "API: deleted RBAC grant #{params[:id]}" if defined?(Legion::Logging)
261
+ Legion::Logging.info "API: deleted RBAC grant #{params[:id]}"
262
262
  emit_rbac_policy_changed('runner_grant.deleted', 'runner_grant', snapshot)
263
263
  json_response({ deleted: true })
264
264
  end
@@ -274,7 +274,7 @@ module Legion
274
274
  end
275
275
 
276
276
  app.post '/api/rbac/grants/cross-team' do
277
- Legion::Logging.debug "API: POST /api/rbac/grants/cross-team params=#{params.keys}" if defined?(Legion::Logging)
277
+ Legion::Logging.debug "API: POST /api/rbac/grants/cross-team params=#{params.keys}"
278
278
  return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
279
279
  return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
280
280
 
@@ -287,13 +287,13 @@ module Legion
287
287
  granted_by: current_owner_msid || 'api',
288
288
  expires_at: parse_optional_time(body[:expires_at], field: 'expires_at')
289
289
  )
290
- Legion::Logging.info "API: created cross-team RBAC grant #{record.id} #{body[:source_team]}->#{body[:target_team]}" if defined?(Legion::Logging)
290
+ Legion::Logging.info "API: created cross-team RBAC grant #{record.id} #{body[:source_team]}->#{body[:target_team]}"
291
291
  emit_rbac_policy_changed('cross_team_grant.created', 'cross_team_grant', record.values)
292
292
  json_response(record.values, status_code: 201)
293
293
  rescue Legion::Rbac::Routes::InvalidTimestamp => e
294
294
  json_error('validation_error', e.message, status_code: 422)
295
295
  rescue Sequel::ValidationFailed => e
296
- Legion::Logging.warn "API POST /api/rbac/grants/cross-team returned 422: #{e.message}" if defined?(Legion::Logging)
296
+ Legion::Logging.warn "API POST /api/rbac/grants/cross-team returned 422: #{e.message}"
297
297
  json_error('validation_error', e.message, status_code: 422)
298
298
  end
299
299
 
@@ -306,7 +306,7 @@ module Legion
306
306
 
307
307
  snapshot = record.values.dup
308
308
  record.destroy
309
- Legion::Logging.info "API: deleted cross-team RBAC grant #{params[:id]}" if defined?(Legion::Logging)
309
+ Legion::Logging.info "API: deleted cross-team RBAC grant #{params[:id]}"
310
310
  emit_rbac_policy_changed('cross_team_grant.deleted', 'cross_team_grant', snapshot)
311
311
  json_response({ deleted: true })
312
312
  end
@@ -390,8 +390,6 @@ module Legion
390
390
  )
391
391
  )
392
392
  rescue StandardError => e
393
- return unless defined?(Legion::Logging)
394
-
395
393
  Legion::Logging.warn("API policy change event failed type=#{target_type} change=#{change_type} error=#{e.class}: #{e.message}")
396
394
  end
397
395
 
@@ -18,6 +18,7 @@ module Legion
18
18
  default_local_role: 'admin',
19
19
  static_assignments: [],
20
20
  route_permissions: {},
21
+ group_role_map: {},
21
22
  roles: default_roles,
22
23
  entra: entra_defaults,
23
24
  capability_audit: capability_audit_defaults
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Rbac
5
- VERSION = '0.3.2'
5
+ VERSION = '0.3.4'
6
6
  end
7
7
  end
data/lib/legion/rbac.rb CHANGED
@@ -13,6 +13,7 @@ require 'legion/rbac/team_scope'
13
13
  require 'legion/rbac/store'
14
14
  require 'legion/rbac/kerberos_claims_mapper'
15
15
  require 'legion/rbac/entra_claims_mapper'
16
+ require 'legion/rbac/group_role_mapper'
16
17
  require 'legion/rbac/middleware'
17
18
  require 'legion/rbac/routes'
18
19
  require 'legion/rbac/capability_audit'
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.3.2
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -79,6 +79,7 @@ files:
79
79
  - lib/legion/rbac/capability_registry.rb
80
80
  - lib/legion/rbac/config_loader.rb
81
81
  - lib/legion/rbac/entra_claims_mapper.rb
82
+ - lib/legion/rbac/group_role_mapper.rb
82
83
  - lib/legion/rbac/kerberos_claims_mapper.rb
83
84
  - lib/legion/rbac/middleware.rb
84
85
  - lib/legion/rbac/permission.rb