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
data/docs/idp_setup.md ADDED
@@ -0,0 +1,335 @@
1
+ # IdP Setup Guide
2
+
3
+ ## Overview
4
+
5
+ `devise_scim` implements the **SCIM 2.0 server** (RFC 7643 / RFC 7644). Your application is the SCIM provider; the Identity Provider (Okta, Microsoft Entra, JumpCloud, etc.) is the SCIM client.
6
+
7
+ The IdP initiates all communication by pushing user lifecycle events to your app's SCIM endpoints. Your app never calls out to the IdP — it only responds to inbound HTTP requests from it.
8
+
9
+ Typical lifecycle events:
10
+
11
+ - User created in the IdP directory → `POST /scim/v2/Users`
12
+ - User profile updated → `PATCH /scim/v2/Users/:id`
13
+ - User deactivated or removed → `DELETE /scim/v2/Users/:id` or `PATCH` with `active=false`
14
+ - Group membership changed → `PATCH /scim/v2/Groups/:id` (when `enable_groups: true`)
15
+
16
+ ---
17
+
18
+ ## Obtaining Credentials
19
+
20
+ ### Bearer token (multi-tenant)
21
+
22
+ ```ruby
23
+ # Rails console
24
+ tenant = DeviseScim::ScimTenant.create!(name: "Acme Corp", auth_method: "token")
25
+ raw_token = tenant.rotate_token!
26
+ # => "a3f8c2d1e9b7..." — copy this now; it will not be shown again
27
+ ```
28
+
29
+ Provide `raw_token` to the IdP as the Bearer token. The BCrypt digest is stored in `token_digest`; the raw value is never persisted.
30
+
31
+ ### Bearer token (single-tenant)
32
+
33
+ Set the token in an environment variable and reference it in the initializer:
34
+
35
+ ```ruby
36
+ # config/initializers/devise_scim.rb
37
+ config.token = ENV.fetch("SCIM_BEARER_TOKEN", nil)
38
+ ```
39
+
40
+ Generate a token:
41
+
42
+ ```bash
43
+ ruby -e "require 'securerandom'; puts SecureRandom.hex(32)"
44
+ ```
45
+
46
+ Set `SCIM_BEARER_TOKEN` in your deployment environment and in the IdP's SCIM configuration.
47
+
48
+ ### OAuth 2.0 (client credentials grant)
49
+
50
+ OAuth is only available when `auth_method: :oauth` and Doorkeeper >= 5.6 is installed.
51
+
52
+ ```ruby
53
+ # Rails console
54
+ app = Doorkeeper::Application.create!(
55
+ name: "Acme SCIM",
56
+ uid: SecureRandom.hex(8),
57
+ secret: SecureRandom.hex(16),
58
+ redirect_uri: "",
59
+ scopes: ""
60
+ )
61
+ puts "client_id: #{app.uid}"
62
+ puts "client_secret: #{app.secret}"
63
+ ```
64
+
65
+ In multi-tenant OAuth mode, associate the application with the tenant:
66
+
67
+ ```ruby
68
+ tenant.update!(auth_method: "oauth", doorkeeper_application: app)
69
+ ```
70
+
71
+ Provide `client_id` and `client_secret` to the IdP.
72
+
73
+ ---
74
+
75
+ ## Okta Setup
76
+
77
+ 1. In Okta Admin, go to **Applications → Applications → Browse App Catalog**.
78
+ 2. Search for **SCIM 2.0** or open your existing app and navigate to **Provisioning → Configure API Integration**.
79
+ 3. Check **Enable API Integration**.
80
+ 4. Set the fields:
81
+
82
+ | Field | Value |
83
+ |---|---|
84
+ | SCIM connector base URL | `https://yourapp.com/scim/v2` |
85
+ | Unique identifier field for users | `userName` |
86
+ | Authentication Mode | `HTTP Header` (Bearer) or `OAuth` |
87
+ | Authorization / Bearer Token | the raw token from `rotate_token!` |
88
+
89
+ 5. Click **Test API Credentials** — expect HTTP 200 with a `ServiceProviderConfig` response.
90
+ 6. Under **Provisioning → To App**, enable:
91
+ - **Create Users**
92
+ - **Update User Attributes**
93
+ - **Deactivate Users**
94
+ - **Push Groups** (only if `enable_groups: true` in your config)
95
+
96
+ **Attribute mapping** (Okta attribute → SCIM attribute → your model column via the adapter):
97
+
98
+ | Okta attribute | SCIM attribute | Default AR column |
99
+ |---|---|---|
100
+ | `login` / `email` | `userName` | `email` |
101
+ | `firstName` | `name.givenName` | `first_name` |
102
+ | `lastName` | `name.familyName` | `last_name` |
103
+ | `active` | `active` | `scim_active` |
104
+
105
+ **What Okta sends for each operation:**
106
+
107
+ - **CREATE** — `POST /Users` with a full SCIM user payload (`schemas`, `userName`, `name`, `emails`, `active`).
108
+ - **UPDATE** — `PATCH /Users/:id` with a `Operations` array. Each operation has an `op` (replace/add/remove), optional `path`, and `value`.
109
+ - **DEACTIVATE** — `PATCH /Users/:id` with `{ op: "replace", path: "active", value: false }`, or `DELETE /Users/:id` depending on your Okta app settings.
110
+
111
+ ---
112
+
113
+ ## Microsoft Entra (Azure AD) Setup
114
+
115
+ 1. In the Azure portal, go to **Azure Active Directory → Enterprise Applications → New Application → Create your own application → Non-gallery**.
116
+ 2. Name the app (e.g., "YourApp SCIM"), select **Integrate any other application you don't find in the gallery**.
117
+ 3. Navigate to **Provisioning → Get started → Provisioning Mode: Automatic**.
118
+ 4. Under **Admin Credentials**:
119
+
120
+ | Field | Value |
121
+ |---|---|
122
+ | Tenant URL | `https://yourapp.com/scim/v2` |
123
+ | Secret Token | the raw Bearer token |
124
+
125
+ 5. Click **Test Connection** — expect a success message.
126
+ 6. Expand **Mappings** to review attribute mappings. Defaults are reasonable; adjust if your column names differ.
127
+
128
+ **Entra-specific notes:**
129
+
130
+ - Entra sends `externalId` alongside `userName`. The gem maps `externalId` → `scim_uid` in single-tenant mode (the `ArelVisitor`'s `SCIM_TO_AR` table maps `"externalId"` → `"scim_uid"`).
131
+ - Entra performs **soft-match** on first sync: if a user with matching `userName` (email) already exists in your app, Entra will attempt to link to that record rather than create a duplicate. The gem's claiming behavior (see the multi-tenancy guide) complements this — the user is claimed and `scim_claimed_at` is set.
132
+ - Entra's provisioning cycle runs on a ~40-minute schedule by default. Use **Provision on demand** in the Azure portal to test individual users immediately.
133
+
134
+ ---
135
+
136
+ ## OAuth Token Endpoint
137
+
138
+ Available only when `config.auth_method = :oauth`.
139
+
140
+ ```
141
+ POST {route_prefix}/oauth/token
142
+ Content-Type: application/x-www-form-urlencoded
143
+
144
+ grant_type=client_credentials&client_id=CLIENT_ID&client_secret=CLIENT_SECRET
145
+ ```
146
+
147
+ Example:
148
+
149
+ ```bash
150
+ curl -X POST https://yourapp.com/scim/v2/oauth/token \
151
+ -d "grant_type=client_credentials" \
152
+ -d "client_id=abc123" \
153
+ -d "client_secret=xyz456"
154
+ ```
155
+
156
+ Doorkeeper handles the response format (RFC 6749):
157
+
158
+ ```json
159
+ {
160
+ "access_token": "TOKEN",
161
+ "token_type": "Bearer",
162
+ "expires_in": 7200,
163
+ "created_at": 1700000000
164
+ }
165
+ ```
166
+
167
+ The IdP then uses the returned `access_token` as the Bearer token for subsequent SCIM requests. Tokens expire per Doorkeeper's configuration (`access_token_expires_in`).
168
+
169
+ The OAuth token endpoint is mounted by the router only when `auth_method == :oauth` — it does not appear in the route table for token-authenticated apps.
170
+
171
+ ---
172
+
173
+ ## Bearer Token Setup
174
+
175
+ **Single-tenant pattern:**
176
+
177
+ ```ruby
178
+ # config/initializers/devise_scim.rb
179
+ config.auth_method = :token
180
+ config.token = ENV.fetch("SCIM_BEARER_TOKEN", nil)
181
+ ```
182
+
183
+ Set `SCIM_BEARER_TOKEN` in your production environment (Heroku config vars, AWS SSM, Kubernetes secret, etc.). Never commit the raw token to source control.
184
+
185
+ The middleware compares the incoming Bearer token against `config.token` using `ActiveSupport::SecurityUtils.secure_compare` — a timing-safe comparison that prevents timing-oracle attacks.
186
+
187
+ **Rotating tokens without downtime (single-tenant):**
188
+
189
+ There is no grace period for single-tenant token auth — the new value in `config.token` is active as soon as the process restarts. To avoid a gap:
190
+
191
+ 1. Generate a new token.
192
+ 2. Update `SCIM_BEARER_TOKEN` in your deployment environment and deploy (process restarts with new token).
193
+ 3. Update the token in the IdP.
194
+ 4. The window where the old token is rejected and the new one not yet entered in the IdP is typically seconds — most IdPs retry on 401.
195
+
196
+ **Multi-tenant token rotation:**
197
+
198
+ See the `rotate_token!` documentation in the multi-tenancy guide. The new token_digest is active immediately after `rotate_token!` returns; update the IdP promptly.
199
+
200
+ ---
201
+
202
+ ## Testing Connectivity
203
+
204
+ Before configuring an IdP, verify your endpoints respond correctly.
205
+
206
+ **Discovery:**
207
+
208
+ ```bash
209
+ curl -s -H "Authorization: Bearer YOUR_TOKEN" \
210
+ https://yourapp.com/scim/v2/ServiceProviderConfig | jq .
211
+ ```
212
+
213
+ Expected: HTTP 200, `Content-Type: application/scim+json`, body with `schemas` array.
214
+
215
+ **List users:**
216
+
217
+ ```bash
218
+ curl -s -H "Authorization: Bearer YOUR_TOKEN" \
219
+ "https://yourapp.com/scim/v2/Users" | jq .
220
+ ```
221
+
222
+ Expected: HTTP 200, `ListResponse` with `totalResults`, `startIndex`, `itemsPerPage`, `Resources`.
223
+
224
+ **Create a user:**
225
+
226
+ ```bash
227
+ curl -s -X POST https://yourapp.com/scim/v2/Users \
228
+ -H "Authorization: Bearer YOUR_TOKEN" \
229
+ -H "Content-Type: application/scim+json" \
230
+ -d '{
231
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
232
+ "userName": "test@example.com",
233
+ "name": { "givenName": "Test", "familyName": "User" },
234
+ "active": true
235
+ }' | jq .
236
+ ```
237
+
238
+ Expected: HTTP 201, full SCIM user representation.
239
+
240
+ **Filter users:**
241
+
242
+ ```bash
243
+ curl -s -G -H "Authorization: Bearer YOUR_TOKEN" \
244
+ https://yourapp.com/scim/v2/Users \
245
+ --data-urlencode 'filter=userName eq "test@example.com"' | jq .
246
+ ```
247
+
248
+ > [!NOTE]
249
+ > All SCIM endpoints respond with `Content-Type: application/scim+json`. Some HTTP clients or test tools may show a warning if they expect `application/json` — this is correct per RFC 7644.
250
+
251
+ ---
252
+
253
+ ## Okta SCIM Test Harness
254
+
255
+ Okta provides a Runscope-based SCIM 2.0 test harness that validates your provider before activating provisioning in production. Run it against a staging environment exposed via a public URL (e.g. `ngrok http 3000`).
256
+
257
+ **How to run:**
258
+
259
+ 1. In Okta Admin → your SCIM app → **Provisioning** tab → **Test API Credentials** — verifies the base URL and token before any test.
260
+ 2. Navigate to the [Okta SCIM Test Suite](https://developer.okta.com/docs/guides/scim-provisioning-integration-test/) and import the Runscope test bucket for SCIM 2.0.
261
+ 3. Set environment variables in Runscope:
262
+ - `base_url` — e.g. `https://abc123.ngrok-free.app/scim/v2`
263
+ - `auth_header` — `Bearer YOUR_TOKEN`
264
+ 4. Run the full test suite.
265
+
266
+ **Expected results against `devise_scim` v0.1.0:**
267
+
268
+ | Test group | Result | Notes |
269
+ |---|---|---|
270
+ | Authentication | Pass | 401 on missing/invalid token |
271
+ | User create (`POST /Users`) | Pass | Returns 201 with `id`, full user representation |
272
+ | User read (`GET /Users/:id`) | Pass | Returns 200 with correct schema |
273
+ | User list (`GET /Users`) | Pass | Pagination via `startIndex` + `count`; filter via `userName eq "..."` |
274
+ | User update (`PUT /Users/:id`) | Pass | Full replace |
275
+ | User patch (`PATCH /Users/:id`) | Pass | `replace` op on `active`, `name.*`, `emails` |
276
+ | User deprovision (`PATCH active=false`) | Pass | Calls `after_deprovision`; `active` returns false on next GET |
277
+ | User reprovision (`PATCH active=true`) | Pass | Re-activates; `scim_source` preserved |
278
+ | ServiceProviderConfig | Pass | Correct `schemas`, `filter.supported`, `patch.supported` |
279
+ | Schemas discovery | Pass | Returns User and Group schema definitions |
280
+
281
+ > [!NOTE]
282
+ > Group tests require a `ScimAdapter` that implements `handle_group_create`, `handle_group_update`, and `handle_group_destroy`. The default adapter returns `501 Not Implemented` for these, which Okta's harness flags as a warning (not a failure) when Groups provisioning is disabled in the Okta app.
283
+
284
+ ---
285
+
286
+ ## SCIM Filter Support
287
+
288
+ The gem implements a full recursive-descent SCIM filter parser (RFC 7644 §3.4.2.2).
289
+
290
+ **Supported comparison operators:**
291
+
292
+ | Operator | Meaning | Example |
293
+ |---|---|---|
294
+ | `eq` | Equal | `userName eq "alice@example.com"` |
295
+ | `ne` | Not equal | `active ne true` |
296
+ | `co` | Contains | `userName co "example"` |
297
+ | `sw` | Starts with | `userName sw "alice"` |
298
+ | `ew` | Ends with | `userName ew ".com"` |
299
+ | `pr` | Present (not null) | `userName pr` |
300
+ | `gt` | Greater than | `meta.created gt "2024-01-01"` |
301
+ | `ge` | Greater than or equal | `meta.created ge "2024-01-01"` |
302
+ | `lt` | Less than | `meta.created lt "2025-01-01"` |
303
+ | `le` | Less than or equal | `meta.created le "2025-01-01"` |
304
+
305
+ **Logical operators:** `and`, `or` (standard precedence — `and` binds tighter than `or`).
306
+
307
+ **Supported SCIM attributes and their AR column mappings:**
308
+
309
+ | SCIM attribute | AR column |
310
+ |---|---|
311
+ | `userName` | `email` |
312
+ | `externalId` | `scim_uid` |
313
+ | `active` | `scim_active` |
314
+ | `id` | `id` |
315
+ | `emails` / `emails.value` | `email` |
316
+ | `name.givenName` | `first_name` |
317
+ | `name.familyName` | `last_name` |
318
+
319
+ Filters referencing unmapped or non-existent columns respond with HTTP 400 and SCIM error type `invalidFilter`.
320
+
321
+ ---
322
+
323
+ ## Common Issues
324
+
325
+ | Symptom | Likely cause | Fix |
326
+ |---|---|---|
327
+ | `401 Unauthorized` on all requests | Token mismatch or tenant inactive | Verify the raw token in the IdP matches the one from `rotate_token!`. Check `tenant.active?`. In single-tenant mode, verify `SCIM_BEARER_TOKEN` env var is set and the process has restarted. |
328
+ | `401` with correct token | Warden intercepting the middleware's 401 | This is handled internally by `warden.custom_failure!`. If you see it in a custom setup, ensure the `DeviseScim::Middleware::Authenticator` is mounted before Warden. |
329
+ | `404` for a user that exists | User is outside this tenant's scope | In multi-tenant mode, `GET /Users/:id` returns 404 if the user has no active `scim_tenant_users` record for the current tenant. Check the join table. |
330
+ | `409 Conflict` on `POST /Users` for a user that was deprovisioned | User is deprovisioned but still `scim_active = false` | In single-tenant mode the gem re-provisions deprovisioned users. In multi-tenant mode it re-creates the join record. Verify the user's `scim_active` column and the join record's `active` flag. |
331
+ | `409 Conflict` on `POST /Users` for a user in another tenant | `user_exclusivity: :one_to_one` and `exclusivity_conflict: :error` | Either set `exclusivity_conflict: :reassign` to move the user, or set `user_exclusivity: :multiple` to allow shared membership. |
332
+ | `400 invalidFilter` | Filter uses an unsupported attribute or malformed syntax | Check the `SCIM_TO_AR` mapping in `ArelVisitor`. The attribute must map to a column that exists on your model. |
333
+ | `500` on filter with valid attribute | Column exists in the mapping but not in the database | Run `rails db:migrate`. The `add_scim_to_users` migration adds `first_name`/`last_name` only if they're present on the model. |
334
+ | Okta "Test API Credentials" fails | Base URL includes a trailing slash, or route prefix mismatch | Ensure the base URL ends with `/scim/v2` (no trailing slash) and matches `config.route_prefix`. |
335
+ | Entra creates duplicate users | Soft-match not firing | Entra matches on `userName`. Ensure your `userName` SCIM attribute maps to the same value as your `email` column — the default adapter uses `record.email` for `userName`. |
@@ -0,0 +1,328 @@
1
+ # Multi-Tenancy Guide
2
+
3
+ ## Overview
4
+
5
+ In multi-tenant mode (`config.tenancy = :multi`), each Identity Provider (IdP) is represented as a **tenant** — an AR record that holds its own credentials and configuration. Every SCIM request arrives with a credential (Bearer token or OAuth token) that the middleware resolves to a specific tenant record before the request reaches any controller.
6
+
7
+ All database operations are then scoped to that tenant via the `scim_tenant_users` join table. A single application can serve many IdPs simultaneously; each sees only its own users and cannot see or modify records provisioned by another tenant.
8
+
9
+ Key design properties:
10
+
11
+ - One tenant per IdP — credentials are stored per tenant, not in environment variables.
12
+ - `scim_tenant_users` is the authority for membership — a user exists in a tenant's scope only if a matching active join record exists.
13
+ - Each tenant assigns its own `scim_uid` (the IdP's external ID) — the UID lives on the join record so the same user can carry different external IDs across tenants.
14
+
15
+ ---
16
+
17
+ ## Schema Overview
18
+
19
+ Three tables work together:
20
+
21
+ **`users`** — your application's Devise user table, extended with SCIM tracking columns:
22
+
23
+ | Column | Type | Purpose |
24
+ |---|---|---|
25
+ | `scim_uid` | string | IdP's external ID (single-tenant only; in multi-tenant mode this lives on the join) |
26
+ | `scim_source` | string | `"scim"` if provisioned via SCIM, `nil` for manually created users |
27
+ | `scim_active` | boolean | Whether the user is active |
28
+ | `scim_deprovisioned_at` | datetime | Timestamp of last soft-delete |
29
+ | `scim_raw` | text / jsonb | Raw SCIM payload from last write (debugging aid) |
30
+
31
+ **`scim_tenants`** — one row per IdP:
32
+
33
+ | Column | Type | Purpose |
34
+ |---|---|---|
35
+ | `name` | string | Human-readable label |
36
+ | `auth_method` | string | `"token"` or `"oauth"` |
37
+ | `token_digest` | string | BCrypt digest of the Bearer token |
38
+ | `active` | boolean | Whether this tenant is accepting requests |
39
+ | `doorkeeper_application_id` | integer | FK to `oauth_applications` (OAuth mode) |
40
+
41
+ **`scim_tenant_users`** — the join table that scopes all operations:
42
+
43
+ | Column | Type | Purpose |
44
+ |---|---|---|
45
+ | `scim_tenant_id` | bigint | FK to the tenant (or custom FK — see below) |
46
+ | `user_id` | bigint | FK to your users table |
47
+ | `scim_uid` | string | The IdP's external ID for this user **within this tenant** |
48
+ | `provisioned_at` | datetime | When the user was first provisioned by this tenant |
49
+ | `scim_claimed_at` | datetime | Set when an existing user (not net-new) was claimed by this tenant |
50
+ | `active` | boolean | Whether this assignment is currently active |
51
+ | `scim_raw` | text / jsonb | Raw SCIM payload from last write |
52
+
53
+ `scim_uid` lives on the join table — not on `users` — so the same user can have different external IDs per tenant, and a user's presence in one tenant's scope has no bearing on another's.
54
+
55
+ There are two unique indexes on `scim_tenant_users`:
56
+
57
+ - `(scim_tenant_id, user_id)` — one join record per user per tenant
58
+ - `(scim_tenant_id, scim_uid) WHERE scim_uid IS NOT NULL` — prevents duplicate UID assignment within a tenant
59
+
60
+ ---
61
+
62
+ ## Built-In ScimTenant Model
63
+
64
+ For most applications the built-in `DeviseScim::ScimTenant` is all you need.
65
+
66
+ **1. Generate and migrate:**
67
+
68
+ ```bash
69
+ rails g devise_scim:install User --multi-tenant
70
+ rails db:migrate
71
+ ```
72
+
73
+ This generates:
74
+
75
+ - `db/migrate/add_scim_to_users.rb` — adds SCIM columns to your users table
76
+ - `db/migrate/create_scim_tenants.rb` — creates `scim_tenants`
77
+ - `db/migrate/create_scim_tenant_users.rb` — creates `scim_tenant_users`
78
+ - `config/initializers/devise_scim.rb` — pre-configured for multi-tenant mode
79
+
80
+ The default `config.tenant_model` is `"DeviseScim::ScimTenant"`. You do not need to set it explicitly.
81
+
82
+ **2. Create a tenant and obtain a token:**
83
+
84
+ ```ruby
85
+ # Rails console
86
+ tenant = DeviseScim::ScimTenant.create!(name: "Acme Corp", auth_method: "token")
87
+ raw_token = tenant.rotate_token!
88
+ # => "a3f8c2d1e9b7..." — store this securely; it will not be shown again
89
+ ```
90
+
91
+ > [!WARNING]
92
+ > `rotate_token!` returns the raw token exactly once. Store it immediately and provide it to your IdP. The raw value is never persisted — only the BCrypt digest is stored in `token_digest`.
93
+
94
+ ---
95
+
96
+ ## Custom Tenant Model
97
+
98
+ If you already have an AR model representing organizations, accounts, or workspaces, you can use it as the SCIM tenant instead of `DeviseScim::ScimTenant`.
99
+
100
+ **1. Include the concern in your existing model:**
101
+
102
+ ```ruby
103
+ # app/models/org.rb
104
+ class Org < ApplicationRecord
105
+ include DeviseScim::Concerns::ScimTenant
106
+ # ... rest of your model
107
+ end
108
+ ```
109
+
110
+ **2. Run the generator with `--tenant-model`:**
111
+
112
+ ```bash
113
+ rails g devise_scim:install User --multi-tenant --tenant-model=Org
114
+ rails db:migrate
115
+ ```
116
+
117
+ The generator creates an **additive** migration that only adds columns that do not already exist:
118
+
119
+ ```ruby
120
+ class AddScimToOrgs < ActiveRecord::Migration[7.2]
121
+ def change
122
+ unless column_exists?(:orgs, :token_digest)
123
+ add_column :orgs, :token_digest, :string
124
+ end
125
+ unless column_exists?(:orgs, :auth_method)
126
+ add_column :orgs, :auth_method, :string, null: false, default: "token"
127
+ end
128
+ unless column_exists?(:orgs, :doorkeeper_application_id)
129
+ add_column :orgs, :doorkeeper_application_id, :bigint
130
+ add_foreign_key :orgs, :oauth_applications,
131
+ column: :doorkeeper_application_id, on_delete: :nullify
132
+ end
133
+ end
134
+ end
135
+ ```
136
+
137
+ It also updates the initializer to set:
138
+
139
+ ```ruby
140
+ config.tenant_model = "Org"
141
+ ```
142
+
143
+ **FK column derivation:** the join table's tenant FK column is derived by underscoring the model name and appending `_id`:
144
+
145
+ | `tenant_model` | FK column on `scim_tenant_users` |
146
+ |---|---|
147
+ | `DeviseScim::ScimTenant` (default) | `scim_tenant_id` |
148
+ | `Org` | `org_id` |
149
+ | `MyAccount` | `my_account_id` |
150
+
151
+ The `tenant_fk_column` helper in the controller (`"#{tenant_model.demodulize.underscore}_id"`) performs this derivation at runtime.
152
+
153
+ > [!IMPORTANT]
154
+ > Your custom tenant model must respond to `active` (for `scim_active?`) and `token_digest` (for `authenticate_token`). If your model uses a different column for the human-readable name, override `scim_tenant_label_column` — see below.
155
+
156
+ ---
157
+
158
+ ## ScimTenant Concern API
159
+
160
+ These methods are mixed into any model that `include DeviseScim::Concerns::ScimTenant`.
161
+
162
+ ### `authenticate_token(raw_token)` — class method
163
+
164
+ ```ruby
165
+ DeviseScim::ScimTenant.authenticate_token("a3f8c2d1e9b7...")
166
+ # => #<DeviseScim::ScimTenant id=1 name="Acme Corp"> or nil
167
+ ```
168
+
169
+ Queries all records where `auth_method = "token"` and `active = true`, then BCrypt-compares each `token_digest` against `raw_token`. Returns the matching record or `nil`.
170
+
171
+ Called by the middleware's `TokenStrategy` on every SCIM request in multi-tenant mode.
172
+
173
+ ### `rotate_token!` — instance method
174
+
175
+ ```ruby
176
+ raw = tenant.rotate_token!
177
+ # => "a3f8c2d1e9b7..."
178
+ ```
179
+
180
+ Generates a 64-character hex token, BCrypt-hashes it into `token_digest`, and saves the record. Returns the raw token. The raw value is not stored anywhere — if you lose it, call `rotate_token!` again to issue a new one.
181
+
182
+ ### `scim_active?` — instance method
183
+
184
+ ```ruby
185
+ tenant.scim_active? # => true / false
186
+ ```
187
+
188
+ Delegates to the `active` column. Override if your model uses a different boolean column.
189
+
190
+ ### `scim_tenant_label_column` — class method
191
+
192
+ ```ruby
193
+ # Default:
194
+ def self.scim_tenant_label_column
195
+ :name
196
+ end
197
+ ```
198
+
199
+ Override in your model to point at the column used as the human-readable label (validated for presence on save):
200
+
201
+ ```ruby
202
+ class Org < ApplicationRecord
203
+ include DeviseScim::Concerns::ScimTenant
204
+
205
+ def self.scim_tenant_label_column
206
+ :display_name
207
+ end
208
+ end
209
+ ```
210
+
211
+ ---
212
+
213
+ ## Console Commands
214
+
215
+ **Create a tenant and obtain its token:**
216
+
217
+ ```ruby
218
+ tenant = DeviseScim::ScimTenant.create!(name: "Acme Corp", auth_method: "token")
219
+ raw = tenant.rotate_token!
220
+ puts raw # copy to clipboard — shown once only
221
+ ```
222
+
223
+ **Rotate a token without downtime:**
224
+
225
+ ```ruby
226
+ tenant = DeviseScim::ScimTenant.find_by!(name: "Acme Corp")
227
+ new_raw = tenant.rotate_token!
228
+ # 1. Update token_digest is persisted immediately.
229
+ # 2. Update the IdP with new_raw.
230
+ # 3. Old token is now invalid — there is no grace period.
231
+ ```
232
+
233
+ > [!WARNING]
234
+ > Token rotation is instantaneous. The old token is invalid as soon as `rotate_token!` returns. Update the IdP before rotating if you need zero downtime, or accept a brief authentication failure window.
235
+
236
+ **Link a tenant to a Doorkeeper application (OAuth mode):**
237
+
238
+ ```ruby
239
+ app = Doorkeeper::Application.find_by!(name: "Acme SCIM")
240
+ tenant = DeviseScim::ScimTenant.find_by!(name: "Acme Corp")
241
+ tenant.update!(auth_method: "oauth", doorkeeper_application: app)
242
+ ```
243
+
244
+ **List all active tenants:**
245
+
246
+ ```ruby
247
+ DeviseScim::ScimTenant.where(active: true).map { |t| [t.id, t.name, t.auth_method] }
248
+ ```
249
+
250
+ **Deactivate a tenant:**
251
+
252
+ ```ruby
253
+ DeviseScim::ScimTenant.find_by!(name: "Acme Corp").update!(active: false)
254
+ # The middleware will reject all requests from this tenant's credentials immediately.
255
+ ```
256
+
257
+ ---
258
+
259
+ ## User Scoping
260
+
261
+ In multi-tenant mode every read and write goes through `tenant_scope`, which uses a pure-Arel join — no string interpolation:
262
+
263
+ ```ruby
264
+ # ApplicationController#tenant_scope (simplified)
265
+ stu = ScimTenantUser.arel_table
266
+ dm = devise_model.arel_table
267
+ join = dm.join(stu).on(stu[:user_id].eq(dm[:id])).join_sources
268
+ cond = stu[tenant_fk_column].eq(current_scim_tenant.id).and(stu[:active].eq(true))
269
+ devise_model.joins(join).where(cond)
270
+ ```
271
+
272
+ Consequences:
273
+
274
+ - `GET /Users` returns only users with an **active** join record for the current tenant.
275
+ - `GET /Users/:id`, `PUT /Users/:id`, `PATCH /Users/:id`, `DELETE /Users/:id` — if the user exists in the database but has no active join for this tenant, the controller raises `NotFound` and responds with 404. A tenant cannot read or modify another tenant's users.
276
+ - Deprovisioning (`DELETE /Users/:id`) sets `active = false` on the join record, not on the user row itself (unless `soft_delete` is also true, in which case both are updated).
277
+
278
+ ---
279
+
280
+ ## User Exclusivity Scenarios
281
+
282
+ The `user_exclusivity` and `exclusivity_conflict` settings control what happens when `POST /Users` arrives for a user who already has a join record in a **different** tenant.
283
+
284
+ | `user_exclusivity` | `exclusivity_conflict` | Scenario: user is in tenant B, tenant A provisions them | Result |
285
+ |---|---|---|---|
286
+ | `:multiple` (default) | any | User exists in tenant B | New join created; user now active in both A and B |
287
+ | `:one_to_one` | `:error` (default) | User exists in tenant B | 409 Conflict — user belongs to another tenant |
288
+ | `:one_to_one` | `:reassign` | User exists in tenant B | Tenant B's join set to `active=false`; new active join created for tenant A |
289
+
290
+ **`:multiple` (default):** a user may belong to any number of tenants simultaneously. This is the right choice when your users may legitimately exist in multiple organizations, or when you do not want SCIM provisioning in one IdP to affect another.
291
+
292
+ **`:one_to_one`:** enforces that a user belongs to at most one tenant at a time. Use this when each user maps to exactly one organization.
293
+
294
+ When `:one_to_one` + `:reassign`: the old join record is not deleted — it is deactivated (`active = false`). This preserves the audit trail. If the user is later re-provisioned by tenant B, a new join record is created.
295
+
296
+ Note: "belongs to another tenant" means there is an `active = true` join record with a different tenant FK. A user with only inactive join records (previously deprovisioned) is treated as unowned.
297
+
298
+ ---
299
+
300
+ ## Claiming Existing Users
301
+
302
+ When `POST /Users` arrives and no matching user exists in the database, the controller creates a new user record and an active join record. This is the standard "net-new" provisioning path.
303
+
304
+ When `POST /Users` arrives and a user with that email already exists but has **no** active join for this tenant (e.g., the user was created manually in your app), the controller **claims** the user:
305
+
306
+ 1. Sets `user.scim_source = "scim"` if the column exists.
307
+ 2. Saves the user if changed.
308
+ 3. Creates a new `ScimTenantUser` join record with `active = true`, `provisioned_at = Time.current`, and `scim_claimed_at = Time.current`.
309
+ 4. Calls `adapter.after_provision`.
310
+
311
+ `scim_claimed_at` being set indicates the user was claimed from a pre-existing account rather than created fresh. `provisioned_at` is always the timestamp of the first active assignment by this tenant.
312
+
313
+ A claimed user is indistinguishable from a net-new user from the IdP's perspective — the response is HTTP 201 with the full SCIM user representation either way.
314
+
315
+ Claiming an already-active member (join record exists and `active = true` for the same tenant) raises a 409 Conflict.
316
+
317
+ ---
318
+
319
+ ## Recommended UI Scaffold
320
+
321
+ A minimal tenant management UI typically needs:
322
+
323
+ - **Create form** — `name`, `auth_method` (`"token"` or `"oauth"`). On save, call `rotate_token!` and display the raw token in a one-time reveal modal. Never show it again.
324
+ - **Token rotate button** — confirm intent, call `rotate_token!`, display raw token in a one-time reveal, remind the admin to update the IdP immediately.
325
+ - **Doorkeeper app selector** — shown only when `auth_method = "oauth"`. Renders a `<select>` over `Doorkeeper::Application.all` and saves `doorkeeper_application_id`.
326
+ - **Active toggle** — `update!(active: false/true)`. Deactivating immediately blocks all requests from that tenant's credentials.
327
+
328
+ There is no built-in UI — the gem is intentionally a pure API layer. The above is a starting point for a standard Rails controller + views or a Hotwire component.