Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ EXAMPLES := \
examples/authstacks/minimal.yaml:: \
examples/authstacks/standard.yaml:: \
examples/authstacks/local-colima.yaml:: \
examples/authstacks/with-smtp.yaml:: \
examples/machineusers/minimal.yaml:: \
examples/machineusers/with-pat.yaml:: \
examples/machineusers/with-pat-push.yaml:: \
Expand Down
68 changes: 68 additions & 0 deletions apis/authstacks/definition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,52 @@ spec:
type: string
sectionName:
type: string
smtp:
description: |
Configure Zitadel's SMTP provider from a platform SMTPSender
(aws-smtp-sender). When enabled, AuthStack observes the referenced
SMTPSender XR for host/port/username, pulls the SES SMTP password
from AWS Secrets Manager via ESO, and composes a
smtp.zitadel.m.crossplane.io Config MR driving Zitadel's live
management API. See [[specs/smtpsender-consumer-pattern]].
type: object
properties:
enabled:
description: Compose the Zitadel SMTP wiring. Defaults to false.
type: boolean
smtpSenderRef:
description: The SMTPSender XR (on the control plane) to read host/port/username/awsSecretsManagerPath from.
type: object
properties:
name:
description: SMTPSender metadata.name. Required when enabled.
type: string
namespace:
description: SMTPSender namespace. Defaults to this AuthStack XR's own namespace.
type: string
fromAddress:
description: From address for Zitadel mail. MUST be on the SMTPSender's verified SES domain. Required when enabled.
type: string
fromName:
description: Display name for the From address. Defaults to the domain.
type: string
replyToAddress:
description: Optional Reply-To address.
type: string
tls:
description: Use STARTTLS (SES SMTP on port 587). Defaults to true.
type: boolean
default: true
setActive:
description: Mark this SMTP config active in Zitadel. Defaults to true.
type: boolean
default: true
secretStoreName:
description: ClusterSecretStore to pull the SMTP password from. Defaults to "hops-aws-secrets-manager".
type: string
smtpSecretName:
description: Name of the K8s Secret ESO materializes for the SMTP password. Defaults to "zitadel-smtp".
type: string
chartValues:
description: Escape hatch — Helm values merged on top of stack-rendered values. Prefer typed top-level fields when a knob proves load-bearing.
type: object
Expand Down Expand Up @@ -494,5 +540,27 @@ spec:
type: string
key:
type: string
smtp:
description: SMTP wiring status, populated when spec.smtp.enabled and the SMTPSender is observed.
type: object
properties:
sourceSmtpSender:
description: The observed SMTPSender (namespace/name).
type: string
host:
type: string
port:
type: integer
username:
type: string
awsSecretsManagerPath:
description: AWS SM path holding the SES SMTP password.
type: string
dkimVerified:
description: Whether the observed SMTPSender reports DKIM verified.
type: boolean
ready:
description: Whether the SMTP Config MR is composed (the referenced SMTPSender has been observed with a published SMTP secret path).
type: boolean
required:
- spec
43 changes: 43 additions & 0 deletions examples/authstacks/with-smtp.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# AuthStack consuming a platform SMTPSender to drive Zitadel's outbound email.
#
# spec.smtp.enabled wires the whole consumer chain declaratively:
# - Observes the referenced SMTPSender for host/port/username + DKIM status
# - Pulls the SES SMTP password from AWS Secrets Manager via ESO
# - Publishes this stack's iam-admin PAT (auto-enabled) and assembles a
# zitadel ProviderConfig from it
# - Drives the running Zitadel's SMTP provider via a Config MR
#
# The operator only expresses intent — no helm values, no secret plumbing.
apiVersion: hops.ops.com.ai/v1alpha1
kind: AuthStack
metadata:
name: auth
namespace: example-env
spec:
clusterName: example-cluster
domain: auth.example.com
externalSecure: true
firstInstance:
org: hops-ops
iamAdmin:
username: platform-admin
masterkey:
secretRef:
name: zitadel-masterkey
database:
external:
dsnSecretRef:
name: zitadel-db
key: dsn
gateway:
enabled: true
parentRef:
name: platform-gateway
namespace: istio-system
smtp:
enabled: true
smtpSenderRef:
name: ops
fromAddress: no-reply@auth.example.com
fromName: Hops Ops
replyToAddress: support@auth.example.com
40 changes: 40 additions & 0 deletions functions/render/000-state-init.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,33 @@
{{- $externalSecure = $spec.externalSecure }}
{{- end }}

# ==============================================================================
# SMTP — consume a platform SMTPSender (see specs/smtpsender-consumer-pattern).
# Observe the SMTPSender for host/port/username, pull the SES password from AWS
# SM via ESO, and drive Zitadel's SMTP via a smtp.zitadel.m.crossplane.io Config.
# ==============================================================================
{{- $smtp := $spec.smtp | default dict }}
{{- $smtpEnabled := false }}
{{- if hasKey $smtp "enabled" }}
{{- $smtpEnabled = $smtp.enabled }}
{{- end }}
# SMTP self-contains: enabling it also publishes the iam-admin PAT (the SMTP
# ProviderConfig is just another consumer of that pushed PAT), so the operator
# only flips spec.smtp.enabled — no separate pushCredentials toggle required.
{{- if $smtpEnabled }}{{- $pushCredsEnabled = true }}{{- end }}
{{- $smtpSenderRef := $smtp.smtpSenderRef | default dict }}
{{- $smtpSenderName := $smtpSenderRef.name | default "" }}
{{- $smtpSenderNamespace := $smtpSenderRef.namespace | default ($metadata.namespace | default "default") }}
{{- $smtpFromAddress := $smtp.fromAddress | default "" }}
{{- $smtpFromName := $smtp.fromName | default $domain }}
{{- $smtpReplyTo := $smtp.replyToAddress | default "" }}
{{- $smtpTls := true }}
{{- if hasKey $smtp "tls" }}{{- $smtpTls = $smtp.tls }}{{- end }}
{{- $smtpSetActive := true }}
{{- if hasKey $smtp "setActive" }}{{- $smtpSetActive = $smtp.setActive }}{{- end }}
{{- $smtpStoreName := $smtp.secretStoreName | default "hops-aws-secrets-manager" }}
{{- $smtpSecretName := $smtp.smtpSecretName | default "zitadel-smtp" }}

# ==============================================================================
# Initialize $state
# ==============================================================================
Expand Down Expand Up @@ -268,6 +295,19 @@
"sectionName" ($gwParentRef.sectionName | default "")
)
)
"smtp" (dict
"enabled" $smtpEnabled
"smtpSenderRef" (dict "name" $smtpSenderName "namespace" $smtpSenderNamespace)
"fromAddress" $smtpFromAddress
"fromName" $smtpFromName
"replyToAddress" $smtpReplyTo
"tls" $smtpTls
"setActive" $smtpSetActive
"secretStoreName" $smtpStoreName
"secretName" $smtpSecretName
"observed" (dict)
"render" false
)
"observed" (dict)
"status" (dict)
}}
51 changes: 51 additions & 0 deletions functions/render/010-state-status.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,48 @@
"helmRelease" (dict "ready" $relReady)
) }}

# ==============================================================================
# Observed SMTPSender (when spec.smtp.enabled). Read status.smtp.* + dkim from the
# Observe Object's atProvider.manifest. Gate all SMTP render on the observed
# awsSecretsManagerPath (composition-gate). Validate required fields early.
# ==============================================================================
{{- $smtp := $state.smtp }}
{{- if $smtp.enabled }}
{{- if not $smtp.smtpSenderRef.name }}{{- fail "spec.smtp.enabled requires spec.smtp.smtpSenderRef.name" }}{{- end }}
{{- if not $smtp.fromAddress }}{{- fail "spec.smtp.enabled requires spec.smtp.fromAddress (must be on the SMTPSender's verified SES domain)" }}{{- end }}
{{- end }}
{{- $ssEntry := get $observed "observed-smtp-sender" | default dict }}
{{- $ssManifest := dig "resource" "status" "atProvider" "manifest" dict $ssEntry }}
{{- $ssStatus := $ssManifest.status | default dict }}
{{- $ssSmtp := $ssStatus.smtp | default dict }}
{{- $ssDkim := $ssStatus.dkim | default dict }}
# Once the SMTPSender is observed, enforce the XRD contract that fromAddress is on
# its verified SES domain (or a subdomain) — otherwise SES silently rejects mail
# and the only signal is in Zitadel's logs. Skipped until the domain is observed,
# so it never blocks first-cycle bootstrap.
{{- if $smtp.enabled }}
{{- $ssDomain := dig "spec" "domain" "" $ssManifest }}
{{- $atParts := splitList "@" $smtp.fromAddress }}
{{- if and $ssDomain (eq (len $atParts) 2) }}
{{- $fromDomain := last $atParts }}
{{- if not (or (eq $fromDomain $ssDomain) (hasSuffix (printf ".%s" $ssDomain) $fromDomain)) }}
{{- fail (printf "spec.smtp.fromAddress (%s) must be on the SMTPSender's verified SES domain %q (or a subdomain of it)" $smtp.fromAddress $ssDomain) }}
{{- end }}
{{- end }}
{{- end }}
{{- $smtpObserved := dict
"host" ($ssSmtp.host | default "")
"port" ($ssSmtp.port | default 587)
"username" ($ssSmtp.username | default "")
"awsSecretsManagerPath" ($ssSmtp.awsSecretsManagerPath | default "")
"dkimVerified" ($ssDkim.verified | default false)
}}
{{- $smtpRender := and $smtp.enabled $smtp.smtpSenderRef.name (ne $smtpObserved.awsSecretsManagerPath "") }}
# mergeOverwrite (not merge): 000 pre-seeds smtp.observed={}/render=false, and
# plain `merge` gives precedence to the DEST, so the seeded zero-values would win
# and the real observed values would never propagate. Source must win here.
{{- $state = set $state "smtp" (mergeOverwrite $state.smtp (dict "observed" $smtpObserved "render" $smtpRender)) }}

# ==============================================================================
# providerConfig status surface — consumer contract.
#
Expand Down Expand Up @@ -94,4 +136,13 @@
"discoveryURL" $discovery
)
"bootstrap" $bootstrap
"smtp" (dict
"sourceSmtpSender" (ternary (printf "%s/%s" $smtp.smtpSenderRef.namespace $smtp.smtpSenderRef.name) "" $smtp.enabled)
"host" $smtp.observed.host
"port" $smtp.observed.port
"username" $smtp.observed.username
"awsSecretsManagerPath" $smtp.observed.awsSecretsManagerPath
"dkimVerified" $smtp.observed.dkimVerified
"ready" $smtp.render
)
) }}
33 changes: 33 additions & 0 deletions functions/render/040-observed-smtp-sender.yaml.gotmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# code: language=yaml
#
# Observe the platform SMTPSender XR to read its status.smtp.{host,port,username,
# awsSecretsManagerPath} and status.dkim.verified (see specs/smtpsender-consumer-pattern).
#
# The SMTPSender lives on the colima CONTROL PLANE, NOT the workload cluster — so this
# Object MUST use the in-cluster `default` kubernetes ProviderConfig (InjectedIdentity),
# NOT kubernetesProviderConfigRef (which targets EKS). managementPolicies: [Observe]
# means we only read the SMTPSender, never create/modify it.
#
{{- $smtp := $state.smtp }}
{{- if and $smtp.enabled $smtp.smtpSenderRef.name }}
---
apiVersion: kubernetes.m.crossplane.io/v1alpha1
kind: Object
metadata:
name: {{ $state.name }}-observed-smtp-sender
annotations:
{{ setResourceNameAnnotation "observed-smtp-sender" }}
labels: {{ $state.labels | toJson }}
spec:
managementPolicies: ["Observe"]
forProvider:
manifest:
apiVersion: aws.hops.ops.com.ai/v1alpha1
kind: SMTPSender
metadata:
name: {{ $smtp.smtpSenderRef.name }}
namespace: {{ $smtp.smtpSenderRef.namespace }}
providerConfigRef:
name: default
kind: ProviderConfig
{{- end }}
55 changes: 55 additions & 0 deletions functions/render/160-external-secret-smtp-password.yaml.gotmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# code: language=yaml
#
# ExternalSecret: SES SMTP password.
#
# Pulls the SES SMTP password from the SMTPSender's AWS Secrets Manager path
# (observed in 010) into a K8s Secret in the Zitadel install namespace. The
# SMTPSender stores the password as a BARE string (not JSON), so this pulls the
# whole secret value with NO `property`. The Zitadel SMTP Config MR (180)
# consumes it via passwordSecretRef. See specs/smtpsender-consumer-pattern.
#
{{- $smtp := $state.smtp }}
{{- /* Gate on stable user intent, NOT $smtp.render. The password Secret must be
durable: gating on the observed awsSecretsManagerPath would DROP (delete) the
Secret on any transient Observe miss, breaking live mail and forcing a full
re-sync (see reference_gotmpl_conditional_omit_deletes). remoteRef.key still
comes from the observed path; before it is observed ESO simply reports
SecretSyncedError and retains the last value rather than the resource vanishing.
Mirrors email-marketing 400. */ -}}
{{- if and $smtp.enabled $smtp.smtpSenderRef.name }}
---
apiVersion: kubernetes.m.crossplane.io/v1alpha1
kind: Object
metadata:
name: {{ $state.name }}-external-secret-smtp-password
annotations:
{{ setResourceNameAnnotation "external-secret-smtp-password" }}
labels: {{ $state.labels | toJson }}
spec:
managementPolicies: {{ $state.managementPolicies | toJson }}
forProvider:
manifest:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: {{ $smtp.secretName }}
namespace: {{ $state.namespace }}
labels: {{ $state.labels | toJson }}
spec:
refreshInterval: 1h
secretStoreRef:
name: {{ $smtp.secretStoreName }}
kind: ClusterSecretStore
target:
name: {{ $smtp.secretName }}
creationPolicy: Owner
data:
- secretKey: smtp-password
remoteRef:
# No `property`: the SMTPSender stores the bare SES SMTP password
# string at this path, not a JSON document.
key: {{ $smtp.observed.awsSecretsManagerPath | quote }}
providerConfigRef:
name: {{ $state.kubernetesProviderConfigRef.name }}
kind: {{ $state.kubernetesProviderConfigRef.kind }}
{{- end }}
Loading
Loading