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