# rustauth-scim
Server-side SCIM 2.0 provisioning plugin for RustAuth.
## What It Is
`rustauth-scim` lets external identity providers provision users and groups
into RustAuth through SCIM 2.0. It is server-side only and intentionally omits
browser SDKs, dashboard UI, and Better Auth Infrastructure self-service
features.
## What It Provides
- Provider connection management and bearer-token authentication.
- SCIM Users, Groups, Bulk, search, metadata, schema, and resource type routes.
- Filtering, sorting, pagination, projections, weak ETags, and SCIM error
responses.
- Organization-scoped group provisioning backed by RustAuth organization teams.
- Token storage modes: hashed (default), plain, encrypted, and custom transforms.
- Schema contributions for SCIM providers, user profiles, and group profiles.
## Quick Start
Enable the `scim` feature on the umbrella `rustauth` crate (or depend on
`rustauth-scim` directly):
```toml
[dependencies]
rustauth = { version = "0.2.0", features = ["scim"] }
```
```rust
use rustauth::RustAuth;
use rustauth::scim::{scim, ScimOptions, ScimTokenStorage};
let auth = RustAuth::builder()
.secret("secret-a-at-least-32-chars-long!!")
.base_url("https://app.example.com/api/auth")
.plugin(
scim(
ScimOptions::default()
.token_storage(ScimTokenStorage::Hashed),
),
)
.build()?;
# let _ = auth;
# Ok::<(), Box<dyn std::error::Error>>(())
```
Run your adapter migration flow after adding the plugin. SCIM clients call
routes under your auth base URL:
```text
https://app.example.com/api/auth/scim/v2
```
Your HTTP integration must allow `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`.
## Provider connections (`providerId`)
RustAuth follows the same storage model as Better Auth: **`providerId` is
globally unique**. One id equals one SCIM connection (one bearer token row), not
“one organization.” Organization scope is optional metadata on that connection:
- Omit `organizationId` to provision users linked to the provider account only.
- Set `organizationId` so list/create/update/delete only touch members of that org.
If you need separate tokens for the same vendor (different orgs, environments, or
apps), assign **different provider ids** (`okta-prod`, `okta-eu`, `entra-hr`).
Regenerating a token for the same `providerId` replaces the stored secret on the
existing row (upsert) instead of deleting the connection first.
We keep global uniqueness rather than a composite `(providerId, organizationId)`
key because upstream does the same: it is a deliberate “one integration id”
design, not a missing composite index. A composite key would be an RustAuth-only
extension and would complicate management APIs that key off `providerId` alone.
## Provider Tokens
Create or rotate a provider token with an authenticated RustAuth session:
```text
POST /scim/generate-token
```
Example body:
```json
{
"providerId": "okta",
"organizationId": "org_123"
}
```
`organizationId` is optional. When present, the organization plugin must be
installed and the session user must have an allowed organization role.
When omitted, set `provider_ownership.enabled` to `true` in `ScimOptions` so only
the owning session user can create, list, rotate, or delete that global
connection.
## Route Summary
Management routes use regular RustAuth JSON errors:
- `POST /scim/generate-token`
- `GET /scim/list-provider-connections`
- `GET /scim/get-provider-connection?providerId=...`
- `POST /scim/delete-provider-connection`
SCIM protocol routes use RFC 7644-compatible SCIM errors and
`application/scim+json` responses:
- `/scim/v2/Users`
- `/scim/v2/Users/:userId`
- `/scim/v2/Users/.search`
- `/scim/v2/Groups`
- `/scim/v2/Groups/:groupId`
- `/scim/v2/Groups/.search`
- `/scim/v2/.search`
- `/scim/v2/Bulk`
- `/scim/v2/ServiceProviderConfig`
- `/scim/v2/Schemas`
- `/scim/v2/ResourceTypes`
`GET /scim/v2/Me` returns SCIM `501`; provider-scoped tokens are not end-user
aliases.
## Identity validation
User provisioning resolves a canonical email from `userName` and optional
`emails`. Both must produce a valid email address before create, replace, bulk,
or patch mutations persist changes. Empty `userName` values are rejected.
## Filters and metadata
| `GET /Users?filter=userName eq "a@b.com"` | Pushed to SQL on `users.email` (Better Auth parity). |
| `GET /Users?filter=...` (anything else) | Parsed with the RFC-style parser, evaluated in memory on each provider-scoped User resource (includes extension profile fields). |
| `POST /Users/.search`, `POST /Groups/.search` | Same parser; Groups always filter in memory. |
| Invalid syntax | `400` with `scimType: invalidFilter`. |
Use `rustauth_scim::filters::list_user_filter_uses_database_pushdown` in integrator
code to detect the SQL-backed form.
Better Auth upstream only implements `userName eq` for user list. RustAuth keeps
that path for compatibility and adds in-memory filtering so enterprise
attributes (`urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department`,
`title`, etc.) and operators like `co` work without a second query language.
Large directories should prefer `userName eq` or pagination (`startIndex` /
`count`, capped by `ServiceProviderConfig.filter.maxResults`).
`ServiceProviderConfig` advertises bulk, sort, weak etag, and extended filter
support that this crate implements server-side.
## Bulk operations
Better Auth **1.6.9** does not implement SCIM Bulk (`bulk.supported: false` in
upstream metadata). RustAuth implements `POST /scim/v2/Bulk` with two modes:
| Independent (default) | `bulk_mode: Independent` | RFC-style sequential ops; each mutation runs in its own DB transaction; `failOnErrors` stops the batch. |
| Atomic | `bulk_mode: Atomic` | All mutating ops share one adapter transaction; the first error rolls back earlier ops in the same request (prior successes are reported as `412` in the bulk response). |
`Atomic` requires a database adapter that advertises native transactions
(`AdapterCapabilities::supports_transactions`). The in-memory test adapter does
not qualify; use SQLite/Postgres/MySQL adapters in production.
Bulk operations do not evaluate per-operation `If-Match` headers (unlike direct
`PUT`/`PATCH`/`DELETE` routes).
## Deprovision mode
`ScimDeprovisionMode::UnlinkAccount` (default) removes only the current provider
account and SCIM profile (and org membership when the provider is org-scoped).
The user row remains while other linked accounts exist (password credentials,
other IdPs, or additional SCIM providers).
`ScimDeprovisionMode::DeleteUser` removes the RustAuth user when they have no
other linked accounts besides the current SCIM provider; otherwise it unlinks the
current provider only, so email-linked identities are not destroyed by one IdP.
## Token storage
`ScimOptions::default()` stores generated SCIM base tokens as SHA-256 hashes.
Use `ScimTokenStorage::Plain` only for local development or when you manage
storage security yourself. Provider token rotation updates the existing
`scim_providers` row instead of deleting and recreating it.
### Migrating from Better Auth plain tokens
If you previously stored SCIM tokens in plain text (Better Auth default or
RustAuth `ScimTokenStorage::Plain`), switching to hashed default storage
invalidates existing bearer secrets. Regenerate every provider connection via
`POST /scim/generate-token` (or re-seed `default_scim` with new secrets) after
upgrade. There is no in-place migration of raw tokens to hashes.
## Audit hooks
Optional `audit_event: ScimAuditEventResolver` mirrors the SSO plugin pattern:
structured log lines plus an async callback for token generation, user
provision/deprovision, bulk failures, and atomic bulk rollbacks.
## Storage Notes
The plugin contributes:
- `scim_providers`
- `scim_user_profiles`
- `scim_group_profiles`
The in-memory adapter works for tests and local runtime usage, but durable SCIM
deployments should use a database adapter. Redis and Valkey crates in this
workspace are rate-limit/secondary-storage integrations, not SCIM identity
stores.
### MongoDB (future)
RustAuth does **not** ship a MongoDB `DbAdapter` today. SCIM is tested against
SQLite, PostgreSQL, and MySQL via `rustauth-sqlx` / `rustauth-tokio-postgres` /
`rustauth-deadpool-postgres` (see `tests/scim/db_adapters.rs`). The root
[`docker-compose.yml`](../../docker-compose.yml) includes a `mongodb` service for
local infra experiments only. Upstream Better Auth has
[`@better-auth/mongo-adapter`](https://www.npmjs.com/package/@better-auth/mongo-adapter)
as a separate npm package; a future RustAuth Mongo adapter would live outside
`rustauth-scim` and would need its own SCIM contract tests before this crate
claims support. Telemetry may label connections as `mongodb` when detected, but
that is not storage for SCIM tables.
## Status
Experimental beta. The server provisioning surface is implemented and covered,
but API details, schema shape, and parity choices may change before stable
release.
## Better Auth compatibility
Server-side SCIM provisioning is aligned with Better Auth 1.6.9 where it
matters; RustAuth is not a line-by-line port.
For route-level parity, test counts, differences, and gaps, see
[UPSTREAM.md](./UPSTREAM.md).
## Links
- [Root README](../../README.md)
- [Repository](https://github.com/salasebas/rustauth)