diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml index 4aa3f65..3098b04 100644 --- a/.github/workflows/on-pr.yaml +++ b/.github/workflows/on-pr.yaml @@ -33,7 +33,10 @@ jobs: [ { "example": "examples/authstacks/minimal.yaml", "api_path": "apis/authstacks" }, { "example": "examples/authstacks/standard.yaml", "api_path": "apis/authstacks" }, - { "example": "examples/authstacks/local-colima.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" }, + { "example": "examples/machineusers/with-pat-push.yaml","api_path": "apis/machineusers" } ] error_on_missing_schemas: true diff --git a/.github/workflows/on-push-main.yaml b/.github/workflows/on-push-main.yaml index 4f7b49c..20830b1 100644 --- a/.github/workflows/on-push-main.yaml +++ b/.github/workflows/on-push-main.yaml @@ -29,7 +29,10 @@ jobs: [ { "example": "examples/authstacks/minimal.yaml", "api_path": "apis/authstacks" }, { "example": "examples/authstacks/standard.yaml", "api_path": "apis/authstacks" }, - { "example": "examples/authstacks/local-colima.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" }, + { "example": "examples/machineusers/with-pat-push.yaml","api_path": "apis/machineusers" } ] error_on_missing_schemas: true diff --git a/Makefile b/Makefile index 1608799..e524f37 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,10 @@ build: EXAMPLES := \ examples/authstacks/minimal.yaml:: \ examples/authstacks/standard.yaml:: \ - examples/authstacks/local-colima.yaml:: + examples/authstacks/local-colima.yaml:: \ + examples/machineusers/minimal.yaml:: \ + examples/machineusers/with-pat.yaml:: \ + examples/machineusers/with-pat-push.yaml:: # Render all examples (parallel execution, output shown per-job when complete) render\:all: diff --git a/README.md b/README.md index 404cf9f..a82bf87 100644 --- a/README.md +++ b/README.md @@ -82,18 +82,26 @@ status: ## Auth-group primitives -Per [[specs/identity-architecture]], the auth-group primitive XRDs that have substantive composition value-add — `HumanUser`, `MachineUser`, `Grant`, `IDP` — 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 — `MachineUser`, `Grant` — live in this repo alongside `AuthStack` under the `auth.hops.ops.com.ai` group. Status: | Kind | Plural | Composes | Status | |---|---|---|---| -| `HumanUser` | `humanusers` | `HumanUser` + optional `UserIDPLink` MRs | TO WRITE | -| `MachineUser` | `machineusers` | `MachineUser` + `PAT` + AWS SM push pipeline | TO WRITE | +| `MachineUser` | `machineusers` | `MachineUser` + opt-in `AccessToken` + opt-in AWS SM `Secret` + ESO `PushSecret` (provider-kubernetes Object) | ✓ | | `Grant` | `grants` | `ProjectMember` (same-Org) or `ProjectGrant + GrantMember` (cross-Org) | TO WRITE | -| `IDP` | `idps` | polymorphic over `GoogleIDP` / `GitHubIDP` / `OIDCIDP` / `SAMLIDP` | TO WRITE | -Operators apply raw Zitadel `Org` / `Project` / `Role` MRs (`org.zitadel.m.crossplane.io`, `project.zitadel.m.crossplane.io`) directly when they need them — the `Tenant` business kind in [[tenant-stack]] composes the initial set during Tenant scaffolding. +Single-resource wrappers we deliberately didn't make: `HumanUser`, `IDP`, `OrganizationSsoConfig` (and the previously-attempted `Organization`, `Project`). Operators apply raw Zitadel / OpenPanel MRs directly for those. + +### `MachineUser` + +Declarative Zitadel machine identity for CI runners, Crossplane providers, cross-cluster syncs — anything that needs a long-lived credential to call a SaaS API. + +The XRD's value-add is bundling. The `MachineUser` MR alone is one Zitadel resource. With `spec.pat.enabled: true` it adds an `AccessToken` MR (the PAT). With `spec.pat.pushToAwsSm: true` it adds an AWS Secrets Manager `Secret` MR + an ESO `PushSecret` Kubernetes Object — pushing the control-plane connection secret's `access_token` into AWS SM at the canonical path `push///` per [[reference_aws_sm_push_tag_convention]]. Four resources, one declarative flag. + +PAT generation is **opt-in by default** — `pat.enabled: false` means no long-lived token is minted. Adoption of an existing Zitadel machine user uses `spec.machineUserId` (propagates as `crossplane.io/external-name` on the underlying MR). + +See `examples/machineusers/{minimal,with-pat,with-pat-push}.yaml`. ## Cross-Stack Integration diff --git a/apis/machineusers/composition.yaml b/apis/machineusers/composition.yaml new file mode 100644 index 0000000..d9898b5 --- /dev/null +++ b/apis/machineusers/composition.yaml @@ -0,0 +1,16 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: machineusers.auth.hops.ops.com.ai +spec: + compositeTypeRef: + apiVersion: auth.hops.ops.com.ai/v1alpha1 + kind: MachineUser + mode: Pipeline + pipeline: + - functionRef: + name: hops-ops-auth-stackmachineuser + step: machineuser + - functionRef: + name: crossplane-contrib-function-auto-ready + step: crossplane-contrib-function-auto-ready diff --git a/apis/machineusers/definition.yaml b/apis/machineusers/definition.yaml new file mode 100644 index 0000000..1acc719 --- /dev/null +++ b/apis/machineusers/definition.yaml @@ -0,0 +1,265 @@ +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: machineusers.auth.hops.ops.com.ai +spec: + group: auth.hops.ops.com.ai + names: + kind: MachineUser + plural: machineusers + scope: Namespaced + versions: + - name: v1alpha1 + referenceable: true + served: true + schema: + openAPIV3Schema: + description: | + MachineUser is a declarative Zitadel machine identity used by + CI runners, Crossplane providers, cross-cluster syncs — anything + that needs a long-lived credential to call a SaaS API. + + Composes the underlying Zitadel MachineUser MR always. PAT + generation is OPT-IN (`spec.pat.enabled: true`) — operators + explicitly acknowledge they're minting a long-lived bearer + token. AWS Secrets Manager push (for cross-cluster pull via + ESO ExternalSecret) is a further opt-in + (`spec.pat.pushToAwsSm: true`). + + Adoption: set spec.machineUserId to the existing Zitadel + machine-user UUID; it propagates as the composed MR's + crossplane.io/external-name. Pair with + managementPolicies excluding Create. + + See [[specs/machine-user-xrd]] for the design. + type: object + properties: + spec: + description: MachineUserSpec defines the desired state. + type: object + properties: + orgId: + description: | + Underlying Zitadel Org UUID. Operator copies from the + parent Tenant's status.identity.zitadel.orgId. + type: string + + userName: + description: | + Login name for the machine user. Defaults to metadata.name + when empty. + type: string + default: "" + + displayName: + description: | + Human-readable name shown in Zitadel dashboard. Defaults + to metadata.name when empty. + type: string + default: "" + + description: + description: | + Description of what this machine identity is used for — + e.g., "provider-upjet-zitadel reconciler post-bootstrap". + type: string + default: "" + + accessTokenType: + description: | + Zitadel access-token type for this machine user. JWT is + self-contained + signed; BEARER is opaque + introspected. + type: string + enum: ["ACCESS_TOKEN_TYPE_JWT", "ACCESS_TOKEN_TYPE_BEARER"] + default: "ACCESS_TOKEN_TYPE_JWT" + + machineUserId: + description: | + Existing Zitadel machine-user UUID. When set, propagates + as the composed MachineUser MR's + crossplane.io/external-name annotation (adoption pattern). + type: string + default: "" + + pat: + description: | + Personal Access Token generation. DISABLED by default — + operators must explicitly opt in. PAT generation mints a + long-lived bearer token; the connection secret lands on + the control plane K8s (per Crossplane v2 connection-secret + semantics, NOT on whichever cluster provider-upjet-zitadel + targets). + type: object + properties: + enabled: + description: | + Set true to compose an AccessToken MR (the PAT). + Connection secret written to control-plane K8s + under key `attribute.token` (the upstream + provider-upjet-zitadel attribute name). Consumers + either read that key directly OR — for cross-cluster + pull — enable pushToAwsSm and the ESO PushSecret + renames to `access_token` per the conventional + Zitadel PC credential key. Default false. + type: boolean + default: false + expirationDate: + description: | + Optional ISO-8601 expiration timestamp for the PAT. + Empty = no expiration (Zitadel server default). + type: string + default: "" + pushToAwsSm: + description: | + When true (and pat.enabled is true), composes an AWS + Secrets Manager Secret MR + an ESO PushSecret + Kubernetes Object that pushes the control-plane K8s + Secret's access_token property to AWS SM. Consumer + clusters then pull via ESO ExternalSecret. Uses the + push/// path convention from + [[reference-aws-sm-push-tag-convention]]. + type: boolean + default: false + awsSm: + description: | + AWS Secrets Manager push configuration. Required when + pushToAwsSm is true. + type: object + properties: + cluster: + description: | + Destination cluster identifier (becomes part of + the path: push///). + type: string + tenant: + description: | + Tenant identifier (becomes part of the path). + Defaults to metadata.namespace when empty. + type: string + default: "" + region: + description: AWS region for the SM Secret. + type: string + providerConfigRef: + description: | + ProviderConfig for provider-aws-secretsmanager. + type: object + properties: + name: + type: string + kind: + type: string + default: "ProviderConfig" + eso: + description: | + ESO SecretStore config for the PushSecret. + type: object + properties: + storeName: + description: | + Name of the ClusterSecretStore. Defaults to + "aws-secrets-manager-platform". + type: string + default: "aws-secrets-manager-platform" + storeKind: + type: string + enum: ["SecretStore", "ClusterSecretStore"] + default: "ClusterSecretStore" + deletionPolicy: + description: | + ESO PushSecret deletion policy. Delete tears + down the AWS SM Secret when the PushSecret is + removed; Retain leaves it for audit. + type: string + enum: ["Delete", "Retain"] + default: "Delete" + kubernetesProviderConfigRef: + description: | + ProviderConfig for provider-kubernetes (the + cluster hosting ESO). Defaults to the same + cluster identifier in awsSm.cluster. + type: object + properties: + name: + type: string + kind: + type: string + default: "ProviderConfig" + + providerConfigRef: + description: | + ProviderConfig for provider-upjet-zitadel. No convention + default — operator supplies explicitly. + type: object + properties: + name: + type: string + kind: + type: string + default: "ProviderConfig" + + managementPolicies: + description: | + Crossplane managementPolicies for all composed resources. + Defaults to ["*"]. For adoption flows, set to + ["Observe", "Update", "LateInitialize"]. + type: array + items: + type: string + default: ["*"] + + labels: + description: | + Custom labels merged with stack defaults; applied to every + composed resource. + type: object + additionalProperties: + type: string + x-kubernetes-preserve-unknown-fields: true + + required: + - orgId + - providerConfigRef + + status: + type: object + properties: + userId: + description: Underlying Zitadel machine-user UUID. + type: string + loginName: + description: | + User's login name (typically userName + Org domain). + type: string + pat: + type: object + properties: + enabled: + type: boolean + tokenId: + description: Underlying Zitadel AccessToken UUID. + type: string + controlPlaneSecretRef: + description: | + Connection-secret ref on the control plane (where + provider-upjet-zitadel wrote the PAT under key + access_token). + type: object + properties: + name: + type: string + namespace: + type: string + awsSm: + description: | + AWS SM Secret ARN + path when pushToAwsSm: true. + type: object + properties: + secretArn: + type: string + secretName: + type: string + ready: + type: boolean + required: + - spec diff --git a/examples/machineusers/minimal.yaml b/examples/machineusers/minimal.yaml new file mode 100644 index 0000000..dae6cf3 --- /dev/null +++ b/examples/machineusers/minimal.yaml @@ -0,0 +1,17 @@ +# Minimal MachineUser — composes the Zitadel MachineUser MR only. +# No PAT, no AWS SM push. Useful for service accounts that authenticate +# via JWT key (Zitadel's other mechanism) or for declarative tracking of +# external machine identities without minting credentials via Crossplane. +apiVersion: auth.hops.ops.com.ai/v1alpha1 +kind: MachineUser +metadata: + name: zitadel-provider + namespace: default +spec: + orgId: "373268222482392664" + displayName: "zitadel-provider" + description: "provider-upjet-zitadel reconciler (post-bootstrap)" + accessTokenType: ACCESS_TOKEN_TYPE_JWT + providerConfigRef: + name: zitadel-tenant-stack + kind: ProviderConfig diff --git a/examples/machineusers/with-pat-push.yaml b/examples/machineusers/with-pat-push.yaml new file mode 100644 index 0000000..46f77b1 --- /dev/null +++ b/examples/machineusers/with-pat-push.yaml @@ -0,0 +1,37 @@ +# MachineUser with PAT + AWS SM push pipeline — the full 4-MR composition: +# 1. MachineUser MR (Zitadel) +# 2. AccessToken MR (Zitadel) — PAT lands on control-plane K8s +# 3. AWS Secrets Manager Secret MR — empty Secret at the convention path +# push/// +# 4. ESO PushSecret (via provider-kubernetes Object) — pushes the +# control-plane K8s Secret's access_token property into the AWS SM +# Secret. Consumer clusters pull via ESO ExternalSecret. +apiVersion: auth.hops.ops.com.ai/v1alpha1 +kind: MachineUser +metadata: + name: openpanel-provider + namespace: default +spec: + orgId: "373268222482392664" + displayName: "OpenPanel Provider" + description: "provider-upjet-openpanel reconciler /manage credential" + accessTokenType: ACCESS_TOKEN_TYPE_JWT + pat: + enabled: true + pushToAwsSm: true + awsSm: + cluster: pat-local + tenant: ops-com-ai + region: us-east-1 + providerConfigRef: + name: aws-platform + kind: ProviderConfig + eso: + storeName: aws-secrets-manager-platform + storeKind: ClusterSecretStore + kubernetesProviderConfigRef: + name: pat-local + kind: ProviderConfig + providerConfigRef: + name: zitadel-tenant-stack + kind: ProviderConfig diff --git a/examples/machineusers/with-pat.yaml b/examples/machineusers/with-pat.yaml new file mode 100644 index 0000000..fd7dd48 --- /dev/null +++ b/examples/machineusers/with-pat.yaml @@ -0,0 +1,19 @@ +# MachineUser with PAT — opt-in. Composes MachineUser MR + AccessToken +# MR. The PAT lands on the control-plane K8s as a Secret under the key +# `access_token`. No AWS SM push — consumers on the same cluster as the +# control plane can `secretKeyRef` directly. +apiVersion: auth.hops.ops.com.ai/v1alpha1 +kind: MachineUser +metadata: + name: ci-bot + namespace: default +spec: + orgId: "373268222482392664" + displayName: "CI Bot" + description: "GitHub Actions CI bot for the platform repos" + accessTokenType: ACCESS_TOKEN_TYPE_JWT + pat: + enabled: true + providerConfigRef: + name: zitadel-tenant-stack + kind: ProviderConfig diff --git a/functions/machineuser/000-state-init.yaml.gotmpl b/functions/machineuser/000-state-init.yaml.gotmpl new file mode 100644 index 0000000..3dc16e3 --- /dev/null +++ b/functions/machineuser/000-state-init.yaml.gotmpl @@ -0,0 +1,134 @@ +# code: language=yaml +# +# Initialize $state with spec defaults. +# + +{{- $xr := getCompositeResource . }} +{{- $spec := $xr.spec | default dict }} +{{- $metadata := $xr.metadata | default dict }} + +# ============================================================================== +# Core +# ============================================================================== +{{- $name := $metadata.name }} +{{- $namespace := $metadata.namespace | default "default" }} +{{- $managementPolicies := $spec.managementPolicies | default (list "*") }} + +{{- $userName := $spec.userName | default "" }} +{{- if eq $userName "" }} + {{- $userName = $name }} +{{- end }} + +{{- $displayName := $spec.displayName | default "" }} +{{- if eq $displayName "" }} + {{- $displayName = $name }} +{{- end }} + +{{- $description := $spec.description | default "" }} +{{- $accessTokenType := $spec.accessTokenType | default "ACCESS_TOKEN_TYPE_JWT" }} + +{{- $orgId := $spec.orgId }} +{{- $machineUserId := $spec.machineUserId | default "" }} + +# ============================================================================== +# PAT config +# ============================================================================== +{{- $patSpec := $spec.pat | default dict }} +{{- $patEnabled := $patSpec.enabled | default false }} +{{- $patExpiration := $patSpec.expirationDate | default "" }} +{{- $patPushToAwsSm := $patSpec.pushToAwsSm | default false }} + +{{- $awsSm := $patSpec.awsSm | default dict }} +{{- $awsSmCluster := $awsSm.cluster | default "" }} +{{- $awsSmTenant := $awsSm.tenant | default "" }} +{{- if eq $awsSmTenant "" }} + {{- $awsSmTenant = $namespace }} +{{- end }} +{{- $awsSmRegion := $awsSm.region | default "" }} + +{{- $awsSmPcSpec := $awsSm.providerConfigRef | default dict }} +{{- $awsSmPcName := $awsSmPcSpec.name | default "" }} +{{- $awsSmPcKind := $awsSmPcSpec.kind | default "ProviderConfig" }} + +{{- $eso := $awsSm.eso | default dict }} +{{- $esoStoreName := $eso.storeName | default "aws-secrets-manager-platform" }} +{{- $esoStoreKind := $eso.storeKind | default "ClusterSecretStore" }} +{{- $esoDeletionPolicy := $eso.deletionPolicy | default "Delete" }} + +{{- $esoK8sPcSpec := $eso.kubernetesProviderConfigRef | default dict }} +{{- $esoK8sPcName := $esoK8sPcSpec.name | default "" }} +{{- if eq $esoK8sPcName "" }} + {{- $esoK8sPcName = $awsSmCluster }} +{{- end }} +{{- $esoK8sPcKind := $esoK8sPcSpec.kind | default "ProviderConfig" }} + +# AWS SM external-name (the path) +{{- $awsSmPath := "" }} +{{- if and $patEnabled $patPushToAwsSm }} + {{- $awsSmPath = printf "push/%s/%s/%s" $awsSmCluster $awsSmTenant $name }} +{{- end }} + +# ============================================================================== +# Zitadel ProviderConfig (no convention default — required) +# ============================================================================== +{{- $zitadelPcSpec := $spec.providerConfigRef }} +{{- $zitadelPcName := $zitadelPcSpec.name }} +{{- $zitadelPcKind := $zitadelPcSpec.kind | default "ProviderConfig" }} + +# ============================================================================== +# Connection secret name (control plane K8s) +# ============================================================================== +{{- $patSecretName := printf "%s-pat" $name }} + +# ============================================================================== +# Labels +# ============================================================================== +{{- $defaultLabels := dict + "hops.ops.com.ai/managed" "true" + "hops.ops.com.ai/auth-machineuser" $name +}} +{{- $labels := merge $defaultLabels ($spec.labels | default dict) }} + +# ============================================================================== +# $state +# ============================================================================== +{{- $state := dict + "name" $name + "namespace" $namespace + "userName" $userName + "displayName" $displayName + "description" $description + "accessTokenType" $accessTokenType + "orgId" $orgId + "machineUserId" $machineUserId + "managementPolicies" $managementPolicies + "labels" $labels + + "pat" (dict + "enabled" $patEnabled + "expirationDate" $patExpiration + "pushToAwsSm" $patPushToAwsSm + "secretName" $patSecretName + "awsSm" (dict + "cluster" $awsSmCluster + "tenant" $awsSmTenant + "region" $awsSmRegion + "path" $awsSmPath + "providerConfigName" $awsSmPcName + "providerConfigKind" $awsSmPcKind + "esoStoreName" $esoStoreName + "esoStoreKind" $esoStoreKind + "esoDeletionPolicy" $esoDeletionPolicy + "esoK8sProviderConfigName" $esoK8sPcName + "esoK8sProviderConfigKind" $esoK8sPcKind + ) + ) + + "zitadelProviderConfig" (dict + "name" $zitadelPcName + "kind" $zitadelPcKind + ) + + "observed" (dict) + "status" (dict) +}} diff --git a/functions/machineuser/010-state-status.yaml.gotmpl b/functions/machineuser/010-state-status.yaml.gotmpl new file mode 100644 index 0000000..272ec1d --- /dev/null +++ b/functions/machineuser/010-state-status.yaml.gotmpl @@ -0,0 +1,74 @@ +# code: language=yaml +# +# Extract observed state from composed MRs for status emission + multi-iter +# composition gating (PAT MR needs userId observed; AWS SM resources need PAT +# observed). +# + +{{- $observed := $.observed.resources | default dict }} + +# MachineUser observed state +{{- $muEntry := get $observed "machineuser" | default dict }} +{{- $muResource := $muEntry.resource | default dict }} +{{- $muAtProvider := (($muResource.status | default dict).atProvider | default dict) }} +{{- $userId := $muAtProvider.id | default "" }} +{{- if and (eq $userId "") (ne $state.machineUserId "") }} + {{- $userId = $state.machineUserId }} +{{- end }} +{{- $loginName := $muAtProvider.loginName | default "" }} + +# MachineUser Ready +{{- $muConditions := (($muResource.status | default dict).conditions | default list) }} +{{- $muReady := false }} +{{- range $muConditions }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $muReady = true }} + {{- end }} +{{- end }} + +# PAT observed state +{{- $patTokenId := "" }} +{{- $patReady := true }} +{{- if $state.pat.enabled }} + {{- $patReady = false }} + {{- $patEntry := get $observed "pat" | default dict }} + {{- $patResource := $patEntry.resource | default dict }} + {{- $patAtProvider := (($patResource.status | default dict).atProvider | default dict) }} + {{- $patTokenId = $patAtProvider.id | default "" }} + {{- $patConditions := (($patResource.status | default dict).conditions | default list) }} + {{- range $patConditions }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $patReady = true }} + {{- end }} + {{- end }} +{{- end }} + +# AWS SM + PushSecret observed state +{{- $awsSmArn := "" }} +{{- $awsSmReady := true }} +{{- if and $state.pat.enabled $state.pat.pushToAwsSm }} + {{- $awsSmReady = false }} + {{- $smEntry := get $observed "aws-sm-secret" | default dict }} + {{- $smResource := $smEntry.resource | default dict }} + {{- $smAtProvider := (($smResource.status | default dict).atProvider | default dict) }} + {{- $awsSmArn = $smAtProvider.arn | default "" }} + {{- $smConditions := (($smResource.status | default dict).conditions | default list) }} + {{- range $smConditions }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $awsSmReady = true }} + {{- end }} + {{- end }} +{{- end }} + +# Aggregate +{{- $ready := and $muReady $patReady $awsSmReady }} + +{{- $_ := set $state.observed "userId" $userId }} +{{- $_ := set $state.observed "loginName" $loginName }} +{{- $_ := set $state.observed "patTokenId" $patTokenId }} +{{- $_ := set $state.observed "awsSmArn" $awsSmArn }} +{{- $_ := set $state.status "ready" $ready }} +{{- $_ := set $state.status "userId" $userId }} +{{- $_ := set $state.status "loginName" $loginName }} +{{- $_ := set $state.status "patTokenId" $patTokenId }} +{{- $_ := set $state.status "awsSmArn" $awsSmArn }} diff --git a/functions/machineuser/100-machineuser.yaml.gotmpl b/functions/machineuser/100-machineuser.yaml.gotmpl new file mode 100644 index 0000000..164b76f --- /dev/null +++ b/functions/machineuser/100-machineuser.yaml.gotmpl @@ -0,0 +1,32 @@ +# code: language=yaml +# +# Zitadel MachineUser (machineusers.user.zitadel.m.crossplane.io). +# Always composed. +# +# Adoption: spec.machineUserId propagates as external-name annotation. +# + +--- +apiVersion: user.zitadel.m.crossplane.io/v1alpha1 +kind: MachineUser +metadata: + name: {{ $state.name }} + annotations: + {{ setResourceNameAnnotation "machineuser" }} + {{- if ne $state.machineUserId "" }} + crossplane.io/external-name: {{ $state.machineUserId | quote }} + {{- end }} + labels: {{ $state.labels | toJson }} +spec: + forProvider: + orgId: {{ $state.orgId | quote }} + userName: {{ $state.userName | quote }} + name: {{ $state.displayName | quote }} + {{- if ne $state.description "" }} + description: {{ $state.description | quote }} + {{- end }} + accessTokenType: {{ $state.accessTokenType | quote }} + managementPolicies: {{ $state.managementPolicies | toJson }} + providerConfigRef: + name: {{ $state.zitadelProviderConfig.name | quote }} + kind: {{ $state.zitadelProviderConfig.kind | quote }} diff --git a/functions/machineuser/200-pat.yaml.gotmpl b/functions/machineuser/200-pat.yaml.gotmpl new file mode 100644 index 0000000..9c9ae04 --- /dev/null +++ b/functions/machineuser/200-pat.yaml.gotmpl @@ -0,0 +1,41 @@ +# code: language=yaml +# +# Zitadel AccessToken (PAT) — gated on spec.pat.enabled AND observed +# MachineUser.atProvider.id. Multi-iteration: MachineUser composes first, +# AccessToken emits once userId is observed (atProvider.id populated). +# +# Connection secret lands on the CONTROL plane K8s under +# spec.pat.secretName with key `access_token` (per +# reference_zitadel_pc_credentials_shape). Cross-cluster bridging is +# handled separately by 300-aws-sm-secret + 310-eso-pushsecret. +# + +{{- if and $state.pat.enabled (ne $state.observed.userId "") }} +--- +apiVersion: user.zitadel.m.crossplane.io/v1alpha1 +kind: AccessToken +metadata: + name: {{ printf "%s-pat" $state.name }} + annotations: + {{ setResourceNameAnnotation "pat" }} + labels: + {{- range $k, $v := $state.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + hops.ops.com.ai/auth-machineuser: {{ $state.name | quote }} +spec: + forProvider: + orgId: {{ $state.orgId | quote }} + userId: {{ $state.observed.userId | quote }} + {{- if ne $state.pat.expirationDate "" }} + expirationDate: {{ $state.pat.expirationDate | quote }} + {{- end }} + # Crossplane v2 namespaced MR — connection secret lands in this MR's + # namespace automatically; only the name is needed. + writeConnectionSecretToRef: + name: {{ $state.pat.secretName | quote }} + managementPolicies: {{ $state.managementPolicies | toJson }} + providerConfigRef: + name: {{ $state.zitadelProviderConfig.name | quote }} + kind: {{ $state.zitadelProviderConfig.kind | quote }} +{{- end }} diff --git a/functions/machineuser/300-aws-sm-secret.yaml.gotmpl b/functions/machineuser/300-aws-sm-secret.yaml.gotmpl new file mode 100644 index 0000000..f3e50eb --- /dev/null +++ b/functions/machineuser/300-aws-sm-secret.yaml.gotmpl @@ -0,0 +1,48 @@ +# code: language=yaml +# +# AWS Secrets Manager Secret (secrets.secretsmanager.aws.m.upbound.io). +# +# Gated on pat.enabled AND pat.pushToAwsSm. Multi-iteration: emits once +# the PAT MR has been observed (patTokenId populated) — that's a +# downstream-readiness signal that the connection secret on the control +# plane exists for ESO to push. +# +# The Secret VALUE is not set here — the ESO PushSecret (310) writes the +# access_token from the control-plane K8s Secret into this AWS SM entry's +# `access_token` property. +# +# Path convention: push/// per +# [[reference-aws-sm-push-tag-convention]]. +# + +{{- if and $state.pat.enabled $state.pat.pushToAwsSm (ne $state.observed.patTokenId "") }} +--- +apiVersion: secretsmanager.aws.m.upbound.io/v1beta1 +kind: Secret +metadata: + name: {{ printf "%s-aws-sm" $state.name }} + annotations: + {{ setResourceNameAnnotation "aws-sm-secret" }} + crossplane.io/external-name: {{ $state.pat.awsSm.path | quote }} + labels: + {{- range $k, $v := $state.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + hops.ops.com.ai/auth-machineuser: {{ $state.name | quote }} + hops.ops.com.ai/cluster: {{ $state.pat.awsSm.cluster | quote }} + hops.ops.com.ai/tenant: {{ $state.pat.awsSm.tenant | quote }} +spec: + forProvider: + region: {{ $state.pat.awsSm.region | quote }} + description: {{ printf "PAT for MachineUser %s (Tenant %s, cluster %s)" $state.name $state.pat.awsSm.tenant $state.pat.awsSm.cluster | quote }} + tags: + hops.ops.com.ai/managed: "true" + hops.ops.com.ai/auth-machineuser: {{ $state.name | quote }} + hops.ops.com.ai/tenant: {{ $state.pat.awsSm.tenant | quote }} + hops.ops.com.ai/cluster: {{ $state.pat.awsSm.cluster | quote }} + hops.ops.com.ai/pushed-by: machineuser-xrd + managementPolicies: {{ $state.managementPolicies | toJson }} + providerConfigRef: + name: {{ $state.pat.awsSm.providerConfigName | quote }} + kind: {{ $state.pat.awsSm.providerConfigKind | quote }} +{{- end }} diff --git a/functions/machineuser/310-eso-pushsecret.yaml.gotmpl b/functions/machineuser/310-eso-pushsecret.yaml.gotmpl new file mode 100644 index 0000000..2ec5923 --- /dev/null +++ b/functions/machineuser/310-eso-pushsecret.yaml.gotmpl @@ -0,0 +1,63 @@ +# code: language=yaml +# +# ESO PushSecret (external-secrets.io/v1alpha1) — composed via +# provider-kubernetes Object since PushSecret isn't a Crossplane MR. +# +# Pushes the PAT from the control-plane K8s Secret (the PAT MR's +# connection secret) into the AWS SM Secret composed in step 300. +# Consumer-cluster ESO ExternalSecrets then pull from AWS SM. +# +# Key translation: provider-upjet-zitadel writes the AccessToken's value +# under key `attribute.token` (the upstream Terraform attribute name). +# We rename it to `access_token` in AWS SM to match the conventional +# Zitadel PC credential key (per reference_zitadel_pc_credentials_shape). +# + +{{- if and $state.pat.enabled $state.pat.pushToAwsSm (ne $state.observed.patTokenId "") }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ printf "%s-pushsecret" $state.name }} + annotations: + {{ setResourceNameAnnotation "eso-pushsecret" }} + labels: + {{- range $k, $v := $state.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + hops.ops.com.ai/auth-machineuser: {{ $state.name | quote }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: external-secrets.io/v1alpha1 + kind: PushSecret + metadata: + name: {{ printf "%s-pat-push" $state.name }} + namespace: {{ $state.namespace }} + labels: + hops.ops.com.ai/managed: "true" + hops.ops.com.ai/auth-machineuser: {{ $state.name | quote }} + spec: + refreshInterval: 1h + secretStoreRefs: + - name: {{ $state.pat.awsSm.esoStoreName | quote }} + kind: {{ $state.pat.awsSm.esoStoreKind | quote }} + selector: + secret: + name: {{ $state.pat.secretName | quote }} + data: + # Source key (control-plane K8s Secret): attribute.token — + # the upstream provider-upjet-zitadel attribute name. Pushed + # to AWS SM as the convention `access_token` so consumer + # ExternalSecrets see the expected Zitadel PC key shape. + - match: + secretKey: attribute.token + remoteRef: + remoteKey: {{ $state.pat.awsSm.path | quote }} + property: access_token + deletionPolicy: {{ $state.pat.awsSm.esoDeletionPolicy | quote }} + providerConfigRef: + name: {{ $state.pat.awsSm.esoK8sProviderConfigName | quote }} + kind: {{ $state.pat.awsSm.esoK8sProviderConfigKind | quote }} +{{- end }} diff --git a/functions/machineuser/999-status.yaml.gotmpl b/functions/machineuser/999-status.yaml.gotmpl new file mode 100644 index 0000000..523587d --- /dev/null +++ b/functions/machineuser/999-status.yaml.gotmpl @@ -0,0 +1,30 @@ +# code: language=yaml +# +# Output XR status. +# + +{{- $xr := getCompositeResource . }} + +--- +apiVersion: {{ $xr.apiVersion }} +kind: {{ $xr.kind }} +status: + ready: {{ $state.status.ready }} + userId: {{ $state.status.userId | default "" | quote }} + loginName: {{ $state.status.loginName | default "" | quote }} + {{- if $state.pat.enabled }} + pat: + enabled: true + tokenId: {{ $state.status.patTokenId | default "" | quote }} + controlPlaneSecretRef: + name: {{ $state.pat.secretName | quote }} + namespace: {{ $state.namespace | quote }} + {{- if $state.pat.pushToAwsSm }} + awsSm: + secretArn: {{ $state.status.awsSmArn | default "" | quote }} + secretName: {{ $state.pat.awsSm.path | quote }} + {{- end }} + {{- else }} + pat: + enabled: false + {{- end }} diff --git a/tests/test-machineuser/kcl.mod b/tests/test-machineuser/kcl.mod new file mode 100644 index 0000000..e2b40d0 --- /dev/null +++ b/tests/test-machineuser/kcl.mod @@ -0,0 +1,6 @@ +[package] +name = "test-render" +version = "0.0.1" + +[dependencies] +models = { path = "./model" } diff --git a/tests/test-machineuser/main.k b/tests/test-machineuser/main.k new file mode 100644 index 0000000..e4f80c7 --- /dev/null +++ b/tests/test-machineuser/main.k @@ -0,0 +1,167 @@ +import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 + +# ============================================================================== +# Unit tests for MachineUser XRD (auth.hops.ops.com.ai) +# +# Verifies first-iteration emission + the conditional behaviors: +# - pat.enabled default false → only MachineUser MR composes +# - pat.enabled true → AccessToken composes once userId observed +# - pat.pushToAwsSm true → AWS SM Secret + ESO PushSecret compose once +# patTokenId observed +# +# Multi-iter convergence (Project + Roles + AWS-SM/PushSecret after their +# upstream observations) is verified via `up composition render +# --observed-resources` against tests/test-machineuser/observed/*.yaml. +# ============================================================================== + +_items = [ + # ========================================================================== + # Test 1: minimal — only MachineUser MR composes; no PAT, no push. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "minimal-machineuser-only" + spec = { + compositionPath = "apis/machineusers/composition.yaml" + xrdPath = "apis/machineusers/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "MachineUser" + metadata = {name = "zitadel-provider", namespace = "default"} + spec = { + orgId = "373268222482392664" + displayName = "zitadel-provider" + accessTokenType = "ACCESS_TOKEN_TYPE_JWT" + providerConfigRef = {name = "zitadel-tenant-stack", kind = "ProviderConfig"} + } + } + assertResources = [ + { + apiVersion = "user.zitadel.m.crossplane.io/v1alpha1" + kind = "MachineUser" + metadata.name = "zitadel-provider" + spec = { + forProvider = { + orgId = "373268222482392664" + userName = "zitadel-provider" + name = "zitadel-provider" + accessTokenType = "ACCESS_TOKEN_TYPE_JWT" + } + providerConfigRef = {name = "zitadel-tenant-stack", kind = "ProviderConfig"} + } + } + ] + } + }, + + # ========================================================================== + # Test 2: pat.enabled (iter 1) — AccessToken gated until userId observed, + # so iter-1 still emits only MachineUser. AccessToken emission is + # verified separately via observed-resources fixture. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "pat-enabled-iter1-still-only-machineuser" + spec = { + compositionPath = "apis/machineusers/composition.yaml" + xrdPath = "apis/machineusers/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "MachineUser" + metadata = {name = "ci-bot", namespace = "default"} + spec = { + orgId = "373268222482392664" + pat = {enabled = True} + providerConfigRef = {name = "zitadel-tenant-stack", kind = "ProviderConfig"} + } + } + assertResources = [ + { + apiVersion = "user.zitadel.m.crossplane.io/v1alpha1" + kind = "MachineUser" + metadata.name = "ci-bot" + } + ] + } + }, + + # ========================================================================== + # Test 3: adoption — spec.machineUserId propagates as external-name; + # managementPolicies excludes Create. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "adopt-existing-machineuser-via-id" + spec = { + compositionPath = "apis/machineusers/composition.yaml" + xrdPath = "apis/machineusers/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "MachineUser" + metadata = {name = "iam-admin", namespace = "default"} + spec = { + orgId = "373268222482392664" + machineUserId = "existing-iam-admin-uuid" + providerConfigRef = {name = "zitadel-tenant-stack", kind = "ProviderConfig"} + managementPolicies = ["Observe", "Update", "LateInitialize"] + } + } + assertResources = [ + { + apiVersion = "user.zitadel.m.crossplane.io/v1alpha1" + kind = "MachineUser" + metadata = { + name = "iam-admin" + annotations = {"crossplane.io/external-name" = "existing-iam-admin-uuid"} + } + spec.managementPolicies = ["Observe", "Update", "LateInitialize"] + } + ] + } + }, + + # ========================================================================== + # Test 4: userName + displayName + description override defaults. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "field-overrides" + spec = { + compositionPath = "apis/machineusers/composition.yaml" + xrdPath = "apis/machineusers/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "MachineUser" + metadata = {name = "openpanel-provider", namespace = "default"} + spec = { + orgId = "373268222482392664" + userName = "openpanel-crossplane-admin" + displayName = "OpenPanel Crossplane Admin" + description = "/manage credential for provider-upjet-openpanel" + accessTokenType = "ACCESS_TOKEN_TYPE_BEARER" + providerConfigRef = {name = "zitadel-tenant-stack", kind = "ProviderConfig"} + } + } + assertResources = [ + { + apiVersion = "user.zitadel.m.crossplane.io/v1alpha1" + kind = "MachineUser" + metadata.name = "openpanel-provider" + spec.forProvider = { + orgId = "373268222482392664" + userName = "openpanel-crossplane-admin" + name = "OpenPanel Crossplane Admin" + description = "/manage credential for provider-upjet-openpanel" + accessTokenType = "ACCESS_TOKEN_TYPE_BEARER" + } + } + ] + } + }, +] + +items = _items diff --git a/tests/test-machineuser/model b/tests/test-machineuser/model new file mode 120000 index 0000000..faff6e4 --- /dev/null +++ b/tests/test-machineuser/model @@ -0,0 +1 @@ +../../.up/kcl/models \ No newline at end of file diff --git a/tests/test-machineuser/observed/iter2-userid.yaml b/tests/test-machineuser/observed/iter2-userid.yaml new file mode 100644 index 0000000..ec0ca5c --- /dev/null +++ b/tests/test-machineuser/observed/iter2-userid.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: user.zitadel.m.crossplane.io/v1alpha1 +kind: MachineUser +metadata: + name: openpanel-provider + annotations: + crossplane.io/composition-resource-name: machineuser +status: + atProvider: + id: "373597474725571419" + loginName: "openpanel-provider@ops-com-ai.zitadel.example" + conditions: + - type: Ready + status: "True" diff --git a/tests/test-machineuser/observed/iter3-patid.yaml b/tests/test-machineuser/observed/iter3-patid.yaml new file mode 100644 index 0000000..013a8e1 --- /dev/null +++ b/tests/test-machineuser/observed/iter3-patid.yaml @@ -0,0 +1,27 @@ +--- +apiVersion: user.zitadel.m.crossplane.io/v1alpha1 +kind: MachineUser +metadata: + name: openpanel-provider + annotations: + crossplane.io/composition-resource-name: machineuser +status: + atProvider: + id: "373597474725571419" + loginName: "openpanel-provider@ops-com-ai.zitadel.example" + conditions: + - type: Ready + status: "True" +--- +apiVersion: user.zitadel.m.crossplane.io/v1alpha1 +kind: AccessToken +metadata: + name: openpanel-provider-pat + annotations: + crossplane.io/composition-resource-name: pat +status: + atProvider: + id: "373597495462210395" + conditions: + - type: Ready + status: "True" diff --git a/upbound.yaml b/upbound.yaml index 905810a..98dd6bf 100644 --- a/upbound.yaml +++ b/upbound.yaml @@ -16,10 +16,19 @@ spec: kind: Provider package: xpkg.crossplane.io/crossplane-contrib/provider-kubernetes version: '>=v1' + - apiVersion: pkg.crossplane.io/v1 + kind: Provider + package: xpkg.crossplane.io/crossplane-contrib/provider-upjet-zitadel + version: '>=v0.1.1' + - apiVersion: pkg.crossplane.io/v1 + kind: Provider + package: xpkg.crossplane.io/crossplane-contrib/provider-aws-secretsmanager + version: '>=v2.5.0' description: AuthStack installs Zitadel as the platform identity provider via the upstream Helm chart, with typed integration points for PostgreSQL and Gateway - API. Also hosts a focused set of auth.hops.ops.com.ai primitive XRDs (HumanUser, - MachineUser, Grant, IDP) that compose against the installed Zitadel. + API. Also hosts the auth.hops.ops.com.ai primitive XRDs that wrap multi-resource + Zitadel patterns — currently MachineUser (with opt-in PAT + AWS SM push pipeline); + Grant planned next. license: Apache-2.0 maintainer: Patrick Lee Scott readme: |