Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/on-pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/on-push-main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
Expand Down
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:: \
Expand Down
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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:

Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
16 changes: 16 additions & 0 deletions apis/oidcclients/composition.yaml
Original file line number Diff line number Diff line change
@@ -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
136 changes: 136 additions & 0 deletions apis/oidcclients/definition.yaml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions examples/oidcclients/storybook-preview.yaml
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions functions/oidcclient/000-state-init.yaml.gotmpl
Original file line number Diff line number Diff line change
@@ -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)
}}
9 changes: 9 additions & 0 deletions functions/oidcclient/010-state-status.yaml.gotmpl
Original file line number Diff line number Diff line change
@@ -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 "") }}
19 changes: 19 additions & 0 deletions functions/oidcclient/050-kubernetes-provider-config.yaml.gotmpl
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions functions/oidcclient/100-bootstrap-credentials.yaml.gotmpl
Original file line number Diff line number Diff line change
@@ -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
Loading