diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml index 90203bc..ad6233a 100644 --- a/.github/workflows/on-pr.yaml +++ b/.github/workflows/on-pr.yaml @@ -34,6 +34,8 @@ jobs: [ { "example": "examples/authstacks/minimal.yaml", "api_path": "apis/authstacks" }, { "example": "examples/authstacks/standard.yaml", "api_path": "apis/authstacks" }, + { "example": "examples/authstacks/with-smtp.yaml", "api_path": "apis/authstacks" }, + { "example": "examples/authstacks/with-instance.yaml", "api_path": "apis/authstacks" }, { "example": "examples/authstacks/local-colima.yaml", "api_path": "apis/authstacks" }, { "example": "examples/machineusers/minimal.yaml", "api_path": "apis/machineusers" }, { "example": "examples/machineusers/with-pat.yaml", "api_path": "apis/machineusers" }, @@ -41,14 +43,30 @@ jobs: { "example": "examples/humanusers/explicit-org.yaml", "api_path": "apis/humanusers" }, { "example": "examples/humanusers/with-org-ref.yaml", "api_path": "apis/humanusers" }, { "example": "examples/oidcclients/storybook-preview.yaml", "api_path": "apis/oidcclients" }, + { "example": "examples/oidcclients/storybook-preview.yaml", "api_path": "apis/oidcclients", "observed_resources": "tests/test-oidcclient/observed/ready.yaml" }, + { "example": "examples/grants/referenced-same-org.yaml", "api_path": "apis/grants" }, { "example": "examples/grants/same-org.yaml", "api_path": "apis/grants" }, - { "example": "examples/grants/cross-org.yaml", "api_path": "apis/grants" } + { "example": "examples/grants/cross-org.yaml", "api_path": "apis/grants" }, + { "example": "examples/grants/cross-org.yaml", "api_path": "apis/grants", "observed_resources": "tests/test-grant/observed/cross-org-iter2.yaml" } ] error_on_missing_schemas: true test: uses: hops-ops/workflows-crossplane/.github/workflows/test.yaml@v3.2.0 + instance-observation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: upbound/action-up@v1.2.0 + with: + skip-login: true + - uses: upbound/action-up-project@v1.2.0 + with: + skip-login-check: true + - run: python3 -m pip install PyYAML==6.0.3 + - run: make test-security + e2e: uses: hops-ops/workflows-crossplane/.github/workflows/e2e.yaml@v3.2.0 @@ -56,6 +74,7 @@ jobs: needs: - validate - test + - instance-observation uses: hops-ops/workflows-crossplane/.github/workflows/publish.yaml@v3.2.0 secrets: GH_PAT: ${{ secrets.GH_PAT }} diff --git a/.github/workflows/on-push-main.yaml b/.github/workflows/on-push-main.yaml index adb1d51..72633cc 100644 --- a/.github/workflows/on-push-main.yaml +++ b/.github/workflows/on-push-main.yaml @@ -29,6 +29,8 @@ jobs: [ { "example": "examples/authstacks/minimal.yaml", "api_path": "apis/authstacks" }, { "example": "examples/authstacks/standard.yaml", "api_path": "apis/authstacks" }, + { "example": "examples/authstacks/with-smtp.yaml", "api_path": "apis/authstacks" }, + { "example": "examples/authstacks/with-instance.yaml", "api_path": "apis/authstacks" }, { "example": "examples/authstacks/local-colima.yaml", "api_path": "apis/authstacks" }, { "example": "examples/machineusers/minimal.yaml", "api_path": "apis/machineusers" }, { "example": "examples/machineusers/with-pat.yaml", "api_path": "apis/machineusers" }, @@ -36,14 +38,30 @@ jobs: { "example": "examples/humanusers/explicit-org.yaml", "api_path": "apis/humanusers" }, { "example": "examples/humanusers/with-org-ref.yaml", "api_path": "apis/humanusers" }, { "example": "examples/oidcclients/storybook-preview.yaml", "api_path": "apis/oidcclients" }, + { "example": "examples/oidcclients/storybook-preview.yaml", "api_path": "apis/oidcclients", "observed_resources": "tests/test-oidcclient/observed/ready.yaml" }, + { "example": "examples/grants/referenced-same-org.yaml", "api_path": "apis/grants" }, { "example": "examples/grants/same-org.yaml", "api_path": "apis/grants" }, - { "example": "examples/grants/cross-org.yaml", "api_path": "apis/grants" } + { "example": "examples/grants/cross-org.yaml", "api_path": "apis/grants" }, + { "example": "examples/grants/cross-org.yaml", "api_path": "apis/grants", "observed_resources": "tests/test-grant/observed/cross-org-iter2.yaml" } ] error_on_missing_schemas: true test: uses: hops-ops/workflows-crossplane/.github/workflows/test.yaml@v3.2.0 + instance-observation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: upbound/action-up@v1.2.0 + with: + skip-login: true + - uses: upbound/action-up-project@v1.2.0 + with: + skip-login-check: true + - run: python3 -m pip install PyYAML==6.0.3 + - run: make test-security + e2e: uses: hops-ops/workflows-crossplane/.github/workflows/e2e.yaml@v3.2.0 @@ -52,6 +70,7 @@ jobs: needs: - validate - test + - instance-observation - e2e uses: unbounded-tech/workflow-vnext-tag/.github/workflows/workflow.yaml@v1.22.3 secrets: diff --git a/Makefile b/Makefile index 66a61bd..94d605a 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,7 @@ EXAMPLES := \ examples/authstacks/standard.yaml:: \ examples/authstacks/local-colima.yaml:: \ examples/authstacks/with-smtp.yaml:: \ + examples/authstacks/with-instance.yaml:: \ examples/machineusers/minimal.yaml:: \ examples/machineusers/with-pat.yaml:: \ examples/machineusers/with-pat-push.yaml:: \ @@ -40,75 +41,37 @@ EXAMPLES := \ examples/grants/cross-org.yaml:: \ examples/grants/cross-org.yaml::tests/test-grant/observed/cross-org-iter2.yaml -# Render all examples (parallel execution, output shown per-job when complete) +# Render serially: up updates shared project metadata/schema caches during build. render\:all: - @tmpdir=$$(mktemp -d); \ - pids=""; \ + @set -e; \ for entry in $(EXAMPLES); do \ - example=$${entry%%::*}; \ - observed=$${entry#*::}; \ + example=$${entry%%::*}; observed=$${entry#*::}; \ api_dir=$$(echo "$$example" | awk -F/ '{print "apis/" $$2}'); \ - composition="$$api_dir/composition.yaml"; \ - definition="$$api_dir/definition.yaml"; \ - outfile="$$tmpdir/$$(echo $$entry | tr '/:' '__')"; \ - ( \ - if [ -n "$$observed" ]; then \ - echo "=== Rendering $$example with observed-resources $$observed ==="; \ - up composition render --xrd=$$definition $$composition $$example --observed-resources=$$observed; \ - else \ - echo "=== Rendering $$example (api=$$api_dir) ==="; \ - up composition render --xrd=$$definition $$composition $$example; \ - fi; \ - echo "" \ - ) > "$$outfile" 2>&1 & \ - pids="$$pids $$!:$$outfile"; \ - done; \ - failed=0; \ - for pair in $$pids; do \ - pid=$${pair%%:*}; \ - outfile=$${pair#*:}; \ - if ! wait $$pid; then failed=1; fi; \ - cat "$$outfile"; \ - done; \ - rm -rf "$$tmpdir"; \ - exit $$failed + echo "Rendering $$example"; \ + if [ -n "$$observed" ]; then \ + up composition render --xrd=$$api_dir/definition.yaml $$api_dir/composition.yaml $$example --observed-resources=$$observed; \ + else \ + up composition render --xrd=$$api_dir/definition.yaml $$api_dir/composition.yaml $$example; \ + fi; \ + done -# Validate all examples (parallel execution, output shown per-job when complete) -validate\:all: - @tmpdir=$$(mktemp -d); \ - pids=""; \ +# Include dependency metadata and all XRDs when loading validation schemas. +.PHONY: generate-configuration +generate-configuration: + hops validate generate-configuration --path . --api-path $(XRD_DIR) --no-gitignore-update + +validate\:all: generate-configuration + @set -e -o pipefail; \ for entry in $(EXAMPLES); do \ - example=$${entry%%::*}; \ - observed=$${entry#*::}; \ + example=$${entry%%::*}; observed=$${entry#*::}; \ api_dir=$$(echo "$$example" | awk -F/ '{print "apis/" $$2}'); \ - composition="$$api_dir/composition.yaml"; \ - definition="$$api_dir/definition.yaml"; \ - outfile="$$tmpdir/$$(echo $$entry | tr '/:' '__')"; \ - ( \ - if [ -n "$$observed" ]; then \ - echo "=== Validating $$example with observed-resources $$observed ==="; \ - up composition render --xrd=$$definition $$composition $$example \ - --observed-resources=$$observed --include-full-xr --quiet | \ - crossplane resource validate $$api_dir --error-on-missing-schemas -; \ - else \ - echo "=== Validating $$example (api=$$api_dir) ==="; \ - up composition render --xrd=$$definition $$composition $$example \ - --include-full-xr --quiet | \ - crossplane resource validate $$api_dir --error-on-missing-schemas -; \ - fi; \ - echo "" \ - ) > "$$outfile" 2>&1 & \ - pids="$$pids $$!:$$outfile"; \ - done; \ - failed=0; \ - for pair in $$pids; do \ - pid=$${pair%%:*}; \ - outfile=$${pair#*:}; \ - if ! wait $$pid; then failed=1; fi; \ - cat "$$outfile"; \ - done; \ - rm -rf "$$tmpdir"; \ - exit $$failed + echo "Validating $$example"; \ + if [ -n "$$observed" ]; then \ + up composition render --xrd=$$api_dir/definition.yaml $$api_dir/composition.yaml $$example --observed-resources=$$observed --include-full-xr --quiet; \ + else \ + up composition render --xrd=$$api_dir/definition.yaml $$api_dir/composition.yaml $$example --include-full-xr --quiet; \ + fi | crossplane resource validate apis --error-on-missing-schemas -; \ + done # Shorthand aliases .PHONY: clean build test e2e publish render validate @@ -120,16 +83,16 @@ render\:%: @example="examples/authstacks/$*.yaml"; \ up composition render --xrd=$(DEFINITION) $(COMPOSITION) $$example -validate\:%: - @example="examples/authstacks/$*.yaml"; \ +validate\:%: generate-configuration + @set -e -o pipefail; example="examples/authstacks/$*.yaml"; \ up composition render --xrd=$(DEFINITION) $(COMPOSITION) $$example \ --include-full-xr --quiet | \ - crossplane resource validate $(XRD_DIR) --error-on-missing-schemas - + crossplane resource validate apis --error-on-missing-schemas - test: up test run $(RENDER_TESTS) -# Includes rejection cases that CompositionTest cannot express. +# Native observation readiness and migration regressions; requires PyYAML. .PHONY: test-security test-security: python3 -m unittest discover -s tests/security -v diff --git a/README.md b/README.md index e313a6c..b797cab 100644 --- a/README.md +++ b/README.md @@ -179,46 +179,66 @@ 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 `..svc` or `..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). +Set `instanceDiscovery.enabled: true` and select an existing Zitadel +ProviderConfig. AuthStack composes a namespaced, read-only `Instance` using +provider-upjet-zitadel **v0.3.0 or newer**: + +```yaml +spec: + instanceDiscovery: + enabled: true + providerConfigRef: + name: zitadel-admin + kind: ClusterProviderConfig +``` + +The ProviderConfig owns authentication, endpoint, TLS and instance headers. It +must target the instance installed by this AuthStack; AuthStack cannot verify +that an arbitrary consumer-owned endpoint belongs to its Helm release. Provision +credentials with ESO, and publish chart-generated PATs through PushSecret before +waiting for native observation. For first install, wait for the composed Helm +Release, establish the ProviderConfig, then wait for AuthStack readiness. Do not +wait for AuthStack readiness before creating its observer's credentials. + +Successful, current-generation observation publishes `status.instanceId` and +`status.instanceRef: {name, namespace}`. Domain resources can use the named +observer directly, without copying the ID or importing an external name: + +```yaml +apiVersion: instance.zitadel.m.crossplane.io/v1alpha1 +kind: TrustedDomain +metadata: + name: browser + namespace: preview +spec: + forProvider: + instanceIdRef: + name: identity-instance # AuthStack metadata.name + "-instance" + namespace: platform # AuthStack namespace + policy: {resolve: Always} + domain: preview.example.com + providerConfigRef: + name: zitadel-admin + kind: ClusterProviderConfig +``` + +`instanceIdSelector` is also supported by the provider; use a unique label match +when consumers should discover the observer rather than name it explicitly. +The observer retries API failures and reads rotated credentials on reconciliation. +It binds the first observed ID and refuses a different ID until deliberately +recreated. Deleting it does not delete Zitadel; Helm still owns that lifecycle. +A Usage keeps the Helm release available until the observer has been deleted. + +### Migrating existing instance discovery + +This changes only the **opt-in discovery API** introduced previously. Replace +`internalURL`, `allowInsecureHTTP`, `caCertSecretRef` and `image` with +`providerConfigRef`. Configure transport in that ProviderConfig instead; an +old discovery configuration without the new reference is rejected. The Job, +ConfigMap, ServiceAccount and RBAC are removed by composition reconciliation. +`enabled` and `status.instanceId` remain; `status.instanceRef` is additive. +AuthStack consumers with discovery disabled retain their installation behavior. +No localhost, plaintext transport or secret backend is defaulted by this API. +The package dependency now requires provider v0.3.0+, so upgrade that provider +before applying this configuration. Recreate only the observer when intentionally +replacing an installed instance; preserve Helm/database resources otherwise. diff --git a/apis/authstacks/definition.yaml b/apis/authstacks/definition.yaml index 53c0719..dd5d8cb 100644 --- a/apis/authstacks/definition.yaml +++ b/apis/authstacks/definition.yaml @@ -69,33 +69,20 @@ spec: 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. + description: Opt-in continuous, read-only observation of the installed Zitadel instance using provider-upjet-zitadel v0.3.0 or newer. The supplied ProviderConfig must authenticate to this stack's instance. 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. + providerConfigRef: + description: Existing Zitadel ProviderConfig in the XR namespace, or ClusterProviderConfig. Credentials and transport are owned by that provider configuration. type: object - required: [name, key] + required: [name, kind] properties: name: {type: string, minLength: 1} - key: {type: string, minLength: 1} - image: - type: string - default: python:3.13.7-alpine3.22 + kind: {type: string, enum: [ProviderConfig, ClusterProviderConfig]} 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 + - rule: '!self.enabled || has(self.providerConfigRef)' + message: instanceDiscovery.providerConfigRef is required when enabled clusterName: description: Name of the target cluster. Used as default for provider config refs. type: string @@ -538,6 +525,12 @@ spec: description: AuthStackStatus surfaces the OIDC contract and chart-managed bootstrap secret refs. type: object properties: + instanceRef: + description: Observed namespaced Instance resource for CustomDomain/TrustedDomain instanceIdRef. Populated together with instanceId after a successful current-generation observation. + type: object + properties: + name: {type: string} + namespace: {type: string} instanceId: description: Instance ID observed through the Zitadel API, populated when instanceDiscovery is enabled. type: string diff --git a/examples/authstacks/with-instance.yaml b/examples/authstacks/with-instance.yaml new file mode 100644 index 0000000..224994f --- /dev/null +++ b/examples/authstacks/with-instance.yaml @@ -0,0 +1,17 @@ +apiVersion: hops.ops.com.ai/v1alpha1 +kind: AuthStack +metadata: + name: identity + namespace: platform +spec: + clusterName: platform + domain: auth.example.com + firstInstance: + masterkey: + secretRef: {name: zitadel-masterkey} + database: + external: + dsnSecretRef: {name: zitadel-db, key: dsn} + instanceDiscovery: + enabled: true + providerConfigRef: {name: zitadel-admin, kind: ClusterProviderConfig} diff --git a/functions/render/010-state-status.yaml.gotmpl b/functions/render/010-state-status.yaml.gotmpl index a81bba7..32dbe34 100644 --- a/functions/render/010-state-status.yaml.gotmpl +++ b/functions/render/010-state-status.yaml.gotmpl @@ -149,11 +149,19 @@ ) ) }} -# 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 }} +# Publish only verified non-secret provider metadata, never the old Job cache. +{{- $instance := dig "instance" "resource" dict $observed }} +{{- $instanceReady := false }} +{{- $instanceSynced := false }} +{{- range (dig "status" "conditions" list $instance) }} + {{- if and (eq .type "Ready") (eq .status "True") }}{{- $instanceReady = true }}{{- end }} + {{- if and (eq .type "Synced") (eq .status "True") (ge (int (.observedGeneration | default 0)) (int (dig "metadata" "generation" 0 $instance))) }}{{- $instanceSynced = true }}{{- end }} +{{- end }} +{{- $instanceId := "" }} +{{- if and $instanceReady $instanceSynced (not (dig "metadata" "deletionTimestamp" "" $instance)) }} + {{- $instanceId = dig "status" "atProvider" "id" "" $instance }} +{{- end }} {{- $_ := set $state.status "instanceId" $instanceId }} - {{- if $state.instanceDiscovery.enabled }} -{{- $_ := set $state.status "ready" (and $relReady (ne $instanceId "")) }} + {{- $_ := set $state.status "ready" (and $relReady (ne $instanceId "")) }} {{- end }} diff --git a/functions/render/210-instance-discovery.yaml.gotmpl b/functions/render/210-instance-discovery.yaml.gotmpl index 5a95ca1..d6ed5f2 100644 --- a/functions/render/210-instance-discovery.yaml.gotmpl +++ b/functions/render/210-instance-discovery.yaml.gotmpl @@ -1,165 +1,41 @@ -# Read-only API discovery. The Job can patch only its metadata ConfigMap. -# No domain/user/project API writes and no credential values in status. +# Native observation uses the consumer-owned ProviderConfig. Credentials never +# pass through this composition, and observing/deleting this resource cannot +# create or delete the Helm-installed Zitadel instance. {{- if $state.instanceDiscovery.enabled }} -{{- if not $state.firstInstance.enabled }}{{- fail "instanceDiscovery requires firstInstance.enabled" }}{{- end }} -{{- $discoveryName := printf "%s-instance" $state.name }} -{{- $ns := $state.namespace }} -# Resolve the chart's Service name using its fullname helper rules. An origin -# supplied by a consumer must not redirect the admin PAT to another service. -{{- $discoveryValues := $state.chartValues }} -{{- if $state.overrideAllValues }}{{- $discoveryValues = $state.overrideAllValues }}{{- end }} -{{- $releaseName := printf "%s-zitadel" $state.name }} -{{- $chartName := $discoveryValues.nameOverride | default "zitadel" }} -{{- $serviceName := $releaseName }} -{{- if not (contains $chartName $releaseName) }}{{- $serviceName = printf "%s-%s" $releaseName $chartName }}{{- end }} -{{- $serviceName = ($discoveryValues.fullnameOverride | default $serviceName) | trunc 63 | trimSuffix "-" }} -{{- $servicePort := dig "service" "port" 8080 $discoveryValues | int }} -{{- $authority := printf "%s.%s.svc:%d" $serviceName $ns $servicePort }} -{{- $fqdnAuthority := printf "%s.%s.svc.cluster.local:%d" $serviceName $ns $servicePort }} -{{- $url := $state.instanceDiscovery.internalURL | default "" }} -{{- $allowed := list (printf "https://%s" $authority) (printf "https://%s" $fqdnAuthority) }} -{{- if $state.instanceDiscovery.allowInsecureHTTP }} - {{- $allowed = concat $allowed (list (printf "http://%s" $authority) (printf "http://%s" $fqdnAuthority)) }} -{{- end }} -{{- if not (has $url $allowed) }}{{- fail "instanceDiscovery.internalURL must target this stack's Zitadel Service and port over HTTPS (HTTP requires explicit allowInsecureHTTP)" }}{{- end }} -{{- $ca := $state.instanceDiscovery.caCertSecretRef | default dict }} -{{- $jobRevision := dict "domain" $state.domain "url" $state.instanceDiscovery.internalURL "image" ($state.instanceDiscovery.image | default "python:3.13.7-alpine3.22") "admin" $state.firstInstance.iamAdmin.username "ca" $ca "transportVersion" 2 | toJson | sha256sum | trunc 8 }} -{{- $sa := dict "apiVersion" "v1" "kind" "ServiceAccount" "metadata" (dict "name" $discoveryName "namespace" $ns) }} -{{- $cm := dict "apiVersion" "v1" "kind" "ConfigMap" "metadata" (dict "name" $discoveryName "namespace" $ns) }} -{{- $role := dict "apiVersion" "rbac.authorization.k8s.io/v1" "kind" "Role" "metadata" (dict "name" $discoveryName "namespace" $ns) - "rules" (list (dict "apiGroups" (list "") "resources" (list "configmaps") "resourceNames" (list $discoveryName) "verbs" (list "get" "patch"))) }} -{{- $binding := dict "apiVersion" "rbac.authorization.k8s.io/v1" "kind" "RoleBinding" "metadata" (dict "name" $discoveryName "namespace" $ns) - "roleRef" (dict "apiGroup" "rbac.authorization.k8s.io" "kind" "Role" "name" $discoveryName) - "subjects" (list (dict "kind" "ServiceAccount" "name" $discoveryName "namespace" $ns)) }} -{{- range $key, $manifest := dict "instance-serviceaccount" $sa "instance-metadata" $cm "instance-role" $role "instance-rolebinding" $binding }} +{{- $pc := $state.instanceDiscovery.providerConfigRef | default dict }} +{{- if not (and $pc.name $pc.kind) }}{{- fail "instanceDiscovery.providerConfigRef is required when enabled" }}{{- end }} --- -apiVersion: kubernetes.m.crossplane.io/v1alpha1 -kind: Object +apiVersion: instance.zitadel.m.crossplane.io/v1alpha1 +kind: Instance metadata: - name: {{ $state.name }}-{{ $key }} + name: {{ $state.name }}-instance annotations: - {{ setResourceNameAnnotation $key }} + {{ setResourceNameAnnotation "instance" }} + gotemplating.fn.crossplane.io/ready: {{ ternary "True" "False" (ne $state.status.instanceId "") | quote }} labels: {{ $state.labels | toJson }} spec: - {{- if eq $key "instance-metadata" }} - # The probe owns data. Crossplane creates and observes but never overwrites it. - {{- if has "*" $state.managementPolicies }} - managementPolicies: [Create, Observe, Delete] - {{- else }} - managementPolicies: {{ without $state.managementPolicies "Update" "LateInitialize" | toJson }} - {{- end }} - readiness: - policy: DeriveFromCelQuery - celQuery: 'has(object.data) && "instanceId" in object.data && object.data.instanceId != ""' - {{- else }} - managementPolicies: {{ $state.managementPolicies | toJson }} - {{- end }} - forProvider: - manifest: {{ $manifest | toJson }} - providerConfigRef: {{ $state.kubernetesProviderConfigRef | toJson }} -{{- end }} + managementPolicies: [Observe] + providerConfigRef: + name: {{ $pc.name }} + kind: {{ $pc.kind }} +{{- if and $state.observed.helmRelease.ready (ne $state.status.instanceId "") }} --- -apiVersion: kubernetes.m.crossplane.io/v1alpha1 -kind: Object +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage metadata: - name: {{ $state.name }}-instance-discovery + name: {{ $state.name }}-delete-instance-before-zitadel annotations: - {{ setResourceNameAnnotation "instance-discovery" }} + {{ setResourceNameAnnotation "usage-instance-zitadel" }} labels: {{ $state.labels | toJson }} spec: - managementPolicies: {{ $state.managementPolicies | toJson }} - forProvider: - manifest: - apiVersion: batch/v1 - kind: Job - metadata: - # Endpoint, image, domain, admin username or CA reference changes create a new Job. - # PAT rotation alone does not change the Job name. - name: {{ printf "%s-discover-%s" $state.name $jobRevision }} - namespace: {{ $ns }} - spec: - # Missing PAT Secrets can hold the pod pending throughout first install. - # Bound execution with the script retry budget, not a wall-clock deadline - # that expires before the chart has even created the credential. - backoffLimit: 3 - template: - spec: - serviceAccountName: {{ $discoveryName }} - restartPolicy: OnFailure - securityContext: - runAsNonRoot: true - runAsUser: 65532 - fsGroup: 65532 - seccompProfile: {type: RuntimeDefault} - containers: - - name: discover - image: {{ $state.instanceDiscovery.image | default "python:3.13.7-alpine3.22" }} - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: {drop: [ALL]} - resources: - requests: {cpu: 10m, memory: 24Mi} - limits: {cpu: 100m, memory: 64Mi} - env: - - {name: ZITADEL_URL, value: {{ $state.instanceDiscovery.internalURL | quote }}} - - {name: ZITADEL_CA_FILE, value: {{ ternary "/zitadel-ca/ca.crt" "" (not (empty $ca)) | quote }}} - - {name: ZITADEL_HOST, value: {{ $state.domain | quote }}} - - {name: METADATA_NAME, value: {{ $discoveryName | quote }}} - command: [python3, -c] - args: - - | - import json, os, pathlib, ssl, time, urllib.request - base = pathlib.Path('/var/run/secrets/kubernetes.io/serviceaccount') - namespace = (base / 'namespace').read_text().strip() - tls = ssl.create_default_context(cafile=str(base / 'ca.crt')) - class NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, req, fp, code, msg, headers, newurl): - raise ValueError('discovery redirects are forbidden') - # Verify certificates and Service DNS names; never inherit proxies. - zitadel_tls = ssl.create_default_context(cafile=os.environ.get('ZITADEL_CA_FILE') or None) - opener = urllib.request.build_opener( - urllib.request.ProxyHandler({}), NoRedirect(), - urllib.request.HTTPSHandler(context=zitadel_tls)) - api_opener = urllib.request.build_opener( - urllib.request.ProxyHandler({}), NoRedirect(), - urllib.request.HTTPSHandler(context=tls)) - for attempt in range(110): - try: - pat = pathlib.Path('/zitadel-pat/pat').read_text().strip() - request = urllib.request.Request(os.environ['ZITADEL_URL'].rstrip('/') + '/admin/v1/instances/me', headers={ - 'Authorization': 'Bearer ' + pat, 'Host': os.environ['ZITADEL_HOST']}) - with opener.open(request, timeout=10) as response: - instance_id = json.load(response)['instance']['id'] - if not instance_id: - raise ValueError('empty instance id') - url = 'https://kubernetes.default.svc/api/v1/namespaces/' + namespace + '/configmaps/' + os.environ['METADATA_NAME'] - request = urllib.request.Request(url, method='PATCH', data=json.dumps({'data': {'instanceId': instance_id}}).encode(), headers={ - 'Authorization': 'Bearer ' + (base / 'token').read_text().strip(), 'Content-Type': 'application/merge-patch+json'}) - with api_opener.open(request, timeout=10) as response: - response.read() - print('Instance metadata published') - break - except Exception as error: - # Never print requests, bodies, or bearer credentials. - print('Waiting for instance metadata: ' + type(error).__name__, flush=True) - time.sleep(5) - else: - raise SystemExit('Instance discovery did not converge') - volumeMounts: - - {name: admin-pat, mountPath: /zitadel-pat, readOnly: true} - {{- if $ca }} - - {name: zitadel-ca, mountPath: /zitadel-ca, readOnly: true} - {{- end }} - volumes: - - name: admin-pat - secret: - secretName: {{ printf "%s-pat" $state.firstInstance.iamAdmin.username }} - {{- if $ca }} - - name: zitadel-ca - secret: - secretName: {{ $ca.name }} - items: [{key: {{ $ca.key | quote }}, path: ca.crt}] - {{- end }} - providerConfigRef: {{ $state.kubernetesProviderConfigRef | toJson }} + replayDeletion: true + of: + apiVersion: helm.m.crossplane.io/v1beta1 + kind: Release + resourceRef: {name: {{ $state.name }}-zitadel} + by: + apiVersion: instance.zitadel.m.crossplane.io/v1alpha1 + kind: Instance + resourceRef: {name: {{ $state.name }}-instance} +{{- end }} {{- end }} diff --git a/functions/render/999-status.yaml.gotmpl b/functions/render/999-status.yaml.gotmpl index 04352a6..8129340 100644 --- a/functions/render/999-status.yaml.gotmpl +++ b/functions/render/999-status.yaml.gotmpl @@ -10,9 +10,10 @@ apiVersion: {{ $xr.apiVersion }} kind: {{ $xr.kind }} status: ready: {{ $s.ready }} - {{- if $s.instanceId }} instanceId: {{ $s.instanceId | quote }} - {{- end }} + instanceRef: + name: {{ ternary (dig "instance" "resource" "metadata" "name" "" ($.observed.resources | default dict)) "" (ne $s.instanceId "") | quote }} + namespace: {{ ternary $state.xrNamespace "" (ne $s.instanceId "") | quote }} oidc: issuerURL: {{ $s.oidc.issuerURL | quote }} discoveryURL: {{ $s.oidc.discoveryURL | quote }} diff --git a/tests/security/test_discovery.py b/tests/security/test_discovery.py deleted file mode 100644 index 3269f6c..0000000 --- a/tests/security/test_discovery.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Credential destination regressions; run with python3 -m unittest discover -s tests/security. - -Requires the same Docker/up setup as render tests. UP_COMMAND can name a wrapper -for running up inside a container on hosts with a remote Docker daemon. -""" -import ast -import json -import os -from pathlib import Path -import shlex -import ssl -import subprocess -import tempfile -import textwrap -import unittest -import urllib.request - -ROOT = Path(__file__).resolve().parents[2] - - -class DiscoveryOriginTests(unittest.TestCase): - def render(self, url, insecure=False): - claim = { - 'apiVersion': 'hops.ops.com.ai/v1alpha1', 'kind': 'AuthStack', - 'metadata': {'name': 'identity'}, - 'spec': { - 'namespace': 'identity', 'domain': 'auth.example.com', - 'firstInstance': {'masterkey': {'secretRef': {'name': 'masterkey'}}}, - 'database': {'external': {'dsnSecretRef': {'name': 'db', 'key': 'dsn'}}}, - 'instanceDiscovery': { - 'enabled': True, 'internalURL': url, 'allowInsecureHTTP': insecure, - }, - }, - } - # Keep the input under the source root so containerized renderers see it. - with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as directory: - path = Path(directory) / 'claim.json' - path.write_text(json.dumps(claim)) - command = shlex.split(os.environ.get('UP_COMMAND', 'up')) - return subprocess.run(command + [ - 'composition', 'render', 'apis/authstacks/composition.yaml', str(path), - ], cwd=ROOT, capture_output=True, text=True, timeout=180) - - @classmethod - def setUpClass(cls): - (ROOT / '.tmp').mkdir(exist_ok=True) - - def test_untrusted_origins_fail_before_rendering_the_pat_job(self): - for url, insecure in [ - ('https://attacker.example:8080', False), - ('https://other.identity.svc:8080', False), - ('https://identity-zitadel.other.svc:8080', False), - ('https://identity-zitadel.identity.svc:8443', False), - ('https://identity-zitadel.identity.svc:8080@attacker.example', False), - ('https://identity-zitadel.identity.svc:8080/path', False), - ('http://identity-zitadel.identity.svc:8080', False), - ('http://attacker.identity.svc:8080', True), - ]: - with self.subTest(url=url, insecure=insecure): - result = self.render(url, insecure) - self.assertNotEqual(result.returncode, 0, result.stdout) - self.assertIn("must target this stack's Zitadel Service", result.stderr + result.stdout) - - -class DiscoveryTransportTests(unittest.TestCase): - def setUp(self): - template = (ROOT / 'functions/render/210-instance-discovery.yaml.gotmpl').read_text() - script = template.split(' - |\n', 1)[1].split(' volumeMounts:', 1)[0] - tree = ast.parse(textwrap.dedent(script)) - # Execute the actual Job transport setup, excluding filesystem/credential - # reads and the API retry loop. No network or real PAT is needed here. - self.scope = {'ssl': ssl, 'urllib': __import__('urllib'), 'os': os, - 'tls': ssl.create_default_context()} - setup = [] - for node in tree.body: - if isinstance(node, ast.ClassDef) or ( - isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name) - and node.targets[0].id in ('zitadel_tls', 'opener', 'api_opener') - ): - setup.append(node) - from unittest.mock import patch - with patch.dict(os.environ, {'ZITADEL_CA_FILE': '', 'https_proxy': 'http://attacker:8080'}): - exec(compile(ast.Module(body=setup, type_ignores=[]), '', 'exec'), self.scope) - - def test_tls_verifies_service_identity(self): - self.assertTrue(self.scope['zitadel_tls'].check_hostname) - self.assertEqual(self.scope['zitadel_tls'].verify_mode, ssl.CERT_REQUIRED) - - def test_redirects_cannot_forward_bearer_headers(self): - handler = self.scope['NoRedirect']() - request = urllib.request.Request('https://identity-zitadel.identity.svc:8080', - headers={'Authorization': 'Bearer fake-test-token'}) - for status in (301, 302, 303, 307, 308): - with self.subTest(status=status), self.assertRaises(ValueError): - handler.redirect_request(request, None, status, '', {}, 'https://attacker.example') - - def test_proxy_environment_is_ignored_for_both_credentials(self): - for name in ('opener', 'api_opener'): - self.assertFalse(any(isinstance(handler, urllib.request.ProxyHandler) - and handler.proxies for handler in self.scope[name].handlers)) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/security/test_instance_observation.py b/tests/security/test_instance_observation.py new file mode 100644 index 0000000..c28c144 --- /dev/null +++ b/tests/security/test_instance_observation.py @@ -0,0 +1,109 @@ +"""Native observation migration and readiness regressions.""" +import json +import os +from pathlib import Path +import shlex +import subprocess +import tempfile +import unittest +import yaml + +ROOT = Path(__file__).resolve().parents[2] + +class InstanceObservationTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + (ROOT / '.tmp').mkdir(exist_ok=True) + + def render(self, enabled=True, observed=None, config=None, first_instance=True): + xr = {'apiVersion': 'hops.ops.com.ai/v1alpha1', 'kind': 'AuthStack', + 'metadata': {'name': 'identity', 'namespace': 'platform'}, + 'spec': {'domain': 'auth.example.com', + 'firstInstance': {'masterkey': {'secretRef': {'name': 'masterkey'}}}, + 'database': {'external': {'dsnSecretRef': {'name': 'db', 'key': 'dsn'}}}}} + if not first_instance: + xr['spec']['firstInstance']['enabled'] = False + if enabled: + xr['spec']['instanceDiscovery'] = config or { + 'enabled': True, 'providerConfigRef': {'name': 'admin', 'kind': 'ClusterProviderConfig'}} + with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as directory: + path = Path(directory) + (path / 'xr.json').write_text(json.dumps(xr)) + command = shlex.split(os.environ.get('UP_COMMAND', 'up')) + [ + 'composition', 'render', '--quiet', 'apis/authstacks/composition.yaml', str(path / 'xr.json')] + if observed: + directory = path / 'observed' + directory.mkdir() + for index, obj in enumerate(observed): + (directory / f'{index}.yaml').write_text(yaml.safe_dump(obj)) + command += ['--observed-resources', str(directory)] + result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, timeout=180) + return result, list(yaml.safe_load_all(result.stdout)) if result.returncode == 0 else [] + + def observation(self, synced='True', generation=3, observed_generation=3): + return [ + {'apiVersion': 'helm.m.crossplane.io/v1beta1', 'kind': 'Release', + 'metadata': {'name': 'identity-zitadel', 'namespace': 'platform', 'annotations': {'crossplane.io/composition-resource-name': 'helm-release-zitadel'}}, + 'status': {'conditions': [{'type': 'Ready', 'status': 'True'}]}}, + {'apiVersion': 'instance.zitadel.m.crossplane.io/v1alpha1', 'kind': 'Instance', + 'metadata': {'name': 'identity-instance', 'namespace': 'platform', 'generation': generation, + 'annotations': {'crossplane.io/composition-resource-name': 'instance'}}, + 'status': {'atProvider': {'id': 'installed-instance'}, 'conditions': [ + {'type': 'Ready', 'status': 'True'}, + {'type': 'Synced', 'status': synced, 'observedGeneration': observed_generation}]}} + ] + + def test_disabled_does_not_render_native_or_probe_resources(self): + result, docs = self.render(enabled=False) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(any(d.get('kind') == 'Instance' for d in docs)) + self.assertFalse(any('instance-' in d.get('metadata', {}).get('name', '') for d in docs)) + + def test_pending_keeps_observer_without_credential_or_job_wiring(self): + result, docs = self.render() + self.assertEqual(result.returncode, 0, result.stderr) + instance = next(d for d in docs if d.get('kind') == 'Instance') + self.assertEqual(instance['spec'], {'managementPolicies': ['Observe'], + 'providerConfigRef': {'name': 'admin', 'kind': 'ClusterProviderConfig'}}) + self.assertFalse(any(d.get('spec', {}).get('forProvider', {}).get('manifest', {}).get('kind') + in ['Job', 'ConfigMap', 'Role', 'RoleBinding', 'ServiceAccount'] for d in docs)) + status = next(d['status'] for d in docs if d.get('kind') == 'AuthStack') + self.assertFalse(status['ready']) + self.assertEqual(status['instanceId'], '') + + def test_ready_publishes_typed_id_and_reference_with_usage(self): + result, docs = self.render(observed=self.observation()) + self.assertEqual(result.returncode, 0, result.stderr) + status = next(d['status'] for d in docs if d.get('kind') == 'AuthStack') + self.assertTrue(status['ready']) + self.assertEqual(status['instanceId'], 'installed-instance') + self.assertEqual(status['instanceRef'], {'name': 'identity-instance', 'namespace': 'platform'}) + usage = next(d for d in docs if d.get('metadata', {}).get('name') == 'identity-delete-instance-before-zitadel') + self.assertEqual(usage['spec']['of']['resourceRef']['name'], 'identity-zitadel') + self.assertEqual(usage['spec']['by']['resourceRef']['name'], 'identity-instance') + + def test_failure_stale_and_deleting_observations_clear_status_and_keep_observer(self): + variants = [self.observation(synced='False'), self.observation(observed_generation=2), self.observation()] + variants[-1][1]['metadata']['deletionTimestamp'] = '2026-10-02T00:00:00Z' + for observed in variants: + with self.subTest(observed=observed[1]['metadata']): + result, docs = self.render(observed=observed) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(any(d.get('kind') == 'Instance' for d in docs)) + status = next(d['status'] for d in docs if d.get('kind') == 'AuthStack') + self.assertFalse(status['ready']) + condition = next(c for c in status['conditions'] if c['type'] == 'Ready') + self.assertEqual(condition['status'], 'False') + self.assertRegex(condition['message'], r'(?=v0.3.0' - apiVersion: pkg.crossplane.io/v1 kind: Provider package: xpkg.crossplane.io/crossplane-contrib/provider-aws-secretsmanager