| .forgejo/workflows | ||
| migrations | ||
| src | ||
| .env.example | ||
| .gitignore | ||
| .release-please-manifest.json | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CHANGELOG.md | ||
| Dockerfile | ||
| LICENSE | ||
| README.md | ||
| release-please-config.json | ||
Stalwart Provisioner
Automatically provisions and deprovisions Stalwart mail
accounts from Keycloak user-lifecycle events. When a user is created or
registers in Keycloak's mime-unified realm, a mail account
username@mime.org is created in Stalwart; when the user is deleted, the
account is destroyed. The provisioner is resilient to transient failures via
SQLite-backed retry and a periodic reconciliation sync.
- Registration-time provisioning — accounts are created at registration, not lazily on first login.
- Deprovisioning — accounts are destroyed when the Keycloak user is deleted.
- Resilient — failed provisions are retried with backoff; a reconciliation sync catches events that were missed.
- Self-contained — no dependency on the auth provider beyond the OIDC
protocol and the Phase Two
ext-event-webhookSPI.
Architecture
flowchart LR
KC[Keycloak
mime-unified realm] -->|ext-event-webhook SPI
HMAC-signed webhook| PROV[Stalwart Provisioner]
PROV -->|JMAP x:Account/set
create / destroy| SW[Stalwart
mail server]
PROV -.->|SQLite state
pending / completed / deleted| DB[(provisioning.db)]
PROV -.->|reconciliation sync
list users| KC
The provisioner is a small Rust service (Axum + reqwest). It receives signed
webhooks from Keycloak, records the intent in SQLite, and drives Stalwart over
its JMAP API. A background task retries failed provisions, and a separate
background task periodically reconciles Keycloak's user list against the local
state.
How it works
- Keycloak emits an event via the Phase Two
ext-event-webhooklistener:admin.USER-CREATEoraccess.REGISTER→ provisionadmin.USER-DELETE→ deprovision
- The provisioner verifies the HMAC
X-Keycloak-Signatureheader (if a shared secret is configured) and parses the event. - It upserts a row in SQLite and creates/destroys the Stalwart account via a
JMAP
x:Account/setcall, deriving the mail address asusername@<STALWART_DOMAIN>. The Keycloak-provided email is never used (the user's recovery email is a separate concern). - State is tracked as
pending → completed(orfailedafterMAX_RETRY_ATTEMPTS), anddeletedon deprovisioning.
The reconciliation sync lists all Keycloak users and:
- provisions any Keycloak user that has no completed local record, and
- deprovisions any local record whose Keycloak user no longer exists.
If the Keycloak user list cannot be fetched, the entire sync aborts so that a transient Keycloak outage can never cause false-positive deprovisioning.
Requirements
- Stalwart v0.16+ with the JMAP API enabled and an admin credential.
- Keycloak with the Phase Two
ext-event-webhookSPI installed, theext-event-webhookevent listener enabled, amime-unifiedrealm, and aprovisioner-ciservice account (see Keycloak setup). - Network reachability between the provisioner and both services.
Configuration
The provisioner is configured entirely through environment variables.
| Variable | Required | Default | Description |
|---|---|---|---|
STALWART_ENDPOINT |
yes | — | Stalwart JMAP base URL, e.g. http://mail:8080. |
STALWART_DOMAIN |
yes | — | Mail domain used to build addresses, e.g. mime.org. |
STALWART_AUTH_METHOD |
no | basic |
basic or bearer. |
STALWART_ADMIN_PASSWORD |
yes* | — | Admin password for basic auth. |
STALWART_API_TOKEN |
yes* | — | Bearer token for bearer auth. |
WEBHOOK_SHARED_SECRET |
no** | — | HMAC secret for verifying webhook signatures. |
KEYCLOAK_ENDPOINT |
no | — | Keycloak base URL, e.g. http://keycloak:8080. Enables sync. |
KEYCLOAK_REALM |
no | master |
Realm to reconcile against. |
KEYCLOAK_CLIENT_ID |
no | — | Service-account client id (e.g. provisioner-ci). |
KEYCLOAK_CLIENT_SECRET |
no | — | Service-account client secret. |
DATABASE_PATH |
no | /data/provisioning.db |
Path to the SQLite database file. |
DEFAULT_QUOTA_BYTES |
no | 5368709120 |
Per-account disk quota (5 GiB). |
MAX_RETRY_ATTEMPTS |
no | 5 |
Retries before a provision is marked failed. |
SYNC_INTERVAL_SECONDS |
no | 3600 |
Reconciliation interval. Disabled if Keycloak is unset. |
LISTEN_ADDR |
no | 0.0.0.0:8080 |
HTTP listen address. |
* Required for the chosen STALWART_AUTH_METHOD.
** Strongly recommended. Without it, webhook signatures are not verified.
RUST_LOG (or the RUST_LOG/TRACE env filter) controls log verbosity; the
service uses tracing and logs to stdout.
Keycloak setup
The provisioner relies on the Phase Two ext-event-webhook SPI and a
least-privilege service account. The full, hardened setup (token handling,
minimal role grants, SPI verification, idempotent ordering) is documented in the
infra repo: keycloak/KEYCLOAK_HARDENING.md.
Minimal steps:
- Install the
ext-event-webhookSPI (io.phasetwo.keycloak:keycloak-events) into Keycloak'sprovidersdirectory and rebuild. - Enable the
ext-event-webhookevent listener and admin events for themime-unifiedrealm (PUT /admin/realms/mime-unified/events/configwitheventsListenersincludingext-event-webhook). - Create a confidential, service-accounts-only client
provisioner-ciand grant it theview-usersrole from therealm-managementclient. - Register a webhook pointing at the provisioner:
POST /realms/mime-unified/webhookswithurl = http://provisioner:8080/api/webhook/keycloak, yoursecret(=WEBHOOK_SHARED_SECRET), andeventTypes = ["*"].
Stalwart setup
- Ensure the mail domain (e.g.
mime.org) exists in Stalwart. The provisioner resolves itsdomainIdautomatically viax:Domain/query/x:Domain/get. - Provide admin credentials that can call
x:Account/set(create and destroy). - Accounts are created as Stalwart
Useraccounts with the configured disk quota;encryptionAtRestis disabled by default.
Deployment
The image is built and pushed to the Forgejo registry by the release pipeline
(release.yml) on a Release Please tag, as both a semver tag and latest:
git.mime.org/mime/stalwart-provisioner:latest
A self-contained deployment (assumes the provisioner shares a Docker network
named stalwart_stalwart with Stalwart and Keycloak):
services:
provisioner:
image: git.mime.org/mime/stalwart-provisioner:latest
restart: unless-stopped
hostname: provisioner
ports:
- "127.0.0.1:8083:8080"
environment:
STALWART_ENDPOINT: http://mail:8080
STALWART_ADMIN_PASSWORD: ${STALWART_ADMIN_PASSWORD}
STALWART_DOMAIN: mime.org
WEBHOOK_SHARED_SECRET: ${WEBHOOK_SHARED_SECRET}
DATABASE_PATH: /data/provisioning.db
DEFAULT_QUOTA_BYTES: ${DEFAULT_QUOTA_BYTES:-5368709120}
MAX_RETRY_ATTEMPTS: 5
KEYCLOAK_ENDPOINT: http://keycloak:8080
KEYCLOAK_REALM: mime-unified
KEYCLOAK_CLIENT_ID: provisioner-ci
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
SYNC_INTERVAL_SECONDS: 3600
volumes:
- provisioner_data:/data
networks:
- stalwart_stalwart
volumes:
provisioner_data:
networks:
stalwart_stalwart:
external: true
The canonical deployment for mime.org lives in the
infra repository
(provisioner/docker-compose.yml) and is applied by the deploy-provisioner
workflow.
Health
curl -fsS http://127.0.0.1:8083/api/health
# => {"status":"ok"}
HTTP API
| Method | Path | Description |
|---|---|---|
GET |
/api/health |
Liveness check. |
POST |
/api/webhook/keycloak |
Receives signed Keycloak events. |
POST |
/api/retry |
Retries pending provisions immediately. |
POST |
/api/sync |
Runs a reconciliation sync immediately. |
Webhook
Keycloak signs the raw request body with HMAC-SHA256 using the shared secret and
sends it in the X-Keycloak-Signature: sha256=<hex> header. If
WEBHOOK_SHARED_SECRET is set, the provisioner rejects requests with an invalid
or missing signature (401).
curl -X POST http://127.0.0.1:8083/api/webhook/keycloak \
-H "X-Keycloak-Signature: sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SHARED_SECRET" | awk '{print $2}')" \
-H "Content-Type: application/json" \
-d "$BODY"
Manual triggers
curl -X POST http://127.0.0.1:8083/api/retry # retry pending provisions
curl -X POST http://127.0.0.1:8083/api/sync # run reconciliation now
Data model
State is stored in a single SQLite table (migrations/001_init.sql):
| Column | Type | Notes |
|---|---|---|
id |
TEXT (PK) | Keycloak user id. |
username |
TEXT | Keycloak username. |
email |
TEXT | Derived username@<STALWART_DOMAIN>. |
status |
TEXT | pending, completed, failed, or deleted. |
stalwart_account_id |
TEXT | Stalwart account id once created. |
attempts |
INTEGER | Retry counter. |
last_error |
TEXT | Last failure detail. |
created_at / updated_at |
TEXT | UTC timestamps. |
Operations
- Logs: structured
tracingoutput to stdout; setRUST_LOG=debugfor verbose event handling. - Retry: failed provisions are retried by a background task (every 5 min)
and on startup, up to
MAX_RETRY_ATTEMPTS. - Sync: runs immediately on startup and then every
SYNC_INTERVAL_SECONDS; aborts if Keycloak is unreachable to avoid false deprovisioning. - Idempotency: duplicate events are safe — an already-existing Stalwart
account is treated as
completed, and aprimaryKeyViolationon create is resolved by looking up the existing account id.
Security considerations
- Signed webhooks: always set
WEBHOOK_SHARED_SECRETso the provisioner verifiesX-Keycloak-Signature. - Least privilege: the
provisioner-ciservice account needs onlyview-users(for reconciliation); privileged setup is done with an admin token at deploy time, not by the running service. - Address derivation: mail addresses are built from the Keycloak username, never the user-supplied email, so a user cannot influence their mail address.
Development
cargo build --release
cp .env.example .env # fill in STALWART_*/WEBHOOK_*/KEYCLOAK_* values
cargo run
cargo test
The Dockerfile uses a fn main() {} pre-build step to warm the dependency
cache layer (the real binary is rebuilt in the final stage); this is intentional
and not a stub.
License
See LICENSE.