verikloak-pundit 0.1.0 → 0.2.0

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: 6ece87ad9829b8123230d55d270c57bf399c3b16a5c8d38e7e89e3c2e31a3f63
4
- data.tar.gz: 511d923d115ebe33105099ab61b0390e46288e013ecca8a455c9b896abf9d5e7
3
+ metadata.gz: 6936cd5efb5ccaecf789b427db2f7cd09f358604ba334f592f3e5079ac6501c4
4
+ data.tar.gz: 37db6dc08acd9918bebb15bd8dbb32320e00661ffc81b1b40c85850ec1c0822c
5
5
  SHA512:
6
- metadata.gz: acf8d7d1a12e3b12ce152deb35c4c506adbb6a9002cbc30aa9293ed8c5577c838692795c27edc332802db689a5af9ae55dcf6e9b06abd18f8b3cf77e06f1a331
7
- data.tar.gz: 9a514dcdd539c3a9e1ef7abbcfb858df7a8899ecb71985972b8a9199d2153efd434f06e3cbb798daf5ea9a990c7e41b750f189dbf01b0149208ead90b0f39072
6
+ metadata.gz: 120391c41f3f769ce49d7bddd83bed04b010ea367d8b63ddedb5de4610f9f8a6600f29f56f84c929b2209e4c26d6825a3d774ae1f32c20f16dd6d53378d5f9bf
7
+ data.tar.gz: a6d8babf7f27a842105fcc9ba44a42f9c73a21c45eeb486339c9afddca8aa1a6df97720c944407945ebd02a7918455bbb235b3e74de39025594d50d8babf0014
data/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.2.0] - 2025-09-21
11
+
12
+ ### Added
13
+ - Allow `permission_role_scope = :all_resources` to respect the new
14
+ `permission_resource_clients` whitelist so only approved clients contribute
15
+ to permission checks.
16
+ - Document verikloak-bff and verikloak-audience integration patterns,
17
+ including `env_claims_key` examples and role naming guidance.
18
+
19
+ ### Changed
20
+ - `UserContext` now snapshots the configuration at initialization to keep
21
+ behavior consistent even if `Verikloak::Pundit.configure` runs mid-request.
22
+ - Bump the minimum `verikloak` runtime dependency to `>= 0.1.5` to pick up
23
+ client whitelist support.
24
+
25
+ ## [0.1.1] - 2025-09-20
26
+
27
+ ### Added
28
+ - Optional exposure flag for the Rails helper (`expose_helper_method`) so claims can stay hidden from views when not needed.
29
+ - Continuous integration job that installs the latest `verikloak` and `verikloak-rails` releases and executes `integration/check.rb` to verify compatibility.
30
+ - Detailed operational guidance in README/ERRORS explaining the risks of `permission_role_scope = :all_resources` and helper exposure.
31
+
32
+ ### Changed
33
+ - Configuration publishing now duplicates nested structures and freezes them, reducing race conditions when reconfiguring at runtime.
34
+ - `integration/check.rb` now exercises realm/resource roles, permission scopes, and helper exposure to catch regressions early.
35
+
10
36
  ## [0.1.0] - 2025-09-20
11
37
 
12
38
  ### Added
data/README.md CHANGED
@@ -11,8 +11,6 @@ Pundit integration for the **Verikloak** family. This gem maps **Keycloak roles*
11
11
  - Provides a `pundit_user` hook so policies can use `user.has_role?(:admin)` etc.
12
12
  - Keeps role mapping **configurable** (project-specific mappings differ).
13
13
 
14
-
15
-
16
14
  ## Features
17
15
 
18
16
  - **UserContext**: lightweight wrapper around JWT claims
@@ -88,10 +86,38 @@ Verikloak::Pundit.configure do |c|
88
86
  # Permission mapping scope for `user.has_permission?`:
89
87
  # :default_resource => realm roles + default client roles (recommended)
90
88
  # :all_resources => realm roles + roles from all clients in resource_access
89
+ # (enabling this broadens permissions to every resource client;
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
97
+
98
+ # Expose `verikloak_claims` to views via helper_method (Rails only)
99
+ c.expose_helper_method = true
92
100
  end
93
101
  ```
94
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
+
95
121
  ## Non-Rails / custom usage
96
122
 
97
123
  ```ruby
@@ -114,12 +140,37 @@ docker compose run --rm dev rspec
114
140
  docker compose run --rm dev rubocop -a
115
141
  ```
116
142
 
143
+ 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
+
145
+ ```bash
146
+ docker compose run --rm -e BUNDLE_FROZEN=0 dev bash -lc '
147
+ cd integration && \
148
+ apk add --no-cache --virtual .integration-build-deps \
149
+ build-base \
150
+ linux-headers \
151
+ openssl-dev \
152
+ yaml-dev && \
153
+ bundle config set --local path vendor/bundle && \
154
+ bundle install --jobs 4 --retry 3 && \
155
+ bundle exec ruby check.rb && \
156
+ apk del .integration-build-deps
157
+ '
158
+ ```
159
+
117
160
  ## Contributing
118
161
  Bug reports and pull requests are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details.
119
162
 
120
163
  ## Security
121
164
  If you find a security vulnerability, please follow the instructions in [SECURITY.md](SECURITY.md).
122
165
 
166
+ ### Operational guidance
167
+ - 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.
168
+ - Combine `permission_role_scope = :all_resources` with `permission_resource_clients`
169
+ to explicitly opt-in the clients that may contribute permissions. Leaving the
170
+ whitelist blank (the default) reverts to the legacy behavior of trusting
171
+ every client in the token.
172
+ - 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.
173
+
123
174
  ## License
124
175
  This project is licensed under the [MIT License](LICENSE).
125
176
 
@@ -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.
@@ -16,13 +16,67 @@ 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.
22
+ # @!attribute expose_helper_method
23
+ # @return [Boolean] whether to register `verikloak_claims` as a Rails helper method
19
24
  class Configuration
20
25
  attr_accessor :resource_client, :role_map, :env_claims_key,
21
26
  :realm_roles_path, :resource_roles_path,
22
- :permission_role_scope
27
+ :permission_role_scope, :permission_resource_clients,
28
+ :expose_helper_method
23
29
 
24
- # Initialize default configuration values.
25
- def initialize
30
+ # Build a new configuration, optionally copying values from another
31
+ # configuration so callers can mutate a safe duplicate.
32
+ #
33
+ # @param copy_from [Configuration, nil]
34
+ def initialize(copy_from = nil)
35
+ if copy_from
36
+ initialize_from(copy_from)
37
+ else
38
+ initialize_defaults
39
+ end
40
+ end
41
+
42
+ # Create a deep-ish copy that can be safely mutated without affecting the
43
+ # source configuration. `dup` is overridden so the object returned from
44
+ # `Verikloak::Pundit.config.dup` behaves as expected.
45
+ #
46
+ # @return [Configuration]
47
+ def dup
48
+ self.class.new(self)
49
+ end
50
+
51
+ # Duplicate the configuration via Ruby's `dup`, ensuring the new instance
52
+ # receives freshly-copied nested state.
53
+ #
54
+ # @param other [Configuration]
55
+ def initialize_copy(other)
56
+ super
57
+ initialize_from(other)
58
+ end
59
+
60
+ # Freeze the configuration and its nested structures to prevent runtime
61
+ # mutations once it is published to the global state. Returns `self` to
62
+ # allow chaining inside callers.
63
+ #
64
+ # @return [Configuration]
65
+ def finalize!
66
+ @resource_client = freeze_string(@resource_client)
67
+ @env_claims_key = freeze_string(@env_claims_key)
68
+ @role_map = dup_hash(@role_map).freeze
69
+ @realm_roles_path = dup_array(@realm_roles_path).freeze
70
+ @resource_roles_path = dup_array(@resource_roles_path).freeze
71
+ @permission_resource_clients = freeze_permission_clients(@permission_resource_clients)
72
+ @expose_helper_method = !@expose_helper_method.nil? && @expose_helper_method
73
+ freeze
74
+ end
75
+
76
+ private
77
+
78
+ # Populate default values that mirror the gem's out-of-the-box behavior.
79
+ def initialize_defaults
26
80
  @resource_client = 'rails-api'
27
81
  @role_map = {} # e.g., { admin: :manage_all }
28
82
  @env_claims_key = 'verikloak.user'
@@ -32,6 +86,115 @@ module Verikloak
32
86
  # rubocop:enable Style/SymbolProc
33
87
  # :default_resource (realm + default client), :all_resources (realm + all clients)
34
88
  @permission_role_scope = :default_resource
89
+ @permission_resource_clients = nil
90
+ @expose_helper_method = true
91
+ end
92
+
93
+ # Copy configuration fields from another instance, duplicating mutable
94
+ # structures so future writes do not leak across instances.
95
+ #
96
+ # @param other [Configuration]
97
+ def initialize_from(other)
98
+ @resource_client = dup_string(other.resource_client)
99
+ @role_map = dup_hash(other.role_map)
100
+ @env_claims_key = dup_string(other.env_claims_key)
101
+ @realm_roles_path = dup_array(other.realm_roles_path)
102
+ @resource_roles_path = dup_array(other.resource_roles_path)
103
+ @permission_role_scope = other.permission_role_scope
104
+ @permission_resource_clients = dup_array(other.permission_resource_clients)
105
+ @expose_helper_method = other.expose_helper_method
106
+ end
107
+
108
+ # Duplicate and freeze a string value, returning `nil` when appropriate.
109
+ #
110
+ # @param value [String, nil]
111
+ # @return [String, nil]
112
+ def freeze_string(value)
113
+ return nil if value.nil?
114
+
115
+ dup_string(value).freeze
116
+ end
117
+
118
+ # Recursively duplicate a hash, cloning nested structures so the copy can
119
+ # be mutated safely.
120
+ #
121
+ # @param value [Hash, nil]
122
+ # @return [Hash, nil]
123
+ 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
141
+ end
142
+
143
+ # Duplicate a string guardingly, returning `nil` when no value is present.
144
+ #
145
+ # @param value [String, nil]
146
+ # @return [String, nil]
147
+ def dup_string(value)
148
+ return nil if value.nil?
149
+
150
+ value.dup
151
+ end
152
+
153
+ # Recursively duplicate an array while copying nested structures.
154
+ #
155
+ # @param value [Array, nil]
156
+ # @return [Array, nil]
157
+ 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
175
+ end
176
+
177
+ # Check whether a value can be safely duplicated using `dup`.
178
+ #
179
+ # @param value [Object]
180
+ # @return [Boolean]
181
+ def duplicable?(value)
182
+ return false if value.nil?
183
+ return false if [true, false].include?(value)
184
+ return false if value.is_a?(Symbol) || value.is_a?(Numeric) || value.is_a?(Proc)
185
+
186
+ value.respond_to?(:dup)
187
+ end
188
+
189
+ # Normalize and freeze the configured permission clients list.
190
+ #
191
+ # @param value [Array<String, Symbol>, nil]
192
+ # @return [Array<String>, nil]
193
+ def freeze_permission_clients(value)
194
+ array = dup_array(value)
195
+ return nil if array.nil?
196
+
197
+ array.compact.map(&:to_s).uniq.freeze
35
198
  end
36
199
  end
37
200
  end
@@ -7,7 +7,10 @@ module Verikloak
7
7
  # Hook used by Rails to include helper methods in views when available.
8
8
  # @param base [Class]
9
9
  def self.included(base)
10
- base.helper_method :verikloak_claims if base.respond_to?(:helper_method)
10
+ return unless base.respond_to?(:helper_method)
11
+
12
+ config = Verikloak::Pundit.config
13
+ base.helper_method :verikloak_claims if config.expose_helper_method
11
14
  end
12
15
 
13
16
  # Pundit hook returning the UserContext built from Rack env claims.
@@ -4,15 +4,17 @@ module Verikloak
4
4
  module Pundit
5
5
  # Lightweight wrapper around Keycloak claims for Pundit policies.
6
6
  class UserContext
7
- attr_reader :claims, :resource_client
7
+ attr_reader :claims, :resource_client, :config
8
8
 
9
9
  # Create a new user context from JWT claims.
10
10
  #
11
11
  # @param claims [Hash] JWT claims issued by Keycloak
12
12
  # @param resource_client [String] default resource client name for resource roles
13
- def initialize(claims, resource_client: Verikloak::Pundit.config.resource_client)
13
+ # @param config [Verikloak::Pundit::Configuration] configuration snapshot to use
14
+ def initialize(claims, resource_client: nil, config: nil)
15
+ @config = config || Verikloak::Pundit.config
14
16
  @claims = claims || {}
15
- @resource_client = resource_client.to_s
17
+ @resource_client = (resource_client || @config.resource_client).to_s
16
18
  end
17
19
 
18
20
  # Subject identifier from claims.
@@ -30,7 +32,7 @@ module Verikloak
30
32
  # Realm-level roles from claims based on configuration path.
31
33
  # @return [Array<String>]
32
34
  def realm_roles
33
- path = resolve_path(Verikloak::Pundit.config.realm_roles_path)
35
+ path = resolve_path(config.realm_roles_path)
34
36
  Array(claims.dig(*path))
35
37
  end
36
38
 
@@ -40,7 +42,7 @@ module Verikloak
40
42
  # @return [Array<String>]
41
43
  def resource_roles(client = resource_client)
42
44
  client = client.to_s
43
- path = resolve_path(Verikloak::Pundit.config.resource_roles_path, client: client)
45
+ path = resolve_path(config.resource_roles_path, client: client)
44
46
  Array(claims.dig(*path))
45
47
  end
46
48
 
@@ -82,7 +84,7 @@ module Verikloak
82
84
  def has_permission?(perm) # rubocop:disable Naming/PredicatePrefix
83
85
  pr = perm.to_sym
84
86
  roles = realm_roles + resource_roles_scope
85
- mapped = roles.map { |r| RoleMapper.map(r, Verikloak::Pundit.config) }
87
+ mapped = roles.map { |r| RoleMapper.map(r, config) }
86
88
  mapped.map(&:to_sym).include?(pr)
87
89
  end
88
90
 
@@ -91,8 +93,9 @@ module Verikloak
91
93
  # @param env [Hash] Rack environment
92
94
  # @return [UserContext]
93
95
  def self.from_env(env)
94
- claims = env[Verikloak::Pundit.config.env_claims_key]
95
- new(claims)
96
+ config = Verikloak::Pundit.config
97
+ claims = env[config.env_claims_key]
98
+ new(claims, config: config)
96
99
  end
97
100
 
98
101
  private
@@ -108,9 +111,9 @@ module Verikloak
108
111
  when Proc
109
112
  # Support lambdas that accept (config) or (config, client)
110
113
  if seg.arity >= 2
111
- seg.call(Verikloak::Pundit.config, client).to_s
114
+ seg.call(config, client).to_s
112
115
  else
113
- seg.call(Verikloak::Pundit.config).to_s
116
+ seg.call(config).to_s
114
117
  end
115
118
  else
116
119
  seg.to_s
@@ -121,7 +124,7 @@ module Verikloak
121
124
  # Resolve resource roles based on configured permission scope.
122
125
  # @return [Array<String>]
123
126
  def resource_roles_scope
124
- case Verikloak::Pundit.config.permission_role_scope&.to_sym
127
+ case config.permission_role_scope&.to_sym
125
128
  when :all_resources
126
129
  resource_roles_all_clients
127
130
  else
@@ -137,7 +140,22 @@ module Verikloak
137
140
 
138
141
  # Bypass configured path lambda (which targets the default client)
139
142
  # and gather roles from all clients explicitly.
140
- access.values.flat_map { |entry| Array(entry['roles']) }
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']))
147
+ end
148
+ end
149
+
150
+ # Check whether the given client is allowed for permission scope.
151
+ #
152
+ # @param client_id [String]
153
+ # @return [Boolean]
154
+ def permission_client_allowed?(client_id)
155
+ whitelist = config.permission_resource_clients
156
+ return true if whitelist.nil?
157
+
158
+ whitelist.include?(client_id.to_s)
141
159
  end
142
160
  end
143
161
  end
@@ -5,6 +5,6 @@ module Verikloak
5
5
  # Gem version for verikloak-pundit.
6
6
  #
7
7
  # @return [String]
8
- VERSION = '0.1.0'
8
+ VERSION = '0.2.0'
9
9
  end
10
10
  end
@@ -19,16 +19,32 @@ module Verikloak
19
19
  # @yield [Configuration] Yields the configuration instance for mutation.
20
20
  # @return [Configuration] the current configuration after applying changes
21
21
  def configure
22
- @config ||= Configuration.new
23
- yield @config if block_given?
24
- @config
22
+ new_config = nil
23
+ config_mutex.synchronize do
24
+ current = @config&.dup || Configuration.new
25
+ yield current if block_given?
26
+ new_config = current.finalize!
27
+ @config = new_config
28
+ end
29
+ new_config
25
30
  end
26
31
 
27
32
  # Access the current configuration without mutating it.
28
33
  #
29
34
  # @return [Configuration]
30
35
  def config
31
- @config ||= Configuration.new
36
+ config_mutex.synchronize do
37
+ @config ||= Configuration.new.finalize!
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # Mutex protecting configuration reads/writes to maintain thread safety.
44
+ #
45
+ # @return [Mutex]
46
+ def config_mutex
47
+ @config_mutex ||= Mutex.new
32
48
  end
33
49
  end
34
50
  end
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.1.0
4
+ version: 0.2.0
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.1.2
52
+ version: 0.1.5
53
53
  - - "<"
54
54
  - !ruby/object:Gem::Version
55
- version: '0.2'
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.1.2
62
+ version: 0.1.5
63
63
  - - "<"
64
64
  - !ruby/object:Gem::Version
65
- version: '0.2'
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: []
@@ -92,7 +92,7 @@ metadata:
92
92
  source_code_uri: https://github.com/taiyaky/verikloak-pundit
93
93
  changelog_uri: https://github.com/taiyaky/verikloak-pundit/blob/main/CHANGELOG.md
94
94
  bug_tracker_uri: https://github.com/taiyaky/verikloak-pundit/issues
95
- documentation_uri: https://rubydoc.info/gems/verikloak-pundit/0.1.0
95
+ documentation_uri: https://rubydoc.info/gems/verikloak-pundit/0.2.0
96
96
  rubygems_mfa_required: 'true'
97
97
  rdoc_options: []
98
98
  require_paths: