scimitar 2.7.3 → 2.9.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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -18
  3. data/app/controllers/scimitar/active_record_backed_resources_controller.rb +5 -4
  4. data/app/controllers/scimitar/resource_types_controller.rb +0 -2
  5. data/app/controllers/scimitar/resources_controller.rb +0 -2
  6. data/app/controllers/scimitar/schemas_controller.rb +361 -3
  7. data/app/controllers/scimitar/service_provider_configurations_controller.rb +0 -1
  8. data/app/models/scimitar/engine_configuration.rb +3 -1
  9. data/app/models/scimitar/lists/query_parser.rb +88 -3
  10. data/app/models/scimitar/resources/base.rb +36 -5
  11. data/app/models/scimitar/resources/mixin.rb +87 -20
  12. data/app/models/scimitar/schema/name.rb +2 -2
  13. data/app/models/scimitar/schema/user.rb +10 -10
  14. data/config/initializers/scimitar.rb +41 -0
  15. data/lib/scimitar/engine.rb +57 -12
  16. data/lib/scimitar/support/utilities.rb +8 -3
  17. data/lib/scimitar/version.rb +2 -2
  18. data/spec/apps/dummy/app/models/mock_user.rb +18 -3
  19. data/spec/apps/dummy/config/initializers/scimitar.rb +31 -2
  20. data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +1 -0
  21. data/spec/apps/dummy/db/schema.rb +1 -0
  22. data/spec/controllers/scimitar/schemas_controller_spec.rb +342 -54
  23. data/spec/models/scimitar/lists/query_parser_spec.rb +70 -0
  24. data/spec/models/scimitar/resources/base_spec.rb +11 -11
  25. data/spec/models/scimitar/resources/base_validation_spec.rb +16 -3
  26. data/spec/models/scimitar/resources/mixin_spec.rb +71 -10
  27. data/spec/models/scimitar/schema/user_spec.rb +2 -2
  28. data/spec/requests/active_record_backed_resources_controller_spec.rb +132 -1
  29. data/spec/requests/engine_spec.rb +75 -0
  30. data/spec/spec_helper.rb +1 -1
  31. metadata +20 -20
@@ -59,13 +59,18 @@ module Scimitar
59
59
  #
60
60
  BINARY_OPERATORS = Set.new(OPERATORS.keys.reject { |op| UNARY_OPERATORS.include?(op) }).freeze
61
61
 
62
+ # Precompiled expression that matches a valid attribute name according to
63
+ # https://tools.ietf.org/html/rfc7643#section-2.1.
64
+ #
65
+ ATTRNAME = /[[:alpha:]][[:alnum:]$-_]*/
66
+
62
67
  # =======================================================================
63
68
  # Tokenizing expressions
64
69
  # =======================================================================
65
70
 
66
71
  PAREN = /[\(\)]/.freeze
67
72
  STR = /"(?:\\"|[^"])*"/.freeze
68
- OP = /#{OPERATORS.keys.join('|')}/i.freeze
73
+ OP = /(?:#{OPERATORS.keys.join('|')})\b/i.freeze
69
74
  WORD = /[\w\.]+/.freeze
70
75
  SEP = /\s?/.freeze
71
76
  NEXT_TOKEN = /\A(#{PAREN}|#{STR}|#{OP}|#{WORD})#{SEP}/.freeze
@@ -291,6 +296,10 @@ module Scimitar
291
296
  # the part before the "[" as a prefix - "emails[type" to "emails.type",
292
297
  # with similar substitutions therein.
293
298
  #
299
+ # Further, via https://github.com/RIPAGlobal/scimitar/issues/115 we see
300
+ # a requirement to support a broken form emitted by Microsoft; that is
301
+ # supported herein.
302
+ #
294
303
  # This method tries to flatten things thus. It throws exceptions if any
295
304
  # problems arise at all. Some limitations:
296
305
  #
@@ -314,6 +323,9 @@ module Scimitar
314
323
  # <- userType ne "Employee" and not (emails[value co "example.com" or (value co "example.org")]) and userName="foo"
315
324
  # => userType ne "Employee" and not (emails.value co "example.com" or (emails.value co "example.org")) and userName="foo"
316
325
  #
326
+ # <- emails[type eq "work"].value eq "foo@bar.com" (Microsoft workaround)
327
+ # => emails.type eq "work" and emails.value eq "foo@bar.com"
328
+ #
317
329
  # +filter+:: Input filter string. Returns a possibly modified String,
318
330
  # with the hopefully equivalent but flattened filter inside.
319
331
  #
@@ -363,9 +375,53 @@ module Scimitar
363
375
  end
364
376
 
365
377
  elsif (expecting_value)
366
- matches = downcased.match(/([^\\])\]/) # Contains no-backslash-then-literal (unescaped) ']'
378
+ matches = downcased.match(/([^\\])\](.*)/) # Contains no-backslash-then-literal (unescaped) ']'; also capture anything after
367
379
  unless matches.nil? # Contains no-backslash-then-literal (unescaped) ']'
368
380
  character_before_closing_bracket = matches[1]
381
+ characters_after_closing_bracket = matches[2]
382
+
383
+ # https://github.com/RIPAGlobal/scimitar/issues/115 - detect
384
+ # bad Microsoft filters. After the closing bracket, we expect a
385
+ # dot then valid attribute characters and at least one white
386
+ # space character and filter operator, but we split on spaces,
387
+ # so the next item in the components array must be a recognised
388
+ # operator for the special case code to kick in.
389
+ #
390
+ # If this all works out, we transform this section of the
391
+ # filter string into a local dotted form, reconstruct the
392
+ # overall filter with this substitution, and call back to this
393
+ # method with that modified filter, returning the result.
394
+ #
395
+ # So - NOTE RECURSION AND EARLY EXIT POSSIBILITY HEREIN.
396
+ #
397
+ if (
398
+ ! attribute_prefix.nil? &&
399
+ OPERATORS.key?(components[index + 1]&.downcase) &&
400
+ characters_after_closing_bracket.match?(/^\.#{ATTRNAME}$/)
401
+ )
402
+ # E.g. '"work"' and '.value' from input '"work"].value'
403
+ #
404
+ component_matches = component.match(/^(.*?[^\\])\](.*)/)
405
+ part_before_closing_bracket = component_matches[1]
406
+ part_after_closing_bracket = component_matches[2]
407
+
408
+ # Produces e.g. '"work"] and emails.value'
409
+ #
410
+ dotted_version = "#{part_before_closing_bracket}] and #{attribute_prefix}#{part_after_closing_bracket}"
411
+
412
+ # Overwrite the components array entry with this new version.
413
+ #
414
+ components[index] = dotted_version
415
+
416
+ # Join it back again as a reconstructed valid filter string.
417
+ #
418
+ hopefully_valid_filter = components.join(' ')
419
+
420
+ # NOTE EARLY EXIT
421
+ #
422
+ return flatten_filter(hopefully_valid_filter)
423
+ end
424
+
369
425
  component.gsub!(/[^\\]\]/, character_before_closing_bracket)
370
426
 
371
427
  if expecting_closing_bracket
@@ -410,7 +466,36 @@ module Scimitar
410
466
  end
411
467
  end
412
468
 
413
- return rewritten.join(' ')
469
+ # Handle schema IDs.
470
+ #
471
+ # Scimitar currently has a limitation where it strips schema IDs in
472
+ # things like PATCH operation path traversal; see
473
+ # https://github.com/RIPAGlobal/scimitar/issues/130. At least that
474
+ # makes things easy here; use the same approach and strip them out!
475
+ #
476
+ # We don't know which resource is being queried at this layer of the
477
+ # software. If Scimitar were to bump major version, then an extension
478
+ # to QueryParser#parse to include this information would be wise. In
479
+ # the mean time, all we can do is enumerate all extension schema
480
+ # subclasses with IDs and remove those IDs if present in the filter.
481
+ #
482
+ # Inbound unrecognised schema IDs will be left behind. If the client
483
+ # Scimitar application hasn't defined requested schemas, it would
484
+ # very likely never have been able to handle the filter either way.
485
+ #
486
+ rewritten_joined = rewritten.join(' ')
487
+ if rewritten_joined.include?(':')
488
+
489
+ # README.md notes that extensions *must* be a subclass of
490
+ # Scimitar::Schema::Base and must define IDs.
491
+ #
492
+ known_schema_ids = Scimitar::Schema::Base.subclasses.map { |s| s.new.id }.compact
493
+ known_schema_ids.each do | schema_id |
494
+ rewritten_joined.gsub!(/#{schema_id}[\:\.]/, '')
495
+ end
496
+ end
497
+
498
+ return rewritten_joined
414
499
  end
415
500
 
416
501
  # Service method to DRY up #flatten_filter a little. Applies a prefix
@@ -35,14 +35,45 @@ module Scimitar
35
35
  @errors = ActiveModel::Errors.new(self)
36
36
  end
37
37
 
38
+ # Scimitar has at present a general limitation in handling schema IDs,
39
+ # which just involves stripping them and requiring attributes across all
40
+ # extension schemas to be overall unique.
41
+ #
42
+ # This method takes an options payload for the initializer and strips out
43
+ # *recognised* schema IDs, so that the resulting attribute data matches
44
+ # the resource attribute map.
45
+ #
46
+ # +attributes+:: Attributes to assign via initializer; typically a POST
47
+ # payload of attributes that has been run through Rails
48
+ # strong parameters for safety.
49
+ #
50
+ # Returns a new object of the same class as +options+ with recognised
51
+ # schema IDs removed.
52
+ #
38
53
  def flatten_extension_attributes(options)
39
- flattened = options.dup
40
- self.class.extended_schemas.each do |extended_schema|
41
- if extension_attrs = flattened.delete(extended_schema.id)
42
- flattened.merge!(extension_attrs)
54
+ flattened = options.class.new
55
+ lower_case_schema_ids = self.class.extended_schemas.map do | schema |
56
+ schema.id.downcase()
57
+ end
58
+
59
+ options.each do | key, value |
60
+ path = Scimitar::Support::Utilities::path_str_to_array(
61
+ self.class.extended_schemas,
62
+ key
63
+ )
64
+
65
+ if path.first.include?(':') && lower_case_schema_ids.include?(path.first.downcase)
66
+ path.shift()
67
+ end
68
+
69
+ if path.empty?
70
+ flattened.merge!(value)
71
+ else
72
+ flattened[path.join('.')] = value
43
73
  end
44
74
  end
45
- flattened
75
+
76
+ return flattened
46
77
  end
47
78
 
48
79
  # Can be used to extend an existing resource type's schema. For example:
@@ -139,27 +139,31 @@ module Scimitar
139
139
  # # ...
140
140
  # groups: [
141
141
  # {
142
- # list: :users, # <-- i.e. Team.users,
142
+ # list: :users, # <-- i.e. Team.users,
143
143
  # using: {
144
144
  # value: :id, # <-- i.e. Team.users[n].id
145
145
  # display: :full_name # <-- i.e. Team.users[n].full_name
146
146
  # },
147
+ # class: Team, # Optional; see below
147
148
  # find_with: -> (scim_list_entry) {...} # See below
148
149
  # }
149
150
  # ],
150
151
  # #...
151
152
  # end
152
153
  #
153
- # The mixing-in class _must+ implement the read accessor identified by the
154
+ # The mixing-in class _must_ implement the read accessor identified by the
154
155
  # value of the "list" key, returning any indexed, Enumerable collection
155
156
  # (e.g. an Array or ActiveRecord::Relation instance). The optional key
156
- # ":find_with" is defined with a Proc that's passed the SCIM entry at each
157
+ # ":find_with" is defined with a Proc that is passed the SCIM entry at each
157
158
  # list position. It must use this to look up the equivalent entry for
158
159
  # association via the write accessor described by the ":list" key. In the
159
160
  # example above, "find_with"'s Proc might look at a SCIM entry value which
160
161
  # is expected to be a user ID and find that User. The mapped set of User
161
162
  # data thus found would be written back with "#users=", due to the ":list"
162
- # key declaring the method name ":users".
163
+ # key declaring the method name ":users". The optional "class" key is
164
+ # recommended but not really *needed* unless the configuration option
165
+ # Scimitar::EngineConfiguration::schema_list_from_attribute_mappings is
166
+ # defined; see documentation of that option for more information.
163
167
  #
164
168
  # Note that you can only use either:
165
169
  #
@@ -176,7 +180,8 @@ module Scimitar
176
180
  # == scim_mutable_attributes
177
181
  #
178
182
  # Define this method to return a Set (preferred) or Array of names of
179
- # attributes which may be written in the mixing-in class.
183
+ # attributes which may be written in the mixing-in class. The names MUST be
184
+ # expressed as Symbols, *not* Strings.
180
185
  #
181
186
  # If you return +nil+, it is assumed that +any+ attribute mapped by
182
187
  # ::scim_attributes_map which has a write accessor will be eligible for
@@ -204,12 +209,12 @@ module Scimitar
204
209
  # Define this method to return a Hash that maps field names you wish to
205
210
  # support in SCIM filter queries to corresponding attributes in the in the
206
211
  # mixing-in class. If +nil+ then filtering is not supported in the
207
- # ResouceController subclass which declares that it maps to the mixing-in
212
+ # ResourceController subclass which declares that it maps to the mixing-in
208
213
  # class. If not +nil+ but a SCIM filter enquiry is made for an unmapped
209
214
  # attribute, an 'invalid filter' exception is raised.
210
215
  #
211
216
  # If using ActiveRecord support in Scimitar::Lists::QueryParser, the mapped
212
- # entites are columns and that's expressed in the names of keys described
217
+ # entities are columns and that's expressed in the names of keys described
213
218
  # below; if you have other approaches to searching, these might be virtual
214
219
  # attributes or other such constructs rather than columns. That would be up
215
220
  # to your non-ActiveRecord's implementation to decide.
@@ -262,8 +267,8 @@ module Scimitar
262
267
  # both of the keys 'created' and 'lastModified', as Symbols. The values
263
268
  # should be methods that the including method supports which return a
264
269
  # creation or most-recently-updated time, respectively. The returned object
265
- # mustsupport #iso8601 to convert to a String representation. Example for a
266
- # typical ActiveRecord object with standard timestamps:
270
+ # must support #iso8601 to convert to a String representation. Example for
271
+ # a typical ActiveRecord object with standard timestamps:
267
272
  #
268
273
  # def self.scim_timestamps_map
269
274
  # {
@@ -291,7 +296,7 @@ module Scimitar
291
296
  # the result in an instance variable.
292
297
  #
293
298
  def scim_mutable_attributes
294
- @scim_mutable_attributes ||= self.class.scim_mutable_attributes()
299
+ @scim_mutable_attributes ||= self.class.scim_mutable_attributes()&.map(&:to_sym)
295
300
 
296
301
  if @scim_mutable_attributes.nil?
297
302
  @scim_mutable_attributes = Set.new
@@ -338,16 +343,32 @@ module Scimitar
338
343
  # Render self as a SCIM object using ::scim_attributes_map. Fields that
339
344
  # are marked as <tt>returned: 'never'</tt> are excluded.
340
345
  #
341
- # +location+:: The location (HTTP(S) full URI) of this resource, in the
342
- # domain of the object including this mixin - "your" IDs,
343
- # not the remote SCIM client's external IDs. #url_for is a
344
- # good way to generate this.
346
+ # +location+:: The location (HTTP(S) full URI) of this
347
+ # resource in the domain of the object including
348
+ # this mixin - "your" IDs, not the remote SCIM
349
+ # client's external IDs. #url_for is a good way
350
+ # to generate this.
345
351
  #
346
- def to_scim(location:)
352
+ # +include_attributes+:: The attributes that should be included in the
353
+ # response, in the form of a list of full
354
+ # attribute paths. Schema IDs are not supported.
355
+ # See RFC 7644 section 3.9 and section 3.10 for
356
+ # more. When a collection is given, +nil+ value
357
+ # items are also excluded from the response. If
358
+ # omitted or given an empty collection, all
359
+ # attributes are included.
360
+ #
361
+ def to_scim(location:, include_attributes: [])
347
362
  map = self.class.scim_attributes_map()
348
363
  resource_type = self.class.scim_resource_type()
349
364
  timestamps_map = self.class.scim_timestamps_map() if self.class.respond_to?(:scim_timestamps_map)
350
- attrs_hash = self.to_scim_backend(data_source: self, resource_type: resource_type, attrs_map_or_leaf_value: map)
365
+ attrs_hash = self.to_scim_backend(
366
+ data_source: self,
367
+ resource_type: resource_type,
368
+ attrs_map_or_leaf_value: map,
369
+ include_attributes: include_attributes
370
+ )
371
+
351
372
  resource = resource_type.new(attrs_hash)
352
373
  meta_attrs_hash = { location: location }
353
374
 
@@ -532,6 +553,16 @@ module Scimitar
532
553
  # +attrs_map_or_leaf_value+:: The attribute map. At the top level,
533
554
  # this is from ::scim_attributes_map.
534
555
  #
556
+ # +include_attributes+:: The attributes that should be included
557
+ # in the response, in the form of a list
558
+ # of full attribute paths. Schema IDs are
559
+ # not supported. See RFC 7644 section
560
+ # 3.9 and section 3.10 for more. When a
561
+ # collection is given, +nil+ value items
562
+ # are also excluded from the response. If
563
+ # omitted or given an empty collection,
564
+ # all attributes are included.
565
+ #
535
566
  # Internal recursive calls also send:
536
567
  #
537
568
  # +attribute_path+:: Array of path components to the
@@ -543,8 +574,15 @@ module Scimitar
543
574
  data_source:,
544
575
  resource_type:,
545
576
  attrs_map_or_leaf_value:,
577
+ include_attributes:,
546
578
  attribute_path: []
547
579
  )
580
+ # NOTE EARLY EXIT
581
+ #
582
+ return unless scim_attribute_included?(
583
+ include_attributes: include_attributes,
584
+ attribute_path: attribute_path
585
+ )
548
586
 
549
587
  # On assumption of a top-level attributes list, the 'return never'
550
588
  # state is only checked on the recursive call from a Hash type. The
@@ -554,7 +592,7 @@ module Scimitar
554
592
  #
555
593
  case attrs_map_or_leaf_value
556
594
  when Hash # Expected at top-level of any map, or nested within
557
- attrs_map_or_leaf_value.each.with_object({}) do |(key, value), hash|
595
+ result = attrs_map_or_leaf_value.each.with_object({}) do |(key, value), hash|
558
596
  nested_attribute_path = attribute_path + [key]
559
597
 
560
598
  if resource_type.find_attribute(*nested_attribute_path)&.returned != "never"
@@ -562,11 +600,15 @@ module Scimitar
562
600
  data_source: data_source,
563
601
  resource_type: resource_type,
564
602
  attribute_path: nested_attribute_path,
565
- attrs_map_or_leaf_value: value
603
+ attrs_map_or_leaf_value: value,
604
+ include_attributes: include_attributes
566
605
  )
567
606
  end
568
607
  end
569
608
 
609
+ result.compact! if include_attributes.any?
610
+ result
611
+
570
612
  when Array # Static or dynamic mapping against lists in data source
571
613
  built_dynamic_list = false
572
614
  mapped_array = attrs_map_or_leaf_value.map do |value|
@@ -580,7 +622,8 @@ module Scimitar
580
622
  data_source: data_source,
581
623
  resource_type: resource_type,
582
624
  attribute_path: attribute_path,
583
- attrs_map_or_leaf_value: value[:using]
625
+ attrs_map_or_leaf_value: value[:using],
626
+ include_attributes: include_attributes
584
627
  )
585
628
  )
586
629
  static_hash
@@ -593,7 +636,8 @@ module Scimitar
593
636
  data_source: list_entry,
594
637
  resource_type: resource_type,
595
638
  attribute_path: attribute_path,
596
- attrs_map_or_leaf_value: value[:using]
639
+ attrs_map_or_leaf_value: value[:using],
640
+ include_attributes: include_attributes
597
641
  )
598
642
  end
599
643
 
@@ -1501,6 +1545,29 @@ module Scimitar
1501
1545
  return handled
1502
1546
  end
1503
1547
 
1548
+ # Related to to_scim_backend, this methods tells whether +attribute_path+
1549
+ # should be included in the current +include_attributes+. This method
1550
+ # implements the attributes request from RFC 7644, section 3.9 and 3.10.
1551
+ #
1552
+ # +include_attributes+:: The attributes that should be included
1553
+ # in the response, in the form of a list of
1554
+ # full attribute paths. See RFC 7644 section
1555
+ # 3.9 and section 3.10. An empty collection
1556
+ # will include all attributes.
1557
+ #
1558
+ # +attribute_path+:: Array of path components to the attribute,
1559
+ # e.g. <tt>["name", "givenName"]</tt>.
1560
+ #
1561
+ def scim_attribute_included?(include_attributes:, attribute_path:)
1562
+ return true unless attribute_path.any? && include_attributes.any?
1563
+
1564
+ full_path = attribute_path.join(".")
1565
+ attribute_included = full_path.start_with?(*include_attributes)
1566
+ will_include_nested = include_attributes.any? { |att| att.start_with?(full_path) }
1567
+
1568
+ attribute_included || will_include_nested
1569
+ end
1570
+
1504
1571
  end # "included do"
1505
1572
  end # "module Mixin"
1506
1573
  end # "module Resources"
@@ -6,8 +6,8 @@ module Scimitar
6
6
 
7
7
  def self.scim_attributes
8
8
  @scim_attributes ||= [
9
- Attribute.new(name: 'familyName', type: 'string', required: true),
10
- Attribute.new(name: 'givenName', type: 'string', required: true),
9
+ Attribute.new(name: 'familyName', type: 'string'),
10
+ Attribute.new(name: 'givenName', type: 'string'),
11
11
  Attribute.new(name: 'middleName', type: 'string'),
12
12
  Attribute.new(name: 'formatted', type: 'string'),
13
13
  Attribute.new(name: 'honorificPrefix', type: 'string'),
@@ -20,7 +20,7 @@ module Scimitar
20
20
  [
21
21
  Attribute.new(name: 'userName', type: 'string', uniqueness: 'server', required: true),
22
22
 
23
- Attribute.new(name: 'name', complexType: Scimitar::ComplexTypes::Name),
23
+ Attribute.new(name: 'name', complexType: Scimitar::ComplexTypes::Name),
24
24
 
25
25
  Attribute.new(name: 'displayName', type: 'string'),
26
26
  Attribute.new(name: 'nickName', type: 'string'),
@@ -35,15 +35,15 @@ module Scimitar
35
35
 
36
36
  Attribute.new(name: 'password', type: 'string', mutability: 'writeOnly', returned: 'never'),
37
37
 
38
- Attribute.new(name: 'emails', multiValued: true, complexType: Scimitar::ComplexTypes::Email),
39
- Attribute.new(name: 'phoneNumbers', multiValued: true, complexType: Scimitar::ComplexTypes::PhoneNumber),
40
- Attribute.new(name: 'ims', multiValued: true, complexType: Scimitar::ComplexTypes::Ims),
41
- Attribute.new(name: 'photos', multiValued: true, complexType: Scimitar::ComplexTypes::Photo),
42
- Attribute.new(name: 'addresses', multiValued: true, complexType: Scimitar::ComplexTypes::Address),
43
- Attribute.new(name: 'groups', multiValued: true, complexType: Scimitar::ComplexTypes::ReferenceGroup, mutability: 'readOnly'),
44
- Attribute.new(name: 'entitlements', multiValued: true, complexType: Scimitar::ComplexTypes::Entitlement),
45
- Attribute.new(name: 'roles', multiValued: true, complexType: Scimitar::ComplexTypes::Role),
46
- Attribute.new(name: 'x509Certificates', multiValued: true, complexType: Scimitar::ComplexTypes::X509Certificate),
38
+ Attribute.new(name: 'emails', multiValued: true, complexType: Scimitar::ComplexTypes::Email),
39
+ Attribute.new(name: 'phoneNumbers', multiValued: true, complexType: Scimitar::ComplexTypes::PhoneNumber),
40
+ Attribute.new(name: 'ims', multiValued: true, complexType: Scimitar::ComplexTypes::Ims),
41
+ Attribute.new(name: 'photos', multiValued: true, complexType: Scimitar::ComplexTypes::Photo),
42
+ Attribute.new(name: 'addresses', multiValued: true, complexType: Scimitar::ComplexTypes::Address),
43
+ Attribute.new(name: 'groups', multiValued: true, complexType: Scimitar::ComplexTypes::ReferenceGroup, mutability: 'readOnly'),
44
+ Attribute.new(name: 'entitlements', multiValued: true, complexType: Scimitar::ComplexTypes::Entitlement),
45
+ Attribute.new(name: 'roles', multiValued: true, complexType: Scimitar::ComplexTypes::Role),
46
+ Attribute.new(name: 'x509Certificates', multiValued: true, complexType: Scimitar::ComplexTypes::X509Certificate),
47
47
  ]
48
48
  end
49
49
 
@@ -106,6 +106,47 @@ Rails.application.config.to_prepare do # (required for >= Rails 7 / Zeitwerk)
106
106
  # whatever that means for you receiving system in your model code.
107
107
  #
108
108
  # optional_value_fields_required: false
109
+
110
+ # The SCIM standard `/Schemas` endpoint lists, by default, all known schema
111
+ # definitions with the mutabilty (read-write, read-only, write-only) state
112
+ # described by those definitions, and includes all defined attributes. For
113
+ # user-defined schema, this will typically exactly match your underlying
114
+ # mapped attribute and model capability - it wouldn't make sense to define
115
+ # your own schema that misrepresented the implementation! For core SCIM RFC
116
+ # schema, though, you might want to only list actually mapped attributes.
117
+ # Further, if you happen to have a non-compliant implementation especially
118
+ # in relation to mutability of some attributes, you may want to report that
119
+ # accurately in the '/Schemas' list, for auto-discovery purposes. To switch
120
+ # to a significantly slower but more accurate render method for the list,
121
+ # driven by your resource subclasses and their attribute maps, set:
122
+ #
123
+ # schema_list_from_attribute_mappings: [...array...]
124
+ #
125
+ # ...where you provide an Array of *models*, your classes that include the
126
+ # Scimitar::Resources::Mixin module and, therefore, define an attribute map
127
+ # translating SCIM schema attributes into actual implemented data. These
128
+ # must *uniquely* describe, via the Scimitar resources they each declare in
129
+ # their Scimitar::Resources::Mixin::scim_resource_type implementation, the
130
+ # set of schemas and extended schemas you want to render. Should resources
131
+ # share schema, the '/Schemas' endpoint will fail since it cannot determine
132
+ # which model attribute map it should use and it needs the map in order to
133
+ # resolve the differences (if any) between what the schema might say, and
134
+ # what the actual underlying model supports.
135
+ #
136
+ # It is further _very_ _strongly_ _recommended_ that, for any
137
+ # +scim_attributes_map+ containing a collection which has "list:" key (for
138
+ # an associative array of zero or more entities; the Groups to which a User
139
+ # might belong is a good example) then you should also specify the "class:"
140
+ # key, giving the class used for objects in that associated collection. The
141
+ # class *must* include Scimitar::Resources::Mixin, since its own attribute
142
+ # map is consulted in order to render the part of the schema describing
143
+ # those associated properties in the owning resource. If you don't do this,
144
+ # and if you're using ActiveRecord, then Scimitar attempts association
145
+ # reflection to determine the collection class - but that's more fragile
146
+ # than just being told the exact class in the attribute map. No matter how
147
+ # this class is determined, though, it must be possible to create a simple
148
+ # instance with +new+ and no parameters, since that's needed in order to
149
+ # call Scimitar::Resources::Mixin#scim_mutable_attributes.
109
150
  })
110
151
 
111
152
  end
@@ -1,15 +1,38 @@
1
+ require 'rails/engine'
2
+
1
3
  module Scimitar
2
4
  class Engine < ::Rails::Engine
3
5
  isolate_namespace Scimitar
4
6
 
7
+ config.autoload_once_paths = %W(
8
+ #{root}/app/controllers
9
+ #{root}/app/models
10
+ )
11
+
5
12
  Mime::Type.register 'application/scim+json', :scim
6
13
 
7
14
  ActionDispatch::Request.parameter_parsers[Mime::Type.lookup('application/scim+json').symbol] = lambda do |body|
8
15
  JSON.parse(body)
9
16
  end
10
17
 
18
+ # Return an Array of all supported default and custom resource classes.
19
+ # See also :add_custom_resource and :set_default_resources.
20
+ #
11
21
  def self.resources
12
- default_resources + custom_resources
22
+ self.default_resources() + self.custom_resources()
23
+ end
24
+
25
+ # Returns a flat array of instances of all resource schema included in the
26
+ # resource classes returned by ::resources.
27
+ #
28
+ def self.schemas
29
+ self.resources().map(&:schemas).flatten.uniq.map(&:new)
30
+ end
31
+
32
+ # Returns the list of custom resources, if any.
33
+ #
34
+ def self.custom_resources
35
+ @custom_resources ||= []
13
36
  end
14
37
 
15
38
  # Can be used to add a new resource type which is not provided by the gem.
@@ -30,7 +53,7 @@ module Scimitar
30
53
  # Scimitar::Engine.add_custom_resource Scim::Resources::ShinyResource
31
54
  #
32
55
  def self.add_custom_resource(resource)
33
- custom_resources << resource
56
+ self.custom_resources() << resource
34
57
  end
35
58
 
36
59
  # Resets the resource list to default. This is really only intended for use
@@ -40,23 +63,45 @@ module Scimitar
40
63
  @custom_resources = []
41
64
  end
42
65
 
43
- # Returns the list of custom resources, if any.
44
- #
45
- def self.custom_resources
46
- @custom_resources ||= []
47
- end
48
-
49
- # Returns the default resources added in this gem:
66
+ # Returns the default resources added in this gem - by default, these are:
50
67
  #
51
68
  # * Scimitar::Resources::User
52
69
  # * Scimitar::Resources::Group
53
70
  #
71
+ # ...but if an implementation does not e.g. support Group, it can
72
+ # be overridden via ::set_default_resources to help with service
73
+ # auto-discovery.
74
+ #
54
75
  def self.default_resources
55
- [ Resources::User, Resources::Group ]
76
+ @standard_default_resources = [ Resources::User, Resources::Group ]
77
+ @default_resources ||= @standard_default_resources.dup()
56
78
  end
57
79
 
58
- def self.schemas
59
- resources.map(&:schemas).flatten.uniq.map(&:new)
80
+ # Override the resources returned by ::default_resources.
81
+ #
82
+ # +resource_array+:: An Array containing one or both of
83
+ # Scimitar::Resources::User and/or
84
+ # Scimitar::Resources::Group, and nothing else.
85
+ #
86
+ def self.set_default_resources(resource_array)
87
+ self.default_resources()
88
+ unrecognised_resources = resource_array - @standard_default_resources
89
+
90
+ if unrecognised_resources.any?
91
+ raise "Scimitar::Engine::set_default_resources: Only #{@standard_default_resources.map(&:name).join(', ')} are supported"
92
+ elsif resource_array.empty?
93
+ raise 'Scimitar::Engine::set_default_resources: At least one resource must be given'
94
+ end
95
+
96
+ @default_resources = resource_array
97
+ end
98
+
99
+ # Resets the default resource list. This is really only intended for use
100
+ # during testing, to avoid one test polluting another.
101
+ #
102
+ def self.reset_default_resources
103
+ self.default_resources()
104
+ @default_resources = @standard_default_resources
60
105
  end
61
106
 
62
107
  end
@@ -57,9 +57,10 @@ module Scimitar
57
57
  # <tt>scim_resource_type.extended_schemas</tt> value. The
58
58
  # Array should be empty if there are no extensions.
59
59
  #
60
- # +path_str+:: Path string, e.g. <tt>"password"</tt>, <tt>"name.givenName"</tt>,
60
+ # +path_str+:: Path String, e.g. <tt>"password"</tt>, <tt>"name.givenName"</tt>,
61
61
  # <tt>"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"</tt> (special case),
62
62
  # <tt>"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization"</tt>
63
+ # (if given a Symbol, it'll be converted to a String).
63
64
  #
64
65
  # Returns an array of components, e.g. <tt>["password"]</tt>, <tt>["name",
65
66
  # "givenName"]</tt>,
@@ -74,6 +75,7 @@ module Scimitar
74
75
  # path-free payload.
75
76
  #
76
77
  def self.path_str_to_array(schemas, path_str)
78
+ path_str = path_str.to_s
77
79
  components = []
78
80
 
79
81
  # Note the ":" separating the schema ID (URN) from the attribute.
@@ -84,11 +86,14 @@ module Scimitar
84
86
  # particular, https://tools.ietf.org/html/rfc7644#page-35.
85
87
  #
86
88
  if path_str.include?(':')
89
+ lower_case_path_str = path_str.downcase()
90
+
87
91
  schemas.each do |schema|
88
- attributes_after_schema_id = path_str.downcase.split(schema.id.downcase + ':').drop(1)
92
+ lower_case_schema_id = schema.id.downcase()
93
+ attributes_after_schema_id = lower_case_path_str.split(lower_case_schema_id + ':').drop(1)
89
94
 
90
95
  if attributes_after_schema_id.empty?
91
- components += [schema.id]
96
+ components += [schema.id] if lower_case_path_str == lower_case_schema_id
92
97
  else
93
98
  attributes_after_schema_id.each do |component|
94
99
  components += [schema.id] + component.split('.')
@@ -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.7.3'
6
+ VERSION = '2.9.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 = '2024-06-11'
11
+ DATE = '2024-06-27'
12
12
 
13
13
  end