powerhome-scimitar 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +708 -0
- data/Rakefile +16 -0
- data/app/controllers/scimitar/active_record_backed_resources_controller.rb +257 -0
- data/app/controllers/scimitar/application_controller.rb +157 -0
- data/app/controllers/scimitar/resource_types_controller.rb +28 -0
- data/app/controllers/scimitar/resources_controller.rb +203 -0
- data/app/controllers/scimitar/schemas_controller.rb +21 -0
- data/app/controllers/scimitar/service_provider_configurations_controller.rb +8 -0
- data/app/models/scimitar/authentication_error.rb +9 -0
- data/app/models/scimitar/authentication_scheme.rb +18 -0
- data/app/models/scimitar/bulk.rb +8 -0
- data/app/models/scimitar/complex_types/address.rb +12 -0
- data/app/models/scimitar/complex_types/base.rb +83 -0
- data/app/models/scimitar/complex_types/email.rb +12 -0
- data/app/models/scimitar/complex_types/entitlement.rb +12 -0
- data/app/models/scimitar/complex_types/ims.rb +12 -0
- data/app/models/scimitar/complex_types/name.rb +12 -0
- data/app/models/scimitar/complex_types/phone_number.rb +12 -0
- data/app/models/scimitar/complex_types/photo.rb +12 -0
- data/app/models/scimitar/complex_types/reference_group.rb +12 -0
- data/app/models/scimitar/complex_types/reference_member.rb +12 -0
- data/app/models/scimitar/complex_types/role.rb +12 -0
- data/app/models/scimitar/complex_types/x509_certificate.rb +12 -0
- data/app/models/scimitar/engine_configuration.rb +32 -0
- data/app/models/scimitar/error_response.rb +32 -0
- data/app/models/scimitar/errors.rb +14 -0
- data/app/models/scimitar/filter.rb +11 -0
- data/app/models/scimitar/filter_error.rb +22 -0
- data/app/models/scimitar/invalid_syntax_error.rb +9 -0
- data/app/models/scimitar/lists/count.rb +64 -0
- data/app/models/scimitar/lists/query_parser.rb +745 -0
- data/app/models/scimitar/meta.rb +7 -0
- data/app/models/scimitar/not_found_error.rb +10 -0
- data/app/models/scimitar/resource_invalid_error.rb +9 -0
- data/app/models/scimitar/resource_type.rb +29 -0
- data/app/models/scimitar/resources/base.rb +190 -0
- data/app/models/scimitar/resources/group.rb +13 -0
- data/app/models/scimitar/resources/mixin.rb +1524 -0
- data/app/models/scimitar/resources/user.rb +13 -0
- data/app/models/scimitar/schema/address.rb +25 -0
- data/app/models/scimitar/schema/attribute.rb +132 -0
- data/app/models/scimitar/schema/base.rb +90 -0
- data/app/models/scimitar/schema/derived_attributes.rb +24 -0
- data/app/models/scimitar/schema/email.rb +10 -0
- data/app/models/scimitar/schema/entitlement.rb +10 -0
- data/app/models/scimitar/schema/group.rb +27 -0
- data/app/models/scimitar/schema/ims.rb +10 -0
- data/app/models/scimitar/schema/name.rb +20 -0
- data/app/models/scimitar/schema/phone_number.rb +10 -0
- data/app/models/scimitar/schema/photo.rb +10 -0
- data/app/models/scimitar/schema/reference_group.rb +23 -0
- data/app/models/scimitar/schema/reference_member.rb +21 -0
- data/app/models/scimitar/schema/role.rb +10 -0
- data/app/models/scimitar/schema/user.rb +52 -0
- data/app/models/scimitar/schema/vdtp.rb +18 -0
- data/app/models/scimitar/schema/x509_certificate.rb +22 -0
- data/app/models/scimitar/service_provider_configuration.rb +60 -0
- data/app/models/scimitar/supportable.rb +14 -0
- data/app/views/layouts/scimitar/application.html.erb +14 -0
- data/config/initializers/scimitar.rb +111 -0
- data/config/routes.rb +6 -0
- data/lib/scimitar/engine.rb +63 -0
- data/lib/scimitar/support/hash_with_indifferent_case_insensitive_access.rb +216 -0
- data/lib/scimitar/support/utilities.rb +51 -0
- data/lib/scimitar/version.rb +13 -0
- data/lib/scimitar.rb +29 -0
- data/spec/apps/dummy/app/controllers/custom_create_mock_users_controller.rb +25 -0
- data/spec/apps/dummy/app/controllers/custom_destroy_mock_users_controller.rb +24 -0
- data/spec/apps/dummy/app/controllers/custom_replace_mock_users_controller.rb +25 -0
- data/spec/apps/dummy/app/controllers/custom_request_verifiers_controller.rb +30 -0
- data/spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb +24 -0
- data/spec/apps/dummy/app/controllers/custom_update_mock_users_controller.rb +25 -0
- data/spec/apps/dummy/app/controllers/mock_groups_controller.rb +13 -0
- data/spec/apps/dummy/app/controllers/mock_users_controller.rb +13 -0
- data/spec/apps/dummy/app/models/mock_group.rb +83 -0
- data/spec/apps/dummy/app/models/mock_user.rb +132 -0
- data/spec/apps/dummy/config/application.rb +18 -0
- data/spec/apps/dummy/config/boot.rb +2 -0
- data/spec/apps/dummy/config/environment.rb +2 -0
- data/spec/apps/dummy/config/environments/test.rb +38 -0
- data/spec/apps/dummy/config/initializers/cookies_serializer.rb +3 -0
- data/spec/apps/dummy/config/initializers/scimitar.rb +61 -0
- data/spec/apps/dummy/config/initializers/session_store.rb +3 -0
- data/spec/apps/dummy/config/routes.rb +45 -0
- data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +24 -0
- data/spec/apps/dummy/db/migrate/20210308020313_create_mock_groups.rb +10 -0
- data/spec/apps/dummy/db/migrate/20210308044214_create_join_table_mock_groups_mock_users.rb +13 -0
- data/spec/apps/dummy/db/schema.rb +48 -0
- data/spec/controllers/scimitar/application_controller_spec.rb +296 -0
- data/spec/controllers/scimitar/resource_types_controller_spec.rb +94 -0
- data/spec/controllers/scimitar/resources_controller_spec.rb +247 -0
- data/spec/controllers/scimitar/schemas_controller_spec.rb +83 -0
- data/spec/controllers/scimitar/service_provider_configurations_controller_spec.rb +22 -0
- data/spec/models/scimitar/complex_types/address_spec.rb +18 -0
- data/spec/models/scimitar/complex_types/email_spec.rb +21 -0
- data/spec/models/scimitar/lists/count_spec.rb +147 -0
- data/spec/models/scimitar/lists/query_parser_spec.rb +830 -0
- data/spec/models/scimitar/resource_type_spec.rb +21 -0
- data/spec/models/scimitar/resources/base_spec.rb +485 -0
- data/spec/models/scimitar/resources/base_validation_spec.rb +86 -0
- data/spec/models/scimitar/resources/mixin_spec.rb +3562 -0
- data/spec/models/scimitar/resources/user_spec.rb +68 -0
- data/spec/models/scimitar/schema/attribute_spec.rb +99 -0
- data/spec/models/scimitar/schema/base_spec.rb +64 -0
- data/spec/models/scimitar/schema/group_spec.rb +87 -0
- data/spec/models/scimitar/schema/user_spec.rb +720 -0
- data/spec/requests/active_record_backed_resources_controller_spec.rb +1354 -0
- data/spec/requests/application_controller_spec.rb +61 -0
- data/spec/requests/controller_configuration_spec.rb +17 -0
- data/spec/requests/engine_spec.rb +45 -0
- data/spec/spec_helper.rb +101 -0
- data/spec/spec_helper_spec.rb +30 -0
- data/spec/support/hash_with_indifferent_case_insensitive_access_spec.rb +169 -0
- metadata +321 -0
data/Rakefile
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'rake'
|
2
|
+
require 'rspec/core/rake_task'
|
3
|
+
require 'rdoc/task'
|
4
|
+
require 'sdoc'
|
5
|
+
|
6
|
+
RSpec::Core::RakeTask.new(:default) do | t |
|
7
|
+
end
|
8
|
+
|
9
|
+
Rake::RDocTask.new do | rd |
|
10
|
+
rd.rdoc_files.include('README.md', 'lib/**/*.rb', 'app/**/*.rb')
|
11
|
+
|
12
|
+
rd.title = 'Scimitar'
|
13
|
+
rd.main = 'README.md'
|
14
|
+
rd.rdoc_dir = 'docs/rdoc'
|
15
|
+
rd.generator = 'sdoc'
|
16
|
+
end
|
@@ -0,0 +1,257 @@
|
|
1
|
+
require_dependency "scimitar/application_controller"
|
2
|
+
|
3
|
+
module Scimitar
|
4
|
+
|
5
|
+
# An ActiveRecord-centric subclass of Scimitar::ResourcesController. See that
|
6
|
+
# class's documentation first, as it describes things that your subclass must
|
7
|
+
# do which apply equally to subclasses of this ActiveRecord-focused code.
|
8
|
+
#
|
9
|
+
# In addition to requirements mentioned above, your subclass MUST override
|
10
|
+
# protected method #storage_scope, returning an ActiveRecord::Relation which
|
11
|
+
# is used as a starting scope for any 'index' (list) views. This gives you an
|
12
|
+
# opportunity to apply things like is-active filters, apply soft deletion
|
13
|
+
# scopes, apply security scopes and so-on. For example:
|
14
|
+
#
|
15
|
+
# protected
|
16
|
+
# def storage_scope
|
17
|
+
# self.storage_class().where(is_deleted: false)
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
class ActiveRecordBackedResourcesController < ResourcesController
|
21
|
+
|
22
|
+
rescue_from ActiveRecord::RecordNotFound, with: :handle_resource_not_found # See Scimitar::ApplicationController
|
23
|
+
|
24
|
+
before_action :obtain_id_column_name_from_attribute_map
|
25
|
+
|
26
|
+
# GET (list)
|
27
|
+
#
|
28
|
+
def index
|
29
|
+
query = if params[:filter].blank?
|
30
|
+
self.storage_scope()
|
31
|
+
else
|
32
|
+
attribute_map = storage_class().new.scim_queryable_attributes()
|
33
|
+
parser = ::Scimitar::Lists::QueryParser.new(attribute_map)
|
34
|
+
|
35
|
+
parser.parse(params[:filter])
|
36
|
+
parser.to_activerecord_query(self.storage_scope())
|
37
|
+
end
|
38
|
+
|
39
|
+
pagination_info = scim_pagination_info(query.count())
|
40
|
+
|
41
|
+
page_of_results = query
|
42
|
+
.order(@id_column => :asc)
|
43
|
+
.offset(pagination_info.offset)
|
44
|
+
.limit(pagination_info.limit)
|
45
|
+
.to_a()
|
46
|
+
|
47
|
+
super(pagination_info, page_of_results) do | record |
|
48
|
+
record_to_scim(record)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# GET/id (show)
|
53
|
+
#
|
54
|
+
def show
|
55
|
+
super do |record_id|
|
56
|
+
record = self.find_record(record_id)
|
57
|
+
record_to_scim(record)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# POST (create)
|
62
|
+
#
|
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)
|
72
|
+
super do |scim_resource|
|
73
|
+
self.storage_class().transaction do
|
74
|
+
record = self.storage_class().new
|
75
|
+
record.from_scim!(scim_hash: scim_resource.as_json())
|
76
|
+
self.save!(record, &block)
|
77
|
+
record_to_scim(record)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# PUT (replace)
|
83
|
+
#
|
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)
|
89
|
+
super do |record_id, scim_resource|
|
90
|
+
self.storage_class().transaction do
|
91
|
+
record = self.find_record(record_id)
|
92
|
+
record.from_scim!(scim_hash: scim_resource.as_json())
|
93
|
+
self.save!(record, &block)
|
94
|
+
record_to_scim(record)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# PATCH (update)
|
100
|
+
#
|
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)
|
106
|
+
super do |record_id, patch_hash|
|
107
|
+
self.storage_class().transaction do
|
108
|
+
record = self.find_record(record_id)
|
109
|
+
record.from_scim_patch!(patch_hash: patch_hash)
|
110
|
+
self.save!(record, &block)
|
111
|
+
record_to_scim(record)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# DELETE (remove)
|
117
|
+
#
|
118
|
+
# Deletion methods can vary quite a lot with ActiveRecord objects. If you
|
119
|
+
# just let this superclass handle things, it'll call:
|
120
|
+
#
|
121
|
+
# https://api.rubyonrails.org/classes/ActiveRecord/Persistence.html#method-i-destroy-21
|
122
|
+
#
|
123
|
+
# ...i.e. the standard delete-record-with-callbacks method. If you pass
|
124
|
+
# a block, then this block is invoked and passed the ActiveRecord model
|
125
|
+
# instance to be destroyed. You can then do things like soft-deletions,
|
126
|
+
# updating an "active" flag, perform audit-related operations and so-on.
|
127
|
+
#
|
128
|
+
def destroy(&block)
|
129
|
+
super do |record_id|
|
130
|
+
record = self.find_record(record_id)
|
131
|
+
|
132
|
+
if block_given?
|
133
|
+
yield(record)
|
134
|
+
else
|
135
|
+
record.destroy!
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
# =========================================================================
|
141
|
+
# PROTECTED INSTANCE METHODS
|
142
|
+
# =========================================================================
|
143
|
+
#
|
144
|
+
protected
|
145
|
+
|
146
|
+
# Return an ActiveRecord::Relation used as the starting scope for #index
|
147
|
+
# lists and any 'find by ID' operation.
|
148
|
+
#
|
149
|
+
def storage_scope
|
150
|
+
raise NotImplementedError
|
151
|
+
end
|
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
|
+
|
164
|
+
# Find a record by ID. Subclasses can override this if they need special
|
165
|
+
# lookup behaviour.
|
166
|
+
#
|
167
|
+
# +record_id+:: Record ID (SCIM schema 'id' value - "our" ID).
|
168
|
+
#
|
169
|
+
def find_record(record_id)
|
170
|
+
self.storage_scope().find_by!(@id_column => record_id)
|
171
|
+
end
|
172
|
+
|
173
|
+
# DRY up controller actions - pass a record; returns the SCIM
|
174
|
+
# representation, with a "show" location specified via #url_for.
|
175
|
+
#
|
176
|
+
def record_to_scim(record)
|
177
|
+
record.to_scim(
|
178
|
+
location: url_for(action: :show, id: record.send(@id_column)),
|
179
|
+
include_attributes: params.fetch(:attributes, "").split(",")
|
180
|
+
)
|
181
|
+
end
|
182
|
+
|
183
|
+
# Save a record, dealing with validation exceptions by raising SCIM
|
184
|
+
# errors.
|
185
|
+
#
|
186
|
+
# +record+:: ActiveRecord subclass to save.
|
187
|
+
#
|
188
|
+
# If you just let this superclass handle things, it'll call the standard
|
189
|
+
# +#save!+ method on the record. If you pass a block, then this block is
|
190
|
+
# invoked and passed the ActiveRecord model instance to be saved. You can
|
191
|
+
# then do things like calling a different method, using a service object
|
192
|
+
# of some kind, perform audit-related operations and so-on.
|
193
|
+
#
|
194
|
+
# The return value is not used internally, making life easier for
|
195
|
+
# overriding subclasses to "do the right thing" / avoid mistakes (instead
|
196
|
+
# of e.g. requiring that a to-SCIM representation of 'record' is returned
|
197
|
+
# and relying upon this to generate correct response payloads - an early
|
198
|
+
# version of the gem did this and it caused a confusing subclass bug).
|
199
|
+
#
|
200
|
+
def save!(record, &block)
|
201
|
+
if block_given?
|
202
|
+
yield(record)
|
203
|
+
else
|
204
|
+
record.save!
|
205
|
+
end
|
206
|
+
rescue *self.scimitar_rescuable_exceptions() => exception
|
207
|
+
handle_on_save_exception(record, exception)
|
208
|
+
end
|
209
|
+
|
210
|
+
# Deal with exceptions related to errors upon saving, by responding with
|
211
|
+
# an appropriate SCIM error. This is most effective if the record has
|
212
|
+
# validation errors defined, but falls back to the provided exception's
|
213
|
+
# message otherwise.
|
214
|
+
#
|
215
|
+
# +record+:: The record that provoked the exception. Mandatory.
|
216
|
+
# +exception+:: The exception that was raised. If omitted, a default of
|
217
|
+
# 'Unknown', in English with no I18n, is used.
|
218
|
+
#
|
219
|
+
def handle_on_save_exception(record, exception = RuntimeError.new('Unknown'))
|
220
|
+
details = if record.errors.present?
|
221
|
+
record.errors.full_messages.join('; ')
|
222
|
+
else
|
223
|
+
exception.message
|
224
|
+
end
|
225
|
+
|
226
|
+
# https://tools.ietf.org/html/rfc7644#page-12
|
227
|
+
#
|
228
|
+
# If the service provider determines that the creation of the requested
|
229
|
+
# resource conflicts with existing resources (e.g., a "User" resource
|
230
|
+
# with a duplicate "userName"), the service provider MUST return HTTP
|
231
|
+
# status code 409 (Conflict) with a "scimType" error code of
|
232
|
+
# "uniqueness"
|
233
|
+
#
|
234
|
+
if exception.is_a?(ActiveRecord::RecordNotUnique) || record.errors.any? { | e | e.type == :taken }
|
235
|
+
raise Scimitar::ErrorResponse.new(
|
236
|
+
status: 409,
|
237
|
+
scimType: 'uniqueness',
|
238
|
+
detail: "Operation failed due to a uniqueness constraint: #{details}"
|
239
|
+
)
|
240
|
+
else
|
241
|
+
raise Scimitar::ResourceInvalidError.new(details)
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
# Called via +before_action+ - stores in @id_column the name of whatever
|
246
|
+
# model column is used to store the record ID, via
|
247
|
+
# Scimitar::Resources::Mixin::scim_attributes_map.
|
248
|
+
#
|
249
|
+
# Default is <tt>:id</tt>.
|
250
|
+
#
|
251
|
+
def obtain_id_column_name_from_attribute_map
|
252
|
+
attrs = storage_class().scim_attributes_map() || {}
|
253
|
+
@id_column = attrs[:id] || :id
|
254
|
+
end
|
255
|
+
|
256
|
+
end
|
257
|
+
end
|
@@ -0,0 +1,157 @@
|
|
1
|
+
module Scimitar
|
2
|
+
class ApplicationController < ActionController::Base
|
3
|
+
|
4
|
+
rescue_from StandardError, with: :handle_unexpected_error
|
5
|
+
rescue_from ActionDispatch::Http::Parameters::ParseError, with: :handle_bad_json_error # Via "ActionDispatch::Request.parameter_parsers" block in lib/scimitar/engine.rb
|
6
|
+
rescue_from Scimitar::ErrorResponse, with: :handle_scim_error
|
7
|
+
|
8
|
+
before_action :require_scim
|
9
|
+
before_action :add_mandatory_response_headers
|
10
|
+
before_action :authenticate
|
11
|
+
|
12
|
+
if Scimitar.engine_configuration.application_controller_mixin
|
13
|
+
include Scimitar.engine_configuration.application_controller_mixin
|
14
|
+
end
|
15
|
+
|
16
|
+
# =========================================================================
|
17
|
+
# PROTECTED INSTANCE METHODS
|
18
|
+
# =========================================================================
|
19
|
+
#
|
20
|
+
protected
|
21
|
+
|
22
|
+
# You can use:
|
23
|
+
#
|
24
|
+
# rescue_from SomeException, with: :handle_resource_not_found
|
25
|
+
#
|
26
|
+
# ...to "globally" invoke this handler if you wish.
|
27
|
+
#
|
28
|
+
# +exception+:: Exception instance, used for a configured error reporter
|
29
|
+
# via #handle_scim_error (if present).
|
30
|
+
#
|
31
|
+
def handle_resource_not_found(exception)
|
32
|
+
handle_scim_error(NotFoundError.new(params[:id]), exception)
|
33
|
+
end
|
34
|
+
|
35
|
+
# This base controller uses:
|
36
|
+
#
|
37
|
+
# rescue_from Scimitar::ErrorResponse, with: :handle_scim_error
|
38
|
+
#
|
39
|
+
# ...to "globally" invoke this handler for all Scimitar errors (including
|
40
|
+
# subclasses).
|
41
|
+
#
|
42
|
+
# Mandatory parameters are:
|
43
|
+
#
|
44
|
+
# +error_response+:: Scimitar::ErrorResponse (or subclass) instance.
|
45
|
+
#
|
46
|
+
# Optional parameters are:
|
47
|
+
#
|
48
|
+
# *exception+:: If a Ruby exception was the reason this method is being
|
49
|
+
# called, pass it here. Any configured exception reporting
|
50
|
+
# mechanism will be invokved with the given parameter.
|
51
|
+
# Otherwise, the +error_response+ value is reported.
|
52
|
+
#
|
53
|
+
def handle_scim_error(error_response, exception = error_response)
|
54
|
+
unless Scimitar.engine_configuration.exception_reporter.nil?
|
55
|
+
Scimitar.engine_configuration.exception_reporter.call(exception)
|
56
|
+
end
|
57
|
+
|
58
|
+
render json: error_response, status: error_response.status
|
59
|
+
end
|
60
|
+
|
61
|
+
# This base controller uses:
|
62
|
+
#
|
63
|
+
# rescue_from ActionDispatch::Http::Parameters::ParseError, with: :handle_bad_json_error
|
64
|
+
#
|
65
|
+
# ...to "globally" handle JSON errors implied by parse errors raised via
|
66
|
+
# the "ActionDispatch::Request.parameter_parsers" block in
|
67
|
+
# lib/scimitar/engine.rb.
|
68
|
+
#
|
69
|
+
# +exception+:: Exception instance.
|
70
|
+
#
|
71
|
+
def handle_bad_json_error(exception)
|
72
|
+
handle_scim_error(ErrorResponse.new(status: 400, detail: "Invalid JSON - #{exception.message}"), exception)
|
73
|
+
end
|
74
|
+
|
75
|
+
# This base controller uses:
|
76
|
+
#
|
77
|
+
# rescue_from StandardError, with: :handle_unexpected_error
|
78
|
+
#
|
79
|
+
# ...to "globally" handle 500-style cases with a SCIM response.
|
80
|
+
#
|
81
|
+
# +exception+:: Exception instance.
|
82
|
+
#
|
83
|
+
def handle_unexpected_error(exception)
|
84
|
+
Rails.logger.error("#{exception.message}\n#{exception.backtrace}")
|
85
|
+
handle_scim_error(ErrorResponse.new(status: 500, detail: exception.message), exception)
|
86
|
+
end
|
87
|
+
|
88
|
+
# =========================================================================
|
89
|
+
# PRIVATE INSTANCE METHODS
|
90
|
+
# =========================================================================
|
91
|
+
#
|
92
|
+
private
|
93
|
+
|
94
|
+
# Tries to be permissive in what it receives - ".scim" extensions or a
|
95
|
+
# Content-Type header (or both) lead to both being set up for the inbound
|
96
|
+
# request and subclass processing.
|
97
|
+
#
|
98
|
+
def require_scim
|
99
|
+
scim_mime_type = Mime::Type.lookup_by_extension(:scim).to_s
|
100
|
+
|
101
|
+
if request.media_type.nil? || request.media_type.empty?
|
102
|
+
request.format = :scim
|
103
|
+
request.headers['CONTENT_TYPE'] = scim_mime_type
|
104
|
+
elsif request.media_type.downcase == scim_mime_type
|
105
|
+
request.format = :scim
|
106
|
+
elsif request.format == :scim
|
107
|
+
request.headers['CONTENT_TYPE'] = scim_mime_type
|
108
|
+
else
|
109
|
+
handle_scim_error(ErrorResponse.new(status: 406, detail: "Only #{scim_mime_type} type is accepted."))
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def add_mandatory_response_headers
|
114
|
+
|
115
|
+
# https://tools.ietf.org/html/rfc7644#section-2
|
116
|
+
#
|
117
|
+
# "...a SCIM service provider SHALL indicate supported HTTP
|
118
|
+
# authentication schemes via the "WWW-Authenticate" header."
|
119
|
+
#
|
120
|
+
# Rack may not handle an attempt to set two instances of the header and
|
121
|
+
# there is much debate on how to specify multiple methods in one header
|
122
|
+
# so we just let Token override Basic (since Token is much stronger, or
|
123
|
+
# at least has the potential to do so) if that's how Rack handles it.
|
124
|
+
#
|
125
|
+
# https://stackoverflow.com/questions/10239970/what-is-the-delimiter-for-www-authenticate-for-multiple-schemes
|
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?
|
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")
|
134
|
+
end
|
135
|
+
|
136
|
+
def authenticate
|
137
|
+
handle_scim_error(Scimitar::AuthenticationError.new) unless authenticated?
|
138
|
+
end
|
139
|
+
|
140
|
+
def authenticated?
|
141
|
+
result = if Scimitar.engine_configuration.basic_authenticator.present?
|
142
|
+
authenticate_with_http_basic do |username, password|
|
143
|
+
instance_exec(username, password, &Scimitar.engine_configuration.basic_authenticator)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
result ||= if Scimitar.engine_configuration.token_authenticator.present?
|
148
|
+
authenticate_with_http_token do |token, options|
|
149
|
+
instance_exec(token, options, &Scimitar.engine_configuration.token_authenticator)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
return result
|
154
|
+
end
|
155
|
+
|
156
|
+
end
|
157
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require_dependency "scimitar/application_controller"
|
2
|
+
|
3
|
+
module Scimitar
|
4
|
+
class ResourceTypesController < ApplicationController
|
5
|
+
def index
|
6
|
+
resource_types = Scimitar::Engine.resources.map do |resource|
|
7
|
+
resource.resource_type(scim_resource_type_url(name: resource.resource_type_id))
|
8
|
+
end
|
9
|
+
|
10
|
+
render json: resource_types
|
11
|
+
end
|
12
|
+
|
13
|
+
def show
|
14
|
+
resource_types = Scimitar::Engine.resources.reduce({}) do |hash, resource|
|
15
|
+
hash[resource.resource_type_id] = resource.resource_type(scim_resource_type_url(name: resource.resource_type_id))
|
16
|
+
hash
|
17
|
+
end
|
18
|
+
|
19
|
+
resource_type = resource_types[params[:name]]
|
20
|
+
|
21
|
+
if resource_type.nil?
|
22
|
+
raise Scimitar::NotFoundError.new(params[:name])
|
23
|
+
else
|
24
|
+
render json: resource_type
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,203 @@
|
|
1
|
+
require_dependency "scimitar/application_controller"
|
2
|
+
|
3
|
+
module Scimitar
|
4
|
+
|
5
|
+
# A Rails controller which is mostly idiomatic, with #index, #show, #create
|
6
|
+
# and #destroy methods mapping to the conventional HTTP methods in Rails.
|
7
|
+
# The #update method is used for partial-update PATCH calls, while the
|
8
|
+
# #replace method is used for whole-update PUT calls.
|
9
|
+
#
|
10
|
+
# Subclass this controller to deal with resource-specific API calls, for
|
11
|
+
# endpoints such as Users and Groups. Any one controller is assumed to be
|
12
|
+
# related to one class in your application which has mixed in
|
13
|
+
# Scimitar::Resources::Mixin. Your subclass MUST override protected method
|
14
|
+
# #storage_class, which returns that class. For example, if you had a class
|
15
|
+
# User with the mixin included, then:
|
16
|
+
#
|
17
|
+
# protected
|
18
|
+
# def storage_class
|
19
|
+
# User
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# ...is sufficient.
|
23
|
+
#
|
24
|
+
# The controller makes no assumptions about storage method - it does not have
|
25
|
+
# any ActiveRecord specialisations, for example. If you do use ActiveRecord,
|
26
|
+
# consider subclassing Scimitar::ActiveRecordBackedResourcesController
|
27
|
+
# instead as it does most of the mapping, persistence and error handling work
|
28
|
+
# for you.
|
29
|
+
#
|
30
|
+
class ResourcesController < ApplicationController
|
31
|
+
|
32
|
+
# GET (list)
|
33
|
+
#
|
34
|
+
# Pass a Scimitar::Lists::Count object providing pagination data along with
|
35
|
+
# a page of results in "your" data domain as an Enumerable, along with a
|
36
|
+
# block. Renders as "list" result by calling your block with each of the
|
37
|
+
# results, allowing you to use something like
|
38
|
+
# Scimitar::Resources::Mixin#to_scim to convert to a SCIM representation.
|
39
|
+
#
|
40
|
+
# +pagination_info+:: A Scimitar::Lists::Count instance with #total set.
|
41
|
+
# See e.g. protected method #scim_pagination_info to
|
42
|
+
# assist with this.
|
43
|
+
#
|
44
|
+
# +page_of_results+:: An Enumerable single page of results.
|
45
|
+
#
|
46
|
+
def index(pagination_info, page_of_results, &block)
|
47
|
+
render(json: {
|
48
|
+
schemas: [
|
49
|
+
'urn:ietf:params:scim:api:messages:2.0:ListResponse'
|
50
|
+
],
|
51
|
+
totalResults: pagination_info.total,
|
52
|
+
startIndex: pagination_info.start_index,
|
53
|
+
itemsPerPage: pagination_info.limit,
|
54
|
+
Resources: page_of_results.map(&block)
|
55
|
+
})
|
56
|
+
end
|
57
|
+
|
58
|
+
# GET/id (show)
|
59
|
+
#
|
60
|
+
# Call with a block that is passed an ID to find in "your" domain. Evaluate
|
61
|
+
# to the SCIM representation of the arising found record.
|
62
|
+
#
|
63
|
+
def show(&block)
|
64
|
+
scim_resource = yield(self.safe_params()[:id])
|
65
|
+
render(json: scim_resource)
|
66
|
+
end
|
67
|
+
|
68
|
+
# POST (create)
|
69
|
+
#
|
70
|
+
# Call with a block that is passed a SCIM resource instance - e.g a
|
71
|
+
# Scimitar::Resources::User instance - representing an item to be created.
|
72
|
+
# Your ::storage_class class's ::scim_resource_type method determines the
|
73
|
+
# kind of object you'll be given.
|
74
|
+
#
|
75
|
+
# See also e.g. Scimitar::Resources::Mixin#from_scim!.
|
76
|
+
#
|
77
|
+
# Evaluate to the SCIM representation of the arising created record.
|
78
|
+
#
|
79
|
+
def create(&block)
|
80
|
+
with_scim_resource() do |resource|
|
81
|
+
render(json: yield(resource, :create), status: :created)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# PUT (replace)
|
86
|
+
#
|
87
|
+
# Similar to #create, but you're passed an ID to find as well as the
|
88
|
+
# resource details to then use for all replacement attributes in that found
|
89
|
+
# resource. See also e.g. Scimitar::Resources::Mixin#from_scim!.
|
90
|
+
#
|
91
|
+
# Evaluate to the SCIM representation of the arising created record.
|
92
|
+
#
|
93
|
+
def replace(&block)
|
94
|
+
with_scim_resource() do |resource|
|
95
|
+
render(json: yield(self.safe_params()[:id], resource))
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# PATCH (update)
|
100
|
+
#
|
101
|
+
# A variant of #create where you're again passed the resource ID (in "your"
|
102
|
+
# domain) to look up, but then a Hash with patch operation details from the
|
103
|
+
# calling client. This can be passed to e.g.
|
104
|
+
# Scimitar::Resources::Mixin#from_scim_patch!.
|
105
|
+
#
|
106
|
+
# Evaluate to the SCIM representation of the arising created record.
|
107
|
+
#
|
108
|
+
def update(&block)
|
109
|
+
validate_request()
|
110
|
+
|
111
|
+
# Params includes all of the PATCH data at the top level along with other
|
112
|
+
# other Rails-injected params like 'id', 'action', 'controller'. These
|
113
|
+
# are harmless given no namespace collision and we're only interested in
|
114
|
+
# the 'Operations' key for the actual patch data.
|
115
|
+
#
|
116
|
+
render(json: yield(self.safe_params()[:id], self.safe_params().to_hash()))
|
117
|
+
end
|
118
|
+
|
119
|
+
# DELETE (remove)
|
120
|
+
#
|
121
|
+
def destroy
|
122
|
+
if yield(self.safe_params()[:id]) != false
|
123
|
+
head :no_content
|
124
|
+
else
|
125
|
+
raise ErrorResponse.new(
|
126
|
+
status: 500,
|
127
|
+
detail: "Failed to delete the resource with id '#{params[:id]}'. Please try again later."
|
128
|
+
)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# =========================================================================
|
133
|
+
# PROTECTED INSTANCE METHODS
|
134
|
+
# =========================================================================
|
135
|
+
#
|
136
|
+
protected
|
137
|
+
|
138
|
+
# The class including Scimitar::Resources::Mixin which declares mappings
|
139
|
+
# to the entity you return in #resource_type.
|
140
|
+
#
|
141
|
+
def storage_class
|
142
|
+
raise NotImplementedError
|
143
|
+
end
|
144
|
+
|
145
|
+
# For #index actions, returns a Scimitar::Lists::Count instance which can
|
146
|
+
# be used to access offset-vs-start-index (0-indexed or 1-indexed),
|
147
|
+
# per-page limit and also holds the total number-of-items count which you
|
148
|
+
# can optionally pass up-front here, or set via #total= later.
|
149
|
+
#
|
150
|
+
# +total_count+:: Optional integer total record count across all pages,
|
151
|
+
# else must be set later - BEFORE passing an instance to
|
152
|
+
# the #index implementation in this class.
|
153
|
+
#
|
154
|
+
def scim_pagination_info(total_count = nil)
|
155
|
+
::Scimitar::Lists::Count.new(
|
156
|
+
start_index: params[:startIndex],
|
157
|
+
limit: params[:count] || Scimitar.service_provider_configuration(location: nil).filter.maxResults,
|
158
|
+
total: total_count
|
159
|
+
)
|
160
|
+
end
|
161
|
+
|
162
|
+
# =========================================================================
|
163
|
+
# PRIVATE INSTANCE METHODS
|
164
|
+
# =========================================================================
|
165
|
+
#
|
166
|
+
private
|
167
|
+
|
168
|
+
def validate_request
|
169
|
+
if request.raw_post.blank?
|
170
|
+
raise Scimitar::ErrorResponse.new(status: 400, detail: 'must provide a request body')
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
def with_scim_resource
|
175
|
+
validate_request()
|
176
|
+
|
177
|
+
resource_type = storage_class().scim_resource_type() # See Scimitar::Resources::Mixin
|
178
|
+
resource = resource_type.new(self.safe_params().to_h)
|
179
|
+
|
180
|
+
if resource.valid?
|
181
|
+
yield(resource)
|
182
|
+
else
|
183
|
+
raise Scimitar::ErrorResponse.new(
|
184
|
+
status: 400,
|
185
|
+
detail: "Invalid resource: #{resource.errors.full_messages.join(', ')}.",
|
186
|
+
scimType: 'invalidValue'
|
187
|
+
)
|
188
|
+
end
|
189
|
+
|
190
|
+
# Gem bugs aside - if this happens, we couldn't create "resource"; bad
|
191
|
+
# (or unsupported) attributes encountered in inbound payload data.
|
192
|
+
#
|
193
|
+
rescue NoMethodError => exception
|
194
|
+
Rails.logger.error("#{exception.message}\n#{exception.backtrace}")
|
195
|
+
raise Scimitar::ErrorResponse.new(status: 400, detail: 'Invalid request')
|
196
|
+
end
|
197
|
+
|
198
|
+
def safe_params
|
199
|
+
params.permit!
|
200
|
+
end
|
201
|
+
|
202
|
+
end
|
203
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require_dependency "scimitar/application_controller"
|
2
|
+
|
3
|
+
module Scimitar
|
4
|
+
class SchemasController < ApplicationController
|
5
|
+
def index
|
6
|
+
schemas = Scimitar::Engine.schemas
|
7
|
+
|
8
|
+
schemas.each do |schema|
|
9
|
+
schema.meta.location = scim_schemas_url(name: schema.id)
|
10
|
+
end
|
11
|
+
|
12
|
+
schemas_by_id = schemas.reduce({}) do |hash, schema|
|
13
|
+
hash[schema.id] = schema
|
14
|
+
hash
|
15
|
+
end
|
16
|
+
|
17
|
+
render json: schemas_by_id[params[:name]] || schemas
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
end
|