legion-rbac 0.2.9 → 0.3.1

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: db6027eb10e80db5c7fce237a53e1640036d4d30e73e2e468029054843df5c4f
4
- data.tar.gz: dd520f3dc81753b13bb29bf41bd664bbb0acdd789697a50649f93f77c68d3655
3
+ metadata.gz: 1e31de28dec8d6ed48595581dae626ad860e9f7f3b5bf7bd40086d66edeca25a
4
+ data.tar.gz: ef15533a37dafea6e3783761405ea446d57cdfa55aca62816770e913aa0823e1
5
5
  SHA512:
6
- metadata.gz: e56a6e0e93987b829f04ca89c907753d893ec9d6907d7cf8f29d011b92b82906b249abac00fe3951142589b6ba8cbd8308fe0fd57b9d5974c708e00b744585f3
7
- data.tar.gz: 5d47a2e7805ae8fcd242c1a7bcf2f916063adf8517831ec06b198f77c145b4734eaa9b36d4ce8450eebe17d5ff47c852abd8288ae9da9f6e115780f9bb152f45
6
+ metadata.gz: 61dc20722e2fb34563fb06ec2f73b8db7c43e96450ff8ed7052ef3316827418b8896185a3d52ca6843ca620055ebae444090f4fb075f5740bbe521c30c900f86
7
+ data.tar.gz: 1154d70f8bbb2cee9e7b65414606d3bd5808b3f1822d40e02f1c5ca7b0896a6edd09e27534c7ab749ffaa0e3642ed99a08c00f19f169192d2803f5d7c9cf0822
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1] - 2026-04-03
4
+
5
+ ### Fixed
6
+ - `authorize!` and `authorize_execution!` now early-return when `rbac.enabled: false`, preventing NameError on missing RBAC models
7
+ - `authorize!` and `authorize_execution!` respect `rbac.enforce: false` — logs denials but does not raise AccessDenied
8
+ - `Store.db_available?` now also checks that `RbacRoleAssignment` model constant is defined before attempting DB queries
9
+
10
+ ## [0.3.0] - 2026-04-02
11
+
12
+ ### Changed
13
+ - Uplifted non-Sinatra RBAC library code to `Legion::Logging::Helper` with structured `log.*` usage instead of direct `Legion::Logging.*` calls.
14
+ - Added structured exception handling via `handle_exception` across the RBAC library surface and expanded operational `info`/`debug` logging for setup, authorization, store access, claims mapping, middleware, and capability audit flows.
15
+ - Promoted `legion-logging >= 1.5.0` to a runtime gem dependency and added coverage for the new logging rescue paths.
16
+ - Explicitly load full `legion/logging` from RBAC library files so `require 'legion/rbac'` boots cleanly without preloading logging elsewhere.
17
+ - Exposed `KerberosClaimsMapper` from the gem entrypoint and preserved caller-supplied fallback defaults/profile attributes when Kerberos fallback delegates to Entra.
18
+ - Made `rbac.enabled` disable RBAC setup/enforcement paths consistently and normalized malformed `expires_at` inputs into validation errors with explicit time parsing.
19
+ - Expanded middleware route coverage to include `/api/rbac/*`, honored `rbac.route_permissions` overrides, and compiled route matchers once per permission table.
20
+ - Wired static and DB-backed role assignments into policy evaluation, enforced `target_team` scope in the core evaluator, and made execution authorization intersect role policy with runner grants and cross-team grants.
21
+ - Hardened the smaller runtime edges: static assignment lookups now respect `principal_type`, capability registry reads return copies instead of live internals, capability denials render useful messages, resource regexes compile once, and RBAC collection routes use bounded `limit`/`offset` windows.
22
+ - Synchronized RBAC role index lifecycle state so setup/shutdown expose a stable frozen empty index instead of `nil`, avoiding transitional reads during authorization and route access.
23
+
3
24
  ## [0.2.9] - 2026-03-31
4
25
 
5
26
  ### Added
data/legion-rbac.gemspec CHANGED
@@ -27,5 +27,6 @@ Gem::Specification.new do |spec|
27
27
  }
28
28
 
29
29
  spec.add_dependency 'legion-json', '>= 1.2.0'
30
+ spec.add_dependency 'legion-logging', '>= 1.5.0'
30
31
  spec.add_dependency 'legion-settings', '>= 1.3.12'
31
32
  end
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/logging'
4
+
3
5
  module Legion
4
6
  module Rbac
5
7
  module CapabilityAudit
8
+ extend Legion::Logging::Helper
9
+
6
10
  PATTERN_TO_CAPABILITY = {
7
11
  /\bKernel\.system\b|\bsystem\s*\(/ => :shell_execute,
8
12
  /\bKernel\.exec\b|\bexec\s*\(/ => :shell_execute,
@@ -49,24 +53,50 @@ module Legion
49
53
 
50
54
  class << self
51
55
  def audit(extension_name:, source_path:, declared_capabilities: [])
52
- return skip_result(extension_name, 'capability audit disabled') unless enabled?
56
+ log.info(
57
+ "RBAC capability_audit start extension=#{extension_name} source_path=#{source_path} " \
58
+ "declared=#{Array(declared_capabilities).size}"
59
+ )
60
+ unless enabled?
61
+ result = skip_result(extension_name, 'capability audit disabled')
62
+ log.info("RBAC capability_audit skipped extension=#{extension_name} reason=#{result.reason}")
63
+ return result
64
+ end
53
65
 
54
- return skip_result(extension_name, 'no source path') unless source_path && Dir.exist?(source_path.to_s)
66
+ unless source_path && Dir.exist?(source_path.to_s)
67
+ result = skip_result(extension_name, 'no source path')
68
+ log.info("RBAC capability_audit skipped extension=#{extension_name} reason=#{result.reason}")
69
+ return result
70
+ end
55
71
 
56
72
  detected = scan_source(source_path)
57
73
  declared_syms = Array(declared_capabilities).map(&:to_sym)
58
74
  undeclared = (detected.uniq - declared_syms)
59
75
 
60
- if undeclared.empty?
61
- AuditResult.new(
62
- extension_name: extension_name,
63
- detected: detected,
64
- declared: declared_syms,
65
- allowed: true
66
- )
67
- else
68
- handle_undeclared(extension_name, detected, declared_syms, undeclared)
69
- end
76
+ result = if undeclared.empty?
77
+ AuditResult.new(
78
+ extension_name: extension_name,
79
+ detected: detected,
80
+ declared: declared_syms,
81
+ allowed: true
82
+ )
83
+ else
84
+ handle_undeclared(extension_name, detected, declared_syms, undeclared)
85
+ end
86
+ log.info(
87
+ "RBAC capability_audit extension=#{extension_name} allowed=#{result.allowed} " \
88
+ "detected=#{result.detected_capabilities.size} undeclared=#{result.undeclared.size}"
89
+ )
90
+ result
91
+ rescue StandardError => e
92
+ handle_exception(
93
+ e,
94
+ level: :error,
95
+ operation: 'rbac.capability_audit.audit',
96
+ extension_name: extension_name,
97
+ source_path: source_path
98
+ )
99
+ raise
70
100
  end
71
101
 
72
102
  def enabled?
@@ -83,13 +113,15 @@ module Legion
83
113
 
84
114
  def scan_source(source_path)
85
115
  capabilities = []
86
- Dir.glob(File.join(source_path, '**', '*.rb')).each do |file|
116
+ files = Dir.glob(File.join(source_path, '**', '*.rb'))
117
+ files.each do |file|
87
118
  File.foreach(file) do |line|
88
119
  PATTERN_TO_CAPABILITY.each do |pattern, capability|
89
120
  capabilities << capability if line.match?(pattern)
90
121
  end
91
122
  end
92
123
  end
124
+ log.debug("RBAC capability_audit scanned source_path=#{source_path} files=#{files.size}")
93
125
  capabilities.uniq
94
126
  end
95
127
 
@@ -104,6 +136,7 @@ module Legion
104
136
  reason: "undeclared capabilities (warn mode): #{undeclared.join(', ')}"
105
137
  )
106
138
  else
139
+ log.warn("CapabilityAudit: #{extension_name} blocked for undeclared capabilities: #{undeclared.join(', ')}")
107
140
  AuditResult.new(
108
141
  extension_name: extension_name,
109
142
  detected: detected,
@@ -115,14 +148,11 @@ module Legion
115
148
  end
116
149
 
117
150
  def log_warning(extension_name, undeclared)
118
- return unless defined?(Legion::Logging)
119
-
120
- Legion::Logging.warn(
121
- "CapabilityAudit: #{extension_name} uses undeclared capabilities: #{undeclared.join(', ')}"
122
- )
151
+ log.warn("CapabilityAudit: #{extension_name} uses undeclared capabilities: #{undeclared.join(', ')}")
123
152
  end
124
153
 
125
154
  def skip_result(extension_name, reason)
155
+ log.debug("RBAC capability_audit skip_result extension=#{extension_name} reason=#{reason}")
126
156
  AuditResult.new(
127
157
  extension_name: extension_name,
128
158
  detected: [],
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/logging'
3
4
  require 'monitor'
4
5
 
5
6
  module Legion
6
7
  module Rbac
7
8
  module CapabilityRegistry
8
9
  class << self
10
+ include Legion::Logging::Helper
11
+
9
12
  def register(extension_name, capabilities:, audit_result: nil)
10
13
  mon.synchronize do
11
14
  entries[extension_name.to_s] = {
@@ -14,39 +17,59 @@ module Legion
14
17
  registered_at: Time.now
15
18
  }
16
19
  end
20
+ log.info("RBAC capability_registry register extension=#{extension_name} count=#{Array(capabilities).uniq.size}")
17
21
  end
18
22
 
19
23
  def for_extension(extension_name)
20
- mon.synchronize do
24
+ capabilities = mon.synchronize do
21
25
  entry = entries[extension_name.to_s]
22
- entry ? entry[:capabilities] : []
26
+ entry ? entry[:capabilities].dup : []
23
27
  end
28
+ log.debug("RBAC capability_registry for_extension extension=#{extension_name} count=#{capabilities.size}")
29
+ capabilities
24
30
  end
25
31
 
26
32
  def extensions_with(capability)
27
33
  cap_sym = capability.to_sym
28
- mon.synchronize do
34
+ extensions = mon.synchronize do
29
35
  entries.select { |_, entry| entry[:capabilities].include?(cap_sym) }.keys
30
36
  end
37
+ log.debug("RBAC capability_registry extensions_with capability=#{capability} count=#{extensions.size}")
38
+ extensions
31
39
  end
32
40
 
33
41
  def audit_result_for(extension_name)
34
- mon.synchronize do
42
+ audit_result = mon.synchronize do
35
43
  entry = entries[extension_name.to_s]
36
44
  entry&.dig(:audit_result)
37
45
  end
46
+ log.debug("RBAC capability_registry audit_result_for extension=#{extension_name} present=#{!audit_result.nil?}")
47
+ audit_result
38
48
  end
39
49
 
40
50
  def all
41
- mon.synchronize { entries.dup }
51
+ registry = mon.synchronize do
52
+ entries.each_with_object({}) do |(extension_name, entry), copy|
53
+ copy[extension_name] = {
54
+ capabilities: entry[:capabilities].dup,
55
+ audit_result: entry[:audit_result],
56
+ registered_at: entry[:registered_at]
57
+ }
58
+ end
59
+ end
60
+ log.debug("RBAC capability_registry all count=#{registry.size}")
61
+ registry
42
62
  end
43
63
 
44
64
  def registered?(extension_name)
45
- mon.synchronize { entries.key?(extension_name.to_s) }
65
+ registered = mon.synchronize { entries.key?(extension_name.to_s) }
66
+ log.debug("RBAC capability_registry registered extension=#{extension_name} value=#{registered}")
67
+ registered
46
68
  end
47
69
 
48
70
  def clear!
49
71
  mon.synchronize { @entries = {} }
72
+ log.info('RBAC capability_registry cleared')
50
73
  end
51
74
 
52
75
  private
@@ -1,13 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/logging'
3
4
  require 'legion/rbac/role'
4
5
 
5
6
  module Legion
6
7
  module Rbac
7
8
  module ConfigLoader
9
+ extend Legion::Logging::Helper
10
+
8
11
  def self.load_roles(roles_config = nil)
9
12
  roles_config ||= Legion::Settings[:rbac][:roles]
10
- roles_config.each_with_object({}) do |(name, config), index|
13
+ roles = roles_config.each_with_object({}) do |(name, config), index|
11
14
  index[name.to_sym] = Role.new(
12
15
  name: name,
13
16
  description: config[:description] || '',
@@ -17,7 +20,13 @@ module Legion
17
20
  capability_grants: config[:capability_grants] || [],
18
21
  capability_denials: config[:capability_denials] || []
19
22
  )
23
+ log.debug("RBAC role loaded name=#{name} permissions=#{config[:permissions]&.size || 0} deny=#{config[:deny]&.size || 0}")
20
24
  end
25
+ log.info("RBAC roles loaded count=#{roles.size}")
26
+ roles
27
+ rescue StandardError => e
28
+ handle_exception(e, level: :error, operation: 'rbac.config_loader.load_roles')
29
+ raise
21
30
  end
22
31
  end
23
32
  end
@@ -1,41 +1,79 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/logging'
4
+
3
5
  module Legion
4
6
  module Rbac
5
7
  module EntraClaimsMapper
8
+ extend Legion::Logging::Helper
9
+
6
10
  DEFAULT_ROLE_MAP = {
7
11
  'Legion.Admin' => 'admin',
8
12
  'Legion.Supervisor' => 'supervisor',
9
13
  'Legion.Worker' => 'worker',
10
14
  'Legion.Observer' => 'governance-observer'
11
15
  }.freeze
16
+ DEFAULT_TEAM_KEYS = %i[legion_team extension_legion_team tid].freeze
12
17
 
13
18
  module_function
14
19
 
15
- def map_claims(entra_claims, role_map: DEFAULT_ROLE_MAP, group_map: {}, default_role: 'worker')
20
+ def map_claims(entra_claims, role_map: DEFAULT_ROLE_MAP, group_map: {}, default_role: 'worker',
21
+ team_keys: DEFAULT_TEAM_KEYS, team_map: nil)
22
+ roles = resolve_roles(entra_claims, role_map: role_map, group_map: group_map)
23
+ used_default_role = roles.empty?
24
+ roles << default_role if used_default_role
25
+ team = resolve_team(entra_claims, team_keys: team_keys, team_map: team_map)
26
+
27
+ claims = {
28
+ sub: claim_value(entra_claims, :oid, :sub),
29
+ name: claim_value(entra_claims, :name, :preferred_username),
30
+ roles: roles.to_a,
31
+ team: team,
32
+ scope: 'human'
33
+ }
34
+ log.info(
35
+ "RBAC entra_claims map sub=#{claims[:sub]} roles=#{claims[:roles].size} " \
36
+ "team=#{claims[:team]} default_role=#{used_default_role}"
37
+ )
38
+ claims
39
+ rescue StandardError => e
40
+ handle_exception(e, level: :error, operation: 'rbac.entra_claims_mapper.map_claims')
41
+ raise
42
+ end
43
+
44
+ def resolve_roles(entra_claims, role_map:, group_map:)
16
45
  roles = Set.new
17
46
 
18
- Array(entra_claims[:roles] || entra_claims['roles']).each do |entra_role|
47
+ Array(claim_value(entra_claims, :roles)).each do |entra_role|
19
48
  legion_role = role_map[entra_role]
20
49
  roles << legion_role if legion_role
21
50
  end
22
51
 
23
- Array(entra_claims[:groups] || entra_claims['groups']).each do |group_oid|
52
+ Array(claim_value(entra_claims, :groups)).each do |group_oid|
24
53
  legion_role = group_map[group_oid]
25
54
  roles << legion_role if legion_role
26
55
  end
27
56
 
28
- roles << default_role if roles.empty?
57
+ roles
58
+ end
59
+
60
+ def resolve_team(entra_claims, team_keys:, team_map:)
61
+ raw_team = claim_value(entra_claims, *Array(team_keys))
62
+ return raw_team if raw_team.nil? || team_map.nil? || team_map.empty?
29
63
 
30
- {
31
- sub: entra_claims[:oid] || entra_claims[:sub] || entra_claims['oid'] || entra_claims['sub'],
32
- name: entra_claims[:name] || entra_claims[:preferred_username] ||
33
- entra_claims['name'] || entra_claims['preferred_username'],
34
- roles: roles.to_a,
35
- team: entra_claims[:tid] || entra_claims['tid'],
36
- scope: 'human'
37
- }
64
+ team_map[raw_team] || team_map[raw_team.to_s] || team_map[raw_team.to_sym]
38
65
  end
66
+
67
+ def claim_value(claims, *keys)
68
+ keys.each do |key|
69
+ value = claims[key] || claims[key.to_s]
70
+ return value unless value.nil?
71
+ end
72
+
73
+ nil
74
+ end
75
+
76
+ private :resolve_roles, :resolve_team, :claim_value
39
77
  end
40
78
  end
41
79
  end
@@ -1,43 +1,141 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/logging'
4
+
3
5
  module Legion
4
6
  module Rbac
5
7
  module KerberosClaimsMapper
8
+ extend Legion::Logging::Helper
9
+
6
10
  DEFAULT_ROLE = 'worker'
11
+ DEFAULT_TEAM_KEYS = %i[team legion_team].freeze
7
12
 
8
13
  module_function
9
14
 
10
- def map(principal:, groups:, role_map: {}, default_role: DEFAULT_ROLE, **profile)
15
+ def map(principal:, groups:, role_map: {}, default_role: DEFAULT_ROLE, team_keys: DEFAULT_TEAM_KEYS,
16
+ team_map: nil, **profile)
11
17
  parts = principal.split('@', 2)
12
18
  username = parts.first
13
19
  realm = parts.length > 1 ? parts.last : nil
14
20
  roles = Array(groups).filter_map { |g| role_map[g] }.uniq
15
- roles = [default_role] if roles.empty?
21
+ used_default_role = roles.empty?
22
+ roles = [default_role] if used_default_role
23
+ team = resolve_team(profile, team_keys: team_keys, team_map: team_map)
16
24
 
17
- {
25
+ claims = {
18
26
  sub: username,
19
27
  samaccountname: username,
20
28
  ad_fqdn: realm&.downcase,
21
29
  roles: roles,
22
30
  scope: 'human',
23
31
  auth_method: 'kerberos',
24
- **profile
32
+ **profile,
33
+ team: team
25
34
  }.compact
35
+ log.info(
36
+ "RBAC kerberos_claims map principal=#{username} roles=#{claims[:roles].size} " \
37
+ "default_role=#{used_default_role} realm=#{claims[:ad_fqdn]} team=#{claims[:team] || 'none'}"
38
+ )
39
+ claims
40
+ rescue StandardError => e
41
+ handle_exception(e, level: :error, operation: 'rbac.kerberos_claims_mapper.map', principal: principal)
42
+ raise
26
43
  end
27
44
 
28
45
  def map_with_fallback(principal:, groups: nil, fallback: :entra, role_map: {},
29
46
  default_role: DEFAULT_ROLE, **profile)
47
+ profile, team_resolution = extract_team_resolution(profile)
30
48
  if groups&.any?
31
- map(principal: principal, groups: groups, role_map: role_map, default_role: default_role, **profile)
49
+ claims = mapped_claims(
50
+ principal: principal,
51
+ groups: groups,
52
+ role_map: role_map,
53
+ default_role: default_role,
54
+ team_resolution: team_resolution,
55
+ profile: profile
56
+ )
57
+ path = 'groups'
32
58
  elsif fallback == :entra && defined?(Legion::Rbac::EntraClaimsMapper)
33
- entra_claims = { sub: principal, preferred_username: principal }
34
- result = EntraClaimsMapper.map_claims(entra_claims)
35
- result&.merge(auth_method: 'kerberos') || map(principal: principal, groups: [],
36
- role_map: role_map, default_role: default_role)
59
+ claims = entra_fallback_claims(
60
+ principal: principal,
61
+ role_map: role_map,
62
+ default_role: default_role,
63
+ team_resolution: team_resolution,
64
+ profile: profile
65
+ )
66
+ path = 'entra'
37
67
  else
38
- map(principal: principal, groups: [], role_map: role_map, default_role: default_role, **profile)
68
+ claims = mapped_claims(
69
+ principal: principal,
70
+ groups: [],
71
+ role_map: role_map,
72
+ default_role: default_role,
73
+ team_resolution: team_resolution,
74
+ profile: profile
75
+ )
76
+ path = 'default_role'
39
77
  end
78
+ log.info("RBAC kerberos_claims fallback principal=#{principal} path=#{path}")
79
+ claims
80
+ rescue StandardError => e
81
+ handle_exception(
82
+ e,
83
+ level: :error,
84
+ operation: 'rbac.kerberos_claims_mapper.map_with_fallback',
85
+ principal: principal,
86
+ fallback: fallback
87
+ )
88
+ raise
89
+ end
90
+
91
+ def resolve_team(profile, team_keys:, team_map:)
92
+ Array(team_keys).each do |key|
93
+ value = profile[key] || profile[key.to_s]
94
+ return value if value && (team_map.nil? || team_map.empty?)
95
+ return team_map[value] || team_map[value.to_s] || team_map[value.to_sym] if value
96
+ end
97
+ nil
98
+ end
99
+
100
+ def extract_team_resolution(profile)
101
+ sanitized_profile = profile.dup
102
+ team_resolution = {
103
+ team_keys: sanitized_profile.delete(:team_keys) || DEFAULT_TEAM_KEYS,
104
+ team_map: sanitized_profile.delete(:team_map)
105
+ }
106
+ [sanitized_profile, team_resolution]
107
+ end
108
+
109
+ def mapped_claims(principal:, groups:, role_map:, default_role:, team_resolution:, profile:)
110
+ map(
111
+ principal: principal,
112
+ groups: groups,
113
+ role_map: role_map,
114
+ default_role: default_role,
115
+ **team_resolution,
116
+ **profile
117
+ )
40
118
  end
119
+
120
+ def entra_fallback_claims(principal:, role_map:, default_role:, team_resolution:, profile:)
121
+ entra_claims = { sub: principal, preferred_username: principal, **profile }.compact
122
+ result = EntraClaimsMapper.map_claims(
123
+ entra_claims,
124
+ role_map: role_map,
125
+ default_role: default_role,
126
+ **team_resolution
127
+ )
128
+ result&.merge(**profile, auth_method: 'kerberos', team: result[:team]) || mapped_claims(
129
+ principal: principal,
130
+ groups: [],
131
+ role_map: role_map,
132
+ default_role: default_role,
133
+ team_resolution: team_resolution,
134
+ profile: profile
135
+ )
136
+ end
137
+
138
+ private :resolve_team, :extract_team_resolution, :mapped_claims, :entra_fallback_claims
41
139
  end
42
140
  end
43
141
  end