legion-rbac 0.2.7 → 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/.github/CODEOWNERS +7 -0
- data/.github/dependabot.yml +18 -0
- data/.github/workflows/ci.yml +20 -2
- data/CHANGELOG.md +19 -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/routes.rb +260 -0
- data/lib/legion/rbac/settings.rb +34 -17
- data/lib/legion/rbac/version.rb +1 -1
- data/lib/legion/rbac.rb +38 -0
- metadata +6 -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/.github/CODEOWNERS
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
- package-ecosystem: bundler
|
|
4
|
+
directory: /
|
|
5
|
+
schedule:
|
|
6
|
+
interval: weekly
|
|
7
|
+
day: monday
|
|
8
|
+
open-pull-requests-limit: 5
|
|
9
|
+
labels:
|
|
10
|
+
- "type:dependencies"
|
|
11
|
+
- package-ecosystem: github-actions
|
|
12
|
+
directory: /
|
|
13
|
+
schedule:
|
|
14
|
+
interval: weekly
|
|
15
|
+
day: monday
|
|
16
|
+
open-pull-requests-limit: 5
|
|
17
|
+
labels:
|
|
18
|
+
- "type:dependencies"
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -3,14 +3,32 @@ on:
|
|
|
3
3
|
push:
|
|
4
4
|
branches: [main]
|
|
5
5
|
pull_request:
|
|
6
|
+
schedule:
|
|
7
|
+
- cron: '0 9 * * 1'
|
|
6
8
|
|
|
7
9
|
jobs:
|
|
8
10
|
ci:
|
|
9
11
|
uses: LegionIO/.github/.github/workflows/ci.yml@main
|
|
10
12
|
|
|
13
|
+
lint:
|
|
14
|
+
uses: LegionIO/.github/.github/workflows/lint-patterns.yml@main
|
|
15
|
+
|
|
16
|
+
security:
|
|
17
|
+
uses: LegionIO/.github/.github/workflows/security-scan.yml@main
|
|
18
|
+
|
|
19
|
+
version-changelog:
|
|
20
|
+
uses: LegionIO/.github/.github/workflows/version-changelog.yml@main
|
|
21
|
+
|
|
22
|
+
dependency-review:
|
|
23
|
+
uses: LegionIO/.github/.github/workflows/dependency-review.yml@main
|
|
24
|
+
|
|
25
|
+
stale:
|
|
26
|
+
if: github.event_name == 'schedule'
|
|
27
|
+
uses: LegionIO/.github/.github/workflows/stale.yml@main
|
|
28
|
+
|
|
11
29
|
release:
|
|
12
|
-
needs: ci
|
|
30
|
+
needs: [ci, lint]
|
|
13
31
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
14
32
|
uses: LegionIO/.github/.github/workflows/release.yml@main
|
|
15
33
|
secrets:
|
|
16
|
-
rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }}
|
|
34
|
+
rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }}
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
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
|
+
|
|
17
|
+
## [0.2.8] - 2026-03-28
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- `Legion::Rbac::Routes` self-registering Sinatra route module (`lib/legion/rbac/routes.rb`): extracts all `/api/rbac/*` route handlers from LegionIO. Self-registers with `Legion::API.register_library_routes('rbac', Routes)` during boot. Includes fallback helpers for standalone mounting.
|
|
21
|
+
|
|
3
22
|
## [0.2.7] - 2026-03-22
|
|
4
23
|
|
|
5
24
|
### Changed
|
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
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Self-registering route module for legion-rbac.
|
|
4
|
+
# All routes previously defined in LegionIO/lib/legion/api/rbac.rb now live here
|
|
5
|
+
# and are mounted via Legion::API.register_library_routes when legion-rbac boots.
|
|
6
|
+
#
|
|
7
|
+
# LegionIO/lib/legion/api/rbac.rb is preserved for backward compatibility but guards
|
|
8
|
+
# its registration with defined?(Legion::Rbac::Routes) so double-registration is avoided.
|
|
9
|
+
|
|
10
|
+
module Legion
|
|
11
|
+
module Rbac
|
|
12
|
+
module Routes
|
|
13
|
+
def self.registered(app)
|
|
14
|
+
app.helpers do # rubocop:disable Metrics/BlockLength
|
|
15
|
+
unless method_defined?(:parse_request_body)
|
|
16
|
+
define_method(:parse_request_body) do
|
|
17
|
+
raw = request.body.read
|
|
18
|
+
return {} if raw.nil? || raw.empty?
|
|
19
|
+
|
|
20
|
+
begin
|
|
21
|
+
parsed = Legion::JSON.load(raw)
|
|
22
|
+
rescue StandardError
|
|
23
|
+
halt 400, { 'Content-Type' => 'application/json' },
|
|
24
|
+
Legion::JSON.dump({ error: { code: 'invalid_json', message: 'request body is not valid JSON' } })
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
unless parsed.respond_to?(:transform_keys)
|
|
28
|
+
halt 400, { 'Content-Type' => 'application/json' },
|
|
29
|
+
Legion::JSON.dump({ error: { code: 'invalid_request_body',
|
|
30
|
+
message: 'request body must be a JSON object' } })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
parsed.transform_keys(&:to_sym)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
unless method_defined?(:json_response)
|
|
38
|
+
define_method(:json_response) do |data, status_code: 200|
|
|
39
|
+
content_type :json
|
|
40
|
+
status status_code
|
|
41
|
+
Legion::JSON.dump({ data: data })
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
unless method_defined?(:json_error)
|
|
46
|
+
define_method(:json_error) do |code, message, status_code: 400|
|
|
47
|
+
content_type :json
|
|
48
|
+
status status_code
|
|
49
|
+
Legion::JSON.dump({ error: { code: code, message: message } })
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
unless method_defined?(:json_collection)
|
|
54
|
+
define_method(:json_collection) do |dataset|
|
|
55
|
+
content_type :json
|
|
56
|
+
Legion::JSON.dump({ data: dataset.all.map(&:values) })
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
unless method_defined?(:current_owner_msid)
|
|
61
|
+
define_method(:current_owner_msid) do
|
|
62
|
+
env['legion.owner_msid']
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
register_roles(app)
|
|
68
|
+
register_check(app)
|
|
69
|
+
register_assignments(app)
|
|
70
|
+
register_grants(app)
|
|
71
|
+
register_cross_team_grants(app)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.register_roles(app)
|
|
75
|
+
app.get '/api/rbac/roles' do
|
|
76
|
+
return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
|
|
77
|
+
|
|
78
|
+
roles = Legion::Rbac.role_index.transform_values do |role|
|
|
79
|
+
{ name: role.name, description: role.description, cross_team: role.cross_team? }
|
|
80
|
+
end
|
|
81
|
+
json_response(roles)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
app.get '/api/rbac/roles/:name' do
|
|
85
|
+
return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
|
|
86
|
+
|
|
87
|
+
role = Legion::Rbac.role_index[params[:name].to_sym]
|
|
88
|
+
halt 404, json_error('not_found', "Role #{params[:name]} not found", status_code: 404) unless role
|
|
89
|
+
|
|
90
|
+
json_response({
|
|
91
|
+
name: role.name,
|
|
92
|
+
description: role.description,
|
|
93
|
+
cross_team: role.cross_team?,
|
|
94
|
+
permissions: role.permissions.map { |p| { resource: p.resource_pattern, actions: p.actions } },
|
|
95
|
+
deny_rules: role.deny_rules.map { |d| { resource: d.resource_pattern, above_level: d.above_level } }
|
|
96
|
+
})
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.register_check(app)
|
|
101
|
+
app.post '/api/rbac/check' do
|
|
102
|
+
Legion::Logging.debug "API: POST /api/rbac/check params=#{params.keys}" if defined?(Legion::Logging)
|
|
103
|
+
return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
|
|
104
|
+
|
|
105
|
+
body = parse_request_body
|
|
106
|
+
principal = Legion::Rbac::Principal.new(
|
|
107
|
+
id: body[:principal] || 'anonymous',
|
|
108
|
+
roles: body[:roles] || [],
|
|
109
|
+
team: body[:team]
|
|
110
|
+
)
|
|
111
|
+
result = Legion::Rbac::PolicyEngine.evaluate(
|
|
112
|
+
principal: principal,
|
|
113
|
+
action: body[:action] || 'read',
|
|
114
|
+
resource: body[:resource] || '*',
|
|
115
|
+
enforce: false
|
|
116
|
+
)
|
|
117
|
+
json_response(result)
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
Legion::Logging.error "API POST /api/rbac/check: #{e.class} — #{e.message}" if defined?(Legion::Logging)
|
|
120
|
+
json_error('rbac_error', e.message, status_code: 500)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.register_assignments(app) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
125
|
+
app.get '/api/rbac/assignments' do
|
|
126
|
+
return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
|
|
127
|
+
return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
|
|
128
|
+
|
|
129
|
+
dataset = Legion::Data::Model::RbacRoleAssignment.order(:id)
|
|
130
|
+
dataset = dataset.where(team: params[:team]) if params[:team]
|
|
131
|
+
dataset = dataset.where(role: params[:role]) if params[:role]
|
|
132
|
+
dataset = dataset.where(principal_id: params[:principal]) if params[:principal]
|
|
133
|
+
json_collection(dataset)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
app.post '/api/rbac/assignments' do
|
|
137
|
+
Legion::Logging.debug "API: POST /api/rbac/assignments params=#{params.keys}" if defined?(Legion::Logging)
|
|
138
|
+
return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
|
|
139
|
+
return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
|
|
140
|
+
|
|
141
|
+
body = parse_request_body
|
|
142
|
+
record = Legion::Data::Model::RbacRoleAssignment.create(
|
|
143
|
+
principal_type: body[:principal_type] || 'human',
|
|
144
|
+
principal_id: body[:principal_id],
|
|
145
|
+
role: body[:role],
|
|
146
|
+
team: body[:team],
|
|
147
|
+
granted_by: current_owner_msid || 'api',
|
|
148
|
+
expires_at: body[:expires_at] ? Time.parse(body[:expires_at]) : nil
|
|
149
|
+
)
|
|
150
|
+
Legion::Logging.info "API: created RBAC assignment #{record.id} role=#{body[:role]} principal=#{body[:principal_id]}" if defined?(Legion::Logging)
|
|
151
|
+
json_response(record.values, status_code: 201)
|
|
152
|
+
rescue Sequel::ValidationFailed => e
|
|
153
|
+
Legion::Logging.warn "API POST /api/rbac/assignments returned 422: #{e.message}" if defined?(Legion::Logging)
|
|
154
|
+
json_error('validation_error', e.message, status_code: 422)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
app.delete '/api/rbac/assignments/:id' do
|
|
158
|
+
return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
|
|
159
|
+
return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
|
|
160
|
+
|
|
161
|
+
record = Legion::Data::Model::RbacRoleAssignment[params[:id].to_i]
|
|
162
|
+
halt 404, json_error('not_found', 'Assignment not found', status_code: 404) unless record
|
|
163
|
+
|
|
164
|
+
record.destroy
|
|
165
|
+
Legion::Logging.info "API: deleted RBAC assignment #{params[:id]}" if defined?(Legion::Logging)
|
|
166
|
+
json_response({ deleted: true })
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.register_grants(app)
|
|
171
|
+
app.get '/api/rbac/grants' do
|
|
172
|
+
return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
|
|
173
|
+
return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
|
|
174
|
+
|
|
175
|
+
dataset = Legion::Data::Model::RbacRunnerGrant.order(:id)
|
|
176
|
+
dataset = dataset.where(team: params[:team]) if params[:team]
|
|
177
|
+
json_collection(dataset)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
app.post '/api/rbac/grants' do
|
|
181
|
+
Legion::Logging.debug "API: POST /api/rbac/grants params=#{params.keys}" if defined?(Legion::Logging)
|
|
182
|
+
return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
|
|
183
|
+
return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
|
|
184
|
+
|
|
185
|
+
body = parse_request_body
|
|
186
|
+
record = Legion::Data::Model::RbacRunnerGrant.create(
|
|
187
|
+
team: body[:team],
|
|
188
|
+
runner_pattern: body[:runner_pattern],
|
|
189
|
+
actions: Array(body[:actions]).join(','),
|
|
190
|
+
granted_by: current_owner_msid || 'api'
|
|
191
|
+
)
|
|
192
|
+
Legion::Logging.info "API: created RBAC grant #{record.id} team=#{body[:team]} pattern=#{body[:runner_pattern]}" if defined?(Legion::Logging)
|
|
193
|
+
json_response(record.values, status_code: 201)
|
|
194
|
+
rescue Sequel::ValidationFailed => e
|
|
195
|
+
Legion::Logging.warn "API POST /api/rbac/grants returned 422: #{e.message}" if defined?(Legion::Logging)
|
|
196
|
+
json_error('validation_error', e.message, status_code: 422)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
app.delete '/api/rbac/grants/:id' do
|
|
200
|
+
return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
|
|
201
|
+
return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
|
|
202
|
+
|
|
203
|
+
record = Legion::Data::Model::RbacRunnerGrant[params[:id].to_i]
|
|
204
|
+
halt 404, json_error('not_found', 'Grant not found', status_code: 404) unless record
|
|
205
|
+
|
|
206
|
+
record.destroy
|
|
207
|
+
Legion::Logging.info "API: deleted RBAC grant #{params[:id]}" if defined?(Legion::Logging)
|
|
208
|
+
json_response({ deleted: true })
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def self.register_cross_team_grants(app)
|
|
213
|
+
app.get '/api/rbac/grants/cross-team' do
|
|
214
|
+
return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
|
|
215
|
+
return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
|
|
216
|
+
|
|
217
|
+
dataset = Legion::Data::Model::RbacCrossTeamGrant.order(:id)
|
|
218
|
+
json_collection(dataset)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
app.post '/api/rbac/grants/cross-team' do
|
|
222
|
+
Legion::Logging.debug "API: POST /api/rbac/grants/cross-team params=#{params.keys}" if defined?(Legion::Logging)
|
|
223
|
+
return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
|
|
224
|
+
return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
|
|
225
|
+
|
|
226
|
+
body = parse_request_body
|
|
227
|
+
record = Legion::Data::Model::RbacCrossTeamGrant.create(
|
|
228
|
+
source_team: body[:source_team],
|
|
229
|
+
target_team: body[:target_team],
|
|
230
|
+
runner_pattern: body[:runner_pattern],
|
|
231
|
+
actions: Array(body[:actions]).join(','),
|
|
232
|
+
granted_by: current_owner_msid || 'api',
|
|
233
|
+
expires_at: body[:expires_at] ? Time.parse(body[:expires_at]) : nil
|
|
234
|
+
)
|
|
235
|
+
Legion::Logging.info "API: created cross-team RBAC grant #{record.id} #{body[:source_team]}->#{body[:target_team]}" if defined?(Legion::Logging)
|
|
236
|
+
json_response(record.values, status_code: 201)
|
|
237
|
+
rescue Sequel::ValidationFailed => e
|
|
238
|
+
Legion::Logging.warn "API POST /api/rbac/grants/cross-team returned 422: #{e.message}" if defined?(Legion::Logging)
|
|
239
|
+
json_error('validation_error', e.message, status_code: 422)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
app.delete '/api/rbac/grants/cross-team/:id' do
|
|
243
|
+
return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
|
|
244
|
+
return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
|
|
245
|
+
|
|
246
|
+
record = Legion::Data::Model::RbacCrossTeamGrant[params[:id].to_i]
|
|
247
|
+
halt 404, json_error('not_found', 'Grant not found', status_code: 404) unless record
|
|
248
|
+
|
|
249
|
+
record.destroy
|
|
250
|
+
Legion::Logging.info "API: deleted cross-team RBAC grant #{params[:id]}" if defined?(Legion::Logging)
|
|
251
|
+
json_response({ deleted: true })
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
class << self
|
|
256
|
+
private :register_roles, :register_check, :register_assignments, :register_grants, :register_cross_team_grants
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
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
|
@@ -11,6 +11,9 @@ require 'legion/rbac/team_scope'
|
|
|
11
11
|
require 'legion/rbac/store'
|
|
12
12
|
require 'legion/rbac/entra_claims_mapper'
|
|
13
13
|
require 'legion/rbac/middleware'
|
|
14
|
+
require 'legion/rbac/routes'
|
|
15
|
+
require 'legion/rbac/capability_audit'
|
|
16
|
+
require 'legion/rbac/capability_registry'
|
|
14
17
|
|
|
15
18
|
module Legion
|
|
16
19
|
module Rbac
|
|
@@ -26,10 +29,20 @@ module Legion
|
|
|
26
29
|
class << self
|
|
27
30
|
attr_reader :role_index
|
|
28
31
|
|
|
32
|
+
def register_routes
|
|
33
|
+
return unless defined?(Legion::API) && Legion::API.respond_to?(:register_library_routes)
|
|
34
|
+
|
|
35
|
+
Legion::API.register_library_routes('rbac', Legion::Rbac::Routes)
|
|
36
|
+
Legion::Logging.debug 'Legion::Rbac routes registered with API' if defined?(Legion::Logging)
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
Legion::Logging.warn "Legion::Rbac route registration failed: #{e.message}" if defined?(Legion::Logging)
|
|
39
|
+
end
|
|
40
|
+
|
|
29
41
|
def setup
|
|
30
42
|
Legion::Settings.merge_settings(:rbac, Legion::Rbac::Settings.default)
|
|
31
43
|
@role_index = ConfigLoader.load_roles
|
|
32
44
|
Legion::Settings[:rbac][:connected] = true
|
|
45
|
+
register_routes
|
|
33
46
|
end
|
|
34
47
|
|
|
35
48
|
def shutdown
|
|
@@ -49,6 +62,31 @@ module Legion
|
|
|
49
62
|
authorize!(principal: principal, action: :execute, resource: runner_path, **)
|
|
50
63
|
end
|
|
51
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
|
+
|
|
52
90
|
private
|
|
53
91
|
|
|
54
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
|
|
@@ -47,6 +47,8 @@ extra_rdoc_files:
|
|
|
47
47
|
- LICENSE
|
|
48
48
|
- README.md
|
|
49
49
|
files:
|
|
50
|
+
- ".github/CODEOWNERS"
|
|
51
|
+
- ".github/dependabot.yml"
|
|
50
52
|
- ".github/workflows/ci.yml"
|
|
51
53
|
- ".gitignore"
|
|
52
54
|
- ".rspec"
|
|
@@ -59,6 +61,8 @@ files:
|
|
|
59
61
|
- docs/implementation-plan.md
|
|
60
62
|
- legion-rbac.gemspec
|
|
61
63
|
- lib/legion/rbac.rb
|
|
64
|
+
- lib/legion/rbac/capability_audit.rb
|
|
65
|
+
- lib/legion/rbac/capability_registry.rb
|
|
62
66
|
- lib/legion/rbac/config_loader.rb
|
|
63
67
|
- lib/legion/rbac/entra_claims_mapper.rb
|
|
64
68
|
- lib/legion/rbac/kerberos_claims_mapper.rb
|
|
@@ -67,6 +71,7 @@ files:
|
|
|
67
71
|
- lib/legion/rbac/policy_engine.rb
|
|
68
72
|
- lib/legion/rbac/principal.rb
|
|
69
73
|
- lib/legion/rbac/role.rb
|
|
74
|
+
- lib/legion/rbac/routes.rb
|
|
70
75
|
- lib/legion/rbac/settings.rb
|
|
71
76
|
- lib/legion/rbac/store.rb
|
|
72
77
|
- lib/legion/rbac/team_scope.rb
|