scimitar 2.5.0 → 2.11.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -0
  3. data/README.md +721 -0
  4. data/app/controllers/scimitar/active_record_backed_resources_controller.rb +72 -18
  5. data/app/controllers/scimitar/application_controller.rb +17 -9
  6. data/app/controllers/scimitar/resource_types_controller.rb +7 -3
  7. data/app/controllers/scimitar/resources_controller.rb +0 -2
  8. data/app/controllers/scimitar/schemas_controller.rb +366 -3
  9. data/app/controllers/scimitar/service_provider_configurations_controller.rb +3 -2
  10. data/app/models/scimitar/complex_types/address.rb +0 -6
  11. data/app/models/scimitar/complex_types/base.rb +2 -2
  12. data/app/models/scimitar/engine_configuration.rb +3 -1
  13. data/app/models/scimitar/lists/query_parser.rb +97 -12
  14. data/app/models/scimitar/resource_invalid_error.rb +1 -1
  15. data/app/models/scimitar/resource_type.rb +4 -6
  16. data/app/models/scimitar/resources/base.rb +52 -8
  17. data/app/models/scimitar/resources/mixin.rb +539 -76
  18. data/app/models/scimitar/schema/attribute.rb +18 -8
  19. data/app/models/scimitar/schema/base.rb +2 -2
  20. data/app/models/scimitar/schema/name.rb +2 -2
  21. data/app/models/scimitar/schema/user.rb +10 -10
  22. data/config/initializers/scimitar.rb +49 -3
  23. data/lib/scimitar/engine.rb +57 -12
  24. data/lib/scimitar/support/hash_with_indifferent_case_insensitive_access.rb +140 -10
  25. data/lib/scimitar/support/utilities.rb +111 -0
  26. data/lib/scimitar/version.rb +2 -2
  27. data/lib/scimitar.rb +1 -0
  28. data/spec/apps/dummy/app/controllers/custom_create_mock_users_controller.rb +25 -0
  29. data/spec/apps/dummy/app/controllers/custom_replace_mock_users_controller.rb +25 -0
  30. data/spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb +24 -0
  31. data/spec/apps/dummy/app/controllers/custom_update_mock_users_controller.rb +25 -0
  32. data/spec/apps/dummy/app/models/mock_user.rb +20 -3
  33. data/spec/apps/dummy/config/application.rb +8 -0
  34. data/spec/apps/dummy/config/initializers/scimitar.rb +40 -3
  35. data/spec/apps/dummy/config/routes.rb +18 -1
  36. data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +2 -0
  37. data/spec/apps/dummy/db/schema.rb +3 -1
  38. data/spec/controllers/scimitar/application_controller_spec.rb +56 -2
  39. data/spec/controllers/scimitar/resource_types_controller_spec.rb +8 -4
  40. data/spec/controllers/scimitar/schemas_controller_spec.rb +344 -48
  41. data/spec/controllers/scimitar/service_provider_configurations_controller_spec.rb +1 -0
  42. data/spec/models/scimitar/complex_types/address_spec.rb +3 -4
  43. data/spec/models/scimitar/lists/query_parser_spec.rb +70 -0
  44. data/spec/models/scimitar/resources/base_spec.rb +55 -13
  45. data/spec/models/scimitar/resources/base_validation_spec.rb +16 -3
  46. data/spec/models/scimitar/resources/mixin_spec.rb +781 -124
  47. data/spec/models/scimitar/schema/attribute_spec.rb +22 -0
  48. data/spec/models/scimitar/schema/user_spec.rb +2 -2
  49. data/spec/requests/active_record_backed_resources_controller_spec.rb +723 -40
  50. data/spec/requests/engine_spec.rb +75 -0
  51. data/spec/spec_helper.rb +10 -2
  52. data/spec/support/hash_with_indifferent_case_insensitive_access_spec.rb +108 -0
  53. metadata +42 -34
@@ -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
 
@@ -60,12 +58,20 @@ module Scimitar
60
58
 
61
59
  # POST (create)
62
60
  #
63
- def create
61
+ # Calls #save! on the new record if no block is given, else invokes the
62
+ # block, passing it the new ActiveRecord model instance to be saved. It
63
+ # is up to the block to make any further changes and persist the record.
64
+ #
65
+ # Blocks are invoked from within a wrapping database transaction.
66
+ # ActiveRecord::RecordInvalid exceptions are handled for you, rendering
67
+ # an appropriate SCIM error.
68
+ #
69
+ def create(&block)
64
70
  super do |scim_resource|
65
71
  self.storage_class().transaction do
66
72
  record = self.storage_class().new
67
73
  record.from_scim!(scim_hash: scim_resource.as_json())
68
- self.save!(record)
74
+ self.save!(record, &block)
69
75
  record_to_scim(record)
70
76
  end
71
77
  end
@@ -73,12 +79,16 @@ module Scimitar
73
79
 
74
80
  # PUT (replace)
75
81
  #
76
- def replace
82
+ # Calls #save! on the updated record if no block is given, else invokes the
83
+ # block, passing the updated record which the block must persist, with the
84
+ # same rules as for #create.
85
+ #
86
+ def replace(&block)
77
87
  super do |record_id, scim_resource|
78
88
  self.storage_class().transaction do
79
89
  record = self.find_record(record_id)
80
90
  record.from_scim!(scim_hash: scim_resource.as_json())
81
- self.save!(record)
91
+ self.save!(record, &block)
82
92
  record_to_scim(record)
83
93
  end
84
94
  end
@@ -86,12 +96,16 @@ module Scimitar
86
96
 
87
97
  # PATCH (update)
88
98
  #
89
- def update
99
+ # Calls #save! on the updated record if no block is given, else invokes the
100
+ # block, passing the updated record which the block must persist, with the
101
+ # same rules as for #create.
102
+ #
103
+ def update(&block)
90
104
  super do |record_id, patch_hash|
91
105
  self.storage_class().transaction do
92
106
  record = self.find_record(record_id)
93
107
  record.from_scim_patch!(patch_hash: patch_hash)
94
- self.save!(record)
108
+ self.save!(record, &block)
95
109
  record_to_scim(record)
96
110
  end
97
111
  end
@@ -134,6 +148,17 @@ module Scimitar
134
148
  raise NotImplementedError
135
149
  end
136
150
 
151
+ # Return an Array of exceptions that #save! can rescue and handle with a
152
+ # SCIM error automatically.
153
+ #
154
+ def scimitar_rescuable_exceptions
155
+ [
156
+ ActiveRecord::RecordInvalid,
157
+ ActiveRecord::RecordNotSaved,
158
+ ActiveRecord::RecordNotUnique,
159
+ ]
160
+ end
161
+
137
162
  # Find a record by ID. Subclasses can override this if they need special
138
163
  # lookup behaviour.
139
164
  #
@@ -147,13 +172,22 @@ module Scimitar
147
172
  # representation, with a "show" location specified via #url_for.
148
173
  #
149
174
  def record_to_scim(record)
150
- 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
+ )
151
179
  end
152
180
 
153
181
  # Save a record, dealing with validation exceptions by raising SCIM
154
182
  # errors.
155
183
  #
156
- # +record+:: ActiveRecord subclass to save (via #save!).
184
+ # +record+:: ActiveRecord subclass to save.
185
+ #
186
+ # If you just let this superclass handle things, it'll call the standard
187
+ # +#save!+ method on the record. If you pass a block, then this block is
188
+ # invoked and passed the ActiveRecord model instance to be saved. You can
189
+ # then do things like calling a different method, using a service object
190
+ # of some kind, perform audit-related operations and so-on.
157
191
  #
158
192
  # The return value is not used internally, making life easier for
159
193
  # overriding subclasses to "do the right thing" / avoid mistakes (instead
@@ -161,11 +195,31 @@ module Scimitar
161
195
  # and relying upon this to generate correct response payloads - an early
162
196
  # version of the gem did this and it caused a confusing subclass bug).
163
197
  #
164
- def save!(record)
165
- record.save!
198
+ def save!(record, &block)
199
+ if block_given?
200
+ yield(record)
201
+ else
202
+ record.save!
203
+ end
204
+ rescue *self.scimitar_rescuable_exceptions() => exception
205
+ handle_on_save_exception(record, exception)
206
+ end
166
207
 
167
- rescue ActiveRecord::RecordInvalid => exception
168
- joined_errors = record.errors.full_messages.join('; ')
208
+ # Deal with exceptions related to errors upon saving, by responding with
209
+ # an appropriate SCIM error. This is most effective if the record has
210
+ # validation errors defined, but falls back to the provided exception's
211
+ # message otherwise.
212
+ #
213
+ # +record+:: The record that provoked the exception. Mandatory.
214
+ # +exception+:: The exception that was raised. If omitted, a default of
215
+ # 'Unknown', in English with no I18n, is used.
216
+ #
217
+ def handle_on_save_exception(record, exception = RuntimeError.new('Unknown'))
218
+ details = if record.errors.present?
219
+ record.errors.full_messages.join('; ')
220
+ else
221
+ exception.message
222
+ end
169
223
 
170
224
  # https://tools.ietf.org/html/rfc7644#page-12
171
225
  #
@@ -175,14 +229,14 @@ module Scimitar
175
229
  # status code 409 (Conflict) with a "scimType" error code of
176
230
  # "uniqueness"
177
231
  #
178
- if record.errors.any? { | e | e.type == :taken }
232
+ if exception.is_a?(ActiveRecord::RecordNotUnique) || record.errors.any? { | e | e.type == :taken }
179
233
  raise Scimitar::ErrorResponse.new(
180
234
  status: 409,
181
235
  scimType: 'uniqueness',
182
- detail: joined_errors
236
+ detail: "Operation failed due to a uniqueness constraint: #{details}"
183
237
  )
184
238
  else
185
- raise Scimitar::ResourceInvalidError.new(joined_errors)
239
+ raise Scimitar::ResourceInvalidError.new(details)
186
240
  end
187
241
  end
188
242
 
@@ -9,10 +9,6 @@ module Scimitar
9
9
  before_action :add_mandatory_response_headers
10
10
  before_action :authenticate
11
11
 
12
- if Scimitar.engine_configuration.application_controller_mixin
13
- include Scimitar.engine_configuration.application_controller_mixin
14
- end
15
-
16
12
  # =========================================================================
17
13
  # PROTECTED INSTANCE METHODS
18
14
  # =========================================================================
@@ -47,7 +43,7 @@ module Scimitar
47
43
  #
48
44
  # *exception+:: If a Ruby exception was the reason this method is being
49
45
  # called, pass it here. Any configured exception reporting
50
- # mechanism will be invokved with the given parameter.
46
+ # mechanism will be invoked with the given parameter.
51
47
  # Otherwise, the +error_response+ value is reported.
52
48
  #
53
49
  def handle_scim_error(error_response, exception = error_response)
@@ -124,8 +120,13 @@ module Scimitar
124
120
  #
125
121
  # https://stackoverflow.com/questions/10239970/what-is-the-delimiter-for-www-authenticate-for-multiple-schemes
126
122
  #
127
- response.set_header('WWW_AUTHENTICATE', 'Basic' ) if Scimitar.engine_configuration.basic_authenticator.present?
128
- response.set_header('WWW_AUTHENTICATE', 'Bearer') if Scimitar.engine_configuration.token_authenticator.present?
123
+ response.set_header('WWW-Authenticate', 'Basic' ) if Scimitar.engine_configuration.basic_authenticator.present?
124
+ response.set_header('WWW-Authenticate', 'Bearer') if Scimitar.engine_configuration.token_authenticator.present?
125
+
126
+ # No matter what a caller might request via headers, the only content
127
+ # type we can ever respond with is JSON-for-SCIM.
128
+ #
129
+ response.set_header('Content-Type', "#{Mime::Type.lookup_by_extension(:scim)}; charset=utf-8")
129
130
  end
130
131
 
131
132
  def authenticate
@@ -134,15 +135,22 @@ module Scimitar
134
135
 
135
136
  def authenticated?
136
137
  result = if Scimitar.engine_configuration.basic_authenticator.present?
137
- authenticate_with_http_basic(&Scimitar.engine_configuration.basic_authenticator)
138
+ authenticate_with_http_basic do |username, password|
139
+ instance_exec(username, password, &Scimitar.engine_configuration.basic_authenticator)
140
+ end
138
141
  end
139
142
 
140
143
  result ||= if Scimitar.engine_configuration.token_authenticator.present?
141
- authenticate_with_http_token(&Scimitar.engine_configuration.token_authenticator)
144
+ authenticate_with_http_token do |token, options|
145
+ instance_exec(token, options, &Scimitar.engine_configuration.token_authenticator)
146
+ end
142
147
  end
143
148
 
144
149
  return result
145
150
  end
146
151
 
152
+ if Scimitar.engine_configuration.application_controller_mixin
153
+ include Scimitar.engine_configuration.application_controller_mixin
154
+ end
147
155
  end
148
156
  end
@@ -1,5 +1,3 @@
1
- require_dependency "scimitar/application_controller"
2
-
3
1
  module Scimitar
4
2
  class ResourceTypesController < ApplicationController
5
3
  def index
@@ -7,7 +5,13 @@ module Scimitar
7
5
  resource.resource_type(scim_resource_type_url(name: resource.resource_type_id))
8
6
  end
9
7
 
10
- render json: resource_types
8
+ render json: {
9
+ schemas: [
10
+ 'urn:ietf:params:scim:api:messages:2.0:ListResponse'
11
+ ],
12
+ totalResults: resource_types.size,
13
+ Resources: resource_types
14
+ }
11
15
  end
12
16
 
13
17
  def show
@@ -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,16 +1,379 @@
1
- require_dependency "scimitar/application_controller"
2
-
3
1
  module Scimitar
4
2
  class SchemasController < ApplicationController
5
3
  def index
6
4
  schemas = Scimitar::Engine.schemas
5
+
6
+ schemas.each do |schema|
7
+ schema.meta.location = scim_schemas_url(name: schema.id)
8
+ end
9
+
7
10
  schemas_by_id = schemas.reduce({}) do |hash, schema|
8
11
  hash[schema.id] = schema
9
12
  hash
10
13
  end
11
14
 
12
- 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
+ })
13
38
  end
14
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 arbitrary 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
+
15
378
  end
16
379
  end
@@ -1,8 +1,9 @@
1
- require_dependency "scimitar/application_controller"
2
1
  module Scimitar
3
2
  class ServiceProviderConfigurationsController < ApplicationController
4
3
  def show
5
- render json: Scimitar.service_provider_configuration(location: request.url)
4
+ service_provider_configuration = Scimitar.service_provider_configuration(location: request.url).as_json
5
+ service_provider_configuration.delete("uses_defaults")
6
+ render json: service_provider_configuration
6
7
  end
7
8
  end
8
9
  end
@@ -7,12 +7,6 @@ module Scimitar
7
7
  #
8
8
  class Address < Base
9
9
  set_schema Scimitar::Schema::Address
10
-
11
- # Returns the JSON representation of an Address.
12
- #
13
- def as_json(options = {})
14
- {'type' => 'work'}.merge(super(options))
15
- end
16
10
  end
17
11
  end
18
12
  end
@@ -75,8 +75,8 @@ module Scimitar
75
75
  # attributes of this complex type object.
76
76
  #
77
77
  def as_json(options={})
78
- options[:except] ||= ['errors']
79
- super.except(options)
78
+ exclusions = options[:except] || ['errors']
79
+ super(options.merge(except: exclusions))
80
80
  end
81
81
  end
82
82
  end