legion-rbac 0.3.2 → 0.3.3

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: 2805fec42a72b9618d9790becf4439f3fba5bbe3af5f60f4fd6ce22b230ca4b6
4
+ data.tar.gz: b4648e10753c56aec63c122f765a00cc545fe6626a3fcd25a344fa829df5e615
5
5
  SHA512:
6
- metadata.gz: e6ef4dce9750b6de9544a49f12df5311f3a312a35ee46c89465f724b5ea0d02822bd9a8c23954665df498985a143913c4012f83401e8b0b12f776fd71e624eb2
7
- data.tar.gz: 97bb68fae5dc33ac682461a12ef626ec8e5e392250ef11391381a9ad3212f587385648dcda982f6bcf2d9fc2bb601cdde9d699b58924da8bf4f7c73950164f54
6
+ metadata.gz: 2f1a104089a800dbfd7fdd6e062f24cef9205aed00c0accd543deee71327705b5f20dbb259f0cf9c579a67df5046b441ecdf1cfa33f84e485105f4b2fb18c4b9
7
+ data.tar.gz: 76c1c1466a6956c8406626c414173476181c2b936675aee21e6b7590afd7eb5021b4718c1db3dc01b1ee27f016039673387b54cfbf44933be7915b1c0e22e0ff
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.3] - 2026-04-08
4
+
5
+ ### Added
6
+ - `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?`
7
+ - `group_role_map: {}` default added to `Settings.default` for Phase 7 configuration surface
8
+
9
+ ### Changed
10
+ - 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
11
+ - Middleware principal resolution now reads `env['legion.rbac_principal']` first, falling back to `env['legion.principal']`, bridging Phase 7 Identity middleware handoff
12
+ - Removed dead private `enforce?` method from middleware; callers now use `Legion::Rbac.enabled?` and `Legion::Rbac.enforcing?` directly to eliminate parallel implementations
13
+
3
14
  ## [0.3.2] - 2026-04-08
4
15
 
5
16
  ### 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)
@@ -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.3'
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.3
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