From f67e7641e350298981d236052543c5205b9dcda5 Mon Sep 17 00:00:00 2001 From: David Croft Date: Sun, 19 Jul 2026 01:42:40 +1000 Subject: [PATCH] Make useScim dynamic and add SCIM documentation 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 --- docs/scim/README.md | 108 +++++++++++++++++ docs/scim/design.md | 217 ++++++++++++++++++++++++++++++++++ src/db/models/organization.rs | 4 +- 3 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 docs/scim/README.md create mode 100644 docs/scim/design.md diff --git a/docs/scim/README.md b/docs/scim/README.md new file mode 100644 index 00000000..fcf471ff --- /dev/null +++ b/docs/scim/README.md @@ -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..`) 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:///scim/v2/` + - 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. diff --git a/docs/scim/design.md b/docs/scim/design.md new file mode 100644 index 00000000..b7993745 --- /dev/null +++ b/docs/scim/design.md @@ -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..`, 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. diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index d8dc8753..2a32c167 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -210,7 +210,7 @@ impl Organization { "useGroups": CONFIG.org_groups_enabled(), "useTotp": true, "usePolicies": true, - "useScim": false, // Not supported (Not AGPLv3 Licensed) + "useScim": CONFIG.scim_enabled(), // Implemented in this fork; see src/api/scim "useSso": false, // Not supported "useKeyConnector": false, // Not supported "usePasswordManager": true, @@ -480,7 +480,7 @@ impl Membership { "useEvents": CONFIG.org_events_enabled(), "useGroups": CONFIG.org_groups_enabled(), "useTotp": true, - "useScim": false, // Not supported (Not AGPLv3 Licensed) + "useScim": CONFIG.scim_enabled(), // Implemented in this fork; see src/api/scim "usePolicies": true, "useApi": true, "selfHost": true,