scimitar 2.6.0 → 2.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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