legionio 1.4.13 → 1.4.14

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: 8b61599f64cd23e52b9cbf85631dce4bdb16afeec87b5022229e7ae63f664917
4
- data.tar.gz: 339df852d48a4ce9eddc92d1a8fd6c3240bda51f7d91f258352a6bc021aea2f0
3
+ metadata.gz: '0927bca374a6df75ab9a1e75067219af7205291a694defff2fa356fe2b330bf3'
4
+ data.tar.gz: 1c3171e36c3447a104889d556df53596e611b4965d60abb08d4e91fe5c50a996
5
5
  SHA512:
6
- metadata.gz: bbb574075f5512af6a3783acb5bf02d666cf94eb959ac9827639e3bfb29467b1c97ad0d2d562e8318b173e3eaaf73a1ad2db7aaa29957f1682ffd0076dfaa27d
7
- data.tar.gz: 2e4dd37e3f3a7e4520d4b270998a2df0eb1e51accc235061b0974fad27977895e7e29064a6af671b003462815a143081c808fd938e0e4b11eda2823de5416c8d
6
+ metadata.gz: 4bba3556ee1380f6b61aaa14263dbcccb4340428237f0203cbed9d7090d1cf95e811109b75ebf14e1a1bd3767640ad805e2dee4b4d5711352aa7becd528bf259
7
+ data.tar.gz: 243d79f20efa83d65b41a6e39f3953aee765369f37aa30caa6b85d16cb0baeab95e46628a443c45b082f233925c3ba413734c5e8aa45146c3827e9f367597ae7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.14] - 2026-03-16
4
+
5
+ ### Added
6
+ - Optional RBAC integration via legion-rbac gem (`if defined?(Legion::Rbac)` guards)
7
+ - `setup_rbac` lifecycle hook in Service (after setup_data)
8
+ - `authorize_execution!` guard in Ingress for task execution
9
+ - Rack middleware registration in API when legion-rbac loaded
10
+ - REST API routes for RBAC management (roles, assignments, grants, cross-team grants, check)
11
+ - `legion rbac` CLI subcommand (roles, show, assignments, assign, revoke, grants, grant, check)
12
+ - MCP tools: legion.rbac_check, legion.rbac_assignments, legion.rbac_grants
13
+
3
14
  ## [1.4.13] - 2026-03-16
4
15
 
5
16
  ### Changed
data/CLAUDE.md CHANGED
@@ -346,6 +346,22 @@ legion
346
346
  bash # output bash completion script
347
347
  zsh # output zsh completion script
348
348
  install # print installation instructions
349
+
350
+ openapi
351
+ generate [-o FILE] # output OpenAPI 3.1.0 spec JSON
352
+ routes # list all API routes with HTTP method + summary
353
+
354
+ doctor [--fix] [--json] # diagnose environment, suggest/apply fixes
355
+ # checks: Ruby, bundle, config, RabbitMQ, DB, cache, Vault,
356
+ # extensions, PID files, permissions
357
+ # exit 0=all pass, 1=any fail
358
+
359
+ telemetry
360
+ stats [SESSION_ID] # aggregate or per-session telemetry stats
361
+ ingest PATH # manually ingest a session log file
362
+
363
+ auth
364
+ teams [--tenant-id ID] [--client-id ID] # browser OAuth flow for Microsoft Teams
349
365
  ```
350
366
 
351
367
  **CLI design rules:**
@@ -450,6 +466,8 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
450
466
  | `lib/legion/api/coldstart.rb` | Coldstart: `POST /api/coldstart/ingest` — triggers lex-coldstart ingest runner (requires lex-coldstart + lex-memory) |
451
467
  | `lib/legion/api/gaia.rb` | Gaia: system status endpoints |
452
468
  | `lib/legion/api/token.rb` | Token: JWT token issuance endpoint |
469
+ | `lib/legion/api/openapi.rb` | OpenAPI: `Legion::API::OpenAPI.spec` / `.to_json`; also served at `GET /api/openapi.json` |
470
+ | `lib/legion/api/oauth.rb` | OAuth: `GET /api/oauth/microsoft_teams/callback` — receives delegated OAuth redirect and stores tokens |
453
471
  | `lib/legion/api/middleware/auth.rb` | Auth: JWT Bearer auth middleware (real token validation, skip paths for health/ready) |
454
472
  | **MCP** | |
455
473
  | `lib/legion/mcp.rb` | Entry point: `Legion::MCP.server` singleton factory |
@@ -506,6 +524,10 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
506
524
  | `lib/legion/cli/gaia_command.rb` | `legion gaia` subcommands (status) |
507
525
  | `lib/legion/cli/schedule_command.rb` | `legion schedule` subcommands (list, show, add, remove, logs) |
508
526
  | `lib/legion/cli/completion_command.rb` | `legion completion` subcommands (bash, zsh, install) |
527
+ | `lib/legion/cli/openapi_command.rb` | `legion openapi` subcommands (generate, routes); also `GET /api/openapi.json` endpoint |
528
+ | `lib/legion/cli/doctor_command.rb` | `legion doctor` — 10-check environment diagnosis; `Doctor::Result` value object with status/message/prescription/auto_fixable |
529
+ | `lib/legion/cli/telemetry_command.rb` | `legion telemetry` subcommands (stats, ingest) — session log analytics |
530
+ | `lib/legion/cli/auth_command.rb` | `legion auth` subcommands (teams) — delegated OAuth browser flow for external services |
509
531
  | `completions/legion.bash` | Bash tab completion script |
510
532
  | `completions/_legion` | Zsh tab completion script |
511
533
  | `lib/legion/cli/theme.rb` | Purple palette, orbital ASCII banner, branded CLI output |
data/Gemfile CHANGED
@@ -14,6 +14,7 @@ unless ENV['CI']
14
14
  gem 'legion-json', path: '../legion-json'
15
15
  gem 'legion-llm', path: '../legion-llm'
16
16
  gem 'legion-logging', path: '../legion-logging'
17
+ gem 'legion-rbac', path: '../legion-rbac'
17
18
  gem 'legion-settings', path: '../legion-settings'
18
19
  gem 'legion-transport', path: '../legion-transport'
19
20
 
data/README.md CHANGED
@@ -14,7 +14,7 @@ Schedule tasks, chain services into dependency graphs, run them concurrently via
14
14
  ╰──────────────────────────────────────╯
15
15
  ```
16
16
 
17
- **Ruby >= 3.4** | **v1.4.6** | **Apache-2.0** | [@Esity](https://github.com/Esity)
17
+ **Ruby >= 3.4** | **v1.4.13** | **Apache-2.0** | [@Esity](https://github.com/Esity)
18
18
 
19
19
  ---
20
20
 
@@ -210,6 +210,16 @@ legion config scaffold # generate starter config files
210
210
 
211
211
  Settings load from the first directory found: `/etc/legionio/` → `~/legionio/` → `./settings/`
212
212
 
213
+ ### Diagnostics
214
+
215
+ ```bash
216
+ legion doctor # diagnose environment, suggest fixes
217
+ legion doctor --fix # auto-remediate fixable issues (stale PIDs, missing gems)
218
+ legion doctor --json # machine-readable output
219
+ ```
220
+
221
+ Checks Ruby version, bundle status, config files, RabbitMQ, database, cache, Vault, extensions, PID files, and permissions. Exits 1 if any check fails.
222
+
213
223
  All commands support `--json` for structured output and `--no-color` to strip ANSI codes.
214
224
 
215
225
  ## REST API
@@ -332,6 +342,25 @@ legion generate actor myactor
332
342
  bundle exec rspec
333
343
  ```
334
344
 
345
+ ## Role Profiles
346
+
347
+ Control which extensions load at startup via `settings/legion.json`:
348
+
349
+ ```json
350
+ {"role": {"profile": "dev"}}
351
+ ```
352
+
353
+ | Profile | What loads |
354
+ |---------|-----------|
355
+ | *(default)* | Everything — no filtering |
356
+ | `core` | 14 core operational extensions only |
357
+ | `cognitive` | core + all agentic extensions |
358
+ | `service` | core + service + other integrations |
359
+ | `dev` | core + AI + essential agentic (~20 extensions) |
360
+ | `custom` | only what's listed in `role.extensions` |
361
+
362
+ Faster boot and lower memory footprint for dedicated worker roles.
363
+
335
364
  ## Scaling
336
365
 
337
366
  Task distribution uses RabbitMQ FIFO queues. Add workers by running more Legion processes — each subscribes to the same queues and picks up work automatically. Tested to 100+ workers.
@@ -363,6 +392,12 @@ CMD ruby --yjit $(which legion) start
363
392
 
364
393
  ## Architecture
365
394
 
395
+ Before any Legion code loads, the executable applies three performance optimizations:
396
+
397
+ - **YJIT** — `RubyVM::YJIT.enable` for 15-30% runtime throughput (Ruby 3.1+ builds)
398
+ - **GC tuning** — pre-allocates 600k heap slots and raises malloc limits (ENV overrides respected)
399
+ - **bootsnap** — caches YARV bytecodes and `$LOAD_PATH` resolution at `~/.legionio/cache/bootsnap/`
400
+
366
401
  ```
367
402
  legion start
368
403
  └── Legion::Service
@@ -374,13 +409,15 @@ legion start
374
409
  ├── 6. Data (legion-data — database + migrations)
375
410
  ├── 7. LLM (legion-llm — AI provider setup + routing)
376
411
  ├── 8. Supervision (process supervision)
377
- ├── 9. Extensions (discover + load 280+ LEX gems)
412
+ ├── 9. Extensions (discover + load 280+ LEX gems, filtered by role profile)
378
413
  ├── 10. Cluster Secret (distribute via Vault or memory)
379
414
  └── 11. API (Sinatra/Puma on port 4567)
380
415
  ```
381
416
 
382
417
  Each phase registers with `Legion::Readiness`. All phases are individually toggleable.
383
418
 
419
+ `SIGHUP` triggers a live reload (`Legion.reload`) — subsystems shut down in reverse order and restart fresh without killing the process. Useful for rolling restarts and config changes.
420
+
384
421
  ## Similar Projects
385
422
 
386
423
  | Project | Language | HA | AI | Cognitive |
@@ -397,7 +434,7 @@ Each phase registers with `Legion::Readiness`. All phases are individually toggl
397
434
  git clone https://github.com/LegionIO/LegionIO.git
398
435
  cd LegionIO
399
436
  bundle install
400
- bundle exec rspec # 694 examples, 0 failures
437
+ bundle exec rspec # 880 examples, 0 failures
401
438
  bundle exec rubocop # 0 offenses
402
439
  ```
403
440
 
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module Rbac
7
+ def self.registered(app)
8
+ register_roles(app)
9
+ register_check(app)
10
+ register_assignments(app)
11
+ register_grants(app)
12
+ register_cross_team_grants(app)
13
+ end
14
+
15
+ def self.register_roles(app)
16
+ app.get '/api/rbac/roles' do
17
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
18
+
19
+ roles = Legion::Rbac.role_index.transform_values do |role|
20
+ { name: role.name, description: role.description, cross_team: role.cross_team? }
21
+ end
22
+ json_response(roles)
23
+ end
24
+
25
+ app.get '/api/rbac/roles/:name' do
26
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
27
+
28
+ role = Legion::Rbac.role_index[params[:name].to_sym]
29
+ halt 404, json_error('not_found', "Role #{params[:name]} not found", status_code: 404) unless role
30
+
31
+ json_response({
32
+ name: role.name,
33
+ description: role.description,
34
+ cross_team: role.cross_team?,
35
+ permissions: role.permissions.map { |p| { resource: p.resource_pattern, actions: p.actions } },
36
+ deny_rules: role.deny_rules.map { |d| { resource: d.resource_pattern, above_level: d.above_level } }
37
+ })
38
+ end
39
+ end
40
+
41
+ def self.register_check(app)
42
+ app.post '/api/rbac/check' do
43
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
44
+
45
+ body = parse_request_body
46
+ principal = Legion::Rbac::Principal.new(
47
+ id: body[:principal] || 'anonymous',
48
+ roles: body[:roles] || [],
49
+ team: body[:team]
50
+ )
51
+ result = Legion::Rbac::PolicyEngine.evaluate(
52
+ principal: principal,
53
+ action: body[:action] || 'read',
54
+ resource: body[:resource] || '*',
55
+ enforce: false
56
+ )
57
+ json_response(result)
58
+ end
59
+ end
60
+
61
+ def self.register_assignments(app)
62
+ app.get '/api/rbac/assignments' do
63
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
64
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
65
+
66
+ dataset = Legion::Data::Model::RbacRoleAssignment.order(:id)
67
+ dataset = dataset.where(team: params[:team]) if params[:team]
68
+ dataset = dataset.where(role: params[:role]) if params[:role]
69
+ dataset = dataset.where(principal_id: params[:principal]) if params[:principal]
70
+ json_collection(dataset)
71
+ end
72
+
73
+ app.post '/api/rbac/assignments' do
74
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
75
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
76
+
77
+ body = parse_request_body
78
+ record = Legion::Data::Model::RbacRoleAssignment.create(
79
+ principal_type: body[:principal_type] || 'human',
80
+ principal_id: body[:principal_id],
81
+ role: body[:role],
82
+ team: body[:team],
83
+ granted_by: current_owner_msid || 'api',
84
+ expires_at: body[:expires_at] ? Time.parse(body[:expires_at]) : nil
85
+ )
86
+ json_response(record.values, status_code: 201)
87
+ rescue Sequel::ValidationFailed => e
88
+ json_error('validation_error', e.message, status_code: 422)
89
+ end
90
+
91
+ app.delete '/api/rbac/assignments/:id' do
92
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
93
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
94
+
95
+ record = Legion::Data::Model::RbacRoleAssignment[params[:id].to_i]
96
+ halt 404, json_error('not_found', 'Assignment not found', status_code: 404) unless record
97
+
98
+ record.destroy
99
+ json_response({ deleted: true })
100
+ end
101
+ end
102
+
103
+ def self.register_grants(app)
104
+ app.get '/api/rbac/grants' do
105
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
106
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
107
+
108
+ dataset = Legion::Data::Model::RbacRunnerGrant.order(:id)
109
+ dataset = dataset.where(team: params[:team]) if params[:team]
110
+ json_collection(dataset)
111
+ end
112
+
113
+ app.post '/api/rbac/grants' do
114
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
115
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
116
+
117
+ body = parse_request_body
118
+ record = Legion::Data::Model::RbacRunnerGrant.create(
119
+ team: body[:team],
120
+ runner_pattern: body[:runner_pattern],
121
+ actions: Array(body[:actions]).join(','),
122
+ granted_by: current_owner_msid || 'api'
123
+ )
124
+ json_response(record.values, status_code: 201)
125
+ rescue Sequel::ValidationFailed => e
126
+ json_error('validation_error', e.message, status_code: 422)
127
+ end
128
+
129
+ app.delete '/api/rbac/grants/:id' do
130
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
131
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
132
+
133
+ record = Legion::Data::Model::RbacRunnerGrant[params[:id].to_i]
134
+ halt 404, json_error('not_found', 'Grant not found', status_code: 404) unless record
135
+
136
+ record.destroy
137
+ json_response({ deleted: true })
138
+ end
139
+ end
140
+
141
+ def self.register_cross_team_grants(app)
142
+ app.get '/api/rbac/grants/cross-team' do
143
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
144
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
145
+
146
+ dataset = Legion::Data::Model::RbacCrossTeamGrant.order(:id)
147
+ json_collection(dataset)
148
+ end
149
+
150
+ app.post '/api/rbac/grants/cross-team' do
151
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
152
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
153
+
154
+ body = parse_request_body
155
+ record = Legion::Data::Model::RbacCrossTeamGrant.create(
156
+ source_team: body[:source_team],
157
+ target_team: body[:target_team],
158
+ runner_pattern: body[:runner_pattern],
159
+ actions: Array(body[:actions]).join(','),
160
+ granted_by: current_owner_msid || 'api',
161
+ expires_at: body[:expires_at] ? Time.parse(body[:expires_at]) : nil
162
+ )
163
+ json_response(record.values, status_code: 201)
164
+ rescue Sequel::ValidationFailed => e
165
+ json_error('validation_error', e.message, status_code: 422)
166
+ end
167
+
168
+ app.delete '/api/rbac/grants/cross-team/:id' do
169
+ return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac)
170
+ return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available?
171
+
172
+ record = Legion::Data::Model::RbacCrossTeamGrant[params[:id].to_i]
173
+ halt 404, json_error('not_found', 'Grant not found', status_code: 404) unless record
174
+
175
+ record.destroy
176
+ json_response({ deleted: true })
177
+ end
178
+ end
179
+
180
+ class << self
181
+ private :register_roles, :register_check, :register_assignments, :register_grants, :register_cross_team_grants
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
data/lib/legion/api.rb CHANGED
@@ -22,6 +22,7 @@ require_relative 'api/coldstart'
22
22
  require_relative 'api/gaia'
23
23
  require_relative 'api/oauth'
24
24
  require_relative 'api/openapi'
25
+ require_relative 'api/rbac'
25
26
 
26
27
  module Legion
27
28
  class API < Sinatra::Base
@@ -88,6 +89,9 @@ module Legion
88
89
  register Routes::Coldstart
89
90
  register Routes::Gaia
90
91
  register Routes::OAuth
92
+ register Routes::Rbac
93
+
94
+ use Legion::Rbac::Middleware if defined?(Legion::Rbac::Middleware)
91
95
 
92
96
  # Hook registry (preserved from original implementation)
93
97
  class << self
@@ -11,7 +11,7 @@ module Legion
11
11
  module_function
12
12
 
13
13
  def run(formatter, options)
14
- dir = options[:dir] || './settings'
14
+ dir = options[:dir] || "#{Dir.home}/.legionio/settings"
15
15
  only = options[:only] ? options[:only].split(',').map(&:strip) : SUBSYSTEMS
16
16
  full_mode = options[:full]
17
17
  force = options[:force]
@@ -132,6 +132,7 @@ module Legion
132
132
 
133
133
  [
134
134
  '/etc/legionio',
135
+ "#{Dir.home}/.legionio/settings",
135
136
  "#{Dir.home}/legionio",
136
137
  '~/legionio',
137
138
  './settings'
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module CLI
5
+ class Rbac < Thor
6
+ def self.exit_on_failure?
7
+ true
8
+ end
9
+
10
+ class_option :json, type: :boolean, default: false, desc: 'Output as JSON'
11
+ class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
12
+ class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging'
13
+ class_option :config_dir, type: :string, desc: 'Config directory path'
14
+
15
+ desc 'roles', 'List role definitions from config'
16
+ def roles
17
+ out = formatter
18
+ with_rbac do
19
+ index = Legion::Rbac.role_index
20
+ if options[:json]
21
+ out.json(index.transform_values { |r| { description: r.description, cross_team: r.cross_team? } })
22
+ else
23
+ rows = index.map { |name, r| [name.to_s, r.description, r.cross_team? ? 'yes' : 'no'] }
24
+ out.table(%w[Role Description CrossTeam], rows)
25
+ end
26
+ end
27
+ end
28
+ default_task :roles
29
+
30
+ desc 'show ROLE', 'Show permissions for a role'
31
+ def show(role_name)
32
+ out = formatter
33
+ with_rbac do
34
+ role = Legion::Rbac.role_index[role_name.to_sym]
35
+ unless role
36
+ out.error("Role not found: #{role_name}")
37
+ return
38
+ end
39
+
40
+ if options[:json]
41
+ out.json({
42
+ name: role.name,
43
+ description: role.description,
44
+ cross_team: role.cross_team?,
45
+ permissions: role.permissions.map { |p| { resource: p.resource_pattern, actions: p.actions } },
46
+ deny_rules: role.deny_rules.map { |d| { resource: d.resource_pattern, above_level: d.above_level } }
47
+ })
48
+ else
49
+ out.header("Role: #{role.name}")
50
+ puts " #{role.description}"
51
+ puts " Cross-team: #{role.cross_team? ? 'yes' : 'no'}"
52
+ puts "\n Permissions:"
53
+ role.permissions.each { |p| puts " #{p.resource_pattern} -> #{p.actions.join(', ')}" }
54
+ puts "\n Deny rules:"
55
+ role.deny_rules.each { |d| puts " #{d.resource_pattern}#{" (above level #{d.above_level})" if d.above_level}" }
56
+ end
57
+ end
58
+ end
59
+
60
+ desc 'assignments', 'List role assignments from DB'
61
+ option :team, type: :string, desc: 'Filter by team'
62
+ option :role, type: :string, desc: 'Filter by role'
63
+ option :principal, type: :string, desc: 'Filter by principal ID'
64
+ def assignments
65
+ out = formatter
66
+ with_data do
67
+ ds = Legion::Data::Model::RbacRoleAssignment.dataset
68
+ ds = ds.where(team: options[:team]) if options[:team]
69
+ ds = ds.where(role: options[:role]) if options[:role]
70
+ ds = ds.where(principal_id: options[:principal]) if options[:principal]
71
+
72
+ records = ds.all
73
+ if options[:json]
74
+ out.json(records.map(&:values))
75
+ else
76
+ rows = records.map { |r| [r.id, r.principal_id, r.principal_type, r.role, r.team || '-', r.active? ? 'active' : 'expired'] }
77
+ out.table(%w[ID Principal Type Role Team Status], rows)
78
+ end
79
+ end
80
+ end
81
+
82
+ desc 'assign PRINCIPAL ROLE', 'Assign a role to a principal'
83
+ option :type, type: :string, default: 'human', desc: 'Principal type (human/worker)'
84
+ option :team, type: :string, desc: 'Team scope'
85
+ option :expires, type: :string, desc: 'Expiry (ISO 8601)'
86
+ def assign(principal, role)
87
+ out = formatter
88
+ with_data do
89
+ record = Legion::Data::Model::RbacRoleAssignment.create(
90
+ principal_type: options[:type],
91
+ principal_id: principal,
92
+ role: role,
93
+ team: options[:team],
94
+ granted_by: 'cli',
95
+ expires_at: options[:expires] ? Time.parse(options[:expires]) : nil
96
+ )
97
+ out.success("Assigned #{role} to #{principal} (id: #{record.id})")
98
+ end
99
+ end
100
+
101
+ desc 'revoke PRINCIPAL ROLE', 'Remove a role assignment'
102
+ def revoke(principal, role)
103
+ out = formatter
104
+ with_data do
105
+ ds = Legion::Data::Model::RbacRoleAssignment.where(principal_id: principal, role: role)
106
+ count = ds.count
107
+ ds.destroy
108
+ out.success("Revoked #{count} assignment(s) of #{role} from #{principal}")
109
+ end
110
+ end
111
+
112
+ desc 'grants', 'List runner grants'
113
+ option :team, type: :string, desc: 'Filter by team'
114
+ def grants
115
+ out = formatter
116
+ with_data do
117
+ ds = Legion::Data::Model::RbacRunnerGrant.dataset
118
+ ds = ds.where(team: options[:team]) if options[:team]
119
+
120
+ records = ds.all
121
+ if options[:json]
122
+ out.json(records.map(&:values))
123
+ else
124
+ rows = records.map { |r| [r.id, r.team, r.runner_pattern, r.actions] }
125
+ out.table(%w[ID Team Pattern Actions], rows)
126
+ end
127
+ end
128
+ end
129
+
130
+ desc 'grant TEAM PATTERN', 'Grant runner access to a team'
131
+ option :actions, type: :string, default: 'execute', desc: 'Comma-separated actions'
132
+ def grant(team, pattern)
133
+ out = formatter
134
+ with_data do
135
+ record = Legion::Data::Model::RbacRunnerGrant.create(
136
+ team: team,
137
+ runner_pattern: pattern,
138
+ actions: options[:actions],
139
+ granted_by: 'cli'
140
+ )
141
+ out.success("Granted #{pattern} to team #{team} (id: #{record.id})")
142
+ end
143
+ end
144
+
145
+ desc 'check PRINCIPAL RESOURCE', 'Dry-run authorization check'
146
+ option :action, type: :string, default: 'read', desc: 'Action to check'
147
+ option :roles, type: :array, default: [], desc: 'Roles to check (comma-separated)'
148
+ option :team, type: :string, desc: 'Team scope'
149
+ def check(principal_id, resource)
150
+ out = formatter
151
+ with_rbac do
152
+ principal = Legion::Rbac::Principal.new(
153
+ id: principal_id,
154
+ roles: options[:roles],
155
+ team: options[:team]
156
+ )
157
+ result = Legion::Rbac::PolicyEngine.evaluate(
158
+ principal: principal,
159
+ action: options[:action],
160
+ resource: resource,
161
+ enforce: false
162
+ )
163
+ if options[:json]
164
+ out.json(result)
165
+ else
166
+ status = result[:allowed] ? 'ALLOWED' : 'DENIED'
167
+ puts " #{status}: #{principal_id} -> #{options[:action]} #{resource}"
168
+ puts " Reason: #{result[:reason]}" if result[:reason]
169
+ puts " Would deny: #{result[:would_deny]}" if result[:would_deny]
170
+ end
171
+ end
172
+ end
173
+
174
+ no_commands do
175
+ def formatter
176
+ @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color])
177
+ end
178
+
179
+ private
180
+
181
+ def with_rbac
182
+ Connection.config_dir = options[:config_dir] if options[:config_dir]
183
+ Connection.log_level = options[:verbose] ? 'debug' : 'error'
184
+ Connection.ensure_settings
185
+ require 'legion/rbac'
186
+ Legion::Rbac.setup
187
+ yield
188
+ rescue CLI::Error => e
189
+ formatter.error(e.message)
190
+ raise SystemExit, 1
191
+ end
192
+
193
+ def with_data
194
+ Connection.config_dir = options[:config_dir] if options[:config_dir]
195
+ Connection.log_level = options[:verbose] ? 'debug' : 'error'
196
+ Connection.ensure_data
197
+ require 'legion/rbac'
198
+ Legion::Rbac.setup
199
+ yield
200
+ rescue CLI::Error => e
201
+ formatter.error(e.message)
202
+ raise SystemExit, 1
203
+ ensure
204
+ Connection.shutdown
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
data/lib/legion/cli.rb CHANGED
@@ -33,6 +33,7 @@ module Legion
33
33
  autoload :Doctor, 'legion/cli/doctor_command'
34
34
  autoload :Telemetry, 'legion/cli/telemetry_command'
35
35
  autoload :Auth, 'legion/cli/auth_command'
36
+ autoload :Rbac, 'legion/cli/rbac_command'
36
37
 
37
38
  class Main < Thor
38
39
  def self.exit_on_failure?
@@ -195,6 +196,9 @@ module Legion
195
196
  desc 'auth SUBCOMMAND', 'Authenticate with external services'
196
197
  subcommand 'auth', Legion::CLI::Auth
197
198
 
199
+ desc 'rbac SUBCOMMAND', 'Role-based access control management'
200
+ subcommand 'rbac', Legion::CLI::Rbac
201
+
198
202
  desc 'tree', 'Print a tree of all available commands'
199
203
  def tree
200
204
  legion_print_command_tree(self.class, 'legion', '')
@@ -25,11 +25,12 @@ module Legion
25
25
 
26
26
  # Normalize and execute via Legion::Runner.run.
27
27
  # Returns the runner result hash.
28
- def run(payload:, runner_class: nil, function: nil, source: 'unknown', **opts)
28
+ def run(payload:, runner_class: nil, function: nil, source: 'unknown', principal: nil, **opts) # rubocop:disable Metrics/ParameterLists
29
29
  check_subtask = opts.fetch(:check_subtask, true)
30
30
  generate_task = opts.fetch(:generate_task, true)
31
31
  message = normalize(payload: payload, runner_class: runner_class,
32
- function: function, source: source, **opts.except(:check_subtask, :generate_task))
32
+ function: function, source: source,
33
+ **opts.except(:check_subtask, :generate_task, :principal))
33
34
 
34
35
  rc = message.delete(:runner_class)
35
36
  fn = message.delete(:function)
@@ -37,6 +38,11 @@ module Legion
37
38
  raise 'runner_class is required' if rc.nil?
38
39
  raise 'function is required' if fn.nil?
39
40
 
41
+ if defined?(Legion::Rbac)
42
+ principal ||= Legion::Rbac::Principal.local_admin
43
+ Legion::Rbac.authorize_execution!(principal: principal, runner_class: rc.to_s, function: fn.to_s)
44
+ end
45
+
40
46
  Legion::Events.emit('ingress.received', runner_class: rc.to_s, function: fn, source: source)
41
47
 
42
48
  Legion::Runner.run(
@@ -30,6 +30,9 @@ require_relative 'tools/worker_lifecycle'
30
30
  require_relative 'tools/worker_costs'
31
31
  require_relative 'tools/team_summary'
32
32
  require_relative 'tools/routing_stats'
33
+ require_relative 'tools/rbac_check'
34
+ require_relative 'tools/rbac_assignments'
35
+ require_relative 'tools/rbac_grants'
33
36
  require_relative 'resources/runner_catalog'
34
37
  require_relative 'resources/extension_info'
35
38
 
@@ -66,7 +69,10 @@ module Legion
66
69
  Tools::WorkerLifecycle,
67
70
  Tools::WorkerCosts,
68
71
  Tools::TeamSummary,
69
- Tools::RoutingStats
72
+ Tools::RoutingStats,
73
+ Tools::RbacCheck,
74
+ Tools::RbacAssignments,
75
+ Tools::RbacGrants
70
76
  ].freeze
71
77
 
72
78
  class << self
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class RbacAssignments < ::MCP::Tool
7
+ tool_name 'legion.rbac_assignments'
8
+ description 'List RBAC role assignments. Filterable by team, role, or principal.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ team: { type: 'string', description: 'Filter by team' },
13
+ role: { type: 'string', description: 'Filter by role name' },
14
+ principal: { type: 'string', description: 'Filter by principal ID' }
15
+ }
16
+ )
17
+
18
+ class << self
19
+ def call(team: nil, role: nil, principal: nil)
20
+ return error_response('legion-rbac not installed') unless defined?(Legion::Rbac)
21
+ return error_response('legion-data not connected') unless Legion::Rbac::Store.db_available?
22
+
23
+ ds = Legion::Data::Model::RbacRoleAssignment.dataset
24
+ ds = ds.where(team: team) if team
25
+ ds = ds.where(role: role) if role
26
+ ds = ds.where(principal_id: principal) if principal
27
+ text_response(ds.all.map(&:values))
28
+ rescue StandardError => e
29
+ error_response("Failed to list assignments: #{e.message}")
30
+ end
31
+
32
+ private
33
+
34
+ def text_response(data)
35
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
36
+ end
37
+
38
+ def error_response(msg)
39
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class RbacCheck < ::MCP::Tool
7
+ tool_name 'legion.rbac_check'
8
+ description 'Dry-run authorization check. Evaluates RBAC policies without enforcing.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ principal: { type: 'string', description: 'Principal ID to check' },
13
+ action: { type: 'string', description: 'Action (read, execute, manage, etc.)' },
14
+ resource: { type: 'string', description: 'Resource path (e.g. runners/lex-github/*)' },
15
+ roles: { type: 'array', items: { type: 'string' }, description: 'Roles to evaluate' },
16
+ team: { type: 'string', description: 'Team scope' }
17
+ },
18
+ required: %w[principal action resource roles]
19
+ )
20
+
21
+ class << self
22
+ def call(principal:, action:, resource:, roles: [], team: nil)
23
+ return error_response('legion-rbac not installed') unless defined?(Legion::Rbac)
24
+
25
+ p = Legion::Rbac::Principal.new(id: principal, roles: roles, team: team)
26
+ result = Legion::Rbac::PolicyEngine.evaluate(principal: p, action: action, resource: resource, enforce: false)
27
+ text_response(result)
28
+ rescue StandardError => e
29
+ error_response("RBAC check failed: #{e.message}")
30
+ end
31
+
32
+ private
33
+
34
+ def text_response(data)
35
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
36
+ end
37
+
38
+ def error_response(msg)
39
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class RbacGrants < ::MCP::Tool
7
+ tool_name 'legion.rbac_grants'
8
+ description 'List RBAC runner grants. Filterable by team.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ team: { type: 'string', description: 'Filter by team' }
13
+ }
14
+ )
15
+
16
+ class << self
17
+ def call(team: nil)
18
+ return error_response('legion-rbac not installed') unless defined?(Legion::Rbac)
19
+ return error_response('legion-data not connected') unless Legion::Rbac::Store.db_available?
20
+
21
+ ds = Legion::Data::Model::RbacRunnerGrant.dataset
22
+ ds = ds.where(team: team) if team
23
+ text_response(ds.all.map(&:values))
24
+ rescue StandardError => e
25
+ error_response("Failed to list grants: #{e.message}")
26
+ end
27
+
28
+ private
29
+
30
+ def text_response(data)
31
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
32
+ end
33
+
34
+ def error_response(msg)
35
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -25,6 +25,8 @@ module Legion
25
25
  Legion::Readiness.mark_ready(:crypt)
26
26
  end
27
27
 
28
+ Legion::Settings.resolve_secrets!
29
+
28
30
  if transport
29
31
  setup_transport
30
32
  Legion::Readiness.mark_ready(:transport)
@@ -41,6 +43,8 @@ module Legion
41
43
  Legion::Readiness.mark_ready(:data)
42
44
  end
43
45
 
46
+ setup_rbac if data
47
+
44
48
  if llm
45
49
  setup_llm
46
50
  Legion::Readiness.mark_ready(:llm)
@@ -74,10 +78,22 @@ module Legion
74
78
  Legion::Logging.warn "Legion::Data failed to load, starting without it. e: #{e.message}"
75
79
  end
76
80
 
81
+ def setup_rbac
82
+ require 'legion/rbac'
83
+ Legion::Rbac.setup
84
+ Legion::Readiness.mark_ready(:rbac)
85
+ Legion::Logging.info 'Legion::Rbac loaded'
86
+ rescue LoadError
87
+ Legion::Logging.debug 'Legion::Rbac gem is not installed, starting without RBAC'
88
+ rescue StandardError => e
89
+ Legion::Logging.warn "Legion::Rbac failed to load: #{e.message}"
90
+ end
91
+
77
92
  # noinspection RubyArgCount
78
93
  def default_paths
79
94
  [
80
95
  '/etc/legionio',
96
+ "#{Dir.home}/.legionio/settings",
81
97
  "#{ENV.fetch('home', nil)}/legionio",
82
98
  '~/legionio',
83
99
  './settings'
@@ -212,6 +228,11 @@ module Legion
212
228
  Legion::Readiness.mark_not_ready(:llm)
213
229
  end
214
230
 
231
+ if defined?(Legion::Rbac) && Legion::Settings[:rbac]&.dig(:connected)
232
+ Legion::Rbac.shutdown
233
+ Legion::Readiness.mark_not_ready(:rbac)
234
+ end
235
+
215
236
  Legion::Data.shutdown if Legion::Settings[:data][:connected]
216
237
  Legion::Readiness.mark_not_ready(:data)
217
238
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.4.13'
4
+ VERSION = '1.4.14'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.13
4
+ version: 1.4.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -326,6 +326,7 @@ files:
326
326
  - lib/legion/api/nodes.rb
327
327
  - lib/legion/api/oauth.rb
328
328
  - lib/legion/api/openapi.rb
329
+ - lib/legion/api/rbac.rb
329
330
  - lib/legion/api/relationships.rb
330
331
  - lib/legion/api/schedules.rb
331
332
  - lib/legion/api/settings.rb
@@ -426,6 +427,7 @@ files:
426
427
  - lib/legion/cli/output.rb
427
428
  - lib/legion/cli/plan_command.rb
428
429
  - lib/legion/cli/pr_command.rb
430
+ - lib/legion/cli/rbac_command.rb
429
431
  - lib/legion/cli/relationship.rb
430
432
  - lib/legion/cli/review_command.rb
431
433
  - lib/legion/cli/schedule_command.rb
@@ -500,6 +502,9 @@ files:
500
502
  - lib/legion/mcp/tools/list_schedules.rb
501
503
  - lib/legion/mcp/tools/list_tasks.rb
502
504
  - lib/legion/mcp/tools/list_workers.rb
505
+ - lib/legion/mcp/tools/rbac_assignments.rb
506
+ - lib/legion/mcp/tools/rbac_check.rb
507
+ - lib/legion/mcp/tools/rbac_grants.rb
503
508
  - lib/legion/mcp/tools/routing_stats.rb
504
509
  - lib/legion/mcp/tools/run_task.rb
505
510
  - lib/legion/mcp/tools/show_worker.rb