scimitar 2.6.0 → 2.7.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.
@@ -60,12 +60,20 @@ module Scimitar
60
60
 
61
61
  # POST (create)
62
62
  #
63
- def create
63
+ # Calls #save! on the new record if no block is given, else invokes the
64
+ # block, passing it the new ActiveRecord model instance to be saved. It
65
+ # is up to the block to make any further changes and persist the record.
66
+ #
67
+ # Blocks are invoked from within a wrapping database transaction.
68
+ # ActiveRecord::RecordInvalid exceptions are handled for you, rendering
69
+ # an appropriate SCIM error.
70
+ #
71
+ def create(&block)
64
72
  super do |scim_resource|
65
73
  self.storage_class().transaction do
66
74
  record = self.storage_class().new
67
75
  record.from_scim!(scim_hash: scim_resource.as_json())
68
- self.save!(record)
76
+ self.save!(record, &block)
69
77
  record_to_scim(record)
70
78
  end
71
79
  end
@@ -73,12 +81,16 @@ module Scimitar
73
81
 
74
82
  # PUT (replace)
75
83
  #
76
- def replace
84
+ # Calls #save! on the updated record if no block is given, else invokes the
85
+ # block, passing the updated record which the block must persist, with the
86
+ # same rules as for #create.
87
+ #
88
+ def replace(&block)
77
89
  super do |record_id, scim_resource|
78
90
  self.storage_class().transaction do
79
91
  record = self.find_record(record_id)
80
92
  record.from_scim!(scim_hash: scim_resource.as_json())
81
- self.save!(record)
93
+ self.save!(record, &block)
82
94
  record_to_scim(record)
83
95
  end
84
96
  end
@@ -86,12 +98,16 @@ module Scimitar
86
98
 
87
99
  # PATCH (update)
88
100
  #
89
- def update
101
+ # Calls #save! on the updated record if no block is given, else invokes the
102
+ # block, passing the updated record which the block must persist, with the
103
+ # same rules as for #create.
104
+ #
105
+ def update(&block)
90
106
  super do |record_id, patch_hash|
91
107
  self.storage_class().transaction do
92
108
  record = self.find_record(record_id)
93
109
  record.from_scim_patch!(patch_hash: patch_hash)
94
- self.save!(record)
110
+ self.save!(record, &block)
95
111
  record_to_scim(record)
96
112
  end
97
113
  end
@@ -134,6 +150,17 @@ module Scimitar
134
150
  raise NotImplementedError
135
151
  end
136
152
 
153
+ # Return an Array of exceptions that #save! can rescue and handle with a
154
+ # SCIM error automatically.
155
+ #
156
+ def scimitar_rescuable_exceptions
157
+ [
158
+ ActiveRecord::RecordInvalid,
159
+ ActiveRecord::RecordNotSaved,
160
+ ActiveRecord::RecordNotUnique,
161
+ ]
162
+ end
163
+
137
164
  # Find a record by ID. Subclasses can override this if they need special
138
165
  # lookup behaviour.
139
166
  #
@@ -158,8 +185,8 @@ module Scimitar
158
185
  # If you just let this superclass handle things, it'll call the standard
159
186
  # +#save!+ method on the record. If you pass a block, then this block is
160
187
  # 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.
188
+ # then do things like calling a different method, using a service object
189
+ # of some kind, perform audit-related operations and so-on.
163
190
  #
164
191
  # The return value is not used internally, making life easier for
165
192
  # overriding subclasses to "do the right thing" / avoid mistakes (instead
@@ -173,16 +200,25 @@ module Scimitar
173
200
  else
174
201
  record.save!
175
202
  end
176
- rescue ActiveRecord::RecordInvalid => exception
177
- handle_invalid_record(exception.record)
203
+ rescue *self.scimitar_rescuable_exceptions() => exception
204
+ handle_on_save_exception(record, exception)
178
205
  end
179
206
 
180
- # Deal with validation errors by responding with an appropriate SCIM error.
207
+ # Deal with exceptions related to errors upon saving, by responding with
208
+ # an appropriate SCIM error. This is most effective if the record has
209
+ # validation errors defined, but falls back to the provided exception's
210
+ # message otherwise.
181
211
  #
182
- # +record+:: The record with validation errors.
212
+ # +record+:: The record that provoked the exception. Mandatory.
213
+ # +exception+:: The exception that was raised. If omitted, a default of
214
+ # 'Unknown', in English with no I18n, is used.
183
215
  #
184
- def handle_invalid_record(record)
185
- joined_errors = record.errors.full_messages.join('; ')
216
+ def handle_on_save_exception(record, exception = RuntimeError.new('Unknown'))
217
+ details = if record.errors.present?
218
+ record.errors.full_messages.join('; ')
219
+ else
220
+ exception.message
221
+ end
186
222
 
187
223
  # https://tools.ietf.org/html/rfc7644#page-12
188
224
  #
@@ -192,14 +228,14 @@ module Scimitar
192
228
  # status code 409 (Conflict) with a "scimType" error code of
193
229
  # "uniqueness"
194
230
  #
195
- if record.errors.any? { | e | e.type == :taken }
231
+ if exception.is_a?(ActiveRecord::RecordNotUnique) || record.errors.any? { | e | e.type == :taken }
196
232
  raise Scimitar::ErrorResponse.new(
197
233
  status: 409,
198
234
  scimType: 'uniqueness',
199
- detail: joined_errors
235
+ detail: "Operation failed due to a uniqueness constraint: #{details}"
200
236
  )
201
237
  else
202
- raise Scimitar::ResourceInvalidError.new(joined_errors)
238
+ raise Scimitar::ResourceInvalidError.new(details)
203
239
  end
204
240
  end
205
241
 
@@ -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
@@ -134,11 +139,15 @@ module Scimitar
134
139
 
135
140
  def authenticated?
136
141
  result = if Scimitar.engine_configuration.basic_authenticator.present?
137
- authenticate_with_http_basic(&Scimitar.engine_configuration.basic_authenticator)
142
+ authenticate_with_http_basic do |username, password|
143
+ instance_exec(username, password, &Scimitar.engine_configuration.basic_authenticator)
144
+ end
138
145
  end
139
146
 
140
147
  result ||= if Scimitar.engine_configuration.token_authenticator.present?
141
- authenticate_with_http_token(&Scimitar.engine_configuration.token_authenticator)
148
+ authenticate_with_http_token do |token, options|
149
+ instance_exec(token, options, &Scimitar.engine_configuration.token_authenticator)
150
+ end
142
151
  end
143
152
 
144
153
  return result
@@ -7,12 +7,6 @@ module Scimitar
7
7
  #
8
8
  class Address < Base
9
9
  set_schema Scimitar::Schema::Address
10
-
11
- # Returns the JSON representation of an Address.
12
- #
13
- def as_json(options = {})
14
- {'type' => 'work'}.merge(super(options))
15
- end
16
10
  end
17
11
  end
18
12
  end
@@ -2,7 +2,7 @@ module Scimitar
2
2
  class ResourceInvalidError < ErrorResponse
3
3
 
4
4
  def initialize(error_message)
5
- super(status: 400, scimType: 'invalidValue', detail:"Operation failed since record has become invalid: #{error_message}")
5
+ super(status: 400, scimType: 'invalidValue', detail: "Operation failed since record has become invalid: #{error_message}")
6
6
  end
7
7
 
8
8
  end
@@ -952,7 +952,10 @@ module Scimitar
952
952
 
953
953
  when 'replace'
954
954
  if path_component == 'root'
955
- altering_hash[path_component].merge!(value)
955
+ dot_pathed_value = value.inject({}) do |hash, (k, v)|
956
+ hash.deep_merge!(::Scimitar::Support::Utilities.dot_path(k.split('.'), v))
957
+ end
958
+ altering_hash[path_component].deep_merge!(dot_pathed_value)
956
959
  else
957
960
  altering_hash[path_component] = value
958
961
  end
@@ -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)
@@ -0,0 +1,51 @@
1
+ module Scimitar
2
+
3
+ # Namespace containing various chunks of Scimitar support code that don't
4
+ # logically fit into other areas.
5
+ #
6
+ module Support
7
+
8
+ # A namespace that contains various stand-alone utility methods which act
9
+ # as helpers for other parts of the code base, without risking namespace
10
+ # pollution by e.g. being part of a module loaded into a client class.
11
+ #
12
+ module Utilities
13
+
14
+ # Takes an array of components that usually come from a dotted path such
15
+ # as <tt>foo.bar.baz</tt>, along with a value that is found at the end of
16
+ # that path, then converts it into a nested Hash with each level of the
17
+ # Hash corresponding to a step along the path.
18
+ #
19
+ # This was written to help with edge case SCIM uses where (most often, at
20
+ # least) inbound calls use a dotted notation where nested values are more
21
+ # commonly accepted; converting to nesting makes it easier for subsequent
22
+ # processing code, which needs only handle nested Hash data.
23
+ #
24
+ # As an example, passing:
25
+ #
26
+ # ['foo', 'bar', 'baz'], 'value'
27
+ #
28
+ # ...yields:
29
+ #
30
+ # {'foo' => {'bar' => {'baz' => 'value'}}}
31
+ #
32
+ # Parameters:
33
+ #
34
+ # +array+:: Array containing path components, usually acquired from a
35
+ # string with dot separators and a call to String#split.
36
+ #
37
+ # +value+:: The value found at the path indicated by +array+.
38
+ #
39
+ # If +array+ is empty, +value+ is returned directly, with no nesting
40
+ # Hash wrapping it.
41
+ #
42
+ def self.dot_path(array, value)
43
+ return value if array.empty?
44
+
45
+ {}.tap do | hash |
46
+ hash[array.shift()] = self.dot_path(array, value)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -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.7.0'
7
7
 
8
8
  # Date for VERSION. If this changes, be sure to re-run "bundle install"
9
9
  # or "bundle update".
10
10
  #
11
- DATE = '2023-11-14'
11
+ DATE = '2024-01-15'
12
12
 
13
13
  end
data/lib/scimitar.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require 'scimitar/version'
2
2
  require 'scimitar/support/hash_with_indifferent_case_insensitive_access'
3
+ require 'scimitar/support/utilities'
3
4
  require 'scimitar/engine'
4
5
 
5
6
  module Scimitar
@@ -0,0 +1,25 @@
1
+ # For tests only - uses custom 'create' implementation which passes a block to
2
+ # Scimitar::ActiveRecordBackedResourcesController#create.
3
+ #
4
+ class CustomCreateMockUsersController < Scimitar::ActiveRecordBackedResourcesController
5
+
6
+ OVERRIDDEN_NAME = SecureRandom.uuid
7
+
8
+ def create
9
+ super do | resource |
10
+ resource.first_name = OVERRIDDEN_NAME
11
+ resource.save!
12
+ end
13
+ end
14
+
15
+ protected
16
+
17
+ def storage_class
18
+ MockUser
19
+ end
20
+
21
+ def storage_scope
22
+ MockUser.all
23
+ end
24
+
25
+ end
@@ -0,0 +1,25 @@
1
+ # For tests only - uses custom 'replace' implementation which passes a block to
2
+ # Scimitar::ActiveRecordBackedResourcesController#create.
3
+ #
4
+ class CustomReplaceMockUsersController < Scimitar::ActiveRecordBackedResourcesController
5
+
6
+ OVERRIDDEN_NAME = SecureRandom.uuid
7
+
8
+ def replace
9
+ super do | resource |
10
+ resource.first_name = OVERRIDDEN_NAME
11
+ resource.save!
12
+ end
13
+ end
14
+
15
+ protected
16
+
17
+ def storage_class
18
+ MockUser
19
+ end
20
+
21
+ def storage_scope
22
+ MockUser.all
23
+ end
24
+
25
+ end
@@ -0,0 +1,25 @@
1
+ # For tests only - uses custom 'update' implementation which passes a block to
2
+ # Scimitar::ActiveRecordBackedResourcesController#create.
3
+ #
4
+ class CustomUpdateMockUsersController < Scimitar::ActiveRecordBackedResourcesController
5
+
6
+ OVERRIDDEN_NAME = SecureRandom.uuid
7
+
8
+ def update
9
+ super do | resource |
10
+ resource.first_name = OVERRIDDEN_NAME
11
+ resource.save!
12
+ end
13
+ end
14
+
15
+ protected
16
+
17
+ def storage_class
18
+ MockUser
19
+ end
20
+
21
+ def storage_scope
22
+ MockUser.all
23
+ end
24
+
25
+ end
@@ -17,14 +17,26 @@ Rails.application.routes.draw do
17
17
  get 'Groups/:id', to: 'mock_groups#show'
18
18
  patch 'Groups/:id', to: 'mock_groups#update'
19
19
 
20
- # For testing blocks passed to ActiveRecordBackedResourcesController#destroy
20
+ # For testing blocks passed to ActiveRecordBackedResourcesController#create,
21
+ # #update, #replace and #destroy.
21
22
  #
23
+ post 'CustomCreateUsers', to: 'custom_create_mock_users#create'
24
+ patch 'CustomUpdateUsers/:id', to: 'custom_update_mock_users#update'
25
+ put 'CustomReplaceUsers/:id', to: 'custom_replace_mock_users#replace'
22
26
  delete 'CustomDestroyUsers/:id', to: 'custom_destroy_mock_users#destroy'
23
27
 
28
+ # Needed because the auto-render of most of the above includes a 'url_for'
29
+ # call for a 'show' action, so we must include routes (implemented in the
30
+ # base class) for the "show" endpoint.
31
+ #
32
+ get 'CustomCreateUsers/:id', to: 'custom_create_mock_users#show'
33
+ get 'CustomUpdateUsers/:id', to: 'custom_update_mock_users#show'
34
+ get 'CustomReplaceUsers/:id', to: 'custom_replace_mock_users#show'
35
+
24
36
  # For testing blocks passed to ActiveRecordBackedResourcesController#save!
25
37
  #
26
- post 'CustomSaveUsers', to: 'custom_save_mock_users#create'
27
- get 'CustomSaveUsers/:id', to: 'custom_save_mock_users#show'
38
+ post 'CustomSaveUsers', to: 'custom_save_mock_users#create'
39
+ get 'CustomSaveUsers/:id', to: 'custom_save_mock_users#show'
28
40
 
29
41
  # For testing environment inside Scimitar::ApplicationController subclasses.
30
42
  #
@@ -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,61 @@ 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
+ end
89
+
90
+ it 'renders failure with bad token' do
91
+ request.env['HTTP_AUTHORIZATION'] = 'Bearer Invalid'
92
+
93
+ get :index, params: { format: :scim }
94
+ expect(response).not_to be_ok
95
+ end
96
+
97
+ it 'renders failure with blank token' do
98
+ request.env['HTTP_AUTHORIZATION'] = 'Bearer'
99
+
100
+ get :index, params: { format: :scim }
101
+ expect(response).not_to be_ok
102
+ end
103
+
104
+ it 'renders failure with missing header' do
105
+ get :index, params: { format: :scim }
106
+ expect(response).not_to be_ok
107
+ end
108
+ end
109
+
110
+ context 'authenticator evaluated within controller context' do
111
+
112
+ # Define a controller with a custom instance method 'valid_token'.
113
+ #
114
+ controller do
115
+ def index
116
+ render json: { 'message' => 'cool, cool!' }, format: :scim
117
+ end
118
+
119
+ def valid_token
120
+ 'B'
121
+ end
122
+ end
123
+
124
+ # Call the above controller method from the token authenticator Proc,
125
+ # proving that it was executed in the controller's context.
126
+ #
127
+ before do
128
+ Scimitar.engine_configuration = Scimitar::EngineConfiguration.new(
129
+ token_authenticator: Proc.new do | token, options |
130
+ token == self.valid_token()
131
+ end
132
+ )
133
+ end
134
+
135
+ it 'renders success when valid creds are given' do
136
+ request.env['HTTP_AUTHORIZATION'] = 'Bearer B'
137
+
138
+ get :index, params: { format: :scim }
139
+ expect(response).to be_ok
140
+ expect(JSON.parse(response.body)).to eql({ 'message' => 'cool, cool!' })
141
+ expect(response.headers['WWW-Authenticate']).to eql('Bearer')
88
142
  end
89
143
 
90
144
  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)
@@ -2,8 +2,8 @@ require 'spec_helper'
2
2
 
3
3
  RSpec.describe Scimitar::ComplexTypes::Address do
4
4
  context '#as_json' do
5
- it 'assumes a type of "work" as a default' do
6
- expect(described_class.new.as_json).to eq('type' => 'work')
5
+ it 'assumes no defaults' do
6
+ expect(described_class.new.as_json).to eq({})
7
7
  end
8
8
 
9
9
  it 'allows a custom address type' do
@@ -11,9 +11,8 @@ RSpec.describe Scimitar::ComplexTypes::Address do
11
11
  end
12
12
 
13
13
  it 'shows the set address' do
14
- expect(described_class.new(country: 'NZ').as_json).to eq('type' => 'work', 'country' => 'NZ')
14
+ expect(described_class.new(country: 'NZ').as_json).to eq('country' => 'NZ')
15
15
  end
16
16
  end
17
17
 
18
18
  end
19
-
@@ -2717,6 +2717,28 @@ RSpec.describe Scimitar::Resources::Mixin do
2717
2717
  expect(@instance.username).to eql('1234')
2718
2718
  end
2719
2719
 
2720
+ it 'which updates nested values using root syntax' do
2721
+ @instance.update!(first_name: 'Foo', last_name: 'Bar')
2722
+
2723
+ path = 'name.givenName'
2724
+ path = path.upcase if force_upper_case
2725
+
2726
+ patch = {
2727
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
2728
+ 'Operations' => [
2729
+ {
2730
+ 'op' => 'replace',
2731
+ 'value' => {
2732
+ path => 'Baz'
2733
+ }
2734
+ }
2735
+ ]
2736
+ }
2737
+
2738
+ @instance.from_scim_patch!(patch_hash: patch)
2739
+ expect(@instance.first_name).to eql('Baz')
2740
+ end
2741
+
2720
2742
  it 'which updates nested values' do
2721
2743
  @instance.update!(first_name: 'Foo', last_name: 'Bar')
2722
2744