Browse Source

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 <noreply@anthropic.com>
pull/7443/head
David Croft 4 days ago
parent
commit
f67e7641e3
  1. 108
      docs/scim/README.md
  2. 217
      docs/scim/design.md
  3. 4
      src/db/models/organization.rs

108
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.<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.

217
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.<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.

4
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,

Loading…
Cancel
Save