verikloak-pundit 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +36 -1
- data/lib/generators/verikloak/pundit/install/install_generator.rb +0 -1
- data/lib/verikloak/pundit/claim_utils.rb +28 -0
- data/lib/verikloak/pundit/configuration.rb +45 -41
- data/lib/verikloak/pundit/delegations.rb +29 -0
- data/lib/verikloak/pundit/helpers.rb +13 -19
- data/lib/verikloak/pundit/policy.rb +11 -20
- data/lib/verikloak/pundit/user_context.rb +103 -29
- data/lib/verikloak/pundit/version.rb +1 -1
- data/lib/verikloak/pundit.rb +11 -0
- metadata +8 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 59ac975927d056e25dc3c468292b45622d0e5646c508fa0038c3df03158125e7
|
|
4
|
+
data.tar.gz: 15fbc3548ed4670ecbdc11ea11e39bb279a11bad273b8202209739842de7f3d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a50e8038e0e72719bce60eeeebd3d6e028bd93b86ab59b25667d06857e9767e781831ad15d92687999223861221fd5d4c9db207922bf91cac7d377a5bf002ec9
|
|
7
|
+
data.tar.gz: cba11eb87bf8d77e6f265007b721c6ff1d672f21d5c245a6a9aa458e481b1c2ac4c52354ad9c04737bb41cf8506e644a842043f4f2f1fe15c200b3da7f3204dd
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.2.1] - 2025-09-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `Verikloak::Pundit.reset!` helper to restore default configuration, easing test teardown.
|
|
14
|
+
- `Verikloak::Pundit::Delegations` shared module consolidating role and permission helper methods.
|
|
15
|
+
- `Verikloak::Pundit::ClaimUtils` for consistent claim normalization across entry points.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- `UserContext` memoizes role lookups and caches mapped permissions to reduce repeated work inside policies.
|
|
19
|
+
- `UserContext` now normalizes inputs via `ClaimUtils` and supports symbolized permission comparison for mixed role map values.
|
|
20
|
+
- Configuration duplication derives from a unified `deep_dup`, ensuring hash keys are copied and nested structures remain isolated.
|
|
21
|
+
- README documents the new delegations module, configuration reset helper, and deprecation guidance.
|
|
22
|
+
|
|
23
|
+
## [0.2.0] - 2025-09-21
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- Allow `permission_role_scope = :all_resources` to respect the new
|
|
27
|
+
`permission_resource_clients` whitelist so only approved clients contribute
|
|
28
|
+
to permission checks.
|
|
29
|
+
- Document verikloak-bff and verikloak-audience integration patterns,
|
|
30
|
+
including `env_claims_key` examples and role naming guidance.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
- `UserContext` now snapshots the configuration at initialization to keep
|
|
34
|
+
behavior consistent even if `Verikloak::Pundit.configure` runs mid-request.
|
|
35
|
+
- Bump the minimum `verikloak` runtime dependency to `>= 0.1.5` to pick up
|
|
36
|
+
client whitelist support.
|
|
37
|
+
|
|
10
38
|
## [0.1.1] - 2025-09-20
|
|
11
39
|
|
|
12
40
|
### Added
|
data/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Pundit integration for the **Verikloak** family. This gem maps **Keycloak roles*
|
|
|
14
14
|
## Features
|
|
15
15
|
|
|
16
16
|
- **UserContext**: lightweight wrapper around JWT claims
|
|
17
|
-
- **
|
|
17
|
+
- **Delegations**: `has_role?`, `in_group?`, `resource_role?(client, role)` helpers for controllers and policies
|
|
18
18
|
- **RoleMapper**: optional map from Keycloak roles → domain permissions
|
|
19
19
|
- **Controller integration**: `pundit_user` provider for Rails controllers
|
|
20
20
|
- **Generator**: `rails g verikloak:pundit:install` creates initializer + policy template (with `has_permission?` support for realm roles plus the configured resource scope)
|
|
@@ -89,12 +89,35 @@ Verikloak::Pundit.configure do |c|
|
|
|
89
89
|
# (enabling this broadens permissions to every resource client;
|
|
90
90
|
# review the upstream role assignments before turning it on)
|
|
91
91
|
c.permission_role_scope = :default_resource
|
|
92
|
+
# Optional whitelist of resource clients when `permission_role_scope = :all_resources`.
|
|
93
|
+
# Leaving this as nil keeps the legacy "all clients" behavior, while providing
|
|
94
|
+
# an explicit list (e.g., %w[rails-api verikloak-bff]) limits which clients can
|
|
95
|
+
# contribute roles to permission checks.
|
|
96
|
+
c.permission_resource_clients = nil
|
|
92
97
|
|
|
93
98
|
# Expose `verikloak_claims` to views via helper_method (Rails only)
|
|
94
99
|
c.expose_helper_method = true
|
|
95
100
|
end
|
|
96
101
|
```
|
|
97
102
|
|
|
103
|
+
### Working with other Verikloak gems
|
|
104
|
+
|
|
105
|
+
- **verikloak-bff**: When your Rails application sits behind the BFF, the access
|
|
106
|
+
token presented to verikloak-pundit typically originates from the BFF
|
|
107
|
+
(e.g. via the `x-verikloak-user` header). Make sure your Rack stack stores the
|
|
108
|
+
decoded claims under the same `env_claims_key` configured above (the default
|
|
109
|
+
`"verikloak.user"` works out of the box with `verikloak-bff >= 0.3`). If the
|
|
110
|
+
BFF issues tokens for multiple downstream services, set
|
|
111
|
+
`permission_resource_clients` to the limited list of clients whose roles should
|
|
112
|
+
affect Rails-side authorization to avoid accidentally inheriting permissions
|
|
113
|
+
meant for other services.
|
|
114
|
+
- **verikloak-audience**: Audience services often mint resource roles with a
|
|
115
|
+
service-specific prefix (for example, `audience-service:editor`). Align your
|
|
116
|
+
`role_map` keys with that naming convention so `user.has_permission?` resolves
|
|
117
|
+
correctly. If Audience adds its own client entry inside `resource_access`, add
|
|
118
|
+
that client id to `permission_resource_clients` when you need to consume those
|
|
119
|
+
roles from Rails.
|
|
120
|
+
|
|
98
121
|
## Non-Rails / custom usage
|
|
99
122
|
|
|
100
123
|
```ruby
|
|
@@ -117,6 +140,14 @@ docker compose run --rm dev rspec
|
|
|
117
140
|
docker compose run --rm dev rubocop -a
|
|
118
141
|
```
|
|
119
142
|
|
|
143
|
+
When writing specs, call `Verikloak::Pundit.reset!` in your test teardown to ensure configuration changes do not leak between examples:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
RSpec.configure do |config|
|
|
147
|
+
config.after { Verikloak::Pundit.reset! }
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
120
151
|
An additional integration check exercises the gem together with the latest `verikloak` and `verikloak-rails` releases. This runs in CI automatically, and you can execute it locally with:
|
|
121
152
|
|
|
122
153
|
```bash
|
|
@@ -142,6 +173,10 @@ If you find a security vulnerability, please follow the instructions in [SECURIT
|
|
|
142
173
|
|
|
143
174
|
### Operational guidance
|
|
144
175
|
- Enabling `permission_role_scope = :all_resources` pulls roles from every Keycloak client in `resource_access`. Review the granted roles carefully to ensure you are not expanding permissions beyond what the application expects.
|
|
176
|
+
- Combine `permission_role_scope = :all_resources` with `permission_resource_clients`
|
|
177
|
+
to explicitly opt-in the clients that may contribute permissions. Leaving the
|
|
178
|
+
whitelist blank (the default) reverts to the legacy behavior of trusting
|
|
179
|
+
every client in the token.
|
|
145
180
|
- Leaving `expose_helper_method = true` exposes `verikloak_claims` to the Rails view layer. If the claims include personal or sensitive data, consider switching it to `false` and pass only the minimum required information through controller-provided helpers.
|
|
146
181
|
|
|
147
182
|
## License
|
|
@@ -11,7 +11,6 @@ module Verikloak
|
|
|
11
11
|
desc 'Creates Verikloak Pundit initializer and a base ApplicationPolicy (optional).'
|
|
12
12
|
|
|
13
13
|
# Skip creating application_policy.rb
|
|
14
|
-
# @return [Boolean]
|
|
15
14
|
class_option :skip_policy, type: :boolean, default: false, desc: 'Do not create application_policy.rb'
|
|
16
15
|
|
|
17
16
|
# Create the initializer file under config/initializers.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Verikloak
|
|
4
|
+
module Pundit
|
|
5
|
+
# Helpers for coercing incoming claims payloads into safe Hashes.
|
|
6
|
+
module ClaimUtils
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Normalize incoming claims to a Hash to guard against odd payload types.
|
|
10
|
+
#
|
|
11
|
+
# @param claims [Object]
|
|
12
|
+
# @return [Hash]
|
|
13
|
+
def normalize(claims)
|
|
14
|
+
return {} if claims.nil?
|
|
15
|
+
return claims if claims.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
if claims.respond_to?(:to_hash)
|
|
18
|
+
coerced = claims.to_hash
|
|
19
|
+
return coerced if coerced.is_a?(Hash)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
{}
|
|
23
|
+
rescue StandardError
|
|
24
|
+
{}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -16,12 +16,16 @@ module Verikloak
|
|
|
16
16
|
# @return [Array<String,Proc>] path inside JWT claims to reach resource roles
|
|
17
17
|
# @!attribute permission_role_scope
|
|
18
18
|
# @return [Symbol] :default_resource or :all_resources for permission mapping scope
|
|
19
|
+
# @!attribute permission_resource_clients
|
|
20
|
+
# @return [Array<String>, nil] list of resource clients allowed when
|
|
21
|
+
# {#permission_role_scope} is `:all_resources`. `nil` permits every client.
|
|
19
22
|
# @!attribute expose_helper_method
|
|
20
23
|
# @return [Boolean] whether to register `verikloak_claims` as a Rails helper method
|
|
21
24
|
class Configuration
|
|
22
25
|
attr_accessor :resource_client, :role_map, :env_claims_key,
|
|
23
26
|
:realm_roles_path, :resource_roles_path,
|
|
24
|
-
:permission_role_scope, :
|
|
27
|
+
:permission_role_scope, :permission_resource_clients,
|
|
28
|
+
:expose_helper_method
|
|
25
29
|
|
|
26
30
|
# Build a new configuration, optionally copying values from another
|
|
27
31
|
# configuration so callers can mutate a safe duplicate.
|
|
@@ -64,6 +68,7 @@ module Verikloak
|
|
|
64
68
|
@role_map = dup_hash(@role_map).freeze
|
|
65
69
|
@realm_roles_path = dup_array(@realm_roles_path).freeze
|
|
66
70
|
@resource_roles_path = dup_array(@resource_roles_path).freeze
|
|
71
|
+
@permission_resource_clients = freeze_permission_clients(@permission_resource_clients)
|
|
67
72
|
@expose_helper_method = !@expose_helper_method.nil? && @expose_helper_method
|
|
68
73
|
freeze
|
|
69
74
|
end
|
|
@@ -81,6 +86,7 @@ module Verikloak
|
|
|
81
86
|
# rubocop:enable Style/SymbolProc
|
|
82
87
|
# :default_resource (realm + default client), :all_resources (realm + all clients)
|
|
83
88
|
@permission_role_scope = :default_resource
|
|
89
|
+
@permission_resource_clients = nil
|
|
84
90
|
@expose_helper_method = true
|
|
85
91
|
end
|
|
86
92
|
|
|
@@ -95,6 +101,7 @@ module Verikloak
|
|
|
95
101
|
@realm_roles_path = dup_array(other.realm_roles_path)
|
|
96
102
|
@resource_roles_path = dup_array(other.resource_roles_path)
|
|
97
103
|
@permission_role_scope = other.permission_role_scope
|
|
104
|
+
@permission_resource_clients = dup_array(other.permission_resource_clients)
|
|
98
105
|
@expose_helper_method = other.expose_helper_method
|
|
99
106
|
end
|
|
100
107
|
|
|
@@ -108,29 +115,33 @@ module Verikloak
|
|
|
108
115
|
dup_string(value).freeze
|
|
109
116
|
end
|
|
110
117
|
|
|
111
|
-
#
|
|
112
|
-
#
|
|
118
|
+
# Deep duplicate any object, handling nested structures recursively.
|
|
119
|
+
#
|
|
120
|
+
# @param value [Object] The value to duplicate
|
|
121
|
+
# @return [Object] A deep copy of the value
|
|
122
|
+
def deep_dup(value)
|
|
123
|
+
case value
|
|
124
|
+
when nil
|
|
125
|
+
nil
|
|
126
|
+
when Hash
|
|
127
|
+
value.each_with_object({}) do |(key, element), copy|
|
|
128
|
+
copy[deep_dup(key)] = deep_dup(element)
|
|
129
|
+
end
|
|
130
|
+
when Array
|
|
131
|
+
value.map { |element| deep_dup(element) }
|
|
132
|
+
when String
|
|
133
|
+
value.dup
|
|
134
|
+
else
|
|
135
|
+
duplicable?(value) ? value.dup : value
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Duplicate a hash using deep duplication.
|
|
113
140
|
#
|
|
114
141
|
# @param value [Hash, nil]
|
|
115
142
|
# @return [Hash, nil]
|
|
116
143
|
def dup_hash(value)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
copy = value.dup
|
|
120
|
-
copy.each do |key, element|
|
|
121
|
-
copy[key] =
|
|
122
|
-
case element
|
|
123
|
-
when Hash
|
|
124
|
-
dup_hash(element)
|
|
125
|
-
when Array
|
|
126
|
-
dup_array(element)
|
|
127
|
-
when String
|
|
128
|
-
dup_string(element)
|
|
129
|
-
else
|
|
130
|
-
duplicable?(element) ? element.dup : element
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
copy
|
|
144
|
+
deep_dup(value)
|
|
134
145
|
end
|
|
135
146
|
|
|
136
147
|
# Duplicate a string guardingly, returning `nil` when no value is present.
|
|
@@ -138,33 +149,15 @@ module Verikloak
|
|
|
138
149
|
# @param value [String, nil]
|
|
139
150
|
# @return [String, nil]
|
|
140
151
|
def dup_string(value)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
value.dup
|
|
152
|
+
deep_dup(value)
|
|
144
153
|
end
|
|
145
154
|
|
|
146
|
-
#
|
|
155
|
+
# Duplicate an array using deep duplication.
|
|
147
156
|
#
|
|
148
157
|
# @param value [Array, nil]
|
|
149
158
|
# @return [Array, nil]
|
|
150
159
|
def dup_array(value)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
copy = value.dup
|
|
154
|
-
return copy unless copy.respond_to?(:map)
|
|
155
|
-
|
|
156
|
-
copy.map do |element|
|
|
157
|
-
case element
|
|
158
|
-
when Hash
|
|
159
|
-
dup_hash(element)
|
|
160
|
-
when Array
|
|
161
|
-
dup_array(element)
|
|
162
|
-
when String
|
|
163
|
-
dup_string(element)
|
|
164
|
-
else
|
|
165
|
-
duplicable?(element) ? element.dup : element
|
|
166
|
-
end
|
|
167
|
-
end
|
|
160
|
+
deep_dup(value)
|
|
168
161
|
end
|
|
169
162
|
|
|
170
163
|
# Check whether a value can be safely duplicated using `dup`.
|
|
@@ -178,6 +171,17 @@ module Verikloak
|
|
|
178
171
|
|
|
179
172
|
value.respond_to?(:dup)
|
|
180
173
|
end
|
|
174
|
+
|
|
175
|
+
# Normalize and freeze the configured permission clients list.
|
|
176
|
+
#
|
|
177
|
+
# @param value [Array<String, Symbol>, nil]
|
|
178
|
+
# @return [Array<String>, nil]
|
|
179
|
+
def freeze_permission_clients(value)
|
|
180
|
+
array = dup_array(value)
|
|
181
|
+
return nil if array.nil?
|
|
182
|
+
|
|
183
|
+
array.compact.map(&:to_s).uniq.freeze
|
|
184
|
+
end
|
|
181
185
|
end
|
|
182
186
|
end
|
|
183
187
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Verikloak
|
|
4
|
+
module Pundit
|
|
5
|
+
# Shared role/permission delegations to expose consistent helpers.
|
|
6
|
+
module Delegations
|
|
7
|
+
# Check whether the user has a realm role.
|
|
8
|
+
# @param role [String, Symbol]
|
|
9
|
+
# @return [Boolean]
|
|
10
|
+
def has_role?(role) = user.has_role?(role) # rubocop:disable Naming/PredicatePrefix
|
|
11
|
+
|
|
12
|
+
# Check whether the user belongs to a group (alias to role).
|
|
13
|
+
# @param group [String, Symbol]
|
|
14
|
+
# @return [Boolean]
|
|
15
|
+
def in_group?(group) = user.in_group?(group)
|
|
16
|
+
|
|
17
|
+
# Check whether the user has a role for a specific resource client.
|
|
18
|
+
# @param client [String, Symbol]
|
|
19
|
+
# @param role [String, Symbol]
|
|
20
|
+
# @return [Boolean]
|
|
21
|
+
def resource_role?(client, role) = user.resource_role?(client, role)
|
|
22
|
+
|
|
23
|
+
# Check whether the user has a mapped permission.
|
|
24
|
+
# @param perm [String, Symbol]
|
|
25
|
+
# @return [Boolean]
|
|
26
|
+
def has_permission?(perm) = user.has_permission?(perm) # rubocop:disable Naming/PredicatePrefix
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -3,27 +3,21 @@
|
|
|
3
3
|
module Verikloak
|
|
4
4
|
module Pundit
|
|
5
5
|
# Helpers expose convenient delegations to the policy `user`.
|
|
6
|
+
#
|
|
7
|
+
# @deprecated Use {Delegations} directly instead. This module will be removed in v1.0.0.
|
|
6
8
|
module Helpers
|
|
7
|
-
|
|
8
|
-
# @param role [String, Symbol]
|
|
9
|
-
# @return [Boolean]
|
|
10
|
-
def has_role?(role) = user.has_role?(role) # rubocop:disable Naming/PredicatePrefix
|
|
9
|
+
include Delegations
|
|
11
10
|
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# @
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
# Check whether the user has a mapped permission.
|
|
24
|
-
# @param perm [String, Symbol]
|
|
25
|
-
# @return [Boolean]
|
|
26
|
-
def has_permission?(perm) = user.has_permission?(perm) # rubocop:disable Naming/PredicatePrefix
|
|
11
|
+
# Warn consumers when the deprecated helper module is included.
|
|
12
|
+
#
|
|
13
|
+
# @param base [Module] module or class including this helper
|
|
14
|
+
# @return [void]
|
|
15
|
+
def self.included(base)
|
|
16
|
+
warn '[DEPRECATED] Verikloak::Pundit::Helpers is deprecated. ' \
|
|
17
|
+
'Include Verikloak::Pundit::Delegations directly instead. ' \
|
|
18
|
+
'This will be removed in v1.0.0.'
|
|
19
|
+
super
|
|
20
|
+
end
|
|
27
21
|
end
|
|
28
22
|
end
|
|
29
23
|
end
|
|
@@ -3,34 +3,25 @@
|
|
|
3
3
|
module Verikloak
|
|
4
4
|
module Pundit
|
|
5
5
|
# Policy mixin to delegate common helpers to the `user` context.
|
|
6
|
+
#
|
|
7
|
+
# @deprecated Use {Delegations} directly instead. This module will be removed in v1.0.0.
|
|
6
8
|
module Policy
|
|
9
|
+
# Warn consumers when the deprecated policy mixin is included.
|
|
10
|
+
#
|
|
11
|
+
# @param base [Module] module or class including this policy mixin
|
|
12
|
+
# @return [void]
|
|
7
13
|
def self.included(base)
|
|
14
|
+
warn '[DEPRECATED] Verikloak::Pundit::Policy is deprecated. ' \
|
|
15
|
+
'Include Verikloak::Pundit::Delegations directly instead. ' \
|
|
16
|
+
'This will be removed in v1.0.0.'
|
|
8
17
|
base.extend(ClassMethods)
|
|
18
|
+
super
|
|
9
19
|
end
|
|
10
20
|
|
|
11
21
|
# Placeholder for future class-level helpers
|
|
12
22
|
module ClassMethods; end
|
|
13
23
|
|
|
14
|
-
|
|
15
|
-
# @param role [String, Symbol]
|
|
16
|
-
# @return [Boolean]
|
|
17
|
-
def has_role?(role) = user.has_role?(role) # rubocop:disable Naming/PredicatePrefix
|
|
18
|
-
|
|
19
|
-
# Check whether the user belongs to a group (alias to role).
|
|
20
|
-
# @param group [String, Symbol]
|
|
21
|
-
# @return [Boolean]
|
|
22
|
-
def in_group?(group) = user.in_group?(group)
|
|
23
|
-
|
|
24
|
-
# Check whether the user has a role for a specific resource client.
|
|
25
|
-
# @param client [String, Symbol]
|
|
26
|
-
# @param role [String, Symbol]
|
|
27
|
-
# @return [Boolean]
|
|
28
|
-
def resource_role?(client, role) = user.resource_role?(client, role)
|
|
29
|
-
|
|
30
|
-
# Check whether the user has a mapped permission.
|
|
31
|
-
# @param perm [String, Symbol]
|
|
32
|
-
# @return [Boolean]
|
|
33
|
-
def has_permission?(perm) = user.has_permission?(perm) # rubocop:disable Naming/PredicatePrefix
|
|
24
|
+
include Delegations
|
|
34
25
|
end
|
|
35
26
|
end
|
|
36
27
|
end
|
|
@@ -1,37 +1,50 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
3
5
|
module Verikloak
|
|
4
6
|
module Pundit
|
|
5
7
|
# Lightweight wrapper around Keycloak claims for Pundit policies.
|
|
6
8
|
class UserContext
|
|
7
|
-
|
|
9
|
+
# JWT claim keys used internally
|
|
10
|
+
CLAIM_SUB = 'sub'
|
|
11
|
+
CLAIM_EMAIL = 'email'
|
|
12
|
+
CLAIM_PREFERRED_USERNAME = 'preferred_username'
|
|
13
|
+
CLAIM_RESOURCE_ACCESS = 'resource_access'
|
|
14
|
+
CLAIM_ROLES = 'roles'
|
|
15
|
+
|
|
16
|
+
attr_reader :claims, :resource_client, :config
|
|
8
17
|
|
|
9
18
|
# Create a new user context from JWT claims.
|
|
10
19
|
#
|
|
11
20
|
# @param claims [Hash] JWT claims issued by Keycloak
|
|
12
21
|
# @param resource_client [String] default resource client name for resource roles
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@
|
|
22
|
+
# @param config [Verikloak::Pundit::Configuration] configuration snapshot to use
|
|
23
|
+
def initialize(claims, resource_client: nil, config: nil)
|
|
24
|
+
@config = config || Verikloak::Pundit.config
|
|
25
|
+
@claims = ClaimUtils.normalize(claims)
|
|
26
|
+
@resource_client = (resource_client || @config.resource_client).to_s
|
|
16
27
|
end
|
|
17
28
|
|
|
18
29
|
# Subject identifier from claims.
|
|
19
30
|
# @return [String, nil]
|
|
20
31
|
def sub
|
|
21
|
-
claims[
|
|
32
|
+
claims[CLAIM_SUB]
|
|
22
33
|
end
|
|
23
34
|
|
|
24
35
|
# Email or preferred username from claims.
|
|
25
36
|
# @return [String, nil]
|
|
26
37
|
def email
|
|
27
|
-
claims[
|
|
38
|
+
claims[CLAIM_EMAIL] || claims[CLAIM_PREFERRED_USERNAME]
|
|
28
39
|
end
|
|
29
40
|
|
|
30
41
|
# Realm-level roles from claims based on configuration path.
|
|
31
42
|
# @return [Array<String>]
|
|
32
43
|
def realm_roles
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
@realm_roles ||= begin
|
|
45
|
+
path = resolve_path(config.realm_roles_path)
|
|
46
|
+
Array(claims.dig(*path)).map(&:to_s).uniq.freeze
|
|
47
|
+
end
|
|
35
48
|
end
|
|
36
49
|
|
|
37
50
|
# Resource-level roles for a given client from claims based on configuration path.
|
|
@@ -40,8 +53,10 @@ module Verikloak
|
|
|
40
53
|
# @return [Array<String>]
|
|
41
54
|
def resource_roles(client = resource_client)
|
|
42
55
|
client = client.to_s
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
(@resource_roles_cache ||= {})[client] ||= begin
|
|
57
|
+
path = resolve_path(config.resource_roles_path, client: client)
|
|
58
|
+
Array(claims.dig(*path)).map(&:to_s).uniq.freeze
|
|
59
|
+
end
|
|
45
60
|
end
|
|
46
61
|
|
|
47
62
|
# Check whether the user has a realm role.
|
|
@@ -49,8 +64,7 @@ module Verikloak
|
|
|
49
64
|
# @param role [String, Symbol]
|
|
50
65
|
# @return [Boolean]
|
|
51
66
|
def has_role?(role) # rubocop:disable Naming/PredicatePrefix
|
|
52
|
-
|
|
53
|
-
realm_roles.include?(r)
|
|
67
|
+
realm_roles.include?(role.to_s)
|
|
54
68
|
end
|
|
55
69
|
|
|
56
70
|
# Alias to has_role? to align with group-based naming.
|
|
@@ -67,9 +81,7 @@ module Verikloak
|
|
|
67
81
|
# @param role [String, Symbol]
|
|
68
82
|
# @return [Boolean]
|
|
69
83
|
def resource_role?(client, role)
|
|
70
|
-
client
|
|
71
|
-
r = role.to_s
|
|
72
|
-
resource_roles(client).include?(r)
|
|
84
|
+
resource_roles(client).include?(role.to_s)
|
|
73
85
|
end
|
|
74
86
|
|
|
75
87
|
# Check whether the user has a mapped permission.
|
|
@@ -80,10 +92,7 @@ module Verikloak
|
|
|
80
92
|
# @param perm [String, Symbol] permission to check
|
|
81
93
|
# @return [Boolean]
|
|
82
94
|
def has_permission?(perm) # rubocop:disable Naming/PredicatePrefix
|
|
83
|
-
|
|
84
|
-
roles = realm_roles + resource_roles_scope
|
|
85
|
-
mapped = roles.map { |r| RoleMapper.map(r, Verikloak::Pundit.config) }
|
|
86
|
-
mapped.map(&:to_sym).include?(pr)
|
|
95
|
+
permission_set.include?(normalize_to_symbol(perm))
|
|
87
96
|
end
|
|
88
97
|
|
|
89
98
|
# Build a user context from Rack env using configured claims key.
|
|
@@ -91,8 +100,9 @@ module Verikloak
|
|
|
91
100
|
# @param env [Hash] Rack environment
|
|
92
101
|
# @return [UserContext]
|
|
93
102
|
def self.from_env(env)
|
|
94
|
-
|
|
95
|
-
|
|
103
|
+
config = Verikloak::Pundit.config
|
|
104
|
+
claims = env&.fetch(config.env_claims_key, nil)
|
|
105
|
+
new(claims, config: config)
|
|
96
106
|
end
|
|
97
107
|
|
|
98
108
|
private
|
|
@@ -108,9 +118,9 @@ module Verikloak
|
|
|
108
118
|
when Proc
|
|
109
119
|
# Support lambdas that accept (config) or (config, client)
|
|
110
120
|
if seg.arity >= 2
|
|
111
|
-
seg.call(
|
|
121
|
+
seg.call(config, client).to_s
|
|
112
122
|
else
|
|
113
|
-
seg.call(
|
|
123
|
+
seg.call(config).to_s
|
|
114
124
|
end
|
|
115
125
|
else
|
|
116
126
|
seg.to_s
|
|
@@ -121,7 +131,7 @@ module Verikloak
|
|
|
121
131
|
# Resolve resource roles based on configured permission scope.
|
|
122
132
|
# @return [Array<String>]
|
|
123
133
|
def resource_roles_scope
|
|
124
|
-
case
|
|
134
|
+
case config.permission_role_scope&.to_sym
|
|
125
135
|
when :all_resources
|
|
126
136
|
resource_roles_all_clients
|
|
127
137
|
else
|
|
@@ -132,12 +142,76 @@ module Verikloak
|
|
|
132
142
|
# Collect resource roles from all clients under resource_access.
|
|
133
143
|
# @return [Array<String>]
|
|
134
144
|
def resource_roles_all_clients
|
|
135
|
-
|
|
136
|
-
|
|
145
|
+
@resource_roles_all_clients ||= begin
|
|
146
|
+
access = claims[CLAIM_RESOURCE_ACCESS]
|
|
147
|
+
if access.is_a?(Hash)
|
|
148
|
+
roles = access.each_with_object([]) do |(client_id, entry), acc|
|
|
149
|
+
next unless permission_client_allowed?(client_id)
|
|
150
|
+
|
|
151
|
+
acc.concat(Array(entry[CLAIM_ROLES]))
|
|
152
|
+
end
|
|
153
|
+
roles.map(&:to_s).uniq.freeze
|
|
154
|
+
else
|
|
155
|
+
[].freeze
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Check whether the given client is allowed for permission scope.
|
|
161
|
+
#
|
|
162
|
+
# @param client_id [String]
|
|
163
|
+
# @return [Boolean]
|
|
164
|
+
def permission_client_allowed?(client_id)
|
|
165
|
+
whitelist = config.permission_resource_clients
|
|
166
|
+
return true if whitelist.nil?
|
|
137
167
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
168
|
+
whitelist.include?(client_id.to_s)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Cached permission lookup set combining realm and configured resource scopes.
|
|
172
|
+
# @return [Set<Symbol>]
|
|
173
|
+
def permission_set
|
|
174
|
+
@permission_set ||= build_permission_set.freeze
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Build the permission set from roles and role mappings.
|
|
178
|
+
# @return [Set<Symbol>]
|
|
179
|
+
def build_permission_set
|
|
180
|
+
roles = realm_roles + resource_roles_scope
|
|
181
|
+
permissions = Set.new
|
|
182
|
+
|
|
183
|
+
roles.each do |role|
|
|
184
|
+
mapped_permission = RoleMapper.map(role, config)
|
|
185
|
+
symbol_permission = normalize_to_symbol(mapped_permission)
|
|
186
|
+
permissions << symbol_permission if symbol_permission
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
permissions
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Normalize a value to a symbol, handling various types safely.
|
|
193
|
+
# @param value [Object] The value to convert to a symbol
|
|
194
|
+
# @return [Symbol, nil] The symbol representation, or nil if not convertible
|
|
195
|
+
def normalize_to_symbol(value)
|
|
196
|
+
case value
|
|
197
|
+
when Symbol
|
|
198
|
+
value
|
|
199
|
+
when String
|
|
200
|
+
return nil if value.empty?
|
|
201
|
+
|
|
202
|
+
value.to_sym
|
|
203
|
+
else
|
|
204
|
+
if value.respond_to?(:to_sym)
|
|
205
|
+
value.to_sym
|
|
206
|
+
elsif value.respond_to?(:to_s)
|
|
207
|
+
text = value.to_s
|
|
208
|
+
return nil if text.empty?
|
|
209
|
+
|
|
210
|
+
text.to_sym
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
rescue StandardError
|
|
214
|
+
nil
|
|
141
215
|
end
|
|
142
216
|
end
|
|
143
217
|
end
|
data/lib/verikloak/pundit.rb
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
require_relative 'pundit/version'
|
|
5
5
|
require_relative 'pundit/configuration'
|
|
6
6
|
require_relative 'pundit/role_mapper'
|
|
7
|
+
require_relative 'pundit/delegations'
|
|
8
|
+
require_relative 'pundit/claim_utils'
|
|
7
9
|
require_relative 'pundit/user_context'
|
|
8
10
|
require_relative 'pundit/helpers'
|
|
9
11
|
require_relative 'pundit/controller'
|
|
@@ -38,6 +40,15 @@ module Verikloak
|
|
|
38
40
|
end
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
# Reset configuration to defaults. Useful for test suites.
|
|
44
|
+
#
|
|
45
|
+
# @return [void]
|
|
46
|
+
def reset!
|
|
47
|
+
config_mutex.synchronize do
|
|
48
|
+
@config = nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
41
52
|
private
|
|
42
53
|
|
|
43
54
|
# Mutex protecting configuration reads/writes to maintain thread safety.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: verikloak-pundit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- taiyaky
|
|
@@ -49,20 +49,20 @@ dependencies:
|
|
|
49
49
|
requirements:
|
|
50
50
|
- - ">="
|
|
51
51
|
- !ruby/object:Gem::Version
|
|
52
|
-
version: 0.
|
|
52
|
+
version: 0.2.0
|
|
53
53
|
- - "<"
|
|
54
54
|
- !ruby/object:Gem::Version
|
|
55
|
-
version:
|
|
55
|
+
version: 1.0.0
|
|
56
56
|
type: :runtime
|
|
57
57
|
prerelease: false
|
|
58
58
|
version_requirements: !ruby/object:Gem::Requirement
|
|
59
59
|
requirements:
|
|
60
60
|
- - ">="
|
|
61
61
|
- !ruby/object:Gem::Version
|
|
62
|
-
version: 0.
|
|
62
|
+
version: 0.2.0
|
|
63
63
|
- - "<"
|
|
64
64
|
- !ruby/object:Gem::Version
|
|
65
|
-
version:
|
|
65
|
+
version: 1.0.0
|
|
66
66
|
description: Maps Keycloak JWT roles to a Pundit-friendly UserContext with helpers
|
|
67
67
|
and a Rails generator.
|
|
68
68
|
executables: []
|
|
@@ -77,8 +77,10 @@ files:
|
|
|
77
77
|
- lib/generators/verikloak/pundit/install/templates/initializer.rb
|
|
78
78
|
- lib/verikloak-pundit.rb
|
|
79
79
|
- lib/verikloak/pundit.rb
|
|
80
|
+
- lib/verikloak/pundit/claim_utils.rb
|
|
80
81
|
- lib/verikloak/pundit/configuration.rb
|
|
81
82
|
- lib/verikloak/pundit/controller.rb
|
|
83
|
+
- lib/verikloak/pundit/delegations.rb
|
|
82
84
|
- lib/verikloak/pundit/helpers.rb
|
|
83
85
|
- lib/verikloak/pundit/policy.rb
|
|
84
86
|
- lib/verikloak/pundit/railtie.rb
|
|
@@ -92,7 +94,7 @@ metadata:
|
|
|
92
94
|
source_code_uri: https://github.com/taiyaky/verikloak-pundit
|
|
93
95
|
changelog_uri: https://github.com/taiyaky/verikloak-pundit/blob/main/CHANGELOG.md
|
|
94
96
|
bug_tracker_uri: https://github.com/taiyaky/verikloak-pundit/issues
|
|
95
|
-
documentation_uri: https://rubydoc.info/gems/verikloak-pundit/0.
|
|
97
|
+
documentation_uri: https://rubydoc.info/gems/verikloak-pundit/0.2.1
|
|
96
98
|
rubygems_mfa_required: 'true'
|
|
97
99
|
rdoc_options: []
|
|
98
100
|
require_paths:
|