diff --git a/README.md b/README.md index 32510e5..870fd19 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,12 @@ separate iterations tracked under GitKB `tasks/tenant-stack-*`. namespaces the user owns as a Tenant. **capsule-proxy itself does NOT validate JWTs** — see "Auth integration" below. - **AuthStack integration** (optional, off by default) — when - `spec.auth.enabled: true`, TenantStack composes a namespaced Zitadel - `ProviderConfig` and a Zitadel `Oidc` Application MR for tenant - kubectl users (`kubectl oidc-login`). The OIDC client's `client_id` - lands in a Crossplane connection Secret tenants pull from to build - their kubeconfig. **Requires a one-time `zitadel-credentials` Secret - bootstrap** on the Crossplane cluster — see "Auth integration" below. + `spec.auth.enabled: true`, TenantStack composes a Zitadel `Oidc` + Application MR for tenant kubectl users (`kubectl oidc-login`). The + OIDC client's `client_id` lands in a Crossplane connection Secret + tenants pull from to build their kubeconfig. **Requires a + pre-existing Zitadel `ProviderConfig`** in the TenantStack namespace on + the Crossplane cluster. The default reference is `ProviderConfig/default`. ## What's NOT (yet) included @@ -107,47 +107,29 @@ spec: When `spec.auth.enabled: true`, TenantStack composes: -1. A namespaced Zitadel `ProviderConfig` (`zitadel-tenant-stack`) that - consumes a pre-bootstrapped credentials Secret named - `zitadel-credentials` (see Bootstrap below). -2. A Zitadel `Oidc` Application MR provisioning the OIDC App in Zitadel +1. A Zitadel `Oidc` Application MR provisioning the OIDC App in Zitadel under `spec.auth.zitadelProjectId`. -3. Crossplane writes the issued `client_id` + `client_secret` to the +2. Crossplane writes the issued `client_id` + `client_secret` to the Secret named in `status.auth.oidcClientSecretRef`. -### Bootstrap: the `zitadel-credentials` Secret +TenantStack expects a namespaced Zitadel `ProviderConfig` named +`default` to already exist in the TenantStack namespace on the +**Crossplane cluster**. Override `spec.auth.zitadelProviderConfigRef` when +the ProviderConfig has a different name. The ProviderConfig and any +credentials Secret it references are owned by platform bootstrap, not by +TenantStack. -The Zitadel provider's ProviderConfig needs a credentials JSON in a K8s -Secret on the **Crossplane cluster** (the cluster running the Zitadel -provider's controllers — `colima` in the hops-ops topology). The shape: +If that separate ProviderConfig uses a credentials Secret, the JSON shape +is: ```json { "access_token": "", "domain": "auth.ops.com.ai", "port": "443", "insecure": false } ``` -**Why TenantStack does NOT auto-compose this Secret**: AuthStack's -iam-admin PAT Secret lives on the workload cluster; ESO (with AWS SM -read access via IRSA) runs there. Crossplane runs on the control-plane -cluster. Crossplane's provider-kubernetes Object MRs are bound to a -single ProviderConfig context, so cross-cluster Secret sync requires -either ESO on the control-plane cluster OR an out-of-band copy. Both -are operator-managed concerns outside this stack's scope. - -The simplest bootstrap (one-time, per cluster): - -```sh -# Pull the PAT off the workload cluster, strip trailing newline. -PAT=$(kubectl --context pat-local get secret -n zitadel iam-admin-pat \ - -o jsonpath='{.data.pat}' | base64 -d | tr -d '\n') - -# Build the credentials JSON. -CREDS=$(printf '{"access_token":"%s","domain":"auth.ops.com.ai","port":"443","insecure":false}' "$PAT") - -# Drop the Secret on the control-plane cluster (where the Zitadel -# provider's controllers run), in the same namespace as the TenantStack. -kubectl --context colima create secret generic zitadel-credentials \ - -n default --from-literal=credentials="$CREDS" -``` +**Why TenantStack does NOT auto-compose this ProviderConfig**: AuthStack's +iam-admin PAT Secret lives on the workload cluster; ESO runs there. +Crossplane runs on the control-plane cluster. That cross-cluster +bootstrap is a platform concern outside this stack's scope. After reconcile: @@ -241,7 +223,7 @@ issuer and map them via: - [ ] AuthStack installed and Ready - [ ] AuthStack `status.oidc.issuerURL` known (copy into spec.auth.issuerURL) - [ ] A Zitadel Project exists for OIDC apps; capture its ID -- [ ] `zitadel-credentials` Secret bootstrapped on the Crossplane cluster (see Bootstrap above) +- [ ] `ProviderConfig/default` exists in the TenantStack namespace on the Crossplane cluster, or `spec.auth.zitadelProviderConfigRef` points at the one you created - [ ] kube-apiserver OIDC IdP association is wired (or oauth2-proxy is deployed) ## Standard usage diff --git a/apis/tenantstacks/definition.yaml b/apis/tenantstacks/definition.yaml index 44ef081..17d094f 100644 --- a/apis/tenantstacks/definition.yaml +++ b/apis/tenantstacks/definition.yaml @@ -213,24 +213,19 @@ spec: auth: description: | Cross-stack AuthStack integration. When `enabled: true`, - TenantStack composes a Zitadel ProviderConfig + Oidc - Application MR for tenant kubectl users - (`kubectl oidc-login`). The resulting client_id lands in a + TenantStack composes a Zitadel Oidc Application MR for + tenant kubectl users (`kubectl oidc-login`). The resulting + client_id lands in a Crossplane connection Secret tenants pull from to build their kubeconfig. Prerequisites: 1. AuthStack must be installed and Ready (this is what publishes the OIDC issuer at spec.auth.issuerURL). - 2. A K8s Secret named `zitadel-credentials` must exist in - the TenantStack's namespace ON THE CROSSPLANE CLUSTER - (the cluster where the Zitadel provider's controllers - run, not necessarily the workload cluster), containing - a single key `credentials` with the provider creds JSON - ({access_token, domain, port, insecure}). For the hops - colima+pat-local topology run - `hops auth zitadel-credentials sync` (or copy from - AuthStack's iam-admin-pat Secret manually). + 2. The configured Zitadel ProviderConfig must already exist + in the TenantStack namespace on the Crossplane cluster. + TenantStack references it but does not create it. Defaults + to `ProviderConfig/default`. 3. The Zitadel Project under which to provision OIDC apps must already exist; capture its ID via the Zitadel management API or UI and pass via spec.auth.zitadelProjectId. @@ -253,6 +248,17 @@ spec: zitadelProjectId: description: Zitadel Project ID under which to provision the OIDC Application. Capture from the Zitadel UI ("Projects → → details"), or compose a Project MR out-of-band and pass its ID here. Required when auth.enabled. type: string + zitadelProviderConfigRef: + description: Reference to the Zitadel ProviderConfig used to provision the OIDC Application. Defaults to ProviderConfig/default in the TenantStack namespace. + type: object + properties: + name: + type: string + kind: + type: string + enum: + - ProviderConfig + - ClusterProviderConfig oidcClient: description: OIDC Application shape provisioned in Zitadel for tenant kubectl users (`kubectl oidc-login`). type: object diff --git a/examples/tenantstacks/with-auth.yaml b/examples/tenantstacks/with-auth.yaml index a0abba5..6057313 100644 --- a/examples/tenantstacks/with-auth.yaml +++ b/examples/tenantstacks/with-auth.yaml @@ -15,9 +15,8 @@ spec: usernameClaim: preferred_username auth: enabled: true - # Copy from AuthStack status.oidc.issuerURL — surfaced in TenantStack - # status for tenant kubeconfig snippets. Does NOT directly drive the - # ProviderConfig (the PC reads creds from the pre-bootstrapped Secret). + # Copy from AuthStack status.oidc.issuerURL. TenantStack expects a + # separately-created Zitadel ProviderConfig named default. issuerURL: https://auth.ops.com.ai # Pre-existing Zitadel Project ID (capture from the Zitadel UI or via # `curl POST $issuerURL/management/v1/projects` with the iam-admin PAT). diff --git a/functions/render/000-state-init.yaml.gotmpl b/functions/render/000-state-init.yaml.gotmpl index 268693b..2b28bbc 100644 --- a/functions/render/000-state-init.yaml.gotmpl +++ b/functions/render/000-state-init.yaml.gotmpl @@ -101,6 +101,11 @@ {{- end }} {{- $authIssuerURL := $auth.issuerURL | default "" }} {{- $authProjectId := $auth.zitadelProjectId | default "" }} +{{- $authZitadelPCR := $auth.zitadelProviderConfigRef | default dict }} +{{- $authZitadelPCR = dict + "name" ($authZitadelPCR.name | default "default") + "kind" ($authZitadelPCR.kind | default "ProviderConfig") +}} {{- $authOidc := $auth.oidcClient | default dict }} {{- $authOidc = dict "name" ($authOidc.name | default "capsule-proxy") @@ -140,6 +145,7 @@ "enabled" $authEnabled "issuerURL" $authIssuerURL "zitadelProjectId" $authProjectId + "zitadelProviderConfigRef" $authZitadelPCR "oidcClient" $authOidc ) "aws" (dict diff --git a/functions/render/120-zitadel-provider-config.yaml.gotmpl b/functions/render/120-zitadel-provider-config.yaml.gotmpl deleted file mode 100644 index 8ed971e..0000000 --- a/functions/render/120-zitadel-provider-config.yaml.gotmpl +++ /dev/null @@ -1,33 +0,0 @@ -# code: language=yaml -# -# Zitadel ProviderConfig (namespaced) — consumes the credentials Secret -# the ExternalSecret in 110-* materializes. Used by the Oidc Application -# MR in 300-* to talk to Zitadel. -# -# Renders only when spec.auth.enabled. Sits in the XR's namespace; the -# Oidc MR in the same namespace references it by name. -# -# Reference: xrs/stacks/k8s/auth/examples/consumer-providerconfig.yaml. -# Naming pinned to "zitadel-tenant-stack" rather than the AuthStack -# example's bare "zitadel" so a single cluster running multiple -# auth-consumer XRs doesn't collide on PC name. -# - -{{- $a := $state.auth }} -{{- if $a.enabled }} ---- -apiVersion: zitadel.m.crossplane.io/v1beta1 -kind: ProviderConfig -metadata: - name: zitadel-tenant-stack - annotations: - {{ setResourceNameAnnotation "zitadel-provider-config" }} - labels: {{ $state.labels | toJson }} -spec: - credentials: - source: Secret - secretRef: - name: zitadel-credentials - namespace: {{ $state.namespace }} - key: credentials -{{- end }} diff --git a/functions/render/300-zitadel-oidc-app.yaml.gotmpl b/functions/render/300-zitadel-oidc-app.yaml.gotmpl index 3e03bba..1e2a06f 100644 --- a/functions/render/300-zitadel-oidc-app.yaml.gotmpl +++ b/functions/render/300-zitadel-oidc-app.yaml.gotmpl @@ -80,6 +80,6 @@ spec: name: {{ $a.oidcClient.name }}-oidc-client providerConfigRef: - name: zitadel-tenant-stack - kind: ProviderConfig + name: {{ $a.zitadelProviderConfigRef.name }} + kind: {{ $a.zitadelProviderConfigRef.kind }} {{- end }} diff --git a/tests/test-render/main.k b/tests/test-render/main.k index 49ed71e..a622318 100644 --- a/tests/test-render/main.k +++ b/tests/test-render/main.k @@ -279,8 +279,8 @@ _items = [ } } } - # The Oidc App + ProviderConfig render, but the EKS IdP MR - # does NOT because the gate requires a non-empty observed + # The Oidc App renders, but the EKS IdP MR does NOT because + # the gate requires a non-empty observed # connectionDetails["attribute.client_id"]. Asserting on # Zitadel resources keeps the test small; absence of the # IdentityProviderConfig is verified by it not appearing in @@ -296,15 +296,12 @@ _items = [ } # ========================================================================== - # Test 8: auth.enabled composes Zitadel ProviderConfig + Oidc App MR. - # The credentials Secret is operator-managed (pre-bootstrapped on the - # Crossplane cluster) — TenantStack does not auto-compose ESO sync - # because Crossplane provider-kubernetes Object MRs are bound to a - # single ProviderConfig context (no clean cross-cluster Secret copy - # primitive). See README "Auth integration" for the bootstrap step. + # Test 8: auth.enabled composes the Zitadel Oidc App MR. + # The Zitadel ProviderConfig is created separately in the Crossplane + # cluster namespace and referenced by name here. # ========================================================================== metav1alpha1.CompositionTest { - metadata.name = "auth-enabled-composes-pc-and-oidc-app" + metadata.name = "auth-enabled-composes-oidc-app" spec = { compositionPath = "apis/tenantstacks/composition.yaml" xrdPath = "apis/tenantstacks/definition.yaml" @@ -327,19 +324,6 @@ _items = [ } } assertResources = [ - { - apiVersion = "zitadel.m.crossplane.io/v1beta1" - kind = "ProviderConfig" - metadata.name = "zitadel-tenant-stack" - spec.credentials = { - source = "Secret" - secretRef = { - name = "zitadel-credentials" - namespace = "pat-local" - key = "credentials" - } - } - } { apiVersion = "application.zitadel.m.crossplane.io/v1alpha1" kind = "Oidc" @@ -353,7 +337,47 @@ _items = [ } spec.writeConnectionSecretToRef.name = "capsule-proxy-oidc-client" spec.providerConfigRef = { - name = "zitadel-tenant-stack" + name = "default" + kind = "ProviderConfig" + } + } + ] + } + } + + # ========================================================================== + # Test 9: auth.zitadelProviderConfigRef overrides the default ProviderConfig. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "auth-zitadel-provider-config-ref-override" + spec = { + compositionPath = "apis/tenantstacks/composition.yaml" + xrdPath = "apis/tenantstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.TenantStack { + metadata.name = "tenant" + metadata.namespace = "pat-local" + spec = { + clusterName = "pat-local" + auth = { + enabled = True + issuerURL = "https://auth.ops.com.ai" + zitadelProjectId = "316732890294485506" + zitadelProviderConfigRef = { + name = "tenant-zitadel" + kind = "ProviderConfig" + } + } + } + } + assertResources = [ + { + apiVersion = "application.zitadel.m.crossplane.io/v1alpha1" + kind = "Oidc" + metadata.name = "capsule-proxy" + spec.providerConfigRef = { + name = "tenant-zitadel" kind = "ProviderConfig" } }