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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 345f210c7e7263e7d484a6ff6122dd1d9ac7d5bd4e00e93303924c50394493c9
4
- data.tar.gz: 424af65233d1f63374d173e979df6bc0deb2b987c31a84569ed02c1447d1f7ff
3
+ metadata.gz: db6027eb10e80db5c7fce237a53e1640036d4d30e73e2e468029054843df5c4f
4
+ data.tar.gz: dd520f3dc81753b13bb29bf41bd664bbb0acdd789697a50649f93f77c68d3655
5
5
  SHA512:
6
- metadata.gz: 672e559fbe46910f934899fa75bed1283ebccc62534f503d59f0f4b190c282042daa82571f33312ec64ccf88c823a98a0a9d2bfa3e753646d2586d05b754b9aa
7
- data.tar.gz: ba2da7a5ce8a08dc9aa5343e8bc711f672be4350b9b0617b1689631e6b0b66d81513dede28cde136a571660ab1082d02df0afb6b043f51809024adb94752a9f1
6
+ metadata.gz: e56a6e0e93987b829f04ca89c907753d893ec9d6907d7cf8f29d011b92b82906b249abac00fe3951142589b6ba8cbd8308fe0fd57b9d5974c708e00b744585f3
7
+ data.tar.gz: 5d47a2e7805ae8fcd242c1a7bcf2f916063adf8517831ec06b198f77c145b4734eaa9b36d4ce8450eebe17d5ff47c852abd8288ae9da9f6e115780f9bb152f45
@@ -0,0 +1,7 @@
1
+ # Auto-generated from team-config.yml
2
+ # Team: core
3
+ #
4
+ # To apply: scripts/apply-codeowners.sh legion-rbac
5
+
6
+ * @LegionIO/maintainers
7
+ * @LegionIO/core
@@ -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"
@@ -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
@@ -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
@@ -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: name,
13
- description: config[:description] || '',
14
- permissions: config[:permissions] || [],
15
- deny: config[:deny] || [],
16
- cross_team: config[:cross_team] || false
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
@@ -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
@@ -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: 'Execute assigned runners within team scope',
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: 'Manage workers and schedules within team scope',
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: 'Full access, cross-team capability',
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: true
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: 'Read-only visibility across all teams for audit and compliance',
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: true
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Rbac
5
- VERSION = '0.2.7'
5
+ VERSION = '0.2.9'
6
6
  end
7
7
  end
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.7
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