scimitar 1.8.1 → 1.10.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -20
  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 +48 -14
  11. data/app/models/scimitar/resources/mixin.rb +531 -71
  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/hash_with_indifferent_case_insensitive_access.rb +140 -10
  17. data/lib/scimitar/support/utilities.rb +60 -0
  18. data/lib/scimitar/version.rb +2 -2
  19. data/spec/apps/dummy/app/models/mock_user.rb +18 -3
  20. data/spec/apps/dummy/config/initializers/scimitar.rb +31 -2
  21. data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +1 -0
  22. data/spec/apps/dummy/db/schema.rb +1 -0
  23. data/spec/controllers/scimitar/schemas_controller_spec.rb +342 -54
  24. data/spec/models/scimitar/lists/query_parser_spec.rb +70 -0
  25. data/spec/models/scimitar/resources/base_spec.rb +20 -12
  26. data/spec/models/scimitar/resources/base_validation_spec.rb +16 -3
  27. data/spec/models/scimitar/resources/mixin_spec.rb +754 -122
  28. data/spec/models/scimitar/schema/user_spec.rb +2 -2
  29. data/spec/requests/active_record_backed_resources_controller_spec.rb +312 -5
  30. data/spec/requests/engine_spec.rb +75 -0
  31. data/spec/spec_helper.rb +1 -1
  32. data/spec/support/hash_with_indifferent_case_insensitive_access_spec.rb +108 -0
  33. metadata +22 -22
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed2a0e01326248966fb009845e4fa05f4cae800b79bc3d85497960c833869f24
4
- data.tar.gz: c7f92d402444abf8b4df77d8b404c85ff90b06e113126ebafe75c8935608f869
3
+ metadata.gz: db2ddb3c77cd7a482505003624275950458638efef053abe9df427b644412de4
4
+ data.tar.gz: f2bc178baf10fd8cfc995114ae7dad80f822a604f9b016ae0f16cea5e1abbc9f
5
5
  SHA512:
6
- metadata.gz: 834cbc912ea8cdf996cb74a57d4f24495b3e0b005924635829ddb5ab99025ca17bdec5aef58c79353af189bb3304c4560017b31c9089614af4cf4b00832909b9
7
- data.tar.gz: 71398ecaa9a879cd37fe41867526a824b9cc4db32efd8c3a23012e086a5509a95db66140f7e0c623bc3bde4ac648f5eca5f51bd99e5a7b2322b65d326ff7ea4d
6
+ metadata.gz: 945f6073d1fa337eab6ef82644402a7c977858db3289af7480970fadd047c4ef6a55ed8f6d2756265a84c794ca957f1c83d83ecbad07d999355c23dd0cf2cbfa
7
+ data.tar.gz: '0869c64c2c49128005a7163814a3ccb32e0c99209ad2a867b4c24a87c9b95e8bbb7cb39fc92bdec5c01a9d45c4316ea25c33ed3fd347e500bea02fe62922df2a'
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 some more recent versions of Rails 6, 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`:
@@ -244,8 +246,6 @@ If you use ActiveRecord, your controllers can potentially be extremely simple by
244
246
  module Scim
245
247
  class UsersController < Scimitar::ActiveRecordBackedResourcesController
246
248
 
247
- skip_before_action :verify_authenticity_token
248
-
249
249
  protected
250
250
 
251
251
  def storage_class
@@ -457,7 +457,9 @@ You can extend schema with custom data by defining an extension class and callin
457
457
  * Must call `super` in `def initialize`, providing data as shown in the example below
458
458
  * Must define class methods for `::id` and `::scim_attributes`
459
459
 
460
- The `::id` class method defines a unique schema ID that is used to namespace payloads or paths in JSON responses describing extended resources, JSON payloads creating them or PATCH paths modifying them. The SCIM RFCs would refer to this as the URN. For example, we might choose to use the [RFC-defined User extension schema](https://tools.ietf.org/html/rfc7643#section-4.3) to define a couple of extra fields our User model happens to support:
460
+ The `::id` class method defines a unique schema ID that is used to namespace payloads or paths in JSON responses describing extended resources, JSON payloads creating them or PATCH paths modifying them. The RFCs require this to be a URN ([see RFC 2141](https://tools.ietf.org/html/rfc2141)). Your extension's ID URN must be globally unique. Depending on your expected use case, you should review the [IANA registration considerations that RFC 7643 describes](https://tools.ietf.org//html/rfc7643#section-10) and definitely review the [syntactic structure declaration therein](https://tools.ietf.org/html/rfc7643#section-10.2.1) (`urn:ietf:params:scim:{type}:{name}{:other}`).
461
+
462
+ For example, we might choose to use the [RFC-defined User extension schema](https://tools.ietf.org/html/rfc7643#section-4.3) to define a couple of extra fields our User model happens to support:
461
463
 
462
464
  ```ruby
463
465
  class UserEnterpriseExtension < Scimitar::Schema::Base
@@ -542,6 +544,8 @@ Whatever you provide in the `::id` method in your extension class will be used a
542
544
  }
543
545
  ```
544
546
 
547
+ **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.
548
+
545
549
  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.
546
550
 
547
551
  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:
@@ -580,6 +584,23 @@ And write to it like this:
580
584
  }
581
585
  ```
582
586
 
587
+ ### Helping with auto-discovery
588
+
589
+ If you have an API consumer entity querying your Scimitar-based SCIM API provider endpoint and want to enable a degree of auto-discovery for that entity, then depending on your implementation, there may be customisations you wish to make.
590
+
591
+ #### Default resources
592
+
593
+ By default, Scimitar advertises (via things like [the `/Schemas` endpoint](https://tools.ietf.org/html/rfc7644#section-4)) support for both a `User` and `Group` resource, but if you (say) only support a `User` concept, you override the default using code such as this in your `config/initializers/scimitar.rb` file:
594
+
595
+ ```ruby
596
+ Rails.application.config.to_prepare do
597
+ Scimitar::Engine::set_default_resources([Scimitar::Resources::User])
598
+ # ...other Scimitar configuration / initialisation code...
599
+ end
600
+ ```
601
+
602
+
603
+
583
604
  ## Security
584
605
 
585
606
  One vital feature of SCIM is its authorisation and security model. The best resource I've found to describe this in any detail is [section 2 of the protocol RFC, 7644](https://tools.ietf.org/html/rfc7644#section-2).
@@ -598,8 +619,6 @@ Often, you'll find that bearer tokens are in use by SCIM API consumers, but the
598
619
 
599
620
  ### Specification versus implementation
600
621
 
601
- * The `name` complex type of a User has `givenName` and `familyName` fields which [the RFC 7643 core schema](https://tools.ietf.org/html/rfc7643#section-8.7.1) describes as optional. Scimitar marks these as required, in the belief that most user synchronisation scenarios between clients and a Scimitar-based provider would require at least those names for basic user management on the provider side, in conjunction with the in-spec-required `userName` field. That's only if the whole `name` type is given at all - at the top level, this itself remains optional per spec, but if you're going to bother specifying names at all, Scimitar wants at least those two pieces of data.
602
-
603
622
  * Several complex types for User contain the same set of `value`, `display`, `type` and `primary` fields, all used in synonymous ways.
604
623
 
605
624
  - The `value` field - which is e.g. an e-mail address or phone number - is described as optional by [the RFC 7643 core schema](https://tools.ietf.org/html/rfc7643#section-8.7.1), also using "SHOULD" rather than "MUST" in field descriptions elsewhere. Scimitar marks this as required by default, since there's not much point being sent (say) an e-mail section which has entries that don't provide the e-mail address. Some services might send `null` values here regardless so, if you need to be able to accept such data, you can set [engine configuration option `optional_value_fields_required`](https://github.com/RIPAGlobal/scimitar/blob/main/config/initializers/scimitar.rb) to `false`.
@@ -618,6 +637,8 @@ Often, you'll find that bearer tokens are in use by SCIM API consumers, but the
618
637
 
619
638
  * [RFC 7644 indicates](https://tools.ietf.org/html/rfc7644#page-35) that a resource might only return its core schema in the `schemas` attribute if it was created without any extension fields used. Only if e.g. a subsequent `PATCH` operation added data provided by extension schema, would that extension also appear in `schemas`. This behaviour is extremely difficult to implement and Scimitar does not try - it will always return a resource's core schema and any/all defined extension schemas in the `schemas` array at all times.
620
639
 
640
+ * As noted earlier, extension schema attribute names must be unique across your entire combined schema, regardless of schema IDs (URNs) used.
641
+
621
642
  If you believe choices made in this section may be incorrect, please [create a GitHub issue](https://github.com/RIPAGlobal/scimitar/issues/new) describing the problem.
622
643
 
623
644
  ### Omissions
@@ -630,20 +651,6 @@ If you believe choices made in this section may be incorrect, please [create a G
630
651
 
631
652
  It's very strange just specifying `emails co...`, since this is an Array which contains complex types. Is the filter there meant to try and match every attribute of the nested types in all array entries? I.e. if `type` happened to contain `example.com`, is that meant to match? It's strongly implied, because the next part of the filter specifically says `emails.value`. Again, we have to reach a little and assume that `emails.value` means "in _any_ of the objects in the `emails` Array, match all things where `value` contains `example.org`. It seems likely that this is a specification error and both of the specifiers should be `emails.value`.
632
653
 
633
- Adding even more complexity - the specification shows filters _which include filters within them_. In the same way that PATCH operations use paths to identify attributes not just by name, but by filter matches within collections - e.g. `emails[type eq "work"]`, for all e-mail objects inside the `emails` array with a `type` attribute that has a value of `work`) - so also can a filter _contain a filter_, which isn't supported. So, this [example from the RFC](https://tools.ietf.org/html/rfc7644#page-23) is not supported by Scimitar:
634
-
635
- - `filter=userType eq "Employee" and emails[type eq "work" and value co "@example.com"]`
636
-
637
- Another filter shows a potential workaround:
638
-
639
- - `filter=userType eq "Employee" and (emails.type eq "work")`
640
-
641
- ...which is just a match on `emails.type`, so if you have a queryable attribute mapping defined for `emails.type`, that would become queryable. Likewise, you could rewrite the more complex prior example thus:
642
-
643
- - `filter=userType eq "Employee" and emails.type eq "work" and emails.value co "@example.com"`
644
-
645
- ...so adding a mapping for `emails.value` would then allow a database query to be constructed.
646
-
647
654
  * Currently filtering for lists is always matched case-insensitive regardless of schema declarations that might indicate otherwise, for `eq`, `ne`, `co`, `sw` and `ew` operators; for greater/less-thank style filters, case is maintained with simple `>`, `<` etc. database operations in use. The standard Group and User schema have `caseExact` set to `false` for just about anything readily queryable, so this hopefully would only ever potentially be an issue for custom schema.
648
655
 
649
656
  * As an exception to the above, attributes `id`, `externalId` and `meta.*` are matched case-sensitive. Filters that use `eq` on such attributes will end up a comparison using `=` rather than e.g. `ILIKE` (arising from https://github.com/RIPAGlobal/scimitar/issues/36).
@@ -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
@@ -14,8 +12,368 @@ module Scimitar
14
12
  hash
15
13
  end
16
14
 
17
- render json: schemas_by_id[params[:name]] || schemas
15
+ list = if params.key?(:name)
16
+ [ schemas_by_id[params[:name]] ]
17
+ else
18
+ schemas
19
+ end
20
+
21
+ # Now we either have a simple render method, or a complex one.
22
+ #
23
+ schemas_to_render = if Scimitar.engine_configuration.schema_list_from_attribute_mappings.empty?
24
+ list
25
+ else
26
+ self.redraw_schema_list_using_mappings(list)
27
+ end
28
+
29
+ render(json: {
30
+ schemas: [
31
+ 'urn:ietf:params:scim:api:messages:2.0:ListResponse'
32
+ ],
33
+ totalResults: schemas_to_render.size,
34
+ startIndex: 1,
35
+ itemsPerPage: schemas_to_render.size,
36
+ Resources: schemas_to_render
37
+ })
18
38
  end
19
39
 
40
+ # =========================================================================
41
+ # PRIVATE INSTANCE METHODS
42
+ # =========================================================================
43
+ #
44
+ private
45
+
46
+ # Given a list of schema *instances*, find all Scimitar::Resources::Mixin
47
+ # inclusions to obtain classes with a ::scim_resource_type implementation
48
+ # that is invoked to get at the associated Scimitar resource class; each
49
+ # resource class then describes the schema it uses; the list is filtered
50
+ # to include only those found schemas. Then, for each, use the discovered
51
+ # class' attribute maps to walk the schema attribute tree alongside the
52
+ # map and render only mapped attributes. This is done via calling down to
53
+ # ::redraw_schema_using_mappings for each remaining schema in the list.
54
+ #
55
+ # An array of new schema data is returned.
56
+ #
57
+ # +list+:: Array of schema instances to examine.
58
+ #
59
+ def redraw_schema_list_using_mappings(list)
60
+
61
+ # Iterate over the configured model classes to build a mapping from
62
+ # Scimitar schema class to an array of one or more model classes that
63
+ # seem to use it. This is to detect the error condition wherein some
64
+ # schema gets used more than once, leading to multiple possible
65
+ # attribute map choices.
66
+ #
67
+ classes_using_scimitar_mixin = Scimitar.engine_configuration.schema_list_from_attribute_mappings
68
+ schema_to_resource_map = {}
69
+
70
+ classes_using_scimitar_mixin.each do | model_class |
71
+ resource_class = model_class.scim_resource_type()
72
+ schemas = resource_class.extended_schemas + [resource_class.schema]
73
+
74
+ schemas.each do | schema_class |
75
+ schema_to_resource_map[schema_class] ||= []
76
+ schema_to_resource_map[schema_class] << model_class
77
+ end
78
+ end
79
+
80
+ # Take the schema list and map to rewritten versions based on finding
81
+ # out which of the above resource classes use a given schema and then
82
+ # walking this schema's attribute tree while comparing against the
83
+ # resource class's attribute map. Unmapped attributes are removed. The
84
+ # reality of resource class attribute mutability might give different
85
+ # answers for the corresponding schema attribute's mutability; for any
86
+ # custom schema we'd expect a match, but for core schema where local
87
+ # resources don't quite work to spec, at least the /Schemas endpoint
88
+ # can try to reflect reality and aid auto-discovery.
89
+ #
90
+ redrawn_list = list.map do | schema_instance |
91
+ resource_classes_using_schema = schema_to_resource_map[schema_instance.class]
92
+
93
+ if resource_classes_using_schema.nil?
94
+ next # NOTE EARLY LOOP RESTART (schema not used by a resource)
95
+ elsif resource_classes_using_schema.size > 1
96
+ raise "Cannot infer attribute map for engine configuration 'schema_list_from_attribute_mappings: true' because multiple resource classes use schema '#{schema_instance.class.name}': #{resource_classes_using_schema.map(&:name).join(', ')}"
97
+ end
98
+
99
+ found_class = classes_using_scimitar_mixin.find do | class_using_scimitar_mixin |
100
+ resource_class = class_using_scimitar_mixin.scim_resource_type()
101
+
102
+ resource_class.schema == schema_instance.class ||
103
+ resource_class.extended_schemas.include?(schema_instance.class)
104
+ end
105
+
106
+ rebuilt_schema_instance = if found_class
107
+ redraw_schema_using_mappings(
108
+ original_schema_instance: schema_instance,
109
+ instance_including_mixin: found_class.new
110
+ )
111
+ else
112
+ nil
113
+ end
114
+
115
+ rebuilt_schema_instance
116
+ end
117
+
118
+ redrawn_list.compact!
119
+ redrawn_list
120
+ end
121
+
122
+ # "Redraw" a schema, by walking its attribute tree alongside a related
123
+ # resource class's attribute map. Only mapped attributes are included.
124
+ # The mapped model is checked for a read accessor and write ability is
125
+ # determined via Scimitar::Resources::Mixin#scim_mutable_attributes. This
126
+ # gives the actual read/write ability of the mapped attribute; if the
127
+ # schema's declared mutability differs, the *most restrictive* is chosen.
128
+ # For example, if the schema says read-write but the mapped model only
129
+ # has read ability, then "readOnly" is used. Conversely, if the schema
130
+ # says read-only but the mapped model has read-write, the schema's
131
+ # "readOnly" is chosen instead as the source of truth.
132
+ #
133
+ # See the implementation's comments for a table describing exactly how
134
+ # all mutability conflict cases are resolved.
135
+ #
136
+ # The returned schema instance may be a full or partial duplicate of the
137
+ # one given on input - some or all attributes and/or sub-attributes may
138
+ # have been duplicated due to e.g. mutability differences. Do not assume
139
+ # or rely upon this as a caller.
140
+ #
141
+ # Mandatory named parameters for external callers are:
142
+ #
143
+ # +original_schema_instance+:: The Scimitar::Schema::Base subclass
144
+ # schema *instance* that is to be examined
145
+ # and possibly "redrawn".
146
+ #
147
+ # +instance_including_mixin+:: Instance of the model class including
148
+ # Scimitar::Resources::Mixin, providing
149
+ # the attribute map to be examined.
150
+ #
151
+ # Named parameters used internally for recursive calls are:
152
+ #
153
+ # +scim_attributes_map+:: The fragment of the attribute map found
154
+ # from +instance_including_mixin+'s class
155
+ # initially, which is relevant to the
156
+ # current recursion level. E.g. it might
157
+ # be the sub-attributes map of "name",
158
+ # for things like a "familyName" mapping.
159
+ #
160
+ # +schema_attributes+:: An array of schema attributes for the
161
+ # current recursion level, corresponding
162
+ # to +scim_attributes_map+.
163
+ #
164
+ # +rebuilt_attribute_array+:: Redrawn schema attributes are collected
165
+ # into this array, which is altered in
166
+ # place. It is usually a 'subAttributes'
167
+ # property of a schema attribute that's
168
+ # provoked recursion in order to examine
169
+ # and rebuild its sub-attributes directly.
170
+ #
171
+ def redraw_schema_using_mappings(
172
+ original_schema_instance:,
173
+ instance_including_mixin:,
174
+
175
+ scim_attributes_map: nil,
176
+ schema_attributes: nil,
177
+ rebuilt_attribute_array: nil
178
+ )
179
+ schema_attributes ||= original_schema_instance.scim_attributes
180
+ scim_attributes_map ||= instance_including_mixin
181
+ .class
182
+ .scim_attributes_map()
183
+ .with_indifferent_case_insensitive_access()
184
+
185
+ rebuilt_schema_instance = nil
186
+
187
+ if rebuilt_attribute_array.nil?
188
+ rebuilt_schema_instance = self.duplicate_attribute(original_schema_instance)
189
+ rebuilt_schema_instance.scim_attributes = []
190
+ rebuilt_attribute_array = rebuilt_schema_instance.scim_attributes
191
+ end
192
+
193
+ schema_attributes.each do | schema_attribute |
194
+ if schema_attribute.multiValued && schema_attribute.subAttributes&.any?
195
+ mapped_multivalue_attribute = scim_attributes_map[schema_attribute.name]
196
+
197
+ # We expect either an array in the attribute map to correspond with
198
+ # a multivalued schema attribute, or nothing. If we get some other
199
+ # non-Array, not-nil thing, it's just ignored.
200
+ #
201
+ if mapped_multivalue_attribute.is_a?(Array)
202
+
203
+ # A single-entry array with "list using" semantics, for a
204
+ # collection of an artbirary number of same-class items - e.g.
205
+ # Groups to which a User belongs.
206
+ #
207
+ # If this is an up-to-date mapping, there's a "class" entry that
208
+ # tells us what the collection is compromised of. If not, then we
209
+ # check for ActiveRecord collections as a fallback and if that is
210
+ # the case here, can use reflection to try and find the class. If
211
+ # all else fails, we drop to generic schema for the collection.
212
+ #
213
+ if mapped_multivalue_attribute.first&.dig(:list)
214
+ associated_resource_class = mapped_multivalue_attribute.first[:class]
215
+
216
+ if (
217
+ associated_resource_class.nil? &&
218
+ instance_including_mixin.is_a?(ActiveRecord::Base)
219
+ )
220
+ associated_resource_class = instance_including_mixin
221
+ .class
222
+ .reflect_on_association(mapped_multivalue_attribute.first[:list])
223
+ &.klass
224
+ end
225
+
226
+ if associated_resource_class.nil? || ! associated_resource_class.include?(Scimitar::Resources::Mixin)
227
+ rebuilt_attribute_array << schema_attribute
228
+ else
229
+ rebuilt_schema_attribute = self.duplicate_attribute(schema_attribute)
230
+ rebuilt_schema_attribute.subAttributes = []
231
+ rebuilt_attribute_array << rebuilt_schema_attribute
232
+
233
+ redraw_schema_using_mappings(
234
+ original_schema_instance: original_schema_instance,
235
+ instance_including_mixin: associated_resource_class.new,
236
+ scim_attributes_map: mapped_multivalue_attribute.first[:using],
237
+ schema_attributes: schema_attribute.subAttributes,
238
+ rebuilt_attribute_array: rebuilt_schema_attribute.subAttributes
239
+ )
240
+ end
241
+
242
+ # A one-or-more entry array with "match with" semantics, to match
243
+ # discrete mapped items with a particular value in a particular
244
+ # field - e.g. an e-mail of type "work" mapping the SCIM "value"
245
+ # to a local attribute of "work_email_address".
246
+ #
247
+ # Mutability or supported attributes here might vary per matched
248
+ # type. There's no way for SCIM schema to represent that so we
249
+ # just merge all the "using" mappings together, in order of array
250
+ # appearance, and have that combined attribute map treated as the
251
+ # data the schema response will use.
252
+ #
253
+ elsif mapped_multivalue_attribute.first&.dig(:match)
254
+ union_of_mappings = {}
255
+
256
+ mapped_multivalue_attribute.each do | mapped_multivalue_attribute_description |
257
+ union_of_mappings.merge!(mapped_multivalue_attribute_description[:using])
258
+ end
259
+
260
+ rebuilt_schema_attribute = self.duplicate_attribute(schema_attribute)
261
+ rebuilt_schema_attribute.subAttributes = []
262
+ rebuilt_attribute_array << rebuilt_schema_attribute
263
+
264
+ redraw_schema_using_mappings(
265
+ original_schema_instance: original_schema_instance,
266
+ instance_including_mixin: instance_including_mixin,
267
+ scim_attributes_map: union_of_mappings,
268
+ schema_attributes: schema_attribute.subAttributes,
269
+ rebuilt_attribute_array: rebuilt_schema_attribute.subAttributes
270
+ )
271
+ end
272
+ end
273
+
274
+ elsif schema_attribute.subAttributes&.any?
275
+ mapped_subattributes = scim_attributes_map[schema_attribute.name]
276
+
277
+ if mapped_subattributes.is_a?(Hash)
278
+ rebuilt_schema_attribute = self.duplicate_attribute(schema_attribute)
279
+ rebuilt_schema_attribute.subAttributes = []
280
+ rebuilt_attribute_array << rebuilt_schema_attribute
281
+
282
+ redraw_schema_using_mappings(
283
+ original_schema_instance: original_schema_instance,
284
+ instance_including_mixin: instance_including_mixin,
285
+ scim_attributes_map: mapped_subattributes,
286
+ schema_attributes: schema_attribute.subAttributes,
287
+ rebuilt_attribute_array: rebuilt_schema_attribute.subAttributes
288
+ )
289
+ end
290
+
291
+ else
292
+ mapped_attribute = scim_attributes_map[schema_attribute.name]
293
+
294
+ unless mapped_attribute.nil?
295
+ rebuilt_schema_attribute = self.duplicate_attribute(schema_attribute)
296
+ has_mapped_reader = true
297
+ has_mapped_writer = false
298
+
299
+ if mapped_attribute.is_a?(String) || mapped_attribute.is_a?(Symbol)
300
+ has_mapped_reader = instance_including_mixin.respond_to?(mapped_attribute)
301
+ has_mapped_writer = instance_including_mixin.scim_mutable_attributes().include?(mapped_attribute.to_sym)
302
+ end
303
+
304
+ # The schema is taken as the primary source of truth, leading to
305
+ # a matrix of "do we override it or not?" based on who is the
306
+ # more limited. When both have the same mutability there is no
307
+ # more work to do, so we just need to consider differences:
308
+ #
309
+ # Actual class support Schema says Result
310
+ # =============================================================
311
+ # readWrite readOnly readOnly (schema wins)
312
+ # readWrite writeOnly writeOnly (schema wins)
313
+ # readOnly readWrite readOnly (class wins)
314
+ # writeOnly readWrite writeOnly (class wins)
315
+ #
316
+ # Those cases are easy. But there are gnarly cases too, where we
317
+ # have no good answer and the class's mapped implementation is in
318
+ # essence broken compared to the schema. Since it is not useful
319
+ # to insist on the schema's not-reality version, the class wins.
320
+ #
321
+ # Actual class support Schema says Result
322
+ # ====================== =======================================
323
+ # readOnly writeOnly readOnly (class "wins")
324
+ # writeOnly readOnly writeOnly (class "wins")
325
+
326
+ schema_attribute_mutability = schema_attribute.mutability.downcase
327
+
328
+ if has_mapped_reader && has_mapped_writer
329
+ #
330
+ # Read-write Nothing to do. Schema always "wins" by matching or
331
+ # being more restrictive than the class's actual abilities.
332
+
333
+ elsif has_mapped_reader && ! has_mapped_writer
334
+ #
335
+ # Read-only. Class is more restrictive if schema is 'readWrite'
336
+ # or if there's the broken clash of schema 'writeOnly'.
337
+ #
338
+ if schema_attribute_mutability == 'readwrite' || schema_attribute_mutability == 'writeonly'
339
+ rebuilt_schema_attribute.mutability = 'readOnly'
340
+ end
341
+
342
+ elsif has_mapped_writer && ! has_mapped_reader
343
+ #
344
+ # Opposite to the above case.
345
+ #
346
+ if schema_attribute_mutability == 'readwrite' || schema_attribute_mutability == 'readonly'
347
+ rebuilt_schema_attribute.mutability = 'writeOnly'
348
+ end
349
+
350
+ # ...else we cannot fathom how this class works - it appears to
351
+ # have no read or write accessor for the mapped attribute. Keep
352
+ # the schema's declaration as-is.
353
+ #
354
+ end
355
+
356
+ rebuilt_attribute_array << rebuilt_schema_attribute
357
+ end
358
+ end
359
+ end
360
+
361
+ return rebuilt_schema_instance # (meaningless except for topmost call)
362
+ end
363
+
364
+ # Small helper that duplicates Scimitar::Schema::Attribute instances, but
365
+ # then removes their 'errors' collection which otherwise gets initialised
366
+ # to an empty value and is rendered as if part of the schema (which isn't
367
+ # a valid entry in a SCIM schema representation).
368
+ #
369
+ # +schema_attribute+:: Scimitar::Schema::Attribute to be duplicated.
370
+ # A renderable duplicate is returned.
371
+ #
372
+ def duplicate_attribute(schema_attribute)
373
+ duplicated_schema_attribute = schema_attribute.dup()
374
+ duplicated_schema_attribute.remove_instance_variable('@errors')
375
+ duplicated_schema_attribute
376
+ end
377
+
20
378
  end
21
379
  end
@@ -1,4 +1,3 @@
1
- require_dependency "scimitar/application_controller"
2
1
  module Scimitar
3
2
  class ServiceProviderConfigurationsController < ApplicationController
4
3
  def show
@@ -14,6 +14,7 @@ module Scimitar
14
14
  :application_controller_mixin,
15
15
  :exception_reporter,
16
16
  :optional_value_fields_required,
17
+ :schema_list_from_attribute_mappings,
17
18
  )
18
19
 
19
20
  def initialize(attributes = {})
@@ -22,7 +23,8 @@ module Scimitar
22
23
  # Set defaults that may be overridden by the initializer.
23
24
  #
24
25
  defaults = {
25
- optional_value_fields_required: true
26
+ optional_value_fields_required: true,
27
+ schema_list_from_attribute_mappings: []
26
28
  }
27
29
 
28
30
  super(defaults.merge(attributes))