Browse Source
Phase 4 of the SCIM v2 implementation: - The two hardcoded 'useScim: false' sites in organization.rs now report CONFIG.scim_enabled(), so clients see the truthful capability flag. manageScim stays false: management is via the /api endpoints, not the web vault's enterprise UI. - docs/scim/README.md: operator setup, token generation/rotation, the full Entra ID enterprise-app walkthrough with attribute mappings, and the documented deviations (DELETE=revoke, no role sync, no post-create rename sync). - docs/scim/design.md: architecture and security model with four mermaid diagrams (provision/deprovision sequence, membership state machine including the -128/-127/-126 revocation offsets, the guard decision flow with uniform-401 sinks, and the module map), the sha256-vs-argon2 rationale, and the E2EE constraint analysis explaining why confirm cannot be server-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>pull/7443/head
3 changed files with 327 additions and 2 deletions
@ -0,0 +1,108 @@ |
|||||
|
# SCIM v2 provisioning |
||||
|
|
||||
|
This fork adds a SCIM v2 provisioning server (RFC 7643 / RFC 7644) so |
||||
|
organization membership can be driven from an identity provider. Microsoft |
||||
|
Entra ID is the tested IdP. Design details and diagrams: [design.md](design.md). |
||||
|
|
||||
|
## What it does, honestly |
||||
|
|
||||
|
- **Automated invite**: assigning a user in the IdP creates the Vaultwarden |
||||
|
account (if needed) and org membership, and sends the invite email. |
||||
|
- **Automated deprovision**: removing a user (or setting `active: false`) |
||||
|
revokes org access immediately. Restore is lossless. |
||||
|
- **Group sync**: group existence and membership, when `ORG_GROUPS_ENABLED=true`. |
||||
|
- **Not automated**: the final *Confirm* step. End-to-end encryption means the |
||||
|
org key must be wrapped for each member by an admin's client; no server can |
||||
|
do it. Provisioned users wait in *Invited* / *Accepted* until an admin |
||||
|
confirms them in the web vault. See design.md for why this is a property of |
||||
|
the encryption model, not a missing feature. |
||||
|
|
||||
|
## Server setup |
||||
|
|
||||
|
1. Enable SCIM in the server config (default is off): |
||||
|
|
||||
|
```ini |
||||
|
SCIM_ENABLED=true |
||||
|
# optional tuning |
||||
|
SCIM_RATELIMIT_SECONDS=1 |
||||
|
SCIM_RATELIMIT_MAX_BURST=60 |
||||
|
``` |
||||
|
|
||||
|
2. Generate the per-organization SCIM token (requires an org admin session; |
||||
|
re-authenticates with your master password like API key rotation): |
||||
|
|
||||
|
```bash |
||||
|
curl -s -X POST "$DOMAIN/api/organizations/$ORG_ID/scim/api-key" \ |
||||
|
-H "Authorization: Bearer $ADMIN_SESSION_TOKEN" \ |
||||
|
-H "Content-Type: application/json" \ |
||||
|
-d '{"masterPasswordHash": "..."}' |
||||
|
``` |
||||
|
|
||||
|
The response contains the token (`scim_v1.<org>.<secret>`) and the tenant |
||||
|
URL. **The token is shown exactly once**; only its sha256 digest is stored. |
||||
|
POST again to rotate (the old token stops working immediately), DELETE the |
||||
|
same path to disable SCIM for the org, GET `/scim/status` to inspect. |
||||
|
|
||||
|
3. TLS is required in front of the server (Entra refuses plain http). If a |
||||
|
reverse proxy sets client IPs, make sure it overwrites the `IP_HEADER` |
||||
|
header (default `X-Real-IP`); the SCIM rate limiter keys on that value. |
||||
|
|
||||
|
## Entra ID setup |
||||
|
|
||||
|
1. Entra admin center: **Enterprise applications > New application > Create |
||||
|
your own application** (non-gallery). |
||||
|
2. **Provisioning > Automatic**: |
||||
|
- Tenant URL: `https://<your-domain>/scim/v2/<org_id>` |
||||
|
- Secret Token: the `scim_v1...` value from step 2 above. |
||||
|
- Test Connection, then Save. |
||||
|
3. **Attribute mappings** (Users): |
||||
|
|
||||
|
| Entra attribute | SCIM attribute | Vaultwarden | |
||||
|
|---|---|---| |
||||
|
| userPrincipalName (or mail) | `userName` | account email (lowercased) | |
||||
|
| objectId | `externalId` | membership external id | |
||||
|
| Switch([IsSoftDeleted], , "False", "True", "True", "False") | `active` | revoke / restore | |
||||
|
| displayName | `displayName` | account name (set at creation only) | |
||||
|
|
||||
|
Map `userName` from **mail** rather than userPrincipalName if your UPNs |
||||
|
are not routable mailboxes: the value must be a deliverable email address, |
||||
|
because it becomes the Vaultwarden login and receives the invite. |
||||
|
|
||||
|
Recommended: delete the default mappings this server does not sync |
||||
|
(`roles`, `preferredLanguage`, `title`, addresses, phones). They are |
||||
|
accepted and ignored, but trimming them keeps the Entra sync log clean. |
||||
|
|
||||
|
4. Groups: map `displayName` and `objectId` to `externalId`. Members sync as |
||||
|
diffs. Assign users before (or together with) their groups; a group member |
||||
|
who is not yet provisioned as a user is rejected until the user sync runs. |
||||
|
5. Assign users/groups to the app and start provisioning. Entra's initial |
||||
|
cycle lists existing users first (`userName eq` filters), then creates. |
||||
|
|
||||
|
## Behaviour notes and deviations |
||||
|
|
||||
|
- **DELETE = revoke.** Both Entra soft delete (`active: false`) and hard |
||||
|
DELETE revoke the membership. The row and its keys survive, so restoring a |
||||
|
returning user needs no re-confirmation. No destructive operation is |
||||
|
exposed to the IdP at all. This is a deliberate deviation from RFC 7644. |
||||
|
- **Roles are not synced.** Everyone provisions as the User role; |
||||
|
promote people in the web vault. Vaultwarden's Custom role cannot round-trip |
||||
|
through SCIM. |
||||
|
- **userName / displayName changes are not synced after creation.** The email |
||||
|
is the login identity; the display name belongs to the person globally, not |
||||
|
to one org's directory. Renames in Entra succeed (accepted and ignored) |
||||
|
rather than erroring the sync. |
||||
|
- **SCIM user id** is the org membership id, not the account id. The same |
||||
|
person in two orgs has two SCIM ids: SCIM is org-scoped by construction. |
||||
|
- Every SCIM change is written to the org event log (admin-visible) with the |
||||
|
synthetic actor `vaultwarden-scim-...` when `ORG_EVENTS_ENABLED=true`. |
||||
|
|
||||
|
## Operational lifecycle |
||||
|
|
||||
|
1. Entra assigns user, SCIM creates membership at *Invited*, invite mail sent. |
||||
|
2. User clicks the invite, creates or logs into their account (*Accepted*). |
||||
|
3. An org admin confirms the member in the web vault (*Confirmed*). This is |
||||
|
the manual step; the admin's client wraps the org key for the member. |
||||
|
4. Entra unassigns or soft deletes, SCIM revokes immediately. Vault access |
||||
|
stops on the member's next sync. |
||||
|
5. Re-assignment restores the membership exactly as it was, including the |
||||
|
confirmed state, with no new invite or confirmation needed. |
||||
@ -0,0 +1,217 @@ |
|||||
|
# SCIM v2 design |
||||
|
|
||||
|
Architecture and security model for the SCIM implementation in this fork. |
||||
|
Operator instructions live in [README.md](README.md). |
||||
|
|
||||
|
## Provision and deprovision flows |
||||
|
|
||||
|
```mermaid |
||||
|
sequenceDiagram |
||||
|
autonumber |
||||
|
participant Entra as Entra ID |
||||
|
participant Guard as ScimToken guard |
||||
|
participant H as SCIM handlers |
||||
|
participant DB as Database |
||||
|
participant Mail as SMTP |
||||
|
|
||||
|
Note over Entra,Mail: Provision (assign user in Entra) |
||||
|
Entra->>Guard: GET /Users?filter=userName eq "a@x.com" (Bearer scim_v1...) |
||||
|
Guard->>DB: verify org key digest (ct_eq) |
||||
|
Guard-->>H: ScimToken{org} |
||||
|
H->>DB: find user + membership |
||||
|
H-->>Entra: 200 ListResponse (totalResults 0) |
||||
|
Entra->>Guard: POST /Users {userName, externalId, active} |
||||
|
Guard-->>H: ScimToken{org} |
||||
|
H->>DB: create shell User (if new), Membership status=Invited(0) |
||||
|
H->>Mail: send invite (rollback membership on failure) |
||||
|
H->>DB: log event (SCIM actor) |
||||
|
H-->>Entra: 201 Created {id = membership uuid} |
||||
|
|
||||
|
Note over Entra,Mail: Deprovision (unassign / soft delete) |
||||
|
Entra->>Guard: PATCH /Users/{id} {op replace, active false} |
||||
|
Guard-->>H: ScimToken{org} |
||||
|
H->>DB: last-confirmed-owner check |
||||
|
H->>DB: status -= 128 (revoke, akey kept) |
||||
|
H->>DB: log event (SCIM actor) |
||||
|
H-->>Entra: 200 {active: false} |
||||
|
``` |
||||
|
|
||||
|
## Membership state machine |
||||
|
|
||||
|
The revocation encoding is the one non-obvious invariant in the whole |
||||
|
integration: revoked is a stored **offset** (`status - 128`), and the enum |
||||
|
value `Revoked = -1` never reaches the database. Every active/inactive |
||||
|
decision in the SCIM code funnels through one helper that tests |
||||
|
`status <= -1`. |
||||
|
|
||||
|
```mermaid |
||||
|
stateDiagram-v2 |
||||
|
direction LR |
||||
|
[*] --> Invited0: SCIM POST /Users |
||||
|
Invited0: Invited (0) |
||||
|
Accepted1: Accepted (1) |
||||
|
Confirmed2: Confirmed (2) |
||||
|
RevokedI: Revoked Invited (-128) |
||||
|
RevokedA: Revoked Accepted (-127) |
||||
|
RevokedC: Revoked Confirmed (-126) |
||||
|
|
||||
|
Invited0 --> Accepted1: user accepts invite (own session) |
||||
|
Accepted1 --> Confirmed2: admin confirms (client wraps org key) |
||||
|
|
||||
|
Invited0 --> RevokedI: SCIM active false / DELETE |
||||
|
Accepted1 --> RevokedA: SCIM active false / DELETE |
||||
|
Confirmed2 --> RevokedC: SCIM active false / DELETE |
||||
|
RevokedI --> Invited0: SCIM active true (restore, +128) |
||||
|
RevokedA --> Accepted1: SCIM active true |
||||
|
RevokedC --> Confirmed2: SCIM active true (akey intact, no re-confirm) |
||||
|
|
||||
|
note right of Confirmed2 |
||||
|
Vault access exists only here. |
||||
|
The wrap happens in the admin's |
||||
|
client. No server-side path can |
||||
|
produce Membership.akey. |
||||
|
end note |
||||
|
``` |
||||
|
|
||||
|
Why confirm cannot be automated server-side, verified against source: |
||||
|
|
||||
|
- `Organization.private_key` is stored encrypted under the org symmetric key; |
||||
|
the server cannot decrypt it. |
||||
|
- `confirm_invite_impl` only stores an opaque client-computed blob into |
||||
|
`Membership.akey`. |
||||
|
- Account Recovery cannot substitute: enrollment is self-service and needs |
||||
|
the user's master password, and `recover_account` requires the member to |
||||
|
already be Confirmed. It is gated behind the state it would need to reach. |
||||
|
|
||||
|
A future companion *confirm-worker* (a headless client holding an admin |
||||
|
account, wrapping keys client-side and posting `bulk_confirm_invite`) could |
||||
|
automate step three without weakening zero-knowledge. It is deliberately not |
||||
|
part of the server. |
||||
|
|
||||
|
## Authentication |
||||
|
|
||||
|
The credential is a per-organization static bearer token, |
||||
|
`scim_v1.<org_uuid>.<secret>`, because Entra's provisioning client sends a |
||||
|
fixed Secret Token and cannot run an OAuth flow (which rules out reusing the |
||||
|
one-hour organization api-key JWT that `/api/public` uses). |
||||
|
|
||||
|
- The secret is 32 random bytes (256 bits). At rest it exists only as a |
||||
|
sha256 hex digest in the `scim_api_key` table. |
||||
|
- sha256 instead of argon2 is deliberate: argon2's cost is protection for |
||||
|
low-entropy human passwords. This secret is machine-generated at full |
||||
|
entropy, so digest inversion is infeasible, while per-request argon2 on an |
||||
|
unauthenticated endpoint would be a denial-of-service amplifier during |
||||
|
Entra's sync bursts. |
||||
|
- Verification uses a constant-time compare, with a dummy compare when no |
||||
|
key row exists. The dominant timing signal is still the database lookup; |
||||
|
the dummy compare is hygiene, not a timing-proof guarantee. |
||||
|
- An active `scim_api_key` row is also the per-org enable switch; the global |
||||
|
`SCIM_ENABLED` config is the master gate. Both must hold. |
||||
|
- Generation and rotation require an interactive org admin session plus |
||||
|
master-password or OTP re-authentication. Rotation replaces the row, so |
||||
|
the previous token dies instantly. The plaintext is returned exactly once |
||||
|
and never logged. |
||||
|
|
||||
|
```mermaid |
||||
|
flowchart TD |
||||
|
A[Request to /scim/v2/org_id/...] --> B{Rate limit by client IP} |
||||
|
B -- exceeded --> R429[429 SCIM error] |
||||
|
B --> C{SCIM_ENABLED} |
||||
|
C -- no --> R401 |
||||
|
C --> D{Bearer parses as scim_v1.org.secret} |
||||
|
D -- no --> R401 |
||||
|
D --> E{token org == path org} |
||||
|
E -- no --> R401 |
||||
|
E --> F{active scim_api_key row for org} |
||||
|
F -- "no (dummy ct_eq burned)" --> R401 |
||||
|
F --> G{ct_eq sha256 of secret vs stored digest} |
||||
|
G -- no --> R401 |
||||
|
G --> H[ScimToken org_uuid to handler] |
||||
|
H --> I[Handler scopes every query to token org] |
||||
|
|
||||
|
R401[Uniform 401 SCIM error body] |
||||
|
|
||||
|
style R401 fill:#7a1f1f,color:#fff |
||||
|
style R429 fill:#7a5a1f,color:#fff |
||||
|
style H fill:#1f5c2e,color:#fff |
||||
|
``` |
||||
|
|
||||
|
Every 401 leaving the mount is byte-identical regardless of which check |
||||
|
failed (asserted by test); causes are logged server-side only. Misses on |
||||
|
filters return empty lists and unknown ids return the same 404 as another |
||||
|
org's ids, so the surface does not confirm what exists. |
||||
|
|
||||
|
## Module layout |
||||
|
|
||||
|
```mermaid |
||||
|
flowchart LR |
||||
|
subgraph scim [src/api/scim] |
||||
|
guard[guard.rs\nScimToken] |
||||
|
errm[error.rs\nSCIM envelope + catchers] |
||||
|
filter[filter.rs\neq filter parser] |
||||
|
patchm[patch.rs\nPatchOp user/group] |
||||
|
modelsm[models.rs\nserde requests] |
||||
|
usersm[users.rs\n/Users handlers] |
||||
|
groupsm[groups.rs\n/Groups handlers] |
||||
|
disc[discovery.rs\nSPConfig/ResourceTypes/Schemas] |
||||
|
manage[manage.rs\ntoken mgmt under /api] |
||||
|
end |
||||
|
|
||||
|
subgraph core [existing Vaultwarden] |
||||
|
ratelimit[ratelimit.rs] |
||||
|
cryptom[crypto.rs ct_eq/sha256] |
||||
|
events[core/events.rs log_event] |
||||
|
models[db/models Membership/Group/ScimApiKey] |
||||
|
end |
||||
|
|
||||
|
guard --> ratelimit |
||||
|
guard --> cryptom |
||||
|
guard --> models |
||||
|
usersm --> patchm |
||||
|
usersm --> filter |
||||
|
usersm --> modelsm |
||||
|
groupsm --> patchm |
||||
|
groupsm --> filter |
||||
|
usersm --> events |
||||
|
groupsm --> events |
||||
|
usersm --> models |
||||
|
groupsm --> models |
||||
|
manage --> models |
||||
|
usersm --> errm |
||||
|
groupsm --> errm |
||||
|
disc --> guard |
||||
|
``` |
||||
|
|
||||
|
The `/scim` mount carries its own catchers so every error, including ones |
||||
|
Rocket generates before a handler runs, is a SCIM `Error` envelope. Token |
||||
|
management deliberately lives under `/api` with `AdminHeaders`: the SCIM |
||||
|
surface itself can never mint or rotate its own credential. |
||||
|
|
||||
|
## Semantics that differ from a naive SCIM reading |
||||
|
|
||||
|
| Topic | Choice | Reason | |
||||
|
|---|---|---| |
||||
|
| DELETE /Users | revoke, not delete | preserves `akey`; restore is lossless; compromised token cannot destroy state | |
||||
|
| Roles | not synced (always User) | `Custom` collapses to Manager in `MembershipType::from_str`; no honest round-trip | |
||||
|
| userName/displayName updates | accepted, ignored | email is login identity; `user.name` is global to the person; erroring would fail every directory rename sync | |
||||
|
| Group DELETE | real delete | groups carry no E2EE state | |
||||
|
| Group members | diff add/remove; replace only on PUT/replace | Entra PATCHes diffs, including `members[value eq "..."]` removal paths | |
||||
|
| Groups when disabled | loud 501 | `ldap_import` silently skips; silence hides misconfiguration in the IdP | |
||||
|
| Filter misses | empty 200 list | Entra Test Connection probes a random user; distinguishable misses enable enumeration | |
||||
|
|
||||
|
## Test strategy |
||||
|
|
||||
|
All tests are inline (bin-only crate). The integration harness runs the real |
||||
|
Rocket router with a temporary sqlite database. A `#[ctor]` constructor in |
||||
|
the `test-support` workspace crate rewrites the process environment before |
||||
|
`main`, because `CONFIG` is a process-global that reads `.env` at first |
||||
|
touch: without this, tests would inherit the developer's live configuration. |
||||
|
The main crate forbids `unsafe`, so the `set_var` calls live in |
||||
|
`test-support`. Integration tests serialize on a mutex and share one |
||||
|
never-dropped pool (sqlite WAL locking). |
||||
|
|
||||
|
Covered end to end: the full provision / deprovision / restore lifecycle at |
||||
|
every status offset (`-126`, `-127`, `-128`), duplicate and last-owner |
||||
|
conflicts, uniform-401 byte-equality, enumeration shape, Entra PATCH quirks |
||||
|
(op casing, string booleans, path-less values, filter-path member removal), |
||||
|
pagination edges, and the rate limiter. |
||||
Loading…
Reference in new issue