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:
parent
9447d88dfd
commit
a1ca1872aa
@ -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: |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}]
|
||||
|
||||
@ -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 }}
|
||||
|
||||
149
platform/stalwart-tenant/chart/tests/oidc-render.sh
Executable file
149
platform/stalwart-tenant/chart/tests/oidc-render.sh
Executable 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"
|
||||
@ -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
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user