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
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`
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -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
|