scimitar 2.7.3 → 2.8.0

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: 7524696efa05186edcbba9876a3309d3c8a8d51772e3ac0a009a9e83d8f25d16
4
- data.tar.gz: e6cad46dc8754981f1d85cc9bcc98f73f9edddfa0d0386497b710acd11ad666c
3
+ metadata.gz: 207cb3e880d58e44ed4c995fd85e165a4a5890ff2974cef418ed605cf4ddba0b
4
+ data.tar.gz: b1aade94993912f93f783e64b1142447b6d2394f1e9fccd1fc8708116af8542b
5
5
  SHA512:
6
- metadata.gz: d040bbf693140f5e62dea569d02d977cb5fd966d2464c0a722bea449428de097901471a2f4b0c2380cf935c424195336d7eed4f469a8ebd019d4277dbe90c629
7
- data.tar.gz: 15616cedc7fffc3df69dcc1b2f35e9bd3c5408e12f8cccd536d724ce2925c72e4aa9a8f63732135ba3a71d23438dc02e8835978ff37977722cd23cab63c1ee28
6
+ metadata.gz: 9c9ecf7480dd0a18df82ad0c2b18b9122540d13c44a9bc7a5a2e8d1b5fe8091faa1d57c2fa7791c7509e4736ded919c5dde6c7ff5a52252ef05c5eac9a057c5d
7
+ data.tar.gz: 3153843636fca6bcf4d2b719eef31d32188b9f1c2b2a4f645f76302bd0be316454a941fb1d50f315cf5ec6118d5553e46eff83c92aecf168152d0d9620ad56d9
data/README.md CHANGED
@@ -79,7 +79,7 @@ Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
79
79
 
80
80
  When it comes to token access, Scimitar neither enforces nor presumes any kind of encoding for bearer tokens. You can use anything you like, including encoding/encrypting JWTs if you so wish - https://rubygems.org/gems/jwt may be useful. The way in which a client might integrate with your SCIM service varies by client and you will have to check documentation to see how a token gets conveyed to that client in the first place (e.g. a full OAuth flow with your application, or just a static token generated in some UI which an administrator copies and pastes into their client's SCIM configuration UI).
81
81
 
82
- **Important:** Under Rails 7 or later, you may need to wrap any Scimitar configuration with `Rails.application.config.to_prepare do...` to avoid `NameError: uninitialized constant...` exceptions arising due to autoloader problems:
82
+ **Strongly recommended:** You should wrap any Scimitar configuration with `Rails.application.config.to_prepare do...` so that any changes you make to configuration during local development are reflected via auto-reload, rather than requiring a server restart.
83
83
 
84
84
  ```ruby
85
85
  Rails.application.config.to_prepare do
@@ -89,6 +89,8 @@ Rails.application.config.to_prepare do
89
89
  end
90
90
  ```
91
91
 
92
+ In general, Scimitar's own development and tests assume this approach. If you choose to put the configuration directly into an initializer file without the `to_prepare` wrapper, you will be at a _slightly_ higher risk of tripping over unrecognised Scimitar bugs; please make sure that your own application test coverage is reasonably comprehensive.
93
+
92
94
  ### Routes
93
95
 
94
96
  For each resource you support, add these lines to your `routes.rb`:
@@ -540,6 +542,8 @@ Whatever you provide in the `::id` method in your extension class will be used a
540
542
  }
541
543
  ```
542
544
 
545
+ **IMPORTANT: Attribute names must be unique** across your entire combined schema, regardless of URNs used. This is because of a limitation in Scimitar's implementation. [This GitHub issue](https://github.com/RIPAGlobal/scimitar/issues/130) explains more. If this is a problem for you, please comment on the GitHub issue to help the maintainers understand the level of demand for remediation.
546
+
543
547
  Resource extensions can provide any fields you choose, under any ID/URN you choose, to either RFC-described resources or entirely custom SCIM resources. There are no hard-coded assumptions or other "magic" that might require you to only extend RFC-described resources with RFC-described extensions. Of course, if you use custom resources or custom extensions that are not described by the SCIM RFCs, then the SCIM API you provide may only work with custom-written API callers that are aware of your bespoke resources and/or extensions.
544
548
 
545
549
  Extensions can also contain complex attributes such as groups. For instance, if you want the ability to write to groups from the User resource perspective (since 'groups' collection in a SCIM User resource is read-only), you can add one attribute to your extension like this:
@@ -1,5 +1,3 @@
1
- require_dependency "scimitar/application_controller"
2
-
3
1
  module Scimitar
4
2
 
5
3
  # An ActiveRecord-centric subclass of Scimitar::ResourcesController. See that
@@ -19,7 +17,7 @@ module Scimitar
19
17
  #
20
18
  class ActiveRecordBackedResourcesController < ResourcesController
21
19
 
22
- rescue_from ActiveRecord::RecordNotFound, with: :handle_resource_not_found # See Scimitar::ApplicationController
20
+ rescue_from 'ActiveRecord::RecordNotFound', with: :handle_resource_not_found # See Scimitar::ApplicationController
23
21
 
24
22
  before_action :obtain_id_column_name_from_attribute_map
25
23
 
@@ -174,7 +172,10 @@ module Scimitar
174
172
  # representation, with a "show" location specified via #url_for.
175
173
  #
176
174
  def record_to_scim(record)
177
- record.to_scim(location: url_for(action: :show, id: record.send(@id_column)))
175
+ record.to_scim(
176
+ location: url_for(action: :show, id: record.send(@id_column)),
177
+ include_attributes: params.fetch(:attributes, "").split(",")
178
+ )
178
179
  end
179
180
 
180
181
  # Save a record, dealing with validation exceptions by raising SCIM
@@ -1,5 +1,3 @@
1
- require_dependency "scimitar/application_controller"
2
-
3
1
  module Scimitar
4
2
  class ResourceTypesController < ApplicationController
5
3
  def index
@@ -1,5 +1,3 @@
1
- require_dependency "scimitar/application_controller"
2
-
3
1
  module Scimitar
4
2
 
5
3
  # A Rails controller which is mostly idiomatic, with #index, #show, #create
@@ -1,5 +1,3 @@
1
- require_dependency "scimitar/application_controller"
2
-
3
1
  module Scimitar
4
2
  class SchemasController < ApplicationController
5
3
  def index
@@ -1,4 +1,3 @@
1
- require_dependency "scimitar/application_controller"
2
1
  module Scimitar
3
2
  class ServiceProviderConfigurationsController < ApplicationController
4
3
  def show
@@ -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
@@ -150,10 +150,10 @@ module Scimitar
150
150
  # #...
151
151
  # end
152
152
  #
153
- # The mixing-in class _must+ implement the read accessor identified by the
153
+ # The mixing-in class _must_ implement the read accessor identified by the
154
154
  # value of the "list" key, returning any indexed, Enumerable collection
155
155
  # (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
156
+ # ":find_with" is defined with a Proc that is passed the SCIM entry at each
157
157
  # list position. It must use this to look up the equivalent entry for
158
158
  # association via the write accessor described by the ":list" key. In the
159
159
  # example above, "find_with"'s Proc might look at a SCIM entry value which
@@ -204,12 +204,12 @@ module Scimitar
204
204
  # Define this method to return a Hash that maps field names you wish to
205
205
  # support in SCIM filter queries to corresponding attributes in the in the
206
206
  # mixing-in class. If +nil+ then filtering is not supported in the
207
- # ResouceController subclass which declares that it maps to the mixing-in
207
+ # ResourceController subclass which declares that it maps to the mixing-in
208
208
  # class. If not +nil+ but a SCIM filter enquiry is made for an unmapped
209
209
  # attribute, an 'invalid filter' exception is raised.
210
210
  #
211
211
  # If using ActiveRecord support in Scimitar::Lists::QueryParser, the mapped
212
- # entites are columns and that's expressed in the names of keys described
212
+ # entities are columns and that's expressed in the names of keys described
213
213
  # below; if you have other approaches to searching, these might be virtual
214
214
  # attributes or other such constructs rather than columns. That would be up
215
215
  # to your non-ActiveRecord's implementation to decide.
@@ -262,8 +262,8 @@ module Scimitar
262
262
  # both of the keys 'created' and 'lastModified', as Symbols. The values
263
263
  # should be methods that the including method supports which return a
264
264
  # 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:
265
+ # must support #iso8601 to convert to a String representation. Example for
266
+ # a typical ActiveRecord object with standard timestamps:
267
267
  #
268
268
  # def self.scim_timestamps_map
269
269
  # {
@@ -338,16 +338,32 @@ module Scimitar
338
338
  # Render self as a SCIM object using ::scim_attributes_map. Fields that
339
339
  # are marked as <tt>returned: 'never'</tt> are excluded.
340
340
  #
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.
341
+ # +location+:: The location (HTTP(S) full URI) of this
342
+ # resource in the domain of the object including
343
+ # this mixin - "your" IDs, not the remote SCIM
344
+ # client's external IDs. #url_for is a good way
345
+ # to generate this.
345
346
  #
346
- def to_scim(location:)
347
+ # +include_attributes+:: The attributes that should be included in the
348
+ # response, in the form of a list of full
349
+ # attribute paths. Schema IDs are not supported.
350
+ # See RFC 7644 section 3.9 and section 3.10 for
351
+ # more. When a collection is given, +nil+ value
352
+ # items are also excluded from the response. If
353
+ # omitted or given an empty collection, all
354
+ # attributes are included.
355
+ #
356
+ def to_scim(location:, include_attributes: [])
347
357
  map = self.class.scim_attributes_map()
348
358
  resource_type = self.class.scim_resource_type()
349
359
  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)
360
+ attrs_hash = self.to_scim_backend(
361
+ data_source: self,
362
+ resource_type: resource_type,
363
+ attrs_map_or_leaf_value: map,
364
+ include_attributes: include_attributes
365
+ )
366
+
351
367
  resource = resource_type.new(attrs_hash)
352
368
  meta_attrs_hash = { location: location }
353
369
 
@@ -532,6 +548,16 @@ module Scimitar
532
548
  # +attrs_map_or_leaf_value+:: The attribute map. At the top level,
533
549
  # this is from ::scim_attributes_map.
534
550
  #
551
+ # +include_attributes+:: The attributes that should be included
552
+ # in the response, in the form of a list
553
+ # of full attribute paths. Schema IDs are
554
+ # not supported. See RFC 7644 section
555
+ # 3.9 and section 3.10 for more. When a
556
+ # collection is given, +nil+ value items
557
+ # are also excluded from the response. If
558
+ # omitted or given an empty collection,
559
+ # all attributes are included.
560
+ #
535
561
  # Internal recursive calls also send:
536
562
  #
537
563
  # +attribute_path+:: Array of path components to the
@@ -543,8 +569,15 @@ module Scimitar
543
569
  data_source:,
544
570
  resource_type:,
545
571
  attrs_map_or_leaf_value:,
572
+ include_attributes:,
546
573
  attribute_path: []
547
574
  )
575
+ # NOTE EARLY EXIT
576
+ #
577
+ return unless scim_attribute_included?(
578
+ include_attributes: include_attributes,
579
+ attribute_path: attribute_path
580
+ )
548
581
 
549
582
  # On assumption of a top-level attributes list, the 'return never'
550
583
  # state is only checked on the recursive call from a Hash type. The
@@ -554,7 +587,7 @@ module Scimitar
554
587
  #
555
588
  case attrs_map_or_leaf_value
556
589
  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|
590
+ result = attrs_map_or_leaf_value.each.with_object({}) do |(key, value), hash|
558
591
  nested_attribute_path = attribute_path + [key]
559
592
 
560
593
  if resource_type.find_attribute(*nested_attribute_path)&.returned != "never"
@@ -562,11 +595,15 @@ module Scimitar
562
595
  data_source: data_source,
563
596
  resource_type: resource_type,
564
597
  attribute_path: nested_attribute_path,
565
- attrs_map_or_leaf_value: value
598
+ attrs_map_or_leaf_value: value,
599
+ include_attributes: include_attributes
566
600
  )
567
601
  end
568
602
  end
569
603
 
604
+ result.compact! if include_attributes.any?
605
+ result
606
+
570
607
  when Array # Static or dynamic mapping against lists in data source
571
608
  built_dynamic_list = false
572
609
  mapped_array = attrs_map_or_leaf_value.map do |value|
@@ -580,7 +617,8 @@ module Scimitar
580
617
  data_source: data_source,
581
618
  resource_type: resource_type,
582
619
  attribute_path: attribute_path,
583
- attrs_map_or_leaf_value: value[:using]
620
+ attrs_map_or_leaf_value: value[:using],
621
+ include_attributes: include_attributes
584
622
  )
585
623
  )
586
624
  static_hash
@@ -593,7 +631,8 @@ module Scimitar
593
631
  data_source: list_entry,
594
632
  resource_type: resource_type,
595
633
  attribute_path: attribute_path,
596
- attrs_map_or_leaf_value: value[:using]
634
+ attrs_map_or_leaf_value: value[:using],
635
+ include_attributes: include_attributes
597
636
  )
598
637
  end
599
638
 
@@ -1501,6 +1540,29 @@ module Scimitar
1501
1540
  return handled
1502
1541
  end
1503
1542
 
1543
+ # Related to to_scim_backend, this methods tells whether +attribute_path+
1544
+ # should be included in the current +include_attributes+. This method
1545
+ # implements the attributes request from RFC 7644, section 3.9 and 3.10.
1546
+ #
1547
+ # +include_attributes+:: The attributes that should be included
1548
+ # in the response, in the form of a list of
1549
+ # full attribute paths. See RFC 7644 section
1550
+ # 3.9 and section 3.10. An empty collection
1551
+ # will include all attributes.
1552
+ #
1553
+ # +attribute_path+:: Array of path components to the attribute,
1554
+ # e.g. <tt>["name", "givenName"]</tt>.
1555
+ #
1556
+ def scim_attribute_included?(include_attributes:, attribute_path:)
1557
+ return true unless attribute_path.any? && include_attributes.any?
1558
+
1559
+ full_path = attribute_path.join(".")
1560
+ attribute_included = full_path.start_with?(*include_attributes)
1561
+ will_include_nested = include_attributes.any? { |att| att.start_with?(full_path) }
1562
+
1563
+ attribute_included || will_include_nested
1564
+ end
1565
+
1504
1566
  end # "included do"
1505
1567
  end # "module Mixin"
1506
1568
  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
 
@@ -1,7 +1,14 @@
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|
@@ -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.8.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-13'
12
12
 
13
13
  end
@@ -96,6 +96,7 @@ class MockUser < ActiveRecord::Base
96
96
  #
97
97
  organization: :organization,
98
98
  department: :department,
99
+ primaryEmail: :scim_primary_email,
99
100
  userGroups: [
100
101
  {
101
102
  list: :mock_groups,
@@ -124,9 +125,15 @@ class MockUser < ActiveRecord::Base
124
125
  'groups.value' => { column: MockGroup.arel_table[:id] },
125
126
  'emails' => { columns: [ :work_email_address, :home_email_address ] },
126
127
  'emails.value' => { columns: [ :work_email_address, :home_email_address ] },
127
- 'emails.type' => { ignore: true } # We can't filter on that; it'll just search all e-mails
128
+ 'emails.type' => { ignore: true }, # We can't filter on that; it'll just search all e-mails
129
+ 'primaryEmail' => { column: :scim_primary_email },
128
130
  }
129
131
  end
130
132
 
133
+ # reader
134
+ def scim_primary_email
135
+ work_email_address
136
+ end
137
+
131
138
  include Scimitar::Resources::Mixin
132
139
  end
@@ -50,7 +50,8 @@ Rails.application.config.to_prepare do
50
50
  def self.scim_attributes
51
51
  [
52
52
  Scimitar::Schema::Attribute.new(name: 'organization', type: 'string'),
53
- Scimitar::Schema::Attribute.new(name: 'department', type: 'string')
53
+ Scimitar::Schema::Attribute.new(name: 'department', type: 'string'),
54
+ Scimitar::Schema::Attribute.new(name: 'primaryEmail', type: 'string'),
54
55
  ]
55
56
  end
56
57
  end
@@ -60,6 +60,15 @@ RSpec.describe Scimitar::Lists::QueryParser do
60
60
  expect(%Q("O'Malley")).to eql(tree[2])
61
61
  end
62
62
 
63
+ it "extended attribute equals" do
64
+ @instance.parse(%Q(primaryEmail eq "foo@bar.com"))
65
+
66
+ rpn = @instance.rpn
67
+ expect('primaryEmail').to eql(rpn[0])
68
+ expect(%Q("foo@bar.com")).to eql(rpn[1])
69
+ expect('eq').to eql(rpn[2])
70
+ end
71
+
63
72
  it "user name starts with" do
64
73
  @instance.parse(%Q(userName sw "J"))
65
74
 
@@ -337,6 +346,62 @@ RSpec.describe Scimitar::Lists::QueryParser do
337
346
  result = @instance.send(:flatten_filter, 'emails[type eq "work" and value co "@example.com" ] or userType eq "Admin" or ims[type eq "xmpp" and value co "@foo.com"]')
338
347
  expect(result).to eql('emails.type eq "work" and emails.value co "@example.com" or userType eq "Admin" or ims.type eq "xmpp" and ims.value co "@foo.com"')
339
348
  end
349
+
350
+ # https://github.com/RIPAGlobal/scimitar/issues/116
351
+ #
352
+ context 'with schema IDs (GitHub issue #116)' do
353
+ it 'handles simple attributes' do
354
+ result = @instance.send(:flatten_filter, 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeId eq "gsar"')
355
+ expect(result).to eql('employeeId eq "gsar"')
356
+ end
357
+
358
+ it 'handles dotted attribute paths' do
359
+ result = @instance.send(:flatten_filter, 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:imaginary.path eq "gsar"')
360
+ expect(result).to eql('imaginary.path eq "gsar"')
361
+ end
362
+
363
+ it 'replaces all examples' do
364
+ result = @instance.send(:flatten_filter, 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeId eq "gsar" or urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:imaginary.path eq "gsar"')
365
+ expect(result).to eql('employeeId eq "gsar" or imaginary.path eq "gsar"')
366
+ end
367
+
368
+ it 'handles the square bracket form with schema ID at the root' do
369
+ result = @instance.send(:flatten_filter, 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User[employeeId eq "gsar"')
370
+ expect(result).to eql('employeeId eq "gsar"')
371
+ end
372
+
373
+ it 'handles the square bracket form with schema ID and attribute at the root' do
374
+ result = @instance.send(:flatten_filter, 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:imaginary[path eq "gsar"')
375
+ expect(result).to eql('imaginary.path eq "gsar"')
376
+ end
377
+ end
378
+
379
+ # https://github.com/RIPAGlobal/scimitar/issues/115
380
+ #
381
+ context 'broken filters from Microsoft (GitHub issue #115)' do
382
+ it 'work with "eq"' do
383
+ result = @instance.send(:flatten_filter, 'emails[type eq "work"].value eq "foo@bar.com"')
384
+ expect(result).to eql('emails.type eq "work" and emails.value eq "foo@bar.com"')
385
+ end
386
+
387
+ it 'work with "ne"' do # (just check a couple of operators, not all!)
388
+ result = @instance.send(:flatten_filter, 'emails[type eq "work"].value ne "foo@bar.com"')
389
+ expect(result).to eql('emails.type eq "work" and emails.value ne "foo@bar.com"')
390
+ end
391
+
392
+ it 'preserve input case' do
393
+ result = @instance.send(:flatten_filter, 'emaiLs[TYPE eq "work"].valUE eq "FOO@bar.com"')
394
+ expect(result).to eql('emaiLs.TYPE eq "work" and emaiLs.valUE eq "FOO@bar.com"')
395
+ end
396
+
397
+ # At the time of writing, this was used in a "belt and braces" request
398
+ # spec in 'active_record_backed_resources_controller_spec.rb'.
399
+ #
400
+ it 'handles more complex, hypothetical cases' do
401
+ result = @instance.send(:flatten_filter, 'name[givenName eq "FOO"].familyName pr and emails ne "home_1@test.com"')
402
+ expect(result).to eql('name.givenName eq "FOO" and name.familyName pr and emails ne "home_1@test.com"')
403
+ end
404
+ end # "context 'broken filters from Microsoft' do"
340
405
  end # "context 'when flattening is needed' do"
341
406
 
342
407
  context 'with bad filters' do
@@ -7,6 +7,19 @@ RSpec.describe Scimitar::Resources::Base do
7
7
  'custom-id'
8
8
  end
9
9
 
10
+ class NameWithRequirementsSchema < Scimitar::Schema::Base
11
+ def self.scim_attributes
12
+ @scim_attributes ||= [
13
+ Scimitar::Schema::Attribute.new(name: 'familyName', type: 'string', required: true),
14
+ Scimitar::Schema::Attribute.new(name: 'givenName', type: 'string', required: true),
15
+ ]
16
+ end
17
+ end
18
+
19
+ class NameWithRequirementsComplexType < Scimitar::ComplexTypes::Base
20
+ set_schema NameWithRequirementsSchema
21
+ end
22
+
10
23
  def self.scim_attributes
11
24
  [
12
25
  Scimitar::Schema::Attribute.new(
@@ -16,10 +29,10 @@ RSpec.describe Scimitar::Resources::Base do
16
29
  name: 'enforce', type: 'boolean', required: true
17
30
  ),
18
31
  Scimitar::Schema::Attribute.new(
19
- name: 'complexName', complexType: Scimitar::ComplexTypes::Name, required: false
32
+ name: 'complexName', complexType: NameWithRequirementsComplexType, required: false
20
33
  ),
21
34
  Scimitar::Schema::Attribute.new(
22
- name: 'complexNames', complexType: Scimitar::ComplexTypes::Name, multiValued:true, required: false
35
+ name: 'complexNames', complexType: Scimitar::ComplexTypes::Name, multiValued: true, required: false
23
36
  ),
24
37
  Scimitar::Schema::Attribute.new(
25
38
  name: 'vdtpTestByEmail', complexType: Scimitar::ComplexTypes::Email, required: false
@@ -255,6 +255,47 @@ RSpec.describe Scimitar::Resources::Mixin do
255
255
  # =========================================================================
256
256
 
257
257
  context '#to_scim' do
258
+ context 'with list of requested attributes' do
259
+ it 'compiles instance attribute values into a SCIM representation, including only the requested attributes' do
260
+ uuid = SecureRandom.uuid
261
+
262
+ instance = MockUser.new
263
+ instance.primary_key = uuid
264
+ instance.scim_uid = 'AA02984'
265
+ instance.username = 'foo'
266
+ instance.password = 'correcthorsebatterystaple'
267
+ instance.first_name = 'Foo'
268
+ instance.last_name = 'Bar'
269
+ instance.work_email_address = 'foo.bar@test.com'
270
+ instance.home_email_address = nil
271
+ instance.work_phone_number = '+642201234567'
272
+ instance.organization = 'SOMEORG'
273
+
274
+ g1 = MockGroup.create!(display_name: 'Group 1')
275
+ g2 = MockGroup.create!(display_name: 'Group 2')
276
+ g3 = MockGroup.create!(display_name: 'Group 3')
277
+
278
+ g1.mock_users << instance
279
+ g3.mock_users << instance
280
+
281
+ scim = instance.to_scim(location: "https://test.com/mock_users/#{uuid}", include_attributes: %w[id userName name groups.display groups.value organization])
282
+ json = scim.to_json()
283
+ hash = JSON.parse(json)
284
+
285
+ expect(hash).to eql({
286
+ 'id' => uuid,
287
+ 'userName' => 'foo',
288
+ 'name' => {'givenName'=>'Foo', 'familyName'=>'Bar'},
289
+ 'groups' => [{'display'=>g1.display_name, 'value'=>g1.id.to_s}, {'display'=>g3.display_name, 'value'=>g3.id.to_s}],
290
+ 'meta' => {'location'=>"https://test.com/mock_users/#{uuid}", 'resourceType'=>'User'},
291
+ 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:User', 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'],
292
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => {
293
+ 'organization' => 'SOMEORG',
294
+ },
295
+ })
296
+ end
297
+ end # "context 'with list of requested attributes' do"
298
+
258
299
  context 'with a UUID, renamed primary key column' do
259
300
  it 'compiles instance attribute values into a SCIM representation, but omits do-not-return fields' do
260
301
  uuid = SecureRandom.uuid
@@ -296,7 +337,8 @@ RSpec.describe Scimitar::Resources::Mixin do
296
337
 
297
338
  'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => {
298
339
  'organization' => 'SOMEORG',
299
- 'department' => nil
340
+ 'department' => nil,
341
+ 'primaryEmail' => instance.work_email_address
300
342
  }
301
343
  })
302
344
  end
@@ -27,7 +27,7 @@ RSpec.describe Scimitar::Schema::User do
27
27
  "subAttributes": [
28
28
  {
29
29
  "multiValued": false,
30
- "required": true,
30
+ "required": false,
31
31
  "caseExact": false,
32
32
  "mutability": "readWrite",
33
33
  "uniqueness": "none",
@@ -37,7 +37,7 @@ RSpec.describe Scimitar::Schema::User do
37
37
  },
38
38
  {
39
39
  "multiValued": false,
40
- "required": true,
40
+ "required": false,
41
41
  "caseExact": false,
42
42
  "mutability": "readWrite",
43
43
  "uniqueness": "none",
@@ -107,6 +107,29 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
107
107
  expect(usernames).to match_array(['2'])
108
108
  end
109
109
 
110
+ it 'returns only the requested attributes' do
111
+ get '/Users', params: {
112
+ format: :scim,
113
+ attributes: "id,name"
114
+ }
115
+
116
+ expect(response.status ).to eql(200)
117
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
118
+
119
+ result = JSON.parse(response.body)
120
+
121
+ expect(result['totalResults']).to eql(3)
122
+ expect(result['Resources'].size).to eql(3)
123
+
124
+ keys = result['Resources'].map { |resource| resource.keys }.flatten.uniq
125
+ expect(keys).to match_array(%w[id meta name schemas urn:ietf:params:scim:schemas:extension:enterprise:2.0:User])
126
+ expect(result.dig('Resources', 0, 'id')).to eql @u1.primary_key.to_s
127
+ expect(result.dig('Resources', 0, 'name', 'givenName')).to eql 'Foo'
128
+ expect(result.dig('Resources', 0, 'name', 'familyName')).to eql 'Ark'
129
+ end
130
+
131
+ # https://github.com/RIPAGlobal/scimitar/issues/37
132
+ #
110
133
  it 'applies a filter, with case-insensitive attribute matching (GitHub issue #37)' do
111
134
  get '/Users', params: {
112
135
  format: :scim,
@@ -128,6 +151,30 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
128
151
  expect(usernames).to match_array(['2'])
129
152
  end
130
153
 
154
+ # https://github.com/RIPAGlobal/scimitar/issues/115
155
+ #
156
+ it 'handles broken Microsoft filters (GitHub issue #115)' do
157
+ get '/Users', params: {
158
+ format: :scim,
159
+ filter: 'name[givenName eq "FOO"].familyName pr and emails ne "home_1@test.com"'
160
+ }
161
+
162
+ expect(response.status ).to eql(200)
163
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
164
+
165
+ result = JSON.parse(response.body)
166
+
167
+ expect(result['totalResults']).to eql(1)
168
+ expect(result['Resources'].size).to eql(1)
169
+
170
+ ids = result['Resources'].map { |resource| resource['id'] }
171
+ expect(ids).to match_array([@u2.primary_key.to_s])
172
+
173
+ usernames = result['Resources'].map { |resource| resource['userName'] }
174
+ expect(usernames).to match_array(['2'])
175
+ end
176
+
177
+
131
178
  # Strange attribute capitalisation in tests here builds on test coverage
132
179
  # for now-fixed GitHub issue #37.
133
180
  #
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.7.3
4
+ version: 2.8.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: 2024-06-11 00:00:00.000000000 Z
12
+ date: 2024-06-13 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails