legion-rbac 0.2.8 → 0.2.9
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 +14 -0
- data/CLAUDE.md +1 -1
- data/Gemfile +1 -0
- data/README.md +31 -1
- data/lib/legion/rbac/capability_audit.rb +143 -0
- data/lib/legion/rbac/capability_registry.rb +64 -0
- data/lib/legion/rbac/config_loader.rb +7 -5
- data/lib/legion/rbac/policy_engine.rb +50 -0
- data/lib/legion/rbac/role.rb +13 -2
- data/lib/legion/rbac/settings.rb +34 -17
- data/lib/legion/rbac/version.rb +1 -1
- data/lib/legion/rbac.rb +27 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: db6027eb10e80db5c7fce237a53e1640036d4d30e73e2e468029054843df5c4f
|
|
4
|
+
data.tar.gz: dd520f3dc81753b13bb29bf41bd664bbb0acdd789697a50649f93f77c68d3655
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e56a6e0e93987b829f04ca89c907753d893ec9d6907d7cf8f29d011b92b82906b249abac00fe3951142589b6ba8cbd8308fe0fd57b9d5974c708e00b744585f3
|
|
7
|
+
data.tar.gz: 5d47a2e7805ae8fcd242c1a7bcf2f916063adf8517831ec06b198f77c145b4734eaa9b36d4ce8450eebe17d5ff47c852abd8288ae9da9f6e115780f9bb152f45
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.9] - 2026-03-31
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `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.
|
|
7
|
+
- `Legion::Rbac::CapabilityAudit::AuditResult` value object with `blocked?`, `undeclared`, `detected_capabilities`, `declared_capabilities`, and `to_h` conversion.
|
|
8
|
+
- `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.
|
|
9
|
+
- `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.
|
|
10
|
+
- `Legion::Rbac::Role#capability_allowed?`: per-role capability check (denial takes precedence over grant).
|
|
11
|
+
- `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).
|
|
12
|
+
- `Legion::Rbac.audit_extension`: convenience method that audits an extension and registers it in the CapabilityRegistry.
|
|
13
|
+
- `Legion::Rbac.authorize_capability!`: raises `AccessDenied` when a principal lacks the required capability.
|
|
14
|
+
- `rbac.capability_audit` settings: `enabled` (default true), `mode` (enforce/warn), `undeclared_policy` (block).
|
|
15
|
+
- 43 new specs (159 total) covering all three phases of the capability enforcement system.
|
|
16
|
+
|
|
3
17
|
## [0.2.8] - 2026-03-28
|
|
4
18
|
|
|
5
19
|
### 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
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Rbac
|
|
5
|
+
module CapabilityAudit
|
|
6
|
+
PATTERN_TO_CAPABILITY = {
|
|
7
|
+
/\bKernel\.system\b|\bsystem\s*\(/ => :shell_execute,
|
|
8
|
+
/\bKernel\.exec\b|\bexec\s*\(/ => :shell_execute,
|
|
9
|
+
/\bOpen3\b/ => :shell_execute,
|
|
10
|
+
/`[^`]+`/ => :shell_execute,
|
|
11
|
+
/\bIO\.popen\b/ => :shell_execute,
|
|
12
|
+
/\bKernel\.eval\b|\beval\s*\(/ => :code_eval,
|
|
13
|
+
/\bNet::HTTP\b/ => :network_outbound,
|
|
14
|
+
/\bFaraday\b/ => :network_outbound,
|
|
15
|
+
/\bHTTParty\b/ => :network_outbound,
|
|
16
|
+
/\bFile\.(write|open|delete|rename)\b/ => :filesystem_write,
|
|
17
|
+
/\bFileUtils\b/ => :filesystem_write
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
class AuditResult
|
|
21
|
+
attr_reader :extension_name, :detected_capabilities, :declared_capabilities,
|
|
22
|
+
:undeclared, :allowed, :reason
|
|
23
|
+
|
|
24
|
+
def initialize(extension_name:, detected:, declared:, allowed:, reason: nil)
|
|
25
|
+
@extension_name = extension_name
|
|
26
|
+
@detected_capabilities = detected.uniq.sort
|
|
27
|
+
@declared_capabilities = declared.map(&:to_sym).uniq.sort
|
|
28
|
+
@undeclared = (@detected_capabilities - @declared_capabilities).sort
|
|
29
|
+
@allowed = allowed
|
|
30
|
+
@reason = reason
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def blocked?
|
|
34
|
+
!@allowed
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_h
|
|
38
|
+
hash = {
|
|
39
|
+
extension_name: @extension_name,
|
|
40
|
+
allowed: @allowed,
|
|
41
|
+
detected_capabilities: @detected_capabilities,
|
|
42
|
+
declared_capabilities: @declared_capabilities,
|
|
43
|
+
undeclared: @undeclared
|
|
44
|
+
}
|
|
45
|
+
hash[:reason] = @reason if @reason
|
|
46
|
+
hash
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
def audit(extension_name:, source_path:, declared_capabilities: [])
|
|
52
|
+
return skip_result(extension_name, 'capability audit disabled') unless enabled?
|
|
53
|
+
|
|
54
|
+
return skip_result(extension_name, 'no source path') unless source_path && Dir.exist?(source_path.to_s)
|
|
55
|
+
|
|
56
|
+
detected = scan_source(source_path)
|
|
57
|
+
declared_syms = Array(declared_capabilities).map(&:to_sym)
|
|
58
|
+
undeclared = (detected.uniq - declared_syms)
|
|
59
|
+
|
|
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
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def enabled?
|
|
73
|
+
settings = capability_audit_settings
|
|
74
|
+
settings[:enabled] != false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def mode
|
|
78
|
+
settings = capability_audit_settings
|
|
79
|
+
(settings[:mode] || 'enforce').to_s
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def scan_source(source_path)
|
|
85
|
+
capabilities = []
|
|
86
|
+
Dir.glob(File.join(source_path, '**', '*.rb')).each do |file|
|
|
87
|
+
File.foreach(file) do |line|
|
|
88
|
+
PATTERN_TO_CAPABILITY.each do |pattern, capability|
|
|
89
|
+
capabilities << capability if line.match?(pattern)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
capabilities.uniq
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def handle_undeclared(extension_name, detected, declared, undeclared)
|
|
97
|
+
if mode == 'warn'
|
|
98
|
+
log_warning(extension_name, undeclared)
|
|
99
|
+
AuditResult.new(
|
|
100
|
+
extension_name: extension_name,
|
|
101
|
+
detected: detected,
|
|
102
|
+
declared: declared,
|
|
103
|
+
allowed: true,
|
|
104
|
+
reason: "undeclared capabilities (warn mode): #{undeclared.join(', ')}"
|
|
105
|
+
)
|
|
106
|
+
else
|
|
107
|
+
AuditResult.new(
|
|
108
|
+
extension_name: extension_name,
|
|
109
|
+
detected: detected,
|
|
110
|
+
declared: declared,
|
|
111
|
+
allowed: false,
|
|
112
|
+
reason: "undeclared capabilities: #{undeclared.join(', ')}"
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
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
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def skip_result(extension_name, reason)
|
|
126
|
+
AuditResult.new(
|
|
127
|
+
extension_name: extension_name,
|
|
128
|
+
detected: [],
|
|
129
|
+
declared: [],
|
|
130
|
+
allowed: true,
|
|
131
|
+
reason: reason
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def capability_audit_settings
|
|
136
|
+
return {} unless defined?(Legion::Settings)
|
|
137
|
+
|
|
138
|
+
Legion::Settings[:rbac]&.dig(:capability_audit) || {}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'monitor'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Rbac
|
|
7
|
+
module CapabilityRegistry
|
|
8
|
+
class << self
|
|
9
|
+
def register(extension_name, capabilities:, audit_result: nil)
|
|
10
|
+
mon.synchronize do
|
|
11
|
+
entries[extension_name.to_s] = {
|
|
12
|
+
capabilities: Array(capabilities).map(&:to_sym).uniq,
|
|
13
|
+
audit_result: audit_result,
|
|
14
|
+
registered_at: Time.now
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def for_extension(extension_name)
|
|
20
|
+
mon.synchronize do
|
|
21
|
+
entry = entries[extension_name.to_s]
|
|
22
|
+
entry ? entry[:capabilities] : []
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def extensions_with(capability)
|
|
27
|
+
cap_sym = capability.to_sym
|
|
28
|
+
mon.synchronize do
|
|
29
|
+
entries.select { |_, entry| entry[:capabilities].include?(cap_sym) }.keys
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def audit_result_for(extension_name)
|
|
34
|
+
mon.synchronize do
|
|
35
|
+
entry = entries[extension_name.to_s]
|
|
36
|
+
entry&.dig(:audit_result)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def all
|
|
41
|
+
mon.synchronize { entries.dup }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def registered?(extension_name)
|
|
45
|
+
mon.synchronize { entries.key?(extension_name.to_s) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def clear!
|
|
49
|
+
mon.synchronize { @entries = {} }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def entries
|
|
55
|
+
@entries ||= {}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def mon
|
|
59
|
+
@mon ||= Monitor.new
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -9,11 +9,13 @@ module Legion
|
|
|
9
9
|
roles_config ||= Legion::Settings[:rbac][:roles]
|
|
10
10
|
roles_config.each_with_object({}) do |(name, config), index|
|
|
11
11
|
index[name.to_sym] = Role.new(
|
|
12
|
-
name:
|
|
13
|
-
description:
|
|
14
|
-
permissions:
|
|
15
|
-
deny:
|
|
16
|
-
cross_team:
|
|
12
|
+
name: name,
|
|
13
|
+
description: config[:description] || '',
|
|
14
|
+
permissions: config[:permissions] || [],
|
|
15
|
+
deny: config[:deny] || [],
|
|
16
|
+
cross_team: config[:cross_team] || false,
|
|
17
|
+
capability_grants: config[:capability_grants] || [],
|
|
18
|
+
capability_denials: config[:capability_denials] || []
|
|
17
19
|
)
|
|
18
20
|
end
|
|
19
21
|
end
|
|
@@ -49,6 +49,44 @@ module Legion
|
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
def self.evaluate_capability(principal:, capability:, extension_name: nil, role_index: nil, enforce: nil)
|
|
53
|
+
role_index ||= Legion::Rbac.role_index || {}
|
|
54
|
+
enforce = Legion::Settings[:rbac][:enforce] if enforce.nil?
|
|
55
|
+
|
|
56
|
+
resolved_roles = resolve_roles(principal, role_index)
|
|
57
|
+
|
|
58
|
+
if resolved_roles.empty?
|
|
59
|
+
return build_capability_result(
|
|
60
|
+
allowed: false, reason: 'no roles assigned',
|
|
61
|
+
principal: principal, capability: capability,
|
|
62
|
+
extension_name: extension_name, enforce: enforce
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
denied = resolved_roles.any? { |role| role.capability_denials.include?(capability.to_sym) }
|
|
67
|
+
if denied
|
|
68
|
+
return build_capability_result(
|
|
69
|
+
allowed: false, reason: "capability #{capability} denied by role policy",
|
|
70
|
+
principal: principal, capability: capability,
|
|
71
|
+
extension_name: extension_name, enforce: enforce
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
granted = resolved_roles.any? { |role| role.capability_grants.include?(capability.to_sym) }
|
|
76
|
+
unless granted
|
|
77
|
+
return build_capability_result(
|
|
78
|
+
allowed: false, reason: "capability #{capability} not granted by any role",
|
|
79
|
+
principal: principal, capability: capability,
|
|
80
|
+
extension_name: extension_name, enforce: enforce
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
build_capability_result(
|
|
85
|
+
allowed: true, principal: principal, capability: capability,
|
|
86
|
+
extension_name: extension_name, enforce: enforce
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
52
90
|
def self.build_result(allowed:, principal:, action:, resource:, enforce:, reason: nil)
|
|
53
91
|
result = {
|
|
54
92
|
allowed: enforce ? allowed : true,
|
|
@@ -60,6 +98,18 @@ module Legion
|
|
|
60
98
|
result[:would_deny] = true if !enforce && !allowed
|
|
61
99
|
result
|
|
62
100
|
end
|
|
101
|
+
|
|
102
|
+
def self.build_capability_result(allowed:, principal:, capability:, enforce:, extension_name: nil, reason: nil)
|
|
103
|
+
result = {
|
|
104
|
+
allowed: enforce ? allowed : true,
|
|
105
|
+
capability: capability.to_s,
|
|
106
|
+
principal_id: principal.id
|
|
107
|
+
}
|
|
108
|
+
result[:extension_name] = extension_name if extension_name
|
|
109
|
+
result[:reason] = reason if reason
|
|
110
|
+
result[:would_deny] = true if !enforce && !allowed
|
|
111
|
+
result
|
|
112
|
+
end
|
|
63
113
|
end
|
|
64
114
|
end
|
|
65
115
|
end
|
data/lib/legion/rbac/role.rb
CHANGED
|
@@ -5,9 +5,11 @@ require 'legion/rbac/permission'
|
|
|
5
5
|
module Legion
|
|
6
6
|
module Rbac
|
|
7
7
|
class Role
|
|
8
|
-
attr_reader :name, :description, :permissions, :deny_rules, :cross_team
|
|
8
|
+
attr_reader :name, :description, :permissions, :deny_rules, :cross_team,
|
|
9
|
+
:capability_grants, :capability_denials
|
|
9
10
|
|
|
10
|
-
def initialize(name:, description: '', permissions: [], deny: [], cross_team: false
|
|
11
|
+
def initialize(name:, description: '', permissions: [], deny: [], cross_team: false,
|
|
12
|
+
capability_grants: [], capability_denials: [])
|
|
11
13
|
@name = name.to_s
|
|
12
14
|
@description = description
|
|
13
15
|
@permissions = permissions.map do |p|
|
|
@@ -17,11 +19,20 @@ module Legion
|
|
|
17
19
|
DenyRule.new(resource_pattern: d[:resource], above_level: d[:above_level])
|
|
18
20
|
end
|
|
19
21
|
@cross_team = cross_team
|
|
22
|
+
@capability_grants = Array(capability_grants).map(&:to_sym)
|
|
23
|
+
@capability_denials = Array(capability_denials).map(&:to_sym)
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
def cross_team?
|
|
23
27
|
@cross_team == true
|
|
24
28
|
end
|
|
29
|
+
|
|
30
|
+
def capability_allowed?(capability)
|
|
31
|
+
cap = capability.to_sym
|
|
32
|
+
return false if @capability_denials.include?(cap)
|
|
33
|
+
|
|
34
|
+
@capability_grants.include?(cap)
|
|
35
|
+
end
|
|
25
36
|
end
|
|
26
37
|
end
|
|
27
38
|
end
|
data/lib/legion/rbac/settings.rb
CHANGED
|
@@ -12,7 +12,16 @@ module Legion
|
|
|
12
12
|
static_assignments: [],
|
|
13
13
|
route_permissions: {},
|
|
14
14
|
roles: default_roles,
|
|
15
|
-
entra: entra_defaults
|
|
15
|
+
entra: entra_defaults,
|
|
16
|
+
capability_audit: capability_audit_defaults
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.capability_audit_defaults
|
|
21
|
+
{
|
|
22
|
+
enabled: true,
|
|
23
|
+
mode: 'enforce',
|
|
24
|
+
undeclared_policy: 'block'
|
|
16
25
|
}
|
|
17
26
|
end
|
|
18
27
|
|
|
@@ -41,25 +50,27 @@ module Legion
|
|
|
41
50
|
|
|
42
51
|
def self.worker_role
|
|
43
52
|
{
|
|
44
|
-
description:
|
|
45
|
-
permissions:
|
|
53
|
+
description: 'Execute assigned runners within team scope',
|
|
54
|
+
permissions: [
|
|
46
55
|
{ resource: 'runners/*', actions: %w[execute] },
|
|
47
56
|
{ resource: 'tasks/*', actions: %w[read create] },
|
|
48
57
|
{ resource: 'schedules/*', actions: %w[read] },
|
|
49
58
|
{ resource: 'workers/self', actions: %w[read] }
|
|
50
59
|
],
|
|
51
|
-
deny:
|
|
60
|
+
deny: [
|
|
52
61
|
{ resource: 'runners/lex-extinction/*' },
|
|
53
62
|
{ resource: 'runners/lex-governance/*' },
|
|
54
63
|
{ resource: 'workers/*/lifecycle' }
|
|
55
|
-
]
|
|
64
|
+
],
|
|
65
|
+
capability_grants: %w[network_outbound filesystem_write],
|
|
66
|
+
capability_denials: %w[shell_execute code_eval]
|
|
56
67
|
}
|
|
57
68
|
end
|
|
58
69
|
|
|
59
70
|
def self.supervisor_role
|
|
60
71
|
{
|
|
61
|
-
description:
|
|
62
|
-
permissions:
|
|
72
|
+
description: 'Manage workers and schedules within team scope',
|
|
73
|
+
permissions: [
|
|
63
74
|
{ resource: 'runners/*', actions: %w[execute] },
|
|
64
75
|
{ resource: 'tasks/*', actions: %w[read create delete] },
|
|
65
76
|
{ resource: 'schedules/*', actions: %w[read create update delete] },
|
|
@@ -67,28 +78,32 @@ module Legion
|
|
|
67
78
|
{ resource: 'extensions/*', actions: %w[read] },
|
|
68
79
|
{ resource: 'events/*', actions: %w[read] }
|
|
69
80
|
],
|
|
70
|
-
deny:
|
|
81
|
+
deny: [
|
|
71
82
|
{ resource: 'runners/lex-extinction/escalate', above_level: 2 },
|
|
72
83
|
{ resource: 'workers/*/lifecycle/terminated' }
|
|
73
|
-
]
|
|
84
|
+
],
|
|
85
|
+
capability_grants: %w[network_outbound filesystem_write shell_execute],
|
|
86
|
+
capability_denials: %w[code_eval]
|
|
74
87
|
}
|
|
75
88
|
end
|
|
76
89
|
|
|
77
90
|
def self.admin_role
|
|
78
91
|
{
|
|
79
|
-
description:
|
|
80
|
-
permissions:
|
|
92
|
+
description: 'Full access, cross-team capability',
|
|
93
|
+
permissions: [
|
|
81
94
|
{ resource: '*', actions: %w[read create update delete execute manage] }
|
|
82
95
|
],
|
|
83
|
-
deny:
|
|
84
|
-
cross_team:
|
|
96
|
+
deny: [],
|
|
97
|
+
cross_team: true,
|
|
98
|
+
capability_grants: %w[shell_execute code_eval network_outbound filesystem_write],
|
|
99
|
+
capability_denials: []
|
|
85
100
|
}
|
|
86
101
|
end
|
|
87
102
|
|
|
88
103
|
def self.governance_observer_role
|
|
89
104
|
{
|
|
90
|
-
description:
|
|
91
|
-
permissions:
|
|
105
|
+
description: 'Read-only visibility across all teams for audit and compliance',
|
|
106
|
+
permissions: [
|
|
92
107
|
{ resource: 'workers/*', actions: %w[read] },
|
|
93
108
|
{ resource: 'tasks/*', actions: %w[read] },
|
|
94
109
|
{ resource: 'events/*', actions: %w[read] },
|
|
@@ -96,8 +111,10 @@ module Legion
|
|
|
96
111
|
{ resource: 'extensions/*', actions: %w[read] },
|
|
97
112
|
{ resource: 'runners/lex-governance/*', actions: %w[read execute] }
|
|
98
113
|
],
|
|
99
|
-
deny:
|
|
100
|
-
cross_team:
|
|
114
|
+
deny: [],
|
|
115
|
+
cross_team: true,
|
|
116
|
+
capability_grants: [],
|
|
117
|
+
capability_denials: %w[shell_execute code_eval network_outbound filesystem_write]
|
|
101
118
|
}
|
|
102
119
|
end
|
|
103
120
|
end
|
data/lib/legion/rbac/version.rb
CHANGED
data/lib/legion/rbac.rb
CHANGED
|
@@ -12,6 +12,8 @@ require 'legion/rbac/store'
|
|
|
12
12
|
require 'legion/rbac/entra_claims_mapper'
|
|
13
13
|
require 'legion/rbac/middleware'
|
|
14
14
|
require 'legion/rbac/routes'
|
|
15
|
+
require 'legion/rbac/capability_audit'
|
|
16
|
+
require 'legion/rbac/capability_registry'
|
|
15
17
|
|
|
16
18
|
module Legion
|
|
17
19
|
module Rbac
|
|
@@ -60,6 +62,31 @@ module Legion
|
|
|
60
62
|
authorize!(principal: principal, action: :execute, resource: runner_path, **)
|
|
61
63
|
end
|
|
62
64
|
|
|
65
|
+
def audit_extension(extension_name:, source_path:, declared_capabilities: [])
|
|
66
|
+
result = CapabilityAudit.audit(
|
|
67
|
+
extension_name: extension_name,
|
|
68
|
+
source_path: source_path,
|
|
69
|
+
declared_capabilities: declared_capabilities
|
|
70
|
+
)
|
|
71
|
+
CapabilityRegistry.register(
|
|
72
|
+
extension_name,
|
|
73
|
+
capabilities: result.detected_capabilities,
|
|
74
|
+
audit_result: result
|
|
75
|
+
)
|
|
76
|
+
result
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def authorize_capability!(principal:, capability:, extension_name: nil)
|
|
80
|
+
result = PolicyEngine.evaluate_capability(
|
|
81
|
+
principal: principal,
|
|
82
|
+
capability: capability,
|
|
83
|
+
extension_name: extension_name
|
|
84
|
+
)
|
|
85
|
+
raise AccessDenied, result unless result[:allowed]
|
|
86
|
+
|
|
87
|
+
result
|
|
88
|
+
end
|
|
89
|
+
|
|
63
90
|
private
|
|
64
91
|
|
|
65
92
|
def build_runner_path(runner_class, function)
|
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.2.
|
|
4
|
+
version: 0.2.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -61,6 +61,8 @@ files:
|
|
|
61
61
|
- docs/implementation-plan.md
|
|
62
62
|
- legion-rbac.gemspec
|
|
63
63
|
- lib/legion/rbac.rb
|
|
64
|
+
- lib/legion/rbac/capability_audit.rb
|
|
65
|
+
- lib/legion/rbac/capability_registry.rb
|
|
64
66
|
- lib/legion/rbac/config_loader.rb
|
|
65
67
|
- lib/legion/rbac/entra_claims_mapper.rb
|
|
66
68
|
- lib/legion/rbac/kerberos_claims_mapper.rb
|