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
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ validate\:%:
test:
up test run $(RENDER_TESTS)

# Includes rejection cases that CompositionTest cannot express.
.PHONY: test-security
test-security:
python3 -m unittest discover -s tests/security -v

e2e:
up test run $(E2E_TESTS) --e2e

Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,57 @@ See [[specs/auth-stack-zitadel]] for the design and open questions.
- Spec: `[[specs/auth-stack-zitadel]]`
- Task: `[[tasks/auth-stack]]`
- Upstream chart: `zitadel/zitadel` 9.34.1 (ships Zitadel v4)

## System API and instance metadata

AuthStack accepts optional `systemAPIUsers`: each entry has `id`,
`publicKeySecretRef: {name, key}`, and optional `memberships` entries
(`memberType`, `roles`, optional `aggregateId`). Keys must exist in the target
install namespace. Only public key files are mounted; generate and persist
private keys outside AuthStack and deliver them to provider consumers via ESO.
No user or hostname is enabled by default. Omitting memberships grants Zitadel's
SYSTEM_OWNER default; specify memberships explicitly when narrower access fits.

Set `instanceDiscovery.enabled: true` with an explicit `internalURL` to publish
`status.instanceId`. A retry-bounded Job reads the chart-generated IAM PAT, queries
instance metadata, and patches only its named ConfigMap. It never mutates
Zitadel. `firstInstance.enabled` must remain enabled for this discovery mode.
The ConfigMap is observed without Update management so reconcile cannot erase
the probe's result. Recreating an instance requires recreating the discovery
Job/ConfigMap alongside it. The default discovery image is Python 3.13.7 Alpine;
set `instanceDiscovery.image` to an approved mirror/digest when required.
Discovery configuration (including its image and chart overrides) is an
administrator-level interface and must not be delegated to untrusted tenants.

`internalURL` must match the chart's actual Zitadel Service and configured port,
using `<service>.<namespace>.svc` or `<service>.<namespace>.svc.cluster.local`,
without credentials, path, query or fragment. For example:
`https://identity-zitadel.identity.svc:8080`. The origin is validated before
rendering the credential-consuming Job. Redirects and environment proxies are
disabled. HTTPS verifies the certificate chain and Service hostname; configure
server TLS through chart values and optionally set
`caCertSecretRef: {name: zitadel-ca, key: ca.crt}` for a private CA.

Only a trusted local cluster with plaintext Service traffic should set
`allowInsecureHTTP: true`. This explicit exception sends the PAT in cleartext
inside the cluster; browser/gateway HTTPS does not encrypt that hop. It is false
by default and is not a cloud default. Enabling HTTPS requires the Service itself
to support TLS, not merely TLS termination at the ingress.

The pod can wait for the chart-generated PAT without a Job wall-clock deadline.
Once started it makes at most 110 attempts (two 10-second request timeouts and a
5-second sleep per attempt), with Kubernetes backoffLimit 3. A terminal API
failure still requires retrying the Job after fixing the cause; waiting for a
missing first-install Secret no longer consumes that retry budget. PAT rotation
alone does not recreate the Job. Endpoint, image, domain, admin username, or CA
reference changes do.

These additions let consumers use provider CustomDomain/TrustedDomain resources
without carrying instance IDs in git. Project domains and browser ingress remain
consumer/platform responsibilities. `spec.domain` is always supplied by the
installation: cloud examples use real domains; only local CLI templates use
localhost. Existing installations have both features disabled unless requested.

Discovery security regressions: run `make test-security` with the same Docker/
`up` setup as render tests. It checks rejected origins and the actual Job
transport code (TLS identity verification, no redirects, no environment proxies).
63 changes: 63 additions & 0 deletions apis/authstacks/definition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,66 @@ spec:
description: AuthStackSpec defines the desired state.
type: object
properties:
systemAPIUsers:
description: Optional System API users. Public keys must exist in Secrets in the Zitadel install namespace; private keys belong to API consumers.
type: array
x-kubernetes-list-type: map
x-kubernetes-list-map-keys: [id]
items:
type: object
required: [id, publicKeySecretRef]
properties:
id:
type: string
pattern: '^[a-z0-9][a-z0-9-]*$'
maxLength: 63
publicKeySecretRef:
type: object
required: [name, key]
properties:
name: {type: string, minLength: 1}
key: {type: string, minLength: 1}
memberships:
description: Explicit System API memberships; defaults to SYSTEM_OWNER when omitted (Zitadel behavior).
type: array
items:
type: object
required: [memberType, roles]
properties:
memberType: {type: string, enum: [System, IAM, Organization]}
aggregateId: {type: string}
roles:
type: array
minItems: 1
items: {type: string}
instanceDiscovery:
description: Opt-in read-only discovery of the bootstrapped instance ID. Uses the chart-generated IAM admin PAT and publishes only non-secret metadata.
type: object
properties:
enabled: {type: boolean, default: false}
internalURL:
description: Origin of this stack's Zitadel Service (HTTPS by default), with explicit service port and no path. Host must match the rendered chart Service in the install namespace.
type: string
pattern: '^https?://[a-z0-9-]+[.][a-z0-9-]+[.]svc([.]cluster[.]local)?:[0-9]+$'
allowInsecureHTTP:
description: Explicit opt-in for trusted local clusters with plaintext Service traffic. Never enable on an untrusted network; the IAM PAT is transmitted in cleartext.
type: boolean
default: false
caCertSecretRef:
description: Optional CA bundle for verifying the Zitadel Service certificate. The certificate must cover the Service hostname. Secret must be in the install namespace.
type: object
required: [name, key]
properties:
name: {type: string, minLength: 1}
key: {type: string, minLength: 1}
image:
type: string
default: python:3.13.7-alpine3.22
x-kubernetes-validations:
- rule: '!self.enabled || has(self.internalURL)'
message: instanceDiscovery.internalURL is required when enabled
- rule: '!has(self.internalURL) || self.internalURL.startsWith("https://") || (has(self.allowInsecureHTTP) && self.allowInsecureHTTP)'
message: instanceDiscovery requires HTTPS unless allowInsecureHTTP is explicitly enabled
clusterName:
description: Name of the target cluster. Used as default for provider config refs.
type: string
Expand Down Expand Up @@ -478,6 +538,9 @@ spec:
description: AuthStackStatus surfaces the OIDC contract and chart-managed bootstrap secret refs.
type: object
properties:
instanceId:
description: Instance ID observed through the Zitadel API, populated when instanceDiscovery is enabled.
type: string
ready:
description: Overall readiness of the auth stack.
type: boolean
Expand Down
2 changes: 2 additions & 0 deletions functions/render/000-state-init.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@
"labels" $labels
"helmProviderConfigRef" $helmProviderConfigRef
"kubernetesProviderConfigRef" $k8sProviderConfigRef
"systemAPIUsers" ($spec.systemAPIUsers | default list)
"instanceDiscovery" ($spec.instanceDiscovery | default dict)
"domain" $domain
"externalSecure" $externalSecure
"chartVersion" $chartVersion
Expand Down
9 changes: 9 additions & 0 deletions functions/render/010-state-status.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,12 @@
"ready" $smtp.render
)
) }}

# The ConfigMap is written by a read-only metadata probe, not a desired input.
{{- $discoveryEntry := get $observed "instance-metadata" | default dict }}
{{- $instanceId := dig "resource" "status" "atProvider" "manifest" "data" "instanceId" "" $discoveryEntry }}
{{- $_ := set $state.status "instanceId" $instanceId }}

{{- if $state.instanceDiscovery.enabled }}
{{- $_ := set $state.status "ready" (and $relReady (ne $instanceId "")) }}
{{- end }}
28 changes: 28 additions & 0 deletions functions/render/200-helm-release-zitadel.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,34 @@
"env" $dbEnv
}}

# System API users are explicit, environment-neutral inputs. Mount only public
# keys into Zitadel; provider credentials/private keys never enter Helm values.
{{- $systemUsers := dict }}
{{- $systemVolumes := list }}
{{- $systemMounts := list }}
{{- range $user := $state.systemAPIUsers }}
{{- $volumeName := printf "system-api-%s" ($user.id | sha256sum | trunc 16) }}
{{- $mountPath := printf "/system-api/%s" $user.id }}
{{- $config := dict "Path" (printf "%s/public.pem" $mountPath) }}
{{- $memberships := list }}
{{- range ($user.memberships | default list) }}
{{- $membership := dict "MemberType" .memberType "Roles" .roles }}
{{- if .aggregateId }}{{- $_ := set $membership "AggregateID" .aggregateId }}{{- end }}
{{- $memberships = append $memberships $membership }}
{{- end }}
{{- if $memberships }}{{- $_ := set $config "Memberships" $memberships }}{{- end }}
{{- $_ := set $systemUsers $user.id $config }}
{{- $systemVolumes = append $systemVolumes (dict "name" $volumeName "secret" (dict
"secretName" $user.publicKeySecretRef.name
"items" (list (dict "key" $user.publicKeySecretRef.key "path" "public.pem")) )) }}
{{- $systemMounts = append $systemMounts (dict "name" $volumeName "mountPath" $mountPath "readOnly" true) }}
{{- end }}
{{- if $systemUsers }}
{{- $_ := set $cmCfg "SystemAPIUsers" $systemUsers }}
{{- $_ := set $chartDefaults "extraVolumes" $systemVolumes }}
{{- $_ := set $chartDefaults "extraVolumeMounts" $systemMounts }}
{{- end }}

# Gateway API wiring (HTTPRoute + GRPCRoute drive off chart-native fields)
{{- if $gw.enabled }}
{{- $parentRef := dict "name" $gw.parentRef.name }}
Expand Down
Loading
Loading