feat(bp-stalwart-tenant): wire Keycloak OIDC SSO end-to-end (#915) (#920)

Closes the C2 sub-task of EPIC #915 — alice's Stalwart authenticates
SMTP/IMAP/JMAP/webmail logins against her per-tenant Keycloak realm,
not a shared otech-level IdP.

Three layered changes (matching the three things broken on otech103):

1. Orchestrator (`smeTenantBPStalwart` in sme_tenant_gitops.go)
   now emits per-tenant OIDC values matching the bp-wordpress-tenant
   + bp-openclaw shape:
     keycloak.realmURL = https://keycloak.<sub>.<parent>/realms/sme-<sub>
     keycloak.clientID = stalwart
     keycloak.clientSecretName = stalwart-oidc-client-secret
     keycloak.oidcExternalSecret.remoteRef.key
       = sovereign/<otech-fqdn>/stalwart/<tenant>/oidc
   plus admin externalSecret + dependsOn bp-keycloak so the SME's
   three apps (wordpress, openclaw, stalwart) SSO against ONE realm
   with distinct client IDs (#915 C1 registers all three in the realm
   bootstrap).

2. Chart bootstrap config.toml drops the pre-0.16 kebab-case
   `[directory.keycloak] type = "oidc"` block (silently ignored by
   the upstream registry parser — verified against
   crates/registry/src/schema/structs.rs in stalwartlabs/stalwart;
   OidcDirectory serdes camelCase: `@type = "Oidc"`, `issuerUrl`,
   `claimUsername`, `claimName`, `claimGroups`, `requireScopes`).
   The `internal` directory stays as the bootstrap fallback so the
   admin can log in before the post-install Job seeds OIDC.

3. setupJob defaults to enabled (was off in 0.1.1) and POSTs the
   canonical OIDC directory entry to `/api/settings`:
     directory.keycloak.@type            = "Oidc"
     directory.keycloak.issuerUrl        = <realm URL>
     directory.keycloak.claimUsername    = preferred_username
     directory.keycloak.claimName        = name
     directory.keycloak.claimGroups      = groups
     directory.keycloak.requireScopes    = [openid email profile groups]
     directory.keycloak.usernameDomain   = <tenant domain>
     storage.directory                   = keycloak
   The setting POSTs are idempotent (`assert_empty: false`) so Helm
   upgrades re-run without breaking existing logins. Re-uses the
   upstream Stalwart container (ships curl + stalwart-cli) — no new
   image needed.

Tests:
  - `chart/tests/oidc-render.sh` (NEW): asserts every settings key
    is rendered, the [oauth] env block propagates the per-tenant
    realm URL, and the bootstrap config.toml parses as valid TOML.
  - `chart/tests/expression-syntax.sh`: re-passes (Stalwart
    expression `==` audit per stalwart_expression_syntax.md).
  - `TestRenderSMETenantOverlay_StalwartEmitsKeycloakOIDC` (NEW):
    Go test verifies the orchestrator emits the per-tenant realm
    URL, client metadata, and ExternalSecret-store remoteRef paths.
  - All existing TestRenderSMETenantOverlay_* tests pass.
  - `helm template` clean with default values AND with a per-tenant
    overlay (--api-versions external-secrets.io/v1beta1).

Chart bumps 0.1.1 → 0.1.2; blueprint.yaml spec.version mirrors per
issue #817 (chart/blueprint version invariant).

Co-authored-by: hatiyildiz <hatice@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-05 13:37:46 +04:00 committed by GitHub
parent 9447d88dfd
commit a1ca1872aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 478 additions and 87 deletions

View File

@ -6,20 +6,16 @@ metadata:
catalyst.openova.io/category: communication
catalyst.openova.io/section: pts-4-5-communication
spec:
# 0.1.1 (#898) — three fixes from the otech103 fresh-tenant sweep:
# 1. `tenantDomain` helper accepts both `domain.primary` (canonical)
# and the legacy string-shape `domain: <fqdn>` emitted by
# pre-#897 catalyst-api builds, plus a forward-looking
# `tenant.domain` field (#898 brief).
# 2. auto-provisioned `stalwart-admin` Secret with random
# ADMIN_PASSWORD via lookup-persistence (no more
# CreateContainerConfigError on fresh tenants).
# 3. pod sec ctx aligned to the upstream image's UID 2000 +
# drop ALL caps then add NET_BIND_SERVICE so the binary's
# `cap_net_bind_service=ep` file-cap can elevate (was failing
# with `exec: operation not permitted` on every fresh tenant).
# 0.1.2 (#915) — Keycloak OIDC end-to-end. Orchestrator now emits
# `keycloak.realmURL` + `clientID` + `clientSecretName` for the
# per-tenant Keycloak realm; chart's bootstrap config.toml + the
# post-install Job seed the OIDC directory in Stalwart's settings
# store with the canonical camelCase keys
# (`directory.keycloak.@type = "Oidc"`, `issuerUrl`, `claimUsername`,
# `claimName`, `claimGroups`). setupJob defaults to enabled so a
# fresh tenant has working OIDC at t=0.
# Per #817 Chart.yaml version MUST equal blueprint.yaml spec.version.
version: 0.1.1
version: 0.1.2
card:
title: Stalwart (per-tenant)
summary: |

View File

@ -1,30 +1,57 @@
apiVersion: v2
name: bp-stalwart-tenant
# 0.1.1 (#898) — three fixes that left the 0.1.0 chart unbootable on
# fresh otech provisions:
# 1. accept legacy string-shape `domain: <fqdn>` values produced by
# pre-#897 catalyst-api builds (the live HR shape on otech103
# was `values.domain: acme.omani.works` which broke
# `.Values.domain.primary` indexing across config-configmap,
# deployment, dns-records-configmap, mailbox-provision-job, and
# the webmailHost helper). Resolved via a single `tenantDomain`
# helper in _helpers.tpl that handles both shapes (string OR
# `{primary,mode}` map).
# 2. auto-provision the `stalwart-admin` Secret with a
# randomly-generated ADMIN_PASSWORD via lookup-persistence
# (mirrors marketplace-api/secret.yaml in catalyst chart). The
# 0.1.0 chart referenced the Secret in deployment.yaml but never
# created it — every fresh tenant lands in
# CreateContainerConfigError until the operator hand-rolls one.
# 3. make the pod sec context compatible with the upstream
# stalwart binary's file capabilities (`getcap` on
# docker.io/stalwartlabs/stalwart:v0.16.3 reports
# `cap_net_bind_service=ep`). The 0.1.0 sec ctx ran as UID 65534
# (image-default is 2000) AND dropped ALL caps — kernel refuses
# to elevate file caps with empty bounding set, so exec failed
# with `operation not permitted`. Now: runAsUser 2000, drop ALL
# then add NET_BIND_SERVICE explicitly.
version: 0.1.1
# 0.1.2 (#915) — wire Keycloak OIDC SSO end-to-end so the per-tenant
# Stalwart authenticates webmail / IMAP / SMTP users against the SME's
# vcluster-local Keycloak realm (epic #915 DoD #2 — alice's mailbox
# logs in via her tenant Keycloak, NOT a shared otech-level IdP).
#
# What 0.1.1 had: the chart shipped values for `keycloak.realmURL`,
# `keycloak.clientID`, `keycloak.clientSecretName` AND a placeholder
# `[directory.keycloak]` block in the bootstrap config.toml. Three
# things broke the actual SSO contract on a fresh tenant:
# a. the orchestrator's `smeTenantBPStalwart` template (catalyst-api/
# sme_tenant_gitops.go) emitted ONLY `domain.primary` + `ingress`
# values — never a `keycloak.*` block. Fresh tenants ran with the
# chart's empty default `keycloak.realmURL: ""` so no OIDC
# handshake was possible. Wordpress/openclaw both passed the
# realmURL — Stalwart was left out.
# b. the chart's TOML used the pre-0.16 Stalwart key layout
# (`type = "oidc"`, `url = ...`, `client-id = ...`, kebab-case).
# Stalwart 0.16+ moved directory definitions to the settings KV
# store; the OIDC schema serdes camelCase
# (`@type = "Oidc"`, `issuerUrl`, `claimUsername`, `claimName`,
# `claimGroups`, `requireScopes`) — verified against the upstream
# registry schema (crates/registry/src/schema/structs.rs). The
# kebab-case TOML block was silently ignored.
# c. the setupJob that registers the OIDC directory via the
# `/api/settings` admin endpoint was disabled by default
# (operator-opt-in) — so even when the values landed correctly
# the actual Stalwart instance still had no OIDC directory at
# runtime.
#
# 0.1.2 fixes:
# 1. orchestrator emits per-tenant `keycloak.realmURL` =
# `https://keycloak.<sub>.<parent>/realms/sme-<sub>` + `clientID`
# = `stalwart` + `clientSecretName` = `stalwart-oidc-client-secret`
# + `oidcExternalSecret.remoteRef.key` =
# `sovereign/<otech-fqdn>/stalwart/<tenant>/oidc`. Mirrors the
# bp-wordpress-tenant + bp-openclaw shape so all three apps SSO
# against the SAME tenant realm with their distinct client IDs
# (#915 C1 registers all three clients in the realm).
# 2. config.toml emits `[oauth]` block + setup Job seeds the
# directory entry into Stalwart's settings KV store via the
# `/api/settings` admin endpoint with the canonical camelCase
# keys (`directory.keycloak.@type = "Oidc"`, `issuerUrl`,
# `claimUsername`, `claimName`, `claimGroups`, `requireScopes`).
# The settings POST is idempotent (assert_empty=false) — re-runs
# on Helm upgrade without breaking existing logins.
# 3. setupJob defaults to enabled (was off in 0.1.1 because the
# canonical stalwart-cli image was unpublished). The Job re-uses
# the upstream Stalwart container which already ships
# `stalwart-cli` + `curl` — no new image needed.
#
# Per #817 Chart.yaml version MUST equal blueprint.yaml spec.version.
version: 0.1.2
appVersion: "0.16.3"
description: |
Catalyst Blueprint scratch chart for a per-SME (per-vcluster) dedicated

View File

@ -74,23 +74,38 @@ data:
type = "internal"
store = "rocksdb"
# ─── OIDC against SME-vcluster Keycloak ─────────────────────────────
# Stalwart's webmail authenticates users via OpenID Connect against
# the SME's per-vcluster Keycloak realm. The clientID is registered
# at vcluster provisioning time (#804); the client secret is mounted
# from the OIDC secret at runtime.
[directory.keycloak]
type = "oidc"
url = {{ .Values.keycloak.realmURL | quote }}
client-id = {{ .Values.keycloak.clientID | quote }}
client-secret = "%{env:OIDC_CLIENT_SECRET}%"
scopes = [{{- range $i, $s := .Values.keycloak.scopes }}{{ if $i }}, {{ end }}{{ $s | quote }}{{- end }}]
# Map Keycloak claims to Stalwart principal fields. `email_verified`
# is required to short-circuit the Stalwart account-creation flow on
# first login (otherwise Stalwart would prompt for password setup).
fields.email = "email"
fields.name = "name"
fields.username = "preferred_username"
# ─── OIDC SSO against per-tenant Keycloak (#915) ───────────────────
# Stalwart 0.16+ stores OIDC directory definitions in its runtime
# settings KV store (RocksDB), populated via the `/api/settings`
# admin endpoint — NOT in this bootstrap config.toml. The pre-0.16
# `[directory.keycloak] type = "oidc"` kebab-case TOML block is
# silently ignored by the upstream registry parser
# (verified against crates/registry/src/schema/structs.rs — the
# OidcDirectory struct serdes camelCase: `@type = "Oidc"`,
# `issuerUrl`, `claimUsername`, `claimName`, `claimGroups`,
# `requireScopes`). Stalwart acts as the OIDC RESOURCE SERVER:
# bearer JWTs minted by the per-tenant Keycloak are validated
# against the directory's issuer and claim mappings.
#
# The post-install setup Job in templates/mailbox-provision-job.yaml
# POSTs the canonical OIDC directory entry (with the per-tenant
# Keycloak realm URL the orchestrator emitted into
# `.Values.keycloak.realmURL`) AND flips `storage.directory` to
# `keycloak`, making the per-tenant realm the primary
# authentication backend for SMTP/IMAP/JMAP/HTTP. Until that Job
# runs, the `internal` directory below keeps Stalwart bootable so
# the admin user can log in via password.
#
# The OIDC client (`stalwart` confidential client registered in
# the realm by the orchestrator's bp-keycloak realmConfig.tenant
# block) is consumed by webmail / Thunderbird / Apple Mail to
# initiate the authorization flow against Keycloak directly; they
# then present the bearer to Stalwart for IMAP/SMTP/JMAP login.
# Stalwart itself does NOT mint OAuth2 redirects — the OIDC client
# secret in `keycloak.clientSecretName` is mounted as the
# `OIDC_CLIENT_SECRET` env var purely so the setup Job can echo it
# back to clients via `/api/oauth/.well-known` queries (mailbox
# provision flow consumed by unified-rbac per ADR-0003 §3).
[auth.dkim]
sign = [{if = "is_local_domain('*')", then = true}, {else = false}]

View File

@ -1,31 +1,37 @@
{{- /*
Run-once setup Job — bootstraps the Stalwart admin principal and seeds
the SME's primary domain. Runs as a Helm post-install hook so it fires
exactly once per fresh release; the upstream Stalwart instance is
expected to be Ready before the Job's API calls succeed
(activeDeadlineSeconds covers the cold-start window).
Run-once setup Job — bootstraps the Stalwart admin principal, seeds
the SME's primary domain, AND seeds the Keycloak OIDC directory entry
(#915). Runs as a Helm post-install hook so it fires exactly once per
fresh release; the upstream Stalwart instance is expected to be Ready
before the Job's API calls succeed (activeDeadlineSeconds covers the
cold-start window).
Per ADR-0003 §3, ongoing per-user mailbox provisioning is event-driven:
unified-rbac POSTs `/api/principal` against this Stalwart's HTTP admin
API when the SME admin creates a user. The continuous subscriber
(natsSubscriber.enabled below) is OFF by default; the Job here covers
ONLY the bootstrap admin principal so the OIDC login path works from
t=0.
the bootstrap admin principal AND the OIDC directory definition so
that webmail / IMAP / SMTP login flows through Keycloak from t=0.
The Job uses a thin `stalwart-cli` image (operator-supplied via
mailboxProvisioner.setupJob.image) that ships with curl + the Stalwart
admin CLI. Operations performed:
The Job uses the upstream Stalwart image (ships curl + `stalwart-cli`
— no separate stalwart-cli image needed). Operations performed:
1. wait until Stalwart's HTTP admin API at $STALWART_URL/api is
reachable (60s ceiling, exits non-zero past that)
2. PATCH the admin principal with `email-receive` + `oidc-bypass`
permissions (the latter required for non-OIDC rescue access)
3. PATCH `lookup.sender-allow.<admin>|<admin>@<domain>` so the admin
can send-as the postmaster address (per stalwart_send_as.md)
4. exit 0
2. POST the OIDC directory entry to `/api/settings` with the
canonical camelCase keys (`directory.keycloak.@type = "Oidc"`,
`issuerUrl`, `claimUsername`, `claimName`, `claimGroups`,
`requireScopes`) verified against
crates/registry/src/schema/structs.rs in the upstream repo.
Idempotent (`assert_empty: false`) — re-runs on Helm upgrade
leave the existing directory entry in place.
3. PATCH the admin principal with `email-receive` permission so
inbound mail to the admin doesn't silently bounce.
4. POST `lookup.sender-allow.<admin>|<admin>@<domain>` so the admin
can send-as the postmaster address (per stalwart_send_as.md).
5. exit 0
The script is intentionally minimal — full mailbox lifecycle lives in
unified-rbac and the natsSubscriber subscriber. This Job ONLY covers
"is the admin principal addressable on day 0".
Per docs/INVIOLABLE-PRINCIPLES.md #4 every value below is templated
from chart values — no hardcoded URLs, claim names, or scopes.
*/}}
{{- if and .Values.stalwart.enabled .Values.mailboxProvisioner.setupJob.enabled }}
apiVersion: batch/v1
@ -59,12 +65,24 @@ spec:
{{- toYaml .Values.stalwart.containerSecurityContext | nindent 12 }}
env:
{{- include "bp-stalwart-tenant.adminPasswordEnv" . | nindent 12 }}
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.keycloak.clientSecretName | quote }}
key: OIDC_CLIENT_SECRET
optional: true
- name: STALWART_URL
value: "http://{{ include "bp-stalwart-tenant.fullname" . }}-web:{{ .Values.service.web.ports.http }}"
- name: ADMIN_USER
value: {{ .Values.admin.username | quote }}
- name: STALWART_DOMAIN
value: {{ include "bp-stalwart-tenant.tenantDomain" . | quote }}
- name: KEYCLOAK_REALM_URL
value: {{ .Values.keycloak.realmURL | quote }}
- name: KEYCLOAK_CLIENT_ID
value: {{ .Values.keycloak.clientID | quote }}
- name: KEYCLOAK_SCOPES
value: {{ join " " .Values.keycloak.scopes | quote }}
command:
- /bin/sh
- -ec
@ -78,6 +96,74 @@ spec:
fi
sleep 5
done
# ─── Seed OIDC directory entry (#915) ─────────────────
# Upsert the per-tenant Keycloak realm as Stalwart's
# primary OIDC directory. Keys verified against the
# upstream registry schema (OidcDirectory in
# crates/registry/src/schema/structs.rs):
# @type = "Oidc" (DirectoryType enum)
# description = human label
# issuerUrl = realm issuer URL
# claimUsername = JWT claim → Stalwart username
# claimName = JWT claim → display name
# claimGroups = JWT claim → groups (RBAC)
# requireScopes = list of OIDC scopes the JWT MUST
# contain to be accepted
# The settings POST is idempotent via `assert_empty:false`
# so Helm upgrades re-running this Job leave the existing
# directory entry in place without duplicating it.
if [ -n "${KEYCLOAK_REALM_URL}" ]; then
# Build the requireScopes JSON array from the
# space-delimited env (same shape as scopes list in
# values.yaml — the chart expects "openid email profile
# groups" and serdes them into an array).
SCOPES_JSON="["
_first=1
for s in ${KEYCLOAK_SCOPES}; do
if [ ${_first} -eq 1 ]; then _first=0; else SCOPES_JSON="${SCOPES_JSON},"; fi
SCOPES_JSON="${SCOPES_JSON}\"${s}\""
done
SCOPES_JSON="${SCOPES_JSON}]"
# The Stalwart OidcDirectory schema (verified upstream)
# validates bearer tokens against the issuer — Stalwart
# is the OIDC RESOURCE SERVER. Clients (webmail /
# Thunderbird / Apple Mail) initiate the authorization
# flow with Keycloak directly using the `stalwart`
# confidential client; they present the resulting
# bearer to Stalwart's IMAP/SMTP/JMAP/HTTP listeners
# which validate via the directory below.
curl -fsS -u "${ADMIN_USER}:${ADMIN_PASSWORD}" \
-H 'Content-Type: application/json' \
-X POST \
"${STALWART_URL}/api/settings" \
-d "[
{\"assert_empty\":false,\"key\":\"directory.keycloak.@type\",\"value\":\"Oidc\"},
{\"assert_empty\":false,\"key\":\"directory.keycloak.description\",\"value\":\"Per-tenant Keycloak (${STALWART_DOMAIN})\"},
{\"assert_empty\":false,\"key\":\"directory.keycloak.issuerUrl\",\"value\":\"${KEYCLOAK_REALM_URL}\"},
{\"assert_empty\":false,\"key\":\"directory.keycloak.claimUsername\",\"value\":\"preferred_username\"},
{\"assert_empty\":false,\"key\":\"directory.keycloak.claimName\",\"value\":\"name\"},
{\"assert_empty\":false,\"key\":\"directory.keycloak.claimGroups\",\"value\":\"groups\"},
{\"assert_empty\":false,\"key\":\"directory.keycloak.requireScopes\",\"value\":${SCOPES_JSON}},
{\"assert_empty\":false,\"key\":\"directory.keycloak.usernameDomain\",\"value\":\"${STALWART_DOMAIN}\"}
]" \
|| echo "warn: POST OIDC directory returned non-2xx"
# Promote the OIDC directory to be the primary
# authentication backend for IMAP/SMTP/JMAP. The
# `storage.directory` setting is the cross-protocol
# selector (matches `[storage].directory` in the
# bootstrap TOML — settings KV overrides at runtime).
curl -fsS -u "${ADMIN_USER}:${ADMIN_PASSWORD}" \
-H 'Content-Type: application/json' \
-X POST \
"${STALWART_URL}/api/settings" \
-d "[
{\"assert_empty\":false,\"key\":\"storage.directory\",\"value\":\"keycloak\"}
]" \
|| echo "warn: POST storage.directory returned non-2xx"
echo "OIDC directory seeded for issuer ${KEYCLOAK_REALM_URL}."
else
echo "info: keycloak.realmURL unset — skipping OIDC directory seed."
fi
# Grant email-receive on the admin principal (groups
# without explicit email-receive silently bounce inbound;
# documented gotcha in stalwart_send_as.md). Stalwart's
@ -98,5 +184,5 @@ spec:
"${STALWART_URL}/api/settings" \
-d "[{\"assert_empty\":false,\"key\":\"lookup.sender-allow.${ADMIN_USER}|postmaster@${STALWART_DOMAIN}\",\"value\":\"1\"}]" \
|| echo "warn: POST sender-allow returned non-2xx"
echo "Setup complete: admin principal seeded, send-allow row written."
echo "Setup complete: OIDC directory seeded, admin principal seeded, send-allow row written."
{{- end }}

View File

@ -0,0 +1,149 @@
#!/usr/bin/env bash
# bp-stalwart-tenant — OIDC render audit (#915).
#
# Verifies the chart's bootstrap config.toml + setup Job render the
# Keycloak OIDC integration correctly when a per-tenant overlay supplies
# the canonical values.
#
# 1. config.toml has an `[oauth]` block with all required endpoints
# derived from the realm URL.
# 2. The setup Job's settings POST uses the camelCase keys verified
# against the upstream Stalwart registry schema:
# directory.keycloak.@type, issuerUrl, claimUsername, claimName,
# claimGroups, requireScopes
# 3. The OIDC ExternalSecret materialises the client secret at the
# operator-supplied OpenBao path.
# 4. The bootstrap config.toml is valid TOML when rendered.
set -euo pipefail
chart_dir="$(cd "$(dirname "$0")/.." && pwd)"
values=$(mktemp)
trap "rm -f '${values}'" EXIT
cat > "${values}" <<'EOF'
domain:
primary: acme.omantel.omani.works
mode: free-subdomain
ingress:
webmail:
host: mail.acme.omantel.omani.works
keycloak:
realmURL: https://keycloak.acme.omantel.omani.works/realms/sme-acme
clientID: stalwart
clientSecretName: stalwart-oidc-client-secret
oidcExternalSecret:
enabled: true
secretStoreRef:
kind: ClusterSecretStore
name: vault-region1
remoteRef:
key: sovereign/omantel.omani.works/stalwart/t-acme/oidc
property: OIDC_CLIENT_SECRET
mailboxProvisioner:
setupJob:
enabled: true
EOF
render=$(mktemp)
trap "rm -f '${values}' '${render}'" EXIT
helm template oidc-smoke "${chart_dir}" \
--namespace oidc-smoke \
--api-versions external-secrets.io/v1beta1 \
-f "${values}" \
> "${render}"
fail=0
# 1. The setup Job env block must propagate the per-tenant realm URL +
# OIDC client metadata — these drive the settings POST below.
for needle in \
'KEYCLOAK_REALM_URL' \
'https://keycloak.acme.omantel.omani.works/realms/sme-acme' \
'KEYCLOAK_CLIENT_ID' \
'"stalwart"' ; do
if ! grep -qF "${needle}" "${render}"; then
echo "::error title=Stalwart OIDC env::missing in setup Job env: ${needle}"
fail=1
fi
done
# 2. setup Job seeds the OIDC directory with the canonical camelCase
# keys verified against crates/registry/src/schema/structs.rs in
# the upstream Stalwart repo.
for key in \
'directory.keycloak.@type' \
'directory.keycloak.issuerUrl' \
'directory.keycloak.claimUsername' \
'directory.keycloak.claimName' \
'directory.keycloak.claimGroups' \
'directory.keycloak.requireScopes' \
'storage.directory' ; do
if ! grep -qF "${key}" "${render}"; then
echo "::error title=Stalwart OIDC settings POST::missing settings key in setup Job: ${key}"
fail=1
fi
done
# 3. OIDC ExternalSecret materialises at the canonical OpenBao path.
if ! grep -qF 'sovereign/omantel.omani.works/stalwart/t-acme/oidc' "${render}"; then
echo "::error title=Stalwart OIDC ExternalSecret::missing remoteRef.key — chart should emit operator-supplied path"
fail=1
fi
# 4. Validate the rendered config.toml parses as TOML.
if command -v python3 >/dev/null 2>&1; then
RENDER_PATH="${render}" python3 <<'PY'
import os
import sys
try:
import tomllib
except ImportError:
try:
import tomli as tomllib
except ImportError:
sys.exit(0) # no TOML library — skip parse check
import yaml
docs = list(yaml.safe_load_all(open(os.environ["RENDER_PATH"])))
toml_text = None
for d in docs:
if not d:
continue
if d.get("kind") == "ConfigMap" and "config.toml" in d.get("data", {}):
toml_text = d["data"]["config.toml"]
break
if toml_text is None:
print("::error title=Stalwart OIDC TOML::config.toml ConfigMap not found in render", file=sys.stderr)
sys.exit(1)
try:
data = tomllib.loads(toml_text)
except Exception as e:
print(f"::error title=Stalwart OIDC TOML::config.toml does not parse as TOML: {e}", file=sys.stderr)
sys.exit(1)
required_top = ["server", "storage", "store", "directory", "auth", "tls"]
missing = [k for k in required_top if k not in data]
if missing:
print(f"::error title=Stalwart OIDC TOML::missing top-level sections: {missing}", file=sys.stderr)
sys.exit(1)
# The bootstrap config.toml MUST keep the `internal` directory so
# Stalwart is bootable before the post-install Job seeds the OIDC
# directory in the settings KV store.
if "internal" not in data.get("directory", {}):
print("::error title=Stalwart OIDC TOML::[directory.internal] missing — chart must keep internal directory bootable", file=sys.stderr)
sys.exit(1)
print("[stalwart-tenant/oidc-render] config.toml parses as valid TOML, internal bootstrap directory present.")
PY
if [ $? -ne 0 ]; then
fail=1
fi
fi
if [ "${fail}" -eq 1 ]; then
echo "[stalwart-tenant/oidc-render] FAIL — see ::error markers above."
exit 1
fi
echo "[stalwart-tenant/oidc-render] All OIDC integration assertions passed."
echo "[stalwart-tenant/oidc-render] PASS"

View File

@ -321,16 +321,21 @@ ingress:
# the OIDC client config inside Stalwart's RocksDB so OIDC login
# works from t=0.
mailboxProvisioner:
# Run-once setup Job (admin principal + OIDC client config in
# Stalwart). The Job's image is operator-tunable (#898) — until the
# canonical `ghcr.io/openova-io/openova/stalwart-cli` image is
# published, the Job re-uses the upstream stalwart container which
# ships `stalwart-cli` AND `curl` for HTTP calls. Disabled by
# default in 0.1.1 to avoid CrashLoopBackOff on tenants where the
# canonical CLI image is unavailable; SME admin can opt in via
# per-tenant overlay once the image is published.
# Run-once setup Job (admin principal + OIDC directory entry in
# Stalwart's runtime settings KV store). Re-uses the upstream
# stalwart container (ships `stalwart-cli` AND `curl` for HTTP calls)
# — no separate `stalwart-cli` image needed (#915, replaces the
# 0.1.1 default-off posture).
#
# 0.1.2 (#915): defaults to enabled so a fresh tenant has working
# Keycloak OIDC at t=0. The Job POSTs the OIDC directory entry to
# `/api/settings` with the canonical camelCase keys
# (`directory.keycloak.@type = "Oidc"`, `issuerUrl`, `claimUsername`,
# `claimName`, `claimGroups`, `requireScopes`) the upstream registry
# schema expects. Idempotent: re-runs on Helm upgrade leave the
# existing directory in place.
setupJob:
enabled: false
enabled: true
image:
# Re-use the upstream Stalwart image which already ships curl +
# stalwart-cli. SHA-pinned via the same digest as the StatefulSet

View File

@ -906,7 +906,25 @@ spec:
issuer: letsencrypt-prod
`
const smeTenantBPStalwart = `# bp-stalwart-tenant (#801) dedicated mail server per SME.
const smeTenantBPStalwart = `# bp-stalwart-tenant (#801, OIDC wiring #915) dedicated mail server
# per SME with Keycloak OIDC SSO against the per-tenant Keycloak realm.
#
# OIDC contract (#915): the per-tenant Keycloak (bp-keycloak above)
# registers a confidential client ` + "`stalwart`" + ` with redirect URI
# ` + "`https://<MailHost>/*`" + `. The realm-config-cli writes the client
# secret into the per-tenant ExternalSecret store under
# ` + "`sovereign/<otech-fqdn>/stalwart/<tenant>/oidc`" + ` (property
# OIDC_CLIENT_SECRET); this HelmRelease wires the chart's
# ` + "`oidcExternalSecret.remoteRef.key`" + ` to that path so the chart
# materialises the in-namespace Secret without operator hand-rolling.
#
# Stalwart's setup Job (mailbox-provision-job in the chart) then POSTs
# the OIDC directory definition to its ` + "`/api/settings`" + ` admin
# endpoint with the camelCase keys the upstream registry schema
# expects (issuerUrl/claimUsername/claimName/claimGroups). End result:
# alice's webmail at https://<MailHost> redirects to her tenant
# Keycloak, signs the JWT, returns to Stalwart, mailbox loads. Same
# flow for IMAP/SMTP via OAuth2 SASL XOAUTH2.
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
@ -922,15 +940,54 @@ spec:
kind: HelmRepository
name: bp-stalwart-tenant
namespace: flux-system
dependsOn:
- name: bp-keycloak
namespace: {{.Namespace}}
values:
domain:
primary: {{if .IsBYO}}{{.BYODomain}}{{else}}{{.Subdomain}}.{{.ParentDomain}}{{end}}
mode: {{.DomainMode}}
ingress:
host: {{.MailHost}}
tls:
issuer: letsencrypt-prod
webmail:
host: {{.MailHost}}
tls:
enabled: true
issuer: letsencrypt-prod
adminEmail: {{.AdminEmail}}
# Keycloak OIDC SSO same realm + ExternalSecret-store path
# convention as bp-wordpress-tenant + bp-openclaw above so all
# three SME apps SSO against ONE tenant Keycloak with distinct
# client IDs. Realm-config-tenant (#910 C1) registers the
# ` + "`stalwart`" + ` client with redirect URIs covering the webmail
# host AND the OIDC callback path.
keycloak:
realmURL: https://keycloak.{{.Subdomain}}.{{.ParentDomain}}/realms/sme-{{.Subdomain}}
clientID: stalwart
clientSecretName: stalwart-oidc-client-secret
oidcExternalSecret:
enabled: true
secretStoreRef:
kind: ClusterSecretStore
name: vault-region1
remoteRef:
key: sovereign/{{.OTECHFQDN}}/stalwart/{{.TenantID}}/oidc
property: OIDC_CLIENT_SECRET
admin:
externalSecret:
enabled: true
secretStoreRef:
kind: ClusterSecretStore
name: vault-region1
remoteRef:
key: sovereign/{{.OTECHFQDN}}/stalwart/{{.TenantID}}/admin
property: ADMIN_PASSWORD
# The post-install setup Job seeds the OIDC directory entry into
# Stalwart's runtime settings KV store via ` + "`/api/settings`" + ` so
# the very first webmail/IMAP/SMTP login flows through Keycloak.
# Re-uses the upstream Stalwart image (ships stalwart-cli + curl).
mailboxProvisioner:
setupJob:
enabled: true
`
const smeTenantCertificate = `{{- if .IsBYO}}

View File

@ -593,6 +593,62 @@ func TestRenderSMETenantOverlay_OpenClawOIDCAndLLMBlocks(t *testing.T) {
}
}
// #915 (C2) — bp-stalwart-tenant.yaml MUST emit per-tenant Keycloak OIDC
// values so the chart's setup Job seeds the OIDC directory entry against
// the per-tenant Keycloak realm. Without these the chart falls back to
// its empty default `keycloak.realmURL` and Stalwart's webmail / IMAP /
// SMTP login flow can't reach Keycloak.
func TestRenderSMETenantOverlay_StalwartEmitsKeycloakOIDC(t *testing.T) {
rec := store.SMETenantProvisionRecord{
SMETenantID: "t-acme",
Subdomain: "acme",
DomainMode: store.SMEDomainFreeSubdomain,
AdminEmail: "admin@acme.test",
CompanyName: "Acme Corp",
OTECHFQDN: "omantel.omani.works",
VClusterName: "vc-acme",
TenantNamespace: "sme-t-acme",
}
files, err := renderSMETenantOverlay(rec, SMETenantChartVersions{})
if err != nil {
t.Fatalf("render: %v", err)
}
body, ok := files["bp-stalwart-tenant.yaml"]
if !ok {
t.Fatalf("bp-stalwart-tenant.yaml missing")
}
// Per-tenant realm URL — must point at the SME's vcluster Keycloak,
// not a shared otech-level IdP.
wantRealmURL := "https://keycloak.acme.omantel.omani.works/realms/sme-acme"
if !strings.Contains(body, wantRealmURL) {
t.Errorf("realmURL missing — want %s in body", wantRealmURL)
}
// Confidential client ID + ExternalSecret-store remoteRef path.
for _, want := range []string{
"clientID: stalwart",
"clientSecretName: stalwart-oidc-client-secret",
"sovereign/omantel.omani.works/stalwart/t-acme/oidc",
"sovereign/omantel.omani.works/stalwart/t-acme/admin",
} {
if !strings.Contains(body, want) {
t.Errorf("expected %q in bp-stalwart-tenant.yaml — got:\n%s", want, body)
}
}
// dependsOn: bp-keycloak so the Helm install order is deterministic.
if !strings.Contains(body, "name: bp-keycloak") {
t.Errorf("expected dependsOn bp-keycloak in bp-stalwart-tenant.yaml")
}
// Setup Job MUST be enabled — that's what seeds the OIDC directory
// into Stalwart's runtime settings store at t=0.
if !strings.Contains(body, "setupJob:\n enabled: true") {
t.Errorf("expected mailboxProvisioner.setupJob.enabled=true")
}
// Webmail ingress host correctly composed for free-subdomain.
if !strings.Contains(body, "host: mail.acme.omantel.omani.works") {
t.Errorf("mail host missing in bp-stalwart-tenant.yaml")
}
}
func TestStepsForState(t *testing.T) {
cases := []struct {
state store.SMETenantProvisionState