devise_scim 0.1.11
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/AGENTS.md +124 -0
- data/CHANGELOG.md +47 -0
- data/CODE_OF_CONDUCT.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +348 -0
- data/Rakefile +21 -0
- data/app/controllers/devise_scim/application_controller.rb +69 -0
- data/app/controllers/devise_scim/groups_controller.rb +67 -0
- data/app/controllers/devise_scim/resource_types_controller.rb +43 -0
- data/app/controllers/devise_scim/schemas_controller.rb +55 -0
- data/app/controllers/devise_scim/service_provider_controller.rb +34 -0
- data/app/controllers/devise_scim/users_controller.rb +281 -0
- data/docs/contributing.md +163 -0
- data/docs/custom_adapter.md +456 -0
- data/docs/idp_setup.md +335 -0
- data/docs/multi_tenant.md +328 -0
- data/docs/testing.md +444 -0
- data/lib/devise_scim/auth/base_strategy.rb +16 -0
- data/lib/devise_scim/auth/oauth_strategy.rb +28 -0
- data/lib/devise_scim/auth/token_strategy.rb +25 -0
- data/lib/devise_scim/concerns/scim_group_identifiable.rb +21 -0
- data/lib/devise_scim/concerns/scim_tenant.rb +41 -0
- data/lib/devise_scim/configuration.rb +92 -0
- data/lib/devise_scim/engine.rb +15 -0
- data/lib/devise_scim/filter/arel_visitor.rb +77 -0
- data/lib/devise_scim/filter/parser.rb +190 -0
- data/lib/devise_scim/middleware/authenticator.rb +51 -0
- data/lib/devise_scim/minitest.rb +57 -0
- data/lib/devise_scim/models/scim_tenant.rb +14 -0
- data/lib/devise_scim/models/scim_tenant_user.rb +15 -0
- data/lib/devise_scim/routing.rb +43 -0
- data/lib/devise_scim/rspec/factories.rb +17 -0
- data/lib/devise_scim/rspec/scim_helpers.rb +43 -0
- data/lib/devise_scim/rspec/shared_examples/discovery_endpoints.rb +94 -0
- data/lib/devise_scim/rspec/shared_examples/groups_endpoint.rb +148 -0
- data/lib/devise_scim/rspec/shared_examples/users_endpoint.rb +301 -0
- data/lib/devise_scim/rspec.rb +7 -0
- data/lib/devise_scim/scim/error.rb +59 -0
- data/lib/devise_scim/scim/group.rb +66 -0
- data/lib/devise_scim/scim/list_response.rb +32 -0
- data/lib/devise_scim/scim/patch_operation.rb +55 -0
- data/lib/devise_scim/scim/user.rb +161 -0
- data/lib/devise_scim/scim_adapter.rb +84 -0
- data/lib/devise_scim/version.rb +5 -0
- data/lib/devise_scim.rb +48 -0
- data/lib/generators/devise_scim/adapter_generator.rb +17 -0
- data/lib/generators/devise_scim/install_generator.rb +117 -0
- data/lib/generators/devise_scim/templates/add_scim_to_tenant.rb.tt +17 -0
- data/lib/generators/devise_scim/templates/add_scim_to_users.rb.tt +15 -0
- data/lib/generators/devise_scim/templates/application_scim_adapter.rb.tt +34 -0
- data/lib/generators/devise_scim/templates/create_scim_tenant_users.rb.tt +22 -0
- data/lib/generators/devise_scim/templates/create_scim_tenants.rb.tt +18 -0
- data/lib/generators/devise_scim/templates/devise_scim.rb.tt +53 -0
- data/sig/devise_scim.rbs +4 -0
- metadata +146 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# Custom Adapter Guide
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`devise_scim` separates protocol concerns from business logic using an adapter pattern. The gem handles all RFC 7643/7644 wire protocol — routing, authentication, filter parsing, serialization, and error responses. Your adapter handles the domain side: what attributes to persist, how to represent a user or group as SCIM JSON, and what side effects to trigger after provisioning events.
|
|
6
|
+
|
|
7
|
+
Every SCIM operation that touches a record instantiates your adapter and calls one method. You only override the methods relevant to your application.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Generating the adapter
|
|
12
|
+
|
|
13
|
+
Run the generator to create a pre-filled starting point:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
rails g devise_scim:adapter
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
This creates `app/scim/application_scim_adapter.rb` with every overridable method stubbed out as commented examples.
|
|
20
|
+
|
|
21
|
+
Then register it in your initializer:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# config/initializers/devise_scim.rb
|
|
25
|
+
DeviseScim.configure do |config|
|
|
26
|
+
config.adapter = "ApplicationScimAdapter"
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The string is constantized at runtime so the class can be autoloaded normally by Rails.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Adapter lifecycle
|
|
35
|
+
|
|
36
|
+
The gem instantiates `ScimAdapter.new(record, scim_object, tenant: tenant)` before each operation and calls one method. `record` is the AR user or group instance. `scim_object` is either a `Scim::User` or `Scim::Group` parsed from the request body.
|
|
37
|
+
|
|
38
|
+
| Operation | Method called | Notes |
|
|
39
|
+
|-----------|---------------|-------|
|
|
40
|
+
| `POST /Users` (new) | `attributes_for_create` | Attributes are passed to `record.assign_attributes` before `save!` |
|
|
41
|
+
| `POST /Users` (re-provision) | `attributes_for_update` + `after_provision` | Called when an inactive SCIM user is re-activated |
|
|
42
|
+
| `PUT /Users/:id` | `attributes_for_update` | Full replacement |
|
|
43
|
+
| `PATCH /Users/:id` | `attributes_for_update` (per op) | Applied once per SCIM operation |
|
|
44
|
+
| `DELETE /Users/:id` | `after_deprovision` | Called after the record is soft-deleted |
|
|
45
|
+
| `GET /Users`, `GET /Users/:id` | `to_scim` | Must return a `Scim::User` |
|
|
46
|
+
| `POST /Groups` | `handle_group_create` then `group_to_scim` | |
|
|
47
|
+
| `PATCH /Groups/:id` | `handle_group_update` then `group_to_scim` | |
|
|
48
|
+
| `DELETE /Groups/:id` | `handle_group_destroy` | Returns 204, no body |
|
|
49
|
+
| `GET /Groups`, `GET /Groups/:id` | `group_to_scim` | Must return a `Scim::Group` |
|
|
50
|
+
|
|
51
|
+
`after_provision` is called on **both** initial provisioning and re-provisioning. It is not called on updates.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## `attributes_for_create` / `attributes_for_update`
|
|
56
|
+
|
|
57
|
+
Both methods must return a `Hash` of ActiveRecord attribute names (symbols) to values. The hash is passed to `record.assign_attributes` before `save!` is called.
|
|
58
|
+
|
|
59
|
+
The base class maps `scim_user.user_name` (or `scim_user.primary_email`) to `email`, and conditionally maps `name.given_name` / `name.family_name` to `first_name` / `last_name` if those columns exist.
|
|
60
|
+
|
|
61
|
+
Override to add fields:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
class ApplicationScimAdapter < DeviseScim::ScimAdapter
|
|
65
|
+
def attributes_for_create
|
|
66
|
+
super.merge(
|
|
67
|
+
role: scim_user.user_type || "member",
|
|
68
|
+
department: scim_user.name&.formatted,
|
|
69
|
+
phone: scim_user.phone_numbers&.first&.value
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def attributes_for_update
|
|
74
|
+
super.merge(
|
|
75
|
+
department: scim_user.name&.formatted,
|
|
76
|
+
phone: scim_user.phone_numbers&.first&.value
|
|
77
|
+
)
|
|
78
|
+
# Don't override role on update — IdPs may not re-send it.
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Use `column?` to guard against attributes that may not exist in every deployment:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
def attributes_for_create
|
|
87
|
+
attrs = super
|
|
88
|
+
attrs[:role] = scim_user.user_type || "member" if column?(:role)
|
|
89
|
+
attrs
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Access fields from the parsed SCIM payload via `scim_user`:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
scim_user.user_name # "alice@example.com"
|
|
97
|
+
scim_user.primary_email # first email with primary: true, falls back to user_name
|
|
98
|
+
scim_user.name&.given_name # "Alice"
|
|
99
|
+
scim_user.name&.family_name # "Smith"
|
|
100
|
+
scim_user.name&.formatted # "Alice Smith" (if IdP sends it)
|
|
101
|
+
scim_user.user_type # "Employee" (Okta enterprise field)
|
|
102
|
+
scim_user.phone_numbers&.first&.value
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## `to_scim`
|
|
108
|
+
|
|
109
|
+
Must return a `Scim::User` instance. The base implementation populates `id`, `user_name`, `active`, `emails`, `name` (if `first_name`/`last_name` columns exist), and `meta`. Override to add custom attributes or change how fields map.
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
def to_scim
|
|
113
|
+
scim = Scim::User.new
|
|
114
|
+
scim.id = record.id.to_s
|
|
115
|
+
scim.external_id = record.scim_uid
|
|
116
|
+
scim.user_name = record.email
|
|
117
|
+
scim.display_name = "#{record.first_name} #{record.last_name}".strip
|
|
118
|
+
scim.active = resolve_active # use the private helper — handles scim_active/deleted_at/fallback
|
|
119
|
+
|
|
120
|
+
scim.name = Scim::Name.new(
|
|
121
|
+
given_name: record.first_name,
|
|
122
|
+
family_name: record.last_name,
|
|
123
|
+
formatted: "#{record.first_name} #{record.last_name}".strip
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
scim.emails = [
|
|
127
|
+
Scim::Email.new(value: record.email, type: "work", primary: true)
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
scim.meta = build_meta("User") # sets resource_type, created, last_modified from record timestamps
|
|
131
|
+
|
|
132
|
+
scim
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`Scim::Name`, `Scim::Email`, `Scim::PhoneNumber`, and `Scim::Meta` are keyword-argument structs defined in `DeviseScim::Scim`.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Lifecycle hooks
|
|
141
|
+
|
|
142
|
+
`after_provision` and `after_deprovision` are called after the record is saved. They have access to `record`, `scim_user`, and `tenant`.
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
def after_provision
|
|
146
|
+
# Send a welcome email the first time the user is provisioned.
|
|
147
|
+
# scim_deprovisioned_at is nil for brand-new users.
|
|
148
|
+
if record.scim_deprovisioned_at.nil?
|
|
149
|
+
UserMailer.welcome(record).deliver_later
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
record.update_columns(last_provisioned_at: Time.current)
|
|
153
|
+
|
|
154
|
+
AuditLog.create!(
|
|
155
|
+
action: "scim_provision",
|
|
156
|
+
user_id: record.id,
|
|
157
|
+
tenant_id: tenant&.id
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def after_deprovision
|
|
162
|
+
# Revoke active sessions and API keys.
|
|
163
|
+
record.active_tokens.revoke_all!
|
|
164
|
+
record.api_keys.update_all(revoked_at: Time.current)
|
|
165
|
+
|
|
166
|
+
AuditLog.create!(
|
|
167
|
+
action: "scim_deprovision",
|
|
168
|
+
user_id: record.id,
|
|
169
|
+
tenant_id: tenant&.id
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`@tenant` is the `DeviseScim::ScimTenant` (or your custom tenant model) in multi-tenant mode, and `nil` in single-tenant mode.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Group mapping overview
|
|
179
|
+
|
|
180
|
+
The gem delegates group operations entirely to the adapter. There is no built-in group model or membership table. You choose the strategy that fits your application:
|
|
181
|
+
|
|
182
|
+
- **AR model** — a dedicated `Group` model with a join table
|
|
183
|
+
- **Rolify** — roles as group membership, no extra table
|
|
184
|
+
- **Enum / bitmask** — a column on the user record
|
|
185
|
+
|
|
186
|
+
Regardless of strategy, you must implement `group_to_scim` (returns `Scim::Group`) and the three `handle_group_*` mutators.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Group operations — AR model approach
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
class ApplicationScimAdapter < DeviseScim::ScimAdapter
|
|
194
|
+
def handle_group_create
|
|
195
|
+
group = Group.find_or_create_by!(scim_group_uid: scim_group.external_id || SecureRandom.uuid) do |g|
|
|
196
|
+
g.display_name = scim_group.display_name
|
|
197
|
+
end
|
|
198
|
+
# Assign initial members if the IdP sends them on create.
|
|
199
|
+
sync_members(group)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def handle_group_update
|
|
203
|
+
group = Group.find_by!(scim_group_uid: scim_group.external_id || scim_group.id)
|
|
204
|
+
group.update!(display_name: scim_group.display_name)
|
|
205
|
+
sync_members(group)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def handle_group_destroy
|
|
209
|
+
Group.find_by(scim_group_uid: scim_group.external_id || scim_group.id)&.destroy!
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def group_to_scim
|
|
213
|
+
group = Group.find_by!(scim_group_uid: scim_group.external_id || scim_group.id)
|
|
214
|
+
|
|
215
|
+
scim = Scim::Group.new
|
|
216
|
+
scim.id = group.id.to_s
|
|
217
|
+
scim.display_name = group.display_name
|
|
218
|
+
scim.meta = build_meta("Group")
|
|
219
|
+
scim.members = group.users.map do |u|
|
|
220
|
+
Scim::Member.new(value: u.id.to_s, display: u.email)
|
|
221
|
+
end
|
|
222
|
+
scim
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
def sync_members(group)
|
|
228
|
+
incoming_ids = scim_group.members.map(&:value).compact
|
|
229
|
+
return if incoming_ids.empty?
|
|
230
|
+
|
|
231
|
+
users = User.where(id: incoming_ids)
|
|
232
|
+
group.users = users
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Group operations — Rolify approach
|
|
240
|
+
|
|
241
|
+
With Rolify, SCIM groups map to roles. No `Group` model is needed.
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
class ApplicationScimAdapter < DeviseScim::ScimAdapter
|
|
245
|
+
# scim_group.display_name is the role name (e.g. "admin", "billing").
|
|
246
|
+
|
|
247
|
+
def handle_group_create
|
|
248
|
+
# Nothing to persist — roles are created on assignment.
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def handle_group_update
|
|
252
|
+
role_name = scim_group.display_name
|
|
253
|
+
incoming = scim_group.members.map(&:value).compact
|
|
254
|
+
|
|
255
|
+
User.with_role(role_name).each do |u|
|
|
256
|
+
u.remove_role(role_name) unless incoming.include?(u.id.to_s)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
User.where(id: incoming).each { |u| u.add_role(role_name) }
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def handle_group_destroy
|
|
263
|
+
# Revoke the role from all users who have it.
|
|
264
|
+
User.with_role(scim_group.display_name).each do |u|
|
|
265
|
+
u.remove_role(scim_group.display_name)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def group_to_scim
|
|
270
|
+
role_name = scim_group.display_name
|
|
271
|
+
|
|
272
|
+
scim = Scim::Group.new
|
|
273
|
+
scim.id = Digest::UUID.uuid_v5(Digest::UUID::DNS_NAMESPACE, role_name)
|
|
274
|
+
scim.display_name = role_name
|
|
275
|
+
scim.members = User.with_role(role_name).map do |u|
|
|
276
|
+
Scim::Member.new(value: u.id.to_s, display: u.email)
|
|
277
|
+
end
|
|
278
|
+
scim
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
`scim_group.members` is an array of `Scim::Member` structs with `value` (user id sent by IdP), `display`, and `ref`.
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Group operations — enum/flag approach
|
|
288
|
+
|
|
289
|
+
For applications where group membership is a column on the user record (e.g., a `role` enum):
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
def handle_group_update
|
|
293
|
+
role = scim_group.display_name.downcase # "admin"
|
|
294
|
+
incoming_ids = scim_group.members.map(&:value).compact
|
|
295
|
+
|
|
296
|
+
# Remove role from users no longer in the group.
|
|
297
|
+
User.where(role: role).where.not(id: incoming_ids).update_all(role: "member")
|
|
298
|
+
|
|
299
|
+
# Assign role to incoming members.
|
|
300
|
+
User.where(id: incoming_ids).update_all(role: role)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def group_to_scim
|
|
304
|
+
role = scim_group.display_name.downcase
|
|
305
|
+
|
|
306
|
+
scim = Scim::Group.new
|
|
307
|
+
scim.id = scim_group.id
|
|
308
|
+
scim.display_name = scim_group.display_name
|
|
309
|
+
scim.members = User.where(role: role).map do |u|
|
|
310
|
+
Scim::Member.new(value: u.id.to_s, display: u.email)
|
|
311
|
+
end
|
|
312
|
+
scim
|
|
313
|
+
end
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## `ScimGroupIdentifiable` concern
|
|
319
|
+
|
|
320
|
+
Include `DeviseScim::Concerns::ScimGroupIdentifiable` in your group model to get two class methods for looking up groups by SCIM UID.
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
class Group < ApplicationRecord
|
|
324
|
+
include DeviseScim::Concerns::ScimGroupIdentifiable
|
|
325
|
+
# Requires a `scim_group_uid` string column.
|
|
326
|
+
end
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
This adds:
|
|
330
|
+
|
|
331
|
+
```ruby
|
|
332
|
+
Group.find_by_scim_uid(uid, tenant: nil)
|
|
333
|
+
# => WHERE scim_group_uid = ? [AND tenant_id = ?]
|
|
334
|
+
|
|
335
|
+
Group.authenticate_scim_group(scim_group, tenant: nil)
|
|
336
|
+
# => find_by_scim_uid(scim_group.external_id || scim_group.id, tenant: tenant)
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
`tenant_id` scoping is applied automatically when the model has a `tenant_id` column and a tenant is passed. Use it inside `group_to_scim` to look up the AR record safely:
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
def group_to_scim
|
|
343
|
+
group = Group.authenticate_scim_group(scim_group, tenant: tenant)
|
|
344
|
+
raise ActiveRecord::RecordNotFound unless group
|
|
345
|
+
|
|
346
|
+
scim = Scim::Group.new
|
|
347
|
+
scim.id = group.id.to_s
|
|
348
|
+
scim.display_name = group.display_name
|
|
349
|
+
scim.members = group.users.map { |u| Scim::Member.new(value: u.id.to_s, display: u.email) }
|
|
350
|
+
scim
|
|
351
|
+
end
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## Using the tenant context
|
|
357
|
+
|
|
358
|
+
`@tenant` (accessible as `tenant`) is the authenticated tenant object — a `DeviseScim::ScimTenant` instance in multi-tenant mode, `nil` in single-tenant mode. Use it to scope queries and to attach tenant information to side effects.
|
|
359
|
+
|
|
360
|
+
```ruby
|
|
361
|
+
# Scope a group lookup to the current tenant.
|
|
362
|
+
def handle_group_create
|
|
363
|
+
Group.create!(
|
|
364
|
+
display_name: scim_group.display_name,
|
|
365
|
+
scim_group_uid: scim_group.external_id || SecureRandom.uuid,
|
|
366
|
+
tenant_id: tenant&.id
|
|
367
|
+
)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Log the tenant name in after_provision.
|
|
371
|
+
def after_provision
|
|
372
|
+
Rails.logger.info "[SCIM] Provisioned #{record.email} for tenant=#{tenant&.name || "single"}"
|
|
373
|
+
end
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## Full multi-tenant adapter example
|
|
379
|
+
|
|
380
|
+
```ruby
|
|
381
|
+
class ApplicationScimAdapter < DeviseScim::ScimAdapter
|
|
382
|
+
# ── User attributes ──────────────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
def attributes_for_create
|
|
385
|
+
super.merge(
|
|
386
|
+
role: scim_user.user_type&.downcase || "member"
|
|
387
|
+
).tap do |attrs|
|
|
388
|
+
attrs[:department] = scim_user.name&.formatted if column?(:department)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def after_provision
|
|
393
|
+
AuditEvent.create!(
|
|
394
|
+
tenant_id: tenant.id,
|
|
395
|
+
user_id: record.id,
|
|
396
|
+
action: "scim_provision",
|
|
397
|
+
metadata: { email: record.email, role: record.role }
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# Send welcome email only on initial provisioning.
|
|
401
|
+
UserMailer.welcome(record, tenant: tenant).deliver_later if record.scim_deprovisioned_at.nil?
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def after_deprovision
|
|
405
|
+
record.access_tokens.revoke_all!
|
|
406
|
+
AuditEvent.create!(
|
|
407
|
+
tenant_id: tenant.id,
|
|
408
|
+
user_id: record.id,
|
|
409
|
+
action: "scim_deprovision"
|
|
410
|
+
)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# ── Group operations ─────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
def handle_group_create
|
|
416
|
+
Group.create!(
|
|
417
|
+
display_name: scim_group.display_name,
|
|
418
|
+
scim_group_uid: scim_group.external_id || SecureRandom.uuid,
|
|
419
|
+
tenant_id: tenant.id
|
|
420
|
+
)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def handle_group_update
|
|
424
|
+
group = Group.find_by_scim_uid(scim_group.external_id || scim_group.id, tenant: tenant)
|
|
425
|
+
group.update!(display_name: scim_group.display_name)
|
|
426
|
+
sync_members(group)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def handle_group_destroy
|
|
430
|
+
Group.find_by_scim_uid(scim_group.external_id || scim_group.id, tenant: tenant)&.destroy!
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def group_to_scim
|
|
434
|
+
group = Group.find_by_scim_uid(scim_group.external_id || scim_group.id, tenant: tenant)
|
|
435
|
+
raise ActiveRecord::RecordNotFound unless group
|
|
436
|
+
|
|
437
|
+
scim = Scim::Group.new
|
|
438
|
+
scim.id = group.id.to_s
|
|
439
|
+
scim.display_name = group.display_name
|
|
440
|
+
scim.meta = build_meta("Group")
|
|
441
|
+
scim.members = group.users.where(tenant_id: tenant.id).map do |u|
|
|
442
|
+
Scim::Member.new(value: u.id.to_s, display: u.email)
|
|
443
|
+
end
|
|
444
|
+
scim
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
private
|
|
448
|
+
|
|
449
|
+
def sync_members(group)
|
|
450
|
+
incoming = scim_group.members.map(&:value).compact
|
|
451
|
+
return if incoming.empty?
|
|
452
|
+
|
|
453
|
+
group.users = User.where(id: incoming, tenant_id: tenant.id)
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
```
|