No description
Find a file
Espen M a142d41ba3
All checks were successful
Release / Release Please (push) Successful in 8s
Release / Build and Push Docker Image (push) Has been skipped
chore(licensing): add MIT license
2026-07-13 01:13:50 +02:00
.forgejo/workflows fix: pin ci version to fixed version 2026-07-11 20:52:14 +02:00
migrations chore: initial creation of provisioner 2026-07-11 19:01:09 +02:00
src feat: add periodic sync and reconciliation 2026-07-12 23:04:58 +02:00
.env.example docs: add README 2026-07-13 01:02:43 +02:00
.gitignore chore: initial creation of provisioner 2026-07-11 19:01:09 +02:00
.release-please-manifest.json chore(master): release 0.11.0 (#18) 2026-07-13 00:23:08 +02:00
Cargo.lock chore(master): release 0.11.0 (#18) 2026-07-13 00:23:08 +02:00
Cargo.toml chore(master): release 0.11.0 (#18) 2026-07-13 00:23:08 +02:00
CHANGELOG.md chore(master): release 0.11.0 (#18) 2026-07-13 00:23:08 +02:00
Dockerfile fix: correct issue causing indefinite restart loop 2026-07-13 00:12:58 +02:00
LICENSE chore(licensing): add MIT license 2026-07-13 01:13:50 +02:00
README.md chore(licensing): add MIT license 2026-07-13 01:13:50 +02:00
release-please-config.json fix: add initial version 2026-07-11 20:24:40 +02:00

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-webhook SPI.

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

  1. Keycloak emits an event via the Phase Two ext-event-webhook listener:
    • admin.USER-CREATE or access.REGISTERprovision
    • admin.USER-DELETEdeprovision
  2. The provisioner verifies the HMAC X-Keycloak-Signature header (if a shared secret is configured) and parses the event.
  3. It upserts a row in SQLite and creates/destroys the Stalwart account via a JMAP x:Account/set call, deriving the mail address as username@<STALWART_DOMAIN>. The Keycloak-provided email is never used (the user's recovery email is a separate concern).
  4. State is tracked as pending → completed (or failed after MAX_RETRY_ATTEMPTS), and deleted on 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-webhook SPI installed, the ext-event-webhook event listener enabled, a mime-unified realm, and a provisioner-ci service 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:

  1. Install the ext-event-webhook SPI (io.phasetwo.keycloak:keycloak-events) into Keycloak's providers directory and rebuild.
  2. Enable the ext-event-webhook event listener and admin events for the mime-unified realm (PUT /admin/realms/mime-unified/events/config with eventsListeners including ext-event-webhook).
  3. Create a confidential, service-accounts-only client provisioner-ci and grant it the view-users role from the realm-management client.
  4. Register a webhook pointing at the provisioner: POST /realms/mime-unified/webhooks with url = http://provisioner:8080/api/webhook/keycloak, your secret (= WEBHOOK_SHARED_SECRET), and eventTypes = ["*"].

Stalwart setup

  • Ensure the mail domain (e.g. mime.org) exists in Stalwart. The provisioner resolves its domainId automatically via x:Domain/query / x:Domain/get.
  • Provide admin credentials that can call x:Account/set (create and destroy).
  • Accounts are created as Stalwart User accounts with the configured disk quota; encryptionAtRest is 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 tracing output to stdout; set RUST_LOG=debug for 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 a primaryKeyViolation on create is resolved by looking up the existing account id.

Security considerations

  • Signed webhooks: always set WEBHOOK_SHARED_SECRET so the provisioner verifies X-Keycloak-Signature.
  • Least privilege: the provisioner-ci service account needs only view-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.