better_auth-scim 0.2.0 → 0.5.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 +4 -4
- data/lib/better_auth/plugins/scim.rb +14 -676
- data/lib/better_auth/scim/client.rb +15 -0
- data/lib/better_auth/scim/mappings.rb +47 -0
- data/lib/better_auth/scim/middlewares.rb +30 -0
- data/lib/better_auth/scim/patch_operations.rb +46 -0
- data/lib/better_auth/scim/routes.rb +421 -0
- data/lib/better_auth/scim/scim_error.rb +17 -0
- data/lib/better_auth/scim/scim_filters.rb +21 -0
- data/lib/better_auth/scim/scim_metadata.rb +42 -0
- data/lib/better_auth/scim/scim_resources.rb +26 -0
- data/lib/better_auth/scim/scim_tokens.rb +56 -0
- data/lib/better_auth/scim/user_schemas.rb +61 -0
- data/lib/better_auth/scim/utils.rb +17 -0
- data/lib/better_auth/scim/version.rb +1 -1
- data/lib/better_auth/scim.rb +12 -0
- metadata +13 -1
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
require_relative "../scim/version"
|
|
4
|
+
require_relative "../scim/scim_metadata"
|
|
5
|
+
require_relative "../scim/scim_error"
|
|
6
|
+
require_relative "../scim/utils"
|
|
7
|
+
require_relative "../scim/client"
|
|
8
|
+
require_relative "../scim/user_schemas"
|
|
9
|
+
require_relative "../scim/scim_resources"
|
|
10
|
+
require_relative "../scim/mappings"
|
|
11
|
+
require_relative "../scim/scim_filters"
|
|
12
|
+
require_relative "../scim/patch_operations"
|
|
13
|
+
require_relative "../scim/scim_tokens"
|
|
14
|
+
require_relative "../scim/middlewares"
|
|
15
|
+
require_relative "../scim/routes"
|
|
5
16
|
|
|
6
17
|
module BetterAuth
|
|
7
18
|
module Plugins
|
|
8
|
-
SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"
|
|
9
|
-
SCIM_LIST_RESPONSE_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
|
|
10
|
-
SCIM_USER_SCHEMA_ID = "urn:ietf:params:scim:schemas:core:2.0:User"
|
|
11
|
-
SCIM_SUPPORTED_MEDIA_TYPES = ["application/json", "application/scim+json"].freeze
|
|
12
|
-
|
|
13
19
|
module_function
|
|
14
20
|
|
|
15
21
|
remove_method :scim if method_defined?(:scim) || private_method_defined?(:scim)
|
|
@@ -17,12 +23,11 @@ module BetterAuth
|
|
|
17
23
|
|
|
18
24
|
def scim(options = {})
|
|
19
25
|
config = {store_scim_token: "plain"}.merge(normalize_hash(options))
|
|
20
|
-
schema = scim_schema(config)
|
|
21
26
|
Plugin.new(
|
|
22
27
|
id: "scim",
|
|
23
28
|
version: BetterAuth::SCIM::VERSION,
|
|
24
29
|
client: scim_client,
|
|
25
|
-
schema:
|
|
30
|
+
schema: scim_schema(config),
|
|
26
31
|
endpoints: {
|
|
27
32
|
generate_scim_token: scim_generate_token_endpoint(config),
|
|
28
33
|
list_scim_provider_connections: scim_list_provider_connections_endpoint(config),
|
|
@@ -44,44 +49,6 @@ module BetterAuth
|
|
|
44
49
|
)
|
|
45
50
|
end
|
|
46
51
|
|
|
47
|
-
def scim_client
|
|
48
|
-
{
|
|
49
|
-
"id" => "scim-client",
|
|
50
|
-
"version" => BetterAuth::SCIM::VERSION,
|
|
51
|
-
"serverPluginId" => "scim"
|
|
52
|
-
}
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def scim_hidden_metadata(summary, allowed_media_types)
|
|
56
|
-
{
|
|
57
|
-
hide: true,
|
|
58
|
-
allowed_media_types: allowed_media_types,
|
|
59
|
-
openapi: {
|
|
60
|
-
summary: summary,
|
|
61
|
-
responses: scim_openapi_responses
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def scim_openapi_metadata(summary)
|
|
67
|
-
{
|
|
68
|
-
openapi: {
|
|
69
|
-
summary: summary,
|
|
70
|
-
responses: scim_openapi_responses
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def scim_openapi_responses
|
|
76
|
-
{
|
|
77
|
-
"200" => {description: "Success"},
|
|
78
|
-
"400" => {description: "Bad Request"},
|
|
79
|
-
"401" => {description: "Unauthorized"},
|
|
80
|
-
"403" => {description: "Forbidden"},
|
|
81
|
-
"404" => {description: "Not Found"}
|
|
82
|
-
}
|
|
83
|
-
end
|
|
84
|
-
|
|
85
52
|
def scim_schema(config = {})
|
|
86
53
|
scim_provider_fields = {
|
|
87
54
|
providerId: {type: "string", required: true, unique: true},
|
|
@@ -96,634 +63,5 @@ module BetterAuth
|
|
|
96
63
|
}
|
|
97
64
|
}
|
|
98
65
|
end
|
|
99
|
-
|
|
100
|
-
def scim_generate_token_endpoint(config)
|
|
101
|
-
Endpoint.new(path: "/scim/generate-token", method: "POST", metadata: scim_openapi_metadata("Generates a new SCIM token for the given provider")) do |ctx|
|
|
102
|
-
session = Routes.current_session(ctx)
|
|
103
|
-
body = normalize_hash(ctx.body)
|
|
104
|
-
provider_id = body[:provider_id].to_s
|
|
105
|
-
organization_id = body[:organization_id]
|
|
106
|
-
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["MISSING_FIELD"]) if provider_id.empty?
|
|
107
|
-
raise APIError.new("BAD_REQUEST", message: "Provider id contains forbidden characters") if provider_id.include?(":")
|
|
108
|
-
required_roles = scim_required_roles(ctx, config)
|
|
109
|
-
if organization_id && !scim_has_organization_plugin?(ctx)
|
|
110
|
-
raise APIError.new("BAD_REQUEST", message: "Restricting a token to an organization requires the organization plugin")
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
member = nil
|
|
114
|
-
if organization_id
|
|
115
|
-
member = scim_find_organization_member(ctx, session.fetch(:user).fetch("id"), organization_id)
|
|
116
|
-
raise APIError.new("FORBIDDEN", message: "You are not a member of the organization") unless member
|
|
117
|
-
raise APIError.new("FORBIDDEN", message: "Insufficient role for this operation") unless scim_has_required_role?(member.fetch("role", ""), required_roles)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
where = [{field: "providerId", value: provider_id}]
|
|
121
|
-
where << {field: "organizationId", value: organization_id} if organization_id
|
|
122
|
-
existing = ctx.context.adapter.find_one(model: "scimProvider", where: where)
|
|
123
|
-
scim_assert_provider_access!(ctx, session.fetch(:user).fetch("id"), existing, required_roles) if existing
|
|
124
|
-
|
|
125
|
-
base_token = Crypto.random_string(24)
|
|
126
|
-
token = Base64.urlsafe_encode64([base_token, provider_id, organization_id].compact.join(":"), padding: false)
|
|
127
|
-
scim_call_token_hook(config[:before_scim_token_generated], user: session.fetch(:user), member: member, scim_token: token)
|
|
128
|
-
stored = scim_store_token(ctx, config, base_token)
|
|
129
|
-
data = {providerId: provider_id, scimToken: stored, organizationId: organization_id}
|
|
130
|
-
data[:userId] = session.fetch(:user).fetch("id") if scim_provider_ownership_enabled?(config)
|
|
131
|
-
ctx.context.adapter.delete(model: "scimProvider", where: [{field: "id", value: existing.fetch("id")}]) if existing
|
|
132
|
-
provider = ctx.context.adapter.create(model: "scimProvider", data: data)
|
|
133
|
-
scim_call_token_hook(config[:after_scim_token_generated], user: session.fetch(:user), member: member, scim_token: token, scim_provider: provider)
|
|
134
|
-
ctx.json({scimToken: token}, status: 201)
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def scim_list_provider_connections_endpoint(config)
|
|
139
|
-
Endpoint.new(path: "/scim/list-provider-connections", method: "GET", metadata: scim_openapi_metadata("List SCIM provider connections.")) do |ctx|
|
|
140
|
-
session = Routes.current_session(ctx)
|
|
141
|
-
user_id = session.fetch(:user).fetch("id")
|
|
142
|
-
required_roles = scim_required_roles(ctx, config)
|
|
143
|
-
org_memberships = scim_has_organization_plugin?(ctx) ? scim_user_org_memberships(ctx, user_id) : {}
|
|
144
|
-
providers = ctx.context.adapter.find_many(model: "scimProvider").select do |provider|
|
|
145
|
-
organization_id = provider["organizationId"]
|
|
146
|
-
if organization_id
|
|
147
|
-
member = org_memberships[organization_id]
|
|
148
|
-
member && scim_has_required_role?(member.fetch("role", ""), required_roles)
|
|
149
|
-
else
|
|
150
|
-
!provider.key?("userId") || provider["userId"].nil? || provider["userId"] == user_id
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
ctx.json({providers: providers.map { |provider| scim_normalized_provider(provider) }})
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def scim_get_provider_connection_endpoint(config)
|
|
158
|
-
Endpoint.new(path: "/scim/get-provider-connection", method: "GET", metadata: scim_openapi_metadata("Get SCIM provider connection.")) do |ctx|
|
|
159
|
-
session = Routes.current_session(ctx)
|
|
160
|
-
provider = scim_provider_by_provider_id!(ctx, ctx.query[:provider_id] || ctx.query["providerId"] || ctx.query["provider_id"])
|
|
161
|
-
scim_assert_provider_access!(ctx, session.fetch(:user).fetch("id"), provider, scim_required_roles(ctx, config))
|
|
162
|
-
ctx.json(scim_normalized_provider(provider))
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def scim_delete_provider_connection_endpoint(config)
|
|
167
|
-
Endpoint.new(path: "/scim/delete-provider-connection", method: "POST", metadata: scim_openapi_metadata("Delete SCIM provider connection.")) do |ctx|
|
|
168
|
-
session = Routes.current_session(ctx)
|
|
169
|
-
body = normalize_hash(ctx.body)
|
|
170
|
-
provider = scim_provider_by_provider_id!(ctx, body[:provider_id])
|
|
171
|
-
scim_assert_provider_access!(ctx, session.fetch(:user).fetch("id"), provider, scim_required_roles(ctx, config))
|
|
172
|
-
ctx.context.adapter.delete(model: "scimProvider", where: [{field: "providerId", value: provider.fetch("providerId")}])
|
|
173
|
-
ctx.json({success: true})
|
|
174
|
-
end
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def scim_create_user_endpoint(config)
|
|
178
|
-
Endpoint.new(path: "/scim/v2/Users", method: "POST", metadata: scim_hidden_metadata("Create SCIM user.", SCIM_SUPPORTED_MEDIA_TYPES), use: [scim_auth_middleware(config)]) do |ctx|
|
|
179
|
-
body = normalize_hash(ctx.body)
|
|
180
|
-
scim_validate_user_body!(body)
|
|
181
|
-
provider = ctx.context.scim_provider
|
|
182
|
-
provider_id = provider.fetch("providerId")
|
|
183
|
-
email = scim_primary_email(body).downcase
|
|
184
|
-
account_id = scim_account_id(body)
|
|
185
|
-
existing_account = ctx.context.adapter.find_one(model: "account", where: [{field: "accountId", value: account_id}, {field: "providerId", value: provider_id}])
|
|
186
|
-
raise scim_error("CONFLICT", "User already exists", scim_type: "uniqueness") if existing_account
|
|
187
|
-
|
|
188
|
-
user, account = ctx.context.adapter.transaction do
|
|
189
|
-
user = ctx.context.internal_adapter.find_user_by_email(email)&.fetch(:user)
|
|
190
|
-
user ||= ctx.context.internal_adapter.create_user(
|
|
191
|
-
email: email,
|
|
192
|
-
name: scim_display_name(body, email),
|
|
193
|
-
emailVerified: true
|
|
194
|
-
)
|
|
195
|
-
account = ctx.context.internal_adapter.create_account(
|
|
196
|
-
userId: user.fetch("id"),
|
|
197
|
-
providerId: provider_id,
|
|
198
|
-
accountId: account_id,
|
|
199
|
-
accessToken: "",
|
|
200
|
-
refreshToken: ""
|
|
201
|
-
)
|
|
202
|
-
scim_create_org_membership(ctx, user.fetch("id"), provider["organizationId"])
|
|
203
|
-
[user, account]
|
|
204
|
-
end
|
|
205
|
-
resource = scim_user_resource(user, account, ctx.context.base_url)
|
|
206
|
-
ctx.json(resource, status: 201, headers: {location: resource.fetch(:meta).fetch(:location)})
|
|
207
|
-
end
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def scim_update_user_endpoint(config)
|
|
211
|
-
Endpoint.new(path: "/scim/v2/Users/:userId", method: "PUT", metadata: scim_hidden_metadata("Update SCIM user.", SCIM_SUPPORTED_MEDIA_TYPES), use: [scim_auth_middleware(config)]) do |ctx|
|
|
212
|
-
user, account = scim_find_user_with_account!(ctx)
|
|
213
|
-
body = normalize_hash(ctx.body)
|
|
214
|
-
scim_validate_user_body!(body)
|
|
215
|
-
updated, updated_account = ctx.context.adapter.transaction do
|
|
216
|
-
[
|
|
217
|
-
ctx.context.internal_adapter.update_user(user.fetch("id"), scim_user_update(body)),
|
|
218
|
-
ctx.context.internal_adapter.update_account(account.fetch("id"), accountId: scim_account_id(body), updatedAt: Time.now)
|
|
219
|
-
]
|
|
220
|
-
end
|
|
221
|
-
ctx.json(scim_user_resource(updated, updated_account, ctx.context.base_url))
|
|
222
|
-
end
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
def scim_patch_user_endpoint(config)
|
|
226
|
-
Endpoint.new(path: "/scim/v2/Users/:userId", method: "PATCH", metadata: scim_hidden_metadata("Patch SCIM user.", SCIM_SUPPORTED_MEDIA_TYPES), use: [scim_auth_middleware(config)]) do |ctx|
|
|
227
|
-
user, account = scim_find_user_with_account!(ctx)
|
|
228
|
-
body = normalize_hash(ctx.body)
|
|
229
|
-
scim_validate_patch_body!(body)
|
|
230
|
-
update = {}
|
|
231
|
-
account_update = {}
|
|
232
|
-
Array(body[:operations] || ctx.body["Operations"]).each do |operation|
|
|
233
|
-
op = normalize_hash(operation)
|
|
234
|
-
operation_name = op[:op].to_s.empty? ? "replace" : op[:op].to_s.downcase
|
|
235
|
-
raise scim_error("BAD_REQUEST", "Invalid SCIM patch operation") unless %w[replace add remove].include?(operation_name)
|
|
236
|
-
|
|
237
|
-
if op[:value].is_a?(Hash)
|
|
238
|
-
patch_path = op[:path].to_s.empty? ? nil : op[:path]
|
|
239
|
-
scim_apply_patch_value!(user, update, account_update, normalize_hash(op[:value]), operation_name, patch_path)
|
|
240
|
-
next
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
scim_apply_patch_path!(user, update, account_update, op[:path], op[:value], operation_name)
|
|
244
|
-
end
|
|
245
|
-
raise scim_error("BAD_REQUEST", "No valid fields to update") if update.empty? && account_update.empty?
|
|
246
|
-
|
|
247
|
-
ctx.context.internal_adapter.update_user(user.fetch("id"), update.merge(updatedAt: Time.now)) unless update.empty?
|
|
248
|
-
ctx.context.internal_adapter.update_account(account.fetch("id"), account_update.merge(updatedAt: Time.now)) unless account_update.empty?
|
|
249
|
-
ctx.json(nil, status: 204)
|
|
250
|
-
end
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
def scim_delete_user_endpoint(config)
|
|
254
|
-
Endpoint.new(path: "/scim/v2/Users/:userId", method: "DELETE", metadata: scim_hidden_metadata("Delete SCIM user.", [*SCIM_SUPPORTED_MEDIA_TYPES, ""]), use: [scim_auth_middleware(config)]) do |ctx|
|
|
255
|
-
user, = scim_find_user_with_account!(ctx)
|
|
256
|
-
ctx.context.internal_adapter.delete_user(user.fetch("id"))
|
|
257
|
-
ctx.json(nil, status: 204)
|
|
258
|
-
end
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
def scim_list_users_endpoint(config)
|
|
262
|
-
Endpoint.new(path: "/scim/v2/Users", method: "GET", metadata: scim_hidden_metadata("List SCIM users.", SCIM_SUPPORTED_MEDIA_TYPES), use: [scim_auth_middleware(config)]) do |ctx|
|
|
263
|
-
provider = ctx.context.scim_provider
|
|
264
|
-
accounts = ctx.context.adapter.find_many(model: "account", where: [{field: "providerId", value: provider.fetch("providerId")}])
|
|
265
|
-
users_by_id = ctx.context.internal_adapter.list_users.each_with_object({}) { |user, result| result[user.fetch("id")] = user }
|
|
266
|
-
users = accounts.filter_map { |account| users_by_id[account.fetch("userId")] }
|
|
267
|
-
if provider["organizationId"]
|
|
268
|
-
member_ids = ctx.context.adapter.find_many(
|
|
269
|
-
model: "member",
|
|
270
|
-
where: [{field: "organizationId", value: provider.fetch("organizationId")}]
|
|
271
|
-
).map { |member| member.fetch("userId") }
|
|
272
|
-
users = users.select { |user| member_ids.include?(user.fetch("id")) }
|
|
273
|
-
end
|
|
274
|
-
filter_field, filter_value = scim_parse_filter(ctx.query[:filter] || ctx.query["filter"]) if ctx.query[:filter] || ctx.query["filter"]
|
|
275
|
-
resources = users.filter_map do |user|
|
|
276
|
-
account = accounts.find { |entry| entry.fetch("userId") == user.fetch("id") }
|
|
277
|
-
resource = scim_user_resource(user, account, ctx.context.base_url)
|
|
278
|
-
next resource unless filter_field
|
|
279
|
-
|
|
280
|
-
(resource[filter_field.to_sym].to_s.downcase == filter_value.to_s.downcase) ? resource : nil
|
|
281
|
-
end
|
|
282
|
-
ctx.json({schemas: [SCIM_LIST_RESPONSE_SCHEMA], totalResults: resources.length, itemsPerPage: resources.length, startIndex: 1, Resources: resources})
|
|
283
|
-
end
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
def scim_get_user_endpoint(config)
|
|
287
|
-
Endpoint.new(path: "/scim/v2/Users/:userId", method: "GET", metadata: scim_hidden_metadata("Get SCIM user.", SCIM_SUPPORTED_MEDIA_TYPES), use: [scim_auth_middleware(config)]) do |ctx|
|
|
288
|
-
user, account = scim_find_user_with_account!(ctx)
|
|
289
|
-
ctx.json(scim_user_resource(user, account, ctx.context.base_url))
|
|
290
|
-
end
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def scim_service_provider_config_endpoint
|
|
294
|
-
Endpoint.new(path: "/scim/v2/ServiceProviderConfig", method: "GET", metadata: scim_hidden_metadata("SCIM Service Provider Configuration", SCIM_SUPPORTED_MEDIA_TYPES)) do |ctx|
|
|
295
|
-
ctx.json({
|
|
296
|
-
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
|
297
|
-
patch: {supported: true},
|
|
298
|
-
bulk: {supported: false},
|
|
299
|
-
filter: {supported: true},
|
|
300
|
-
changePassword: {supported: false},
|
|
301
|
-
sort: {supported: false},
|
|
302
|
-
etag: {supported: false},
|
|
303
|
-
authenticationSchemes: [{
|
|
304
|
-
type: "oauthbearertoken",
|
|
305
|
-
name: "OAuth Bearer Token",
|
|
306
|
-
description: "Authentication scheme using the Authorization header with a bearer token tied to an organization.",
|
|
307
|
-
specUri: "http://www.rfc-editor.org/info/rfc6750",
|
|
308
|
-
primary: true
|
|
309
|
-
}],
|
|
310
|
-
meta: {resourceType: "ServiceProviderConfig"}
|
|
311
|
-
})
|
|
312
|
-
end
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
def scim_schemas_endpoint
|
|
316
|
-
Endpoint.new(path: "/scim/v2/Schemas", method: "GET", metadata: scim_hidden_metadata("List SCIM schemas.", SCIM_SUPPORTED_MEDIA_TYPES)) do |ctx|
|
|
317
|
-
resource = scim_user_schema(ctx.context.base_url)
|
|
318
|
-
ctx.json({schemas: [SCIM_LIST_RESPONSE_SCHEMA], Resources: [resource], totalResults: 1, itemsPerPage: 1, startIndex: 1})
|
|
319
|
-
end
|
|
320
|
-
end
|
|
321
|
-
|
|
322
|
-
def scim_schema_endpoint
|
|
323
|
-
Endpoint.new(path: "/scim/v2/Schemas/:schemaId", method: "GET", metadata: scim_hidden_metadata("Get SCIM schema.", SCIM_SUPPORTED_MEDIA_TYPES)) do |ctx|
|
|
324
|
-
raise scim_error("NOT_FOUND", "Schema not found") unless scim_param(ctx, :schema_id).to_s == SCIM_USER_SCHEMA_ID
|
|
325
|
-
|
|
326
|
-
ctx.json(scim_user_schema(ctx.context.base_url))
|
|
327
|
-
end
|
|
328
|
-
end
|
|
329
|
-
|
|
330
|
-
def scim_resource_types_endpoint
|
|
331
|
-
Endpoint.new(path: "/scim/v2/ResourceTypes", method: "GET", metadata: scim_hidden_metadata("List SCIM resource types.", SCIM_SUPPORTED_MEDIA_TYPES)) do |ctx|
|
|
332
|
-
resource = scim_user_resource_type(ctx.context.base_url)
|
|
333
|
-
ctx.json({schemas: [SCIM_LIST_RESPONSE_SCHEMA], Resources: [resource], totalResults: 1, itemsPerPage: 1, startIndex: 1})
|
|
334
|
-
end
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
def scim_resource_type_endpoint
|
|
338
|
-
Endpoint.new(path: "/scim/v2/ResourceTypes/:resourceTypeId", method: "GET", metadata: scim_hidden_metadata("Get SCIM resource type.", SCIM_SUPPORTED_MEDIA_TYPES)) do |ctx|
|
|
339
|
-
raise scim_error("NOT_FOUND", "Resource type not found") unless scim_param(ctx, :resource_type_id) == "User"
|
|
340
|
-
|
|
341
|
-
ctx.json(scim_user_resource_type(ctx.context.base_url))
|
|
342
|
-
end
|
|
343
|
-
end
|
|
344
|
-
|
|
345
|
-
def scim_auth_middleware(config)
|
|
346
|
-
lambda do |ctx|
|
|
347
|
-
encoded = ctx.headers["authorization"].to_s.sub(/\ABearer\s+/i, "")
|
|
348
|
-
raise scim_error("UNAUTHORIZED", "SCIM token is required") if encoded.empty?
|
|
349
|
-
|
|
350
|
-
token, provider_id, organization_id = scim_decode_token(encoded)
|
|
351
|
-
provider = scim_default_provider(config, provider_id, organization_id)
|
|
352
|
-
if provider
|
|
353
|
-
raise scim_error("UNAUTHORIZED", "Invalid SCIM token") unless provider.fetch("scimToken") == token
|
|
354
|
-
else
|
|
355
|
-
provider = ctx.context.adapter.find_one(
|
|
356
|
-
model: "scimProvider",
|
|
357
|
-
where: [{field: "providerId", value: provider_id}].tap { |where| where << {field: "organizationId", value: organization_id} if organization_id }
|
|
358
|
-
)
|
|
359
|
-
raise scim_error("UNAUTHORIZED", "Invalid SCIM token") unless provider
|
|
360
|
-
raise scim_error("UNAUTHORIZED", "Invalid SCIM token") unless scim_token_matches?(ctx, config, token, provider.fetch("scimToken"))
|
|
361
|
-
end
|
|
362
|
-
|
|
363
|
-
ctx.context.apply_plugin_context!(scim_provider: provider)
|
|
364
|
-
nil
|
|
365
|
-
end
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
def scim_store_token(ctx, config, token)
|
|
369
|
-
storage = config[:store_scim_token]
|
|
370
|
-
if storage == "hashed"
|
|
371
|
-
Crypto.sha256(token, encoding: :base64url)
|
|
372
|
-
elsif storage == "encrypted"
|
|
373
|
-
Crypto.symmetric_encrypt(key: ctx.context.secret, data: token)
|
|
374
|
-
elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call)
|
|
375
|
-
storage[:hash].call(token)
|
|
376
|
-
elsif storage.is_a?(Hash) && storage[:encrypt].respond_to?(:call)
|
|
377
|
-
storage[:encrypt].call(token)
|
|
378
|
-
else
|
|
379
|
-
token
|
|
380
|
-
end
|
|
381
|
-
end
|
|
382
|
-
|
|
383
|
-
def scim_token_matches?(ctx, config, token, stored)
|
|
384
|
-
storage = config[:store_scim_token]
|
|
385
|
-
return Crypto.symmetric_decrypt(key: ctx.context.secret, data: stored) == token if storage == "encrypted"
|
|
386
|
-
return storage[:decrypt].call(stored) == token if storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)
|
|
387
|
-
|
|
388
|
-
!token.to_s.empty? && scim_store_token(ctx, config, token) == stored
|
|
389
|
-
end
|
|
390
|
-
|
|
391
|
-
def scim_decode_token(encoded)
|
|
392
|
-
decoded = Base64.urlsafe_decode64(encoded.to_s)
|
|
393
|
-
token, provider_id, *organization_parts = decoded.split(":")
|
|
394
|
-
raise scim_error("UNAUTHORIZED", "Invalid SCIM token") if token.to_s.empty? || provider_id.to_s.empty?
|
|
395
|
-
|
|
396
|
-
[token, provider_id, organization_parts.join(":").then { |value| value.empty? ? nil : value }]
|
|
397
|
-
rescue ArgumentError
|
|
398
|
-
raise scim_error("UNAUTHORIZED", "Invalid SCIM token")
|
|
399
|
-
end
|
|
400
|
-
|
|
401
|
-
def scim_default_provider(config, provider_id, organization_id)
|
|
402
|
-
Array(config[:default_scim]).find do |provider|
|
|
403
|
-
candidate = normalize_hash(provider)
|
|
404
|
-
next true if candidate[:provider_id].to_s == provider_id.to_s && organization_id.to_s.empty?
|
|
405
|
-
|
|
406
|
-
candidate[:provider_id].to_s == provider_id.to_s &&
|
|
407
|
-
!organization_id.to_s.empty? &&
|
|
408
|
-
candidate[:organization_id].to_s == organization_id.to_s
|
|
409
|
-
end&.then do |provider|
|
|
410
|
-
data = normalize_hash(provider)
|
|
411
|
-
{"providerId" => data[:provider_id], "scimToken" => data[:scim_token], "organizationId" => data[:organization_id]}
|
|
412
|
-
end
|
|
413
|
-
end
|
|
414
|
-
|
|
415
|
-
def scim_find_user_with_account!(ctx)
|
|
416
|
-
provider = ctx.context.scim_provider
|
|
417
|
-
user_id = scim_param(ctx, :user_id)
|
|
418
|
-
account = ctx.context.adapter.find_one(
|
|
419
|
-
model: "account",
|
|
420
|
-
where: [
|
|
421
|
-
{field: "userId", value: user_id},
|
|
422
|
-
{field: "providerId", value: provider.fetch("providerId")}
|
|
423
|
-
]
|
|
424
|
-
)
|
|
425
|
-
user = account && ctx.context.internal_adapter.find_user_by_id(user_id)
|
|
426
|
-
if user && provider["organizationId"]
|
|
427
|
-
member = ctx.context.adapter.find_one(
|
|
428
|
-
model: "member",
|
|
429
|
-
where: [{field: "organizationId", value: provider.fetch("organizationId")}, {field: "userId", value: user_id}]
|
|
430
|
-
)
|
|
431
|
-
user = nil unless member
|
|
432
|
-
end
|
|
433
|
-
raise scim_error("NOT_FOUND", "User not found") unless user && account
|
|
434
|
-
|
|
435
|
-
[user, account]
|
|
436
|
-
end
|
|
437
|
-
|
|
438
|
-
def scim_user_update(body)
|
|
439
|
-
{
|
|
440
|
-
email: scim_primary_email(body)&.downcase,
|
|
441
|
-
name: scim_display_name(body, body[:user_name].to_s),
|
|
442
|
-
updatedAt: Time.now
|
|
443
|
-
}.compact
|
|
444
|
-
end
|
|
445
|
-
|
|
446
|
-
def scim_validate_user_body!(body)
|
|
447
|
-
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body[:user_name].to_s.empty?
|
|
448
|
-
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body.key?(:external_id) && !body[:external_id].is_a?(String)
|
|
449
|
-
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body.key?(:name) && !body[:name].is_a?(Hash)
|
|
450
|
-
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body.key?(:emails) && !body[:emails].is_a?(Array)
|
|
451
|
-
|
|
452
|
-
Array(body[:emails]).each do |email|
|
|
453
|
-
email = normalize_hash(email)
|
|
454
|
-
value = email[:value]
|
|
455
|
-
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if email.key?(:primary) && ![true, false].include?(email[:primary])
|
|
456
|
-
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) unless value.to_s.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
|
|
457
|
-
end
|
|
458
|
-
end
|
|
459
|
-
|
|
460
|
-
def scim_validate_patch_body!(body)
|
|
461
|
-
schemas = Array(body[:schemas])
|
|
462
|
-
return if schemas.include?("urn:ietf:params:scim:api:messages:2.0:PatchOp")
|
|
463
|
-
|
|
464
|
-
raise scim_error("BAD_REQUEST", "Invalid schemas for PatchOp")
|
|
465
|
-
end
|
|
466
|
-
|
|
467
|
-
def scim_apply_patch_value!(user, update, account_update, value, operation_name, path = nil)
|
|
468
|
-
value.each do |key, nested_value|
|
|
469
|
-
nested_key = Schema.storage_key(key)
|
|
470
|
-
nested_path = path ? "#{path}.#{nested_key}" : nested_key
|
|
471
|
-
if nested_value.is_a?(Hash)
|
|
472
|
-
scim_apply_patch_value!(user, update, account_update, normalize_hash(nested_value), operation_name, nested_path)
|
|
473
|
-
else
|
|
474
|
-
scim_apply_patch_path!(user, update, account_update, nested_path, nested_value, operation_name)
|
|
475
|
-
end
|
|
476
|
-
end
|
|
477
|
-
end
|
|
478
|
-
|
|
479
|
-
def scim_apply_patch_path!(user, update, account_update, path, value, operation_name)
|
|
480
|
-
remove = operation_name == "remove"
|
|
481
|
-
normalized = "/" + path.to_s.sub(%r{\A/+}, "").tr(".", "/")
|
|
482
|
-
case normalized
|
|
483
|
-
when "/userName"
|
|
484
|
-
new_value = value.to_s.downcase
|
|
485
|
-
update[:email] = new_value if scim_patch_should_apply?(user["email"], new_value, operation_name)
|
|
486
|
-
when "/externalId"
|
|
487
|
-
account_update[:accountId] = value unless remove
|
|
488
|
-
when "/name/formatted"
|
|
489
|
-
update[:name] = value if scim_patch_should_apply?(user["name"], value, operation_name)
|
|
490
|
-
when "/name/givenName"
|
|
491
|
-
new_value = scim_full_name(user.fetch("email"), given_name: value, family_name: scim_family_name(update[:name] || user["name"]))
|
|
492
|
-
update[:name] = new_value if scim_patch_should_apply?(user["name"], new_value, operation_name)
|
|
493
|
-
when "/name/familyName"
|
|
494
|
-
new_value = scim_full_name(user.fetch("email"), given_name: scim_given_name(update[:name] || user["name"]), family_name: value)
|
|
495
|
-
update[:name] = new_value if scim_patch_should_apply?(user["name"], new_value, operation_name)
|
|
496
|
-
end
|
|
497
|
-
end
|
|
498
|
-
|
|
499
|
-
def scim_patch_should_apply?(current_value, new_value, operation_name)
|
|
500
|
-
return false if operation_name == "remove"
|
|
501
|
-
return false if operation_name == "add" && current_value == new_value
|
|
502
|
-
|
|
503
|
-
true
|
|
504
|
-
end
|
|
505
|
-
|
|
506
|
-
def scim_display_name(body, fallback = nil)
|
|
507
|
-
name = normalize_hash(body[:name] || {})
|
|
508
|
-
return name[:formatted].to_s.strip unless name[:formatted].to_s.strip.empty?
|
|
509
|
-
|
|
510
|
-
scim_full_name(fallback, given_name: name[:given_name], family_name: name[:family_name])
|
|
511
|
-
end
|
|
512
|
-
|
|
513
|
-
def scim_user_resource(user, account = nil, base_url = nil)
|
|
514
|
-
{
|
|
515
|
-
schemas: [SCIM_USER_SCHEMA_ID],
|
|
516
|
-
id: user.fetch("id"),
|
|
517
|
-
userName: user.fetch("email"),
|
|
518
|
-
externalId: account&.fetch("accountId", nil),
|
|
519
|
-
displayName: user["name"],
|
|
520
|
-
active: true,
|
|
521
|
-
name: {formatted: user["name"]},
|
|
522
|
-
emails: [{primary: true, value: user.fetch("email")}],
|
|
523
|
-
meta: {
|
|
524
|
-
resourceType: "User",
|
|
525
|
-
created: user["createdAt"],
|
|
526
|
-
lastModified: user["updatedAt"],
|
|
527
|
-
location: base_url ? "#{base_url}/scim/v2/Users/#{user.fetch("id")}" : nil
|
|
528
|
-
}.compact
|
|
529
|
-
}.compact
|
|
530
|
-
end
|
|
531
|
-
|
|
532
|
-
def scim_parse_filter(filter)
|
|
533
|
-
match = filter.to_s.match(/\A\s*([^\s]+)\s+(eq|ne|co|sw|ew|pr)\s*(?:"([^"]*)"|([^\s]+))?\s*\z/i)
|
|
534
|
-
raise scim_error("BAD_REQUEST", "Invalid SCIM filter", scim_type: "invalidFilter") unless match
|
|
535
|
-
|
|
536
|
-
field = match[1]
|
|
537
|
-
operator = match[2].downcase
|
|
538
|
-
raise scim_error("BAD_REQUEST", "The operator \"#{operator}\" is not supported", scim_type: "invalidFilter") unless operator == "eq"
|
|
539
|
-
raise scim_error("BAD_REQUEST", "The attribute \"#{field}\" is not supported", scim_type: "invalidFilter") unless field == "userName"
|
|
540
|
-
|
|
541
|
-
[field, match[3] || match[4]]
|
|
542
|
-
end
|
|
543
|
-
|
|
544
|
-
def scim_user_schema(base_url = nil)
|
|
545
|
-
{
|
|
546
|
-
id: SCIM_USER_SCHEMA_ID,
|
|
547
|
-
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
|
|
548
|
-
name: "User",
|
|
549
|
-
description: "User Account",
|
|
550
|
-
attributes: [
|
|
551
|
-
{name: "id", type: "string", multiValued: false, description: "Unique opaque identifier for the User", required: false, caseExact: true, mutability: "readOnly", returned: "default", uniqueness: "server"},
|
|
552
|
-
{name: "userName", type: "string", multiValued: false, description: "Unique identifier for the User, typically used by the user to directly authenticate to the service provider", required: true, caseExact: false, mutability: "readWrite", returned: "default", uniqueness: "server"},
|
|
553
|
-
{name: "displayName", type: "string", multiValued: false, description: "The name of the User, suitable for display to end-users. The name SHOULD be the full name of the User being described, if known.", required: false, caseExact: true, mutability: "readOnly", returned: "default", uniqueness: "none"},
|
|
554
|
-
{name: "active", type: "boolean", multiValued: false, description: "A Boolean value indicating the User's administrative status.", required: false, mutability: "readOnly", returned: "default"},
|
|
555
|
-
{
|
|
556
|
-
name: "name",
|
|
557
|
-
type: "complex",
|
|
558
|
-
multiValued: false,
|
|
559
|
-
description: "The components of the user's real name.",
|
|
560
|
-
required: false,
|
|
561
|
-
subAttributes: [
|
|
562
|
-
{name: "formatted", type: "string", multiValued: false, description: "The full name, including all middlenames, titles, and suffixes as appropriate, formatted for display(e.g., 'Ms. Barbara J Jensen, III').", required: false, caseExact: false, mutability: "readWrite", returned: "default", uniqueness: "none"},
|
|
563
|
-
{name: "familyName", type: "string", multiValued: false, description: "The family name of the User, or last name in most Western languages (e.g., 'Jensen' given the fullname 'Ms. Barbara J Jensen, III').", required: false, caseExact: false, mutability: "readWrite", returned: "default", uniqueness: "none"},
|
|
564
|
-
{name: "givenName", type: "string", multiValued: false, description: "The given name of the User, or first name in most Western languages (e.g., 'Barbara' given the full name 'Ms. Barbara J Jensen, III').", required: false, caseExact: false, mutability: "readWrite", returned: "default", uniqueness: "none"}
|
|
565
|
-
]
|
|
566
|
-
},
|
|
567
|
-
{
|
|
568
|
-
name: "emails",
|
|
569
|
-
type: "complex",
|
|
570
|
-
multiValued: true,
|
|
571
|
-
description: "Email addresses for the user. The value SHOULD be canonicalized by the service provider, e.g., 'bjensen@example.com' instead of 'bjensen@EXAMPLE.COM'. Canonical type values of 'work', 'home', and 'other'.",
|
|
572
|
-
required: false,
|
|
573
|
-
mutability: "readWrite",
|
|
574
|
-
returned: "default",
|
|
575
|
-
uniqueness: "none",
|
|
576
|
-
subAttributes: [
|
|
577
|
-
{name: "value", type: "string", multiValued: false, description: "Email addresses for the user. The value SHOULD be canonicalized by the service provider, e.g., 'bjensen@example.com' instead of 'bjensen@EXAMPLE.COM'. Canonical type values of 'work', 'home', and 'other'.", required: false, caseExact: false, mutability: "readWrite", returned: "default", uniqueness: "server"},
|
|
578
|
-
{name: "primary", type: "boolean", multiValued: false, description: "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred mailing address or primary email address. The primary attribute value 'true' MUST appear no more than once.", required: false, mutability: "readWrite", returned: "default"}
|
|
579
|
-
]
|
|
580
|
-
}
|
|
581
|
-
],
|
|
582
|
-
meta: {resourceType: "Schema", location: scim_resource_url(base_url, "/scim/v2/Schemas/#{SCIM_USER_SCHEMA_ID}")}
|
|
583
|
-
}
|
|
584
|
-
end
|
|
585
|
-
|
|
586
|
-
def scim_user_resource_type(base_url = nil)
|
|
587
|
-
{
|
|
588
|
-
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
|
|
589
|
-
id: "User",
|
|
590
|
-
name: "User",
|
|
591
|
-
endpoint: "/Users",
|
|
592
|
-
description: "User Account",
|
|
593
|
-
schema: SCIM_USER_SCHEMA_ID,
|
|
594
|
-
meta: {resourceType: "ResourceType", location: scim_resource_url(base_url, "/scim/v2/ResourceTypes/User")}
|
|
595
|
-
}
|
|
596
|
-
end
|
|
597
|
-
|
|
598
|
-
def scim_param(ctx, key)
|
|
599
|
-
ctx.params[key] || ctx.params[key.to_s] || ctx.params[Schema.storage_key(key)] || ctx.params[Schema.storage_key(key).to_sym]
|
|
600
|
-
end
|
|
601
|
-
|
|
602
|
-
def scim_has_organization_plugin?(ctx)
|
|
603
|
-
Array(ctx.context.options.plugins).any? { |plugin| plugin.id == "organization" }
|
|
604
|
-
end
|
|
605
|
-
|
|
606
|
-
def scim_organization_plugin(ctx)
|
|
607
|
-
Array(ctx.context.options.plugins).find { |plugin| plugin.id == "organization" }
|
|
608
|
-
end
|
|
609
|
-
|
|
610
|
-
def scim_required_roles(ctx, config)
|
|
611
|
-
configured = config[:required_role] || config[:required_roles]
|
|
612
|
-
return Array(configured).map(&:to_s) if configured
|
|
613
|
-
|
|
614
|
-
creator_role = scim_organization_plugin(ctx)&.options&.fetch(:creator_role, nil)
|
|
615
|
-
["admin", creator_role || "owner"].uniq
|
|
616
|
-
end
|
|
617
|
-
|
|
618
|
-
def scim_provider_ownership_enabled?(config)
|
|
619
|
-
normalize_hash(config[:provider_ownership] || {})[:enabled] == true
|
|
620
|
-
end
|
|
621
|
-
|
|
622
|
-
def scim_find_organization_member(ctx, user_id, organization_id)
|
|
623
|
-
ctx.context.adapter.find_one(
|
|
624
|
-
model: "member",
|
|
625
|
-
where: [
|
|
626
|
-
{field: "userId", value: user_id},
|
|
627
|
-
{field: "organizationId", value: organization_id}
|
|
628
|
-
]
|
|
629
|
-
)
|
|
630
|
-
end
|
|
631
|
-
|
|
632
|
-
def scim_parse_roles(role)
|
|
633
|
-
Array(role).flat_map { |entry| entry.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
634
|
-
end
|
|
635
|
-
|
|
636
|
-
def scim_has_required_role?(role, required_roles)
|
|
637
|
-
required = Array(required_roles).map(&:to_s)
|
|
638
|
-
required.empty? || scim_parse_roles(role).any? { |candidate| required.include?(candidate) }
|
|
639
|
-
end
|
|
640
|
-
|
|
641
|
-
def scim_user_org_memberships(ctx, user_id)
|
|
642
|
-
ctx.context.adapter.find_many(model: "member", where: [{field: "userId", value: user_id}]).each_with_object({}) do |member, result|
|
|
643
|
-
result[member.fetch("organizationId")] = member
|
|
644
|
-
end
|
|
645
|
-
end
|
|
646
|
-
|
|
647
|
-
def scim_assert_provider_access!(ctx, user_id, provider, required_roles)
|
|
648
|
-
return unless provider
|
|
649
|
-
|
|
650
|
-
organization_id = provider["organizationId"]
|
|
651
|
-
if organization_id
|
|
652
|
-
raise APIError.new("FORBIDDEN", message: "Organization plugin is required to access this SCIM provider") unless scim_has_organization_plugin?(ctx)
|
|
653
|
-
|
|
654
|
-
member = scim_find_organization_member(ctx, user_id, organization_id)
|
|
655
|
-
raise APIError.new("FORBIDDEN", message: "You must be a member of the organization to access this provider") unless member
|
|
656
|
-
raise APIError.new("FORBIDDEN", message: "Insufficient role for this operation") unless scim_has_required_role?(member.fetch("role", ""), required_roles)
|
|
657
|
-
elsif provider.key?("userId") && provider["userId"] && provider["userId"] != user_id
|
|
658
|
-
raise APIError.new("FORBIDDEN", message: "You must be the owner to access this provider")
|
|
659
|
-
end
|
|
660
|
-
end
|
|
661
|
-
|
|
662
|
-
def scim_provider_by_provider_id!(ctx, provider_id)
|
|
663
|
-
provider = ctx.context.adapter.find_one(model: "scimProvider", where: [{field: "providerId", value: provider_id.to_s}])
|
|
664
|
-
raise APIError.new("NOT_FOUND", message: "SCIM provider not found") unless provider
|
|
665
|
-
|
|
666
|
-
provider
|
|
667
|
-
end
|
|
668
|
-
|
|
669
|
-
def scim_normalized_provider(provider)
|
|
670
|
-
{
|
|
671
|
-
id: provider.fetch("id"),
|
|
672
|
-
providerId: provider.fetch("providerId"),
|
|
673
|
-
organizationId: provider["organizationId"]
|
|
674
|
-
}
|
|
675
|
-
end
|
|
676
|
-
|
|
677
|
-
def scim_call_token_hook(callback, payload)
|
|
678
|
-
callback.call(payload) if callback.respond_to?(:call)
|
|
679
|
-
end
|
|
680
|
-
|
|
681
|
-
def scim_error(status, detail, scim_type: nil)
|
|
682
|
-
body = {
|
|
683
|
-
schemas: [SCIM_ERROR_SCHEMA],
|
|
684
|
-
status: APIError::STATUS_CODES.fetch(status.to_s.upcase, 500).to_s,
|
|
685
|
-
detail: detail
|
|
686
|
-
}
|
|
687
|
-
body[:scimType] = scim_type if scim_type
|
|
688
|
-
APIError.new(status, message: detail, body: body)
|
|
689
|
-
end
|
|
690
|
-
|
|
691
|
-
def scim_resource_url(base_url, path)
|
|
692
|
-
return path unless base_url
|
|
693
|
-
|
|
694
|
-
"#{base_url}#{path}"
|
|
695
|
-
end
|
|
696
|
-
|
|
697
|
-
def scim_create_org_membership(ctx, user_id, organization_id)
|
|
698
|
-
return unless organization_id
|
|
699
|
-
return if ctx.context.adapter.find_one(model: "member", where: [{field: "organizationId", value: organization_id}, {field: "userId", value: user_id}])
|
|
700
|
-
|
|
701
|
-
ctx.context.adapter.create(model: "member", data: {userId: user_id, organizationId: organization_id, role: "member", createdAt: Time.now})
|
|
702
|
-
end
|
|
703
|
-
|
|
704
|
-
def scim_account_id(body)
|
|
705
|
-
body[:external_id] || body[:user_name].to_s.downcase
|
|
706
|
-
end
|
|
707
|
-
|
|
708
|
-
def scim_primary_email(body)
|
|
709
|
-
primary = Array(body[:emails]).find { |email| normalize_hash(email)[:primary] }
|
|
710
|
-
first = Array(body[:emails]).first
|
|
711
|
-
normalize_hash(primary || first)[:value] || body[:user_name]
|
|
712
|
-
end
|
|
713
|
-
|
|
714
|
-
def scim_full_name(fallback, given_name:, family_name:)
|
|
715
|
-
name = [given_name, family_name].compact.join(" ").strip
|
|
716
|
-
name.empty? ? fallback.to_s : name
|
|
717
|
-
end
|
|
718
|
-
|
|
719
|
-
def scim_given_name(name)
|
|
720
|
-
parts = name.to_s.split
|
|
721
|
-
(parts.length > 1) ? parts[0...-1].join(" ") : name.to_s
|
|
722
|
-
end
|
|
723
|
-
|
|
724
|
-
def scim_family_name(name)
|
|
725
|
-
parts = name.to_s.split
|
|
726
|
-
(parts.length > 1) ? parts[1..].join(" ") : ""
|
|
727
|
-
end
|
|
728
66
|
end
|
|
729
67
|
end
|