Releases: hops-ops/auth-stack
v1.3.0
What's changed in v1.3.0
-
feat: add Grant primitive XRD with polymorphic same-Org / cross-Org dispatch (#9) (by @patrickleet)
Second auth-group primitive that survives the "is it just a single-MR
wrapper?" test. Composes 1 or 2 underlying Zitadel MRs depending on
whether the user's home Org matches the project's enclosing Org.Same-Org (spec.userOrgId == spec.projectOrgId): one MR
- user.zitadel.m.crossplane.io/Grant — the user's role assignment
within the project (no projectGrantId)
Cross-Org (spec.userOrgId != spec.projectOrgId): two MRs
- project.zitadel.m.crossplane.io/Grant — the cross-Org Project
Grant authorizing the role set for the user's home Org - user.zitadel.m.crossplane.io/Grant — the user's role assignment
with projectGrantId pointing at the Project Grant, pulling roles
from the granted set
Multi-iter convergence: in cross-Org mode the user/Grant emits only
once the project/Grant's atProvider.id is observed (standard composition
gating per feedback_crossplane_composition_gates).Schema takes flat IDs (userId + userOrgId + projectId + projectOrgId +
roles[]), matching the established convention from Tenant + MachineUser
— operator copies UUIDs from the relevant XR statuses or Zitadel UI.Two examples (same-org + cross-org); 4 KCL CompositionTests covering
same-org, cross-org iter 1, multi-role, managementPolicies propagation.
Multi-iter convergence verified via observed-resources fixture.21/21 KCL tests pass (13 AuthStack + 4 MachineUser + 4 Grant); all 8
examples render via make render:all.Co-authored-by: Claude Opus 4.7 (1M context) noreply@anthropic.com
- user.zitadel.m.crossplane.io/Grant — the user's role assignment
See full diff: v1.2.0...v1.3.0
v1.2.0
What's changed in v1.2.0
-
feat: add MachineUser primitive XRD with opt-in PAT + AWS SM push pipeline (#7) (by @patrickleet)
The first auth-group primitive that survives the "is it just a single-MR
wrapper?" test. Composes 1-4 underlying resources depending on opt-ins:- Always: Zitadel MachineUser MR (machineusers.user.zitadel.m.crossplane.io)
- When spec.pat.enabled (default false): + Zitadel AccessToken MR (the PAT;
connection secret on control-plane K8s under key access_token) - When spec.pat.pushToAwsSm (default false; requires pat.enabled): + AWS
Secrets Manager Secret MR + ESO PushSecret (via provider-kubernetes
Object) that pushes the control-plane K8s Secret's access_token into
AWS SM at the canonical path push/// per
reference_aws_sm_push_tag_convention. Consumer clusters pull via ESO
ExternalSecret.
Composition gating per feedback_crossplane_composition_gates: AccessToken
emits once MachineUser.atProvider.id observed; AWS SM Secret + PushSecret
emit once AccessToken.atProvider.id observed. Standard multi-iteration
Crossplane convergence.PAT generation is opt-in by default — operators explicitly acknowledge
minting a long-lived bearer token. Adoption via spec.machineUserId
propagates as crossplane.io/external-name on the underlying MR.upbound.yaml gains provider-upjet-zitadel >=v0.1.1 and
provider-aws-secretsmanager >=v2.5.0 deps (both required by the
composition; aws-secretsmanager only matters when pushToAwsSm is on).3 examples (minimal, with-pat, with-pat-push) — all render via
up composition render. Multi-iter convergence verified via
--observed-resources fixtures.4 KCL CompositionTests covering: minimal-machineuser-only, pat-enabled-
iter1-still-only-machineuser (gating verification), adopt-existing-
machineuser-via-id, field-overrides. All 17 tests (13 AuthStack + 4
MachineUser) pass underup test run.Makefile EXAMPLES + CI workflows updated for the new examples.
README "Auth-group primitives" table updated: MachineUser ✓, Grant
TO WRITE (next); HumanUser / IDP / OrganizationSsoConfig dropped as
thin wrappers — operators apply raw provider MRs.Co-authored-by: Claude Opus 4.7 (1M context) noreply@anthropic.com
See full diff: v1.1.0...v1.2.0
v1.1.0
What's changed in v1.1.0
-
feat(deps): update crossplane-contrib/function-auto-ready docker tag to v0.6.4 (#1) (by @renovate[bot])
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
See full diff: v1.0.0...v1.1.0
v1.0.0
What's changed in v1.0.0
-
initial: auth-stack with durable+operational secrets pattern (by @patrickleet)
AuthStack XRD wraps the Zitadel Helm chart with a typed XRD surface.
Implements [[specs/authstack-reconciler]] — durable values (masterkey,
admin-password) project from AWS SM via ExternalSecrets, operational
PATs (iam-admin, login-client) re-mintable via Zitadel API by an
in-cluster reconciler CronJob.Composition pieces:
- ExternalSecret + PushSecret pairs for the two operational PATs
- ExternalSecret for masterkey + admin-password
- Reconciler CronJob + namespaced RBAC
- Chart values disable FirstInstance PAT sidecars when reconciler is on
- Zitadel init wired with Admin.ExistingDatabase pinned to the configured
database so VerifyDatabase short-circuits (CNPG app user lacks CREATEDB)
-
fix(state-init): pin reconciler image default to v0.0.1 (by @patrickleet)
The hops-ops/authstack-reconciler repo uses vnext-tag for releases and
doesn't publish a :latest tag. Pin to the first published version;
bump in lockstep with new reconciler releases. -
fix(state-init): bump reconciler image default to v0.0.3 (multi-arch) (by @patrickleet)
-
feat(authstack): add iamAdminMachineKey durable secret (by @patrickleet)
The reconciler's primary credential is the iam-admin JWT machine key —
not a PAT. The chart's setup-job's MachineKey sidecar writes the K8s
Secret `iam-admin` on first install, but it's purely transient — lost
on AuthStack delete, never recreated on subsequent installs (Zitadel
detects existing instance data and skips FirstInstance).This adds the missing piece of the durability pattern from
[[specs/authstack-reconciler]]:- XRD: new `spec.externalSecrets.iamAdminMachineKey.secretPath`.
- State-init: surface as `state.externalSecrets.iamAdminMachineKey`
with K8s Secret name fixed at `iam-admin` (matches chart). - 154-external-secret-iam-admin-key: project from AWS SM to K8s.
- 162-push-secret-iam-admin-key: capture K8s to AWS SM on first
install. - 200-helm-release-zitadel: set FirstInstance.Org.Machine.MachineKey
(ExpirationDate + Type=1) so the chart actually GENERATES the
machine key on first install. Previously we only set Pat. - 250-reconciler-cronjob: RBAC + env wire-up so the reconciler reads
`iam-admin` as MACHINE_KEY_SECRET.
Lifecycle (matches the PATs):
First install → chart writes K8s → PushSecret → AWS SM populated
AuthStack delete → AWS SM keeps it
Re-install → ESO projects back → reconciler authenticates -
feat(authstack): embed PSQLCluster; drop reconciler (by @patrickleet)
Replaces the prior reconciler + capture-restore design with an embedded
PSQLCluster pattern. AuthStack composes a PSQLCluster XR (XR-composing-
XR) with targetNamespace matching the install ns, so CNPG resources land
where Zitadel pods env-var-mount the app Secret natively. Delete cascades
atomically (DB + ns + Secrets); reapply runs FirstInstance from scratch
against a fresh DB, so the reconciler's re-mint path is no longer needed.XRD changes:
- Add spec.database.embedded mirroring PSQLCluster's surface (storage,
postgresql, app, ha, monitoring, sslMode, cnpg passthrough). Defaults
to a 2Gi single-node Postgres. Mutually exclusive with psqlClusterRef
and external. - Remove spec.reconciler subschema.
- Remove spec.externalSecrets.{iamAdminMachineKey,iamAdminPat,
loginClientPat} subschemas. ExternalSecrets retained only for the
durable inputs: masterkey + admin-password.
Composition changes:
- New 120-embedded-psql-cluster.yaml.gotmpl renders the composed
PSQLCluster XR with name -pg. - 100-namespace.yaml.gotmpl: restore full
*ns ownership. - 200-helm-release-zitadel.yaml.gotmpl: unify DB wiring across embedded
and psqlClusterRef modes; chart FirstInstance sidecars left enabled
(they own the operational Secret lifecycle). - Delete reconciler CronJob + RBAC template (250-) and three
iam-admin*/login-client ExternalSecret + matching PushSecret templates
(152-, 153-, 154-, 160-, 161-, 162-).
Tests rewritten: dropped reconciler-pattern tests, added
embedded-composes-psql-cluster-xr test.Verified end-to-end on aws/hops/pat-local: cold start reaches Ready in
~90s; AuthStack delete + reapply cascades cleanly and re-bootstraps in
~90s.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- Add spec.database.embedded mirroring PSQLCluster's surface (storage,
-
feat(authstack): collapse externalSecrets to a single secretPath (by @patrickleet)
BREAKING CHANGE: Both durable values (masterkey + admin-password) now live as JSON
properties under one AWS SM secret instead of two. The XRD's
externalSecrets.{masterkey,adminPassword}.secretPathpair is
replaced by a singleexternalSecrets.secretPath; both ExternalSecret
renders reference that one path with differentproperty:selectors:externalSecrets:
enabled: true
secretPath: /zitadel # AWS SM blob
# { "masterkey": "...",
# "admin-password": "..." }The collapse drops the redundant directory-name=property-name shape
in the SOPS plaintext layout (masterkey/masterkey,
admin-password/password→ flat files under one dir). Operationally
the two values are seeded together byhops auth bootstrapand rotate
together, so grouping them as one secret is the natural fit.BREAKING: existing manifests using
externalSecrets.{masterkey,adminPassword}.secretPathneed to migrate
toexternalSecrets.secretPath. Only consumer in-tree (pat-local) is
updated in the hops parent repo.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
-
fix(authstack): Usage forces Zitadel helm release to drain before PSQLCluster (by @patrickleet)
The chart's helm uninstall pre-delete hooks (init/setup/cleanup jobs)
need DB access. If Crossplane tears down the embedded PSQLCluster XR
(and its composed CNPG Cluster + app Secret) in parallel with the Helm
release, the chart's jobs hang in CreateContainerConfigError ("secret
'-pg-app' not found") and helm uninstall stalls indefinitely
— the release ends up stuck inpending-upgradeand provider-helm
can't make progress.Add a Usage:
by: Release/-zitadel
of: PSQLCluster/-pgso the embedded PSQLCluster is gated on the Helm release's deletion
completing first. Mirrors the existingdelete-zitadel-before-namespace
Usage (both protect theirof:from the Helm release).Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
-
fix(authstack): render login.gateway.httpRoute when spec.gateway.enabled (by @patrickleet)
Zitadel v4 chart ships a separate Next.js login UI Deployment+Service
serving /ui/v2/login. Composition previously only enabled
gateway.httpRoute (main API service); /ui/v2/login fell through to the
main service which returned gRPC code:5 NotFound.Mirror $gw.parentRef + $hostnames into login.gateway.httpRoute alongside
the existing gateway.httpRoute/grpcRoute wiring. Chart-default paths
(/ui/v2/login) handle the route to the login UI service.Verified live on pat-local: pat-local-zitadel-login HTTPRoute renders
with hostname auth.ops.com.ai; curl /ui/v2/login → HTTP/2 200 with the
login UI; full / → /ui/console/ flow returns 200. -
feat(authstack): publish iam-admin PAT to AWS SM via PushSecret (by @patrickleet)
Replaces the in-composition Zitadel ProviderConfig + credentials Secret
with an ESO PushSecret that publishes the chart-managed iam-admin PAT
to AWS Secrets Manager. Downstream consumers (any cluster, any
namespace, any GitOps repo) now author their own ExternalSecret +
ProviderConfig referencing the AWS SM path — AuthStack no longer
composes a PC.Why: in-composition PC creation hit three trade-offs stacked together —
(1) provider-kubernetes SA RBAC has no permission on zitadel.m.crossplane.io
PCs by default, (2) Crossplane v2's CustomToManagedResource conversion
ignoresmetadata.nameon directly-composed CRD resources and derives
<xr-name>-<composition-resource-name>, breaking the user-tunable
spec.providerConfig.name, (3) function-auto-ready can't synthesize a
Ready condition on a PC CRD without an annotation hack. The
publisher/consumer split sidesteps all three: AuthStack publishes once,
consumers own their own PC lifecycle.XRD shape:
- drop
spec.providerConfig.{enabled, scope}(early-alpha field, no
consumers in the wild yet) - add
spec.externalSecrets.pushCredentials.{enabled, path, accessTokenProperty}— defaults to top-level
externalSecrets.enabled; path defaults to
push/<clusterName>/zitadel-credentials - reshape
status.providerConfigto{awsSecretsManagerPath, accessTokenProperty}(consumer wiring contract)
Composition:
- new
300-credentials-pushsecret.yaml.gotmpl: composes a
kubernetes.m.crossplane.io Objectwrapping an
external-secrets.io/v1alpha1 PushSecreton the target cluster - 11 AWS SM tags on every pushed secret for
hops secrets list
routing and programmatic discovery:
hops.ops.com.ai/managed = true
hops.ops.com.ai/secret = true
hops.ops.com.ai/managed-by = pushsecret
hops.ops.com.ai/cluster =
hops.ops.com.ai/namespace =
hops.ops.com.ai/kind = AuthStack
hops.ops.com.ai/name =
hops.ops.com.ai/apiVersion = hops.ops.com.ai/v1alpha1
hops.ops.com.ai/<kind.lower> = (K8s-selector parity)
managed-by = external-secrets (ESO auto-add)
Consumer reference: `examples/consumer-providercon...
- drop