scimitar 2.3.0 → 2.4.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: 3904e389b3b00b4c49df6d9267cc0b5413613ed56b544ee888dd4b3bfa9d7489
4
- data.tar.gz: 93b14676bda6d86d4add6c0bcbf8ae6415ff49177b384f2b75571a304485d87f
3
+ metadata.gz: bccee840f0dc82854974e8d90d19189ae133f4281142c5de89107230b413041b
4
+ data.tar.gz: 014cbf547dd861c26489aa13d5ab2c75f5826ed24ca3c98d7eb71b76d98994b1
5
5
  SHA512:
6
- metadata.gz: f1fad29d6ba090ad00b55bdf59f1050aacd1858d4e511e726d3fa501a37389e57777cd2cb2253c20972106e567a3f4b5e07ecd53a17723dcab2517f0c8ab99cf
7
- data.tar.gz: 30a1c3ad4c6653b347cf00228cad99b88a3dc4a2da45fe311b61d0a0f2d08db723fabf2041e3d72f1d1cea28d633c540322394841723bdd8db7d805e49819eb4
6
+ metadata.gz: 1619af4e575d6701471820c52e39719fd96b311b45b2c5d9907bc147253cf5904926677901d0e778865d71768405774bf7082d8a641bcc4b33c9ef3c4183ec28
7
+ data.tar.gz: 3b435a7ff4343945de4436c8f6c3e52d4f714e00bdde594074f7dd064befc9d28836946195df80cdae50aa0751f0761dce8cf926b83203efd909b86538f67351
@@ -901,14 +901,86 @@ module Scimitar
901
901
  else
902
902
  altering_hash[path_component] = value
903
903
  end
904
+
904
905
  when 'replace'
905
906
  if path_component == 'root'
906
907
  altering_hash[path_component].merge!(value)
907
908
  else
908
909
  altering_hash[path_component] = value
909
910
  end
911
+
912
+ # The array check handles payloads seen from e.g. Microsoft for
913
+ # remove-user-from-group, where contrary to examples in the RFC
914
+ # which would imply "payload removes all users", there is the
915
+ # clear intent to remove just one.
916
+ #
917
+ # https://www.rfc-editor.org/rfc/rfc7644#section-3.5.2.2
918
+ # https://learn.microsoft.com/en-us/azure/active-directory/app-provisioning/use-scim-to-provision-users-and-groups#update-group-remove-members
919
+ #
920
+ # Since remove-all in the face of remove-one is destructive, we
921
+ # do a special check here to see if there's an array value for
922
+ # the array path that the payload yielded. If so, we can match
923
+ # each value against array items and remove just those items.
924
+ #
925
+ # There is an additional special case to handle a bad example
926
+ # from Salesforce:
927
+ #
928
+ # https://help.salesforce.com/s/articleView?id=sf.identity_scim_manage_groups.htm&type=5
929
+ #
910
930
  when 'remove'
911
- altering_hash.delete(path_component)
931
+ if altering_hash[path_component].is_a?(Array) && value.present?
932
+
933
+ # Handle bad Salesforce example. That might be simply a
934
+ # documentation error, but just in case...
935
+ #
936
+ value = value.values.first if (
937
+ path_component&.downcase == 'members' &&
938
+ value.is_a?(Hash) &&
939
+ value.keys.size == 1 &&
940
+ value.keys.first&.downcase == 'members'
941
+ )
942
+
943
+ # The Microsoft example provides an array of values, but we
944
+ # may as well cope with a value specified 'flat'. Promote
945
+ # such a thing to an Array to simplify the following code.
946
+ #
947
+ value = [value] unless value.is_a?(Array)
948
+
949
+ # For each value item, delete matching array entries. The
950
+ # concept of "matching" is:
951
+ #
952
+ # * For simple non-Hash values (if possible) just delete on
953
+ # an exact match
954
+ #
955
+ # * For Hash-based values, only delete if all 'patch' keys
956
+ # are present in the resource and all values thus match.
957
+ #
958
+ # Special case to ignore '$ref' from the Microsoft payload.
959
+ #
960
+ # Note coercion to strings to account for SCIM vs the usual
961
+ # tricky case of underlying implementations with (say)
962
+ # integer primary keys, which all end up as strings anyway.
963
+ #
964
+ value.each do | value_item |
965
+ altering_hash[path_component].delete_if do | item |
966
+ if item.is_a?(Hash) && value_item.is_a?(Hash)
967
+ matched_all = true
968
+ value_item.each do | value_key, value_value |
969
+ next if value_key == '$ref'
970
+ if ! item.key?(value_key) || item[value_key]&.to_s != value_value&.to_s
971
+ matched_all = false
972
+ end
973
+ end
974
+ matched_all
975
+ else
976
+ item&.to_s == value_item&.to_s
977
+ end
978
+ end
979
+ end
980
+ else
981
+ altering_hash.delete(path_component)
982
+ end
983
+
912
984
  end
913
985
  end
914
986
  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.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 = '2023-01-17'
11
+ DATE = '2023-01-27'
12
12
 
13
13
  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
  #
@@ -1230,6 +1230,595 @@ RSpec.describe Scimitar::Resources::Mixin do
1230
1230
 
1231
1231
  expect(scim_hash).to_not have_key('emails')
1232
1232
  end
1233
+
1234
+ # What we expect:
1235
+ #
1236
+ # https://www.rfc-editor.org/rfc/rfc7644#section-3.5.2.2
1237
+ # https://docs.snowflake.com/en/user-guide/scim-intro.html#patch-scim-v2-groups-id
1238
+ #
1239
+ # ...vs accounting for the unusual payloads we sometimes get,
1240
+ # tested here.
1241
+ #
1242
+ context 'special cases' do
1243
+
1244
+ # https://learn.microsoft.com/en-us/azure/active-directory/app-provisioning/use-scim-to-provision-users-and-groups#update-group-remove-members
1245
+ #
1246
+ context 'Microsoft-style payload' do
1247
+ context 'removing a user from a group' do
1248
+ it 'removes identified user' do
1249
+ path = [ 'members' ]
1250
+ value = [ { '$ref' => nil, 'value' => 'f648f8d5ea4e4cd38e9c' } ]
1251
+ scim_hash = {
1252
+ 'displayname' => 'Mock group',
1253
+ 'members' => [
1254
+ {
1255
+ 'value' => '50ca93d04ab0c2de4772',
1256
+ 'display' => 'Ingrid Smith',
1257
+ 'type' => 'User'
1258
+ },
1259
+ {
1260
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1261
+ 'display' => 'Fred Smith',
1262
+ 'type' => 'User'
1263
+ },
1264
+ {
1265
+ 'value' => 'a774d480e8112101375b',
1266
+ 'display' => 'Taylor Smith',
1267
+ 'type' => 'User'
1268
+ }
1269
+ ]
1270
+ }.with_indifferent_case_insensitive_access()
1271
+
1272
+ @instance.send(
1273
+ :from_patch_backend!,
1274
+ nature: 'remove',
1275
+ path: path,
1276
+ value: value,
1277
+ altering_hash: scim_hash
1278
+ )
1279
+
1280
+ expect(scim_hash).to eql({
1281
+ 'displayname' => 'Mock group',
1282
+ 'members' => [
1283
+ {
1284
+ 'value' => '50ca93d04ab0c2de4772',
1285
+ 'display' => 'Ingrid Smith',
1286
+ 'type' => 'User'
1287
+ },
1288
+ {
1289
+ 'value' => 'a774d480e8112101375b',
1290
+ 'display' => 'Taylor Smith',
1291
+ 'type' => 'User'
1292
+ }
1293
+ ]
1294
+ })
1295
+ end
1296
+
1297
+ it 'removes multiple identified users' do
1298
+ path = [ 'members' ]
1299
+ value = [
1300
+ { '$ref' => nil, 'value' => 'f648f8d5ea4e4cd38e9c' },
1301
+ { '$ref' => nil, 'value' => '50ca93d04ab0c2de4772' }
1302
+ ]
1303
+ scim_hash = {
1304
+ 'displayname' => 'Mock group',
1305
+ 'members' => [
1306
+ {
1307
+ 'value' => '50ca93d04ab0c2de4772',
1308
+ 'display' => 'Ingrid Smith',
1309
+ 'type' => 'User'
1310
+ },
1311
+ {
1312
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1313
+ 'display' => 'Fred Smith',
1314
+ 'type' => 'User'
1315
+ },
1316
+ {
1317
+ 'value' => 'a774d480e8112101375b',
1318
+ 'display' => 'Taylor Smith',
1319
+ 'type' => 'User'
1320
+ }
1321
+ ]
1322
+ }.with_indifferent_case_insensitive_access()
1323
+
1324
+ @instance.send(
1325
+ :from_patch_backend!,
1326
+ nature: 'remove',
1327
+ path: path,
1328
+ value: value,
1329
+ altering_hash: scim_hash
1330
+ )
1331
+
1332
+ expect(scim_hash).to eql({
1333
+ 'displayname' => 'Mock group',
1334
+ 'members' => [
1335
+ {
1336
+ 'value' => 'a774d480e8112101375b',
1337
+ 'display' => 'Taylor Smith',
1338
+ 'type' => 'User'
1339
+ }
1340
+ ]
1341
+ })
1342
+ end
1343
+
1344
+ it 'removes all users individually without error' do
1345
+ path = [ 'members' ]
1346
+ value = [ { '$ref' => nil, 'value' => 'f648f8d5ea4e4cd38e9c' } ]
1347
+ scim_hash = {
1348
+ 'displayname' => 'Mock group',
1349
+ 'members' => [
1350
+ {
1351
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1352
+ 'display' => 'Fred Smith',
1353
+ 'type' => 'User'
1354
+ }
1355
+ ]
1356
+ }.with_indifferent_case_insensitive_access()
1357
+
1358
+ @instance.send(
1359
+ :from_patch_backend!,
1360
+ nature: 'remove',
1361
+ path: path,
1362
+ value: value,
1363
+ altering_hash: scim_hash
1364
+ )
1365
+
1366
+ expect(scim_hash).to eql({
1367
+ 'displayname' => 'Mock group',
1368
+ 'members' => []
1369
+ })
1370
+ end
1371
+
1372
+ it 'can match on multiple attributes' do
1373
+ path = [ 'members' ]
1374
+ value = [ { '$ref' => nil, 'value' => 'f648f8d5ea4e4cd38e9c', 'type' => 'User' } ]
1375
+ scim_hash = {
1376
+ 'displayname' => 'Mock group',
1377
+ 'members' => [
1378
+ {
1379
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1380
+ 'display' => 'Fred Smith',
1381
+ 'type' => 'User'
1382
+ }
1383
+ ]
1384
+ }.with_indifferent_case_insensitive_access()
1385
+
1386
+ @instance.send(
1387
+ :from_patch_backend!,
1388
+ nature: 'remove',
1389
+ path: path,
1390
+ value: value,
1391
+ altering_hash: scim_hash
1392
+ )
1393
+
1394
+ expect(scim_hash).to eql({
1395
+ 'displayname' => 'Mock group',
1396
+ 'members' => []
1397
+ })
1398
+ end
1399
+
1400
+ it 'ignores unrecognised users' do
1401
+ path = [ 'members' ]
1402
+ value = [ { '$ref' => nil, 'value' => '11b054a9c85216ed9356' } ]
1403
+ scim_hash = {
1404
+ 'displayname' => 'Mock group',
1405
+ 'members' => [
1406
+ {
1407
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1408
+ 'display' => 'Fred Smith',
1409
+ 'type' => 'User'
1410
+ }
1411
+ ]
1412
+ }.with_indifferent_case_insensitive_access()
1413
+
1414
+ @instance.send(
1415
+ :from_patch_backend!,
1416
+ nature: 'remove',
1417
+ path: path,
1418
+ value: value,
1419
+ altering_hash: scim_hash
1420
+ )
1421
+
1422
+ # The 'value' mismatched, so the user was not removed.
1423
+ #
1424
+ expect(scim_hash).to eql({
1425
+ 'displayname' => 'Mock group',
1426
+ 'members' => [
1427
+ {
1428
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1429
+ 'display' => 'Fred Smith',
1430
+ 'type' => 'User'
1431
+ }
1432
+ ]
1433
+ })
1434
+ end
1435
+
1436
+ it 'ignores a mismatch on (for example) "type"' do
1437
+ path = [ 'members' ]
1438
+ value = [ { '$ref' => nil, 'value' => 'f648f8d5ea4e4cd38e9c', 'type' => 'Group' } ]
1439
+ scim_hash = {
1440
+ 'displayname' => 'Mock group',
1441
+ 'members' => [
1442
+ {
1443
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1444
+ 'display' => 'Fred Smith',
1445
+ 'type' => 'User'
1446
+ }
1447
+ ]
1448
+ }.with_indifferent_case_insensitive_access()
1449
+
1450
+ @instance.send(
1451
+ :from_patch_backend!,
1452
+ nature: 'remove',
1453
+ path: path,
1454
+ value: value,
1455
+ altering_hash: scim_hash
1456
+ )
1457
+
1458
+ # Type 'Group' mismatches 'User', so the user was not
1459
+ # removed.
1460
+ #
1461
+ expect(scim_hash).to eql({
1462
+ 'displayname' => 'Mock group',
1463
+ 'members' => [
1464
+ {
1465
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1466
+ 'display' => 'Fred Smith',
1467
+ 'type' => 'User'
1468
+ }
1469
+ ]
1470
+ })
1471
+ end
1472
+
1473
+ it 'matches keys case-insensitive' do
1474
+ path = [ 'members' ]
1475
+ value = [ { '$ref' => nil, 'VALUe' => 'f648f8d5ea4e4cd38e9c' } ]
1476
+ scim_hash = {
1477
+ 'displayname' => 'Mock group',
1478
+ 'memBERS' => [
1479
+ {
1480
+ 'vaLUe' => 'f648f8d5ea4e4cd38e9c',
1481
+ 'display' => 'Fred Smith',
1482
+ 'type' => 'User'
1483
+ }
1484
+ ]
1485
+ }.with_indifferent_case_insensitive_access()
1486
+
1487
+ @instance.send(
1488
+ :from_patch_backend!,
1489
+ nature: 'remove',
1490
+ path: path,
1491
+ value: value,
1492
+ altering_hash: scim_hash
1493
+ )
1494
+
1495
+ expect(scim_hash).to eql({
1496
+ 'displayname' => 'Mock group',
1497
+ 'members' => []
1498
+ })
1499
+ end
1500
+
1501
+ it 'matches values case-sensitive' do
1502
+ path = [ 'members' ]
1503
+ value = [ { '$ref' => nil, 'value' => 'f648f8d5ea4e4cd38e9c', 'type' => 'USER' } ]
1504
+ scim_hash = {
1505
+ 'displayname' => 'Mock group',
1506
+ 'members' => [
1507
+ {
1508
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1509
+ 'display' => 'Fred Smith',
1510
+ 'type' => 'User'
1511
+ }
1512
+ ]
1513
+ }.with_indifferent_case_insensitive_access()
1514
+
1515
+ @instance.send(
1516
+ :from_patch_backend!,
1517
+ nature: 'remove',
1518
+ path: path,
1519
+ value: value,
1520
+ altering_hash: scim_hash
1521
+ )
1522
+
1523
+ # USER mismatchs User, so the user was not removed.
1524
+ #
1525
+ expect(scim_hash).to eql({
1526
+ 'displayname' => 'Mock group',
1527
+ 'members' => [
1528
+ {
1529
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1530
+ 'display' => 'Fred Smith',
1531
+ 'type' => 'User'
1532
+ }
1533
+ ]
1534
+ })
1535
+ end
1536
+ end # "context 'removing a user from a group' do"
1537
+
1538
+ context 'generic use' do
1539
+ it 'removes matched items' do
1540
+ path = [ 'emails' ]
1541
+ value = [ { 'type' => 'work' } ]
1542
+ scim_hash = {
1543
+ 'emails' => [
1544
+ {
1545
+ 'type' => 'home',
1546
+ 'value' => 'home@test.com'
1547
+ },
1548
+ {
1549
+ 'type' => 'work',
1550
+ 'value' => 'work@test.com'
1551
+ }
1552
+ ]
1553
+ }.with_indifferent_case_insensitive_access()
1554
+
1555
+ @instance.send(
1556
+ :from_patch_backend!,
1557
+ nature: 'remove',
1558
+ path: path,
1559
+ value: value,
1560
+ altering_hash: scim_hash
1561
+ )
1562
+
1563
+ expect(scim_hash).to eql({
1564
+ 'emails' => [
1565
+ {
1566
+ 'type' => 'home',
1567
+ 'value' => 'home@test.com'
1568
+ }
1569
+ ]
1570
+ })
1571
+ end
1572
+
1573
+ it 'ignores unmatched items' do
1574
+ path = [ 'emails' ]
1575
+ value = [ { 'type' => 'missing' } ]
1576
+ scim_hash = {
1577
+ 'emails' => [
1578
+ {
1579
+ 'type' => 'home',
1580
+ 'value' => 'home@test.com'
1581
+ },
1582
+ {
1583
+ 'type' => 'work',
1584
+ 'value' => 'work@test.com'
1585
+ }
1586
+ ]
1587
+ }.with_indifferent_case_insensitive_access()
1588
+
1589
+ @instance.send(
1590
+ :from_patch_backend!,
1591
+ nature: 'remove',
1592
+ path: path,
1593
+ value: value,
1594
+ altering_hash: scim_hash
1595
+ )
1596
+
1597
+ expect(scim_hash).to eql({
1598
+ 'emails' => [
1599
+ {
1600
+ 'type' => 'home',
1601
+ 'value' => 'home@test.com'
1602
+ },
1603
+ {
1604
+ 'type' => 'work',
1605
+ 'value' => 'work@test.com'
1606
+ }
1607
+ ]
1608
+ })
1609
+ end
1610
+
1611
+ it 'compares string forms' do
1612
+ path = [ 'test' ]
1613
+ value = [
1614
+ { 'active' => true, 'value' => '12' },
1615
+ { 'active' => 'false', 'value' => 42 }
1616
+ ]
1617
+ scim_hash = {
1618
+ 'test' => [
1619
+ {
1620
+ 'active' => 'true',
1621
+ 'value' => 12
1622
+ },
1623
+ {
1624
+ 'active' => false,
1625
+ 'value' => '42'
1626
+ }
1627
+ ]
1628
+ }.with_indifferent_case_insensitive_access()
1629
+
1630
+ @instance.send(
1631
+ :from_patch_backend!,
1632
+ nature: 'remove',
1633
+ path: path,
1634
+ value: value,
1635
+ altering_hash: scim_hash
1636
+ )
1637
+
1638
+ expect(scim_hash).to eql({'test' => []})
1639
+ end
1640
+
1641
+ it 'handles a singular to-remove value rather than an array' do
1642
+ path = [ 'emails' ]
1643
+ value = { 'type' => 'work' }
1644
+ scim_hash = {
1645
+ 'emails' => [
1646
+ {
1647
+ 'type' => 'home',
1648
+ 'value' => 'home@test.com'
1649
+ },
1650
+ {
1651
+ 'type' => 'work',
1652
+ 'value' => 'work@test.com'
1653
+ }
1654
+ ]
1655
+ }.with_indifferent_case_insensitive_access()
1656
+
1657
+ @instance.send(
1658
+ :from_patch_backend!,
1659
+ nature: 'remove',
1660
+ path: path,
1661
+ value: value,
1662
+ altering_hash: scim_hash
1663
+ )
1664
+
1665
+ expect(scim_hash).to eql({
1666
+ 'emails' => [
1667
+ {
1668
+ 'type' => 'home',
1669
+ 'value' => 'home@test.com'
1670
+ }
1671
+ ]
1672
+ })
1673
+ end
1674
+
1675
+ it 'handles simple values rather than object (Hash) values' do
1676
+ path = [ 'test' ]
1677
+ value = 42
1678
+ scim_hash = {
1679
+ 'test' => [
1680
+ '21',
1681
+ '42',
1682
+ '15'
1683
+ ]
1684
+ }.with_indifferent_case_insensitive_access()
1685
+
1686
+ @instance.send(
1687
+ :from_patch_backend!,
1688
+ nature: 'remove',
1689
+ path: path,
1690
+ value: value,
1691
+ altering_hash: scim_hash
1692
+ )
1693
+
1694
+ expect(scim_hash).to eql({
1695
+ 'test' => [
1696
+ '21',
1697
+ '15'
1698
+ ]
1699
+ })
1700
+ end
1701
+ end
1702
+ end # "context 'Microsoft-style payload' do"
1703
+
1704
+ # https://help.salesforce.com/s/articleView?id=sf.identity_scim_manage_groups.htm&type=5
1705
+ #
1706
+ context 'Salesforce-style payload' do
1707
+ it 'removes identified user' do
1708
+ path = [ 'members' ]
1709
+ value = { 'members' => [ { '$ref' => nil, 'value' => 'f648f8d5ea4e4cd38e9c' } ] }
1710
+ scim_hash = {
1711
+ 'displayname' => 'Mock group',
1712
+ 'members' => [
1713
+ {
1714
+ 'value' => '50ca93d04ab0c2de4772',
1715
+ 'display' => 'Ingrid Smith',
1716
+ 'type' => 'User'
1717
+ },
1718
+ {
1719
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1720
+ 'display' => 'Fred Smith',
1721
+ 'type' => 'User'
1722
+ }
1723
+ ]
1724
+ }.with_indifferent_case_insensitive_access()
1725
+
1726
+ @instance.send(
1727
+ :from_patch_backend!,
1728
+ nature: 'remove',
1729
+ path: path,
1730
+ value: value,
1731
+ altering_hash: scim_hash
1732
+ )
1733
+
1734
+ expect(scim_hash).to eql({
1735
+ 'displayname' => 'Mock group',
1736
+ 'members' => [
1737
+ {
1738
+ 'value' => '50ca93d04ab0c2de4772',
1739
+ 'display' => 'Ingrid Smith',
1740
+ 'type' => 'User'
1741
+ }
1742
+ ]
1743
+ })
1744
+ end
1745
+
1746
+ it 'matches the "members" key case-insensitive' do
1747
+ path = [ 'members' ]
1748
+ value = { 'MEMBERS' => [ { '$ref' => nil, 'value' => 'f648f8d5ea4e4cd38e9c' } ] }
1749
+ scim_hash = {
1750
+ 'displayname' => 'Mock group',
1751
+ 'members' => [
1752
+ {
1753
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1754
+ 'display' => 'Fred Smith',
1755
+ 'type' => 'User'
1756
+ },
1757
+ {
1758
+ 'value' => 'a774d480e8112101375b',
1759
+ 'display' => 'Taylor Smith',
1760
+ 'type' => 'User'
1761
+ }
1762
+ ]
1763
+ }.with_indifferent_case_insensitive_access()
1764
+
1765
+ @instance.send(
1766
+ :from_patch_backend!,
1767
+ nature: 'remove',
1768
+ path: path,
1769
+ value: value,
1770
+ altering_hash: scim_hash
1771
+ )
1772
+
1773
+ expect(scim_hash).to eql({
1774
+ 'displayname' => 'Mock group',
1775
+ 'members' => [
1776
+ {
1777
+ 'value' => 'a774d480e8112101375b',
1778
+ 'display' => 'Taylor Smith',
1779
+ 'type' => 'User'
1780
+ }
1781
+ ]
1782
+ })
1783
+ end
1784
+
1785
+ it 'ignores unrecognised users' do
1786
+ path = [ 'members' ]
1787
+ value = { 'members' => [ { '$ref' => nil, 'value' => '11b054a9c85216ed9356' } ] }
1788
+ scim_hash = {
1789
+ 'displayname' => 'Mock group',
1790
+ 'members' => [
1791
+ {
1792
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1793
+ 'display' => 'Fred Smith',
1794
+ 'type' => 'User'
1795
+ }
1796
+ ]
1797
+ }.with_indifferent_case_insensitive_access()
1798
+
1799
+ @instance.send(
1800
+ :from_patch_backend!,
1801
+ nature: 'remove',
1802
+ path: path,
1803
+ value: value,
1804
+ altering_hash: scim_hash
1805
+ )
1806
+
1807
+ # The 'value' mismatched, so the user was not removed.
1808
+ #
1809
+ expect(scim_hash).to eql({
1810
+ 'displayname' => 'Mock group',
1811
+ 'members' => [
1812
+ {
1813
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1814
+ 'display' => 'Fred Smith',
1815
+ 'type' => 'User'
1816
+ }
1817
+ ]
1818
+ })
1819
+ end
1820
+ end # "context 'Salesforce-style payload' do"
1821
+ end # "context 'special cases' do"
1233
1822
  end # context 'when prior value already exists' do
1234
1823
 
1235
1824
  context 'when value is not present' do
@@ -1954,7 +2543,7 @@ RSpec.describe Scimitar::Resources::Mixin do
1954
2543
  :from_patch_backend!,
1955
2544
  nature: 'remove',
1956
2545
  path: ['complex[type eq "type1"]', 'data', 'nested[nature eq "nature2"]', 'info'],
1957
- value: [{ 'deeper' => 'addition' }],
2546
+ value: nil,
1958
2547
  altering_hash: scim_hash
1959
2548
  )
1960
2549
 
@@ -691,6 +691,142 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
691
691
  result = JSON.parse(response.body)
692
692
  expect(result['status']).to eql('404')
693
693
  end
694
+
695
+ context 'when removing users from groups' do
696
+ before :each do
697
+ @g1.mock_users << @u1
698
+ @g1.mock_users << @u2
699
+ @g1.mock_users << @u3
700
+
701
+ # (Self-check) Verify group representation
702
+ #
703
+ get "/Groups/#{@g1.id}", params: { format: :scim }
704
+
705
+ expect(response.status).to eql(200)
706
+ result = JSON.parse(response.body)
707
+
708
+ expect(result['members'].map { |m| m['value'] }.sort()).to eql(MockUser.pluck(:primary_key).sort())
709
+ end
710
+
711
+ it 'can remove all users' do
712
+ expect {
713
+ expect {
714
+ patch "/Groups/#{@g1.id}", params: {
715
+ format: :scim,
716
+ Operations: [
717
+ {
718
+ op: 'remove',
719
+ path: 'members'
720
+ }
721
+ ]
722
+ }
723
+ }.to_not change { MockUser.count }
724
+ }.to_not change { MockGroup.count }
725
+
726
+ get "/Groups/#{@g1.id}", params: { format: :scim }
727
+
728
+ expect(response.status).to eql(200)
729
+ result = JSON.parse(response.body)
730
+
731
+ expect(result['members']).to be_empty
732
+ expect(@g1.reload().mock_users).to be_empty
733
+ end
734
+
735
+ # Define via 'let':
736
+ #
737
+ # * Hash 'payload', to send via 'patch'
738
+ # * MockUser 'removed_user', which is the user that should be removed
739
+ #
740
+ shared_examples 'a user remover' do
741
+ it 'which removes the identified user' do
742
+ expect {
743
+ expect {
744
+ patch "/Groups/#{@g1.id}", params: payload()
745
+ }.to_not change { MockUser.count }
746
+ }.to_not change { MockGroup.count }
747
+
748
+ expected_remaining_user_ids = MockUser
749
+ .where.not(primary_key: removed_user().id)
750
+ .pluck(:primary_key)
751
+ .sort()
752
+
753
+ get "/Groups/#{@g1.id}", params: { format: :scim }
754
+
755
+ expect(response.status).to eql(200)
756
+ result = JSON.parse(response.body)
757
+
758
+ expect(result['members'].map { |m| m['value'] }.sort()).to eql(expected_remaining_user_ids)
759
+ expect(@g1.reload().mock_users.map(&:primary_key).sort()).to eql(expected_remaining_user_ids)
760
+ end
761
+ end
762
+
763
+ # https://www.rfc-editor.org/rfc/rfc7644#section-3.5.2.2
764
+ #
765
+ context 'and using an RFC-compliant payload' do
766
+ let(:removed_user) { @u2 }
767
+ let(:payload) do
768
+ {
769
+ format: :scim,
770
+ Operations: [
771
+ {
772
+ op: 'remove',
773
+ path: "members[value eq \"#{removed_user().primary_key}\"]",
774
+ }
775
+ ]
776
+ }
777
+ end
778
+
779
+ it_behaves_like 'a user remover'
780
+ end # context 'and using an RFC-compliant payload' do
781
+
782
+ # https://learn.microsoft.com/en-us/azure/active-directory/app-provisioning/use-scim-to-provision-users-and-groups#update-group-remove-members
783
+ #
784
+ context 'and using a Microsoft variant payload' do
785
+ let(:removed_user) { @u2 }
786
+ let(:payload) do
787
+ {
788
+ format: :scim,
789
+ Operations: [
790
+ {
791
+ op: 'remove',
792
+ path: 'members',
793
+ value: [{
794
+ '$ref' => nil,
795
+ 'value' => removed_user().primary_key
796
+ }]
797
+ }
798
+ ]
799
+ }
800
+ end
801
+
802
+ it_behaves_like 'a user remover'
803
+ end # context 'and using a Microsoft variant payload' do
804
+
805
+ # https://help.salesforce.com/s/articleView?id=sf.identity_scim_manage_groups.htm&type=5
806
+ #
807
+ context 'and using a Salesforce variant payload' do
808
+ let(:removed_user) { @u2 }
809
+ let(:payload) do
810
+ {
811
+ format: :scim,
812
+ Operations: [
813
+ {
814
+ op: 'remove',
815
+ path: 'members',
816
+ value: {
817
+ 'members' => [{
818
+ '$ref' => nil,
819
+ 'value' => removed_user().primary_key
820
+ }]
821
+ }
822
+ }
823
+ ]
824
+ }
825
+ end
826
+
827
+ it_behaves_like 'a user remover'
828
+ end # context 'and using a Salesforce variant payload' do
829
+ end # "context 'when removing users from groups' do"
694
830
  end # "context '#update' do"
695
831
 
696
832
  # ===========================================================================
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scimitar
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.0
4
+ version: 2.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - RIPA Global
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2023-01-17 00:00:00.000000000 Z
12
+ date: 2023-01-27 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails