8.7 KiB
SCIM v2 design
Architecture and security model for the SCIM implementation in this fork. Operator instructions live in README.md.
Provision and deprovision flows
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.
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_keyis stored encrypted under the org symmetric key; the server cannot decrypt it.confirm_invite_implonly stores an opaque client-computed blob intoMembership.akey.- Account Recovery cannot substitute: enrollment is self-service and needs
the user's master password, and
recover_accountrequires 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_keytable. - 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_keyrow is also the per-org enable switch; the globalSCIM_ENABLEDconfig 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.
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
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.