From 6ea7a9f83422df9fef5fb84f539acfb01187363f Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sun, 30 Aug 2026 03:14:49 -0500 Subject: [PATCH] feat: add human user auth primitive --- .github/workflows/on-pr.yaml | 2 + .github/workflows/on-push-main.yaml | 2 + .gitignore | 4 + Makefile | 2 + README.md | 24 ++- apis/grants/composition.yaml | 33 +++ apis/grants/definition.yaml | 7 +- apis/humanusers/composition.yaml | 38 ++++ apis/humanusers/definition.yaml | 177 +++++++++++++++ examples/humanusers/explicit-org.yaml | 13 ++ examples/humanusers/with-org-ref.yaml | 20 ++ functions/grant/000-state-init.yaml.gotmpl | 24 +-- functions/grant/010-state-status.yaml.gotmpl | 26 ++- .../humanuser/000-state-init.yaml.gotmpl | 38 ++++ .../humanuser/010-state-status.yaml.gotmpl | 47 ++++ functions/humanuser/100-humanuser.yaml.gotmpl | 59 +++++ functions/humanuser/999-status.yaml.gotmpl | 14 ++ tests/test-grant/main.k | 56 +++++ tests/test-humanuser/kcl.mod | 6 + tests/test-humanuser/main.k | 204 ++++++++++++++++++ tests/test-humanuser/model | 1 + upbound.yaml | 9 +- 22 files changed, 765 insertions(+), 41 deletions(-) create mode 100644 apis/humanusers/composition.yaml create mode 100644 apis/humanusers/definition.yaml create mode 100644 examples/humanusers/explicit-org.yaml create mode 100644 examples/humanusers/with-org-ref.yaml create mode 100644 functions/humanuser/000-state-init.yaml.gotmpl create mode 100644 functions/humanuser/010-state-status.yaml.gotmpl create mode 100644 functions/humanuser/100-humanuser.yaml.gotmpl create mode 100644 functions/humanuser/999-status.yaml.gotmpl create mode 100644 tests/test-humanuser/kcl.mod create mode 100644 tests/test-humanuser/main.k create mode 120000 tests/test-humanuser/model diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml index 75695ab..0e3e216 100644 --- a/.github/workflows/on-pr.yaml +++ b/.github/workflows/on-pr.yaml @@ -38,6 +38,8 @@ jobs: { "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" }, + { "example": "examples/humanusers/explicit-org.yaml", "api_path": "apis/humanusers" }, + { "example": "examples/humanusers/with-org-ref.yaml", "api_path": "apis/humanusers" }, { "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 a6fe04f..081b072 100644 --- a/.github/workflows/on-push-main.yaml +++ b/.github/workflows/on-push-main.yaml @@ -33,6 +33,8 @@ jobs: { "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" }, + { "example": "examples/humanusers/explicit-org.yaml", "api_path": "apis/humanusers" }, + { "example": "examples/humanusers/with-org-ref.yaml", "api_path": "apis/humanusers" }, { "example": "examples/grants/same-org.yaml", "api_path": "apis/grants" }, { "example": "examples/grants/cross-org.yaml", "api_path": "apis/grants" } ] diff --git a/.gitignore b/.gitignore index 5ec17d7..6f004b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ _output/ .up/ +.venv/ +.tmp/ node_modules/ .DS_Store apis/**/configuration.yaml +**/aws-creds +tests/**/secrets/ diff --git a/Makefile b/Makefile index cc81361..eb2cb4f 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,8 @@ EXAMPLES := \ examples/machineusers/minimal.yaml:: \ examples/machineusers/with-pat.yaml:: \ examples/machineusers/with-pat-push.yaml:: \ + examples/humanusers/explicit-org.yaml:: \ + examples/humanusers/with-org-ref.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 de4f44b..1050eaa 100644 --- a/README.md +++ b/README.md @@ -82,16 +82,34 @@ status: ## Auth-group primitives -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. +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. Status: | Kind | Plural | Composes | 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) | ✓ | -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. +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. + +### `HumanUser` + +Declarative Zitadel human identity with organization-reference resolution. The +upstream provider documents `orgId` as optional, but its HumanUser v2 create path +sends an invalid empty organization when it is omitted. `HumanUser` resolves a +concrete organization UUID from a stable local resource such as a Zitadel +Project's `status.atProvider.orgId`, then renders the raw provider HumanUser. + +Use `spec.orgIdRef` for GitOps so generated UUIDs do not enter the repository, or +use explicit `spec.orgId` for adoption and external integrations. Initial +passwords are accepted only by namespaced Secret reference. Typed status exposes +`userId`, `orgId`, and `loginName` for `Grant` and other consumers. The composed +resource remains rendered from its own observed `orgId` if the reference lookup +temporarily disappears. + +See `examples/humanusers/{with-org-ref,explicit-org}.yaml`. ### `MachineUser` @@ -105,7 +123,7 @@ See `examples/machineusers/{minimal,with-pat,with-pat-push}.yaml`. ### `Grant` -First-class membership relationship that ties a Zitadel User to a Project + Roles. For GitOps, prefer local references: `userIdRef` points to a HumanUser or MachineUser MR and `projectIdRef` points to a Project MR in the Grant namespace. The composition resolves IDs and Org IDs from each resource's `status.atProvider`, so no live Zitadel UUIDs need to be committed. Explicit `userId + userOrgId + projectId + projectOrgId` inputs remain available for adoption and cross-stack cases. +First-class membership relationship that ties a Zitadel User to a Project + Roles. For GitOps, prefer local references: `userIdRef` points to a raw HumanUser/MachineUser MR or the Hops `HumanUser` XR, and `projectIdRef` points to a Project MR in the Grant namespace. The composition resolves the Hops XR's typed status or raw resources' `status.atProvider`, so no live Zitadel UUIDs need to be committed. Explicit `userId + userOrgId + projectId + projectOrgId` inputs remain available for adoption and cross-stack cases. Polymorphic dispatch then picks the right Zitadel mechanism: diff --git a/apis/grants/composition.yaml b/apis/grants/composition.yaml index b6cac5c..98aee3c 100644 --- a/apis/grants/composition.yaml +++ b/apis/grants/composition.yaml @@ -8,6 +8,39 @@ spec: kind: Grant mode: Pipeline pipeline: + - functionRef: + name: crossplane-contrib-function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{- $xr := getCompositeResource . }} + {{- $spec := $xr.spec | default dict }} + {{- $userRef := $spec.userIdRef | default dict }} + {{- $projectRef := $spec.projectIdRef | default dict }} + {{- $namespace := $xr.metadata.namespace | default "default" }} + {{- if or $userRef.name $projectRef.name }} + apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 + kind: ExtraResources + requirements: + {{- if $userRef.name }} + referenced-user: + apiVersion: {{ $userRef.apiVersion | default "user.zitadel.m.crossplane.io/v1alpha1" | quote }} + kind: {{ $userRef.kind | default "HumanUser" | quote }} + matchName: {{ $userRef.name | quote }} + namespace: {{ $namespace | quote }} + {{- end }} + {{- if $projectRef.name }} + referenced-project: + apiVersion: {{ $projectRef.apiVersion | default "project.zitadel.m.crossplane.io/v1alpha1" | quote }} + kind: {{ $projectRef.kind | default "Project" | quote }} + matchName: {{ $projectRef.name | quote }} + namespace: {{ $namespace | quote }} + {{- end }} + {{- end }} + step: resolve-resource-references - functionRef: name: hops-ops-auth-stackgrant step: grant diff --git a/apis/grants/definition.yaml b/apis/grants/definition.yaml index 019854d..4886a30 100644 --- a/apis/grants/definition.yaml +++ b/apis/grants/definition.yaml @@ -50,9 +50,10 @@ spec: type: string userIdRef: description: | - Reference to a HumanUser or MachineUser managed resource in - the Grant namespace. The composition resolves userId and - userOrgId from status.atProvider. + Reference to a raw provider HumanUser/MachineUser managed + resource or an auth.hops.ops.com.ai HumanUser XR in the Grant + namespace. The composition resolves userId and userOrgId from + the reference's typed status or status.atProvider. Mutually exclusive with userId + userOrgId. type: object properties: diff --git a/apis/humanusers/composition.yaml b/apis/humanusers/composition.yaml new file mode 100644 index 0000000..047fe0d --- /dev/null +++ b/apis/humanusers/composition.yaml @@ -0,0 +1,38 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: humanusers.auth.hops.ops.com.ai +spec: + compositeTypeRef: + apiVersion: auth.hops.ops.com.ai/v1alpha1 + kind: HumanUser + mode: Pipeline + pipeline: + - functionRef: + name: crossplane-contrib-function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{- $xr := getCompositeResource . }} + {{- $spec := $xr.spec | default dict }} + {{- $ref := $spec.orgIdRef | default dict }} + {{- if $ref.name }} + apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 + kind: ExtraResources + requirements: + organization-id-source: + apiVersion: {{ $ref.apiVersion | default "project.zitadel.m.crossplane.io/v1alpha1" | quote }} + kind: {{ $ref.kind | default "Project" | quote }} + matchName: {{ $ref.name | quote }} + namespace: {{ $ref.namespace | default ($xr.metadata.namespace | default "default") | quote }} + {{- end }} + step: resolve-organization-reference + - functionRef: + name: hops-ops-auth-stackhumanuser + step: humanuser + - functionRef: + name: crossplane-contrib-function-auto-ready + step: crossplane-contrib-function-auto-ready diff --git a/apis/humanusers/definition.yaml b/apis/humanusers/definition.yaml new file mode 100644 index 0000000..9e750c7 --- /dev/null +++ b/apis/humanusers/definition.yaml @@ -0,0 +1,177 @@ +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: humanusers.auth.hops.ops.com.ai +spec: + group: auth.hops.ops.com.ai + names: + kind: HumanUser + plural: humanusers + scope: Namespaced + versions: + - name: v1alpha1 + referenceable: true + served: true + schema: + openAPIV3Schema: + description: | + HumanUser creates a Zitadel human identity and resolves its + organization ID from a stable local managed-resource reference. + This keeps generated Zitadel UUIDs out of consumer GitOps while + working around provider-upjet-zitadel's HumanUser create path, + which requires a concrete orgId even when the provider credential + already belongs to that organization. + type: object + properties: + spec: + description: HumanUserSpec defines the desired human identity. + type: object + properties: + orgId: + description: | + Explicit Zitadel organization UUID. Mutually exclusive with + orgIdRef; prefer orgIdRef for GitOps-created organizations. + type: string + minLength: 1 + orgIdRef: + description: | + Reference to a namespaced managed resource whose + status.atProvider.orgId supplies the Zitadel organization + UUID. Defaults to a provider-upjet-zitadel Project. + This is an ID source, not an ownership relationship. + type: object + properties: + name: + type: string + minLength: 1 + namespace: + description: Defaults to the HumanUser namespace. + type: string + minLength: 1 + apiVersion: + type: string + default: project.zitadel.m.crossplane.io/v1alpha1 + kind: + type: string + default: Project + required: + - name + humanUserId: + description: | + Existing Zitadel user UUID to adopt. Propagates as the raw + managed resource's crossplane.io/external-name annotation. + type: string + minLength: 1 + userName: + description: Zitadel username. Defaults to metadata.name. + type: string + minLength: 1 + firstName: + description: Human user's first name. + type: string + minLength: 1 + lastName: + description: Human user's last name. + type: string + minLength: 1 + displayName: + description: Optional display name. + type: string + nickName: + description: Optional nickname. + type: string + email: + description: Human user's email address. + type: string + minLength: 1 + phone: + description: Optional phone number. + type: string + preferredLanguage: + description: Optional preferred language, such as en. + type: string + gender: + description: Optional Zitadel gender enum value. + type: string + enum: + - GENDER_UNSPECIFIED + - GENDER_FEMALE + - GENDER_MALE + - GENDER_DIVERSE + isEmailVerified: + description: Whether Zitadel should mark the email verified. + type: boolean + isPhoneVerified: + description: Whether Zitadel should mark the phone verified. + type: boolean + initialSkipPasswordChange: + description: Whether the initial password may be kept on first login. + type: boolean + initialPasswordSecretRef: + description: | + Secret key containing the initial password. The Secret must + exist in the HumanUser namespace; password bytes never enter + the XR or composition function input. + type: object + properties: + name: + type: string + minLength: 1 + key: + type: string + minLength: 1 + required: + - name + - key + providerConfigRef: + description: ProviderConfig for provider-upjet-zitadel. + type: object + properties: + name: + type: string + minLength: 1 + kind: + type: string + default: ProviderConfig + enum: + - ProviderConfig + - ClusterProviderConfig + required: + - name + managementPolicies: + description: Crossplane managementPolicies for the composed HumanUser. + type: array + items: + type: string + default: ["*"] + labels: + description: | + Custom labels merged with Hops defaults and applied to the + composed managed resource. + type: object + additionalProperties: + type: string + x-kubernetes-preserve-unknown-fields: true + x-kubernetes-validations: + - rule: '(has(self.orgIdRef) && !has(self.orgId)) || (!has(self.orgIdRef) && has(self.orgId))' + message: "provide exactly one organization source: orgIdRef or orgId" + required: + - firstName + - lastName + - email + - providerConfigRef + status: + description: Observed identity values for downstream references. + type: object + properties: + userId: + description: Observed Zitadel user UUID. + type: string + orgId: + description: Observed Zitadel organization UUID. + type: string + loginName: + description: Observed preferred Zitadel login name. + type: string + required: + - spec diff --git a/examples/humanusers/explicit-org.yaml b/examples/humanusers/explicit-org.yaml new file mode 100644 index 0000000..3bf2797 --- /dev/null +++ b/examples/humanusers/explicit-org.yaml @@ -0,0 +1,13 @@ +apiVersion: auth.hops.ops.com.ai/v1alpha1 +kind: HumanUser +metadata: + name: bob + namespace: default +spec: + orgId: "373268222482392664" + firstName: Bob + lastName: Example + email: bob@example.com + providerConfigRef: + name: default + kind: ProviderConfig diff --git a/examples/humanusers/with-org-ref.yaml b/examples/humanusers/with-org-ref.yaml new file mode 100644 index 0000000..15be5b0 --- /dev/null +++ b/examples/humanusers/with-org-ref.yaml @@ -0,0 +1,20 @@ +apiVersion: auth.hops.ops.com.ai/v1alpha1 +kind: HumanUser +metadata: + name: alice + namespace: default +spec: + orgIdRef: + name: platform + firstName: Alice + lastName: Example + displayName: Alice + email: alice@example.com + isEmailVerified: true + initialSkipPasswordChange: true + initialPasswordSecretRef: + name: human-passwords + key: alice + providerConfigRef: + name: default + kind: ClusterProviderConfig diff --git a/functions/grant/000-state-init.yaml.gotmpl b/functions/grant/000-state-init.yaml.gotmpl index 267bdd1..0581c88 100644 --- a/functions/grant/000-state-init.yaml.gotmpl +++ b/functions/grant/000-state-init.yaml.gotmpl @@ -56,25 +56,5 @@ "status" (dict) }} -# Ask Crossplane for local user/project managed resources referenced by name. -# Namespaced extra resources are resolved in the Grant XR's namespace. -{{- if or $state.userRefName $state.projectRefName }} ---- -apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 -kind: ExtraResources -requirements: - {{- if $state.userRefName }} - referenced-user: - apiVersion: {{ $state.userRefApiVersion | quote }} - kind: {{ $state.userRefKind | quote }} - matchName: {{ $state.userRefName | quote }} - namespace: {{ $state.namespace | quote }} - {{- end }} - {{- if $state.projectRefName }} - referenced-project: - apiVersion: {{ $state.projectRefApiVersion | quote }} - kind: {{ $state.projectRefKind | quote }} - matchName: {{ $state.projectRefName | quote }} - namespace: {{ $state.namespace | quote }} - {{- end }} -{{- end }} +# The preceding generic template function resolves namespaced user/project +# references into pipeline context. diff --git a/functions/grant/010-state-status.yaml.gotmpl b/functions/grant/010-state-status.yaml.gotmpl index 5e289c6..e8bf3dd 100644 --- a/functions/grant/010-state-status.yaml.gotmpl +++ b/functions/grant/010-state-status.yaml.gotmpl @@ -6,18 +6,20 @@ # Resolve referenced managed resources from status.atProvider. Rendered grants # are gated on IDs existing, not on Ready=True, so a transient readiness # condition does not remove an already resolvable grant. -{{- $extra := $.extraResources | default dict }} +{{- $resources := get ($.context | default dict) "apiextensions.crossplane.io/extra-resources" | default dict }} {{- if $state.userRefName }} - {{- $matches := get $extra "referenced-user" | default dict }} - {{- $users := $matches.items | default list }} - {{- range $users }} - {{- $user := .resource | default dict }} + {{- $matches := get $resources "referenced-user" | default dict }} + {{- range ($matches.items | default list) }} + {{- $user := . }} + {{- if hasKey $user "resource" }} + {{- $user = $user.resource | default dict }} + {{- end }} {{- if eq (($user.metadata | default dict).name | default "") $state.userRefName }} {{- $status := $user.status | default dict }} {{- $atProvider := $status.atProvider | default dict }} - {{- $_ := set $state "userId" ($atProvider.id | default "") }} - {{- $_ := set $state "userOrgId" ($atProvider.orgId | default "") }} + {{- $_ := set $state "userId" ($status.userId | default ($atProvider.id | default "")) }} + {{- $_ := set $state "userOrgId" ($status.orgId | default ($atProvider.orgId | default "")) }} {{- range ($status.conditions | default list) }} {{- if and (eq .type "Ready") (eq .status "True") }} {{- $_ := set $state "userRefReady" true }} @@ -28,10 +30,12 @@ {{- end }} {{- if $state.projectRefName }} - {{- $matches := get $extra "referenced-project" | default dict }} - {{- $projects := $matches.items | default list }} - {{- range $projects }} - {{- $project := .resource | default dict }} + {{- $matches := get $resources "referenced-project" | default dict }} + {{- range ($matches.items | default list) }} + {{- $project := . }} + {{- if hasKey $project "resource" }} + {{- $project = $project.resource | default dict }} + {{- end }} {{- if eq (($project.metadata | default dict).name | default "") $state.projectRefName }} {{- $status := $project.status | default dict }} {{- $atProvider := $status.atProvider | default dict }} diff --git a/functions/humanuser/000-state-init.yaml.gotmpl b/functions/humanuser/000-state-init.yaml.gotmpl new file mode 100644 index 0000000..f151a71 --- /dev/null +++ b/functions/humanuser/000-state-init.yaml.gotmpl @@ -0,0 +1,38 @@ +# code: language=yaml +# +# Initialize desired state. The preceding generic template function resolves +# the optional namespaced org-ID source into pipeline context. +# + +{{- $xr := getCompositeResource . }} +{{- $spec := $xr.spec | default dict }} +{{- $metadata := $xr.metadata | default dict }} +{{- $name := $metadata.name }} +{{- $namespace := $metadata.namespace | default "default" }} + +{{- $orgIdRef := $spec.orgIdRef | default dict }} +{{- $providerConfigRef := $spec.providerConfigRef }} +{{- $defaultLabels := dict + "hops.ops.com.ai/managed" "true" + "hops.ops.com.ai/auth-humanuser" $name +}} + +{{- $state := dict + "spec" $spec + "name" $name + "namespace" $namespace + "userName" ($spec.userName | default $name) + "humanUserId" ($spec.humanUserId | default "") + "explicitOrgId" ($spec.orgId | default "") + "orgRefName" ($orgIdRef.name | default "") + "orgRefNamespace" ($orgIdRef.namespace | default $namespace) + "orgRefApiVersion" ($orgIdRef.apiVersion | default "project.zitadel.m.crossplane.io/v1alpha1") + "orgRefKind" ($orgIdRef.kind | default "Project") + "resolvedOrgId" "" + "managementPolicies" ($spec.managementPolicies | default (list "*")) + "labels" (merge (dict) $defaultLabels ($spec.labels | default dict)) + "providerConfigName" $providerConfigRef.name + "providerConfigKind" ($providerConfigRef.kind | default "ProviderConfig") + "observed" (dict) + "status" (dict) +}} diff --git a/functions/humanuser/010-state-status.yaml.gotmpl b/functions/humanuser/010-state-status.yaml.gotmpl new file mode 100644 index 0000000..60d5ea5 --- /dev/null +++ b/functions/humanuser/010-state-status.yaml.gotmpl @@ -0,0 +1,47 @@ +# code: language=yaml +# +# Resolve the organization ID and extract typed observed status. The composed +# HumanUser's observed orgId is a sticky fallback so a transient ExtraResources +# lookup miss never un-renders an existing external user. +# + +{{- $observed := $.observed.resources | default dict }} +{{- $humanEntry := get $observed "humanuser" | default dict }} +{{- $humanResource := $humanEntry.resource | default dict }} +{{- $humanStatus := $humanResource.status | default dict }} +{{- $humanAtProvider := $humanStatus.atProvider | default dict }} +{{- $observedUserId := $humanAtProvider.id | default "" }} +{{- $observedOrgId := $humanAtProvider.orgId | default "" }} +{{- $observedLoginName := $humanAtProvider.preferredLoginName | default "" }} +{{- if and (eq $observedLoginName "") (gt (len ($humanAtProvider.loginNames | default list)) 0) }} + {{- $observedLoginName = first $humanAtProvider.loginNames }} +{{- end }} + +{{- $resolvedOrgId := $state.explicitOrgId }} +{{- if $state.orgRefName }} + {{- $resources := get ($.context | default dict) "apiextensions.crossplane.io/extra-resources" | default dict }} + {{- $matches := get $resources "organization-id-source" | default dict }} + {{- range ($matches.items | default list) }} + {{- $resource := . }} + {{- if hasKey $resource "resource" }} + {{- $resource = $resource.resource | default dict }} + {{- end }} + {{- $resourceMetadata := $resource.metadata | default dict }} + {{- if and (eq ($resourceMetadata.name | default "") $state.orgRefName) (eq ($resourceMetadata.namespace | default "default") $state.orgRefNamespace) }} + {{- $status := $resource.status | default dict }} + {{- $atProvider := $status.atProvider | default dict }} + {{- $resolvedOrgId = $atProvider.orgId | default ($status.orgId | default "") }} + {{- end }} + {{- end }} +{{- end }} +{{- if and (eq $resolvedOrgId "") (ne $observedOrgId "") }} + {{- $resolvedOrgId = $observedOrgId }} +{{- end }} + +{{- $_ := set $state "resolvedOrgId" $resolvedOrgId }} +{{- $_ := set $state.observed "userId" $observedUserId }} +{{- $_ := set $state.observed "orgId" $observedOrgId }} +{{- $_ := set $state.observed "loginName" $observedLoginName }} +{{- $_ := set $state.status "userId" $observedUserId }} +{{- $_ := set $state.status "orgId" $observedOrgId }} +{{- $_ := set $state.status "loginName" $observedLoginName }} diff --git a/functions/humanuser/100-humanuser.yaml.gotmpl b/functions/humanuser/100-humanuser.yaml.gotmpl new file mode 100644 index 0000000..1ab090a --- /dev/null +++ b/functions/humanuser/100-humanuser.yaml.gotmpl @@ -0,0 +1,59 @@ +# code: language=yaml +# +# Raw provider HumanUser. Render continuously so function-auto-ready cannot +# treat an unresolved, empty desired set as ready. Crossplane supplies requested +# resources within the same pipeline run; if the source is genuinely absent, +# the raw user remains not ready until a concrete orgId arrives. +# + +--- +apiVersion: user.zitadel.m.crossplane.io/v1alpha1 +kind: HumanUser +metadata: + name: {{ $state.name }} + annotations: + {{ setResourceNameAnnotation "humanuser" }} + {{- if ne $state.humanUserId "" }} + crossplane.io/external-name: {{ $state.humanUserId | quote }} + {{- end }} + labels: {{ $state.labels | toJson }} +spec: + forProvider: + orgId: {{ $state.resolvedOrgId | quote }} + userName: {{ $state.userName | quote }} + firstName: {{ $state.spec.firstName | quote }} + lastName: {{ $state.spec.lastName | quote }} + email: {{ $state.spec.email | quote }} + {{- if ne ($state.spec.displayName | default "") "" }} + displayName: {{ $state.spec.displayName | quote }} + {{- end }} + {{- if ne ($state.spec.nickName | default "") "" }} + nickName: {{ $state.spec.nickName | quote }} + {{- end }} + {{- if ne ($state.spec.phone | default "") "" }} + phone: {{ $state.spec.phone | quote }} + {{- end }} + {{- if ne ($state.spec.preferredLanguage | default "") "" }} + preferredLanguage: {{ $state.spec.preferredLanguage | quote }} + {{- end }} + {{- if ne ($state.spec.gender | default "") "" }} + gender: {{ $state.spec.gender | quote }} + {{- end }} + {{- if hasKey $state.spec "isEmailVerified" }} + isEmailVerified: {{ $state.spec.isEmailVerified }} + {{- end }} + {{- if hasKey $state.spec "isPhoneVerified" }} + isPhoneVerified: {{ $state.spec.isPhoneVerified }} + {{- end }} + {{- if hasKey $state.spec "initialSkipPasswordChange" }} + initialSkipPasswordChange: {{ $state.spec.initialSkipPasswordChange }} + {{- end }} + {{- with $state.spec.initialPasswordSecretRef }} + initialPasswordSecretRef: + name: {{ .name | quote }} + key: {{ .key | quote }} + {{- end }} + managementPolicies: {{ $state.managementPolicies | toJson }} + providerConfigRef: + name: {{ $state.providerConfigName | quote }} + kind: {{ $state.providerConfigKind | quote }} diff --git a/functions/humanuser/999-status.yaml.gotmpl b/functions/humanuser/999-status.yaml.gotmpl new file mode 100644 index 0000000..ccc235f --- /dev/null +++ b/functions/humanuser/999-status.yaml.gotmpl @@ -0,0 +1,14 @@ +# code: language=yaml +# +# Typed integration status. Canonical readiness remains the Crossplane Ready +# condition produced by function-auto-ready; do not duplicate it here. +# + +{{- $xr := getCompositeResource . }} +--- +apiVersion: {{ $xr.apiVersion }} +kind: {{ $xr.kind }} +status: + userId: {{ $state.status.userId | default "" | quote }} + orgId: {{ $state.status.orgId | default "" | quote }} + loginName: {{ $state.status.loginName | default "" | quote }} diff --git a/tests/test-grant/main.k b/tests/test-grant/main.k index d001551..a7c04f5 100644 --- a/tests/test-grant/main.k +++ b/tests/test-grant/main.k @@ -195,6 +195,62 @@ _items = [ ] } }, + + # ========================================================================== + # Test 6: a Hops HumanUser reference uses its typed status contract while + # the Project reference continues to use raw provider atProvider status. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "hops-human-user-reference-resolves" + spec = { + compositionPath = "apis/grants/composition.yaml" + xrdPath = "apis/grants/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "Grant" + metadata = {name = "alice-grant", namespace = "default"} + spec = { + userIdRef = { + name = "e2e-alice" + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "HumanUser" + } + projectIdRef = {name = "e2e-ui"} + roles = ["user"] + providerConfigRef = {name = "default", kind = "ClusterProviderConfig"} + } + } + extraResources = [ + { + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "HumanUser" + metadata = {name = "e2e-alice", namespace = "default"} + status = {userId = "user-123", orgId = "org-456", loginName = "alice"} + } + { + apiVersion = "project.zitadel.m.crossplane.io/v1alpha1" + kind = "Project" + metadata = {name = "e2e-ui", namespace = "default"} + status.atProvider = {id = "project-123", orgId = "org-456"} + } + ] + assertResources = [ + { + apiVersion = "user.zitadel.m.crossplane.io/v1alpha1" + kind = "Grant" + metadata.name = "alice-grant-user-grant" + spec.forProvider = { + orgId = "org-456" + projectId = "project-123" + userId = "user-123" + roleKeys = ["user"] + } + } + ] + } + }, ] items = _items diff --git a/tests/test-humanuser/kcl.mod b/tests/test-humanuser/kcl.mod new file mode 100644 index 0000000..038bb53 --- /dev/null +++ b/tests/test-humanuser/kcl.mod @@ -0,0 +1,6 @@ +[package] +name = "test-humanuser" +version = "0.0.1" + +[dependencies] +models = { path = "./model" } diff --git a/tests/test-humanuser/main.k b/tests/test-humanuser/main.k new file mode 100644 index 0000000..a5067b2 --- /dev/null +++ b/tests/test-humanuser/main.k @@ -0,0 +1,204 @@ +import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 + +_base_spec = { + firstName = "Alice" + lastName = "Example" + email = "alice@example.com" + providerConfigRef = {name = "default", kind = "ClusterProviderConfig"} +} + +_items = [ + # Explicit IDs render on the first iteration and map the intent fields. + metav1alpha1.CompositionTest { + metadata.name = "explicit-org-renders-human-user" + spec = { + compositionPath = "apis/humanusers/composition.yaml" + xrdPath = "apis/humanusers/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "HumanUser" + metadata = {name = "alice", namespace = "default"} + spec = _base_spec | { + orgId = "org-123" + displayName = "Alice" + isEmailVerified = True + initialSkipPasswordChange = True + initialPasswordSecretRef = {name = "human-passwords", key = "alice"} + } + } + assertResources = [ + { + apiVersion = "user.zitadel.m.crossplane.io/v1alpha1" + kind = "HumanUser" + metadata = { + name = "alice" + labels = { + "hops.ops.com.ai/managed" = "true" + "hops.ops.com.ai/auth-humanuser" = "alice" + } + } + spec = { + forProvider = { + orgId = "org-123" + userName = "alice" + firstName = "Alice" + lastName = "Example" + email = "alice@example.com" + displayName = "Alice" + isEmailVerified = True + initialSkipPasswordChange = True + initialPasswordSecretRef = {name = "human-passwords", key = "alice"} + } + providerConfigRef = {name = "default", kind = "ClusterProviderConfig"} + } + } + ] + } + } + + # A referenced Project supplies status.atProvider.orgId without requiring a + # generated UUID in the HumanUser spec. + metav1alpha1.CompositionTest { + metadata.name = "project-reference-resolves-org" + spec = { + compositionPath = "apis/humanusers/composition.yaml" + xrdPath = "apis/humanusers/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "HumanUser" + metadata = {name = "alice", namespace = "default"} + spec = _base_spec | {orgIdRef = {name = "e2e-ui"}} + } + extraResources = [ + { + apiVersion = "project.zitadel.m.crossplane.io/v1alpha1" + kind = "Project" + metadata = {name = "e2e-ui", namespace = "default"} + status.atProvider = {id = "project-123", orgId = "org-456"} + } + ] + assertResources = [ + { + apiVersion = "user.zitadel.m.crossplane.io/v1alpha1" + kind = "HumanUser" + metadata.name = "alice" + spec.forProvider.orgId = "org-456" + } + ] + } + } + + # An unresolved reference still renders the raw resource so canonical + # readiness cannot report an empty desired set as ready. + metav1alpha1.CompositionTest { + metadata.name = "missing-project-keeps-user-pending" + spec = { + compositionPath = "apis/humanusers/composition.yaml" + xrdPath = "apis/humanusers/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "HumanUser" + metadata = {name = "alice", namespace = "default"} + spec = _base_spec | {orgIdRef = {name = "e2e-ui"}} + } + assertResources = [ + { + apiVersion = "user.zitadel.m.crossplane.io/v1alpha1" + kind = "HumanUser" + metadata.name = "alice" + spec.forProvider.orgId = "" + } + ] + } + } + + # A transient source lookup miss does not un-render an existing user. The + # composed user's observed orgId is an existence-stable fallback. + metav1alpha1.CompositionTest { + metadata.name = "observed-user-keeps-rendering-without-source" + spec = { + compositionPath = "apis/humanusers/composition.yaml" + xrdPath = "apis/humanusers/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "HumanUser" + metadata = {name = "alice", namespace = "default"} + spec = _base_spec | {orgIdRef = {name = "e2e-ui"}} + } + observedResources = [ + { + apiVersion = "user.zitadel.m.crossplane.io/v1alpha1" + kind = "HumanUser" + metadata = { + name = "alice" + namespace = "default" + annotations = { + "crossplane.io/composition-resource-name" = "humanuser" + "gotemplating.fn.crossplane.io/composition-resource-name" = "humanuser" + } + } + status = { + atProvider = {id = "user-123", orgId = "org-456", preferredLoginName = "alice"} + conditions = [{type = "Ready", status = "False"}] + } + } + ] + assertResources = [ + { + apiVersion = "user.zitadel.m.crossplane.io/v1alpha1" + kind = "HumanUser" + metadata.name = "alice" + spec.forProvider.orgId = "org-456" + } + { + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "HumanUser" + metadata.name = "alice" + status = {userId = "user-123", orgId = "org-456", loginName = "alice"} + } + ] + } + } + + # Adoption propagates only the stable external identifier and policies. + metav1alpha1.CompositionTest { + metadata.name = "adopt-existing-human-user" + spec = { + compositionPath = "apis/humanusers/composition.yaml" + xrdPath = "apis/humanusers/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "HumanUser" + metadata = {name = "alice", namespace = "default"} + spec = _base_spec | { + orgId = "org-123" + humanUserId = "user-123" + managementPolicies = ["Observe", "Update", "LateInitialize"] + } + } + assertResources = [ + { + apiVersion = "user.zitadel.m.crossplane.io/v1alpha1" + kind = "HumanUser" + metadata = { + name = "alice" + annotations = {"crossplane.io/external-name" = "user-123"} + } + spec.managementPolicies = ["Observe", "Update", "LateInitialize"] + } + ] + } + } +] + +items = _items diff --git a/tests/test-humanuser/model b/tests/test-humanuser/model new file mode 120000 index 0000000..faff6e4 --- /dev/null +++ b/tests/test-humanuser/model @@ -0,0 +1 @@ +../../.up/kcl/models \ No newline at end of file diff --git a/upbound.yaml b/upbound.yaml index 75b72a4..c4b6544 100644 --- a/upbound.yaml +++ b/upbound.yaml @@ -8,6 +8,10 @@ spec: kind: Function package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready version: '^v0' + - apiVersion: pkg.crossplane.io/v1 + kind: Function + package: xpkg.crossplane.io/crossplane-contrib/function-go-templating + version: '>=v0.12.3' - apiVersion: pkg.crossplane.io/v1 kind: Provider package: xpkg.crossplane.io/crossplane-contrib/provider-helm @@ -27,8 +31,9 @@ spec: 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 the auth.hops.ops.com.ai primitive XRDs that wrap multi-resource - Zitadel patterns — MachineUser (with opt-in PAT + AWS SM push pipeline) and Grant - (polymorphic same-Org/cross-Org user-to-project-role assignment). + 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). license: Apache-2.0 maintainer: Patrick Lee Scott readme: |