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
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.
|