Complete EMAIL-WP-0004 transactional invitation mail delivery.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Ship the railiance01 deploy package (OpenBao/ESO custody, NetworkPolicy,
probes), provider failure classification and suppression, T04 unit and live
proof, and non-secret NK-WP-0024 hand-back evidence. Workplan finished.
This commit is contained in:
tegwick 2026-08-12 13:32:11 +02:00
parent 5d1c5e1c5c
commit 04897a149f
18 changed files with 1269 additions and 117 deletions

View file

@ -0,0 +1,136 @@
# email-connect transactional receiver — railiance01
Deploy the narrow invitation/verification HTTP receiver so **user-engine** can
send mail without holding SMTP credentials, and so unrelated workloads cannot
reach the send API.
**Target cluster:** railiance01 only (`KUBECONFIG=~/.kube/config-hosteurope`).
Stable in-cluster URL after apply:
```text
http://email-connect.email-connect.svc.cluster.local:8080
```
## Ownership split
| Concern | Owner | Where |
| --- | --- | --- |
| Package, manifests, probes, NetworkPolicy, rollback | `email-connect` | this directory |
| OpenBao path, ESO role/policy, ClusterSecretStore | `railiance-platform` | CCR-2026-0010, `openbao/policies/` |
| Credential routing catalog | `ops-warden` | `email-connect-transactional` |
Never paste SMTP passwords, ingest tokens, or OpenBao tokens into Git, State
Hub, workplans, logs, or chat. Route first:
```bash
warden route find "email-connect SMTP transactional" --json
warden route show email-connect-transactional --json
```
## Runtime secrets (OpenBao → ESO)
| Item | Value |
| --- | --- |
| KV path | `platform/workloads/email-connect/transactional` |
| Fields | `EMAIL_CONNECT_INGEST_TOKEN`, `EMAIL_CONNECT_SMTP_USERNAME`, `EMAIL_CONNECT_SMTP_PASSWORD` |
| Read / ESO policy | `external-secrets-email-connect` (+ `workload-kv-read-email-connect-transactional` for operator OIDC) |
| ClusterSecretStore | `openbao-email-connect` (namespace-limited; token auth like activity-core/reuse) |
| ESO token Secret | `external-secrets/openbao-email-connect-eso-token` (via `openbao-eso-token-apply.sh`) |
| ExternalSecret | `email-connect/email-connect-runtime` → Secret `email-connect-runtime` |
| ops-warden catalog | `email-connect-transactional` (draft until provisioned) |
Non-secret config (host, port, sender, portal URL) lives in ConfigMap
`email-connect-config`.
### Operator provision (attended, after CCR approval)
```bash
# Values from mode-0600 files only; shred after.
bao kv put platform/workloads/email-connect/transactional \
EMAIL_CONNECT_INGEST_TOKEN=@/path/to/ingest.token \
EMAIL_CONNECT_SMTP_USERNAME=@/path/to/smtp.user \
EMAIL_CONNECT_SMTP_PASSWORD=@/path/to/smtp.pass
```
user-engine must receive the **same** ingest token under its own runtime secret
path so it can call `Authorization: Bearer …` without ever seeing SMTP fields.
## Network posture
- **Default deny** ingress and egress in `email-connect`.
- **Ingress:** only pods labeled `app.kubernetes.io/name=user-engine` in
namespace `user-engine`, TCP 8080.
- **Egress:** kube-system DNS + TCP 587 to the public internet (IONOS
STARTTLS). No cluster-lateral egress and no general HTTPS.
Bearer auth on `/v1/*` is the second gate; NetworkPolicy is the outer gate.
## Apply
Prerequisites:
1. CCR-2026-0010 approved.
2. KV secret provisioned at `platform/workloads/email-connect/transactional`
(fields non-empty; do not log values).
3. ESO delivery token + ClusterSecretStore on railiance01 (token pattern matches
`openbao-activity-core` / `openbao-reuse`, not Kubernetes auth yet).
4. Image available on the node/registry at the digest pinned in `deployment.yaml`.
```bash
export KUBECONFIG=~/.kube/config-hosteurope
# 1) Policy + limited ESO child token → external-secrets Secret
./deploy/k8s/railiance/openbao-eso-token-apply.sh
# 2) Namespace-scoped ClusterSecretStore (platform add-on copy)
kubectl apply -f ~/railiance-platform/argocd/platform-addons/openbao-secretstore/openbao-email-connect.clustersecretstore.yaml
# 3) Optional: load a freshly built image when the registry is not yet updated
# docker build -f Containerfile -t forgejo.coulomb.social/coulomb/email-connect:local .
# docker save … | ssh railiance01 sudo k3s ctr -n k8s.io images import -
# 4) Package
kubectl apply -k deploy/k8s/railiance
kubectl -n email-connect rollout status deployment/email-connect
kubectl -n email-connect get pods,svc,networkpolicy,externalsecret,secret
```
Expect ExternalSecret `SecretSynced` and a Ready pod. If the Secret is missing,
do not create a bootstrap Secret with values from chat — fix OpenBao/ESO.
## Smoke (no SMTP proof yet)
From a **user-engine** pod (or a temporary pod in that namespace with the same
labels — NetworkPolicy will otherwise drop the packet):
```bash
# Health only (no auth)
kubectl -n user-engine exec deploy/user-engine -- \
wget -qO- http://email-connect.email-connect.svc.cluster.local:8080/healthz
# Auth check: wrong token must be 401; correct token is exercised by T04.
```
Negative checks:
- A pod in another namespace cannot reach port 8080 (connection timeout/reset).
- user-engine runtime must not contain `EMAIL_CONNECT_SMTP_*` keys.
## Rollback
```bash
kubectl -n email-connect rollout undo deployment/email-connect
kubectl -n email-connect rollout status deployment/email-connect
```
`revisionHistoryLimit: 5` keeps prior digests. SQLite on the PVC is additive;
rolling back does not clear delivery/idempotency rows (by design).
## Done criteria (EMAIL-WP-0004-T03)
- [ ] user-engine calls the cluster-local receiver without possessing SMTP credentials
- [ ] Unrelated workloads cannot open TCP 8080 on the receiver
- [ ] SMTP password and ingest token exist only in OpenBao + the scoped K8s Secret
- [ ] Probes green; NetworkPolicy default-deny + SMTP-only egress applied
- [ ] Documented rollback path works against the previous revision

View file

@ -0,0 +1,30 @@
# Template copy of the railiance-platform add-on store. Prefer applying from:
# ~/railiance-platform/argocd/platform-addons/openbao-secretstore/openbao-email-connect.clustersecretstore.yaml
#
# Do not apply until CCR-2026-0010 is approved and Secret
# external-secrets/openbao-email-connect-eso-token exists
# (./openbao-eso-token-apply.sh).
---
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: openbao-email-connect
labels:
app.kubernetes.io/part-of: railiance-gitops
railiance-platform/component: external-secrets
app.kubernetes.io/name: email-connect
spec:
provider:
vault:
# In-cluster OpenBao on railiance01 (not the public bao.coulomb.social UI).
server: http://openbao.openbao.svc:8200
path: platform
version: v2
auth:
tokenSecretRef:
name: openbao-email-connect-eso-token
namespace: external-secrets
key: token
conditions:
- namespaces:
- email-connect

View file

@ -0,0 +1,23 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: email-connect-config
namespace: email-connect
labels:
app.kubernetes.io/name: email-connect
app.kubernetes.io/part-of: email-connect
data:
# Non-secret runtime shape only. SMTP auth and the caller ingest token stay
# in OpenBao → ExternalSecret (see externalsecret.yaml).
EMAIL_CONNECT_HTTP_PORT: "8080"
EMAIL_CONNECT_DATABASE_PATH: "/data/email-connect.db"
EMAIL_CONNECT_SMTP_HOST: "smtp.ionos.de"
EMAIL_CONNECT_SMTP_PORT: "587"
# Public portal base used in invitation and verification links.
# Matches the live user-engine Ingress host on railiance01 (nip.io until a
# coulomb.social alias is cut over). Update when the public hostname changes.
EMAIL_CONNECT_PORTAL_URL: "https://users.92-205-62-239.nip.io"
# Envelope From — interim identity matches the live forgejo IONOS SMTP lane
# (forgejo@coulomb.social) used to seed CCR-2026-0010. Switch to a dedicated
# noreply@ mailbox when that credential is minted separately.
EMAIL_CONNECT_SENDER: "forgejo@coulomb.social"

View file

@ -0,0 +1,97 @@
# email-connect transactional receiver — railiance01 (EMAIL-WP-0004-T03).
#
# Conventions match audit-core / user-engine: digest-pinned image from
# forgejo.coulomb.social, non-root, read-only root, probes on named http port.
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: email-connect
namespace: email-connect
labels:
app.kubernetes.io/name: email-connect
app.kubernetes.io/part-of: email-connect
annotations:
email-connect.railiance.io/rollback-note: >-
SQLite schema is additive (CREATE TABLE IF NOT EXISTS + optional
canceled_at column). Rolling back to the previous digest keeps the
same store path; duplicate event_id rows remain authoritative.
spec:
replicas: 1
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: email-connect
template:
metadata:
labels:
app.kubernetes.io/name: email-connect
app.kubernetes.io/part-of: email-connect
spec:
serviceAccountName: email-connect
securityContext:
runAsNonRoot: true
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
terminationGracePeriodSeconds: 15
containers:
- name: email-connect
# Pin at release. T04 failure-matrix image (provider classification +
# suppression + redacted diagnostics), 2026-08-12.
image: forgejo.coulomb.social/coulomb/email-connect@sha256:a9de1994eea88502ec30ba7c2fc029ec901d873140d51e11d2c50e363a0578b1
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
envFrom:
- configMapRef:
name: email-connect-config
- secretRef:
name: email-connect-runtime
optional: false
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 250m
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
volumeMounts:
- name: data
mountPath: /data
- name: tmp
mountPath: /tmp
startupProbe:
httpGet: {path: /healthz, port: http}
periodSeconds: 3
failureThreshold: 20
readinessProbe:
httpGet: {path: /readyz, port: http}
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet: {path: /healthz, port: http}
periodSeconds: 20
timeoutSeconds: 2
failureThreshold: 3
volumes:
- name: data
persistentVolumeClaim:
claimName: email-connect-data
- name: tmp
emptyDir: {}

View file

@ -0,0 +1,44 @@
# Credential delivery for the transactional receiver (EMAIL-WP-0004-T03).
#
# PREREQUISITES (railiance-platform, not this repo):
# - ClusterSecretStore openbao-email-connect limited to this namespace
# - OpenBao policy external-secrets-email-connect + role external-secrets-email-connect
# - Operator-provisioned KV at platform/workloads/email-connect/transactional
# (fields listed below; no secret values in Git)
# Apply order: ClusterSecretStore → this ExternalSecret → Deployment.
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: email-connect-runtime
namespace: email-connect
labels:
app.kubernetes.io/name: email-connect
app.kubernetes.io/part-of: email-connect
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao-email-connect
target:
name: email-connect-runtime
creationPolicy: Owner
deletionPolicy: Retain
data:
# Shared bearer for user-engine → email-connect. user-engine holds the same
# value as EMAIL_CONNECT_INGEST_TOKEN under its own custody path; this
# service never receives SMTP material from the caller.
- secretKey: EMAIL_CONNECT_INGEST_TOKEN
remoteRef:
key: platform/workloads/email-connect/transactional
property: EMAIL_CONNECT_INGEST_TOKEN
- secretKey: EMAIL_CONNECT_SMTP_USERNAME
remoteRef:
key: platform/workloads/email-connect/transactional
property: EMAIL_CONNECT_SMTP_USERNAME
- secretKey: EMAIL_CONNECT_SMTP_PASSWORD
remoteRef:
key: platform/workloads/email-connect/transactional
property: EMAIL_CONNECT_SMTP_PASSWORD

View file

@ -0,0 +1,12 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: email-connect
resources:
- namespace.yaml
- serviceaccount.yaml
- configmap.yaml
- pvc.yaml
- service.yaml
- networkpolicies.yaml
- externalsecret.yaml
- deployment.yaml

View file

@ -0,0 +1,9 @@
apiVersion: v1
kind: Namespace
metadata:
name: email-connect
labels:
kubernetes.io/metadata.name: email-connect
railiance.io/workload-class: platform
app.kubernetes.io/name: email-connect
app.kubernetes.io/part-of: email-connect

View file

@ -0,0 +1,78 @@
# Default-deny plus the narrowest exceptions (EMAIL-WP-0004-T03).
#
# Reachability is part of the threat model: only user-engine may call the
# receiver, and egress is limited to DNS + the IONOS SMTP STARTTLS port.
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: email-connect-default-deny
namespace: email-connect
labels:
app.kubernetes.io/name: email-connect
app.kubernetes.io/part-of: email-connect
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
# No rules: everything not permitted below is denied.
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: email-connect-user-engine-ingress
namespace: email-connect
labels:
app.kubernetes.io/name: email-connect
app.kubernetes.io/part-of: email-connect
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: email-connect
policyTypes: [Ingress]
ingress:
# user-engine is the only approved sender. Adding a second caller is a
# deliberate change here and a matching credential binding.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: user-engine
podSelector:
matchLabels:
app.kubernetes.io/name: user-engine
ports:
- {protocol: TCP, port: 8080}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: email-connect-egress
namespace: email-connect
labels:
app.kubernetes.io/name: email-connect
app.kubernetes.io/part-of: email-connect
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: email-connect
policyTypes: [Egress]
egress:
# Cluster DNS
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- {protocol: UDP, port: 53}
- {protocol: TCP, port: 53}
# IONOS SMTP STARTTLS only. No general internet egress.
# smtp.ionos.de resolves outside the cluster; CIDR is intentionally broad
# but port-locked to 587. Tighten to provider CIDRs if inventory is fixed.
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- {protocol: TCP, port: 587}

View file

@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Mint a read-limited OpenBao token and store it on railiance01 for
# ClusterSecretStore openbao-email-connect / ExternalSecret email-connect-runtime.
#
# Policies (files in railiance-platform/openbao/policies/):
# external-secrets-email-connect
# workload-kv-read-email-connect-transactional (optional operator OIDC sibling)
#
# Does not print secret values. Requires an attended operator OpenBao token.
# CCR-2026-0010 must be approved before production use.
set -euo pipefail
DEFAULT_POLICIES="external-secrets-email-connect"
POLICIES="${OPENBAO_EMAIL_CONNECT_POLICIES:-$DEFAULT_POLICIES}"
POLICY_DIR="${OPENBAO_POLICY_DIR:-$HOME/railiance-platform/openbao/policies}"
BAO_ADDR="${BAO_ADDR:-https://bao.coulomb.social}"
RAILIANCE01_KUBECONFIG="${RAILIANCE01_KUBECONFIG:-$HOME/.kube/config-hosteurope}"
SECRET_NAME="${OPENBAO_EMAIL_CONNECT_ESO_SECRET:-openbao-email-connect-eso-token}"
SECRET_NS="${OPENBAO_EMAIL_CONNECT_ESO_NAMESPACE:-external-secrets}"
TTL="${OPENBAO_EMAIL_CONNECT_ESO_TTL:-768h}"
if ! command -v bao >/dev/null 2>&1; then
echo "ERROR: bao CLI not found" >&2
exit 1
fi
if ! command -v kubectl >/dev/null 2>&1; then
echo "ERROR: kubectl not found" >&2
exit 1
fi
echo "OpenBao addr: $BAO_ADDR"
echo "Policies: $POLICIES"
echo "K8s secret: $SECRET_NS/$SECRET_NAME (railiance01)"
if [[ -n "${BAO_TOKEN:-}" ]]; then
:
elif [[ -n "${OPENBAO_TOKEN_FILE:-}" && -f "${OPENBAO_TOKEN_FILE}" ]]; then
BAO_TOKEN="$(head -n 1 "${OPENBAO_TOKEN_FILE}")"
else
read -r -s -p "OpenBao operator token: " BAO_TOKEN
echo >&2
fi
if [[ -z "${BAO_TOKEN:-}" ]]; then
echo "ERROR: empty OpenBao token" >&2
exit 1
fi
export BAO_ADDR BAO_TOKEN
health="$(curl -fsS "$BAO_ADDR/v1/sys/health")"
if echo "$health" | grep -q '"sealed":true'; then
echo "ERROR: OpenBao at $BAO_ADDR reports sealed" >&2
exit 1
fi
for policy in $POLICIES; do
policy_file="$POLICY_DIR/${policy}.hcl"
if [[ -f "$policy_file" ]]; then
bao policy write "$policy" "$policy_file"
echo "policy written: $policy"
else
echo "WARN: policy file missing ($policy_file); using existing OpenBao policy '$policy'" >&2
fi
done
# Child token: renewable, orphan so operator logout does not revoke delivery.
# shellcheck disable=SC2086
token_json="$(bao token create -policy="$(echo $POLICIES | tr ' ' ',')" -ttl="$TTL" -renewable=true -orphan -format=json)"
child_token="$(printf '%s' "$token_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["auth"]["client_token"])')"
if [[ -z "$child_token" || ${#child_token} -lt 8 ]]; then
echo "ERROR: failed to mint child token" >&2
exit 1
fi
echo "minted child token length=${#child_token} (value not printed)"
export KUBECONFIG="$RAILIANCE01_KUBECONFIG"
kubectl -n "$SECRET_NS" create secret generic "$SECRET_NAME" \
--from-literal=token="$child_token" \
--dry-run=client -o yaml | kubectl apply -f -
unset child_token BAO_TOKEN
echo "Secret $SECRET_NS/$SECRET_NAME applied on railiance01."
echo "Next: apply ClusterSecretStore openbao-email-connect, then email-connect package."

View file

@ -0,0 +1,14 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: email-connect-data
namespace: email-connect
labels:
app.kubernetes.io/name: email-connect
app.kubernetes.io/part-of: email-connect
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
# SQLite idempotency + verification digest store only.
storage: 1Gi

View file

@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: email-connect
namespace: email-connect
labels:
app.kubernetes.io/name: email-connect
app.kubernetes.io/part-of: email-connect
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: email-connect
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP

View file

@ -0,0 +1,9 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: email-connect
namespace: email-connect
labels:
app.kubernetes.io/name: email-connect
app.kubernetes.io/part-of: email-connect
automountServiceAccountToken: false