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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +124 -0
  3. data/CHANGELOG.md +47 -0
  4. data/CODE_OF_CONDUCT.md +11 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +348 -0
  7. data/Rakefile +21 -0
  8. data/app/controllers/devise_scim/application_controller.rb +69 -0
  9. data/app/controllers/devise_scim/groups_controller.rb +67 -0
  10. data/app/controllers/devise_scim/resource_types_controller.rb +43 -0
  11. data/app/controllers/devise_scim/schemas_controller.rb +55 -0
  12. data/app/controllers/devise_scim/service_provider_controller.rb +34 -0
  13. data/app/controllers/devise_scim/users_controller.rb +281 -0
  14. data/docs/contributing.md +163 -0
  15. data/docs/custom_adapter.md +456 -0
  16. data/docs/idp_setup.md +335 -0
  17. data/docs/multi_tenant.md +328 -0
  18. data/docs/testing.md +444 -0
  19. data/lib/devise_scim/auth/base_strategy.rb +16 -0
  20. data/lib/devise_scim/auth/oauth_strategy.rb +28 -0
  21. data/lib/devise_scim/auth/token_strategy.rb +25 -0
  22. data/lib/devise_scim/concerns/scim_group_identifiable.rb +21 -0
  23. data/lib/devise_scim/concerns/scim_tenant.rb +41 -0
  24. data/lib/devise_scim/configuration.rb +92 -0
  25. data/lib/devise_scim/engine.rb +15 -0
  26. data/lib/devise_scim/filter/arel_visitor.rb +77 -0
  27. data/lib/devise_scim/filter/parser.rb +190 -0
  28. data/lib/devise_scim/middleware/authenticator.rb +51 -0
  29. data/lib/devise_scim/minitest.rb +57 -0
  30. data/lib/devise_scim/models/scim_tenant.rb +14 -0
  31. data/lib/devise_scim/models/scim_tenant_user.rb +15 -0
  32. data/lib/devise_scim/routing.rb +43 -0
  33. data/lib/devise_scim/rspec/factories.rb +17 -0
  34. data/lib/devise_scim/rspec/scim_helpers.rb +43 -0
  35. data/lib/devise_scim/rspec/shared_examples/discovery_endpoints.rb +94 -0
  36. data/lib/devise_scim/rspec/shared_examples/groups_endpoint.rb +148 -0
  37. data/lib/devise_scim/rspec/shared_examples/users_endpoint.rb +301 -0
  38. data/lib/devise_scim/rspec.rb +7 -0
  39. data/lib/devise_scim/scim/error.rb +59 -0
  40. data/lib/devise_scim/scim/group.rb +66 -0
  41. data/lib/devise_scim/scim/list_response.rb +32 -0
  42. data/lib/devise_scim/scim/patch_operation.rb +55 -0
  43. data/lib/devise_scim/scim/user.rb +161 -0
  44. data/lib/devise_scim/scim_adapter.rb +84 -0
  45. data/lib/devise_scim/version.rb +5 -0
  46. data/lib/devise_scim.rb +48 -0
  47. data/lib/generators/devise_scim/adapter_generator.rb +17 -0
  48. data/lib/generators/devise_scim/install_generator.rb +117 -0
  49. data/lib/generators/devise_scim/templates/add_scim_to_tenant.rb.tt +17 -0
  50. data/lib/generators/devise_scim/templates/add_scim_to_users.rb.tt +15 -0
  51. data/lib/generators/devise_scim/templates/application_scim_adapter.rb.tt +34 -0
  52. data/lib/generators/devise_scim/templates/create_scim_tenant_users.rb.tt +22 -0
  53. data/lib/generators/devise_scim/templates/create_scim_tenants.rb.tt +18 -0
  54. data/lib/generators/devise_scim/templates/devise_scim.rb.tt +53 -0
  55. data/sig/devise_scim.rbs +4 -0
  56. 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
+ ```