verikloak-pundit 0.2.0 → 0.2.2

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: 6936cd5efb5ccaecf789b427db2f7cd09f358604ba334f592f3e5079ac6501c4
4
- data.tar.gz: 37db6dc08acd9918bebb15bd8dbb32320e00661ffc81b1b40c85850ec1c0822c
3
+ metadata.gz: 40f6167b3a558b93b98bcabbe529acc50b4ffef682d9fc02375ea85c8f9ce26f
4
+ data.tar.gz: 31ca32b4215cc022474e5f385a56f3c6b189137665cdb76b5f748842c12cdce2
5
5
  SHA512:
6
- metadata.gz: 120391c41f3f769ce49d7bddd83bed04b010ea367d8b63ddedb5de4610f9f8a6600f29f56f84c929b2209e4c26d6825a3d774ae1f32c20f16dd6d53378d5f9bf
7
- data.tar.gz: a6d8babf7f27a842105fcc9ba44a42f9c73a21c45eeb486339c9afddca8aa1a6df97720c944407945ebd02a7918455bbb235b3e74de39025594d50d8babf0014
6
+ metadata.gz: e7c270e29f9872f007ebd3863946cb665bc4d62973cfcf7e2f635533ae1e20eaba64511f474e7a33bb3dd6040a751fca2b25027db4457690bdafd9333c05cac1
7
+ data.tar.gz: 81887295612920fe124f1947e1ecc9a91143beda4097ece57015739bb1e28481d5027a2d75e1e77545f4e98c7b5c4cd5c2fab43e86c5a8b386c965dfbe0696ac
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.2.2] - 2025-09-28
11
+
12
+ ### Changed
13
+ - Expanded generator specs to cover pre-existing `application_policy.rb` files and verify directory creation semantics, reinforcing the install generator contract.
14
+ - Clarified the dummy generator base `desc` signature to mirror the Rails API and avoid warning noise in tests.
15
+ - Stubbed helper and policy spec deprecation warnings so suite output stays clean while still asserting the message payload.
16
+
17
+ ## [0.2.1] - 2025-09-27
18
+
19
+ ### Added
20
+ - `Verikloak::Pundit.reset!` helper to restore default configuration, easing test teardown.
21
+ - `Verikloak::Pundit::Delegations` shared module consolidating role and permission helper methods.
22
+ - `Verikloak::Pundit::ClaimUtils` for consistent claim normalization across entry points.
23
+
24
+ ### Changed
25
+ - `UserContext` memoizes role lookups and caches mapped permissions to reduce repeated work inside policies.
26
+ - `UserContext` now normalizes inputs via `ClaimUtils` and supports symbolized permission comparison for mixed role map values.
27
+ - Configuration duplication derives from a unified `deep_dup`, ensuring hash keys are copied and nested structures remain isolated.
28
+ - README documents the new delegations module, configuration reset helper, and deprecation guidance.
29
+
10
30
  ## [0.2.0] - 2025-09-21
11
31
 
12
32
  ### 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
- - **Helpers**: `has_role?`, `in_group?`, `resource_role?(client, role)`
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)
@@ -140,6 +140,14 @@ docker compose run --rm dev rspec
140
140
  docker compose run --rm dev rubocop -a
141
141
  ```
142
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
+
143
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:
144
152
 
145
153
  ```bash
@@ -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
@@ -115,29 +115,33 @@ module Verikloak
115
115
  dup_string(value).freeze
116
116
  end
117
117
 
118
- # Recursively duplicate a hash, cloning nested structures so the copy can
119
- # be mutated safely.
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.
120
140
  #
121
141
  # @param value [Hash, nil]
122
142
  # @return [Hash, nil]
123
143
  def dup_hash(value)
124
- return nil if value.nil?
125
-
126
- copy = value.dup
127
- copy.each do |key, element|
128
- copy[key] =
129
- case element
130
- when Hash
131
- dup_hash(element)
132
- when Array
133
- dup_array(element)
134
- when String
135
- dup_string(element)
136
- else
137
- duplicable?(element) ? element.dup : element
138
- end
139
- end
140
- copy
144
+ deep_dup(value)
141
145
  end
142
146
 
143
147
  # Duplicate a string guardingly, returning `nil` when no value is present.
@@ -145,33 +149,15 @@ module Verikloak
145
149
  # @param value [String, nil]
146
150
  # @return [String, nil]
147
151
  def dup_string(value)
148
- return nil if value.nil?
149
-
150
- value.dup
152
+ deep_dup(value)
151
153
  end
152
154
 
153
- # Recursively duplicate an array while copying nested structures.
155
+ # Duplicate an array using deep duplication.
154
156
  #
155
157
  # @param value [Array, nil]
156
158
  # @return [Array, nil]
157
159
  def dup_array(value)
158
- return nil if value.nil?
159
-
160
- copy = value.dup
161
- return copy unless copy.respond_to?(:map)
162
-
163
- copy.map do |element|
164
- case element
165
- when Hash
166
- dup_hash(element)
167
- when Array
168
- dup_array(element)
169
- when String
170
- dup_string(element)
171
- else
172
- duplicable?(element) ? element.dup : element
173
- end
174
- end
160
+ deep_dup(value)
175
161
  end
176
162
 
177
163
  # Check whether a value can be safely duplicated using `dup`.
@@ -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
- # 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
9
+ include Delegations
11
10
 
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
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
- # Check whether the user has a realm role.
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,9 +1,18 @@
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
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
+
7
16
  attr_reader :claims, :resource_client, :config
8
17
 
9
18
  # Create a new user context from JWT claims.
@@ -13,27 +22,29 @@ module Verikloak
13
22
  # @param config [Verikloak::Pundit::Configuration] configuration snapshot to use
14
23
  def initialize(claims, resource_client: nil, config: nil)
15
24
  @config = config || Verikloak::Pundit.config
16
- @claims = claims || {}
25
+ @claims = ClaimUtils.normalize(claims)
17
26
  @resource_client = (resource_client || @config.resource_client).to_s
18
27
  end
19
28
 
20
29
  # Subject identifier from claims.
21
30
  # @return [String, nil]
22
31
  def sub
23
- claims['sub']
32
+ claims[CLAIM_SUB]
24
33
  end
25
34
 
26
35
  # Email or preferred username from claims.
27
36
  # @return [String, nil]
28
37
  def email
29
- claims['email'] || claims['preferred_username']
38
+ claims[CLAIM_EMAIL] || claims[CLAIM_PREFERRED_USERNAME]
30
39
  end
31
40
 
32
41
  # Realm-level roles from claims based on configuration path.
33
42
  # @return [Array<String>]
34
43
  def realm_roles
35
- path = resolve_path(config.realm_roles_path)
36
- Array(claims.dig(*path))
44
+ @realm_roles ||= begin
45
+ path = resolve_path(config.realm_roles_path)
46
+ Array(claims.dig(*path)).map(&:to_s).uniq.freeze
47
+ end
37
48
  end
38
49
 
39
50
  # Resource-level roles for a given client from claims based on configuration path.
@@ -42,8 +53,10 @@ module Verikloak
42
53
  # @return [Array<String>]
43
54
  def resource_roles(client = resource_client)
44
55
  client = client.to_s
45
- path = resolve_path(config.resource_roles_path, client: client)
46
- Array(claims.dig(*path))
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
47
60
  end
48
61
 
49
62
  # Check whether the user has a realm role.
@@ -51,8 +64,7 @@ module Verikloak
51
64
  # @param role [String, Symbol]
52
65
  # @return [Boolean]
53
66
  def has_role?(role) # rubocop:disable Naming/PredicatePrefix
54
- r = role.to_s
55
- realm_roles.include?(r)
67
+ realm_roles.include?(role.to_s)
56
68
  end
57
69
 
58
70
  # Alias to has_role? to align with group-based naming.
@@ -69,9 +81,7 @@ module Verikloak
69
81
  # @param role [String, Symbol]
70
82
  # @return [Boolean]
71
83
  def resource_role?(client, role)
72
- client = client.to_s
73
- r = role.to_s
74
- resource_roles(client).include?(r)
84
+ resource_roles(client).include?(role.to_s)
75
85
  end
76
86
 
77
87
  # Check whether the user has a mapped permission.
@@ -82,10 +92,7 @@ module Verikloak
82
92
  # @param perm [String, Symbol] permission to check
83
93
  # @return [Boolean]
84
94
  def has_permission?(perm) # rubocop:disable Naming/PredicatePrefix
85
- pr = perm.to_sym
86
- roles = realm_roles + resource_roles_scope
87
- mapped = roles.map { |r| RoleMapper.map(r, config) }
88
- mapped.map(&:to_sym).include?(pr)
95
+ permission_set.include?(normalize_to_symbol(perm))
89
96
  end
90
97
 
91
98
  # Build a user context from Rack env using configured claims key.
@@ -94,7 +101,7 @@ module Verikloak
94
101
  # @return [UserContext]
95
102
  def self.from_env(env)
96
103
  config = Verikloak::Pundit.config
97
- claims = env[config.env_claims_key]
104
+ claims = env&.fetch(config.env_claims_key, nil)
98
105
  new(claims, config: config)
99
106
  end
100
107
 
@@ -135,15 +142,18 @@ module Verikloak
135
142
  # Collect resource roles from all clients under resource_access.
136
143
  # @return [Array<String>]
137
144
  def resource_roles_all_clients
138
- access = claims['resource_access']
139
- return [] unless access.is_a?(Hash)
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)
140
150
 
141
- # Bypass configured path lambda (which targets the default client)
142
- # and gather roles from all clients explicitly.
143
- access.each_with_object([]) do |(client_id, entry), roles|
144
- next unless permission_client_allowed?(client_id)
145
-
146
- roles.concat(Array(entry['roles']))
151
+ acc.concat(Array(entry[CLAIM_ROLES]))
152
+ end
153
+ roles.map(&:to_s).uniq.freeze
154
+ else
155
+ [].freeze
156
+ end
147
157
  end
148
158
  end
149
159
 
@@ -157,6 +167,52 @@ module Verikloak
157
167
 
158
168
  whitelist.include?(client_id.to_s)
159
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
215
+ end
160
216
  end
161
217
  end
162
218
  end
@@ -5,6 +5,6 @@ module Verikloak
5
5
  # Gem version for verikloak-pundit.
6
6
  #
7
7
  # @return [String]
8
- VERSION = '0.2.0'
8
+ VERSION = '0.2.2'
9
9
  end
10
10
  end
@@ -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.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - taiyaky
@@ -49,7 +49,7 @@ dependencies:
49
49
  requirements:
50
50
  - - ">="
51
51
  - !ruby/object:Gem::Version
52
- version: 0.1.5
52
+ version: 0.2.0
53
53
  - - "<"
54
54
  - !ruby/object:Gem::Version
55
55
  version: 1.0.0
@@ -59,7 +59,7 @@ dependencies:
59
59
  requirements:
60
60
  - - ">="
61
61
  - !ruby/object:Gem::Version
62
- version: 0.1.5
62
+ version: 0.2.0
63
63
  - - "<"
64
64
  - !ruby/object:Gem::Version
65
65
  version: 1.0.0
@@ -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.2.0
97
+ documentation_uri: https://rubydoc.info/gems/verikloak-pundit/0.2.2
96
98
  rubygems_mfa_required: 'true'
97
99
  rdoc_options: []
98
100
  require_paths: