scimitar 2.12.0 → 2.14.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: 4e8493f9f42ec39cc98639167c39e60261400208a011e21d202116d5ca43daff
4
- data.tar.gz: fb1fe271b42006d0e0054207d873b18ad15851d76597bf78c591f2254eab6688
3
+ metadata.gz: c5f63ca5b1572be0cb53a95a47280099713fd32be8f5650396a0db6a0c92550e
4
+ data.tar.gz: 2f1f2a7ec7a6877607875f6b86e7b9859ae9124f71d295541847940dd31c8b1f
5
5
  SHA512:
6
- metadata.gz: 4f2d68d767cdf33d97684697466dbca05122783f6a081010f874ef8546cc684c3fe9ca8c72478c6214a7493ffc3d80dfd8a742d03c0748968a32b3dc76980e05
7
- data.tar.gz: 456146fa9cd91c44208e689ba4ba5663626e4ba8bcd3fbdcd592e78785e5af63449d41e68c465e84f8567352a505d02a200b8fbca36b38b7a9d61bc7784b73b8
6
+ metadata.gz: '08626642f4b3e9dd29a6141c72bea784ab094ec197d9f3b9278957a59bfe230f4ef7ec84d52b1584127af647809351607bc01ef408d7269cc39dcf1f17180d49'
7
+ data.tar.gz: 53845ba684f54f8e2e90e696b769aadfe803809fa3004fa35f779794d6e04bfa034ee231847382591cdc5600cc7544bbad4c959e2031c0271e67e5632d431f6c
data/README.md CHANGED
@@ -64,7 +64,7 @@ Some aspects of configuration are handled via a `config/initializers/scimitar.rb
64
64
  Rails.application.config.to_prepare do
65
65
  Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
66
66
  # ...see subsections below for configuration options...
67
- end
67
+ })
68
68
  end
69
69
  ```
70
70
 
@@ -123,7 +123,7 @@ Here's an example where Warden is being used for authentication, with Warden sto
123
123
 
124
124
  ```ruby
125
125
  Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
126
- custome_authenticator: Proc.new do
126
+ custom_authenticator: Proc.new do
127
127
 
128
128
  # In this example we catch the Warden 'throw' for failed authentication, as
129
129
  # well as allowing Warden to successfully find an *authenticated* user, but
@@ -184,7 +184,36 @@ Note that Okta has some [curious documentation on its use of `POST` vs `PATCH` f
184
184
 
185
185
  ### Google Workspace note
186
186
 
187
- Using SCIM with Google Workspace might only work for a subset of applications. Since web UIs for major service providers change very often, it doesn't make sense to provide extensive documentation here as it would get out of date quickly; you may have to figure out the setup as best you can using whatever current Google documentation exists for their system. There are [some notes which were relevant around mid-2025](https://github.com/pond/scimitar/issues/142#issuecomment-2699050541) (from when a workarond/fix was incorporated into Scimitar to allow it to work with Google Workspace) which may help you get started.
187
+ Using SCIM with Google Workspace might only work for a subset of applications. Since web UIs for major service providers change very often, it doesn't make sense to provide extensive documentation here as it would get out of date quickly; you may have to figure out the setup as best you can using whatever current Google documentation exists for their system. There are [some notes which were relevant around mid-2025](https://github.com/pond/scimitar/issues/142#issuecomment-2699050541) (from when a workaround/fix was incorporated into Scimitar to allow it to work with Google Workspace) which may help you get started.
188
+
189
+ ### Request content type handling
190
+
191
+ The correct content type for SCIM is `application/scim+json`. Scimitar tolerates some variants of this, rewriting things internally so that the request continues to be processed by the rest of the gem (including ending up in subclass code you write) under this media type, with a Rails request format of `:scim`. Sometimes, callers into SCIM endpoints might use a content type that Scimitar rejects. If so, you can configure a custom request sanitizer Proc (with a "z", in keeping with Rails spelling of "sanitize"):
192
+
193
+ ```ruby
194
+ Rails.application.config.to_prepare do
195
+ Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
196
+
197
+ custom_request_sanitizer: Proc.new do | request |
198
+ #
199
+ # Examine e.g. 'request.media_type' and evaluate to a Symbol:
200
+ #
201
+ # :success - set the standard content type and ensure the Rails request
202
+ # format is :scim; continue processing normally
203
+ #
204
+ # :preserve - retain the existing content type and Rails request format;
205
+ # continue processing normally
206
+ #
207
+ # :fail - the request format appears to be invalid; generate a 406
208
+ # ("Not Acceptable") response
209
+ #
210
+ end
211
+
212
+ })
213
+ end
214
+ ```
215
+
216
+ The Proc is passed the [`ActionDispatch::Request`](https://api.rubyonrails.org/classes/ActionDispatch/Request.html) instance for the current request. It **must** evaluable to a Symbol as shown above. Typically, `:preserve` is only for very special use cases where you understand that the request headers and/or format might not match what other parts of Scimitar expect, but have written appropriate custom code elsewhere to deal with that.
188
217
 
189
218
  ### Data models
190
219
 
@@ -255,8 +284,8 @@ class User < ActiveRecord::Base
255
284
  ],
256
285
 
257
286
  # NB The 'groups' collection in a SCIM User resource is read-only, so
258
- # we provide no ":find_with" key for looking up records for writing
259
- # updates to the associated collection.
287
+ # we provide no ":find_with" or ":find_all_with" key for looking up
288
+ # records for writing updates to the associated collection.
260
289
  #
261
290
  groups: [
262
291
  {
@@ -93,16 +93,37 @@ module Scimitar
93
93
  #
94
94
  def require_scim
95
95
  scim_mime_type = Mime::Type.lookup_by_extension(:scim).to_s
96
+ failure_detail = "Only #{scim_mime_type} type is accepted."
97
+
98
+ if Scimitar.engine_configuration.custom_request_sanitizer.is_a?(Proc)
99
+
100
+ result = Scimitar.engine_configuration.custom_request_sanitizer.call(request)
101
+ case result
102
+ when :fail
103
+ handle_scim_error(ErrorResponse.new(status: 406, detail: failure_detail))
104
+ when :preserve
105
+ # Do nothing
106
+ else
107
+ request.format = :scim
108
+ request.headers['CONTENT_TYPE'] = scim_mime_type
109
+ end
110
+
111
+ else # "if Scimitar.engine_configuration.custom_request_sanitizer.present?"
112
+
113
+ if request.media_type.nil? || request.media_type.empty?
114
+ request.format = :scim
115
+ request.headers['CONTENT_TYPE'] = scim_mime_type
116
+ elsif request.media_type.downcase == scim_mime_type
117
+ request.format = :scim
118
+ elsif request.format == :scim
119
+ request.headers['CONTENT_TYPE'] = scim_mime_type
120
+ elsif request.media_type.downcase == 'application/json' && request.user_agent&.start_with?('Google') # https://github.com/pond/scimitar/issues/142
121
+ request.format = :scim
122
+ request.headers["CONTENT_TYPE"] = scim_mime_type
123
+ else
124
+ handle_scim_error(ErrorResponse.new(status: 406, detail: failure_detail))
125
+ end
96
126
 
97
- if request.media_type.nil? || request.media_type.empty?
98
- request.format = :scim
99
- request.headers['CONTENT_TYPE'] = scim_mime_type
100
- elsif request.media_type.downcase == scim_mime_type
101
- request.format = :scim
102
- elsif request.format == :scim
103
- request.headers['CONTENT_TYPE'] = scim_mime_type
104
- else
105
- handle_scim_error(ErrorResponse.new(status: 406, detail: "Only #{scim_mime_type} type is accepted."))
106
127
  end
107
128
  end
108
129
 
@@ -12,6 +12,7 @@ module Scimitar
12
12
  :basic_authenticator,
13
13
  :token_authenticator,
14
14
  :custom_authenticator,
15
+ :custom_request_sanitizer,
15
16
  :application_controller_mixin,
16
17
  :exception_reporter,
17
18
  :optional_value_fields_required,
@@ -145,7 +145,8 @@ module Scimitar
145
145
  # display: :full_name # <-- i.e. Team.users[n].full_name
146
146
  # },
147
147
  # class: Team, # Optional; see below
148
- # find_with: -> (scim_list_entry) {...} # See below
148
+ # find_with: -> (scim_list_entry) {...}, # See below
149
+ # find_all_with: -> (scim_list_entries) {...} # Optional, See below
149
150
  # }
150
151
  # ],
151
152
  # #...
@@ -165,6 +166,11 @@ module Scimitar
165
166
  # Scimitar::EngineConfiguration::schema_list_from_attribute_mappings is
166
167
  # defined; see documentation of that option for more information.
167
168
  #
169
+ # To avoid N+1 queries when resolving many entries (e.g. Group members
170
+ # during PATCH), you can instead provide ":find_all_with" which is passed
171
+ # the entire Array of SCIM entries and should return an Array of resolved
172
+ # model instances. If both are provided, ":find_all_with" is preferred.
173
+ #
168
174
  # Note that you can only use either:
169
175
  #
170
176
  # * One or more static maps where each matches some other piece of source
@@ -315,7 +321,7 @@ module Scimitar
315
321
  enum.each do | static_or_dynamic_mapping |
316
322
  if static_or_dynamic_mapping.key?(:match) # Static
317
323
  extractor.call(static_or_dynamic_mapping[:using])
318
- elsif static_or_dynamic_mapping.key?(:find_with) # Dynamic
324
+ elsif static_or_dynamic_mapping.key?(:find_with) || static_or_dynamic_mapping.key?(:find_all_with) # Dynamic
319
325
  @scim_mutable_attributes << static_or_dynamic_mapping[:list]
320
326
  end
321
327
  end
@@ -839,9 +845,17 @@ module Scimitar
839
845
  method = "#{mapped_array_entry[:list]}="
840
846
 
841
847
  if (attribute&.mutability == 'readWrite' || attribute&.mutability == 'writeOnly') && self.respond_to?(method)
842
- find_with_proc = mapped_array_entry[:find_with]
848
+ find_all_with_proc = mapped_array_entry[:find_all_with]
849
+ find_with_proc = mapped_array_entry[:find_with]
850
+
851
+ if find_all_with_proc.respond_to?(:call)
852
+ scim_entries = (scim_hash_or_leaf_value || [])
853
+ mapped_list = find_all_with_proc.call(scim_entries) || []
843
854
 
844
- unless find_with_proc.nil?
855
+ mapped_list.compact!
856
+
857
+ self.public_send(method, mapped_list)
858
+ elsif find_with_proc.respond_to?(:call)
845
859
  mapped_list = (scim_hash_or_leaf_value || []).map do | source_list_entry |
846
860
  find_with_proc.call(source_list_entry)
847
861
  end
@@ -3,11 +3,11 @@ module Scimitar
3
3
  # Gem version. If this changes, be sure to re-run "bundle install" or
4
4
  # "bundle update".
5
5
  #
6
- VERSION = '2.12.0'
6
+ VERSION = '2.14.0'
7
7
 
8
8
  # Date for VERSION. If this changes, be sure to re-run "bundle install"
9
9
  # or "bundle update".
10
10
  #
11
- DATE = '2025-08-15'
11
+ DATE = '2025-11-14'
12
12
 
13
13
  end
@@ -0,0 +1,13 @@
1
+ class MockBatchGroupsController < Scimitar::ActiveRecordBackedResourcesController
2
+
3
+ protected
4
+
5
+ def storage_class
6
+ MockGroupBatch
7
+ end
8
+
9
+ def storage_scope
10
+ MockGroupBatch.all
11
+ end
12
+
13
+ end
@@ -0,0 +1,20 @@
1
+ class MockGroupBatch < MockGroup
2
+ def self.scim_attributes_map
3
+ {
4
+ id: :id,
5
+ externalId: :scim_uid,
6
+ displayName: :display_name,
7
+ members: [
8
+ {
9
+ list: :scim_users_and_groups,
10
+ using: { value: :id },
11
+ # Minimal mock: assume user-only entries (type omitted => User)
12
+ find_all_with: -> (entries) do
13
+ ids = entries.map { |e| e['value'] }
14
+ MockUser.where(primary_key: ids).to_a
15
+ end
16
+ }
17
+ ]
18
+ }
19
+ end
20
+ end
@@ -17,6 +17,12 @@ Rails.application.routes.draw do
17
17
  get 'Groups/:id', to: 'mock_groups#show'
18
18
  patch 'Groups/:id', to: 'mock_groups#update'
19
19
 
20
+ # Batch lookup variant for testing the mixin 'find_all_with' option.
21
+ #
22
+ get 'BatchGroups', to: 'mock_batch_groups#index'
23
+ get 'BatchGroups/:id', to: 'mock_batch_groups#show'
24
+ patch 'BatchGroups/:id', to: 'mock_batch_groups#update'
25
+
20
26
  # For testing blocks passed to ActiveRecordBackedResourcesController#create,
21
27
  # #update, #replace and #destroy.
22
28
  #
@@ -416,6 +416,80 @@ RSpec.describe Scimitar::ApplicationController do
416
416
  expect(@exception.message).to eql('Only application/scim+json type is accepted.')
417
417
  end
418
418
  end
419
+
420
+ context 'and with Google SCIM calls' do
421
+ it 'reaches the controller action if the expected agent is making the request' do
422
+ request.headers['Content-Type'] = 'application/json'
423
+ request.headers['User-Agent' ] = 'Google-Auto-Provisioning'
424
+ get :index
425
+
426
+ expect(@exception).to be_a(RuntimeError)
427
+ expect(@exception.message).to eql('Bang')
428
+ end
429
+
430
+ it 'is invoked early for unrecognised agents' do
431
+ request.headers['Content-Type'] = 'application/json'
432
+ get :index
433
+
434
+ expect(@exception).to be_a(Scimitar::ErrorResponse)
435
+ expect(@exception.message).to eql('Only application/scim+json type is accepted.')
436
+ end
437
+ end # "context 'and with Google SCIM calls' do"
438
+
439
+ context 'and with a custom request sanitizer' do
440
+ around :each do | example |
441
+ original_configuration = Scimitar.engine_configuration.custom_request_sanitizer
442
+ Scimitar.engine_configuration.custom_request_sanitizer = Proc.new do | request |
443
+ case request.media_type
444
+ when 'application/json+success'
445
+ :success
446
+ when 'application/json+preserve'
447
+ :preserve
448
+ else
449
+ :fail
450
+ end
451
+ end
452
+ example.run()
453
+ ensure
454
+ Scimitar.engine_configuration.custom_request_sanitizer = original_configuration
455
+ end
456
+
457
+ context 'returning "success"' do
458
+ it 'reaches the controller action with cleaned up request data' do
459
+ request.headers['Content-Type'] = 'application/json+success'
460
+ get :index
461
+
462
+ expect(@exception).to be_a(RuntimeError)
463
+ expect(@exception.message).to eql('Bang')
464
+
465
+ expect(request.format == :scim).to eql(true)
466
+ expect(request.headers['CONTENT_TYPE']).to eql('application/scim+json')
467
+ end
468
+ end # "context 'returning "success"' do"
469
+
470
+ context 'returning "preserve"' do
471
+ it 'reaches the controller action with unmodified request data' do
472
+ request.headers['Content-Type'] = 'application/json+preserve'
473
+ get :index
474
+
475
+ expect(@exception).to be_a(RuntimeError)
476
+ expect(@exception.message).to eql('Bang')
477
+
478
+ expect(request.format == :html).to eql(true)
479
+ expect(request.headers['CONTENT_TYPE']).to eql('application/json+preserve')
480
+ end
481
+ end # "context 'returning "keep"' do"
482
+
483
+ context 'returning "fail"' do
484
+ it 'is invoked' do
485
+ request.headers['Content-Type'] = 'application/json+fail'
486
+ get :index
487
+
488
+ expect(@exception).to be_a(Scimitar::ErrorResponse)
489
+ expect(@exception.message).to eql('Only application/scim+json type is accepted.')
490
+ end
491
+ end # "context 'returning "fail"' do"
492
+ end # "context 'and with a custom request sanitizer' do"
419
493
  end # "context 'exception reporter' do"
420
494
  end # "context 'error handling' do"
421
495
  end
@@ -1137,6 +1137,39 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
1137
1137
  expect(@u2.password).to eql('correcthorsebatterystaple')
1138
1138
  end
1139
1139
 
1140
+ context "when updating group members using :find_all_with" do
1141
+ it "uses :find_all_with to batch-resolve users and updates associations" do
1142
+ payload = {
1143
+ schemas: [ 'urn:ietf:params:scim:api:messages:2.0:PatchOp' ],
1144
+ Operations: [
1145
+ {
1146
+ op: 'add',
1147
+ path: 'members',
1148
+ value: [
1149
+ { 'value' => @u1.primary_key },
1150
+ { 'value' => @u2.primary_key }
1151
+ ]
1152
+ }
1153
+ ]
1154
+ }
1155
+
1156
+ payload = spec_helper_hupcase(payload) if force_upper_case
1157
+
1158
+ patch "/BatchGroups/#{@g1.id}", params: payload.merge({ format: :scim })
1159
+
1160
+ expect(response.status).to eql(200)
1161
+
1162
+ # Verify membership updated
1163
+ get "/BatchGroups/#{@g1.id}", params: { format: :scim }
1164
+ expect(response.status).to eql(200)
1165
+ result = JSON.parse(response.body)
1166
+
1167
+ values = result.fetch('members', []).map { |m| m['value'] }
1168
+ expect(values).to include(@u1.primary_key.to_s)
1169
+ expect(values).to include(@u2.primary_key.to_s)
1170
+ end
1171
+ end
1172
+
1140
1173
  context 'which clears attributes' do
1141
1174
  before :each do
1142
1175
  @u2.update!(work_email_address: 'work_2@test.com')
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scimitar
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.12.0
4
+ version: 2.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - RIPA Global
8
8
  - Andrew David Hodgkinson
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-08-15 00:00:00.000000000 Z
11
+ date: 2025-11-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '13.2'
47
+ version: '13.3'
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '13.2'
54
+ version: '13.3'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: pg
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -86,14 +86,14 @@ dependencies:
86
86
  requirements:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
- version: '6.14'
89
+ version: '6.15'
90
90
  type: :development
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
- version: '6.14'
96
+ version: '6.15'
97
97
  - !ruby/object:Gem::Dependency
98
98
  name: warden
99
99
  requirement: !ruby/object:Gem::Requirement
@@ -230,9 +230,11 @@ files:
230
230
  - spec/apps/dummy/app/controllers/custom_request_verifiers_controller.rb
231
231
  - spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb
232
232
  - spec/apps/dummy/app/controllers/custom_update_mock_users_controller.rb
233
+ - spec/apps/dummy/app/controllers/mock_batch_groups_controller.rb
233
234
  - spec/apps/dummy/app/controllers/mock_groups_controller.rb
234
235
  - spec/apps/dummy/app/controllers/mock_users_controller.rb
235
236
  - spec/apps/dummy/app/models/mock_group.rb
237
+ - spec/apps/dummy/app/models/mock_group_batch.rb
236
238
  - spec/apps/dummy/app/models/mock_user.rb
237
239
  - spec/apps/dummy/config/application.rb
238
240
  - spec/apps/dummy/config/boot.rb
@@ -304,9 +306,11 @@ test_files:
304
306
  - spec/apps/dummy/app/controllers/custom_request_verifiers_controller.rb
305
307
  - spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb
306
308
  - spec/apps/dummy/app/controllers/custom_update_mock_users_controller.rb
309
+ - spec/apps/dummy/app/controllers/mock_batch_groups_controller.rb
307
310
  - spec/apps/dummy/app/controllers/mock_groups_controller.rb
308
311
  - spec/apps/dummy/app/controllers/mock_users_controller.rb
309
312
  - spec/apps/dummy/app/models/mock_group.rb
313
+ - spec/apps/dummy/app/models/mock_group_batch.rb
310
314
  - spec/apps/dummy/app/models/mock_user.rb
311
315
  - spec/apps/dummy/config/application.rb
312
316
  - spec/apps/dummy/config/boot.rb