From 4639046a4dc4416785bace93d45adf75f1966c40 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 1 Sep 2026 12:11:20 -0500 Subject: [PATCH] feat: add namespaced OIDC client XR --- .github/workflows/on-pr.yaml | 1 + .github/workflows/on-push-main.yaml | 1 + Makefile | 2 + README.md | 27 +++- apis/oidcclients/composition.yaml | 16 ++ apis/oidcclients/definition.yaml | 136 ++++++++++++++++ examples/oidcclients/storybook-preview.yaml | 21 +++ .../oidcclient/000-state-init.yaml.gotmpl | 41 +++++ .../oidcclient/010-state-status.yaml.gotmpl | 9 ++ ...050-kubernetes-provider-config.yaml.gotmpl | 19 +++ .../100-bootstrap-credentials.yaml.gotmpl | 51 ++++++ .../200-provider-config.yaml.gotmpl | 19 +++ .../300-oidc-application.yaml.gotmpl | 35 +++++ functions/oidcclient/999-status.yaml.gotmpl | 9 ++ tests/test-oidcclient/kcl.mod | 6 + tests/test-oidcclient/main.k | 146 ++++++++++++++++++ tests/test-oidcclient/model | 1 + tests/test-oidcclient/observed/ready.yaml | 47 ++++++ upbound.yaml | 5 +- 19 files changed, 586 insertions(+), 6 deletions(-) create mode 100644 apis/oidcclients/composition.yaml create mode 100644 apis/oidcclients/definition.yaml create mode 100644 examples/oidcclients/storybook-preview.yaml create mode 100644 functions/oidcclient/000-state-init.yaml.gotmpl create mode 100644 functions/oidcclient/010-state-status.yaml.gotmpl create mode 100644 functions/oidcclient/050-kubernetes-provider-config.yaml.gotmpl create mode 100644 functions/oidcclient/100-bootstrap-credentials.yaml.gotmpl create mode 100644 functions/oidcclient/200-provider-config.yaml.gotmpl create mode 100644 functions/oidcclient/300-oidc-application.yaml.gotmpl create mode 100644 functions/oidcclient/999-status.yaml.gotmpl create mode 100644 tests/test-oidcclient/kcl.mod create mode 100644 tests/test-oidcclient/main.k create mode 120000 tests/test-oidcclient/model create mode 100644 tests/test-oidcclient/observed/ready.yaml diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml index 0e3e216..90203bc 100644 --- a/.github/workflows/on-pr.yaml +++ b/.github/workflows/on-pr.yaml @@ -40,6 +40,7 @@ jobs: { "example": "examples/machineusers/with-pat-push.yaml","api_path": "apis/machineusers" }, { "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/grants/same-org.yaml", "api_path": "apis/grants" }, { "example": "examples/grants/cross-org.yaml", "api_path": "apis/grants" } ] diff --git a/.github/workflows/on-push-main.yaml b/.github/workflows/on-push-main.yaml index 081b072..2f4de55 100644 --- a/.github/workflows/on-push-main.yaml +++ b/.github/workflows/on-push-main.yaml @@ -35,6 +35,7 @@ jobs: { "example": "examples/machineusers/with-pat-push.yaml","api_path": "apis/machineusers" }, { "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/grants/same-org.yaml", "api_path": "apis/grants" }, { "example": "examples/grants/cross-org.yaml", "api_path": "apis/grants" } ] diff --git a/Makefile b/Makefile index eb2cb4f..0a3a9ee 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,8 @@ EXAMPLES := \ examples/machineusers/with-pat-push.yaml:: \ examples/humanusers/explicit-org.yaml:: \ examples/humanusers/with-org-ref.yaml:: \ + examples/oidcclients/storybook-preview.yaml:: \ + examples/oidcclients/storybook-preview.yaml::tests/test-oidcclient/observed/ready.yaml \ examples/grants/referenced-same-org.yaml:: \ examples/grants/same-org.yaml:: \ examples/grants/cross-org.yaml:: \ diff --git a/README.md b/README.md index 1050eaa..970e2f1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # auth-stack -Installs Zitadel into a Kubernetes cluster as the platform identity provider, and hosts a focused set of `auth.hops.ops.com.ai` primitive XRDs (`HumanUser`, `MachineUser`, `Grant`, `IDP`) that compose against the installed Zitadel. +Installs Zitadel into a Kubernetes cluster as the platform identity provider, and hosts a focused set of `auth.hops.ops.com.ai` primitive XRDs (`HumanUser`, `MachineUser`, `Grant`, `OIDCClient`) that compose against the installed Zitadel. The stack XRD (`AuthStack`) wraps the upstream `zitadel/zitadel` Helm chart — handling the namespace, database wiring, Gateway API routing, and re-projecting chart-managed bootstrap secrets (admin PAT + login-client PAT) into XR status for downstream consumers. @@ -82,7 +82,7 @@ status: ## Auth-group primitives -Per [[specs/identity-architecture]], the auth-group primitive XRDs that have substantive composition value-add — `HumanUser`, `MachineUser`, `Grant` — live in this repo alongside `AuthStack` under the `auth.hops.ops.com.ai` group. +Per [[specs/identity-architecture]], the auth-group primitive XRDs that have substantive composition value-add — `HumanUser`, `MachineUser`, `Grant`, `OIDCClient` — live in this repo alongside `AuthStack` under the `auth.hops.ops.com.ai` group. Status: @@ -91,6 +91,7 @@ Status: | `HumanUser` | `humanusers` | One provider `HumanUser` with organization-ID reference resolution | ✓ | | `MachineUser` | `machineusers` | `MachineUser` + opt-in `AccessToken` + opt-in AWS SM `Secret` + ESO `PushSecret` (provider-kubernetes Object) | ✓ | | `Grant` | `grants` | `user.zitadel.../Grant` (same-Org) or `project.zitadel.../Grant + user.zitadel.../Grant` with `projectGrantId` (cross-Org) | ✓ | +| `OIDCClient` | `oidcclients` | provider-kubernetes ESO bridge + namespaced Zitadel ProviderConfig + OIDC application + connection Secret | ✓ | Single-resource wrappers we deliberately didn't make: `IDP`, `OrganizationSsoConfig` (and the previously-attempted `Organization`, `Project`). Operators apply raw Zitadel / OpenPanel MRs directly for those. @@ -132,15 +133,33 @@ Polymorphic dispatch then picks the right Zitadel mechanism: See `examples/grants/{referenced-same-org,same-org,cross-org}.yaml`. +### `OIDCClient` + +Declarative Zitadel web client for a namespaced consumer. `OIDCClient` reads an +existing provider bootstrap token through an ExternalSecret applied by +provider-kubernetes, creates a same-namespace Zitadel ProviderConfig and OIDC +application, and writes the generated client ID and secret to the +consumer-selected Secret name. Exact redirect URIs are required because Zitadel +does not support wildcard callback URIs. + +The XR assumes External Secrets, provider-kubernetes, +provider-upjet-zitadel, and its referenced SecretStore are already installed. +Missing dependencies leave the XR unready; they never produce an +unauthenticated fallback. See +`examples/oidcclients/storybook-preview.yaml`. + ## Cross-Stack Integration -The intent is for consumer stacks (gitops/ArgoCD, observe/Grafana, the-website) to wire to AuthStack's status surface rather than configuring OIDC manually. Today, those consumers still need a Zitadel OIDC application created out-of-band (via the Zitadel UI/API) and a client ID/secret provided to them. Once the Zitadel Crossplane provider lands, consumer stacks can declaratively create OIDC applications by referencing `status.bootstrap.iamAdminPatSecretRef`. +Consumer stacks (gitops/ArgoCD, observe/Grafana, the-website) should wire to +AuthStack's status surface rather than configuring OIDC manually. Namespaced +workloads can use `OIDCClient` when they need an independently owned web client +and connection Secret. Larger stacks can continue composing Zitadel managed +resources directly when they already own the provider lifecycle. See [[specs/auth-stack-zitadel]] for the design and open questions. ## Out of Scope -- Per-app OIDC client creation (lives with the Zitadel API or the future Zitadel Crossplane provider). - Istio `RequestAuthentication` / `AuthorizationPolicy` (per-app concern, may land later). - Consumer migration and decommission work is tracked separately. diff --git a/apis/oidcclients/composition.yaml b/apis/oidcclients/composition.yaml new file mode 100644 index 0000000..ca58a65 --- /dev/null +++ b/apis/oidcclients/composition.yaml @@ -0,0 +1,16 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: oidcclients.auth.hops.ops.com.ai +spec: + compositeTypeRef: + apiVersion: auth.hops.ops.com.ai/v1alpha1 + kind: OIDCClient + mode: Pipeline + pipeline: + - functionRef: + name: hops-ops-auth-stackoidcclient + step: oidcclient + - functionRef: + name: crossplane-contrib-function-auto-ready + step: crossplane-contrib-function-auto-ready diff --git a/apis/oidcclients/definition.yaml b/apis/oidcclients/definition.yaml new file mode 100644 index 0000000..96de679 --- /dev/null +++ b/apis/oidcclients/definition.yaml @@ -0,0 +1,136 @@ +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: oidcclients.auth.hops.ops.com.ai +spec: + group: auth.hops.ops.com.ai + names: + kind: OIDCClient + plural: oidcclients + scope: Namespaced + versions: + - name: v1alpha1 + referenceable: true + served: true + schema: + openAPIV3Schema: + description: | + OIDCClient creates a namespaced Zitadel web application from an + AWS Secrets Manager bootstrap credential and writes the generated + client ID and secret into the same namespace for its consumer. + type: object + properties: + spec: + description: OIDCClientSpec defines the desired Zitadel client. + type: object + properties: + projectId: + description: Zitadel project UUID that owns the application. + type: string + minLength: 1 + applicationName: + description: Zitadel display name. Defaults to metadata.name. + type: string + minLength: 1 + redirectUris: + description: Exact OIDC callback URIs. Zitadel does not support wildcard redirects. + type: array + minItems: 1 + uniqueItems: true + items: + type: string + format: uri + postLogoutRedirectUris: + description: Exact destinations allowed after logout. + type: array + uniqueItems: true + items: + type: string + format: uri + connectionSecretRef: + description: Secret written in the OIDCClient namespace with generated client credentials. + type: object + properties: + name: + type: string + minLength: 1 + required: + - name + credentials: + description: Bootstrap credential used by provider-upjet-zitadel. + type: object + properties: + domain: + description: Zitadel API hostname without a URL scheme. + type: string + minLength: 1 + secretStoreRef: + description: External Secrets store containing the bootstrap token. + type: object + properties: + name: + type: string + minLength: 1 + kind: + type: string + enum: + - SecretStore + - ClusterSecretStore + default: ClusterSecretStore + required: + - name + remoteRef: + description: AWS Secrets Manager path and property for the Zitadel access token. + type: object + properties: + key: + type: string + minLength: 1 + property: + type: string + minLength: 1 + default: access_token + required: + - key + required: + - domain + - secretStoreRef + - remoteRef + refreshInterval: + description: ExternalSecret refresh interval. Defaults to 1h. + type: string + default: 1h + managementPolicies: + description: Crossplane managementPolicies for the Zitadel application. + type: array + items: + type: string + default: ["*"] + labels: + description: Labels merged onto composed resources. + type: object + additionalProperties: + type: string + x-kubernetes-preserve-unknown-fields: true + x-kubernetes-validations: + - rule: 'self.redirectUris.all(uri, !uri.contains("*"))' + message: redirectUris must be exact and cannot contain wildcards + - rule: '!has(self.postLogoutRedirectUris) || self.postLogoutRedirectUris.all(uri, !uri.contains("*"))' + message: postLogoutRedirectUris must be exact and cannot contain wildcards + required: + - projectId + - redirectUris + - connectionSecretRef + - credentials + status: + description: Observed Zitadel application state. + type: object + properties: + applicationId: + description: Observed Zitadel application UUID. + type: string + clientId: + description: Observed public OIDC client ID when reported by the provider. + type: string + required: + - spec diff --git a/examples/oidcclients/storybook-preview.yaml b/examples/oidcclients/storybook-preview.yaml new file mode 100644 index 0000000..37c2dbd --- /dev/null +++ b/examples/oidcclients/storybook-preview.yaml @@ -0,0 +1,21 @@ +apiVersion: auth.hops.ops.com.ai/v1alpha1 +kind: OIDCClient +metadata: + name: gitkb-storybook-pr-381 + namespace: gitkb-storybook-pr-381 +spec: + projectId: "388072041921654063" + redirectUris: + - https://gitkb-storybook-pr-381.p0.gitkb.com/oauth2/callback + postLogoutRedirectUris: + - https://gitkb-storybook-pr-381.p0.gitkb.com/ + connectionSecretRef: + name: gitkb-storybook-oidc-client + credentials: + domain: auth.p0.gitkb.com + secretStoreRef: + name: aws-secrets-manager + kind: ClusterSecretStore + remoteRef: + key: push/production/zitadel-credentials + property: access_token diff --git a/functions/oidcclient/000-state-init.yaml.gotmpl b/functions/oidcclient/000-state-init.yaml.gotmpl new file mode 100644 index 0000000..76464ca --- /dev/null +++ b/functions/oidcclient/000-state-init.yaml.gotmpl @@ -0,0 +1,41 @@ +# code: language=yaml +# +# A consumer owns one namespaced XR and receives one namespaced connection +# Secret. The cluster must already provide ESO, provider-upjet-zitadel, and the +# referenced SecretStore; absence leaves the XR unready rather than degrading +# to unauthenticated access. + +{{- $xr := getCompositeResource . }} +{{- $spec := $xr.spec | default dict }} +{{- $credentials := $spec.credentials }} +{{- $remoteRef := $credentials.remoteRef }} +{{- $storeRef := $credentials.secretStoreRef }} +{{- $metadata := $xr.metadata | default dict }} +{{- $name := $metadata.name }} +{{- $namespace := $metadata.namespace | default "default" }} +{{- $applicationName := $spec.applicationName | default $name }} +{{- $providerSecretName := printf "%s-zitadel-credentials" $name | trunc 63 | trimSuffix "-" }} +{{- $defaultLabels := dict + "hops.ops.com.ai/managed" "true" + "hops.ops.com.ai/auth-oidc-client" $name +}} + +{{- $state := dict + "spec" $spec + "name" $name + "namespace" $namespace + "applicationName" $applicationName + "providerSecretName" $providerSecretName + "kubernetesProviderConfigName" (printf "%s-kubernetes" $name | trunc 63 | trimSuffix "-") + "providerConfigName" $name + "connectionSecretName" $spec.connectionSecretRef.name + "domain" $credentials.domain + "storeName" $storeRef.name + "storeKind" ($storeRef.kind | default "ClusterSecretStore") + "remoteKey" $remoteRef.key + "remoteProperty" ($remoteRef.property | default "access_token") + "refreshInterval" ($spec.refreshInterval | default "1h") + "managementPolicies" ($spec.managementPolicies | default (list "*")) + "labels" (merge (dict) $defaultLabels ($spec.labels | default dict)) + "status" (dict) +}} diff --git a/functions/oidcclient/010-state-status.yaml.gotmpl b/functions/oidcclient/010-state-status.yaml.gotmpl new file mode 100644 index 0000000..9daea77 --- /dev/null +++ b/functions/oidcclient/010-state-status.yaml.gotmpl @@ -0,0 +1,9 @@ +# code: language=yaml + +{{- $observed := $.observed.resources | default dict }} +{{- $oidcEntry := get $observed "oidc" | default dict }} +{{- $oidcResource := $oidcEntry.resource | default dict }} +{{- $atProvider := ($oidcResource.status | default dict).atProvider | default dict }} + +{{- $_ := set $state.status "applicationId" ($atProvider.id | default "") }} +{{- $_ := set $state.status "clientId" ($atProvider.clientId | default "") }} diff --git a/functions/oidcclient/050-kubernetes-provider-config.yaml.gotmpl b/functions/oidcclient/050-kubernetes-provider-config.yaml.gotmpl new file mode 100644 index 0000000..19a023b --- /dev/null +++ b/functions/oidcclient/050-kubernetes-provider-config.yaml.gotmpl @@ -0,0 +1,19 @@ +# code: language=yaml +# +# provider-kubernetes applies the ExternalSecret manifest without requiring the +# auth package to vendor the ESO CRD schema. The provider runs in-cluster and is +# scoped by the platform's existing provider RBAC. + +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: {{ $state.kubernetesProviderConfigName }} + namespace: {{ $state.namespace | quote }} + annotations: + {{ setResourceNameAnnotation "kubernetes-providerconfig" }} + gotemplating.fn.crossplane.io/ready: "True" + labels: {{ $state.labels | toJson }} +spec: + credentials: + source: InjectedIdentity diff --git a/functions/oidcclient/100-bootstrap-credentials.yaml.gotmpl b/functions/oidcclient/100-bootstrap-credentials.yaml.gotmpl new file mode 100644 index 0000000..297d873 --- /dev/null +++ b/functions/oidcclient/100-bootstrap-credentials.yaml.gotmpl @@ -0,0 +1,51 @@ +# code: language=yaml +# +# Assemble the provider's credentials JSON without placing token bytes in the +# XR, Composition, or Git repository. + +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.providerSecretName }} + namespace: {{ $state.namespace | quote }} + annotations: + {{ setResourceNameAnnotation "credentials" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: external-secrets.io/v1 + kind: ExternalSecret + metadata: + name: {{ $state.providerSecretName | quote }} + namespace: {{ $state.namespace | quote }} + labels: {{ $state.labels | toJson }} + spec: + refreshInterval: {{ $state.refreshInterval | quote }} + secretStoreRef: + name: {{ $state.storeName | quote }} + kind: {{ $state.storeKind | quote }} + target: + name: {{ $state.providerSecretName | quote }} + creationPolicy: Owner + deletionPolicy: Retain + template: + engineVersion: v2 + data: + credentials: | + { + "access_token": "{{`{{ .access_token | trim }}`}}", + "domain": {{ $state.domain | quote }}, + "port": "443", + "insecure": false + } + data: + - secretKey: access_token + remoteRef: + key: {{ $state.remoteKey | quote }} + property: {{ $state.remoteProperty | quote }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigName | quote }} + kind: ProviderConfig diff --git a/functions/oidcclient/200-provider-config.yaml.gotmpl b/functions/oidcclient/200-provider-config.yaml.gotmpl new file mode 100644 index 0000000..5475341 --- /dev/null +++ b/functions/oidcclient/200-provider-config.yaml.gotmpl @@ -0,0 +1,19 @@ +# code: language=yaml + +--- +apiVersion: zitadel.m.crossplane.io/v1beta1 +kind: ProviderConfig +metadata: + name: {{ $state.providerConfigName }} + namespace: {{ $state.namespace | quote }} + annotations: + {{ setResourceNameAnnotation "providerconfig" }} + gotemplating.fn.crossplane.io/ready: "True" + labels: {{ $state.labels | toJson }} +spec: + credentials: + source: Secret + secretRef: + name: {{ $state.providerSecretName | quote }} + namespace: {{ $state.namespace | quote }} + key: credentials diff --git a/functions/oidcclient/300-oidc-application.yaml.gotmpl b/functions/oidcclient/300-oidc-application.yaml.gotmpl new file mode 100644 index 0000000..da14510 --- /dev/null +++ b/functions/oidcclient/300-oidc-application.yaml.gotmpl @@ -0,0 +1,35 @@ +# code: language=yaml + +--- +apiVersion: application.zitadel.m.crossplane.io/v1alpha1 +kind: Oidc +metadata: + name: {{ $state.name }} + namespace: {{ $state.namespace | quote }} + annotations: + {{ setResourceNameAnnotation "oidc" }} + labels: {{ $state.labels | toJson }} +spec: + forProvider: + name: {{ $state.applicationName | quote }} + projectId: {{ $state.spec.projectId | quote }} + appType: OIDC_APP_TYPE_WEB + authMethodType: OIDC_AUTH_METHOD_TYPE_BASIC + grantTypes: + - OIDC_GRANT_TYPE_AUTHORIZATION_CODE + - OIDC_GRANT_TYPE_REFRESH_TOKEN + responseTypes: + - OIDC_RESPONSE_TYPE_CODE + redirectUris: {{ $state.spec.redirectUris | toJson }} + postLogoutRedirectUris: {{ $state.spec.postLogoutRedirectUris | default (list) | toJson }} + accessTokenType: OIDC_TOKEN_TYPE_BEARER + accessTokenRoleAssertion: true + idTokenRoleAssertion: true + idTokenUserinfoAssertion: true + devMode: false + managementPolicies: {{ $state.managementPolicies | toJson }} + writeConnectionSecretToRef: + name: {{ $state.connectionSecretName | quote }} + providerConfigRef: + name: {{ $state.providerConfigName | quote }} + kind: ProviderConfig diff --git a/functions/oidcclient/999-status.yaml.gotmpl b/functions/oidcclient/999-status.yaml.gotmpl new file mode 100644 index 0000000..4905768 --- /dev/null +++ b/functions/oidcclient/999-status.yaml.gotmpl @@ -0,0 +1,9 @@ +# code: language=yaml + +{{- $xr := getCompositeResource . }} +--- +apiVersion: {{ $xr.apiVersion }} +kind: {{ $xr.kind }} +status: + applicationId: {{ $state.status.applicationId | default "" | quote }} + clientId: {{ $state.status.clientId | default "" | quote }} diff --git a/tests/test-oidcclient/kcl.mod b/tests/test-oidcclient/kcl.mod new file mode 100644 index 0000000..7d99395 --- /dev/null +++ b/tests/test-oidcclient/kcl.mod @@ -0,0 +1,6 @@ +[package] +name = "test-oidcclient" +version = "0.0.1" + +[dependencies] +models = { path = "./model" } diff --git a/tests/test-oidcclient/main.k b/tests/test-oidcclient/main.k new file mode 100644 index 0000000..3128585 --- /dev/null +++ b/tests/test-oidcclient/main.k @@ -0,0 +1,146 @@ +import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 + +_xr = { + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "OIDCClient" + metadata = {name = "storybook-pr-381", namespace = "storybook-pr-381"} + spec = { + projectId = "project-123" + redirectUris = ["https://storybook-pr-381.example.com/oauth2/callback"] + postLogoutRedirectUris = ["https://storybook-pr-381.example.com/"] + connectionSecretRef = {name = "storybook-oidc-client"} + credentials = { + domain = "auth.example.com" + secretStoreRef = {name = "aws-secrets-manager", kind = "ClusterSecretStore"} + remoteRef = {key = "push/production/zitadel-credentials", property = "access_token"} + } + } +} + +items = [ + metav1alpha1.CompositionTest { + metadata.name = "composes-bootstrap-provider-and-application" + spec = { + compositionPath = "apis/oidcclients/composition.yaml" + xrdPath = "apis/oidcclients/definition.yaml" + timeoutSeconds = 120 + validate = False + xr = _xr + # CompositionTest excludes ProviderConfig kinds from resource + # assertions. The Object and Oidc references still pin both names. + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = "storybook-pr-381-zitadel-credentials" + namespace = "storybook-pr-381" + } + spec = { + forProvider.manifest = { + apiVersion = "external-secrets.io/v1" + kind = "ExternalSecret" + metadata = { + name = "storybook-pr-381-zitadel-credentials" + namespace = "storybook-pr-381" + } + spec = { + refreshInterval = "1h" + secretStoreRef = {name = "aws-secrets-manager", kind = "ClusterSecretStore"} + target = { + name = "storybook-pr-381-zitadel-credentials" + creationPolicy = "Owner" + deletionPolicy = "Retain" + } + data = [{ + secretKey = "access_token" + remoteRef = {key = "push/production/zitadel-credentials", property = "access_token"} + }] + } + } + providerConfigRef = {name = "storybook-pr-381-kubernetes", kind = "ProviderConfig"} + } + } + { + apiVersion = "application.zitadel.m.crossplane.io/v1alpha1" + kind = "Oidc" + metadata = { + name = "storybook-pr-381" + namespace = "storybook-pr-381" + } + spec = { + forProvider = { + name = "storybook-pr-381" + projectId = "project-123" + redirectUris = ["https://storybook-pr-381.example.com/oauth2/callback"] + postLogoutRedirectUris = ["https://storybook-pr-381.example.com/"] + } + writeConnectionSecretToRef = {name = "storybook-oidc-client"} + providerConfigRef = {name = "storybook-pr-381", kind = "ProviderConfig"} + } + } + ] + } + } + metav1alpha1.CompositionTest { + metadata.name = "reports-observed-application" + spec = { + compositionPath = "apis/oidcclients/composition.yaml" + xrdPath = "apis/oidcclients/definition.yaml" + timeoutSeconds = 120 + validate = False + xr = _xr + observedResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "ProviderConfig" + metadata = { + name = "storybook-pr-381-kubernetes" + namespace = "storybook-pr-381" + annotations = {"crossplane.io/composition-resource-name" = "kubernetes-providerconfig"} + } + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = "storybook-pr-381-zitadel-credentials" + namespace = "storybook-pr-381" + annotations = {"crossplane.io/composition-resource-name" = "credentials"} + } + status.conditions = [{type = "Ready", status = "True"}] + } + { + apiVersion = "zitadel.m.crossplane.io/v1beta1" + kind = "ProviderConfig" + metadata = { + name = "storybook-pr-381" + namespace = "storybook-pr-381" + annotations = {"crossplane.io/composition-resource-name" = "providerconfig"} + } + } + { + apiVersion = "application.zitadel.m.crossplane.io/v1alpha1" + kind = "Oidc" + metadata = { + name = "storybook-pr-381" + namespace = "storybook-pr-381" + annotations = {"crossplane.io/composition-resource-name" = "oidc"} + } + status = { + atProvider = {id = "app-123", clientId = "client-123"} + conditions = [{type = "Ready", status = "True"}] + } + } + ] + assertResources = [ + { + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "OIDCClient" + metadata.name = "storybook-pr-381" + status = {applicationId = "app-123", clientId = "client-123"} + } + ] + } + } +] diff --git a/tests/test-oidcclient/model b/tests/test-oidcclient/model new file mode 120000 index 0000000..faff6e4 --- /dev/null +++ b/tests/test-oidcclient/model @@ -0,0 +1 @@ +../../.up/kcl/models \ No newline at end of file diff --git a/tests/test-oidcclient/observed/ready.yaml b/tests/test-oidcclient/observed/ready.yaml new file mode 100644 index 0000000..6eab8d0 --- /dev/null +++ b/tests/test-oidcclient/observed/ready.yaml @@ -0,0 +1,47 @@ +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: gitkb-storybook-pr-381-kubernetes + namespace: gitkb-storybook-pr-381 + annotations: + crossplane.io/composition-resource-name: kubernetes-providerconfig + gotemplating.fn.crossplane.io/composition-resource-name: kubernetes-providerconfig +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: gitkb-storybook-pr-381-zitadel-credentials + namespace: gitkb-storybook-pr-381 + annotations: + crossplane.io/composition-resource-name: credentials + gotemplating.fn.crossplane.io/composition-resource-name: credentials +status: + conditions: + - type: Ready + status: "True" +--- +apiVersion: zitadel.m.crossplane.io/v1beta1 +kind: ProviderConfig +metadata: + name: gitkb-storybook-pr-381 + namespace: gitkb-storybook-pr-381 + annotations: + crossplane.io/composition-resource-name: providerconfig + gotemplating.fn.crossplane.io/composition-resource-name: providerconfig +--- +apiVersion: application.zitadel.m.crossplane.io/v1alpha1 +kind: Oidc +metadata: + name: gitkb-storybook-pr-381 + namespace: gitkb-storybook-pr-381 + annotations: + crossplane.io/composition-resource-name: oidc + gotemplating.fn.crossplane.io/composition-resource-name: oidc +status: + atProvider: + id: app-123 + clientId: client-123 + conditions: + - type: Ready + status: "True" diff --git a/upbound.yaml b/upbound.yaml index c4b6544..c543e64 100644 --- a/upbound.yaml +++ b/upbound.yaml @@ -32,8 +32,9 @@ spec: upstream Helm chart, with typed integration points for PostgreSQL and Gateway API. Also hosts the auth.hops.ops.com.ai primitive XRDs that wrap multi-resource Zitadel patterns — HumanUser (organization-reference resolution), MachineUser - (with opt-in PAT + AWS SM push pipeline), and Grant (polymorphic same-Org/cross-Org - user-to-project-role assignment). + (with opt-in PAT + AWS SM push pipeline), Grant (polymorphic same-Org/cross-Org + user-to-project-role assignment), and OIDCClient (preview-safe Zitadel web + application lifecycle with namespaced credential delivery). license: Apache-2.0 maintainer: Patrick Lee Scott readme: |