scimitar 2.3.0 → 2.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3904e389b3b00b4c49df6d9267cc0b5413613ed56b544ee888dd4b3bfa9d7489
4
- data.tar.gz: 93b14676bda6d86d4add6c0bcbf8ae6415ff49177b384f2b75571a304485d87f
3
+ metadata.gz: 7db2b533d9bc2fe1e359a12ad51fea89bdd5498589451b16b99987ca0cd99f09
4
+ data.tar.gz: 7b30102df8ee36d8bac2698ef150a3537c7b40f6193a64e3baed7e8d6c8386d4
5
5
  SHA512:
6
- metadata.gz: f1fad29d6ba090ad00b55bdf59f1050aacd1858d4e511e726d3fa501a37389e57777cd2cb2253c20972106e567a3f4b5e07ecd53a17723dcab2517f0c8ab99cf
7
- data.tar.gz: 30a1c3ad4c6653b347cf00228cad99b88a3dc4a2da45fe311b61d0a0f2d08db723fabf2041e3d72f1d1cea28d633c540322394841723bdd8db7d805e49819eb4
6
+ metadata.gz: 238c6bdebc57c0d8c186ad77d8222a79c7f9fad98b86a1d564c2c27d62a58dd809c1a4b2a7a9c57af535728f6a23d36821c12e74be81e8191d96960cdd1d1db9
7
+ data.tar.gz: 684c778f5e3d03719b3df872ca8efcb29a18cd802360964282e38ed914b095676aeec4556a625c99f2a3de86f898cae1dd989be67614711194fd176a1c5895c1
@@ -443,9 +443,30 @@ module Scimitar
443
443
  ci_scim_hash = { 'root' => ci_scim_hash }.with_indifferent_case_insensitive_access()
444
444
  end
445
445
 
446
+ # Handle extension schema. Contributed by @bettysteger and
447
+ # @MorrisFreeman via:
448
+ #
449
+ # https://github.com/RIPAGlobal/scimitar/issues/48
450
+ # https://github.com/RIPAGlobal/scimitar/pull/49
451
+ #
452
+ # Note the ":" separating the schema ID (URN) from the attribute.
453
+ # The nature of JSON rendering / other payloads might lead you to
454
+ # expect a "." as with any complex types, but that's not the case;
455
+ # see https://tools.ietf.org/html/rfc7644#section-3.10, or
456
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2 of which in
457
+ # particular, https://tools.ietf.org/html/rfc7644#page-35.
458
+ #
459
+ paths = []
460
+ self.class.scim_resource_type.extended_schemas.each do |schema|
461
+ path_str.downcase.split(schema.id.downcase + ':').drop(1).each do |path|
462
+ paths += [schema.id] + path.split('.')
463
+ end
464
+ end
465
+ paths = path_str.split('.') if paths.empty?
466
+
446
467
  self.from_patch_backend!(
447
468
  nature: nature,
448
- path: (path_str || '').split('.'),
469
+ path: paths,
449
470
  value: value,
450
471
  altering_hash: ci_scim_hash
451
472
  )
@@ -616,7 +637,19 @@ module Scimitar
616
637
  attrs_map_or_leaf_value.each do | scim_attribute, sub_attrs_map_or_leaf_value |
617
638
  next if scim_attribute&.to_s&.downcase == 'id' && path.empty?
618
639
 
619
- sub_scim_hash_or_leaf_value = scim_hash_or_leaf_value&.dig(scim_attribute.to_s)
640
+ # Handle extension schema. Contributed by @bettysteger and
641
+ # @MorrisFreeman via:
642
+ #
643
+ # https://github.com/RIPAGlobal/scimitar/issues/48
644
+ # https://github.com/RIPAGlobal/scimitar/pull/49
645
+ #
646
+ attribute_tree = []
647
+ resource_class.extended_schemas.each do |schema|
648
+ attribute_tree << schema.id and break if schema.scim_attributes.any? { |attribute| attribute.name == scim_attribute.to_s }
649
+ end
650
+ attribute_tree << scim_attribute.to_s
651
+
652
+ sub_scim_hash_or_leaf_value = scim_hash_or_leaf_value&.dig(*attribute_tree)
620
653
 
621
654
  self.from_scim_backend!(
622
655
  attrs_map_or_leaf_value: sub_attrs_map_or_leaf_value,
@@ -901,14 +934,86 @@ module Scimitar
901
934
  else
902
935
  altering_hash[path_component] = value
903
936
  end
937
+
904
938
  when 'replace'
905
939
  if path_component == 'root'
906
940
  altering_hash[path_component].merge!(value)
907
941
  else
908
942
  altering_hash[path_component] = value
909
943
  end
944
+
945
+ # The array check handles payloads seen from e.g. Microsoft for
946
+ # remove-user-from-group, where contrary to examples in the RFC
947
+ # which would imply "payload removes all users", there is the
948
+ # clear intent to remove just one.
949
+ #
950
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.2
951
+ # https://learn.microsoft.com/en-us/azure/active-directory/app-provisioning/use-scim-to-provision-users-and-groups#update-group-remove-members
952
+ #
953
+ # Since remove-all in the face of remove-one is destructive, we
954
+ # do a special check here to see if there's an array value for
955
+ # the array path that the payload yielded. If so, we can match
956
+ # each value against array items and remove just those items.
957
+ #
958
+ # There is an additional special case to handle a bad example
959
+ # from Salesforce:
960
+ #
961
+ # https://help.salesforce.com/s/articleView?id=sf.identity_scim_manage_groups.htm&type=5
962
+ #
910
963
  when 'remove'
911
- altering_hash.delete(path_component)
964
+ if altering_hash[path_component].is_a?(Array) && value.present?
965
+
966
+ # Handle bad Salesforce example. That might be simply a
967
+ # documentation error, but just in case...
968
+ #
969
+ value = value.values.first if (
970
+ path_component&.downcase == 'members' &&
971
+ value.is_a?(Hash) &&
972
+ value.keys.size == 1 &&
973
+ value.keys.first&.downcase == 'members'
974
+ )
975
+
976
+ # The Microsoft example provides an array of values, but we
977
+ # may as well cope with a value specified 'flat'. Promote
978
+ # such a thing to an Array to simplify the following code.
979
+ #
980
+ value = [value] unless value.is_a?(Array)
981
+
982
+ # For each value item, delete matching array entries. The
983
+ # concept of "matching" is:
984
+ #
985
+ # * For simple non-Hash values (if possible) just delete on
986
+ # an exact match
987
+ #
988
+ # * For Hash-based values, only delete if all 'patch' keys
989
+ # are present in the resource and all values thus match.
990
+ #
991
+ # Special case to ignore '$ref' from the Microsoft payload.
992
+ #
993
+ # Note coercion to strings to account for SCIM vs the usual
994
+ # tricky case of underlying implementations with (say)
995
+ # integer primary keys, which all end up as strings anyway.
996
+ #
997
+ value.each do | value_item |
998
+ altering_hash[path_component].delete_if do | item |
999
+ if item.is_a?(Hash) && value_item.is_a?(Hash)
1000
+ matched_all = true
1001
+ value_item.each do | value_key, value_value |
1002
+ next if value_key == '$ref'
1003
+ if ! item.key?(value_key) || item[value_key]&.to_s != value_value&.to_s
1004
+ matched_all = false
1005
+ end
1006
+ end
1007
+ matched_all
1008
+ else
1009
+ item&.to_s == value_item&.to_s
1010
+ end
1011
+ end
1012
+ end
1013
+ else
1014
+ altering_hash.delete(path_component)
1015
+ end
1016
+
912
1017
  end
913
1018
  end
914
1019
  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.3.0'
6
+ VERSION = '2.4.1'
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 = '2023-01-17'
11
+ DATE = '2023-03-02'
12
12
 
13
13
  end
@@ -15,6 +15,8 @@ class MockUser < ActiveRecord::Base
15
15
  work_email_address
16
16
  home_email_address
17
17
  work_phone_number
18
+ organization
19
+ department
18
20
  }
19
21
 
20
22
  has_and_belongs_to_many :mock_groups
@@ -84,7 +86,13 @@ class MockUser < ActiveRecord::Base
84
86
  }
85
87
  }
86
88
  ],
87
- active: :is_active
89
+ active: :is_active,
90
+
91
+ # Custom extension schema - see configuration in
92
+ # "spec/apps/dummy/config/initializers/scimitar.rb".
93
+ #
94
+ organization: :organization,
95
+ department: :department
88
96
  }
89
97
  end
90
98
 
@@ -1,5 +1,14 @@
1
1
  # Test app configuration.
2
2
  #
3
+ # Note that as a result of https://github.com/RIPAGlobal/scimitar/issues/48,
4
+ # tests include a custom extension of the core User schema. A shortcoming of
5
+ # some of the code from which Scimitar was originally built is that those
6
+ # extensions are done with class-level ivars, so it is largely impossible (or
7
+ # at least, impractical in tests) to avoid polluting the core class itself
8
+ # with the extension.
9
+ #
10
+ # All related schema tests are written with this in mind.
11
+ #
3
12
  Rails.application.config.to_prepare do
4
13
  Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
5
14
 
@@ -13,4 +22,32 @@ Rails.application.config.to_prepare do
13
22
  end
14
23
 
15
24
  })
25
+
26
+ module ScimSchemaExtensions
27
+ module User
28
+ class Enterprise < Scimitar::Schema::Base
29
+ def initialize(options = {})
30
+ super(
31
+ name: 'ExtendedUser',
32
+ description: 'Enterprise extension for a User',
33
+ id: self.class.id,
34
+ scim_attributes: self.class.scim_attributes
35
+ )
36
+ end
37
+
38
+ def self.id
39
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'
40
+ end
41
+
42
+ def self.scim_attributes
43
+ [
44
+ Scimitar::Schema::Attribute.new(name: 'organization', type: 'string'),
45
+ Scimitar::Schema::Attribute.new(name: 'department', type: 'string')
46
+ ]
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ Scimitar::Resources::User.extend_schema ScimSchemaExtensions::User::Enterprise
16
53
  end
@@ -15,6 +15,7 @@ Rails.application.routes.draw do
15
15
 
16
16
  get 'Groups', to: 'mock_groups#index'
17
17
  get 'Groups/:id', to: 'mock_groups#show'
18
+ patch 'Groups/:id', to: 'mock_groups#update'
18
19
 
19
20
  # For testing blocks passed to ActiveRecordBackedResourcesController#destroy
20
21
  #
@@ -3,6 +3,8 @@ class CreateMockUsers < ActiveRecord::Migration[6.1]
3
3
  create_table :mock_users, id: :uuid, primary_key: :primary_key do |t|
4
4
  t.timestamps
5
5
 
6
+ # Support part of the core schema
7
+ #
6
8
  t.text :scim_uid
7
9
  t.text :username
8
10
  t.text :first_name
@@ -10,6 +12,12 @@ class CreateMockUsers < ActiveRecord::Migration[6.1]
10
12
  t.text :work_email_address
11
13
  t.text :home_email_address
12
14
  t.text :work_phone_number
15
+
16
+ # Support the custom extension schema - see configuration in
17
+ # "spec/apps/dummy/config/initializers/scimitar.rb".
18
+ #
19
+ t.text :organization
20
+ t.text :department
13
21
  end
14
22
  end
15
23
  end
@@ -38,6 +38,8 @@ ActiveRecord::Schema[7.0].define(version: 2021_03_08_044214) do
38
38
  t.text "work_email_address"
39
39
  t.text "home_email_address"
40
40
  t.text "work_phone_number"
41
+ t.text "organization"
42
+ t.text "department"
41
43
  end
42
44
 
43
45
  add_foreign_key "mock_groups_users", "mock_groups"
@@ -14,9 +14,9 @@ RSpec.describe Scimitar::SchemasController do
14
14
  get :index, params: { format: :scim }
15
15
  expect(response).to be_ok
16
16
  parsed_body = JSON.parse(response.body)
17
- expect(parsed_body.length).to eql(2)
17
+ expect(parsed_body.length).to eql(3)
18
18
  schema_names = parsed_body.map {|schema| schema['name']}
19
- expect(schema_names).to match_array(['User', 'Group'])
19
+ expect(schema_names).to match_array(['User', 'ExtendedUser', 'Group'])
20
20
  end
21
21
 
22
22
  it 'returns only the User schema when its id is provided' do
@@ -250,90 +250,185 @@ RSpec.describe Scimitar::Resources::Base do
250
250
  end # "context 'dynamic setters based on schema' do"
251
251
 
252
252
  context 'schema extension' do
253
- ThirdCustomSchema = Class.new(Scimitar::Schema::Base) do
254
- def self.id
255
- 'custom-id'
256
- end
253
+ context 'of custom schema' do
254
+ ThirdCustomSchema = Class.new(Scimitar::Schema::Base) do
255
+ def self.id
256
+ 'custom-id'
257
+ end
257
258
 
258
- def self.scim_attributes
259
- [ Scimitar::Schema::Attribute.new(name: 'name', type: 'string') ]
259
+ def self.scim_attributes
260
+ [ Scimitar::Schema::Attribute.new(name: 'name', type: 'string') ]
261
+ end
260
262
  end
261
- end
262
263
 
263
- ExtensionSchema = Class.new(Scimitar::Schema::Base) do
264
- def self.id
265
- 'extension-id'
266
- end
264
+ ExtensionSchema = Class.new(Scimitar::Schema::Base) do
265
+ def self.id
266
+ 'extension-id'
267
+ end
267
268
 
268
- def self.scim_attributes
269
- [ Scimitar::Schema::Attribute.new(name: 'relationship', type: 'string', required: true) ]
269
+ def self.scim_attributes
270
+ [ Scimitar::Schema::Attribute.new(name: 'relationship', type: 'string', required: true) ]
271
+ end
270
272
  end
271
- end
272
273
 
273
- let(:resource_class) {
274
- Class.new(Scimitar::Resources::Base) do
275
- set_schema ThirdCustomSchema
276
- extend_schema ExtensionSchema
274
+ let(:resource_class) {
275
+ Class.new(Scimitar::Resources::Base) do
276
+ set_schema ThirdCustomSchema
277
+ extend_schema ExtensionSchema
277
278
 
278
- def self.endpoint
279
- '/gaga'
279
+ def self.endpoint
280
+ '/gaga'
281
+ end
282
+
283
+ def self.resource_type_id
284
+ 'CustomResource'
285
+ end
280
286
  end
287
+ }
281
288
 
282
- def self.resource_type_id
283
- 'CustomResource'
289
+ context '#initialize' do
290
+ it 'allows setting extension attributes' do
291
+ resource = resource_class.new('extension-id' => {relationship: 'GAGA'})
292
+ expect(resource.relationship).to eql('GAGA')
284
293
  end
285
- end
286
- }
294
+ end # "context '#initialize' do"
295
+
296
+ context '#as_json' do
297
+ it 'namespaces the extension attributes' do
298
+ resource = resource_class.new(relationship: 'GAGA')
299
+ hash = resource.as_json
300
+ expect(hash["schemas"]).to eql(['custom-id', 'extension-id'])
301
+ expect(hash["extension-id"]).to eql("relationship" => 'GAGA')
302
+ end
303
+ end # "context '#as_json' do"
287
304
 
288
- context '#initialize' do
289
- it 'allows setting extension attributes' do
290
- resource = resource_class.new('extension-id' => {relationship: 'GAGA'})
291
- expect(resource.relationship).to eql('GAGA')
292
- end
293
- end # "context '#initialize' do"
305
+ context '.resource_type' do
306
+ it 'appends the extension schemas' do
307
+ resource_type = resource_class.resource_type('http://gaga')
308
+ expect(resource_type.meta.location).to eql('http://gaga')
309
+ expect(resource_type.schemaExtensions.count).to eql(1)
310
+ end
294
311
 
295
- context '#as_json' do
296
- it 'namespaces the extension attributes' do
297
- resource = resource_class.new(relationship: 'GAGA')
298
- hash = resource.as_json
299
- expect(hash["schemas"]).to eql(['custom-id', 'extension-id'])
300
- expect(hash["extension-id"]).to eql("relationship" => 'GAGA')
301
- end
302
- end # "context '#as_json' do"
312
+ context 'validation' do
313
+ it 'validates into custom schema' do
314
+ resource = resource_class.new('extension-id' => {})
315
+ expect(resource.valid?).to eql(false)
303
316
 
304
- context '.resource_type' do
305
- it 'appends the extension schemas' do
306
- resource_type = resource_class.resource_type('http://gaga')
307
- expect(resource_type.meta.location).to eql('http://gaga')
308
- expect(resource_type.schemaExtensions.count).to eql(1)
309
- end
317
+ resource = resource_class.new('extension-id' => {relationship: 'GAGA'})
318
+ expect(resource.relationship).to eql('GAGA')
319
+ expect(resource.valid?).to eql(true)
320
+ end
321
+ end # context 'validation'
322
+ end # "context '.resource_type' do"
310
323
 
311
- context 'validation' do
312
- it 'validates into custom schema' do
313
- resource = resource_class.new('extension-id' => {})
314
- expect(resource.valid?).to eql(false)
324
+ context '.find_attribute' do
325
+ it 'finds in first schema' do
326
+ found = resource_class().find_attribute('name') # Defined in ThirdCustomSchema
327
+ expect(found).to be_present
328
+ expect(found.name).to eql('name')
329
+ expect(found.type).to eql('string')
330
+ end
315
331
 
316
- resource = resource_class.new('extension-id' => {relationship: 'GAGA'})
317
- expect(resource.relationship).to eql('GAGA')
318
- expect(resource.valid?).to eql(true)
332
+ it 'finds across schemas' do
333
+ found = resource_class().find_attribute('relationship') # Defined in ExtensionSchema
334
+ expect(found).to be_present
335
+ expect(found.name).to eql('relationship')
336
+ expect(found.type).to eql('string')
319
337
  end
320
- end # context 'validation'
321
- end # "context '.resource_type' do"
338
+ end # "context '.find_attribute' do"
339
+ end # "context 'of custom schema' do"
322
340
 
323
- context '.find_attribute' do
324
- it 'finds in first schema' do
325
- found = resource_class().find_attribute('name') # Defined in ThirdCustomSchema
326
- expect(found).to be_present
327
- expect(found.name).to eql('name')
328
- expect(found.type).to eql('string')
329
- end
341
+ context 'of core schema' do
342
+ EnterpriseExtensionSchema = Class.new(Scimitar::Schema::Base) do
343
+ def self.id
344
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'
345
+ end
330
346
 
331
- it 'finds across schemas' do
332
- found = resource_class().find_attribute('relationship') # Defined in ExtensionSchema
333
- expect(found).to be_present
334
- expect(found.name).to eql('relationship')
335
- expect(found.type).to eql('string')
347
+ def self.scim_attributes
348
+ [
349
+ Scimitar::Schema::Attribute.new(name: 'organization', type: 'string'),
350
+ Scimitar::Schema::Attribute.new(name: 'department', type: 'string')
351
+ ]
352
+ end
336
353
  end
337
- end # "context '.find_attribute' do"
354
+
355
+ let(:resource_class) {
356
+ Class.new(Scimitar::Resources::Base) do
357
+ set_schema Scimitar::Schema::User
358
+ extend_schema EnterpriseExtensionSchema
359
+
360
+ def self.endpoint
361
+ '/Users'
362
+ end
363
+
364
+ def self.resource_type_id
365
+ 'User'
366
+ end
367
+ end
368
+ }
369
+
370
+ context '#initialize' do
371
+ it 'allows setting extension attributes' do
372
+ resource = resource_class.new('urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => {organization: 'SOMEORG', department: 'SOMEDPT'})
373
+
374
+ expect(resource.organization).to eql('SOMEORG')
375
+ expect(resource.department ).to eql('SOMEDPT')
376
+ end
377
+ end # "context '#initialize' do"
378
+
379
+ context '#as_json' do
380
+ it 'namespaces the extension attributes' do
381
+ resource = resource_class.new(organization: 'SOMEORG', department: 'SOMEDPT')
382
+ hash = resource.as_json
383
+
384
+ expect(hash['schemas']).to eql(['urn:ietf:params:scim:schemas:core:2.0:User', 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'])
385
+ expect(hash['urn:ietf:params:scim:schemas:extension:enterprise:2.0:User']).to eql('organization' => 'SOMEORG', 'department' => 'SOMEDPT')
386
+ end
387
+ end # "context '#as_json' do"
388
+
389
+ context '.resource_type' do
390
+ it 'appends the extension schemas' do
391
+ resource_type = resource_class.resource_type('http://example.com')
392
+ expect(resource_type.meta.location).to eql('http://example.com')
393
+ expect(resource_type.schemaExtensions.count).to eql(1)
394
+ end
395
+
396
+ context 'validation' do
397
+ it 'validates into custom schema' do
398
+ resource = resource_class.new('urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => {})
399
+ expect(resource.valid?).to eql(false)
400
+
401
+ resource = resource_class.new(
402
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => {
403
+ userName: 'SOMEUSR',
404
+ organization: 'SOMEORG',
405
+ department: 'SOMEDPT'
406
+ }
407
+ )
408
+
409
+ expect(resource.organization).to eql('SOMEORG')
410
+ expect(resource.department ).to eql('SOMEDPT')
411
+ expect(resource.valid? ).to eql(true)
412
+ end
413
+ end # context 'validation'
414
+ end # "context '.resource_type' do"
415
+
416
+ context '.find_attribute' do
417
+ it 'finds in first schema' do
418
+ found = resource_class().find_attribute('userName') # Defined in Scimitar::Schema::User
419
+
420
+ expect(found).to be_present
421
+ expect(found.name).to eql('userName')
422
+ expect(found.type).to eql('string')
423
+ end
424
+
425
+ it 'finds across schemas' do
426
+ found = resource_class().find_attribute('organization') # Defined in EnterpriseExtensionSchema
427
+ expect(found).to be_present
428
+ expect(found.name).to eql('organization')
429
+ expect(found.type).to eql('string')
430
+ end
431
+ end # "context '.find_attribute' do"
432
+ end # "context 'of core schema' do"
338
433
  end # "context 'schema extension' do"
339
434
  end