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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 59a7e721e473fdb1e65fe31d2da68e7fc7fc0a65e2e077f199f62c98aaf78fb5
4
- data.tar.gz: b59c16e31ab6f63b1f93947a6c47ba59c891c2275a4f73463f892f4c0a71d9a8
3
+ metadata.gz: db6027eb10e80db5c7fce237a53e1640036d4d30e73e2e468029054843df5c4f
4
+ data.tar.gz: dd520f3dc81753b13bb29bf41bd664bbb0acdd789697a50649f93f77c68d3655
5
5
  SHA512:
6
- metadata.gz: c8431ff82ad752660da99454b4170999572d2c37259fe33605238fc4faa6fdabe3d5c50a29dfc0d40c2fb45225107e457f15ac1e14aa0d8c41f6985cb39aa855
7
- data.tar.gz: aafee7d9c8e9a3a07974aae8b890a8120dda0a10906a45ec1e967745563db0c2eed90ae39d2e14603d383df900ef450add66484d00cd4254288c573d976a47f2
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
@@ -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
@@ -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.8'
5
+ VERSION = '0.2.9'
6
6
  end
7
7
  end
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.8
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