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/testing.md ADDED
@@ -0,0 +1,444 @@
1
+ # Testing Guide
2
+
3
+ ## Overview
4
+
5
+ `devise_scim` ships two test harnesses:
6
+
7
+ - **RSpec** — shared examples that exercise every endpoint in a real request spec, plus a `ScimHelpers` module for building payloads and headers
8
+ - **Minitest** — a `ScimAssertions` module with targeted assertion helpers for `ActionDispatch::IntegrationTest`
9
+
10
+ Both harnesses configure `DeviseScim` internally and reset after each example/test, so they do not interfere with your application's configuration.
11
+
12
+ ---
13
+
14
+ ## RSpec setup
15
+
16
+ Add one require to your `spec/rails_helper.rb` (or `spec/spec_helper.rb`):
17
+
18
+ ```ruby
19
+ require "devise_scim/rspec"
20
+ ```
21
+
22
+ This loads:
23
+ - `DeviseScim::RSpec::ScimHelpers`
24
+ - FactoryBot factories (`:scim_tenant`, `:scim_tenant_user`) — guarded by `defined?(FactoryBot)`, so they are a no-op if you are not using FactoryBot
25
+ - All three shared example groups
26
+
27
+ Make sure FactoryBot is required before `devise_scim/rspec` if you want the factories:
28
+
29
+ ```ruby
30
+ require "factory_bot_rails"
31
+ require "devise_scim/rspec"
32
+ ```
33
+
34
+ ---
35
+
36
+ ## Running the shared examples
37
+
38
+ Drop `it_behaves_like` inside any `RSpec.describe` block that has access to your Rails routes. A request spec is the natural home.
39
+
40
+ ```ruby
41
+ # spec/requests/scim_users_spec.rb
42
+ require "rails_helper"
43
+
44
+ RSpec.describe "SCIM Users", type: :request do
45
+ it_behaves_like "a SCIM Users endpoint", devise_model: User
46
+ end
47
+ ```
48
+
49
+ ```ruby
50
+ # spec/requests/scim_groups_spec.rb
51
+ require "rails_helper"
52
+
53
+ RSpec.describe "SCIM Groups", type: :request do
54
+ it_behaves_like "a SCIM Groups endpoint"
55
+ end
56
+ ```
57
+
58
+ ```ruby
59
+ # spec/requests/scim_discovery_spec.rb
60
+ require "rails_helper"
61
+
62
+ RSpec.describe "SCIM discovery", type: :request do
63
+ it_behaves_like "SCIM discovery endpoints"
64
+ end
65
+ ```
66
+
67
+ Each shared example wraps itself in `before`/`after` blocks that:
68
+ 1. Call `DeviseScim.configure` with `:single` tenancy, `:token` auth, and a freshly generated token
69
+ 2. Call `DeviseScim.reset_configuration!` after the example finishes
70
+
71
+ This means the shared examples are safe to combine in the same suite with your own configuration.
72
+
73
+ ---
74
+
75
+ ## Available shared examples
76
+
77
+ | Name | What it covers | Required options |
78
+ |------|----------------|-----------------|
79
+ | `"a SCIM Users endpoint"` | GET/POST/PUT/PATCH/DELETE /Users, filter, auth, re-provisioning, multi-tenant context | `devise_model:` (the AR class) |
80
+ | `"a SCIM Groups endpoint"` | GET/POST/PATCH/DELETE /Groups, adapter delegation, 500 when `group_to_scim` not implemented | none |
81
+ | `"SCIM discovery endpoints"` | GET /ServiceProviderConfig, /Schemas, /ResourceTypes; groups conditionally included | none |
82
+
83
+ ---
84
+
85
+ ## `ScimHelpers` module
86
+
87
+ Include it in any `describe` block or RSpec configuration:
88
+
89
+ ```ruby
90
+ RSpec.describe "my SCIM spec", type: :request do
91
+ include DeviseScim::RSpec::ScimHelpers
92
+ # ...
93
+ end
94
+ ```
95
+
96
+ Or globally in `rails_helper.rb`:
97
+
98
+ ```ruby
99
+ RSpec.configure do |config|
100
+ config.include DeviseScim::RSpec::ScimHelpers, type: :request
101
+ end
102
+ ```
103
+
104
+ **Methods:**
105
+
106
+ ```ruby
107
+ # Parse a JSON response body into a Hash.
108
+ scim_json(response.body)
109
+ # => { "schemas" => [...], "id" => "1", ... }
110
+
111
+ # The configured route prefix (default: "/scim/v2").
112
+ scim_prefix
113
+ # => "/scim/v2"
114
+
115
+ # Authorization + Content-Type headers for a given token.
116
+ scim_auth_headers("my-token")
117
+ # => { "Authorization" => "Bearer my-token", "Content-Type" => "application/json" }
118
+
119
+ # Build a minimal valid POST /Users payload.
120
+ scim_user_payload(user_name: "alice@example.com")
121
+ # => { "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName" => "alice@example.com" }
122
+
123
+ # Extra keys are merged in:
124
+ scim_user_payload(user_name: "alice@example.com", "name" => { "givenName" => "Alice" })
125
+
126
+ # Wrap one or more operation hashes in a PatchOp envelope.
127
+ scim_patch_payload(
128
+ scim_replace_op("active", false),
129
+ scim_replace_op("userName", "new@example.com")
130
+ )
131
+ # => { "schemas" => ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations" => [...] }
132
+
133
+ # Individual operation builders:
134
+ scim_replace_op("active", false)
135
+ # => { "op" => "replace", "path" => "active", "value" => false }
136
+
137
+ scim_add_op("emails", [{ "value" => "work@example.com", "type" => "work" }])
138
+ # => { "op" => "add", "path" => "emails", "value" => [...] }
139
+
140
+ scim_remove_op("phoneNumbers")
141
+ # => { "op" => "remove", "path" => "phoneNumbers" }
142
+ ```
143
+
144
+ ---
145
+
146
+ ## Testing multi-tenant scenarios
147
+
148
+ The multi-tenant cases are included automatically as a nested `context "multi-tenant"` inside `"a SCIM Users endpoint"`. You do not need to enable or opt in to them — they run whenever you call `it_behaves_like "a SCIM Users endpoint"`.
149
+
150
+ The context creates a `DeviseScim::ScimTenant` record, calls `rotate_token!` to get a raw token, then reconfigures `DeviseScim` to `:multi` tenancy for those examples.
151
+
152
+ The multi-tenant context covers:
153
+
154
+ - Claiming an existing manual user on first SCIM provision (sets `scim_claimed_at` on the join record)
155
+ - 404 for a user not assigned to the requesting tenant
156
+ - `user_exclusivity: :one_to_one` with `exclusivity_conflict: :error` → 409
157
+ - `user_exclusivity: :one_to_one` with `exclusivity_conflict: :reassign` → reassigns the join record
158
+
159
+ If your spec database does not have the `scim_tenants` and `scim_tenant_users` tables (i.e., you have not run the multi-tenant migrations), the multi-tenant context will fail. Run `rails g devise_scim:install --multi-tenant` and `rails db:migrate` in your test environment, or skip the shared example and write targeted specs instead.
160
+
161
+ ---
162
+
163
+ ## Stubbing IdP payloads
164
+
165
+ Use `scim_user_payload` for standard fields, and merge extra keys for IdP-specific extensions.
166
+
167
+ **Okta-style POST /Users with name and phone:**
168
+
169
+ ```ruby
170
+ payload = scim_user_payload(
171
+ user_name: "alice@example.com",
172
+ "name" => { "givenName" => "Alice", "familyName" => "Smith", "formatted" => "Alice Smith" },
173
+ "emails" => [{ "value" => "alice@example.com", "type" => "work", "primary" => true }],
174
+ "phoneNumbers" => [{ "value" => "+14155550100", "type" => "work" }],
175
+ "userType" => "Employee"
176
+ )
177
+
178
+ post "#{scim_prefix}/Users", params: payload.to_json, headers: scim_auth_headers(token)
179
+ ```
180
+
181
+ **Complex PATCH with multiple operations:**
182
+
183
+ ```ruby
184
+ payload = scim_patch_payload(
185
+ scim_replace_op("active", false),
186
+ scim_replace_op("name.givenName", "Alicia"),
187
+ scim_add_op("phoneNumbers", [{ "value" => "+14155550101", "type" => "mobile" }])
188
+ )
189
+
190
+ patch "#{scim_prefix}/Users/#{user.id}", params: payload.to_json, headers: scim_auth_headers(token)
191
+ ```
192
+
193
+ **Testing a filter-expression PATCH path (e.g., Okta email update):**
194
+
195
+ ```ruby
196
+ payload = scim_patch_payload(
197
+ scim_replace_op('emails[type eq "work"].value', "updated@example.com")
198
+ )
199
+
200
+ patch "#{scim_prefix}/Users/#{user.id}", params: payload.to_json, headers: scim_auth_headers(token)
201
+ expect(response).to have_http_status(:ok)
202
+ expect(user.reload.email).to eq("updated@example.com")
203
+ ```
204
+
205
+ ---
206
+
207
+ ## Testing re-provisioning
208
+
209
+ The full create → DELETE → re-provision cycle:
210
+
211
+ ```ruby
212
+ include DeviseScim::RSpec::ScimHelpers
213
+
214
+ let(:token) { "test-token" }
215
+ let(:headers) { scim_auth_headers(token) }
216
+
217
+ before do
218
+ DeviseScim.configure do |c|
219
+ c.tenancy = :single
220
+ c.auth_method = :token
221
+ c.token = token
222
+ end
223
+ end
224
+
225
+ after { DeviseScim.reset_configuration! }
226
+
227
+ it "re-provisions a deleted SCIM user" do
228
+ # Create.
229
+ post "#{scim_prefix}/Users",
230
+ params: scim_user_payload(user_name: "alice@example.com").to_json,
231
+ headers: headers
232
+ expect(response).to have_http_status(:created)
233
+ user = User.find_by!(email: "alice@example.com")
234
+
235
+ # Deprovision.
236
+ delete "#{scim_prefix}/Users/#{user.id}", headers: headers
237
+ expect(response).to have_http_status(:no_content)
238
+ expect(user.reload.scim_active).to be(false)
239
+
240
+ # Re-provision — same email, POST again.
241
+ post "#{scim_prefix}/Users",
242
+ params: scim_user_payload(user_name: "alice@example.com").to_json,
243
+ headers: headers
244
+ expect(response).to have_http_status(:created)
245
+ expect(user.reload.scim_active).to be(true)
246
+ # Record count should not increase — same row was re-activated.
247
+ expect(User.where(email: "alice@example.com").count).to eq(1)
248
+ end
249
+ ```
250
+
251
+ ---
252
+
253
+ ## Testing custom adapter behavior
254
+
255
+ **Verify `after_provision` is called:**
256
+
257
+ ```ruby
258
+ it "calls after_provision on the adapter after create" do
259
+ adapter = instance_double(ApplicationScimAdapter,
260
+ attributes_for_create: { email: "alice@example.com" },
261
+ after_provision: nil,
262
+ to_scim: DeviseScim::Scim::User.new.tap { |u| u.id = "1"; u.user_name = "alice@example.com" }
263
+ )
264
+
265
+ allow(ApplicationScimAdapter).to receive(:new).and_return(adapter)
266
+
267
+ post "#{scim_prefix}/Users",
268
+ params: scim_user_payload(user_name: "alice@example.com").to_json,
269
+ headers: headers
270
+
271
+ expect(adapter).to have_received(:after_provision)
272
+ end
273
+ ```
274
+
275
+ **Verify group delegation with a spy:**
276
+
277
+ ```ruby
278
+ it "delegates POST /Groups to handle_group_create" do
279
+ group_scim = DeviseScim::Scim::Group.new.tap { |g| g.id = "grp-1"; g.display_name = "Admins" }
280
+ adapter_spy = instance_double(ApplicationScimAdapter,
281
+ handle_group_create: nil,
282
+ group_to_scim: group_scim
283
+ )
284
+
285
+ allow(ApplicationScimAdapter).to receive(:new).and_return(adapter_spy)
286
+
287
+ post "#{scim_prefix}/Groups",
288
+ params: { "schemas" => [DeviseScim::Scim::GROUP_SCHEMA], "displayName" => "Admins" }.to_json,
289
+ headers: headers
290
+
291
+ expect(adapter_spy).to have_received(:handle_group_create)
292
+ expect(response).to have_http_status(:created)
293
+ end
294
+ ```
295
+
296
+ ---
297
+
298
+ ## Factories
299
+
300
+ `devise_scim/rspec` registers two FactoryBot factories when FactoryBot is available.
301
+
302
+ **`:scim_tenant`** — creates a `DeviseScim::ScimTenant` with a sequential name and token auth:
303
+
304
+ ```ruby
305
+ tenant = create(:scim_tenant)
306
+ # tenant.name => "Test Tenant 1"
307
+ # tenant.auth_method => "token"
308
+ # tenant.active => true
309
+ # tenant.token_digest => nil <-- no token yet
310
+ ```
311
+
312
+ The factory does **not** call `rotate_token!`. Call it yourself to get the raw token:
313
+
314
+ ```ruby
315
+ tenant = create(:scim_tenant)
316
+ raw_token = tenant.rotate_token!
317
+ headers = scim_auth_headers(raw_token)
318
+ ```
319
+
320
+ **`:scim_tenant_user`** — creates a `DeviseScim::ScimTenantUser` join record:
321
+
322
+ ```ruby
323
+ user = create(:user)
324
+ tenant = create(:scim_tenant)
325
+
326
+ join = create(:scim_tenant_user, scim_tenant: tenant, user: user)
327
+ # join.active => true
328
+ # join.provisioned_at => Time.current
329
+ ```
330
+
331
+ You must pass `user:` explicitly — the factory has no default user association (it does not know your `User` class name).
332
+
333
+ ---
334
+
335
+ ## Minitest setup
336
+
337
+ Require the module and include it in your test class:
338
+
339
+ ```ruby
340
+ # test/test_helper.rb (or inside an individual test file)
341
+ require "devise_scim/minitest"
342
+ ```
343
+
344
+ ```ruby
345
+ class ScimTest < ActionDispatch::IntegrationTest
346
+ include DeviseScim::Minitest::ScimAssertions
347
+ end
348
+ ```
349
+
350
+ Or include it globally in `ActiveSupport::TestCase` / `ActionDispatch::IntegrationTest` via a concern in `test/test_helper.rb`.
351
+
352
+ ---
353
+
354
+ ## Minitest example
355
+
356
+ ```ruby
357
+ require "test_helper"
358
+
359
+ class ScimUsersTest < ActionDispatch::IntegrationTest
360
+ include DeviseScim::Minitest::ScimAssertions
361
+
362
+ TOKEN = "minitest-scim-token"
363
+
364
+ setup do
365
+ DeviseScim.configure do |c|
366
+ c.tenancy = :single
367
+ c.auth_method = :token
368
+ c.token = TOKEN
369
+ end
370
+ end
371
+
372
+ teardown { DeviseScim.reset_configuration! }
373
+
374
+ test "GET /scim/v2/Users returns a ListResponse" do
375
+ User.create!(email: "alice@example.com")
376
+
377
+ get "/scim/v2/Users", headers: scim_auth_headers(TOKEN)
378
+
379
+ assert_scim_status(response, 200)
380
+ assert_scim_content_type(response)
381
+ assert_scim_list_response(response)
382
+ end
383
+
384
+ test "POST /scim/v2/Users creates a user" do
385
+ payload = scim_user_payload(user_name: "bob@example.com")
386
+
387
+ post "/scim/v2/Users",
388
+ params: payload.to_json,
389
+ headers: scim_auth_headers(TOKEN)
390
+
391
+ assert_scim_status(response, 201)
392
+ assert_scim_schema(response, DeviseScim::Scim::USER_SCHEMA)
393
+ assert_equal "bob@example.com", scim_json(response)["userName"]
394
+ end
395
+
396
+ test "GET /scim/v2/Users returns 401 without auth" do
397
+ get "/scim/v2/Users"
398
+ assert_scim_status(response, 401)
399
+ assert_scim_error(response, expected_status: 401)
400
+ end
401
+
402
+ test "DELETE deprovisioning sets scim_active to false" do
403
+ user = User.create!(email: "carol@example.com", scim_source: "scim")
404
+
405
+ delete "/scim/v2/Users/#{user.id}", headers: scim_auth_headers(TOKEN)
406
+
407
+ assert_scim_status(response, 204)
408
+ assert_equal false, user.reload.scim_active
409
+ end
410
+ end
411
+ ```
412
+
413
+ **Assertion reference:**
414
+
415
+ | Method | What it checks |
416
+ |--------|----------------|
417
+ | `assert_scim_status(response, status)` | `response.status == status` (coerces to string) |
418
+ | `assert_scim_content_type(response)` | `Content-Type` includes `application/scim+json` |
419
+ | `assert_scim_schema(response, schema)` | `body["schemas"]` includes the given URN |
420
+ | `assert_scim_list_response(response)` | Combines schema check with `totalResults` and `Resources` key presence |
421
+ | `assert_scim_error(response, expected_status: nil)` | `body["schemas"]` includes the SCIM error URN; optionally checks `body["status"]` |
422
+ | `scim_json(response)` | `JSON.parse(response.body)` |
423
+ | `scim_auth_headers(token)` | `{ "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }` |
424
+ | `scim_user_payload(user_name:, **attrs)` | Minimal User payload hash; extra keys are merged |
425
+ | `scim_patch_payload(*operations)` | PatchOp envelope hash |
426
+
427
+ ---
428
+
429
+ ## Testing against a real IdP
430
+
431
+ To test your SCIM endpoint with an actual IdP (Okta, Entra ID, JumpCloud) during local development, expose your Rails server over a public HTTPS tunnel:
432
+
433
+ ```sh
434
+ # Cloudflare Tunnel (no account required for one-off tunnels):
435
+ cloudflared tunnel --url http://localhost:3000
436
+
437
+ # ngrok:
438
+ ngrok http 3000
439
+ ```
440
+
441
+ Configure your IdP's SCIM connector to point at `https://<tunnel-host>/scim/v2`. Set a bearer token that matches `config.token` in your initializer (or create a `ScimTenant` record with a rotated token for multi-tenant mode).
442
+
443
+ > [!NOTE]
444
+ > Real IdPs send PATCH operations with filter-expression paths like `emails[type eq "work"].value`. Verify your adapter handles these before testing with an IdP — the gem's filter parser covers them, but your `attributes_for_update` or custom `to_scim` may need to account for the mapped attribute.
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ module Auth
5
+ class BaseStrategy
6
+ private
7
+
8
+ def extract_token(env)
9
+ auth = env["HTTP_AUTHORIZATION"]
10
+ return nil unless auth&.start_with?("Bearer ")
11
+
12
+ auth.delete_prefix("Bearer ").strip
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ module Auth
5
+ class OauthStrategy < BaseStrategy
6
+ def authenticate(env)
7
+ unless defined?(Doorkeeper)
8
+ raise ConfigurationError,
9
+ "auth_method :oauth requires the doorkeeper gem. Add `gem 'doorkeeper'` to your Gemfile."
10
+ end
11
+
12
+ raw = extract_token(env)
13
+ return nil unless raw
14
+
15
+ access_token = Doorkeeper::AccessToken.by_token(raw)
16
+ return nil unless access_token&.accessible?
17
+
18
+ config = DeviseScim.configuration
19
+
20
+ if config.tenancy == :multi
21
+ config.tenant_model.constantize.find_by(doorkeeper_application_id: access_token.application_id)
22
+ else
23
+ :ok
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/security_utils"
4
+
5
+ module DeviseScim
6
+ module Auth
7
+ class TokenStrategy < BaseStrategy
8
+ def authenticate(env)
9
+ raw = extract_token(env)
10
+ return nil unless raw
11
+
12
+ config = DeviseScim.configuration
13
+
14
+ if config.tenancy == :multi
15
+ config.tenant_model.constantize.authenticate_token(raw)
16
+ else
17
+ return nil if config.token.nil?
18
+ return nil unless ActiveSupport::SecurityUtils.secure_compare(raw, config.token)
19
+
20
+ :ok
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ module Concerns
5
+ module ScimGroupIdentifiable
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def find_by_scim_uid(uid, tenant: nil)
10
+ scope = where(scim_group_uid: uid)
11
+ scope = scope.where(tenant_id: tenant.id) if tenant && column_names.include?("tenant_id")
12
+ scope.first
13
+ end
14
+
15
+ def authenticate_scim_group(scim_group, tenant: nil)
16
+ find_by_scim_uid(scim_group.external_id || scim_group.id, tenant: tenant)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bcrypt"
4
+ require "securerandom"
5
+
6
+ module DeviseScim
7
+ module Concerns
8
+ module ScimTenant
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ validates :auth_method, inclusion: { in: %w[token oauth] }
13
+ validates scim_tenant_label_column, presence: true
14
+ end
15
+
16
+ class_methods do
17
+ def authenticate_token(raw_token)
18
+ where(auth_method: "token", active: true).find do |record|
19
+ BCrypt::Password.new(record.token_digest).is_password?(raw_token)
20
+ rescue BCrypt::Errors::InvalidHash
21
+ false
22
+ end
23
+ end
24
+
25
+ def scim_tenant_label_column
26
+ :name
27
+ end
28
+ end
29
+
30
+ def rotate_token!
31
+ raw = SecureRandom.hex(32)
32
+ update!(token_digest: BCrypt::Password.create(raw))
33
+ raw
34
+ end
35
+
36
+ def scim_active?
37
+ active
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ class Configuration
5
+ VALID_TENANCY = %i[single multi].freeze
6
+ VALID_AUTH_METHODS = %i[token oauth].freeze
7
+ VALID_DEPROVISION = [false, true, :error].freeze
8
+ VALID_USER_EXCLUSIVITY = %i[multiple one_to_one].freeze
9
+ VALID_EXCLUSIVITY_CONFLICT = %i[error reassign].freeze
10
+
11
+ attr_accessor \
12
+ :route_prefix,
13
+ :tenancy,
14
+ :auth_method,
15
+ :token,
16
+ :oauth_client_id,
17
+ :oauth_client_secret,
18
+ :devise_model,
19
+ :tenant_model,
20
+ :enable_groups,
21
+ :soft_delete,
22
+ :deprovision_manual_users,
23
+ :user_exclusivity,
24
+ :exclusivity_conflict,
25
+ :adapter
26
+
27
+ def initialize
28
+ @route_prefix = "/scim/v2"
29
+ @tenancy = :single
30
+ @auth_method = :token
31
+ @token = nil
32
+ @oauth_client_id = nil
33
+ @oauth_client_secret = nil
34
+ @devise_model = "User"
35
+ @tenant_model = "DeviseScim::ScimTenant"
36
+ @enable_groups = false
37
+ @soft_delete = true
38
+ @deprovision_manual_users = false
39
+ @user_exclusivity = :multiple
40
+ @exclusivity_conflict = :error
41
+ @adapter = nil
42
+ end
43
+
44
+ def validate!
45
+ validate_enum!(:tenancy, tenancy, VALID_TENANCY)
46
+ validate_enum!(:auth_method, auth_method, VALID_AUTH_METHODS)
47
+ validate_enum!(:user_exclusivity, user_exclusivity, VALID_USER_EXCLUSIVITY)
48
+ validate_enum!(:exclusivity_conflict, exclusivity_conflict, VALID_EXCLUSIVITY_CONFLICT)
49
+ validate_deprovision!
50
+ validate_oauth!
51
+ validate_single_tenant_options!
52
+ end
53
+
54
+ private
55
+
56
+ def validate_enum!(name, value, valid)
57
+ return if valid.include?(value)
58
+
59
+ raise ConfigurationError,
60
+ "#{name} must be one of #{valid.inspect}; got #{value.inspect}"
61
+ end
62
+
63
+ def validate_deprovision!
64
+ return if VALID_DEPROVISION.include?(deprovision_manual_users)
65
+
66
+ raise ConfigurationError,
67
+ "deprovision_manual_users must be true, false, or :error; got #{deprovision_manual_users.inspect}"
68
+ end
69
+
70
+ def validate_oauth!
71
+ return unless auth_method == :oauth && !doorkeeper_available?
72
+
73
+ raise ConfigurationError,
74
+ "auth_method :oauth requires the doorkeeper gem. Add `gem 'doorkeeper'` to your Gemfile."
75
+ end
76
+
77
+ def validate_single_tenant_options!
78
+ if tenancy == :single && tenant_model != "DeviseScim::ScimTenant"
79
+ raise ConfigurationError, "tenant_model is only applicable in multi-tenant mode."
80
+ end
81
+
82
+ return unless tenancy == :single && user_exclusivity == :one_to_one
83
+
84
+ raise ConfigurationError,
85
+ "user_exclusivity :one_to_one is only meaningful in multi-tenant mode."
86
+ end
87
+
88
+ def doorkeeper_available?
89
+ defined?(Doorkeeper)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace DeviseScim
6
+
7
+ initializer "devise_scim.middleware" do |app|
8
+ app.middleware.use DeviseScim::Middleware::Authenticator
9
+ end
10
+
11
+ config.after_initialize do
12
+ DeviseScim.configuration.validate!
13
+ end
14
+ end
15
+ end