scimitar 2.3.0 → 2.4.1

Sign up to get free protection for your applications and to get access to all the features.
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