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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ed8cd737f5466cd3adeeb6f4b1274d0807057f079759ce29f5313d4f4fc414ff
4
+ data.tar.gz: db59a0c733d18f8c319e8019beabd0e5735d6f9f49d7420c999f7ee419b82be0
5
+ SHA512:
6
+ metadata.gz: 3424132891dd868226265693e899136de30c43184091e130144e3763ca3cea21fec366473af0346a1925ea7b0a6ba8cc091ed6f28d520292e108a9b894cff470
7
+ data.tar.gz: b2e3d46585e47da0ae8fb0de86359c1b31b783dc19ad8300d9cd681f6a586b4010a810a5409caa3cbb1d4829848aa5dd8a697665781d65c42ae2d4f142038859
data/AGENTS.md ADDED
@@ -0,0 +1,124 @@
1
+ # AGENTS.md
2
+
3
+ This is `devise_scim` — a SCIM 2.0 server engine for Rails + Devise applications.
4
+
5
+ ## Key directory layout
6
+
7
+ | Path | Contents |
8
+ |------|----------|
9
+ | `lib/devise_scim/` | Gem code: configuration, auth strategies, filter system, SCIM structs, routing, adapter base class, Railtie/engine |
10
+ | `app/controllers/devise_scim/` | Controller layer: UsersController, GroupsController, ServiceProviderController, SchemasController, ResourceTypesController, ApplicationController |
11
+ | `lib/generators/` | `devise_scim:install` and `devise_scim:adapter` generators, plus all `.rb.tt` migration and initializer templates |
12
+ | `spec/` | RSpec suite — unit specs for every subsystem, request specs for all endpoints |
13
+ | `spec/internal/` | Combustion test app: `db/schema.rb`, `config/routes.rb`, minimal models, warden initializer |
14
+ | `lib/devise_scim/rspec/` | Host-app test harness: shared examples for Users/Groups/discovery endpoints, `ScimHelpers`, FactoryBot factories |
15
+
16
+ ## Required checks before any commit
17
+
18
+ Run all three before opening a PR. A single failure is a blocker.
19
+
20
+ ```sh
21
+ bundle exec rspec # 0 failures required
22
+ bundle exec rubocop # 0 offenses required (run --autocorrect for safe fixes first)
23
+ bundle exec brakeman --force --no-pager # 0 security warnings required
24
+ ```
25
+
26
+ ## Configuration contract
27
+
28
+ All configuration lives in `DeviseScim::Configuration` (`lib/devise_scim/configuration.rb`). The generated initializer template is `lib/generators/devise_scim/templates/devise_scim.rb.tt`.
29
+
30
+ **Rule:** changing a default or adding a new attribute requires updating **both** files. The `.tt` template is what host apps receive — it must stay in sync with `configuration.rb`.
31
+
32
+ | Attribute | Valid values | Default |
33
+ |-----------|-------------|---------|
34
+ | `route_prefix` | Any string | `"/scim/v2"` |
35
+ | `tenancy` | `:single`, `:multi` | `:single` |
36
+ | `auth_method` | `:token`, `:oauth` | `:token` |
37
+ | `token` | String or `nil` | `nil` |
38
+ | `oauth_client_id` | String or `nil` | `nil` |
39
+ | `oauth_client_secret` | String or `nil` | `nil` |
40
+ | `devise_model` | String (class name) | `"User"` |
41
+ | `tenant_model` | String (class name) | `"DeviseScim::ScimTenant"` |
42
+ | `enable_groups` | `true`, `false` | `false` |
43
+ | `soft_delete` | `true`, `false` | `true` |
44
+ | `deprovision_manual_users` | `false`, `true`, `:error` | `false` |
45
+ | `user_exclusivity` | `:multiple`, `:one_to_one` | `:multiple` |
46
+ | `exclusivity_conflict` | `:error`, `:reassign` | `:error` |
47
+ | `adapter` | String (class name) or `nil` | `nil` |
48
+
49
+ Validation is in `Configuration#validate!` — add a corresponding guard there for any new attribute with a constrained value set.
50
+
51
+ ## Migration template location
52
+
53
+ Templates live in `lib/generators/devise_scim/templates/*.rb.tt`.
54
+
55
+ | Template | Purpose |
56
+ |----------|---------|
57
+ | `add_scim_to_users.rb.tt` | Adds SCIM columns to the user table (single- and multi-tenant) |
58
+ | `create_scim_tenants.rb.tt` | Creates the `scim_tenants` table (built-in tenant, multi-tenant only) |
59
+ | `add_scim_to_tenant.rb.tt` | Adds SCIM columns to an existing tenant table (`--tenant-model` flag) |
60
+ | `create_scim_tenant_users.rb.tt` | Creates the `scim_tenant_users` join table (multi-tenant only) |
61
+ | `devise_scim.rb.tt` | Initializer |
62
+ | `application_scim_adapter.rb.tt` | Adapter skeleton (adapter generator) |
63
+
64
+ The multi-tenant templates reference the `tenant_fk_column` helper method defined in `InstallGenerator` — it resolves to `scim_tenant_id` for the built-in model or `<tenant_model_underscored>_id` for a custom one. The `add_scim_to_tenant.rb.tt` template guards every `add_column` with `unless column_exists?` so it is safe to run against an existing table.
65
+
66
+ ## Auth strategies
67
+
68
+ ```
69
+ lib/devise_scim/auth/
70
+ base_strategy.rb # extracts Bearer token from Authorization header
71
+ token_strategy.rb # compares against config.token (single) or ScimTenant.authenticate_token (multi)
72
+ oauth_strategy.rb # validates Doorkeeper access tokens
73
+ lib/devise_scim/middleware/authenticator.rb
74
+ ```
75
+
76
+ `Authenticator` is a Rack middleware inserted early in the stack. It intercepts every request whose path starts with `route_prefix`, delegates to the appropriate strategy, and either sets `env["devise_scim.tenant"]` (multi-tenant) or returns a 401 SCIM error response. It also calls `warden.custom_failure!` so Warden does not swallow the 401.
77
+
78
+ Do not move auth logic into controllers — the middleware layer is the single authentication boundary.
79
+
80
+ ## Filter system
81
+
82
+ ```
83
+ lib/devise_scim/filter/
84
+ parser.rb # tokenizer + recursive descent parser → AST
85
+ arel_visitor.rb # walks AST → pure Arel conditions
86
+ ```
87
+
88
+ The tokenizer uses explicit `m = regex.match(s)` calls rather than `gsub` to avoid `$&` clobbering. Keep it that way — `String#gsub` clears `$&` even for String patterns, which silently breaks the tokenizer.
89
+
90
+ `ArelVisitor` maps SCIM attribute names to AR column names and emits only Arel nodes — **no string interpolation, ever**. If you add a new filterable attribute, add it to the column mapping in `ArelVisitor` and add a spec in `spec/filter/arel_visitor_spec.rb`.
91
+
92
+ ## Adding a new SCIM attribute
93
+
94
+ 1. Add the field to the relevant struct in `lib/devise_scim/scim/user.rb` or `lib/devise_scim/scim/group.rb`.
95
+ 2. Update `from_h` to parse the new attribute from the incoming hash.
96
+ 3. Update `to_h` to serialize it in the outgoing hash.
97
+ 4. Update `ScimAdapter#attributes_for_create`, `#attributes_for_update`, and `#to_scim` defaults in `lib/devise_scim/scim_adapter.rb` as appropriate.
98
+ 5. If the attribute is filterable, add the SCIM→column mapping to `ArelVisitor`.
99
+ 6. Add spec coverage: a unit spec for the struct, a request spec or shared-example assertion for the endpoint.
100
+
101
+ ## Test app
102
+
103
+ `spec/internal/` is a [Combustion](https://github.com/pat/combustion) application loaded before the RSpec suite.
104
+
105
+ - **Schema:** `spec/internal/db/schema.rb` is loaded at suite start via `ActiveRecord::Schema.define`. Do not drop or rename existing tables or columns — doing so breaks specs that depend on those structures.
106
+ - **Routes:** `spec/internal/config/routes.rb` mounts three `scim_for` variants (default, groups-enabled, OAuth-enabled) to exercise conditional route generation. Keep all three.
107
+ - **Models:** `spec/internal/app/models/` contains the minimal `User` and `ScimGroup` models used by the suite.
108
+
109
+ When adding a new integration spec, use the existing models and schema. Add columns to the schema only when genuinely required, and add them to the end of the relevant `create_table` block.
110
+
111
+ ## Commit message convention
112
+
113
+ - Imperative mood, under 72 characters on the subject line (`Add`, `Fix`, `Remove`, not `Added` / `Fixes`)
114
+ - Body explains **why**, not what — the diff already shows what changed
115
+ - Reference an issue number in the body when one exists (`Closes #123`)
116
+
117
+ Example:
118
+
119
+ ```
120
+ Add :reassign exclusivity_conflict mode
121
+
122
+ Reassigning to a new tenant is safer than returning an error when an
123
+ IdP reprovisioning cycle runs across a tenant boundary. Closes #47.
124
+ ```
data/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.11] - 2026-04-28
4
+
5
+ ## [0.1.10] - 2026-04-28
6
+
7
+ ## [0.1.9] - 2026-04-28
8
+
9
+ ## [0.1.8] - 2026-04-28
10
+
11
+ ## [0.1.7] - 2026-04-28
12
+
13
+ ## [0.1.6] - 2026-04-28
14
+
15
+ ## [0.1.5] - 2026-04-28
16
+
17
+ ## [0.1.4] - 2026-04-28
18
+
19
+ ## [0.1.3] - 2026-04-28
20
+
21
+ ## [0.1.2] - 2026-04-27
22
+
23
+ ## [0.1.1] - 2026-04-27
24
+
25
+ ## [0.1.0] - 2026-04-27
26
+
27
+ ### Added
28
+
29
+ - SCIM 2.0 server engine (RFC 7643 / 7644) mountable into any Rails + Devise application
30
+ - `UsersController` — full CRUD with PATCH op application, re-provisioning matrix, and configurable deprovision behavior for manual users
31
+ - `GroupsController` — protocol layer delegating to `ScimAdapter`; gracefully handles unimplemented group operations
32
+ - `ServiceProviderController`, `SchemasController`, `ResourceTypesController` — static RFC 7643 discovery responses
33
+ - `ScimAdapter` base class — pluggable attribute mapping for create/update, `to_scim`, lifecycle callbacks (`after_provision`, `after_deprovision`), and no-op group callbacks
34
+ - Single-tenant and multi-tenant deployment modes (`config.tenancy`)
35
+ - Bearer token authentication with bcrypt-safe digest comparison (`config.auth_method = :token`)
36
+ - OAuth 2.0 client credentials authentication via optional Doorkeeper integration (`config.auth_method = :oauth`)
37
+ - `DeviseScim::Concerns::ScimTenant` — brings the full tenant interface to host-app models (`authenticate_token`, `rotate_token!`, `scim_active?`)
38
+ - `DeviseScim::Concerns::ScimGroupIdentifiable` — optional concern for group/role models to standardize `scim_group_uid` storage and lookup
39
+ - SCIM filter parser — tokenizer and recursive-descent AST builder supporting `eq`, `ne`, `co`, `sw`, `ew`, `pr`, `gt`, `ge`, `lt`, `le`, `and`, `or`, `not`
40
+ - `ArelVisitor` — pure Arel condition builder (no string interpolation) that maps SCIM attribute names to AR columns
41
+ - `DeviseScim::Middleware::Authenticator` — Warden-aware middleware that resolves the tenant from the inbound credential
42
+ - `scim_for` routing helper with `groups:` and `oauth:` opt-in flags
43
+ - Install generator (`rails g devise_scim:install`) supporting single-tenant, multi-tenant, and custom tenant-model modes; Doorkeeper preflight check for OAuth/multi-tenant
44
+ - Adapter generator (`rails g devise_scim:adapter`)
45
+ - RSpec test harness (`require "devise_scim/rspec"`) — `ScimHelpers`, `ScimAssertions`, shared examples for Users, Groups, and discovery endpoints; FactoryBot factories
46
+ - Minitest test harness (`require "devise_scim/minitest"`) with equivalent helpers and assertions
47
+ - Comprehensive documentation: `README.md`, `docs/custom_adapter.md`, `docs/multi_tenant.md`, `docs/idp_setup.md`, `docs/testing.md`, `docs/contributing.md`
@@ -0,0 +1,11 @@
1
+ # Code of Conduct
2
+
3
+ "devise_scim" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us through the [Issues](https://github.com/vertigo-prime/devise_scim/issues) tab.
11
+
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Ben Vinson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,348 @@
1
+ # devise_scim
2
+
3
+ **SCIM 2.0 server for Rails + Devise**
4
+
5
+ ## What is this?
6
+
7
+ `devise_scim` mounts a fully compliant SCIM 2.0 server inside any Rails + Devise application, handling user and group provisioning from identity providers like Okta, Azure AD, and OneLogin. Unlike most existing gems it supports both single- and multi-tenant architectures out of the box, is actively maintained, makes no external API calls (pure Ruby — no third-party SCIM SDK), and conforms strictly to RFC 7643 and RFC 7644.
8
+
9
+ ## Requirements
10
+
11
+ - Ruby 3.2+
12
+ - Rails 7.0+
13
+ - Devise 4.9+
14
+ - bcrypt
15
+
16
+ ## Doorkeeper
17
+
18
+ > [!IMPORTANT]
19
+ > `doorkeeper >= 5.6` is required **only** when using OAuth 2.0 authentication (`auth_method: :oauth`) or multi-tenant mode (`tenancy: :multi`). If the gem is missing, `DeviseScim::ConfigurationError` is raised at boot. Add `gem 'doorkeeper', '~> 5.6'` to your Gemfile **before** running the generator with `--oauth` or `--multi-tenant` — the generator performs a preflight check and will abort if it is absent.
20
+
21
+ ## Installation
22
+
23
+ ```ruby
24
+ # Gemfile
25
+ gem 'devise_scim'
26
+ ```
27
+
28
+ ```sh
29
+ bundle install
30
+ ```
31
+
32
+ ## Quick start: single-tenant, bearer token
33
+
34
+ **1. Run the generator**
35
+
36
+ ```sh
37
+ rails g devise_scim:install User
38
+ ```
39
+
40
+ This creates `config/initializers/devise_scim.rb` and the required migrations.
41
+
42
+ **2. Configure the generated initializer**
43
+
44
+ ```ruby
45
+ # config/initializers/devise_scim.rb
46
+ DeviseScim.configure do |config|
47
+ config.route_prefix = "/scim/v2"
48
+ config.tenancy = :single
49
+ config.auth_method = :token
50
+ config.token = ENV.fetch("SCIM_BEARER_TOKEN")
51
+ config.devise_model = "User"
52
+ end
53
+ ```
54
+
55
+ **3. Mount routes**
56
+
57
+ ```ruby
58
+ # config/routes.rb
59
+ Rails.application.routes.draw do
60
+ scim_for :users
61
+ end
62
+ ```
63
+
64
+ **4. Run migrations**
65
+
66
+ ```sh
67
+ rails db:migrate
68
+ ```
69
+
70
+ ## Quick start: single-tenant, OAuth (Doorkeeper)
71
+
72
+ **Prerequisites:** add Doorkeeper to your Gemfile first.
73
+
74
+ ```ruby
75
+ gem 'doorkeeper', '~> 5.6'
76
+ ```
77
+
78
+ ```sh
79
+ bundle install
80
+ rails g devise_scim:install User --oauth
81
+ rails db:migrate
82
+ ```
83
+
84
+ The generator runs `rails g doorkeeper:install` for you if Doorkeeper's tables are not yet present (with your confirmation).
85
+
86
+ The token endpoint is mounted at `{route_prefix}/oauth/token`. Configure your IdP to POST client credentials there.
87
+
88
+ ```ruby
89
+ # config/initializers/devise_scim.rb
90
+ DeviseScim.configure do |config|
91
+ config.route_prefix = "/scim/v2"
92
+ config.tenancy = :single
93
+ config.auth_method = :oauth
94
+ config.oauth_client_id = ENV.fetch("SCIM_CLIENT_ID")
95
+ config.oauth_client_secret = ENV.fetch("SCIM_CLIENT_SECRET")
96
+ config.devise_model = "User"
97
+ end
98
+ ```
99
+
100
+ ## Quick start: multi-tenant (built-in ScimTenant)
101
+
102
+ ```ruby
103
+ # Gemfile
104
+ gem 'doorkeeper', '~> 5.6'
105
+ ```
106
+
107
+ ```sh
108
+ bundle install
109
+ rails g devise_scim:install User --multi-tenant
110
+ rails db:migrate
111
+ ```
112
+
113
+ Create tenants in the console (see [Tenant management](#tenant-management)).
114
+
115
+ ## Quick start: multi-tenant (existing model)
116
+
117
+ Use this when you already have an `Org` (or similar) model that should act as the SCIM tenant.
118
+
119
+ ```sh
120
+ rails g devise_scim:install User --multi-tenant --tenant-model=Org
121
+ rails db:migrate
122
+ ```
123
+
124
+ Include the concern in your model:
125
+
126
+ ```ruby
127
+ class Org < ApplicationRecord
128
+ include DeviseScim::Concerns::ScimTenant
129
+ end
130
+ ```
131
+
132
+ The generator adds only the columns that are missing — every `add_column` call in the migration is guarded with `unless column_exists?`.
133
+
134
+ ## Generator reference
135
+
136
+ ```sh
137
+ rails g devise_scim:install <ModelName> [--oauth] [--multi-tenant] [--tenant-model=ModelName]
138
+ ```
139
+
140
+ | Flag | Effect |
141
+ |------|--------|
142
+ | `--oauth` | Configures OAuth 2.0 client-credentials auth; requires Doorkeeper |
143
+ | `--multi-tenant` | Generates tenant + join-table migrations; requires Doorkeeper |
144
+ | `--tenant-model=Org` | Uses existing `Org` model instead of the built-in `DeviseScim::ScimTenant` |
145
+
146
+ **Preflight behavior:** the generator checks whether `doorkeeper` appears in your Gemfile when `--oauth` or `--multi-tenant` is set. If Doorkeeper is in the Gemfile but its tables have not been generated yet, it prompts to run `rails g doorkeeper:install` before continuing.
147
+
148
+ **Action order:**
149
+
150
+ 1. `preflight_check` — validates Doorkeeper presence
151
+ 2. `copy_user_migration` — `add_scim_to_<table>.rb`
152
+ 3. `copy_tenant_migrations` — `create_scim_tenants.rb` + `create_scim_tenant_users.rb` (or `add_scim_to_<tenant_table>.rb` if `--tenant-model` given)
153
+ 4. `copy_initializer` — `config/initializers/devise_scim.rb`
154
+
155
+ > [!NOTE]
156
+ > The `devise_model` value in the generated initializer is automatically set from the generator argument — no manual editing needed.
157
+
158
+ ## `rails g devise_scim:adapter`
159
+
160
+ Generates a pre-filled `ApplicationScimAdapter` that overrides the base adapter's defaults:
161
+
162
+ ```sh
163
+ rails g devise_scim:adapter
164
+ # → app/scim/application_scim_adapter.rb
165
+ ```
166
+
167
+ Then wire it up:
168
+
169
+ ```ruby
170
+ config.adapter = "ApplicationScimAdapter"
171
+ ```
172
+
173
+ ## Configuration reference
174
+
175
+ | Option | Type | Default | Description |
176
+ |--------|------|---------|-------------|
177
+ | `route_prefix` | `String` | `"/scim/v2"` | Mount path for all SCIM endpoints |
178
+ | `tenancy` | `:single` \| `:multi` | `:single` | Single or multi-tenant mode |
179
+ | `auth_method` | `:token` \| `:oauth` | `:token` | Authentication strategy |
180
+ | `token` | `String\|nil` | `nil` | Bearer token (single-tenant token auth only) |
181
+ | `oauth_client_id` | `String\|nil` | `nil` | OAuth client ID (single-tenant OAuth only) |
182
+ | `oauth_client_secret` | `String\|nil` | `nil` | OAuth client secret (single-tenant OAuth only) |
183
+ | `devise_model` | `String` | `"User"` | Devise model class name |
184
+ | `tenant_model` | `String` | `"DeviseScim::ScimTenant"` | Tenant model class name (multi-tenant only) |
185
+ | `enable_groups` | `Boolean` | `false` | Mount `/Groups` endpoints |
186
+ | `soft_delete` | `Boolean` | `true` | `true` = set `scim_active=false` on DELETE; `false` = destroy record |
187
+ | `deprovision_manual_users` | `false` \| `true` \| `:error` | `false` | Behaviour when DELETE targets a manually-created user |
188
+ | `user_exclusivity` | `:multiple` \| `:one_to_one` | `:multiple` | Whether a user may belong to multiple tenants (multi-tenant only) |
189
+ | `exclusivity_conflict` | `:error` \| `:reassign` | `:error` | Conflict resolution when `user_exclusivity: :one_to_one` (multi-tenant only) |
190
+ | `adapter` | `String\|nil` | `nil` | Adapter class name; uses built-in defaults when `nil` |
191
+
192
+ ## Routes reference
193
+
194
+ `scim_for :users` mounts the following routes under `route_prefix` (default `/scim/v2`):
195
+
196
+ ```
197
+ GET /scim/v2/Users
198
+ POST /scim/v2/Users
199
+ GET /scim/v2/Users/:id
200
+ PUT /scim/v2/Users/:id
201
+ PATCH /scim/v2/Users/:id
202
+ DELETE /scim/v2/Users/:id
203
+
204
+ # when enable_groups: true
205
+ GET /scim/v2/Groups
206
+ POST /scim/v2/Groups
207
+ GET /scim/v2/Groups/:id
208
+ PUT /scim/v2/Groups/:id
209
+ PATCH /scim/v2/Groups/:id
210
+ DELETE /scim/v2/Groups/:id
211
+
212
+ # when auth_method: :oauth
213
+ POST /scim/v2/oauth/token
214
+
215
+ # always present
216
+ GET /scim/v2/ServiceProviderConfig
217
+ GET /scim/v2/Schemas
218
+ GET /scim/v2/ResourceTypes
219
+ ```
220
+
221
+ Override the prefix with the `at:` option:
222
+
223
+ ```ruby
224
+ scim_for :users, at: "/api/scim/v2"
225
+ ```
226
+
227
+ ## Tenant management
228
+
229
+ ```ruby
230
+ # Create a tenant
231
+ tenant = DeviseScim::ScimTenant.create!(name: "Acme Corp", auth_method: "token", active: true)
232
+
233
+ # Issue a bearer token — only returned once, store it securely
234
+ raw_token = tenant.rotate_token!
235
+ ```
236
+
237
+ > [!WARNING]
238
+ > `rotate_token!` returns the raw token **exactly once**. Only the bcrypt digest is persisted. Store the raw token immediately (e.g. copy it to your IdP's configuration) — it cannot be retrieved again. Call `rotate_token!` again to issue a replacement, which invalidates the previous token.
239
+
240
+ ```ruby
241
+ # Link to a Doorkeeper OAuth application (OAuth auth method)
242
+ app = Doorkeeper::Application.find_by(name: "Acme IdP")
243
+ tenant.update!(doorkeeper_application: app, auth_method: "oauth")
244
+
245
+ # Check active status
246
+ tenant.scim_active? # => true / false
247
+
248
+ # Deactivate (blocks all further SCIM requests for this tenant)
249
+ tenant.update!(active: false)
250
+ ```
251
+
252
+ ## User source tracking and deprovision
253
+
254
+ The `scim_source` column on the user record tracks how the user was created:
255
+
256
+ | Value | Meaning |
257
+ |-------|---------|
258
+ | `"scim"` | Created or claimed via SCIM provisioning |
259
+ | `nil` | Created manually (e.g. sign-up, seed data, console) |
260
+
261
+ The `deprovision_manual_users` config controls what happens when a DELETE request targets a user with `scim_source: nil`:
262
+
263
+ | `scim_source` | `deprovision_manual_users` | Result |
264
+ |---------------|---------------------------|--------|
265
+ | `"scim"` | any | Deprovision (soft-delete or destroy) — 204 No Content |
266
+ | `nil` | `false` (default) | Skip silently — 200 OK |
267
+ | `nil` | `true` | Deprovision — 204 No Content |
268
+ | `nil` | `:error` | 409 Conflict |
269
+
270
+ ## Re-provisioning
271
+
272
+ When a POST to `/Users` matches a user with `scim_active: false` (previously deprovisioned), the gem re-provisions them rather than returning a conflict:
273
+
274
+ - `scim_active` is set to `true`
275
+ - `scim_deprovisioned_at` is cleared
276
+ - `after_provision` callback is invoked on the adapter
277
+
278
+ This allows IdPs to deprovision and later re-provision the same user without manual intervention.
279
+
280
+ ## User exclusivity (multi-tenant)
281
+
282
+ Available only in `tenancy: :multi` mode.
283
+
284
+ | `user_exclusivity` | Behaviour |
285
+ |--------------------|-----------|
286
+ | `:multiple` (default) | A user may belong to any number of tenants simultaneously |
287
+ | `:one_to_one` | A user may belong to at most one tenant at a time |
288
+
289
+ When `:one_to_one` is set and a POST `/Users` matches a user already assigned to a different tenant, `exclusivity_conflict` controls the outcome:
290
+
291
+ | `exclusivity_conflict` | Outcome |
292
+ |------------------------|---------|
293
+ | `:error` (default) | 409 Conflict |
294
+ | `:reassign` | Deactivates the old tenant join record, creates a new one for the requesting tenant |
295
+
296
+ ## Group provisioning
297
+
298
+ The gem handles the SCIM protocol layer for groups; business logic lives in your adapter. When `enable_groups: true`, implement the following methods in your `ApplicationScimAdapter`:
299
+
300
+ ```ruby
301
+ def handle_group_create # called on POST /Groups
302
+ def handle_group_update # called on PUT/PATCH /Groups/:id
303
+ def handle_group_destroy # called on DELETE /Groups/:id
304
+ def group_to_scim # returns a DeviseScim::Scim::Group instance
305
+ ```
306
+
307
+ The base adapter raises `NotImplementedError` for `group_to_scim` and no-ops the other callbacks — groups succeed at the protocol level until you implement the methods. See `docs/custom_adapter.md` for a full walkthrough.
308
+
309
+ ## Test harness
310
+
311
+ The gem ships a test harness you can include in your host app's test suite.
312
+
313
+ **RSpec:**
314
+
315
+ ```ruby
316
+ # spec/rails_helper.rb
317
+ require "devise_scim/rspec"
318
+ ```
319
+
320
+ ```ruby
321
+ RSpec.describe "SCIM Users" do
322
+ it_behaves_like "a SCIM Users endpoint", devise_model: User
323
+ end
324
+ ```
325
+
326
+ **Minitest:**
327
+
328
+ ```ruby
329
+ require "devise_scim/minitest"
330
+
331
+ class ScimUsersTest < ActionDispatch::IntegrationTest
332
+ include DeviseScim::Minitest::ScimAssertions
333
+ # ...
334
+ end
335
+ ```
336
+
337
+ The shared RSpec example covers index, show, create, replace, PATCH update, delete, re-provisioning, authentication enforcement, and multi-tenant scenarios. See `docs/testing.md` for available options and writing custom assertions.
338
+
339
+ ## License
340
+
341
+ MIT. Used at your own risk. No liability is held by the author.
342
+
343
+ ## Contributing
344
+
345
+ This gem was developed with significant assistance from Claude (Anthropic). Contributions and audits welcome, AI or otherwise.
346
+
347
+ 1. Please follow the [contributing guidelines](CONTRIBUTING.md) for submitting pull requests and reporting issues.
348
+ 2. Ensure your code adheres to the [code of conduct](CODE_OF_CONDUCT.md) and is tested with the provided test harness.
data/Rakefile ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ require "brakeman"
13
+
14
+ namespace :brakeman do
15
+ desc "Run Brakeman security scan"
16
+ task :check do
17
+ Brakeman.run(app_path: ".", force_scan: true, print_report: true, quiet: false)
18
+ end
19
+ end
20
+
21
+ task default: %i[spec rubocop brakeman:check]
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ class ApplicationController < ActionController::API
5
+ before_action :set_content_type
6
+
7
+ rescue_from NotFound, with: :render_not_found
8
+ rescue_from Conflict, with: :render_conflict
9
+ rescue_from InvalidFilter, with: :render_invalid_filter
10
+
11
+ protected
12
+
13
+ def current_scim_tenant
14
+ request.env["devise_scim.tenant"]
15
+ end
16
+
17
+ def multi_tenant?
18
+ DeviseScim.configuration.tenancy == :multi
19
+ end
20
+
21
+ def devise_model
22
+ DeviseScim.configuration.devise_model.constantize
23
+ end
24
+
25
+ def tenant_scope
26
+ if multi_tenant? && current_scim_tenant
27
+ stu = ScimTenantUser.arel_table
28
+ dm = devise_model.arel_table
29
+ join = dm.join(stu).on(stu[:user_id].eq(dm[:id])).join_sources
30
+ cond = stu[tenant_fk_column].eq(current_scim_tenant.id).and(stu[:active].eq(true))
31
+ devise_model.joins(join).where(cond)
32
+ else
33
+ devise_model.all
34
+ end
35
+ end
36
+
37
+ # "DeviseScim::ScimTenant" → "scim_tenant_id"; "Org" → "org_id"
38
+ def tenant_fk_column
39
+ "#{DeviseScim.configuration.tenant_model.demodulize.underscore}_id"
40
+ end
41
+
42
+ def scim_adapter_for(record, scim_object)
43
+ klass = (DeviseScim.configuration.adapter || "DeviseScim::ScimAdapter").constantize
44
+ klass.new(record, scim_object, tenant: current_scim_tenant)
45
+ end
46
+
47
+ def render_scim(obj, status: :ok)
48
+ render body: obj.to_json, status: status, content_type: "application/scim+json"
49
+ end
50
+
51
+ private
52
+
53
+ def set_content_type
54
+ response.headers["Content-Type"] = "application/scim+json"
55
+ end
56
+
57
+ def render_not_found(err)
58
+ render_scim(Scim::Error.not_found(err.message), status: :not_found)
59
+ end
60
+
61
+ def render_conflict(err)
62
+ render_scim(Scim::Error.conflict(err.message), status: :conflict)
63
+ end
64
+
65
+ def render_invalid_filter(err)
66
+ render_scim(Scim::Error.bad_request(err.message, scim_type: "invalidFilter"), status: :bad_request)
67
+ end
68
+ end
69
+ end