legion-rbac 0.2.8 → 0.3.0

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: 59a7e721e473fdb1e65fe31d2da68e7fc7fc0a65e2e077f199f62c98aaf78fb5
4
- data.tar.gz: b59c16e31ab6f63b1f93947a6c47ba59c891c2275a4f73463f892f4c0a71d9a8
3
+ metadata.gz: 222914812dd7014e897e4aa3a6311ea7b4e8062c74ad1c532774f2f3c9e06fca
4
+ data.tar.gz: 30b265cf04e23b6456f839d5aeeeb47792751d3580f92b2085709b9f786388b5
5
5
  SHA512:
6
- metadata.gz: c8431ff82ad752660da99454b4170999572d2c37259fe33605238fc4faa6fdabe3d5c50a29dfc0d40c2fb45225107e457f15ac1e14aa0d8c41f6985cb39aa855
7
- data.tar.gz: aafee7d9c8e9a3a07974aae8b890a8120dda0a10906a45ec1e967745563db0c2eed90ae39d2e14603d383df900ef450add66484d00cd4254288c573d976a47f2
6
+ metadata.gz: 5e9e541ca68dffe258a0766c0dbf58069faf39475ca94db674f38b8d33430dc04a14bf75b360ce1dc72807361a7d67f9bfff558541e3287ddf51a7325bf0a416
7
+ data.tar.gz: c327b53c9b782f461fc163aef47118c075f3367b9aa60585080faab7ebdf43c57092e580c86ea454a22c0ed5ecbd298a3027c442cc327988edd2dfe812047cb9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-04-02
4
+
5
+ ### Changed
6
+ - Uplifted non-Sinatra RBAC library code to `Legion::Logging::Helper` with structured `log.*` usage instead of direct `Legion::Logging.*` calls.
7
+ - 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.
8
+ - Promoted `legion-logging >= 1.5.0` to a runtime gem dependency and added coverage for the new logging rescue paths.
9
+ - Explicitly load full `legion/logging` from RBAC library files so `require 'legion/rbac'` boots cleanly without preloading logging elsewhere.
10
+ - Exposed `KerberosClaimsMapper` from the gem entrypoint and preserved caller-supplied fallback defaults/profile attributes when Kerberos fallback delegates to Entra.
11
+ - Made `rbac.enabled` disable RBAC setup/enforcement paths consistently and normalized malformed `expires_at` inputs into validation errors with explicit time parsing.
12
+ - Expanded middleware route coverage to include `/api/rbac/*`, honored `rbac.route_permissions` overrides, and compiled route matchers once per permission table.
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+
17
+ ## [0.2.9] - 2026-03-31
18
+
19
+ ### Added
20
+ - `Legion::Rbac::CapabilityAudit` module: static analysis of extension source code to detect dangerous patterns (`system`, `exec`, `Open3`, backticks, `eval`, `Net::HTTP`, `Faraday`, `File.write`, `FileUtils`) and map them to required capabilities (`shell_execute`, `code_eval`, `network_outbound`, `filesystem_write`). Blocks extensions with undeclared capabilities in enforce mode, warns in warn mode. Configurable via `rbac.capability_audit` settings.
21
+ - `Legion::Rbac::CapabilityAudit::AuditResult` value object with `blocked?`, `undeclared`, `detected_capabilities`, `declared_capabilities`, and `to_h` conversion.
22
+ - `Legion::Rbac::CapabilityRegistry` module: thread-safe registry tracking which extensions have which capabilities. `register`, `for_extension`, `extensions_with`, `audit_result_for`, `all`, `registered?`, `clear!` methods.
23
+ - `Legion::Rbac::PolicyEngine.evaluate_capability`: runtime RBAC gating for capabilities — checks if a principal's roles grant or deny a specific capability, with deny-always-wins semantics and dry-run support.
24
+ - `Legion::Rbac::Role#capability_allowed?`: per-role capability check (denial takes precedence over grant).
25
+ - `capability_grants` and `capability_denials` fields on all four built-in roles: admin (all granted), supervisor (shell + network + filesystem, code_eval denied), worker (network + filesystem, shell + eval denied), governance-observer (all denied).
26
+ - `Legion::Rbac.audit_extension`: convenience method that audits an extension and registers it in the CapabilityRegistry.
27
+ - `Legion::Rbac.authorize_capability!`: raises `AccessDenied` when a principal lacks the required capability.
28
+ - `rbac.capability_audit` settings: `enabled` (default true), `mode` (enforce/warn), `undeclared_policy` (block).
29
+ - 43 new specs (159 total) covering all three phases of the capability enforcement system.
30
+
3
31
  ## [0.2.8] - 2026-03-28
4
32
 
5
33
  ### Added
data/CLAUDE.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Parent**: `/Users/miverso2/rubymine/legion/CLAUDE.md`
4
4
  **GitHub**: https://github.com/LegionIO/legion-rbac
5
- **Version**: 0.2.2
5
+ **Version**: 0.2.7
6
6
 
7
7
  Optional RBAC gem for LegionIO. Vault-style flat policy model with deny-always-wins semantics.
8
8
 
data/Gemfile CHANGED
@@ -9,5 +9,6 @@ group :test do
9
9
  gem 'rspec'
10
10
  gem 'rspec_junit_formatter'
11
11
  gem 'rubocop'
12
+ gem 'rubocop-legion'
12
13
  gem 'simplecov'
13
14
  end
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Role-based access control for LegionIO, following Vault-style flat policy patterns.
4
4
 
5
- **Version**: 0.2.2
5
+ **Version**: 0.2.9
6
6
 
7
7
  ## Features
8
8
 
@@ -53,6 +53,36 @@ principal.profile # => { first_name: "Jane", last_name: "Doe", ... }
53
53
  Legion::Rbac.authorize_execution!(principal: principal, runner_class: 'Legion::Extensions::LexHttp::Runners::Request', function: :get)
54
54
  ```
55
55
 
56
+ ### Capability Audit (Extension Security)
57
+
58
+ Audit an extension's source code for dangerous patterns and enforce capability declarations:
59
+
60
+ ```ruby
61
+ result = Legion::Rbac.audit_extension(
62
+ extension_name: 'lex-codegen',
63
+ source_path: '/path/to/lex-codegen/lib',
64
+ declared_capabilities: [:shell_execute, :filesystem_write]
65
+ )
66
+ result.blocked? # => false (all capabilities declared)
67
+ result.detected_capabilities # => [:shell_execute, :filesystem_write]
68
+
69
+ # Query the capability registry
70
+ Legion::Rbac::CapabilityRegistry.for_extension('lex-codegen')
71
+ # => [:filesystem_write, :shell_execute]
72
+
73
+ Legion::Rbac::CapabilityRegistry.extensions_with(:shell_execute)
74
+ # => ["lex-codegen", "lex-exec"]
75
+ ```
76
+
77
+ ### Capability Authorization
78
+
79
+ Check if a principal's role allows a specific capability:
80
+
81
+ ```ruby
82
+ Legion::Rbac.authorize_capability!(principal: principal, capability: :shell_execute, extension_name: 'lex-codegen')
83
+ # Raises AccessDenied if the principal's role denies shell_execute
84
+ ```
85
+
56
86
  ### Dry-Run Check
57
87
 
58
88
  ```ruby
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
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/logging'
4
+
5
+ module Legion
6
+ module Rbac
7
+ module CapabilityAudit
8
+ extend Legion::Logging::Helper
9
+
10
+ PATTERN_TO_CAPABILITY = {
11
+ /\bKernel\.system\b|\bsystem\s*\(/ => :shell_execute,
12
+ /\bKernel\.exec\b|\bexec\s*\(/ => :shell_execute,
13
+ /\bOpen3\b/ => :shell_execute,
14
+ /`[^`]+`/ => :shell_execute,
15
+ /\bIO\.popen\b/ => :shell_execute,
16
+ /\bKernel\.eval\b|\beval\s*\(/ => :code_eval,
17
+ /\bNet::HTTP\b/ => :network_outbound,
18
+ /\bFaraday\b/ => :network_outbound,
19
+ /\bHTTParty\b/ => :network_outbound,
20
+ /\bFile\.(write|open|delete|rename)\b/ => :filesystem_write,
21
+ /\bFileUtils\b/ => :filesystem_write
22
+ }.freeze
23
+
24
+ class AuditResult
25
+ attr_reader :extension_name, :detected_capabilities, :declared_capabilities,
26
+ :undeclared, :allowed, :reason
27
+
28
+ def initialize(extension_name:, detected:, declared:, allowed:, reason: nil)
29
+ @extension_name = extension_name
30
+ @detected_capabilities = detected.uniq.sort
31
+ @declared_capabilities = declared.map(&:to_sym).uniq.sort
32
+ @undeclared = (@detected_capabilities - @declared_capabilities).sort
33
+ @allowed = allowed
34
+ @reason = reason
35
+ end
36
+
37
+ def blocked?
38
+ !@allowed
39
+ end
40
+
41
+ def to_h
42
+ hash = {
43
+ extension_name: @extension_name,
44
+ allowed: @allowed,
45
+ detected_capabilities: @detected_capabilities,
46
+ declared_capabilities: @declared_capabilities,
47
+ undeclared: @undeclared
48
+ }
49
+ hash[:reason] = @reason if @reason
50
+ hash
51
+ end
52
+ end
53
+
54
+ class << self
55
+ def audit(extension_name:, source_path:, declared_capabilities: [])
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
65
+
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
71
+
72
+ detected = scan_source(source_path)
73
+ declared_syms = Array(declared_capabilities).map(&:to_sym)
74
+ undeclared = (detected.uniq - declared_syms)
75
+
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
100
+ end
101
+
102
+ def enabled?
103
+ settings = capability_audit_settings
104
+ settings[:enabled] != false
105
+ end
106
+
107
+ def mode
108
+ settings = capability_audit_settings
109
+ (settings[:mode] || 'enforce').to_s
110
+ end
111
+
112
+ private
113
+
114
+ def scan_source(source_path)
115
+ capabilities = []
116
+ files = Dir.glob(File.join(source_path, '**', '*.rb'))
117
+ files.each do |file|
118
+ File.foreach(file) do |line|
119
+ PATTERN_TO_CAPABILITY.each do |pattern, capability|
120
+ capabilities << capability if line.match?(pattern)
121
+ end
122
+ end
123
+ end
124
+ log.debug("RBAC capability_audit scanned source_path=#{source_path} files=#{files.size}")
125
+ capabilities.uniq
126
+ end
127
+
128
+ def handle_undeclared(extension_name, detected, declared, undeclared)
129
+ if mode == 'warn'
130
+ log_warning(extension_name, undeclared)
131
+ AuditResult.new(
132
+ extension_name: extension_name,
133
+ detected: detected,
134
+ declared: declared,
135
+ allowed: true,
136
+ reason: "undeclared capabilities (warn mode): #{undeclared.join(', ')}"
137
+ )
138
+ else
139
+ log.warn("CapabilityAudit: #{extension_name} blocked for undeclared capabilities: #{undeclared.join(', ')}")
140
+ AuditResult.new(
141
+ extension_name: extension_name,
142
+ detected: detected,
143
+ declared: declared,
144
+ allowed: false,
145
+ reason: "undeclared capabilities: #{undeclared.join(', ')}"
146
+ )
147
+ end
148
+ end
149
+
150
+ def log_warning(extension_name, undeclared)
151
+ log.warn("CapabilityAudit: #{extension_name} uses undeclared capabilities: #{undeclared.join(', ')}")
152
+ end
153
+
154
+ def skip_result(extension_name, reason)
155
+ log.debug("RBAC capability_audit skip_result extension=#{extension_name} reason=#{reason}")
156
+ AuditResult.new(
157
+ extension_name: extension_name,
158
+ detected: [],
159
+ declared: [],
160
+ allowed: true,
161
+ reason: reason
162
+ )
163
+ end
164
+
165
+ def capability_audit_settings
166
+ return {} unless defined?(Legion::Settings)
167
+
168
+ Legion::Settings[:rbac]&.dig(:capability_audit) || {}
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/logging'
4
+ require 'monitor'
5
+
6
+ module Legion
7
+ module Rbac
8
+ module CapabilityRegistry
9
+ class << self
10
+ include Legion::Logging::Helper
11
+
12
+ def register(extension_name, capabilities:, audit_result: nil)
13
+ mon.synchronize do
14
+ entries[extension_name.to_s] = {
15
+ capabilities: Array(capabilities).map(&:to_sym).uniq,
16
+ audit_result: audit_result,
17
+ registered_at: Time.now
18
+ }
19
+ end
20
+ log.info("RBAC capability_registry register extension=#{extension_name} count=#{Array(capabilities).uniq.size}")
21
+ end
22
+
23
+ def for_extension(extension_name)
24
+ capabilities = mon.synchronize do
25
+ entry = entries[extension_name.to_s]
26
+ entry ? entry[:capabilities].dup : []
27
+ end
28
+ log.debug("RBAC capability_registry for_extension extension=#{extension_name} count=#{capabilities.size}")
29
+ capabilities
30
+ end
31
+
32
+ def extensions_with(capability)
33
+ cap_sym = capability.to_sym
34
+ extensions = mon.synchronize do
35
+ entries.select { |_, entry| entry[:capabilities].include?(cap_sym) }.keys
36
+ end
37
+ log.debug("RBAC capability_registry extensions_with capability=#{capability} count=#{extensions.size}")
38
+ extensions
39
+ end
40
+
41
+ def audit_result_for(extension_name)
42
+ audit_result = mon.synchronize do
43
+ entry = entries[extension_name.to_s]
44
+ entry&.dig(:audit_result)
45
+ end
46
+ log.debug("RBAC capability_registry audit_result_for extension=#{extension_name} present=#{!audit_result.nil?}")
47
+ audit_result
48
+ end
49
+
50
+ def all
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
62
+ end
63
+
64
+ def registered?(extension_name)
65
+ registered = mon.synchronize { entries.key?(extension_name.to_s) }
66
+ log.debug("RBAC capability_registry registered extension=#{extension_name} value=#{registered}")
67
+ registered
68
+ end
69
+
70
+ def clear!
71
+ mon.synchronize { @entries = {} }
72
+ log.info('RBAC capability_registry cleared')
73
+ end
74
+
75
+ private
76
+
77
+ def entries
78
+ @entries ||= {}
79
+ end
80
+
81
+ def mon
82
+ @mon ||= Monitor.new
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -1,21 +1,32 @@
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
- name: name,
13
- description: config[:description] || '',
14
- permissions: config[:permissions] || [],
15
- deny: config[:deny] || [],
16
- cross_team: config[:cross_team] || false
15
+ name: name,
16
+ description: config[:description] || '',
17
+ permissions: config[:permissions] || [],
18
+ deny: config[:deny] || [],
19
+ cross_team: config[:cross_team] || false,
20
+ capability_grants: config[:capability_grants] || [],
21
+ capability_denials: config[:capability_denials] || []
17
22
  )
23
+ log.debug("RBAC role loaded name=#{name} permissions=#{config[:permissions]&.size || 0} deny=#{config[:deny]&.size || 0}")
18
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
19
30
  end
20
31
  end
21
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