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
60 changes: 21 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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": "<iam-admin PAT>", "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:

Expand Down Expand Up @@ -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
Expand Down
30 changes: 18 additions & 12 deletions apis/tenantstacks/definition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -253,6 +248,17 @@ spec:
zitadelProjectId:
description: Zitadel Project ID under which to provision the OIDC Application. Capture from the Zitadel UI ("Projects → <your project> → 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
Expand Down
5 changes: 2 additions & 3 deletions examples/tenantstacks/with-auth.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 6 additions & 0 deletions functions/render/000-state-init.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -140,6 +145,7 @@
"enabled" $authEnabled
"issuerURL" $authIssuerURL
"zitadelProjectId" $authProjectId
"zitadelProviderConfigRef" $authZitadelPCR
"oidcClient" $authOidc
)
"aws" (dict
Expand Down
33 changes: 0 additions & 33 deletions functions/render/120-zitadel-provider-config.yaml.gotmpl

This file was deleted.

4 changes: 2 additions & 2 deletions functions/render/300-zitadel-oidc-app.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
70 changes: 47 additions & 23 deletions tests/test-render/main.k
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
}
}
Expand Down
Loading