scimitar 2.6.0 → 2.6.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13a1cc0fad873f6524c65d4d24c6556d22b75abcd13ecdbb422d074a8ce94afc
4
- data.tar.gz: 9244ab9bcf9f32b3a8e9b6b16653c92f0e7a228320f7c3fefe751e7bee290372
3
+ metadata.gz: e763d8f162583b44983db37fe3d8f0e8c9fa4f39f841813e9c3df247ffcd8cf9
4
+ data.tar.gz: cfc7680b8f12d928a8975882109027f0c72f13884b438f04b90dcadc8d8a582c
5
5
  SHA512:
6
- metadata.gz: bc24687beb3d360e5b548b617b699a9b0a6384f3b1477336b18a95fc4647d1364727dd4bf66984a05efc7d06210689a7ad33f662c82ff42b81abae4e1aa26584
7
- data.tar.gz: 9291068aae0c3eb047eedaaf9d2d7587aa49bfd7703b8d4db0235a15a43b2d8d0e3ddeaf5784f432f3f0df0edb6bc8099e85d1eb5937ca262399634d3fc51965
6
+ metadata.gz: 4a4d50b204fa662b9661d258686c22296c07bb88fd5cd2cbebfdbfef3455ca1385877ac66af5c3d6d22de6bfb6b43ae12ce74e21a9b5b279fa640b1042002a0b
7
+ data.tar.gz: 795e29ab7736f0e40fd3bc39e055146935427a13a4a2e10cbbee5b9ddd6980c164cc9d187131d43895a602b0c44a2d950e4cb3c87af3ed7f9959bd1f8dfbb6fa
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 RIPA Global
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,671 @@
1
+ # Scimitar
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/scimitar.svg)](https://badge.fury.io/rb/scimitar)
4
+ [![Build Status](https://app.travis-ci.com/RIPAGlobal/scimitar.svg?branch=main)](https://app.travis-ci.com/RIPAGlobal/scimitar)
5
+ [![License](https://img.shields.io/badge/license-mit-blue.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ A SCIM v2 API endpoint implementation for Ruby On Rails.
8
+
9
+ For a list of changes and information on major version upgrades, please see `CHANGELOG.md`.
10
+
11
+
12
+
13
+ ## Overview
14
+
15
+ System for Cross-domain Identity Management (SCIM) is a protocol that helps systems synchronise user data between different business systems. A _service provider_ hosts a SCIM API endpoint implementation and the Scimitar gem is used to help quickly build this implementation. One or more _enterprise subscribers_ use these APIs to let that service know about changes in the enterprise's user (employee) list.
16
+
17
+ In the context of the names used by the SCIM standard, the service that is provided is some kind of software-as-a-service solution that the enterprise subscriber uses to assist with their day to day business. The enterprise maintains its user (employee) list via whatever means it wants, but includes SCIM support so that any third party services it uses can be kept up to date with adds, removals or changes to employee data.
18
+
19
+ * [Overview](https://en.wikipedia.org/wiki/System_for_Cross-domain_Identity_Management) at Wikipedia
20
+ * [More detailed introduction](http://www.simplecloud.info) at SimpleCloud
21
+ * SCIM v2 RFC [7642](https://tools.ietf.org/html/rfc7642): Concepts
22
+ * SCIM v2 RFC [7643](https://tools.ietf.org/html/rfc7643): Core schema
23
+ * SCIM v2 RFC [7644](https://tools.ietf.org/html/rfc7644): Protocol
24
+
25
+
26
+
27
+ ## Installation
28
+
29
+ Install using:
30
+
31
+ ```shell
32
+ gem install scimitar
33
+ ```
34
+
35
+ In your Gemfile:
36
+
37
+ ```ruby
38
+ gem 'scimitar', '~> 2.0'
39
+ ```
40
+
41
+ Scimitar uses [semantic versioning](https://semver.org) so you can be confident that patch and minor version updates for features, bug fixes and/or security patches will not break your application.
42
+
43
+
44
+
45
+ ## Heritage
46
+
47
+ Scimitar borrows heavily - to the point of cut-and-paste - from:
48
+
49
+ * [ScimEngine](https://github.com/Cisco-AMP/scim_engine) for the Rails controllers and resource-agnostic subclassing approach that makes supporting User and/or Group, along with custom resource types if you need them, quite easy.
50
+ * [ScimRails](https://github.com/lessonly/scim_rails) for the bearer token support, 'index' actions and filter support.
51
+ * [SCIM Query Filter Parser](https://github.com/ingydotnet/scim-query-filter-parser-rb) for advanced filter handling.
52
+
53
+ All three are provided under the MIT license. Scimitar is too.
54
+
55
+
56
+
57
+ ## Usage
58
+
59
+ Scimitar is best used with Rails and ActiveRecord, but it can be used with other persistence back-ends too - you just have to do more of the work in controllers using Scimitar's lower level controller subclasses, rather than relying on Scimitar's higher level ActiveRecord abstractions.
60
+
61
+ ### Authentication
62
+
63
+ Noting the _Security_ section later - to set up an authentication method, create a `config/initializers/scimitar.rb` in your Rails application and define a token-based authenticator and/or a username-password authenticator in the [engine configuration section documented in the sample file](https://github.com/RIPAGlobal/scimitar/blob/main/config/initializers/scimitar.rb). For example:
64
+
65
+ ```ruby
66
+ Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
67
+ token_authenticator: Proc.new do | token, options |
68
+
69
+ # This is where you'd write the code to validate `token` - the means by
70
+ # which your application issues tokens to SCIM clients, or validates them,
71
+ # is outside the scope of the gem; the required mechanisms vary by client.
72
+ # More on this can be found in the 'Security' section later.
73
+ #
74
+ SomeLibraryModule.validate_access_token(token)
75
+
76
+ end
77
+ })
78
+ ```
79
+
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
+
82
+ **Important:** Under Rails 7 or later, you may need to wrap any Scimitar configuration with `Rails.application.config.to_prepare do...` to avoid `NameError: uninitialized constant...` exceptions arising due to autoloader problems:
83
+
84
+ ```ruby
85
+ Rails.application.config.to_prepare do
86
+ Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
87
+ # ...
88
+ end
89
+ end
90
+ ```
91
+
92
+ ### Routes
93
+
94
+ For each resource you support, add these lines to your `routes.rb`:
95
+
96
+ ```ruby
97
+ namespace :scim_v2 do
98
+ mount Scimitar::Engine, at: '/'
99
+
100
+ get 'Users', to: 'users#index'
101
+ get 'Users/:id', to: 'users#show'
102
+ post 'Users', to: 'users#create'
103
+ put 'Users/:id', to: 'users#replace'
104
+ patch 'Users/:id', to: 'users#update'
105
+ delete 'Users/:id', to: 'users#destroy'
106
+ end
107
+ ```
108
+
109
+ All routes then will be available at `https://.../scim_v2/...` via controllers you write in `app/controllers/scim_v2/...`, e.g. `app/controllers/scim_v2/users_controller.rb`. More on controllers later.
110
+
111
+ #### URL helpers
112
+
113
+ Internally Scimitar always invokes URL helpers in the controller layer. I.e. any variable path parameters will be resolved by Rails automatically. If you need more control over the way URLs are generated you can override any URL helper by redefining it in the application controller mixin. See the [`application_controller_mixin` engine configuration option](https://github.com/RIPAGlobal/scimitar/blob/main/config/initializers/scimitar.rb).
114
+
115
+ ### Data models
116
+
117
+ Scimitar assumes that each SCIM resource maps to a single corresponding class in your system. This might be an abstraction over more complex underpinings, but either way, a 1:1 relationship is expected. For example, a SCIM User might map to a User ActiveRecord model in your Rails application, while a SCIM Group might map to some custom class called Team which operates on a more complex set of data "under the hood".
118
+
119
+ Before writing any controllers, it's a good idea to examine the SCIM specification and figure out how you intend to map SCIM attributes in any resources of interest, to your local data. A [mixin is provided](https://github.com/RIPAGlobal/scimitar/blob/main/app/models/scimitar/resources/mixin.rb) which you can include in any plain old Ruby class (including, but not limited to ActiveRecord model classes) - a more readable form of the comments in this file [is in the RDoc output](https://www.rubydoc.info/gems/scimitar/Scimitar/Resources/Mixin).
120
+
121
+ The functionality exposed by the mixin is relatively complicated because the range of operations that the SCIM API supports is quite extensive. Rather than duplicate all the information here, please see the extensive comments in the mixin linked above for more information. There are examples in the [test suite's Rails models](https://github.com/RIPAGlobal/scimitar/tree/main/spec/apps/dummy/app/models), or for another example:
122
+
123
+ ```ruby
124
+ class User < ActiveRecord::Base
125
+
126
+ # The attributes in the SCIM section below include a reference to this
127
+ # hypothesised 'groups' HABTM relationship. All of the other "leaf node"
128
+ # Symbols - e.g. ":first_name", ":last_name" - are expected to be defined as
129
+ # accessors e.g. via ActiveRecord and your related database table columns,
130
+ # "attr_accessor" declarations, or bespoke "def foo"/"def foo=(value)". If a
131
+ # write accessor is not present, the attribute will not be writable via SCIM.
132
+ #
133
+ has_and_belongs_to_many :groups
134
+
135
+ # ===========================================================================
136
+ # SCIM MIXIN AND REQUIRED METHODS
137
+ # ===========================================================================
138
+ #
139
+ # All class methods shown below are mandatory unless otherwise commented.
140
+
141
+ def self.scim_resource_type
142
+ return Scimitar::Resources::User
143
+ end
144
+
145
+ def self.scim_attributes_map
146
+ return {
147
+ id: :id,
148
+ externalId: :scim_uid,
149
+ userName: :username,
150
+ name: {
151
+ givenName: :first_name,
152
+ familyName: :last_name
153
+ },
154
+ emails: [
155
+ {
156
+ match: 'type',
157
+ with: 'work',
158
+ using: {
159
+ value: :work_email_address,
160
+ primary: true
161
+ }
162
+ },
163
+ {
164
+ match: 'type',
165
+ with: 'home',
166
+ using: {
167
+ value: :home_email_address,
168
+ primary: false
169
+ }
170
+ },
171
+ ],
172
+ phoneNumbers: [
173
+ {
174
+ match: 'type',
175
+ with: 'work',
176
+ using: {
177
+ value: :work_phone_number,
178
+ primary: false
179
+ }
180
+ },
181
+ ],
182
+
183
+ # NB The 'groups' collection in a SCIM User resource is read-only, so
184
+ # we provide no ":find_with" key for looking up records for writing
185
+ # updates to the associated collection.
186
+ #
187
+ groups: [
188
+ {
189
+ list: :groups,
190
+ using: {
191
+ value: :id,
192
+ display: :display_name
193
+ }
194
+ }
195
+ ],
196
+ active: :is_active
197
+ }
198
+ end
199
+
200
+ def self.scim_mutable_attributes
201
+ return nil
202
+ end
203
+
204
+ # The attributes in this example include a reference to the same hypothesised
205
+ # 'Group' model as in the HABTM relationship above. In this case, in order to
206
+ # filter by "groups" or "groups.value", the 'column' entry must reference the
207
+ # Group model's ID column as an AREL attribute as shown below, and the SCIM
208
+ # controller's #storage_scope implementation must also introduce a #join with
209
+ # ':groups' - see the "Queries & Optimisations" section below.
210
+ #
211
+ def self.scim_queryable_attributes
212
+ return {
213
+ givenName: { column: :first_name },
214
+ familyName: { column: :last_name },
215
+ emails: { column: :work_email_address },
216
+ groups: { column: Group.arel_table[:id] },
217
+ "groups.value" => { column: Group.arel_table[:id] },
218
+ }
219
+ end
220
+
221
+ # Optional but recommended.
222
+ #
223
+ def self.scim_timestamps_map
224
+ {
225
+ created: :created_at,
226
+ lastModified: :updated_at
227
+ }
228
+ end
229
+
230
+ # If you omit any mandatory declarations, you'll get an exception raised by
231
+ # this inclusion which tells you which method(s) need(s) to be added.
232
+ #
233
+ include Scimitar::Resources::Mixin
234
+ end
235
+ ```
236
+
237
+ ### Controllers
238
+
239
+ #### ActiveRecord
240
+
241
+ If you use ActiveRecord, your controllers can potentially be extremely simple by subclassing [`Scimitar::ActiveRecordBackedResourcesController`](https://www.rubydoc.info/gems/scimitar/Scimitar/ActiveRecordBackedResourcesController) - at a minimum:
242
+
243
+ ```ruby
244
+ module Scim
245
+ class UsersController < Scimitar::ActiveRecordBackedResourcesController
246
+
247
+ skip_before_action :verify_authenticity_token
248
+
249
+ protected
250
+
251
+ def storage_class
252
+ User
253
+ end
254
+
255
+ def storage_scope
256
+ User.all # Or e.g. "User.where(is_deleted: false)" - whatever base scope you require
257
+ end
258
+
259
+ end
260
+ end
261
+ ```
262
+
263
+ All data-layer actions are taken via `#find` or `#save!`, with exceptions such as `ActiveRecord::RecordNotFound`, `ActiveRecord::RecordInvalid` or generalised SCIM exceptions handled by various superclasses. For a real Rails example of this, see the [test suite's controllers](https://github.com/RIPAGlobal/scimitar/tree/main/spec/apps/dummy/app/controllers) which are invoked via its [routing declarations](https://github.com/RIPAGlobal/scimitar/blob/main/spec/apps/dummy/config/routes.rb).
264
+
265
+ #### Queries & Optimisations
266
+
267
+ The scope can be optimised to eager load the data exposed by the SCIM interface, i.e.:
268
+
269
+ ```ruby
270
+ def storage_scope
271
+ User.eager_load(:groups)
272
+ end
273
+ ```
274
+
275
+ In cases where you have references to related columns in your `scim_queryable_attributes`, your `storage_scope` must join the relation:
276
+
277
+ ```ruby
278
+ def storage_scope
279
+ User.left_join(:groups)
280
+ end
281
+ ```
282
+
283
+ #### Other source types
284
+
285
+ If you do _not_ use ActiveRecord to store data, or if you have very esoteric read-write requirements, you can subclass [`Scimigar::ResourcesController`](https://www.rubydoc.info/gems/scimitar/Scimitar/ResourcesController) in a manner similar to this:
286
+
287
+ ```ruby
288
+ class UsersController < Scimitar::ResourcesController
289
+
290
+ # SCIM clients don't use Rails CSRF tokens.
291
+ #
292
+ skip_before_action :verify_authenticity_token
293
+
294
+ # If you have any filters you need to run BEFORE authentication done in
295
+ # the superclass (typically set up in config/initializers/scimitar.rb),
296
+ # then use "prepend_before_filter to declare these - else Scimitar's
297
+ # own authorisation before-action filter would always run first.
298
+
299
+ def index
300
+ # There's a degree of heavy lifting for arbitrary storage engines.
301
+ query = if params[:filter].present?
302
+ attribute_map = User.new.scim_queryable_attributes() # Note use of *instance* method
303
+ parser = Scimitar::Lists::QueryParser.new(attribute_map)
304
+
305
+ parser.parse(params[:filter])
306
+ # Then use 'parser' to read e.g. #tree or #rpn and turn this into a
307
+ # query object for your storage engine. With ActiveRecord, you could
308
+ # just do: parser.to_activerecord_query(base_scope)
309
+ else
310
+ # Return a query object for 'all results' (e.g. User.all).
311
+ end
312
+
313
+ # Assuming the 'query' object above had ActiveRecord-like semantics,
314
+ # you'd create a Scimitar::Lists::Count object with total count filled in
315
+ # via #scim_pagination_info and obtain a page of results with something
316
+ # like the code shown below.
317
+ pagination_info = scim_pagination_info(query.count())
318
+ page_of_results = query.offset(pagination_info.offset).limit(pagination_info.limit).to_a
319
+
320
+ super(pagination_info, page_of_results) do | record |
321
+ # Return each instance as a SCIM object, e.g. via Scimitar::Resources::Mixin#to_scim
322
+ record.to_scim(location: url_for(action: :show, id: record.id))
323
+ end
324
+ end
325
+
326
+ def show
327
+ super do |user_id|
328
+ user = find_user(user_id)
329
+ # Evaluate to the record as a SCIM object, e.g. via Scimitar::Resources::Mixin#to_scim
330
+ user.to_scim(location: url_for(action: :show, id: user_id))
331
+ end
332
+ end
333
+
334
+ def create
335
+ super do |scim_resource|
336
+ # Create an instance based on the Scimitar::Resources::User in
337
+ # "scim_resource" (or whatever your ::storage_class() defines via its
338
+ # ::scim_resource_type class method).
339
+ record = self.storage_class().new
340
+ record.from_scim!(scim_hash: scim_resource.as_json())
341
+ self.save!(record)
342
+ # Evaluate to the record as a SCIM object (or do that via "self.save!")
343
+ user.to_scim(location: url_for(action: :show, id: record.id))
344
+ end
345
+ end
346
+
347
+ def replace
348
+ super do |record_id, scim_resource|
349
+ # Fully update an instance based on the Scimitar::Resources::User in
350
+ # "scim_resource" (or whatever your ::storage_class() defines via its
351
+ # ::scim_resource_type class method). For example:
352
+ record = self.find_record(record_id)
353
+ record.from_scim!(scim_hash: scim_resource.as_json())
354
+ self.save!(record)
355
+ # Evaluate to the record as a SCIM object (or do that via "self.save!")
356
+ user.to_scim(location: url_for(action: :show, id: record_id))
357
+ end
358
+ end
359
+
360
+ def update
361
+ super do |record_id, patch_hash|
362
+ # Partially update an instance based on the PATCH payload *Hash* given
363
+ # in "patch_hash" (note that unlike the "scim_resource" parameter given
364
+ # to blocks in #create or #replace, this is *not* a high-level object).
365
+ record = self.find_record(record_id)
366
+ record.from_scim_patch!(patch_hash: patch_hash)
367
+ self.save!(record)
368
+ # Evaluate to the record as a SCIM object (or do that via "self.save!")
369
+ user.to_scim(location: url_for(action: :show, id: record_id))
370
+ end
371
+ end
372
+
373
+ def destroy
374
+ super do |user_id|
375
+ user = find_user(user_id)
376
+ user.delete
377
+ end
378
+ end
379
+
380
+ protected
381
+
382
+ # The class including Scimitar::Resources::Mixin which declares mappings
383
+ # to the entity you return in #resource_type.
384
+ #
385
+ def storage_class
386
+ User
387
+ end
388
+
389
+ # Find your user. The +id+ parameter is one of YOUR identifiers, which
390
+ # are returned in "id" fields in JSON responses via SCIM schema. If the
391
+ # remote caller (client) doesn't want to remember your IDs and hold a
392
+ # mapping to their IDs, then they do an index with filter on their own
393
+ # "externalId" value and retrieve your "id" from that response.
394
+ #
395
+ def find_user(id)
396
+ # Find records by your ID here.
397
+ end
398
+
399
+ # Persist 'user' - for example, if we *were* using ActiveRecord...
400
+ #
401
+ def save!(user)
402
+ user.save!
403
+ rescue ActiveRecord::RecordInvalid => exception
404
+ raise Scimitar::ResourceInvalidError.new(record.errors.full_messages.join('; '))
405
+ end
406
+
407
+ end
408
+
409
+ ```
410
+
411
+ Note that the [`Scimitar::ApplicationController` parent class](https://www.rubydoc.info/gems/scimitar/Scimitar/ApplicationController) of `Scimitar::ResourcesController` has a few methods to help with handling exceptions and rendering them as SCIM responses; for example, if a resource were not found by ID, you might wish to use [`Scimitar::ApplicationController#handle_resource_not_found`](https://github.com/RIPAGlobal/scimitar/blob/v1.0.3/app/controllers/scimitar/application_controller.rb#L22).
412
+
413
+ ### Extension schema
414
+
415
+ You can extend schema with custom data by defining an extension class and calling `::extend_schema` on the SCIM resource class to which the extension applies. These extension classes:
416
+
417
+ * Must subclass `Scimitar::Schema::Base`
418
+ * Must call `super` in `def initialize`, providing data as shown in the example below
419
+ * Must define class methods for `::id` and `::scim_attributes`
420
+
421
+ 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:
422
+
423
+ ```ruby
424
+ class UserEnterpriseExtension < Scimitar::Schema::Base
425
+ def initialize(options = {})
426
+ super(
427
+ name: 'ExtendedUser',
428
+ description: 'Enterprise extension for a User',
429
+ id: self.class.id,
430
+ scim_attributes: self.class.scim_attributes
431
+ )
432
+ end
433
+
434
+ def self.id
435
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'
436
+ end
437
+
438
+ def self.scim_attributes
439
+ [
440
+ Scimitar::Schema::Attribute.new(name: 'organization', type: 'string'),
441
+ Scimitar::Schema::Attribute.new(name: 'department', type: 'string')
442
+ ]
443
+ end
444
+ end
445
+ ```
446
+
447
+ ...with the `super` call providing your choice of `name` and `description`, but also always providing `id` and `scim_attributes` as shown above. The class name chosen here is just an example and the class can be put inside any level of wrapping namespaces you choose - it's *your* class that can be named however you like. The extension class is then applied to the SCIM User resource _globally in your application_ by calling:
448
+
449
+ ```ruby
450
+ Scimitar::Resources::User.extend_schema(UserEnterpriseExtension)
451
+ ```
452
+
453
+ This is often done in `config/initializers/scimitar.rb` to help make it very clear that extensions are globally available and remove the risk of SCIM resources somehow being referenced before schema extensions have been applied.
454
+
455
+ In `def self.scim_attributes_map` in the underlying data model, add any new fields - `organization` and `department` in this example - to map them to whatever the equivalent data model attributes are, just as you would do with any other resource fields. These are declared without any special nesting - for example:
456
+
457
+ ```ruby
458
+ def self.scim_attributes_map
459
+ return {
460
+ id: :id,
461
+ externalId: :scim_uid,
462
+ userName: :username,
463
+ # ...etc...
464
+ organization: :company,
465
+ department: :team
466
+ }
467
+ end
468
+ ```
469
+
470
+ Whatever you provide in the `::id` method in your extension class will be used as a namespace in JSON data. This means that, for example, a SCIM representation of the above resource would look something like this:
471
+
472
+ ```json
473
+ {
474
+ "schemas": [
475
+ "urn:ietf:params:scim:schemas:core:2.0:User",
476
+ "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
477
+ ],
478
+ "id": "2819c223-7f76-453a-413861904646",
479
+ "externalId": "701984",
480
+ "userName": "bjensen@example.com",
481
+ // ...
482
+
483
+ "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
484
+ "organization": "Corporation Incorporated",
485
+ "department": "Marketing",
486
+ },
487
+ // ...
488
+ }
489
+ ```
490
+
491
+ ...and likewise, creation via `POST` would require the same nesting if a caller wanted to create a resource instance with those extended properties set (and RFC-compliant consumers of your SCIM API should already be doing this). For `PATCH` operations, [the `path` uses a _colon_ to separate the ID/URN part from the path](https://tools.ietf.org/html/rfc7644#section-3.10) rather than just using a dot as you might expect from the JSON nesting above:
492
+
493
+ ```json
494
+ {
495
+ "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
496
+ "Operations": [
497
+ {
498
+ "op": "replace",
499
+ "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization",
500
+ "value": "Sales"
501
+ }
502
+ ]
503
+ }
504
+ ```
505
+
506
+ 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.
507
+
508
+ 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:
509
+
510
+ ```ruby
511
+ Scimitar::Schema::Attribute.new(name: "userGroups", multiValued: true, complexType: Scimitar::ComplexTypes::ReferenceGroup, mutability: "writeOnly"),
512
+ ```
513
+
514
+ Then map it in your `scim_attributes_map`:
515
+
516
+ ```ruby
517
+ userGroups: [
518
+ {
519
+ list: :groups,
520
+ find_with: ->(value) { Group.find(value["value"]) },
521
+ using: {
522
+ value: :id,
523
+ display: :name
524
+ }
525
+ }
526
+ ]
527
+ ```
528
+
529
+ And write to it like this:
530
+
531
+ ```json
532
+ {
533
+ "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
534
+ "Operations": [
535
+ {
536
+ "op": "replace",
537
+ "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:userGroups",
538
+ "value": [{ "value": "1" }]
539
+ }
540
+ ]
541
+ }
542
+ ```
543
+
544
+ ## Security
545
+
546
+ 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).
547
+
548
+ Often, you'll find that bearer tokens are in use by SCIM API consumers, but the way in which this is used by that consumer in practice can vary a great deal. For example, suppose a corporation uses Microsoft Azure Active Directory to maintain a master database of employee details. Azure lets administrators [connect to SCIM endpoints](https://docs.microsoft.com/en-us/azure/active-directory/app-provisioning/how-provisioning-works) for services that this corporation might use. In all cases, bearer tokens are used.
549
+
550
+ * When the third party integration builds an app that it gets hosted in the Azure Marketplace, the token is obtained via full OAuth flow of some kind - the enterprise corporation would sign into your app by some OAuth UI mechanism you provide, which leads to a Bearer token being issued. Thereafter, the Azure system would quote this back to you in API calls via the `Authorization` HTTP header.
551
+
552
+ * If you are providing SCIM services as part of some wider service offering it might not make sense to go to the trouble of adding all the extra features and requirements for Marketplace inclusion. Fortunately, Microsoft support [addition of 'user-defined' enterprise "app" integrations](https://docs.microsoft.com/en-us/azure/active-directory/app-provisioning/use-scim-to-provision-users-and-groups#integrate-your-scim-endpoint-with-the-aad-scim-client) in Azure, so the administrator can set up and 'provision' your SCIM API endpoint. In _this_ case, the bearer token is just some string that you generate which they paste into the Azure AD UI. Clearly, then, this amounts to little more than a glorified password, but you can take steps to make sure that it's long, unguessable and potentially be some encrypted/encoded structure that allows you to make additional security checks on "your side" when you unpack the token as part of API request handling.
553
+
554
+ * HTTPS is obviously a given here and localhost integration during development is difficult; perhaps search around for things like POSTman collections to assist with development testing. Scimitar has a reasonably comprehensive internal test suite but it's only as good as the accuracy and reliability of the subclass code you write to "bridge the gap" between SCIM schema and actions, and your User/Group equivalent records and the operations you perform upon them. Microsoft provide [additional information](https://techcommunity.microsoft.com/t5/identity-standards-blog/provisioning-with-scim-design-build-and-test-your-scim-endpoint/ba-p/1204883) to help guide service provider implementors with best practice.
555
+
556
+
557
+
558
+ ## Limitations
559
+
560
+ ### Specification versus implementation
561
+
562
+ * 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.
563
+
564
+ * Several complex types for User contain the same set of `value`, `display`, `type` and `primary` fields, all used in synonymous ways.
565
+
566
+ - 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`.
567
+
568
+ - The schema _descriptions_ for `display` declare that the field is something optionally sent by the service provider and state clearly that it is read-only - yet the formal schema declares it `readWrite`. Scimitar marks it as read-only.
569
+
570
+ * The `displayName` of a Group is described in [RFC 7643 section 4.2](https://tools.ietf.org/html/rfc7643#section-4.2) and in the free-text schema `description` field as required, but the schema nonetheless states `"required" : false` in the formal definition. We consider this to be an error and mark the property as `"required" : true`.
571
+
572
+ * In the `members` section of a [`Group` in the RFC 7643 core schema](https://tools.ietf.org/html/rfc7643#page-69), any member's `value` is noted as _not_ required but [the RFC also says](https://tools.ietf.org/html/rfc7643#section-4.2) "Service providers MAY require clients to provide a non-empty value by setting the "required" attribute characteristic of a sub-attribute of the "members" attribute in the "Group" resource schema". Scimitar does this. The `value` field would contain the `id` of a SCIM resource, which is the primary key on "our side" as a service provider. Just as we must store `externalId` values to maintain a mapping on "our side", we in turn _do_ require clients to provide our ID in group member lists via the `value` field.
573
+
574
+ * While the gem attempts to support difficult/complex filter strings via incorporating code and ideas in [SCIM Query Filter Parser](https://github.com/ingydotnet/scim-query-filter-parser-rb), it is possible that ActiveRecord / Rails precedence on some query operations in complex cases might not exactly match the SCIM specification. Please do submit a bug report if you encounter this. You may also wish to view [`query_parser_spec.rb`](https://github.com/RIPAGlobal/scimitar/blob/main/spec/models/scimitar/lists/query_parser_spec.rb) to get an idea of the tested examples - more interesting test cases are in the "`context 'with complex cases' do`" section.
575
+
576
+ * Group resource examples show the `members` array including field `display`, but this is not in the [formal schema](https://tools.ietf.org/html/rfc7643#page-69); Scimitar includes it in the Group definition.
577
+
578
+ * `POST` actions with only a subset of attributes specified treat missing attributes "to be cleared" for anything that's mapped for the target model. If you have defaults established at instantiation rather than (say) before-validation, you'll need to override `Scimitar::ActiveRecordBackedResourcesController#create` (if using that controller as a base class) as normally the controller just instantiates a model, applies _all_ attributes (with any mapped attribute values without an inbound value set to `nil`), then saves the record. This might cause default values to be overwritten. For consistency, `PUT` operations apply the same behaviour. The decision on this optional specification aspect is in part constrained by the difficulties of implementing `PATCH`.
579
+
580
+ * [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.
581
+
582
+ 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.
583
+
584
+ ### Omissions
585
+
586
+ * Bulk operations are not supported.
587
+
588
+ * List ("index") endpoint [filters in SCIM](https://tools.ietf.org/html/rfc7644#section-3.4.2.2) are _extremely_ complicated. There is a syntax for specifying equals, not-equals, precedence through parentheses and things like "and"/"or"/"not" along the lines of "attribute operator value", which Scimitar supports to a reasonably comprehensive degree but with some limitations discussed shortly. That aside, it isn't at all clear what some of the [examples in the RFC](https://tools.ietf.org/html/rfc7644#page-23) are even meant to mean. Consider:
589
+
590
+ - `filter=userType eq "Employee" and (emails co "example.com" or emails.value co "example.org")`
591
+
592
+ 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`.
593
+
594
+ 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:
595
+
596
+ - `filter=userType eq "Employee" and emails[type eq "work" and value co "@example.com"]`
597
+
598
+ Another filter shows a potential workaround:
599
+
600
+ - `filter=userType eq "Employee" and (emails.type eq "work")`
601
+
602
+ ...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:
603
+
604
+ - `filter=userType eq "Employee" and emails.type eq "work" and emails.value co "@example.com"`
605
+
606
+ ...so adding a mapping for `emails.value` would then allow a database query to be constructed.
607
+
608
+ * 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.
609
+
610
+ * 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).
611
+
612
+ * The `PATCH` mechanism is supported, but where filters are included, only a single "attribute eq value" is permitted - no other operators or combinations. For example, a work e-mail address's value could be replaced by a PATCH patch of `emails[type eq "work"].value`. For in-path filters such as this, other operators such as `ne` are not supported; combinations with "and"/"or" are not supported; negation with "not" is not supported.
613
+
614
+ If you would like to see something listed in the session implemented, please [create a GitHub issue](https://github.com/RIPAGlobal/scimitar/issues/new) asking for it to be implemented, or if possible, implement the feature and send a Pull Request.
615
+
616
+
617
+
618
+ ## Development
619
+
620
+ Install Ruby dependencies first:
621
+
622
+ ```
623
+ bundle install
624
+ ```
625
+
626
+ ### Tests
627
+
628
+ For testing, two main options are available:
629
+
630
+ * The first option is running the project locally. This is also the recommended way, as running the tests on a variety of setups and platforms increases he chance of finding platform-specific or setup-specific bugs.
631
+ * The second option is utilising the existing Docker Compose setup provided in the project. You can use this if getting the project to work locally is hard or not feasible.
632
+
633
+ #### Testing on your machine
634
+
635
+ You will need to have PostgreSQL running. This database is chosen for tests to prove case-insensitive behaviour via detection of ILIKE in generated queries. Using SQLite would have resulted in a more conceptually self-contained test suite, but SQLite is case-insensitive by default and uses "LIKE" either way, making it hard to "see" if the query system is doing the right thing.
636
+
637
+ After `bundle install` and with PostgreSQL up, set up the test database with:
638
+
639
+ ```shell
640
+ pushd spec/apps/dummy
641
+ RAILS_ENV=test bundle exec bin/rails db:drop db:create db:migrate
642
+ popd
643
+ ```
644
+
645
+ ...and thereafter, run tests with:
646
+
647
+ ```
648
+ bundle exec rspec
649
+ ```
650
+
651
+ You can get an idea of arising test coverage by opening `coverage/index.html` in your preferred web browser.
652
+
653
+ #### Testing with Docker (Compose)
654
+
655
+ In order to be able to utilise the Docker Compose setup, you will need to have Docker installed with the Compose plugin. For an easy installation of Docker (with a GUI and the Compose plugin preinstalled) please see [Docker Desktop](https://www.docker.com/products/docker-desktop/).
656
+
657
+ In order to configure the Docker image, run `docker compose build` in a terminal of your choice, in the root of this project. This will download the required image and install the required libraries. After this is complete, running the tests is as easy as running the command `docker compose up test`.
658
+
659
+ As mentioned in the previous section, test coverage may be analysed using `coverage/index.html` after running the project.
660
+
661
+ You can also open a raw terminal in this test container by running `docker run --rm test sh`. For more Compose commands, please refer to [the Docker Compose reference manual](https://docs.docker.com/compose/reference/).
662
+
663
+ ### Internal documentation
664
+
665
+ Locally generated RDoc HTML seems to contain a more comprehensive and inter-linked set of pages than those available from `rubydoc.info`. You can (re)generate the internal [`rdoc` documentation](https://ruby-doc.org/stdlib-2.4.1/libdoc/rdoc/rdoc/RDoc/Markup.html#label-Supported+Formats) with:
666
+
667
+ ```shell
668
+ bundle exec rake rerdoc
669
+ ```
670
+
671
+ ...yes, that's `rerdoc` - Re-R-Doc - then open `docs/rdoc/index.html`.
@@ -158,8 +158,8 @@ module Scimitar
158
158
  # If you just let this superclass handle things, it'll call the standard
159
159
  # +#save!+ method on the record. If you pass a block, then this block is
160
160
  # invoked and passed the ActiveRecord model instance to be saved. You can
161
- # then do things like calling a different method, using a service object of
162
- # some kind, perform audit-related operations and so-on.
161
+ # then do things like calling a different method, using a service object
162
+ # of some kind, perform audit-related operations and so-on.
163
163
  #
164
164
  # The return value is not used internally, making life easier for
165
165
  # overriding subclasses to "do the right thing" / avoid mistakes (instead
@@ -177,7 +177,8 @@ module Scimitar
177
177
  handle_invalid_record(exception.record)
178
178
  end
179
179
 
180
- # Deal with validation errors by responding with an appropriate SCIM error.
180
+ # Deal with validation errors by responding with an appropriate SCIM
181
+ # error.
181
182
  #
182
183
  # +record+:: The record with validation errors.
183
184
  #
@@ -124,8 +124,13 @@ module Scimitar
124
124
  #
125
125
  # https://stackoverflow.com/questions/10239970/what-is-the-delimiter-for-www-authenticate-for-multiple-schemes
126
126
  #
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?
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?
129
+
130
+ # No matter what a caller might request via headers, the only content
131
+ # type we can ever respond with is JSON-for-SCIM.
132
+ #
133
+ response.set_header('Content-Type', "#{Mime::Type.lookup_by_extension(:scim)}; charset=utf-8")
129
134
  end
130
135
 
131
136
  def authenticate
@@ -105,9 +105,9 @@ module Scimitar
105
105
 
106
106
  def simple_type?(value)
107
107
  (type == 'string' && value.is_a?(String)) ||
108
- (type == 'boolean' && (value.is_a?(TrueClass) || value.is_a?(FalseClass))) ||
109
- (type == 'integer' && (value.is_a?(Integer))) ||
110
- (type == 'dateTime' && valid_date_time?(value))
108
+ (type == 'boolean' && (value.is_a?(TrueClass) || value.is_a?(FalseClass))) ||
109
+ (type == 'integer' && (value.is_a?(Integer))) ||
110
+ (type == 'dateTime' && valid_date_time?(value))
111
111
  end
112
112
 
113
113
  def valid_date_time?(value)
@@ -37,11 +37,11 @@ Rails.application.config.to_prepare do # (required for >= Rails 7 / Zeitwerk)
37
37
  #
38
38
  Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
39
39
 
40
- # If you have filters you want to run for any Scimitar action/route, you can
41
- # define them here. You can also override any shared controller methods
40
+ # If you have filters you want to run for any Scimitar action/route, you
41
+ # can define them here. You can also override any shared controller methods
42
42
  # here. For example, you might use a before-action to set up some
43
43
  # multi-tenancy related state, skip Rails CSRF token verification, or
44
- # customize how Scimitar generates URLs:
44
+ # customise how Scimitar generates URLs:
45
45
  #
46
46
  # application_controller_mixin: Module.new do
47
47
  # def self.included(base)
@@ -3,11 +3,11 @@ module Scimitar
3
3
  # Gem version. If this changes, be sure to re-run "bundle install" or
4
4
  # "bundle update".
5
5
  #
6
- VERSION = '2.6.0'
6
+ VERSION = '2.6.1'
7
7
 
8
8
  # Date for VERSION. If this changes, be sure to re-run "bundle install"
9
9
  # or "bundle update".
10
10
  #
11
- DATE = '2023-11-14'
11
+ DATE = '2023-11-15'
12
12
 
13
13
  end
@@ -23,8 +23,8 @@ Rails.application.routes.draw do
23
23
 
24
24
  # For testing blocks passed to ActiveRecordBackedResourcesController#save!
25
25
  #
26
- post 'CustomSaveUsers', to: 'custom_save_mock_users#create'
27
- get 'CustomSaveUsers/:id', to: 'custom_save_mock_users#show'
26
+ post 'CustomSaveUsers', to: 'custom_save_mock_users#create'
27
+ get 'CustomSaveUsers/:id', to: 'custom_save_mock_users#show'
28
28
 
29
29
  # For testing environment inside Scimitar::ApplicationController subclasses.
30
30
  #
@@ -24,7 +24,7 @@ RSpec.describe Scimitar::ApplicationController do
24
24
  get :index, params: { format: :scim }
25
25
  expect(response).to be_ok
26
26
  expect(JSON.parse(response.body)).to eql({ 'message' => 'cool, cool!' })
27
- expect(response.headers['WWW_AUTHENTICATE']).to eql('Basic')
27
+ expect(response.headers['WWW-Authenticate']).to eql('Basic')
28
28
  end
29
29
 
30
30
  it 'renders failure with bad password' do
@@ -84,7 +84,7 @@ RSpec.describe Scimitar::ApplicationController do
84
84
  get :index, params: { format: :scim }
85
85
  expect(response).to be_ok
86
86
  expect(JSON.parse(response.body)).to eql({ 'message' => 'cool, cool!' })
87
- expect(response.headers['WWW_AUTHENTICATE']).to eql('Bearer')
87
+ expect(response.headers['WWW-Authenticate']).to eql('Bearer')
88
88
  end
89
89
 
90
90
  it 'renders failure with bad token' do
@@ -27,7 +27,7 @@ RSpec.describe Scimitar::SchemasController do
27
27
  expect(parsed_body['name']).to eql('User')
28
28
  end
29
29
 
30
- it 'includes the controller customized schema location' do
30
+ it 'includes the controller customised schema location' do
31
31
  get :index, params: { name: Scimitar::Schema::User.id, format: :scim }
32
32
  expect(response).to be_ok
33
33
  parsed_body = JSON.parse(response.body)
@@ -26,13 +26,17 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
26
26
 
27
27
  context '#index' do
28
28
  context 'with no items' do
29
- it 'returns empty list' do
29
+ before :each do
30
30
  MockUser.delete_all
31
+ end
31
32
 
33
+ it 'returns empty list' do
32
34
  expect_any_instance_of(MockUsersController).to receive(:index).once.and_call_original
33
35
  get '/Users', params: { format: :scim }
34
36
 
35
- expect(response.status).to eql(200)
37
+ expect(response.status ).to eql(200)
38
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
39
+
36
40
  result = JSON.parse(response.body)
37
41
 
38
42
  expect(result['totalResults']).to eql(0)
@@ -46,7 +50,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
46
50
  it 'returns all items' do
47
51
  get '/Users', params: { format: :scim }
48
52
 
49
- expect(response.status).to eql(200)
53
+ expect(response.status ).to eql(200)
54
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
55
+
50
56
  result = JSON.parse(response.body)
51
57
 
52
58
  expect(result['totalResults']).to eql(3)
@@ -64,7 +70,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
64
70
  it 'returns all items' do
65
71
  get '/Groups', params: { format: :scim }
66
72
 
67
- expect(response.status).to eql(200)
73
+ expect(response.status ).to eql(200)
74
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
75
+
68
76
  result = JSON.parse(response.body)
69
77
 
70
78
  expect(result['totalResults']).to eql(3)
@@ -84,7 +92,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
84
92
  filter: 'name.givenName eq "FOO" and name.familyName pr and emails ne "home_1@test.com"'
85
93
  }
86
94
 
87
- expect(response.status).to eql(200)
95
+ expect(response.status ).to eql(200)
96
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
97
+
88
98
  result = JSON.parse(response.body)
89
99
 
90
100
  expect(result['totalResults']).to eql(1)
@@ -103,7 +113,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
103
113
  filter: 'name.GIVENNAME eq "Foo" and name.Familyname pr and emails ne "home_1@test.com"'
104
114
  }
105
115
 
106
- expect(response.status).to eql(200)
116
+ expect(response.status ).to eql(200)
117
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
118
+
107
119
  result = JSON.parse(response.body)
108
120
 
109
121
  expect(result['totalResults']).to eql(1)
@@ -126,7 +138,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
126
138
  filter: "id eq \"#{@u3.primary_key}\""
127
139
  }
128
140
 
129
- expect(response.status).to eql(200)
141
+ expect(response.status ).to eql(200)
142
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
143
+
130
144
  result = JSON.parse(response.body)
131
145
 
132
146
  expect(result['totalResults']).to eql(1)
@@ -145,7 +159,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
145
159
  filter: "externalID eq \"#{@u2.scim_uid}\""
146
160
  }
147
161
 
148
- expect(response.status).to eql(200)
162
+ expect(response.status ).to eql(200)
163
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
164
+
149
165
  result = JSON.parse(response.body)
150
166
 
151
167
  expect(result['totalResults']).to eql(1)
@@ -164,7 +180,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
164
180
  filter: "Meta.LastModified eq \"#{@u3.updated_at}\""
165
181
  }
166
182
 
167
- expect(response.status).to eql(200)
183
+ expect(response.status ).to eql(200)
184
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
185
+
168
186
  result = JSON.parse(response.body)
169
187
 
170
188
  expect(result['totalResults']).to eql(1)
@@ -184,7 +202,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
184
202
  count: 2
185
203
  }
186
204
 
187
- expect(response.status).to eql(200)
205
+ expect(response.status ).to eql(200)
206
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
207
+
188
208
  result = JSON.parse(response.body)
189
209
 
190
210
  expect(result['totalResults']).to eql(3)
@@ -203,7 +223,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
203
223
  startIndex: 2
204
224
  }
205
225
 
206
- expect(response.status).to eql(200)
226
+ expect(response.status ).to eql(200)
227
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
228
+
207
229
  result = JSON.parse(response.body)
208
230
 
209
231
  expect(result['totalResults']).to eql(3)
@@ -224,8 +246,11 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
224
246
  filter: 'name.givenName'
225
247
  }
226
248
 
227
- expect(response.status).to eql(400)
249
+ expect(response.status ).to eql(400)
250
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
251
+
228
252
  result = JSON.parse(response.body)
253
+
229
254
  expect(result['scimType']).to eql('invalidFilter')
230
255
  end
231
256
  end # "context 'with bad calls' do"
@@ -239,7 +264,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
239
264
  expect_any_instance_of(MockUsersController).to receive(:show).once.and_call_original
240
265
  get "/Users/#{@u2.primary_key}", params: { format: :scim }
241
266
 
242
- expect(response.status).to eql(200)
267
+ expect(response.status ).to eql(200)
268
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
269
+
243
270
  result = JSON.parse(response.body)
244
271
 
245
272
  expect(result['id']).to eql(@u2.primary_key.to_s)
@@ -254,7 +281,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
254
281
  expect_any_instance_of(MockGroupsController).to receive(:show).once.and_call_original
255
282
  get "/Groups/#{@g2.id}", params: { format: :scim }
256
283
 
257
- expect(response.status).to eql(200)
284
+ expect(response.status ).to eql(200)
285
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
286
+
258
287
  result = JSON.parse(response.body)
259
288
 
260
289
  expect(result['id']).to eql(@g2.id.to_s) # Note - ID was converted String; not Integer
@@ -266,8 +295,11 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
266
295
  it 'renders 404' do
267
296
  get '/Users/xyz', params: { format: :scim }
268
297
 
269
- expect(response.status).to eql(404)
298
+ expect(response.status ).to eql(404)
299
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
300
+
270
301
  result = JSON.parse(response.body)
302
+
271
303
  expect(result['status']).to eql('404')
272
304
  end
273
305
  end # "context '#show' do"
@@ -291,7 +323,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
291
323
  mock_after = MockUser.all.to_a
292
324
  new_mock = (mock_after - mock_before).first
293
325
 
294
- expect(response.status).to eql(201)
326
+ expect(response.status ).to eql(201)
327
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
328
+
295
329
  result = JSON.parse(response.body)
296
330
 
297
331
  expect(result['id']).to eql(new_mock.primary_key.to_s)
@@ -332,7 +366,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
332
366
  mock_after = MockUser.all.to_a
333
367
  new_mock = (mock_after - mock_before).first
334
368
 
335
- expect(response.status).to eql(201)
369
+ expect(response.status ).to eql(201)
370
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
371
+
336
372
  result = JSON.parse(response.body)
337
373
 
338
374
  expect(result['id']).to eql(new_mock.id.to_s)
@@ -363,8 +399,11 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
363
399
  }
364
400
  }.to_not change { MockUser.count }
365
401
 
366
- expect(response.status).to eql(409)
402
+ expect(response.status ).to eql(409)
403
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
404
+
367
405
  result = JSON.parse(response.body)
406
+
368
407
  expect(result['scimType']).to eql('uniqueness')
369
408
  expect(result['detail']).to include('already been taken')
370
409
  end
@@ -377,8 +416,11 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
377
416
  }
378
417
  }.to_not change { MockUser.count }
379
418
 
380
- expect(response.status).to eql(400)
419
+ expect(response.status ).to eql(400)
420
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
421
+
381
422
  result = JSON.parse(response.body)
423
+
382
424
  expect(result['scimType']).to eql('invalidValue')
383
425
  expect(result['detail']).to include('is required')
384
426
  end
@@ -391,7 +433,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
391
433
  }
392
434
  }.to_not change { MockUser.count }
393
435
 
394
- expect(response.status).to eql(400)
436
+ expect(response.status ).to eql(400)
437
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
438
+
395
439
  result = JSON.parse(response.body)
396
440
 
397
441
  expect(result['scimType']).to eql('invalidValue')
@@ -410,7 +454,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
410
454
  mock_after = MockUser.all.to_a
411
455
  new_mock = (mock_after - mock_before).first
412
456
 
413
- expect(response.status).to eql(201)
457
+ expect(response.status ).to eql(201)
458
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
459
+
414
460
  expect(new_mock.username).to eql(CustomSaveMockUsersController::CUSTOM_SAVE_BLOCK_USERNAME_INDICATOR)
415
461
  end
416
462
  end # "context '#create' do"
@@ -428,7 +474,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
428
474
  put "/Users/#{@u2.primary_key}", params: attributes.merge(format: :scim)
429
475
  }.to_not change { MockUser.count }
430
476
 
431
- expect(response.status).to eql(200)
477
+ expect(response.status ).to eql(200)
478
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
479
+
432
480
  result = JSON.parse(response.body)
433
481
 
434
482
  expect(result['id']).to eql(@u2.primary_key.to_s)
@@ -459,8 +507,11 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
459
507
  }
460
508
  }.to_not change { MockUser.count }
461
509
 
462
- expect(response.status).to eql(400)
510
+ expect(response.status ).to eql(400)
511
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
512
+
463
513
  result = JSON.parse(response.body)
514
+
464
515
  expect(result['scimType']).to eql('invalidValue')
465
516
  expect(result['detail']).to include('is required')
466
517
 
@@ -480,7 +531,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
480
531
  }
481
532
  }.to_not change { MockUser.count }
482
533
 
483
- expect(response.status).to eql(400)
534
+ expect(response.status ).to eql(400)
535
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
536
+
484
537
  result = JSON.parse(response.body)
485
538
 
486
539
  expect(result['scimType']).to eql('invalidValue')
@@ -502,8 +555,11 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
502
555
  }
503
556
  }.to_not change { MockUser.count }
504
557
 
505
- expect(response.status).to eql(404)
558
+ expect(response.status ).to eql(404)
559
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
560
+
506
561
  result = JSON.parse(response.body)
562
+
507
563
  expect(result['status']).to eql('404')
508
564
  end
509
565
  end # "context '#replace' do"
@@ -535,7 +591,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
535
591
  patch "/Users/#{@u2.primary_key}", params: payload.merge(format: :scim)
536
592
  }.to_not change { MockUser.count }
537
593
 
538
- expect(response.status).to eql(200)
594
+ expect(response.status ).to eql(200)
595
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
596
+
539
597
  result = JSON.parse(response.body)
540
598
 
541
599
  expect(result['id']).to eql(@u2.primary_key.to_s)
@@ -572,7 +630,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
572
630
  patch "/Users/#{@u2.primary_key}", params: payload.merge(format: :scim)
573
631
  }.to_not change { MockUser.count }
574
632
 
575
- expect(response.status).to eql(200)
633
+ expect(response.status ).to eql(200)
634
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
635
+
576
636
  result = JSON.parse(response.body)
577
637
 
578
638
  expect(result['id']).to eql(@u2.primary_key.to_s)
@@ -604,7 +664,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
604
664
  patch "/Users/#{@u2.primary_key}", params: payload.merge(format: :scim)
605
665
  }.to_not change { MockUser.count }
606
666
 
607
- expect(response.status).to eql(200)
667
+ expect(response.status ).to eql(200)
668
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
669
+
608
670
  result = JSON.parse(response.body)
609
671
 
610
672
  expect(result['id']).to eql(@u2.primary_key.to_s)
@@ -636,7 +698,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
636
698
  patch "/Users/#{@u2.primary_key}", params: payload.merge(format: :scim)
637
699
  }.to_not change { MockUser.count }
638
700
 
639
- expect(response.status).to eql(200)
701
+ expect(response.status ).to eql(200)
702
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
703
+
640
704
  result = JSON.parse(response.body)
641
705
 
642
706
  expect(result['id']).to eql(@u2.primary_key.to_s)
@@ -675,7 +739,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
675
739
  }
676
740
  }.to_not change { MockUser.count }
677
741
 
678
- expect(response.status).to eql(400)
742
+ expect(response.status ).to eql(400)
743
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
744
+
679
745
  result = JSON.parse(response.body)
680
746
 
681
747
  expect(result['scimType']).to eql('invalidValue')
@@ -703,8 +769,11 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
703
769
  }
704
770
  }.to_not change { MockUser.count }
705
771
 
706
- expect(response.status).to eql(404)
772
+ expect(response.status ).to eql(404)
773
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
774
+
707
775
  result = JSON.parse(response.body)
776
+
708
777
  expect(result['status']).to eql('404')
709
778
  end
710
779
 
@@ -741,7 +810,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
741
810
 
742
811
  get "/Groups/#{@g1.id}", params: { format: :scim }
743
812
 
744
- expect(response.status).to eql(200)
813
+ expect(response.status ).to eql(200)
814
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
815
+
745
816
  result = JSON.parse(response.body)
746
817
 
747
818
  expect(result['members']).to be_empty
@@ -768,7 +839,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
768
839
 
769
840
  get "/Groups/#{@g1.id}", params: { format: :scim }
770
841
 
771
- expect(response.status).to eql(200)
842
+ expect(response.status ).to eql(200)
843
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
844
+
772
845
  result = JSON.parse(response.body)
773
846
 
774
847
  expect(result['members'].map { |m| m['value'] }.sort()).to eql(expected_remaining_user_ids)
@@ -879,8 +952,11 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
879
952
  delete '/Users/xyz', params: { format: :scim }
880
953
  }.to_not change { MockUser.count }
881
954
 
882
- expect(response.status).to eql(404)
955
+ expect(response.status ).to eql(404)
956
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
957
+
883
958
  result = JSON.parse(response.body)
959
+
884
960
  expect(result['status']).to eql('404')
885
961
  end
886
962
  end # "context '#destroy' do"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scimitar
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.6.0
4
+ version: 2.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - RIPA Global
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2023-11-14 00:00:00.000000000 Z
12
+ date: 2023-11-15 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
@@ -130,6 +130,8 @@ executables: []
130
130
  extensions: []
131
131
  extra_rdoc_files: []
132
132
  files:
133
+ - LICENSE.txt
134
+ - README.md
133
135
  - Rakefile
134
136
  - app/controllers/scimitar/active_record_backed_resources_controller.rb
135
137
  - app/controllers/scimitar/application_controller.rb