scimitar 1.0.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 (106) hide show
  1. checksums.yaml +7 -0
  2. data/Rakefile +16 -0
  3. data/app/controllers/scimitar/active_record_backed_resources_controller.rb +180 -0
  4. data/app/controllers/scimitar/application_controller.rb +129 -0
  5. data/app/controllers/scimitar/resource_types_controller.rb +28 -0
  6. data/app/controllers/scimitar/resources_controller.rb +203 -0
  7. data/app/controllers/scimitar/schemas_controller.rb +16 -0
  8. data/app/controllers/scimitar/service_provider_configurations_controller.rb +8 -0
  9. data/app/models/scimitar/authentication_error.rb +9 -0
  10. data/app/models/scimitar/authentication_scheme.rb +18 -0
  11. data/app/models/scimitar/bulk.rb +8 -0
  12. data/app/models/scimitar/complex_types/address.rb +18 -0
  13. data/app/models/scimitar/complex_types/base.rb +41 -0
  14. data/app/models/scimitar/complex_types/email.rb +12 -0
  15. data/app/models/scimitar/complex_types/entitlement.rb +12 -0
  16. data/app/models/scimitar/complex_types/ims.rb +12 -0
  17. data/app/models/scimitar/complex_types/name.rb +12 -0
  18. data/app/models/scimitar/complex_types/phone_number.rb +12 -0
  19. data/app/models/scimitar/complex_types/photo.rb +12 -0
  20. data/app/models/scimitar/complex_types/reference_group.rb +12 -0
  21. data/app/models/scimitar/complex_types/reference_member.rb +12 -0
  22. data/app/models/scimitar/complex_types/role.rb +12 -0
  23. data/app/models/scimitar/complex_types/x509_certificate.rb +12 -0
  24. data/app/models/scimitar/engine_configuration.rb +24 -0
  25. data/app/models/scimitar/error_response.rb +20 -0
  26. data/app/models/scimitar/errors.rb +14 -0
  27. data/app/models/scimitar/filter.rb +11 -0
  28. data/app/models/scimitar/filter_error.rb +22 -0
  29. data/app/models/scimitar/invalid_syntax_error.rb +9 -0
  30. data/app/models/scimitar/lists/count.rb +64 -0
  31. data/app/models/scimitar/lists/query_parser.rb +730 -0
  32. data/app/models/scimitar/meta.rb +7 -0
  33. data/app/models/scimitar/not_found_error.rb +10 -0
  34. data/app/models/scimitar/resource_invalid_error.rb +9 -0
  35. data/app/models/scimitar/resource_type.rb +29 -0
  36. data/app/models/scimitar/resources/base.rb +159 -0
  37. data/app/models/scimitar/resources/group.rb +13 -0
  38. data/app/models/scimitar/resources/mixin.rb +964 -0
  39. data/app/models/scimitar/resources/user.rb +13 -0
  40. data/app/models/scimitar/schema/address.rb +24 -0
  41. data/app/models/scimitar/schema/attribute.rb +123 -0
  42. data/app/models/scimitar/schema/base.rb +86 -0
  43. data/app/models/scimitar/schema/derived_attributes.rb +24 -0
  44. data/app/models/scimitar/schema/email.rb +10 -0
  45. data/app/models/scimitar/schema/entitlement.rb +10 -0
  46. data/app/models/scimitar/schema/group.rb +27 -0
  47. data/app/models/scimitar/schema/ims.rb +10 -0
  48. data/app/models/scimitar/schema/name.rb +20 -0
  49. data/app/models/scimitar/schema/phone_number.rb +10 -0
  50. data/app/models/scimitar/schema/photo.rb +10 -0
  51. data/app/models/scimitar/schema/reference_group.rb +23 -0
  52. data/app/models/scimitar/schema/reference_member.rb +21 -0
  53. data/app/models/scimitar/schema/role.rb +10 -0
  54. data/app/models/scimitar/schema/user.rb +52 -0
  55. data/app/models/scimitar/schema/vdtp.rb +18 -0
  56. data/app/models/scimitar/schema/x509_certificate.rb +22 -0
  57. data/app/models/scimitar/service_provider_configuration.rb +49 -0
  58. data/app/models/scimitar/supportable.rb +14 -0
  59. data/app/views/layouts/scimitar/application.html.erb +14 -0
  60. data/config/initializers/scimitar.rb +82 -0
  61. data/config/routes.rb +6 -0
  62. data/lib/scimitar.rb +23 -0
  63. data/lib/scimitar/engine.rb +63 -0
  64. data/lib/scimitar/version.rb +13 -0
  65. data/spec/apps/dummy/app/controllers/custom_destroy_mock_users_controller.rb +24 -0
  66. data/spec/apps/dummy/app/controllers/custom_request_verifiers_controller.rb +30 -0
  67. data/spec/apps/dummy/app/controllers/mock_groups_controller.rb +13 -0
  68. data/spec/apps/dummy/app/controllers/mock_users_controller.rb +13 -0
  69. data/spec/apps/dummy/app/models/mock_group.rb +83 -0
  70. data/spec/apps/dummy/app/models/mock_user.rb +104 -0
  71. data/spec/apps/dummy/config/application.rb +17 -0
  72. data/spec/apps/dummy/config/boot.rb +2 -0
  73. data/spec/apps/dummy/config/environment.rb +2 -0
  74. data/spec/apps/dummy/config/environments/test.rb +15 -0
  75. data/spec/apps/dummy/config/initializers/cookies_serializer.rb +3 -0
  76. data/spec/apps/dummy/config/initializers/scimitar.rb +14 -0
  77. data/spec/apps/dummy/config/initializers/session_store.rb +3 -0
  78. data/spec/apps/dummy/config/routes.rb +24 -0
  79. data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +15 -0
  80. data/spec/apps/dummy/db/migrate/20210308020313_create_mock_groups.rb +10 -0
  81. data/spec/apps/dummy/db/migrate/20210308044214_create_join_table_mock_groups_mock_users.rb +8 -0
  82. data/spec/apps/dummy/db/schema.rb +42 -0
  83. data/spec/controllers/scimitar/application_controller_spec.rb +173 -0
  84. data/spec/controllers/scimitar/resource_types_controller_spec.rb +94 -0
  85. data/spec/controllers/scimitar/resources_controller_spec.rb +247 -0
  86. data/spec/controllers/scimitar/schemas_controller_spec.rb +75 -0
  87. data/spec/controllers/scimitar/service_provider_configurations_controller_spec.rb +22 -0
  88. data/spec/models/scimitar/complex_types/address_spec.rb +19 -0
  89. data/spec/models/scimitar/complex_types/email_spec.rb +23 -0
  90. data/spec/models/scimitar/lists/count_spec.rb +147 -0
  91. data/spec/models/scimitar/lists/query_parser_spec.rb +763 -0
  92. data/spec/models/scimitar/resource_type_spec.rb +21 -0
  93. data/spec/models/scimitar/resources/base_spec.rb +289 -0
  94. data/spec/models/scimitar/resources/base_validation_spec.rb +61 -0
  95. data/spec/models/scimitar/resources/mixin_spec.rb +2127 -0
  96. data/spec/models/scimitar/resources/user_spec.rb +55 -0
  97. data/spec/models/scimitar/schema/attribute_spec.rb +80 -0
  98. data/spec/models/scimitar/schema/base_spec.rb +64 -0
  99. data/spec/models/scimitar/schema/group_spec.rb +87 -0
  100. data/spec/models/scimitar/schema/user_spec.rb +710 -0
  101. data/spec/requests/active_record_backed_resources_controller_spec.rb +569 -0
  102. data/spec/requests/application_controller_spec.rb +49 -0
  103. data/spec/requests/controller_configuration_spec.rb +17 -0
  104. data/spec/requests/engine_spec.rb +20 -0
  105. data/spec/spec_helper.rb +66 -0
  106. metadata +315 -0
@@ -0,0 +1,7 @@
1
+ module Scimitar
2
+ class Meta
3
+ include ActiveModel::Model
4
+
5
+ attr_accessor :resourceType, :created, :lastModified, :location, :version
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ module Scimitar
2
+
3
+ class NotFoundError < ErrorResponse
4
+
5
+ def initialize(id)
6
+ super(status: 404, detail: "Resource #{id.inspect} not found")
7
+ end
8
+
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ module Scimitar
2
+ class ResourceInvalidError < ErrorResponse
3
+
4
+ def initialize(error_message)
5
+ super(status: 400, scimType: 'invalidValue', detail:"Operation failed since record has become invalid: #{error_message}")
6
+ end
7
+
8
+ end
9
+ end
@@ -0,0 +1,29 @@
1
+ module Scimitar
2
+ # Provides info about a resource type. Instances of this class are used to provide info through the /ResourceTypes endpoint of a SCIM service provider.
3
+ class ResourceType
4
+ include ActiveModel::Model
5
+ attr_accessor :meta, :endpoint, :schema, :schemas, :id, :name, :schemaExtensions
6
+
7
+ def initialize(attributes = {})
8
+ default_attributes = {
9
+ meta: Meta.new(
10
+ 'resourceType': 'ResourceType'
11
+ ),
12
+ schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType']
13
+ }
14
+ super(default_attributes.merge(attributes))
15
+ end
16
+
17
+
18
+ def as_json(options = {})
19
+ without_extensions = super(except: 'schemaExtensions')
20
+ if schemaExtensions.present?
21
+ extensions = schemaExtensions.map{|extension| {"schema" => extension, "required" => false}}
22
+ without_extensions.merge('schemaExtensions' => extensions)
23
+ else
24
+ without_extensions
25
+ end
26
+ end
27
+
28
+ end
29
+ end
@@ -0,0 +1,159 @@
1
+ module Scimitar
2
+ module Resources
3
+ # The base class for all SCIM resources.
4
+ class Base
5
+ include ActiveModel::Model
6
+ include Scimitar::Schema::DerivedAttributes
7
+ include Scimitar::Errors
8
+
9
+ attr_accessor :id, :externalId, :meta
10
+ attr_reader :errors
11
+ validate :validate_resource
12
+
13
+ def initialize(options = {})
14
+ flattended_attributes = flatten_extension_attributes(options)
15
+ attributes = flattended_attributes.with_indifferent_access.slice(*self.class.all_attributes)
16
+ super(attributes)
17
+ constantize_complex_types(attributes)
18
+ @errors = ActiveModel::Errors.new(self)
19
+ end
20
+
21
+ def flatten_extension_attributes(options)
22
+ flattened = options.dup
23
+ self.class.extended_schemas.each do |extended_schema|
24
+ if extension_attrs = flattened.delete(extended_schema.id)
25
+ flattened.merge!(extension_attrs)
26
+ end
27
+ end
28
+ flattened
29
+ end
30
+
31
+ # Can be used to extend an existing resource type's schema. For example:
32
+ #
33
+ # module Scim
34
+ # module Schema
35
+ # class MyExtension < Scimitar::Schema::Base
36
+ #
37
+ # def initialize(options = {})
38
+ # super(name: 'ExtendedGroup',
39
+ # id: self.class.id,
40
+ # description: 'Represents extra info about a group',
41
+ # scim_attributes: self.class.scim_attributes)
42
+ # end
43
+ #
44
+ # def self.id
45
+ # 'urn:ietf:params:scim:schemas:extension:extendedgroup:2.0:Group'
46
+ # end
47
+ #
48
+ # def self.scim_attributes
49
+ # [Scimitar::Schema::Attribute.new(name: 'someAddedAttribute',
50
+ # type: 'string',
51
+ # required: true,
52
+ # canonicalValues: ['FOO', 'BAR'])]
53
+ # end
54
+ # end
55
+ # end
56
+ # end
57
+ #
58
+ # Scimitar::Resources::Group.extend_schema Scim::Schema::MyExtension
59
+ #
60
+ def self.extend_schema(schema)
61
+ derive_attributes_from_schema(schema)
62
+ extended_schemas << schema
63
+ end
64
+
65
+ def self.extended_schemas
66
+ @extended_schemas ||= []
67
+ end
68
+
69
+ def self.schemas
70
+ ([schema] + extended_schemas).flatten
71
+ end
72
+
73
+ def self.all_attributes
74
+ scim_attributes = schemas.map(&:scim_attributes).flatten.map(&:name)
75
+ scim_attributes + [:id, :externalId, :meta]
76
+ end
77
+
78
+ # Calls to Scimitar::Schema::Base::find_attribute for each of the schemas
79
+ # in ::schemas, in order returned (so main schema would be first, then
80
+ # any extended schemas searched next). Returns the first match found, or
81
+ # +nil+.
82
+ #
83
+ # See Scimitar::Schema::Base::find_attribute for details on parameters,
84
+ # more about the return value and other general information.
85
+ #
86
+ def self.find_attribute(*path)
87
+ found_attribute = nil
88
+
89
+ self.schemas.each do | schema |
90
+ found_attribute = schema.find_attribute(*path)
91
+ break unless found_attribute.nil?
92
+ end
93
+
94
+ return found_attribute
95
+ end
96
+
97
+ def self.complex_scim_attributes
98
+ schema.scim_attributes.select(&:complexType).group_by(&:name)
99
+ end
100
+
101
+ def complex_type_from_hash(scim_attribute, attr_value)
102
+ if attr_value.is_a?(Hash)
103
+ scim_attribute.complexType.new(attr_value)
104
+ else
105
+ attr_value
106
+ end
107
+ end
108
+
109
+ def constantize_complex_types(hash)
110
+ hash.with_indifferent_access.each_pair do |attr_name, attr_value|
111
+ scim_attribute = self.class.complex_scim_attributes[attr_name].try(:first)
112
+ if scim_attribute && scim_attribute.complexType
113
+ if scim_attribute.multiValued
114
+ self.send("#{attr_name}=", attr_value.map {|attr_for_each_item| complex_type_from_hash(scim_attribute, attr_for_each_item)})
115
+ else
116
+ self.send("#{attr_name}=", complex_type_from_hash(scim_attribute, attr_value))
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ def as_json(options = {})
123
+ self.meta = Meta.new unless self.meta
124
+ meta.resourceType = self.class.resource_type_id
125
+ original_hash = super(options).except('errors')
126
+ original_hash.merge!('schemas' => self.class.schemas.map(&:id))
127
+ self.class.extended_schemas.each do |extension_schema|
128
+ extension_attributes = extension_schema.scim_attributes.map(&:name)
129
+ original_hash.merge!(extension_schema.id => original_hash.extract!(*extension_attributes))
130
+ end
131
+ original_hash
132
+ end
133
+
134
+ def self.resource_type_id
135
+ name.demodulize
136
+ end
137
+
138
+ def self.resource_type(location)
139
+ resource_type = ResourceType.new(
140
+ endpoint: endpoint,
141
+ schema: schema.id,
142
+ id: resource_type_id,
143
+ name: resource_type_id,
144
+ schemaExtensions: extended_schemas.map(&:id)
145
+ )
146
+
147
+ resource_type.meta.location = location
148
+ resource_type
149
+ end
150
+
151
+ def validate_resource
152
+ self.class.schema.valid?(self)
153
+ self.class.extended_schemas.each do |extended_schema|
154
+ extended_schema.valid?(self)
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,13 @@
1
+ module Scimitar
2
+ module Resources
3
+ class Group < Base
4
+
5
+ set_schema Schema::Group
6
+
7
+ def self.endpoint
8
+ '/Groups'
9
+ end
10
+
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,964 @@
1
+ module Scimitar
2
+ module Resources
3
+
4
+ # The mixin included by any class in your application which is to be mapped
5
+ # to and exposed via a SCIM interface. Any one such class must have one
6
+ # corresponding ResourcesController subclass declaring its association to
7
+ # that model.
8
+ #
9
+ # Your class becomes responsible for implementing various *class methods*
10
+ # as described below. YOU MUST DECLARE THESE **BEFORE** YOU INCLUDE THE
11
+ # MIXIN MODULE because Ruby parses classes top-down and the mixin checks to
12
+ # make sure that required methods exist, so these must be defined *first*.
13
+ #
14
+ #
15
+ #
16
+ # == scim_resource_type
17
+ #
18
+ # Define this method to return the Scimitar resource class that corresponds
19
+ # to the mixing-in class.
20
+ #
21
+ # For example, if you have an ActiveRecord "User" class that maps to a SCIM
22
+ # "User" resource type:
23
+ #
24
+ # def self.scim_resource_type
25
+ # return Scimitar::Resources::User
26
+ # end
27
+ #
28
+ # This is used to render SCIM JSON data via #to_scim.
29
+ #
30
+ #
31
+ #
32
+ # == scim_attributes_map
33
+ #
34
+ # Define this method to return a Hash that maps SCIM attributes to
35
+ # corresponding supported accessor methods in the mixing-in class.
36
+ #
37
+ # Define read-only, write-only or read-write attributes here. Scimitar will
38
+ # check for an appropriate accessor depending on whether SCIM operations
39
+ # are read or write and acts accordingly. At each level of the Ruby Hash,
40
+ # the keys are case-sensitive attributes from the SCIM schema and values
41
+ # are either Symbols, giving a corresponding read/write accessor name in
42
+ # the mixing-in class, Hashes for nested SCIM schema data as shown below or
43
+ # for Array entries, special structures described later.
44
+ #
45
+ # For example, for a User model <-> SCIM user:
46
+ #
47
+ # def self.scim_attributes_map
48
+ # return {
49
+ # id: :id,
50
+ # externalId: :scim_external_id,
51
+ # userName: :username,
52
+ # name: {
53
+ # givenName: :given_name,
54
+ # familyName: :last_name
55
+ # },
56
+ # active: :is_active?
57
+ # }
58
+ # end
59
+ #
60
+ # Note that providing storage and filter (search) support for externalId is
61
+ # VERY STRONGLY recommended (bordering on mandatory) for your service to
62
+ # provide adequate support for typical clients to function smoothly. See
63
+ # "scim_queryable_attributes" below for filtering.
64
+ #
65
+ # This omits things like "email" because in SCIM those are specified in an
66
+ # Array, where each entry has a "type" field - e.g. "home", "work". Within
67
+ # SCIM this is common but there are also just free lists of data, such as
68
+ # the list of Members in a Group. This makes the mapping description more
69
+ # complex. You can provide two kinds of mapping data:
70
+ #
71
+ # * One where a specific SCIM attribute is present in each array entry and
72
+ # can contain only a set of specific, discrete values; your mapping
73
+ # defines entries for each value of interest. E-mail is an example here,
74
+ # where "type" is the SCIM attribute and you might map "work" and "home".
75
+ #
76
+ # For discrete matches, you declare the Array containing Hashes with key
77
+ # "match", where the value gives the name of the SCIM attribute to read or
78
+ # write for each array entry; "with", where the value gives the thing to
79
+ # match at this attribute; then "using", where the value is a Hash giving
80
+ # a mapping schema just as described herein (schema can nest as deeply as
81
+ # you like).
82
+ #
83
+ # Given that e-mails in SCIM look something like this:
84
+ #
85
+ # "emails": [
86
+ # {
87
+ # "value": "bjensen@example.com",
88
+ # "type": "work",
89
+ # "primary": true
90
+ # },
91
+ # {
92
+ # "value": "babs@jensen.org",
93
+ # "type": "home"
94
+ # }
95
+ # ]
96
+ #
97
+ # ...then we could extend the above attributes map example thus:
98
+ #
99
+ # def self.scim_attributes_map
100
+ # # ...
101
+ # emails: [
102
+ # {
103
+ # match: 'type',
104
+ # with: 'work',
105
+ # using: {
106
+ # value: :work_email_address,
107
+ # primary: true
108
+ # }
109
+ # },
110
+ # {
111
+ # match: 'type',
112
+ # with: 'home',
113
+ # using: { value: :home_email_address }
114
+ # }
115
+ # ],
116
+ # # ...
117
+ # end
118
+ #
119
+ # ...where the including class would have a #work_email_address accessor
120
+ # and we're hard-coding this as the primary (preferred) address (but could
121
+ # just as well map this to another accessor, e.g. :work_email_is_primary?).
122
+ #
123
+ # * One where a SCIM array contains just a list of arbitrary entries, each
124
+ # with a known schema, and these map attribute-by-attribute to same-index
125
+ # items in a corresponding array in the mixing-in model. Group members
126
+ # are the example use case here.
127
+ #
128
+ # For things like a group's list of members, again include an array in the
129
+ # attribute map as above but this time have a key "list" with a value that
130
+ # is the attribute accessor in your mixing in model that returns an
131
+ # Enumerable of values to map, then as above, "using" which provides the
132
+ # nested schema saying how each of those objects should be mapped.
133
+ #
134
+ # Suppose you were mixing this module into a Team class and there was an
135
+ # association Team#users that provided an Enumerable of team member User
136
+ # objects:
137
+ #
138
+ # def self.scim_attributes_map
139
+ # # ...
140
+ # groups: [
141
+ # {
142
+ # list: :users, # <-- i.e. Team.users,
143
+ # using: {
144
+ # value: :id, # <-- i.e. Team.users[n].id
145
+ # display: :full_name # <-- i.e. Team.users[n].full_name
146
+ # },
147
+ # find_with: -> (scim_list_entry) {...} # See below
148
+ # }
149
+ # ],
150
+ # #...
151
+ # end
152
+ #
153
+ # The mixing-in class _must+ implement the read accessor identified by the
154
+ # value of the "list" key, returning any indexed, Enumerable collection
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
157
+ # list position. It must use this to look up the equivalent entry for
158
+ # association via the write accessor described by the ":list" key. In the
159
+ # example above, "find_with"'s Proc might look at a SCIM entry value which
160
+ # is expected to be a user ID and find that User. The mapped set of User
161
+ # data thus found would be written back with "#users=", due to the ":list"
162
+ # key declaring the method name ":users".
163
+ #
164
+ # Note that you can only use either:
165
+ #
166
+ # * One or more static maps where each matches some other piece of source
167
+ # SCIM data field value, so that specific SCIM array entries are matched
168
+ #
169
+ # * A single dynamic list entry which maps app SCIM array entries.
170
+ #
171
+ # A mixture of static and dynamic data, or multiple dynamic entries in a
172
+ # single mapping array value will produce undefined behaviour.
173
+ #
174
+ #
175
+ #
176
+ # == scim_mutable_attributes
177
+ #
178
+ # Define this method to return a Set (preferred) or Array of names of
179
+ # attributes which may be written in the mixing-in class.
180
+ #
181
+ # If you return +nil+, it is assumed that +any+ attribute mapped by
182
+ # ::scim_attributes_map which has a write accessor will be eligible for
183
+ # assignment during SCIM creation or update operations.
184
+ #
185
+ # For example, if everything in ::scim_attributes_map with a write accessor
186
+ # is to be mutable over SCIM:
187
+ #
188
+ # def self.scim_mutable_attributes
189
+ # return nil
190
+ # end
191
+ #
192
+ # Note that as a common special case, any mapped attribute of the Symbol
193
+ # value ":id" will be removed from the list, as it is assumed to be e.g. a
194
+ # primary key or similar. So, even though it'll have a write accessor, it
195
+ # is not something that should be mutable over SCIM - it's taken to be your
196
+ # internal record ID. If you do want :id included as mutable or if you have
197
+ # a different primary key attribute name, you'll just need to return the
198
+ # mutable attribute list directly in your ::scim_mutable_attributes method
199
+ # rather than relying on the list extracted from ::scim_attributes_map.
200
+ #
201
+ #
202
+ # == scim_queryable_attributes
203
+ #
204
+ # Define this method to return a Hash that maps field names you wish to
205
+ # support in SCIM filter queries to corresponding attributes in the in the
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
208
+ # class. If not +nil+ but a SCIM filter enquiry is made for an unmapped
209
+ # attribute, an 'invalid filter' exception is raised.
210
+ #
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
213
+ # below; if you have other approaches to searching, these might be virtual
214
+ # attributes or other such constructs rather than columns. That would be up
215
+ # to your non-ActiveRecord's implementation to decide.
216
+ #
217
+ # Each STRING field name(s) represents a *flat* attribute path that might
218
+ # be encountered in a filter - e.g. "name.familyName", "emails.value" (and
219
+ # often it makes sense to define "emails" and "emails.value" identically to
220
+ # allow for different client searching "styles", given ambiguities in RFC
221
+ # 7644 filter examples).
222
+ #
223
+ # Each value is a Hash with Symbol keys ':column', naming just one simple
224
+ # column for a mapping; ':columns', with an Array of column names that you
225
+ # want to map using 'OR' for a single search on the corresponding SCIM
226
+ # attribute; or ':ignore' with value 'true', which means that a fitler on
227
+ # the matching attribute is ignored rather than resulting in an "invalid
228
+ # filter" exception - beware possibilities for surprised clients getting a
229
+ # broader result set than expected. Example:
230
+ #
231
+ # def self.scim_queryable_attributes
232
+ # return {
233
+ # 'name.givenName' => { column: :first_name },
234
+ # 'name.familyName' => { column: :last_name },
235
+ # 'emails' => { columns: [ :work_email_address, :home_email_address ] },
236
+ # 'emails.value' => { columns: [ :work_email_address, :home_email_address ] },
237
+ # 'emails.type' => { ignore: true }
238
+ # }
239
+ # end
240
+ #
241
+ # Filtering is currently limited and searching within e.g. arrays of data
242
+ # is not supported; only simple top-level keys can be mapped.
243
+ #
244
+ #
245
+ # == Optional methods
246
+ #
247
+ # === scim_timestamps_map
248
+ #
249
+ # If you implement this class method, it should return a Hash with one or
250
+ # both of the keys 'created' and 'lastModified', as Symbols. The values
251
+ # should be methods that the including method supports which return a
252
+ # creation or most-recently-updated time, respectively. The returned object
253
+ # mustsupport #iso8601 to convert to a String representation. Example for a
254
+ # typical ActiveRecord object with standard timestamps:
255
+ #
256
+ # def self.scim_timestamps_map
257
+ # {
258
+ # created: :created_at,
259
+ # lastModified: :updated_at
260
+ # }
261
+ # end
262
+ #
263
+ module Mixin
264
+ extend ActiveSupport::Concern
265
+
266
+ included do
267
+ %w{
268
+ scim_resource_type
269
+ scim_attributes_map
270
+ scim_mutable_attributes
271
+ scim_queryable_attributes
272
+ }.each do | required_class_method_name |
273
+ raise "You must define ::#{required_class_method_name} in #{self}" unless self.respond_to?(required_class_method_name)
274
+ end
275
+
276
+ # An instance-level method which calls ::scim_mutable_attributes and
277
+ # either uses its returned array of mutable attribute names or reads
278
+ # ::scim_attributes_map and determines the list from that. Caches
279
+ # the result in an instance variable.
280
+ #
281
+ def scim_mutable_attributes
282
+ @scim_mutable_attributes ||= self.class.scim_mutable_attributes()
283
+
284
+ if @scim_mutable_attributes.nil?
285
+ @scim_mutable_attributes = Set.new
286
+
287
+ # Variant of https://stackoverflow.com/a/49315255
288
+ #
289
+ extractor = ->(outer_enum) do
290
+ outer_enum.each do |key, value|
291
+ enum = [key, value].detect(&Enumerable.method(:===))
292
+ if enum.nil?
293
+ @scim_mutable_attributes << value if value.is_a?(Symbol) && self.respond_to?("#{value}=")
294
+ else
295
+ if enum.is_a?(Hash)
296
+ extractor.call(enum)
297
+ elsif enum.is_a?(Array)
298
+ enum.each do | static_or_dynamic_mapping |
299
+ if static_or_dynamic_mapping.key?(:match) # Static
300
+ extractor.call(static_or_dynamic_mapping[:using])
301
+ elsif static_or_dynamic_mapping.key?(:find_with) # Dynamic
302
+ @scim_mutable_attributes << static_or_dynamic_mapping[:list]
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end
309
+
310
+ extractor.call(self.class.scim_attributes_map())
311
+ @scim_mutable_attributes.delete(:id)
312
+ end
313
+
314
+ @scim_mutable_attributes
315
+ end
316
+
317
+ # An instance level method which calls ::scim_queryable_attributes and
318
+ # caches the result in an instance variable, for symmetry with
319
+ # #scim_mutable_attributes and to permit potential future enhancements
320
+ # for how the return value of ::scim_queryable_attributes is handled.
321
+ #
322
+ def scim_queryable_attributes
323
+ @scim_queryable_attributes ||= self.class.scim_queryable_attributes()
324
+ end
325
+
326
+ # Render self as a SCIM object using ::scim_attributes_map.
327
+ #
328
+ # +location+:: The location (HTTP(S) full URI) of this resource, in the
329
+ # domain of the object including this mixin - "your" IDs,
330
+ # not the remote SCIM client's external IDs. #url_for is a
331
+ # good way to generate this.
332
+ #
333
+ def to_scim(location:)
334
+ map = self.class.scim_attributes_map()
335
+ timestamps_map = self.class.scim_timestamps_map() if self.class.respond_to?(:scim_timestamps_map)
336
+ attrs_hash = self.to_scim_backend(data_source: self, attrs_map_or_leaf_value: map)
337
+ resource = self.class.scim_resource_type().new(attrs_hash)
338
+ meta_attrs_hash = { location: location }
339
+
340
+ meta_attrs_hash[:created ] = self.send(timestamps_map[:created ])&.iso8601(0) if timestamps_map&.key?(:created)
341
+ meta_attrs_hash[:lastModified] = self.send(timestamps_map[:lastModified])&.iso8601(0) if timestamps_map&.key?(:lastModified)
342
+
343
+ resource.meta = Meta.new(meta_attrs_hash)
344
+ return resource
345
+ end
346
+
347
+ # Update self from a SCIM object using ::scim_attributes_map. This does
348
+ # NOT PERSIST ("save") 'this' instance - it just sets attribute values
349
+ # within it.
350
+ #
351
+ # If you are mixing into an ActiveRecord subclass then depending on how
352
+ # your ::scim_attributes_map updates associated objects (if any), Rails
353
+ # might make database writes to update those associations immediately.
354
+ # Given this, it is highly recommended that you wrap calls to this
355
+ # method and your subsequent save of 'self' inside a transaction.
356
+ #
357
+ # ActiveRecord::Base.transaction do
358
+ # record.from_scim!(scim_hash: some_payload)
359
+ # record.save!
360
+ # end
361
+ #
362
+ # Call ONLY for POST or PUT. For PATCH, see #from_scim_patch!.
363
+ #
364
+ # +scim_hash+:: A Hash that's the result of parsing a JSON payload
365
+ # from an inbound POST or PUT request.
366
+ #
367
+ # Returns 'self', for convenience of e.g. chaining other methods.
368
+ #
369
+ def from_scim!(scim_hash:)
370
+ scim_hash.freeze()
371
+ map = self.class.scim_attributes_map().freeze()
372
+
373
+ self.from_scim_backend!(attrs_map_or_leaf_value: map, scim_hash_or_leaf_value: scim_hash)
374
+ return self
375
+ end
376
+
377
+ # Update self from a SCIM object representing a PATCH operation. This
378
+ # does NOT PERSIST ("save") 'this' instance - it just sets attribute
379
+ # values within it.
380
+ #
381
+ # SCIM patch operations are complex. A series of operations is given,
382
+ # each asking to add, remove or replace specific attributes or, via
383
+ # filters, potentially multiple attributes if the filter matches many.
384
+ #
385
+ # Pass the PATCH payload. Then:
386
+ #
387
+ # * This instance (self) is converted to a SCIM representation via
388
+ # calling #to_scim.
389
+ #
390
+ # * The inbound operations are applied. A Scimitar::ErrorResponse may
391
+ # be thrown if the patch data looks bad - if you are calling from a
392
+ # Scimitar::ActiveRecordBackedResourcesController subclass, this will
393
+ # be handled for you and returned as an appropriate HTTP response.
394
+ # Otherwise, you'll need to rescue it yourself and e.g. make use of
395
+ # Scimitar::ApplicationController#handle_scim_error, passing the
396
+ # exception object to it, if you are a subclass of that base class.
397
+ #
398
+ # * The (possibly) updated SCIM representation of 'self' is pushed
399
+ # back into 'this' instance via #from_scim!.
400
+ #
401
+ # IMPORTANT: Please see #from_scim! for notes about associations and
402
+ # use of transactions with ActiveRecord.
403
+ #
404
+ # Call ONLY for PATCH. For POST and PUT, see #from_scim!.
405
+ #
406
+ def from_scim_patch!(patch_hash:)
407
+ patch_hash.freeze()
408
+ scim_hash = self.to_scim(location: '(unused)').as_json()
409
+
410
+ patch_hash['Operations'].each do |operation|
411
+ nature = operation['op' ]&.downcase
412
+ path_str = operation['path' ]
413
+ value = operation['value']
414
+
415
+ unless ['add', 'remove', 'replace'].include?(nature)
416
+ raise Scimitar::InvalidSyntaxError.new("Unrecognised PATCH \"op\" value of \"#{nature}\"")
417
+ end
418
+
419
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.2
420
+ #
421
+ # o If "path" is unspecified, the operation fails with HTTP status
422
+ # code 400 and a "scimType" error code of "noTarget".
423
+ #
424
+ # (...for "add" or "replace", no path means "whole object").
425
+ #
426
+ if nature == 'remove' && path_str.blank?
427
+ raise Scimitar::ErrorResponse.new(
428
+ status: 400,
429
+ scimType: 'noTarget',
430
+ detail: 'No "path" target given for "replace" operation'
431
+ )
432
+ end
433
+
434
+ # Deal with the exception case of no path, where the entire object
435
+ # is addressed. It's easier internally to treat a path as a set of
436
+ # steps towards a final Hash key (attribute) with an associated
437
+ # value to change (and filters may apply if the value is an Array).
438
+ #
439
+ extract_root = false
440
+ if path_str.blank?
441
+ extract_root = true
442
+ path_str = 'root'
443
+ scim_hash = { 'root' => scim_hash }
444
+ end
445
+
446
+ self.from_patch_backend!(
447
+ nature: nature,
448
+ path: (path_str || '').split('.'),
449
+ value: value,
450
+ altering_hash: scim_hash
451
+ )
452
+
453
+ if extract_root
454
+ scim_hash = scim_hash['root']
455
+ end
456
+ end
457
+
458
+ self.from_scim!(scim_hash: scim_hash)
459
+ return self
460
+ end
461
+
462
+ private # (...but note that we're inside "included do" within a mixin)
463
+
464
+ # A recursive method that takes a Hash mapping SCIM attributes to the
465
+ # mixing in class's attributes and via ::scim_attributes_map replaces
466
+ # symbols in the schema with the corresponding value from the user.
467
+ #
468
+ # Given a schema with symbols, this method will search through the
469
+ # object for the symbols, send those symbols to the model and replace
470
+ # the symbol with the return value.
471
+ #
472
+ # +data_source+:: The source of data. At the top level,
473
+ # this is "self" (an instance of the
474
+ # class mixing in this module).
475
+ #
476
+ # +attrs_map_or_leaf_value+:: The attribute map. At the top level,
477
+ # this is from ::scim_attributes_map.
478
+ #
479
+ def to_scim_backend(data_source:, attrs_map_or_leaf_value:)
480
+ case attrs_map_or_leaf_value
481
+ when Hash # Expected at top-level of any map, or nested within
482
+ attrs_map_or_leaf_value.each.with_object({}) do |(key, value), hash|
483
+ hash[key] = to_scim_backend(data_source: data_source, attrs_map_or_leaf_value: value)
484
+ end
485
+
486
+ when Array # Static or dynamic mapping against lists in data source
487
+ built_dynamic_list = false
488
+ mapped_array = attrs_map_or_leaf_value.map do |value|
489
+ if ! value.is_a?(Hash)
490
+ raise 'Bad attribute map: Array contains someting other than mapping Hash(es)'
491
+
492
+ elsif value.key?(:match) # Static map
493
+ static_hash = { value[:match] => value[:with] }
494
+ static_hash.merge!(to_scim_backend(data_source: data_source, attrs_map_or_leaf_value: value[:using]))
495
+ static_hash
496
+
497
+ elsif value.key?(:list) # Dynamic mapping of each complex list item
498
+ built_dynamic_list = true
499
+ list = data_source.public_send(value[:list])
500
+ list.map do |list_entry|
501
+ to_scim_backend(data_source: list_entry, attrs_map_or_leaf_value: value[:using])
502
+ end
503
+
504
+ else # Unknown type, just treat as flat values
505
+ raise 'Bad attribute map: Mapping Hash inside Array does not contain supported data'
506
+
507
+ end
508
+ end
509
+
510
+ # If a dynamic list was generated, it's sitting as a nested
511
+ # Array in the first index of the mapped result; pull it out.
512
+ #
513
+ mapped_array = mapped_array.first if built_dynamic_list
514
+ mapped_array
515
+
516
+ when Symbol # Leaf node, Symbol -> reader method to call on data source
517
+ if data_source.respond_to?(attrs_map_or_leaf_value) # A read-accessor exists?
518
+ value = data_source.public_send(attrs_map_or_leaf_value)
519
+ value = value.to_s if value.is_a?(Numeric)
520
+ value
521
+ else
522
+ nil
523
+ end
524
+
525
+ else # Leaf node, other type -> literal static value to use
526
+ attrs_map_or_leaf_value
527
+ end
528
+ end
529
+
530
+ # Given a SCIM resource representation (left) and an attribute map to
531
+ # an instance of the mixin-including class / 'self' (right), walk the
532
+ # attribute map, looking up equivalent values in the SCIM resource.
533
+ # Mutable attributes will be set from the SCIM data, or cleared if
534
+ # the SCIM data has nothing set ("PUT" semantics; splat resource data
535
+ # in full, writing all mapped attributes).
536
+ #
537
+ # * Literal map values like 'true' are for read-time uses; ignored.
538
+ # * Symbol map values are treated as read accessor method names and a
539
+ # write accessor checked for by adding "=". If this method exists,
540
+ # a value write is attempted using the SCIM resource data.
541
+ # * Static and dynamic array mappings perform as documented for
542
+ # ::scim_attributes_map.
543
+ #
544
+ # { | {
545
+ # "userName": "foo", | "id": "id",
546
+ # "name": { | "externalId": :scim_uid",
547
+ # "givenName": "Foo", | "userName": :username",
548
+ # "familyName": "Bar" | "name": {
549
+ # }, | "givenName": :first_name",
550
+ # "active": true, | "familyName": :last_name"
551
+ # "emails": [ | },
552
+ # { | "emails": [
553
+ # "type": "work", <------\ | {
554
+ # "primary": true, \------+--- "match": "type",
555
+ # "value": "foo.bar@test.com" | "with": "work",
556
+ # } | "using": {
557
+ # ], | "value": :work_email_address",
558
+ # "phoneNumbers": [ | "primary": true
559
+ # { | }
560
+ # "type": "work", | }
561
+ # "primary": false, | ],
562
+ # "value": "+642201234567" | groups: [
563
+ # } | {
564
+ # ], | list: :groups,
565
+ # "id": "42", | using: {
566
+ # "externalId": "AA02984", | value: :id,
567
+ # "meta": { | display: :full_name
568
+ # "location": "https://test.com/mock_users/42", | }
569
+ # "resourceType": "User" | }
570
+ # }, | ],
571
+ # "schemas": [ | "active": :is_active"
572
+ # "urn:ietf:params:scim:schemas:core:2.0:User" | }
573
+ # ] |
574
+ # } |
575
+ #
576
+ # Named parameters:
577
+ #
578
+ # +attrs_map_or_leaf_value+:: Attribute map; recursive calls just
579
+ # pass in the fragment for recursion, so
580
+ # at the deepest level, this ends up
581
+ # being a leaf node which may have a
582
+ # Symbol method name, used to look for a
583
+ # write accessor; or a read-only literal,
584
+ # which is ignored (right hand side of
585
+ # the ASCII art diagram).
586
+ #
587
+ # +scim_hash_or_leaf_value+:: Similar to +attrs_map_or_leaf_value+
588
+ # but tracks the SCIM schema data being
589
+ # read as input source material (left
590
+ # hand side of the ASCII art diagram).
591
+ #
592
+ # +path+:: Array of SCIM attribute names giving a
593
+ # path into the SCIM schema where
594
+ # iteration has reached. Used to find the
595
+ # schema attribute definiton and check
596
+ # mutability before writing.
597
+ #
598
+ def from_scim_backend!(
599
+ attrs_map_or_leaf_value:,
600
+ scim_hash_or_leaf_value:,
601
+ path: []
602
+ )
603
+ attrs_map_or_leaf_value = attrs_map_or_leaf_value.with_indifferent_access() if attrs_map_or_leaf_value.instance_of?(Hash)
604
+
605
+ # We get the schema via this instance's class's resource type, even
606
+ # if we end up in collections of other types - because it's *this*
607
+ # schema at the top level that defines the attributes of interest
608
+ # within any collections, not SCIM schema - if any - for the items
609
+ # within the collection (a User's "groups" per-array-entry schema
610
+ # is quite different from the Group schema).
611
+ #
612
+ resource_class = self.class.scim_resource_type()
613
+
614
+ case attrs_map_or_leaf_value
615
+ when Hash # Nested attribute-value pairs
616
+ attrs_map_or_leaf_value.each do | scim_attribute, sub_attrs_map_or_leaf_value |
617
+ next if scim_attribute&.to_s&.downcase == 'id' && path.empty?
618
+
619
+ sub_scim_hash_or_leaf_value = scim_hash_or_leaf_value&.dig(scim_attribute.to_s)
620
+
621
+ self.from_scim_backend!(
622
+ attrs_map_or_leaf_value: sub_attrs_map_or_leaf_value,
623
+ scim_hash_or_leaf_value: sub_scim_hash_or_leaf_value, # May be 'nil'
624
+ path: path + [scim_attribute]
625
+ )
626
+ end
627
+
628
+ when Array # Static or dynamic maps
629
+ attrs_map_or_leaf_value.each_with_index do | mapped_array_entry |
630
+ next unless mapped_array_entry.is_a?(Hash)
631
+
632
+ if mapped_array_entry.key?(:match) # Static map
633
+ attr_to_match = mapped_array_entry[:match].to_s
634
+ value_to_match = mapped_array_entry[:with]
635
+ sub_attrs_map = mapped_array_entry[:using]
636
+
637
+ # Search for the array entry in the SCIM object that
638
+ # matches the thing we're looking for via :match & :with.
639
+ #
640
+ found_source_list_entry = scim_hash_or_leaf_value&.find do | scim_array_entry |
641
+ scim_array_entry[attr_to_match] == value_to_match
642
+ end
643
+
644
+ self.from_scim_backend!(
645
+ attrs_map_or_leaf_value: sub_attrs_map,
646
+ scim_hash_or_leaf_value: found_source_list_entry, # May be 'nil'
647
+ path: path
648
+ )
649
+
650
+ elsif mapped_array_entry.key?(:list) # Dynamic mapping of each complex list item
651
+ attribute = resource_class.find_attribute(*path)
652
+ method = "#{mapped_array_entry[:list]}="
653
+
654
+ if (attribute&.mutability == 'readWrite' || attribute&.mutability == 'writeOnly') && self.respond_to?(method)
655
+ find_with_proc = mapped_array_entry[:find_with]
656
+
657
+ unless find_with_proc.nil?
658
+ mapped_list = (scim_hash_or_leaf_value || []).map do | source_list_entry |
659
+ find_with_proc.call(source_list_entry)
660
+ end
661
+
662
+ mapped_list.compact!
663
+
664
+ self.public_send(method, mapped_list)
665
+ end
666
+ end
667
+ end # "elsif mapped_array_entry.key?(:list)"
668
+ end # "map_entry&.each do | mapped_array_entry |"
669
+
670
+ when Symbol # Setter/getter method at leaf position in attribute map
671
+ if path == ['externalId'] # Special case held only in schema base class
672
+ mutable = true
673
+ else
674
+ attribute = resource_class.find_attribute(*path)
675
+ mutable = attribute&.mutability == 'readWrite' || attribute&.mutability == 'writeOnly'
676
+ end
677
+
678
+ if mutable
679
+ method = "#{attrs_map_or_leaf_value}="
680
+ self.public_send(method, scim_hash_or_leaf_value) if self.respond_to?(method)
681
+ end
682
+
683
+ # else - fixed value of interest in #to_scim only.
684
+
685
+ end # "case scim_hash_or_leaf_value"
686
+ end # "def from_scim_backend!..."
687
+
688
+ # Recursive back-end for #from_scim_patch! which traverses paths down
689
+ # into one or - if multiple-match filters are encountered - multiple
690
+ # attributes and performs updates on a SCIM Hash representation of
691
+ # 'self'. Throws Scimitar::ErrorResponse (or a subclass thereof) upon
692
+ # encountering any errors.
693
+ #
694
+ # Named parameters:
695
+ #
696
+ # +nature+:: The PATCH operation nature - MUST be a lower case
697
+ # String of 'add', 'remove' or 'replace' ONLY.
698
+ #
699
+ # +path+:: Operation path, as a series of array entries (so
700
+ # an inbound dot-separated path string would first
701
+ # be split into an array by the caller). For
702
+ # internal recursive calls, this will
703
+ #
704
+ # +value+:: The value to apply at the attribute(s) identified
705
+ # by +path+. Ignored for 'remove' operations.
706
+ #
707
+ # +altering_hash+:: The Hash to operate on at the current +path+. For
708
+ # recursive calls, this will be some way down into
709
+ # the SCIM representation of 'self'.
710
+ #
711
+ # Note that SCIM PATCH operations permit *no* path for 'replace' and
712
+ # 'add' operations, meaning "apply to whole object". To avoid special
713
+ # case code in the back-end, callers should in such cases add their
714
+ # own wrapping Hash with a single key addressing the SCIM object of
715
+ # interest and supply this key as the sole array entry in +path+.
716
+ #
717
+ def from_patch_backend!(nature:, path:, value:, altering_hash:)
718
+
719
+ # These all throw exceptions if data is not as expected / required,
720
+ # any of which are rescued below.
721
+ #
722
+ if path.count == 1
723
+ from_patch_backend_apply!(
724
+ nature: nature,
725
+ path: path,
726
+ value: value,
727
+ altering_hash: altering_hash
728
+ )
729
+ else
730
+ from_patch_backend_traverse!(
731
+ nature: nature,
732
+ path: path,
733
+ value: value,
734
+ altering_hash: altering_hash
735
+ )
736
+ end
737
+
738
+ # Treat all exceptions as a malformed or unsupported PATCH.
739
+ #
740
+ rescue => _exception # You can use _exception if debugging
741
+ raise Scimitar::InvalidSyntaxError.new('PATCH describes unrecognised attributes and/or unsupported filters')
742
+ end
743
+
744
+ # Called by #from_patch_backend! when dealing with path elements that
745
+ # is not yet the final (leaf) entry. Deals with filters etc. and
746
+ # traverses down one path level, making one or more recursive calls
747
+ # back up into #from_patch_backend!
748
+ #
749
+ # Parameters are as for #from_patch_backend!, where +path+ is assumed
750
+ # to have at least two entries.
751
+ #
752
+ # Happily throws exceptions if data is not as expected / required.
753
+ #
754
+ def from_patch_backend_traverse!(nature:, path:, value:, altering_hash:)
755
+ path_component, filter = extract_filter_from(path_component: path.first)
756
+
757
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.1
758
+ #
759
+ # o If the target location specifies an attribute that does not exist
760
+ # (has no value), the attribute is added with the new value.
761
+ #
762
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.3
763
+ #
764
+ # o If the target location path specifies an attribute that does not
765
+ # exist, the service provider SHALL treat the operation as an "add".
766
+ #
767
+ # Harmless in this context for 'remove'.
768
+ #
769
+ altering_hash[path_component] ||= {}
770
+
771
+ # Unless the PATCH is bad, inner data is an Array or Hash always as
772
+ # by definition this method is only called at path positions above
773
+ # the leaf (target attribute-to-modify) node.
774
+ #
775
+ inner_data = altering_hash[path_component]
776
+
777
+ found_data_for_recursion = if filter
778
+ matched_hashes = []
779
+
780
+ all_matching_filter(filter: filter, within_array: inner_data) do | matched_hash, _matched_index |
781
+ matched_hashes << matched_hash
782
+ end
783
+
784
+ # Same reason as section 3.5.2.1 / 3.5.2.3 RFC quotes above.
785
+ #
786
+ if nature != 'remove' && matched_hashes.empty?
787
+ new_hash = {}
788
+ altering_hash[path_component] = [new_hash]
789
+ matched_hashes = [new_hash]
790
+ end
791
+
792
+ matched_hashes
793
+ else
794
+ [ inner_data ]
795
+ end
796
+
797
+ found_data_for_recursion.each do | found_data |
798
+ self.from_patch_backend!(
799
+ nature: nature,
800
+ path: path[1..-1],
801
+ value: value,
802
+ altering_hash: found_data
803
+ )
804
+ end
805
+ end
806
+
807
+ # Called by #from_patch_backend! when dealing with path the last path
808
+ # element; applies the operation nature and value. Deals with filters
809
+ # etc. in this final path position (filters only being relevant for
810
+ # 'remove' or 'replace' operations).
811
+ #
812
+ # Parameters are as for #from_patch_backend!, where +path+ is assumed
813
+ # to have exactly one entry only.
814
+ #
815
+ # Happily throws exceptions if data is not as expected / required.
816
+ #
817
+ def from_patch_backend_apply!(nature:, path:, value:, altering_hash:)
818
+ path_component, filter = extract_filter_from(path_component: path.first)
819
+ current_data_at_path = altering_hash[path_component]
820
+
821
+ if current_data_at_path.nil?
822
+ case nature
823
+ when 'add', 'replace'
824
+ if filter.present? # Implies we expected to replace/add to an item matched inside an array
825
+ altering_hash[path_component] = [value]
826
+ else
827
+ altering_hash[path_component] = value
828
+ end
829
+ when 'remove'
830
+ # Nothing to do - no data here anyway
831
+ end
832
+
833
+ # Path filters are not described for 'add' and assumed to have no
834
+ # meaning - https://tools.ietf.org/html/rfc7644#section-3.5.2.1
835
+ #
836
+ elsif filter.present? && nature != 'add'
837
+ compact_after = false
838
+ found_matches = false
839
+
840
+ all_matching_filter(filter: filter, within_array: current_data_at_path) do | matched_hash, matched_index |
841
+ found_matches = true
842
+
843
+ case nature
844
+ when 'remove'
845
+ current_data_at_path[matched_index] = nil
846
+ compact_after = true
847
+ when 'replace'
848
+ matched_hash.reject! { true }
849
+ matched_hash.merge!(value)
850
+ end
851
+ end
852
+
853
+ current_data_at_path.compact! if compact_after
854
+
855
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.1
856
+ #
857
+ # o If the target location specifies an attribute that does not exist
858
+ # (has no value), the attribute is added with the new value.
859
+ #
860
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.3
861
+ #
862
+ # o If the target location path specifies an attribute that does not
863
+ # exist, the service provider SHALL treat the operation as an "add".
864
+ #
865
+ current_data_at_path << value unless found_matches || nature == 'remove'
866
+
867
+ else
868
+ case nature
869
+ when 'add'
870
+ if current_data_at_path.is_a?(Array)
871
+ altering_hash[path_component] += value
872
+ elsif current_data_at_path.is_a?(Hash)
873
+
874
+ # Need to dive down inside a Hash value for additions; a
875
+ # deep merge isn't enough. Take this Group example where
876
+ # nature is "add" and value is:
877
+ #
878
+ # "members":[
879
+ # {
880
+ # "value":"<user-id>"
881
+ # }
882
+ # ]
883
+ #
884
+ # ...in that case, a deep merge would *replace* the array
885
+ # at key 'members' with the above, rather than adding.
886
+ #
887
+ value.keys.each do | key |
888
+ from_patch_backend!(
889
+ nature: nature,
890
+ path: path + [key],
891
+ value: value[key],
892
+ altering_hash: altering_hash
893
+ )
894
+ end
895
+ else
896
+ altering_hash[path_component] = value
897
+ end
898
+ when 'replace'
899
+ altering_hash[path_component] = value
900
+ when 'remove'
901
+ altering_hash.delete(path_component)
902
+ end
903
+ end
904
+ end
905
+
906
+ # Given a path element from SCIM, splits this into the attribute and
907
+ # filter parts. Returns a tuple of [attribute, filter] where +filter+
908
+ # will be +nil+ if no filter string was given.
909
+ #
910
+ # Named parameters:
911
+ #
912
+ # +path_component+:: Path component to examine (a String), e.g.
913
+ # 'userName' or 'emails[type eq "work"]'.
914
+ #
915
+ # Happily throws exceptions if data is not as expected / required.
916
+ #
917
+ def extract_filter_from(path_component:)
918
+ filter = nil
919
+
920
+ if path_component.include?('[')
921
+ composition = path_component.split(/[\[\]]/) # "attribute_name[filter_string]" -> ["attribute_name", "filter_string"]
922
+ path_component = composition.first
923
+ filter = composition.last
924
+ end
925
+
926
+ [path_component, filter]
927
+ end
928
+
929
+ # Given a SCIM filter string and array of Hashes from a SCIM object,
930
+ # search for matches within the array and invoke a given block for
931
+ # each.
932
+ #
933
+ # Obtain filter strings by calling #extract_filter_from.
934
+ #
935
+ # TODO: Support more complex matchers than 'attr eq "value"'.
936
+ #
937
+ # Named parameters:
938
+ #
939
+ # +filter+:: Filter string, e.g. 'type eq "work"'.
940
+ # +within_array+:: Array to search.
941
+ #
942
+ # You must pass a block. It is invoked with each matching array entry
943
+ # (a Hash) and the index into +within_array+ at which this was found.
944
+ #
945
+ # Happily throws exceptions if data is not as expected / required.
946
+ #
947
+ def all_matching_filter(filter:, within_array:, &block)
948
+ filter_components = filter.split(' ')
949
+ raise "Unsupported matcher #{filter.inspect}" unless filter_components.size == 3 && filter_components[1].downcase == 'eq'
950
+
951
+ attribute = filter_components[0]
952
+ value = filter_components[2]
953
+ value = value[1..-2] if value.start_with?('"') && value.end_with?('"')
954
+
955
+ within_array.each.with_index do | hash, index |
956
+ matched = hash.key?(attribute) && hash[attribute]&.to_s == value&.to_s
957
+ yield(hash, index) if matched
958
+ end
959
+ end
960
+
961
+ end # "included do"
962
+ end # "module Mixin"
963
+ end # "module Resources"
964
+ end # "module Scimitar"