From 6ad0907bc49db6d0d3eb75dcaf2b3e448cd141b0 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 19 Aug 2026 15:37:32 -0500 Subject: [PATCH] feat: resolve Grant managed resource references --- Makefile | 10 ++- README.md | 8 +- apis/grants/definition.yaml | 73 ++++++++++++++++--- examples/grants/referenced-same-org.yaml | 18 +++++ functions/grant/000-state-init.yaml.gotmpl | 52 ++++++++++--- functions/grant/010-state-status.yaml.gotmpl | 61 +++++++++++++++- functions/grant/100-project-grant.yaml.gotmpl | 27 ++++++- functions/grant/200-user-grant.yaml.gotmpl | 73 ++++++++++++++++++- functions/grant/999-status.yaml.gotmpl | 3 +- tests/test-grant/main.k | 34 +++++++++ .../test-grant/observed/cross-org-iter2.yaml | 17 +++++ 11 files changed, 339 insertions(+), 37 deletions(-) create mode 100644 examples/grants/referenced-same-org.yaml diff --git a/Makefile b/Makefile index 0fd59dd..cc81361 100644 --- a/Makefile +++ b/Makefile @@ -31,8 +31,10 @@ EXAMPLES := \ examples/machineusers/minimal.yaml:: \ examples/machineusers/with-pat.yaml:: \ examples/machineusers/with-pat-push.yaml:: \ + examples/grants/referenced-same-org.yaml:: \ examples/grants/same-org.yaml:: \ - examples/grants/cross-org.yaml:: + examples/grants/cross-org.yaml:: \ + examples/grants/cross-org.yaml::tests/test-grant/observed/cross-org-iter2.yaml # Render all examples (parallel execution, output shown per-job when complete) render\:all: @@ -83,12 +85,12 @@ validate\:all: echo "=== Validating $$example with observed-resources $$observed ==="; \ up composition render --xrd=$$definition $$composition $$example \ --observed-resources=$$observed --include-full-xr --quiet | \ - crossplane beta validate $$api_dir --error-on-missing-schemas -; \ + crossplane resource validate $$api_dir --error-on-missing-schemas -; \ else \ echo "=== Validating $$example (api=$$api_dir) ==="; \ up composition render --xrd=$$definition $$composition $$example \ --include-full-xr --quiet | \ - crossplane beta validate $$api_dir --error-on-missing-schemas -; \ + crossplane resource validate $$api_dir --error-on-missing-schemas -; \ fi; \ echo "" \ ) > "$$outfile" 2>&1 & \ @@ -118,7 +120,7 @@ validate\:%: @example="examples/authstacks/$*.yaml"; \ up composition render --xrd=$(DEFINITION) $(COMPOSITION) $$example \ --include-full-xr --quiet | \ - crossplane beta validate $(XRD_DIR) --error-on-missing-schemas - + crossplane resource validate $(XRD_DIR) --error-on-missing-schemas - test: up test run $(RENDER_TESTS) diff --git a/README.md b/README.md index e107648..de4f44b 100644 --- a/README.md +++ b/README.md @@ -105,12 +105,14 @@ See `examples/machineusers/{minimal,with-pat,with-pat-push}.yaml`. ### `Grant` -First-class membership relationship that ties a Zitadel User to a Project + Roles. Polymorphic dispatch — caller writes `userId + userOrgId + projectId + projectOrgId + roles` and the composition picks the right Zitadel mechanism: +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. + +Polymorphic dispatch then picks the right Zitadel mechanism: - **Same-Org** (`userOrgId == projectOrgId`): composes one `user.zitadel.m.crossplane.io/Grant` MR (the user's role assignment within the project). - **Cross-Org** (`userOrgId != projectOrgId`): composes a `project.zitadel.m.crossplane.io/Grant` (cross-Org Project Grant authorizing the role set for the user's home Org) plus a `user.zitadel.m.crossplane.io/Grant` with `projectGrantId` set (the user's role assignment, pulling roles from the granted set). Multi-iter: user/Grant emits once project/Grant is observed. -See `examples/grants/{same-org,cross-org}.yaml`. +See `examples/grants/{referenced-same-org,same-org,cross-org}.yaml`. ## Cross-Stack Integration @@ -122,7 +124,7 @@ See [[specs/auth-stack-zitadel]] for the design and open questions. - 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). -- Authentik decommission (per-consumer migration tracked separately). +- Consumer migration and decommission work is tracked separately. ## References diff --git a/apis/grants/definition.yaml b/apis/grants/definition.yaml index 545a223..019854d 100644 --- a/apis/grants/definition.yaml +++ b/apis/grants/definition.yaml @@ -31,9 +31,11 @@ spec: the Project Grant). Multi-iter: user/Grant emits once project/Grant is observed. - Caller writes `userId + userOrgId + projectId + projectOrgId - + roles` and the composition picks the right Zitadel - mechanism. See [[specs/grant-xrd]] for the design. + Caller identifies the user and project either by local managed + resource references (`userIdRef` + `projectIdRef`) or by explicit + Zitadel IDs (`userId + userOrgId + projectId + projectOrgId`). The + composition resolves references from status.atProvider and picks the + right Zitadel mechanism. See [[specs/grant-xrd]] for the design. type: object properties: spec: @@ -44,20 +46,65 @@ spec: description: | Zitadel user UUID. Operator copies from the upstream MachineUser / HumanUser MR's status.atProvider.id (or - Zitadel UI). + Zitadel UI). Mutually exclusive with userIdRef. 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. + Mutually exclusive with userId + userOrgId. + type: object + properties: + name: + type: string + minLength: 1 + apiVersion: + type: string + default: user.zitadel.m.crossplane.io/v1alpha1 + kind: + type: string + default: HumanUser + enum: + - HumanUser + - MachineUser + required: + - name userOrgId: description: | The user's home Org UUID. For same-Org grants, equal - to projectOrgId. + to projectOrgId. Mutually exclusive with userIdRef. type: string projectId: description: | - Target Zitadel Project UUID. + Target Zitadel Project UUID. Mutually exclusive with + projectIdRef. type: string + projectIdRef: + description: | + Reference to a Project managed resource in the Grant + namespace. The composition resolves projectId and + projectOrgId from status.atProvider. + Mutually exclusive with projectId + projectOrgId. + type: object + properties: + name: + type: string + minLength: 1 + apiVersion: + type: string + default: project.zitadel.m.crossplane.io/v1alpha1 + kind: + type: string + default: Project + enum: + - Project + required: + - name projectOrgId: description: | - The Org UUID where the target Project lives. + The Org UUID where the target Project lives. Mutually + exclusive with projectIdRef. type: string roles: description: | @@ -92,11 +139,12 @@ spec: additionalProperties: type: string x-kubernetes-preserve-unknown-fields: true + x-kubernetes-validations: + - rule: '(has(self.userIdRef) && !has(self.userId) && !has(self.userOrgId)) || (!has(self.userIdRef) && has(self.userId) && has(self.userOrgId))' + message: "provide exactly one user source: userIdRef, or userId + userOrgId" + - rule: '(has(self.projectIdRef) && !has(self.projectId) && !has(self.projectOrgId)) || (!has(self.projectIdRef) && has(self.projectId) && has(self.projectOrgId))' + message: "provide exactly one project source: projectIdRef, or projectId + projectOrgId" required: - - userId - - userOrgId - - projectId - - projectOrgId - roles - providerConfigRef @@ -118,5 +166,8 @@ spec: type: string ready: type: boolean + referencesResolved: + description: Whether all requested user and project references have IDs. + type: boolean required: - spec diff --git a/examples/grants/referenced-same-org.yaml b/examples/grants/referenced-same-org.yaml new file mode 100644 index 0000000..4578335 --- /dev/null +++ b/examples/grants/referenced-same-org.yaml @@ -0,0 +1,18 @@ +# Same-Org Grant using local managed-resource references. The Grant must share +# a namespace with the referenced HumanUser and Project. No live Zitadel UUIDs +# are stored in Git; the composition resolves IDs and Org IDs from status. +apiVersion: auth.hops.ops.com.ai/v1alpha1 +kind: Grant +metadata: + name: alice-e2e-ui-user + namespace: default +spec: + userIdRef: + name: e2e-alice + projectIdRef: + name: e2e-ui + roles: + - user + providerConfigRef: + name: default + kind: ClusterProviderConfig diff --git a/functions/grant/000-state-init.yaml.gotmpl b/functions/grant/000-state-init.yaml.gotmpl index da4f989..267bdd1 100644 --- a/functions/grant/000-state-init.yaml.gotmpl +++ b/functions/grant/000-state-init.yaml.gotmpl @@ -1,6 +1,6 @@ # code: language=yaml # -# Initialize $state. Mode discriminator computed from userOrgId vs projectOrgId. +# Initialize $state. References are resolved in the next templates. # {{- $xr := getCompositeResource . }} @@ -10,18 +10,14 @@ {{- $name := $metadata.name }} {{- $namespace := $metadata.namespace | default "default" }} -{{- $userId := $spec.userId }} -{{- $userOrgId := $spec.userOrgId }} -{{- $projectId := $spec.projectId }} -{{- $projectOrgId := $spec.projectOrgId }} +{{- $userId := $spec.userId | default "" }} +{{- $userOrgId := $spec.userOrgId | default "" }} +{{- $userIdRef := $spec.userIdRef | default dict }} +{{- $projectId := $spec.projectId | default "" }} +{{- $projectOrgId := $spec.projectOrgId | default "" }} +{{- $projectIdRef := $spec.projectIdRef | default dict }} {{- $roles := $spec.roles }} -# Mode: same-org vs cross-org -{{- $mode := "cross-org" }} -{{- if eq $userOrgId $projectOrgId }} - {{- $mode = "same-org" }} -{{- end }} - {{- $managementPolicies := $spec.managementPolicies | default (list "*") }} {{- $pcSpec := $spec.providerConfigRef }} @@ -37,11 +33,20 @@ {{- $state := dict "name" $name "namespace" $namespace - "mode" $mode + "mode" "" "userId" $userId "userOrgId" $userOrgId + "userRefName" ($userIdRef.name | default "") + "userRefApiVersion" ($userIdRef.apiVersion | default "user.zitadel.m.crossplane.io/v1alpha1") + "userRefKind" ($userIdRef.kind | default "HumanUser") + "userRefReady" false "projectId" $projectId "projectOrgId" $projectOrgId + "projectRefName" ($projectIdRef.name | default "") + "projectRefApiVersion" ($projectIdRef.apiVersion | default "project.zitadel.m.crossplane.io/v1alpha1") + "projectRefKind" ($projectIdRef.kind | default "Project") + "projectRefReady" false + "referencesResolved" false "roles" $roles "managementPolicies" $managementPolicies "labels" $labels @@ -50,3 +55,26 @@ "observed" (dict) "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 }} diff --git a/functions/grant/010-state-status.yaml.gotmpl b/functions/grant/010-state-status.yaml.gotmpl index 39b9c97..5e289c6 100644 --- a/functions/grant/010-state-status.yaml.gotmpl +++ b/functions/grant/010-state-status.yaml.gotmpl @@ -1,13 +1,67 @@ # code: language=yaml # -# Extract observed state for multi-iter gating + status emission. +# Resolve references, then extract observed state for gating + status emission. # +# 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 }} + +{{- if $state.userRefName }} + {{- $matches := get $extra "referenced-user" | default dict }} + {{- $users := $matches.items | default list }} + {{- range $users }} + {{- $user := .resource | default dict }} + {{- 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 "") }} + {{- range ($status.conditions | default list) }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $_ := set $state "userRefReady" true }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} + +{{- if $state.projectRefName }} + {{- $matches := get $extra "referenced-project" | default dict }} + {{- $projects := $matches.items | default list }} + {{- range $projects }} + {{- $project := .resource | default dict }} + {{- if eq (($project.metadata | default dict).name | default "") $state.projectRefName }} + {{- $status := $project.status | default dict }} + {{- $atProvider := $status.atProvider | default dict }} + {{- $_ := set $state "projectId" ($atProvider.id | default "") }} + {{- $_ := set $state "projectOrgId" ($atProvider.orgId | default "") }} + {{- range ($status.conditions | default list) }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $_ := set $state "projectRefReady" true }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} + +{{- $resolved := and $state.userId $state.userOrgId $state.projectId $state.projectOrgId }} +{{- $_ := set $state "referencesResolved" (not (empty $resolved)) }} + +{{- if $state.referencesResolved }} + {{- $mode := "cross-org" }} + {{- if eq $state.userOrgId $state.projectOrgId }} + {{- $mode = "same-org" }} + {{- end }} + {{- $_ := set $state "mode" $mode }} +{{- end }} + {{- $observed := $.observed.resources | default dict }} # Project Grant (only relevant in cross-org mode) {{- $projectGrantId := "" }} -{{- $projectGrantReady := true }} +{{- $projectGrantReady := $state.referencesResolved }} {{- if eq $state.mode "cross-org" }} {{- $projectGrantReady = false }} {{- $pgEntry := get $observed "project-grant" | default dict }} @@ -39,8 +93,11 @@ {{- $ready := and $projectGrantReady $userGrantReady }} {{- $_ := set $state.observed "projectGrantId" $projectGrantId }} +{{- $_ := set $state.observed "projectGrantReady" $projectGrantReady }} {{- $_ := set $state.observed "userGrantId" $userGrantId }} +{{- $_ := set $state.observed "userGrantReady" $userGrantReady }} {{- $_ := set $state.status "ready" $ready }} {{- $_ := set $state.status "mode" $state.mode }} +{{- $_ := set $state.status "referencesResolved" $state.referencesResolved }} {{- $_ := set $state.status "projectGrantId" $projectGrantId }} {{- $_ := set $state.status "userGrantId" $userGrantId }} diff --git a/functions/grant/100-project-grant.yaml.gotmpl b/functions/grant/100-project-grant.yaml.gotmpl index a849c42..bf851d7 100644 --- a/functions/grant/100-project-grant.yaml.gotmpl +++ b/functions/grant/100-project-grant.yaml.gotmpl @@ -5,12 +5,13 @@ # (userOrgId) with the requested role set. # -{{- if eq $state.mode "cross-org" }} +{{- if and $state.referencesResolved (eq $state.mode "cross-org") }} +{{- $projectGrantName := printf "%s-project-grant" $state.name }} --- apiVersion: project.zitadel.m.crossplane.io/v1alpha1 kind: Grant metadata: - name: {{ printf "%s-project-grant" $state.name }} + name: {{ $projectGrantName }} annotations: {{ setResourceNameAnnotation "project-grant" }} labels: {{ $state.labels | toJson }} @@ -24,4 +25,26 @@ spec: providerConfigRef: name: {{ $state.providerConfigName | quote }} kind: {{ $state.providerConfigKind | quote }} + +{{- if and $state.projectRefName $state.projectRefReady $state.observed.projectGrantReady }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ printf "%s-delete-project-grant-before-project" $state.name }} + annotations: + {{ setResourceNameAnnotation "usage-project-grant-project" }} +spec: + replayDeletion: true + of: + apiVersion: {{ $state.projectRefApiVersion | quote }} + kind: {{ $state.projectRefKind | quote }} + resourceRef: + name: {{ $state.projectRefName | quote }} + by: + apiVersion: project.zitadel.m.crossplane.io/v1alpha1 + kind: Grant + resourceRef: + name: {{ $projectGrantName | quote }} +{{- end }} {{- end }} diff --git a/functions/grant/200-user-grant.yaml.gotmpl b/functions/grant/200-user-grant.yaml.gotmpl index 569f79c..d34d767 100644 --- a/functions/grant/200-user-grant.yaml.gotmpl +++ b/functions/grant/200-user-grant.yaml.gotmpl @@ -8,7 +8,7 @@ # authorized in step 100. # -{{- $shouldEmit := true }} +{{- $shouldEmit := $state.referencesResolved }} {{- if eq $state.mode "cross-org" }} {{- if eq $state.observed.projectGrantId "" }} {{- $shouldEmit = false }} @@ -16,11 +16,12 @@ {{- end }} {{- if $shouldEmit }} +{{- $userGrantName := printf "%s-user-grant" $state.name }} --- apiVersion: user.zitadel.m.crossplane.io/v1alpha1 kind: Grant metadata: - name: {{ printf "%s-user-grant" $state.name }} + name: {{ $userGrantName }} annotations: {{ setResourceNameAnnotation "user-grant" }} labels: {{ $state.labels | toJson }} @@ -37,4 +38,72 @@ spec: providerConfigRef: name: {{ $state.providerConfigName | quote }} kind: {{ $state.providerConfigKind | quote }} + +{{- /* Ref-backed Grants protect their source user and Project until the + provider Grant has been deleted. Usages intentionally gate on Ready. */}} +{{- if and $state.userRefName $state.userRefReady $state.observed.userGrantReady }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ printf "%s-delete-user-grant-before-user" $state.name }} + annotations: + {{ setResourceNameAnnotation "usage-user-grant-user" }} +spec: + replayDeletion: true + of: + apiVersion: {{ $state.userRefApiVersion | quote }} + kind: {{ $state.userRefKind | quote }} + resourceRef: + name: {{ $state.userRefName | quote }} + by: + apiVersion: user.zitadel.m.crossplane.io/v1alpha1 + kind: Grant + resourceRef: + name: {{ $userGrantName | quote }} +{{- end }} + +{{- if and (eq $state.mode "same-org") $state.projectRefName $state.projectRefReady $state.observed.userGrantReady }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ printf "%s-delete-user-grant-before-project" $state.name }} + annotations: + {{ setResourceNameAnnotation "usage-user-grant-project" }} +spec: + replayDeletion: true + of: + apiVersion: {{ $state.projectRefApiVersion | quote }} + kind: {{ $state.projectRefKind | quote }} + resourceRef: + name: {{ $state.projectRefName | quote }} + by: + apiVersion: user.zitadel.m.crossplane.io/v1alpha1 + kind: Grant + resourceRef: + name: {{ $userGrantName | quote }} +{{- end }} + +{{- if and (eq $state.mode "cross-org") $state.observed.projectGrantReady $state.observed.userGrantReady }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ printf "%s-delete-user-grant-before-project-grant" $state.name }} + annotations: + {{ setResourceNameAnnotation "usage-user-grant-project-grant" }} +spec: + replayDeletion: true + of: + apiVersion: project.zitadel.m.crossplane.io/v1alpha1 + kind: Grant + resourceRef: + name: {{ printf "%s-project-grant" $state.name | quote }} + by: + apiVersion: user.zitadel.m.crossplane.io/v1alpha1 + kind: Grant + resourceRef: + name: {{ $userGrantName | quote }} +{{- end }} {{- end }} diff --git a/functions/grant/999-status.yaml.gotmpl b/functions/grant/999-status.yaml.gotmpl index 536a2c2..8bf221e 100644 --- a/functions/grant/999-status.yaml.gotmpl +++ b/functions/grant/999-status.yaml.gotmpl @@ -9,7 +9,8 @@ apiVersion: {{ $xr.apiVersion }} kind: {{ $xr.kind }} status: ready: {{ $state.status.ready }} - mode: {{ $state.status.mode | quote }} + mode: {{ $state.status.mode | default "pending" | quote }} + referencesResolved: {{ $state.status.referencesResolved }} userGrantId: {{ $state.status.userGrantId | default "" | quote }} {{- if eq $state.mode "cross-org" }} projectGrantId: {{ $state.status.projectGrantId | default "" | quote }} diff --git a/tests/test-grant/main.k b/tests/test-grant/main.k index d88d5f0..d001551 100644 --- a/tests/test-grant/main.k +++ b/tests/test-grant/main.k @@ -161,6 +161,40 @@ _items = [ ] } }, + + # ========================================================================== + # Test 5: local references remain pending until both namespaced targets + # are returned by ExtraResources. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "local-references-wait-for-targets" + 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"} + projectIdRef = {name = "e2e-ui"} + roles = ["user"] + providerConfigRef = {name = "default", kind = "ClusterProviderConfig"} + } + } + assertResources = [ + { + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "Grant" + metadata.name = "alice-grant" + status.mode = "pending" + status.referencesResolved = False + } + ] + } + }, ] items = _items diff --git a/tests/test-grant/observed/cross-org-iter2.yaml b/tests/test-grant/observed/cross-org-iter2.yaml index 1cb09eb..6979f10 100644 --- a/tests/test-grant/observed/cross-org-iter2.yaml +++ b/tests/test-grant/observed/cross-org-iter2.yaml @@ -3,11 +3,28 @@ apiVersion: project.zitadel.m.crossplane.io/v1alpha1 kind: Grant metadata: name: pat-platform-admin-project-grant + namespace: default annotations: crossplane.io/composition-resource-name: project-grant + gotemplating.fn.crossplane.io/composition-resource-name: project-grant status: atProvider: id: "cross-org-grant-uuid-xyz" conditions: - type: Ready status: "True" +--- +apiVersion: user.zitadel.m.crossplane.io/v1alpha1 +kind: Grant +metadata: + name: pat-platform-admin-user-grant + namespace: default + annotations: + crossplane.io/composition-resource-name: user-grant + gotemplating.fn.crossplane.io/composition-resource-name: user-grant +status: + atProvider: + id: "user-grant-uuid-xyz" + conditions: + - type: Ready + status: "True"