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 +4 -4
- data/CHANGELOG.md +28 -0
- data/CLAUDE.md +1 -1
- data/Gemfile +1 -0
- data/README.md +31 -1
- data/legion-rbac.gemspec +1 -0
- data/lib/legion/rbac/capability_audit.rb +173 -0
- data/lib/legion/rbac/capability_registry.rb +87 -0
- data/lib/legion/rbac/config_loader.rb +17 -6
- data/lib/legion/rbac/entra_claims_mapper.rb +50 -12
- data/lib/legion/rbac/kerberos_claims_mapper.rb +108 -10
- data/lib/legion/rbac/middleware.rb +193 -38
- data/lib/legion/rbac/permission.rb +25 -9
- data/lib/legion/rbac/policy_engine.rb +448 -26
- data/lib/legion/rbac/principal.rb +34 -7
- data/lib/legion/rbac/role.rb +25 -2
- data/lib/legion/rbac/routes.rb +177 -10
- data/lib/legion/rbac/settings.rb +55 -24
- data/lib/legion/rbac/store.rb +54 -12
- data/lib/legion/rbac/team_scope.rb +37 -7
- data/lib/legion/rbac/version.rb +1 -1
- data/lib/legion/rbac.rb +114 -11
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 222914812dd7014e897e4aa3a6311ea7b4e8062c74ad1c532774f2f3c9e06fca
|
|
4
|
+
data.tar.gz: 30b265cf04e23b6456f839d5aeeeb47792751d3580f92b2085709b9f786388b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
data/Gemfile
CHANGED
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.
|
|
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
|
@@ -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:
|
|
13
|
-
description:
|
|
14
|
-
permissions:
|
|
15
|
-
deny:
|
|
16
|
-
cross_team:
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|