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