legion-rbac 0.2.0 → 0.2.4

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: 0a4b81abbd8b47fb1b0809f98cfc4e070553af729984f9994e2b70708b70e1db
4
- data.tar.gz: 29b556506bf933800fadc5af6837f325b65eca9924bc45b0c3b49521a487f750
3
+ metadata.gz: a3b1a79a9bbe7774bc480993840934423d224605950bcc02f8f0fc6fdd20e432
4
+ data.tar.gz: e29db0b20c25a9b885f82a997f80f687eb65311cf5c5422f4addb894a6c9a581
5
5
  SHA512:
6
- metadata.gz: 79b92ac1e96706268e165c0b613bf506a18e56764e7a0da547a3c0096e49e5f8389193fc34350f70f858ab2eca638c9674ec8d70ed244bae7375c4a9d2504dc6
7
- data.tar.gz: 00be7bf03360454dc126a73b0f65a66555ed4f56b5c16f8e82082f9bd93cacf82537e8bae173d6de85f1af41f43762e10f6ed0b2335f0f249bc04d71013de294
6
+ metadata.gz: '044709e04e2f671b76dfc645a254b11d870af45012d23b5ea514cbeec152cc248db09382a68496d74f0152b30984d2be49d9a9a4e4fa655074b0d6bf3636caa2'
7
+ data.tar.gz: 546da78bffd9f7fbf5bac8fd77f5bc649caca1eb15694dc93fdada82ac7e05fc250ee938cb99e8b14dbe8fca3a4f24cfe07d07cf529c4cebc14df88acca0049c
data/CHANGELOG.md CHANGED
@@ -1,6 +1,35 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.2.3] - 2026-03-20
4
+
5
+ ### Added
6
+ - Emit `rbac.deny` event on access denial for safety metrics integration
7
+
8
+ ## [0.2.2] - 2026-03-18
9
+
10
+ ### Added
11
+ - Organizational profile attributes on `Principal`: `title`, `department`, `company`, `city`, `state`, `country`, `country_code`, `cn`, `ad_created_at`
12
+ - `Principal#profile` hash accessor for all extended identity attributes
13
+ - `PROFILE_KEYS` constant defines the full set of identity/org fields
14
+ - `define_method` generates individual accessors from `PROFILE_KEYS`
15
+
16
+ ### Changed
17
+ - `Principal` constructor accepts `**extra` kwargs for profile fields (replaces individual keyword args)
18
+ - `from_claims` iterates `PROFILE_KEYS` to extract all profile fields from claims
19
+ - `KerberosClaimsMapper.map` uses `**profile` splat directly (passes all profile kwargs through)
20
+
21
+ ## [0.2.1] - 2026-03-18
22
+
23
+ ### Added
24
+ - `samaccountname`, `ad_fqdn`, `first_name`, `last_name`, `email`, `display_name` attributes on `Principal`
25
+ - `from_claims` extracts identity attributes from Kerberos claims
26
+ - `KerberosClaimsMapper.map` emits `samaccountname` and `ad_fqdn` from principal, accepts `**profile` kwargs
27
+ - `KerberosClaimsMapper.map_with_fallback` passes profile through to `map`
28
+
29
+ ### Changed
30
+ - `KerberosClaimsMapper.map` now returns `.compact` hash (omits nil values)
31
+
32
+ ## [0.2.0] - 2026-03-17
4
33
 
5
34
  ### Added
6
35
  - `KerberosClaimsMapper` module: maps Kerberos principals and AD group memberships to Legion roles
data/CLAUDE.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Legion::Rbac
2
2
 
3
3
  **Parent**: `/Users/miverso2/rubymine/legion/CLAUDE.md`
4
+ **GitHub**: https://github.com/LegionIO/legion-rbac
5
+ **Version**: 0.2.2
4
6
 
5
7
  Optional RBAC gem for LegionIO. Vault-style flat policy model with deny-always-wins semantics.
6
8
 
@@ -46,7 +48,29 @@ lib/legion/rbac/kerberos_claims_mapper.rb # Kerberos principal + AD groups -> Le
46
48
  Two identity provider mappers convert external claims to Legion principals:
47
49
 
48
50
  - **EntraClaimsMapper**: Maps Entra ID `roles` and `groups` claims to Legion roles. Uses `module_function` pattern. Configurable `role_map` and `group_map` with `default_role` fallback.
49
- - **KerberosClaimsMapper**: Maps Kerberos principal (`user@REALM`) and AD group DNs to Legion roles. `map_with_fallback` tries LDAP groups first, falls back to Entra if configured. Injects `auth_method: 'kerberos'` for identity signal tracking in lex-identity.
51
+ - **KerberosClaimsMapper**: Maps Kerberos principal (`user@REALM`) and AD group DNs to Legion roles. `map_with_fallback` tries LDAP groups first, falls back to Entra if configured. Passes through all `**profile` kwargs (identity + org attributes) from LDAP into the claims hash.
52
+
53
+ ## Principal Identity Model
54
+
55
+ `Principal` carries core identity (`id`, `type`, `roles`, `team`, `auth_method`, `samaccountname`, `ad_fqdn`) plus a `profile` hash of extended attributes populated from AD/LDAP:
56
+
57
+ | Accessor | LDAP Source | Example |
58
+ |----------|-------------|---------|
59
+ | `first_name` | `givenName` | Jane |
60
+ | `last_name` | `sn` | Doe |
61
+ | `email` | `mail` | jane.doe@example.com |
62
+ | `display_name` | `displayName` | Doe, Jane A |
63
+ | `title` | `title` | Senior Engineer |
64
+ | `department` | `department` | Platform Engineering |
65
+ | `company` | `company` | Acme Corp |
66
+ | `city` | `l` | Minneapolis |
67
+ | `state` | `st` | MN |
68
+ | `country` | `co` | USA |
69
+ | `country_code` | `c` | US |
70
+ | `cn` | `cn` | jdoe1 |
71
+ | `ad_created_at` | `whenCreated` | 20200115093012.0Z |
72
+
73
+ All profile fields are accessible as direct methods (`principal.title`) or via `principal.profile` hash.
50
74
 
51
75
  ## Guards
52
76
 
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Role-based access control for LegionIO, following Vault-style flat policy patterns.
4
4
 
5
+ **Version**: 0.2.2
6
+
5
7
  ## Features
6
8
 
7
9
  - Flat policy model: deny-always-wins, no role inheritance
@@ -12,6 +14,7 @@ Role-based access control for LegionIO, following Vault-style flat policy patter
12
14
  - Dual-mode store: DB-backed via Sequel or static fallback
13
15
  - Entra ID claims mapping (roles and groups to Legion roles)
14
16
  - Kerberos claims mapping (AD group DNs to Legion roles, with Entra fallback)
17
+ - Rich identity profile from AD/LDAP (name, email, title, department, company, location)
15
18
  - Fully optional: guarded by `if defined?(Legion::Rbac)` in LegionIO
16
19
 
17
20
  ## Installation
@@ -36,6 +39,12 @@ Legion::Rbac.setup
36
39
  ```ruby
37
40
  principal = Legion::Rbac::Principal.new(id: 'user-1', roles: [:worker], team: 'team-a')
38
41
  Legion::Rbac.authorize!(principal: principal, action: :execute, resource: 'runners/lex-http/request/get')
42
+
43
+ # Kerberos principals carry full AD profile
44
+ principal.first_name # => "Jane"
45
+ principal.title # => "Senior Engineer"
46
+ principal.department # => "Platform Engineering"
47
+ principal.profile # => { first_name: "Jane", last_name: "Doe", ... }
39
48
  ```
40
49
 
41
50
  ### Execution Authorization
@@ -7,29 +7,35 @@ module Legion
7
7
 
8
8
  module_function
9
9
 
10
- def map(principal:, groups:, role_map: {}, default_role: DEFAULT_ROLE)
11
- username = principal.split('@', 2).first
10
+ def map(principal:, groups:, role_map: {}, default_role: DEFAULT_ROLE, **profile)
11
+ parts = principal.split('@', 2)
12
+ username = parts.first
13
+ realm = parts.length > 1 ? parts.last : nil
12
14
  roles = Array(groups).filter_map { |g| role_map[g] }.uniq
13
15
  roles = [default_role] if roles.empty?
14
16
 
15
17
  {
16
- sub: username,
17
- roles: roles,
18
- scope: 'human',
19
- auth_method: 'kerberos'
20
- }
18
+ sub: username,
19
+ samaccountname: username,
20
+ ad_fqdn: realm&.downcase,
21
+ roles: roles,
22
+ scope: 'human',
23
+ auth_method: 'kerberos',
24
+ **profile
25
+ }.compact
21
26
  end
22
27
 
23
- def map_with_fallback(principal:, groups: nil, fallback: :entra, role_map: {}, default_role: DEFAULT_ROLE)
28
+ def map_with_fallback(principal:, groups: nil, fallback: :entra, role_map: {},
29
+ default_role: DEFAULT_ROLE, **profile)
24
30
  if groups&.any?
25
- map(principal: principal, groups: groups, role_map: role_map, default_role: default_role)
31
+ map(principal: principal, groups: groups, role_map: role_map, default_role: default_role, **profile)
26
32
  elsif fallback == :entra && defined?(Legion::Rbac::EntraClaimsMapper)
27
33
  entra_claims = { sub: principal, preferred_username: principal }
28
34
  result = EntraClaimsMapper.map_claims(entra_claims)
29
35
  result&.merge(auth_method: 'kerberos') || map(principal: principal, groups: [],
30
36
  role_map: role_map, default_role: default_role)
31
37
  else
32
- map(principal: principal, groups: [], role_map: role_map, default_role: default_role)
38
+ map(principal: principal, groups: [], role_map: role_map, default_role: default_role, **profile)
33
39
  end
34
40
  end
35
41
  end
@@ -31,6 +31,8 @@ module Legion
31
31
  end
32
32
 
33
33
  def call(env)
34
+ return @app.call(env) unless enforce?
35
+
34
36
  path = env['PATH_INFO']
35
37
  return @app.call(env) if skip_path?(path)
36
38
  return @app.call(env) if invoke_route?(path)
@@ -50,6 +52,7 @@ module Legion
50
52
  if result[:allowed]
51
53
  @app.call(env)
52
54
  else
55
+ Legion::Events.emit('rbac.deny', reason: result[:reason]) if defined?(Legion::Events)
53
56
  denied_response(result[:reason])
54
57
  end
55
58
  end
@@ -78,6 +81,14 @@ module Legion
78
81
  nil
79
82
  end
80
83
 
84
+ def enforce?
85
+ return false unless defined?(Legion::Settings)
86
+
87
+ Legion::Settings[:rbac][:enforce]
88
+ rescue StandardError
89
+ true
90
+ end
91
+
81
92
  def denied_response(reason)
82
93
  body = Legion::JSON.dump({ error: 'access_denied', reason: reason })
83
94
  [403, { 'content-type' => 'application/json' }, [body]]
@@ -3,31 +3,45 @@
3
3
  module Legion
4
4
  module Rbac
5
5
  class Principal
6
- attr_reader :id, :type, :roles, :team
6
+ PROFILE_KEYS = %i[
7
+ first_name last_name email display_name cn title
8
+ department company country country_code city state ad_created_at
9
+ ].freeze
7
10
 
8
- def initialize(id:, type: :human, roles: [], team: nil)
11
+ attr_reader :id, :type, :roles, :team, :auth_method,
12
+ :samaccountname, :ad_fqdn, :profile
13
+
14
+ def initialize(id:, type: :human, roles: [], team: nil, auth_method: nil, # rubocop:disable Metrics/ParameterLists
15
+ samaccountname: nil, ad_fqdn: nil, **extra)
9
16
  @id = id
10
17
  @type = type.to_sym
11
18
  @roles = roles.map(&:to_s)
12
19
  @team = team
20
+ @auth_method = auth_method
21
+ @samaccountname = samaccountname
22
+ @ad_fqdn = ad_fqdn
23
+ @profile = extra.slice(*PROFILE_KEYS).compact
24
+ end
25
+
26
+ PROFILE_KEYS.each do |key|
27
+ define_method(key) { @profile[key] }
13
28
  end
14
29
 
15
30
  def self.from_claims(claims)
16
31
  scope = claims[:scope] || claims['scope']
32
+ common = {
33
+ roles: claims[:roles] || claims['roles'] || [],
34
+ team: claims[:team] || claims['team'],
35
+ auth_method: claims[:auth_method] || claims['auth_method'],
36
+ samaccountname: claims[:samaccountname] || claims['samaccountname'],
37
+ ad_fqdn: claims[:ad_fqdn] || claims['ad_fqdn']
38
+ }
39
+ PROFILE_KEYS.each { |key| common[key] = claims[key] || claims[key.to_s] }
40
+
17
41
  if scope == 'worker'
18
- new(
19
- id: claims[:worker_id] || claims['worker_id'],
20
- type: :worker,
21
- roles: claims[:roles] || claims['roles'] || [],
22
- team: claims[:team] || claims['team']
23
- )
42
+ new(id: claims[:worker_id] || claims['worker_id'], type: :worker, **common)
24
43
  else
25
- new(
26
- id: claims[:sub] || claims['sub'],
27
- type: :human,
28
- roles: claims[:roles] || claims['roles'] || [],
29
- team: claims[:team] || claims['team']
30
- )
44
+ new(id: claims[:sub] || claims['sub'], type: :human, **common)
31
45
  end
32
46
  end
33
47
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Rbac
5
- VERSION = '0.2.0'
5
+ VERSION = '0.2.4'
6
6
  end
7
7
  end
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.0
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -96,7 +96,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
96
96
  - !ruby/object:Gem::Version
97
97
  version: '0'
98
98
  requirements: []
99
- rubygems_version: 4.0.8
99
+ rubygems_version: 3.6.9
100
100
  specification_version: 4
101
101
  summary: Legion::Rbac
102
102
  test_files: []