From de8c4d02d1bb83f80d9ade91ba075ab67cdc2cb7 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 19:29:47 -0500 Subject: [PATCH] feat: add Grant primitive XRD with polymorphic same-Org / cross-Org dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second auth-group primitive that survives the "is it just a single-MR wrapper?" test. Composes 1 or 2 underlying Zitadel MRs depending on whether the user's home Org matches the project's enclosing Org. Same-Org (spec.userOrgId == spec.projectOrgId): one MR - user.zitadel.m.crossplane.io/Grant — the user's role assignment within the project (no projectGrantId) Cross-Org (spec.userOrgId != spec.projectOrgId): two MRs - project.zitadel.m.crossplane.io/Grant — the cross-Org Project Grant authorizing the role set for the user's home Org - user.zitadel.m.crossplane.io/Grant — the user's role assignment with projectGrantId pointing at the Project Grant, pulling roles from the granted set Multi-iter convergence: in cross-Org mode the user/Grant emits only once the project/Grant's atProvider.id is observed (standard composition gating per feedback_crossplane_composition_gates). Schema takes flat IDs (userId + userOrgId + projectId + projectOrgId + roles[]), matching the established convention from Tenant + MachineUser — operator copies UUIDs from the relevant XR statuses or Zitadel UI. Two examples (same-org + cross-org); 4 KCL CompositionTests covering same-org, cross-org iter 1, multi-role, managementPolicies propagation. Multi-iter convergence verified via observed-resources fixture. 21/21 KCL tests pass (13 AuthStack + 4 MachineUser + 4 Grant); all 8 examples render via make render:all. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/on-pr.yaml | 4 +- .github/workflows/on-push-main.yaml | 4 +- Makefile | 4 +- README.md | 11 +- apis/grants/composition.yaml | 16 ++ apis/grants/definition.yaml | 122 +++++++++++++ examples/grants/cross-org.yaml | 26 +++ examples/grants/same-org.yaml | 18 ++ functions/grant/000-state-init.yaml.gotmpl | 52 ++++++ functions/grant/010-state-status.yaml.gotmpl | 46 +++++ functions/grant/100-project-grant.yaml.gotmpl | 27 +++ functions/grant/200-user-grant.yaml.gotmpl | 40 +++++ functions/grant/999-status.yaml.gotmpl | 16 ++ tests/test-grant/kcl.mod | 6 + tests/test-grant/main.k | 166 ++++++++++++++++++ tests/test-grant/model | 1 + .../test-grant/observed/cross-org-iter2.yaml | 13 ++ upbound.yaml | 4 +- 18 files changed, 570 insertions(+), 6 deletions(-) create mode 100644 apis/grants/composition.yaml create mode 100644 apis/grants/definition.yaml create mode 100644 examples/grants/cross-org.yaml create mode 100644 examples/grants/same-org.yaml create mode 100644 functions/grant/000-state-init.yaml.gotmpl create mode 100644 functions/grant/010-state-status.yaml.gotmpl create mode 100644 functions/grant/100-project-grant.yaml.gotmpl create mode 100644 functions/grant/200-user-grant.yaml.gotmpl create mode 100644 functions/grant/999-status.yaml.gotmpl create mode 100644 tests/test-grant/kcl.mod create mode 100644 tests/test-grant/main.k create mode 120000 tests/test-grant/model create mode 100644 tests/test-grant/observed/cross-org-iter2.yaml diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml index 3098b04..e523602 100644 --- a/.github/workflows/on-pr.yaml +++ b/.github/workflows/on-pr.yaml @@ -36,7 +36,9 @@ jobs: { "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" } + { "example": "examples/machineusers/with-pat-push.yaml","api_path": "apis/machineusers" }, + { "example": "examples/grants/same-org.yaml", "api_path": "apis/grants" }, + { "example": "examples/grants/cross-org.yaml", "api_path": "apis/grants" } ] error_on_missing_schemas: true diff --git a/.github/workflows/on-push-main.yaml b/.github/workflows/on-push-main.yaml index 20830b1..0e1f786 100644 --- a/.github/workflows/on-push-main.yaml +++ b/.github/workflows/on-push-main.yaml @@ -32,7 +32,9 @@ jobs: { "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" } + { "example": "examples/machineusers/with-pat-push.yaml","api_path": "apis/machineusers" }, + { "example": "examples/grants/same-org.yaml", "api_path": "apis/grants" }, + { "example": "examples/grants/cross-org.yaml", "api_path": "apis/grants" } ] error_on_missing_schemas: true diff --git a/Makefile b/Makefile index e524f37..4732473 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,9 @@ EXAMPLES := \ examples/authstacks/local-colima.yaml:: \ examples/machineusers/minimal.yaml:: \ examples/machineusers/with-pat.yaml:: \ - examples/machineusers/with-pat-push.yaml:: + examples/machineusers/with-pat-push.yaml:: \ + examples/grants/same-org.yaml:: \ + examples/grants/cross-org.yaml:: # Render all examples (parallel execution, output shown per-job when complete) render\:all: diff --git a/README.md b/README.md index a82bf87..e107648 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Status: | Kind | Plural | Composes | Status | |---|---|---|---| | `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 | +| `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. @@ -103,6 +103,15 @@ PAT generation is **opt-in by default** — `pat.enabled: false` means no long-l 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: + +- **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`. + ## Cross-Stack Integration The intent is for consumer stacks (gitops/ArgoCD, observe/Grafana, the-website) to wire to AuthStack's status surface rather than configuring OIDC manually. Today, those consumers still need a Zitadel OIDC application created out-of-band (via the Zitadel UI/API) and a client ID/secret provided to them. Once the Zitadel Crossplane provider lands, consumer stacks can declaratively create OIDC applications by referencing `status.bootstrap.iamAdminPatSecretRef`. diff --git a/apis/grants/composition.yaml b/apis/grants/composition.yaml new file mode 100644 index 0000000..b6cac5c --- /dev/null +++ b/apis/grants/composition.yaml @@ -0,0 +1,16 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: grants.auth.hops.ops.com.ai +spec: + compositeTypeRef: + apiVersion: auth.hops.ops.com.ai/v1alpha1 + kind: Grant + mode: Pipeline + pipeline: + - functionRef: + name: hops-ops-auth-stackgrant + step: grant + - functionRef: + name: crossplane-contrib-function-auto-ready + step: crossplane-contrib-function-auto-ready diff --git a/apis/grants/definition.yaml b/apis/grants/definition.yaml new file mode 100644 index 0000000..545a223 --- /dev/null +++ b/apis/grants/definition.yaml @@ -0,0 +1,122 @@ +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: grants.auth.hops.ops.com.ai +spec: + group: auth.hops.ops.com.ai + names: + kind: Grant + plural: grants + scope: Namespaced + versions: + - name: v1alpha1 + referenceable: true + served: true + schema: + openAPIV3Schema: + description: | + Grant is the first-class membership relationship that ties a + Zitadel User to a Project + Roles. Polymorphic over same-Org + and cross-Org grants: + + - same-Org (`spec.userOrgId == spec.projectOrgId`): composes + ONE `user.zitadel.m.crossplane.io/Grant` MR (the user's role + assignment within the project). + + - cross-Org (`spec.userOrgId != spec.projectOrgId`): composes + TWO MRs — a `project.zitadel.m.crossplane.io/Grant` (the + cross-Org Project Grant authorizing roles for the user's + home Org) plus a `user.zitadel.m.crossplane.io/Grant` (the + user's role assignment, with `projectGrantId` pointing at + 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. + type: object + properties: + spec: + description: GrantSpec defines the desired state. + type: object + properties: + userId: + description: | + Zitadel user UUID. Operator copies from the upstream + MachineUser / HumanUser MR's status.atProvider.id (or + Zitadel UI). + type: string + userOrgId: + description: | + The user's home Org UUID. For same-Org grants, equal + to projectOrgId. + type: string + projectId: + description: | + Target Zitadel Project UUID. + type: string + projectOrgId: + description: | + The Org UUID where the target Project lives. + type: string + roles: + description: | + Role keys assigned to the user within the Project. + Each must match a Role defined on the target Project. + type: array + items: + type: string + minItems: 1 + providerConfigRef: + description: ProviderConfig for provider-upjet-zitadel. + type: object + properties: + name: + type: string + kind: + type: string + default: "ProviderConfig" + managementPolicies: + description: | + Crossplane managementPolicies for all composed resources. + Defaults to ["*"]. + 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: + - userId + - userOrgId + - projectId + - projectOrgId + - roles + - providerConfigRef + + status: + type: object + properties: + mode: + description: | + Composition mode discriminator — "same-org" or + "cross-org", computed from userOrgId vs projectOrgId. + type: string + userGrantId: + description: Underlying user/Grant MR's Zitadel UUID. + type: string + projectGrantId: + description: | + Underlying project/Grant MR's Zitadel UUID. Only set + for cross-org mode. + type: string + ready: + type: boolean + required: + - spec diff --git a/examples/grants/cross-org.yaml b/examples/grants/cross-org.yaml new file mode 100644 index 0000000..54a8d96 --- /dev/null +++ b/examples/grants/cross-org.yaml @@ -0,0 +1,26 @@ +# Cross-Org Grant — the user lives in one Org (pat-brand), the project +# lives in another (ops-com-ai). Composes TWO MRs: +# 1. project.zitadel.m.crossplane.io/Grant — authorizes pat-brand's +# Org to receive the platform-services Project with the role set +# [platform-admin]. +# 2. user.zitadel.m.crossplane.io/Grant — assigns the platform-admin +# role to Pat within the Project, referencing the Project Grant +# via projectGrantId. Gated on (1) being observed (multi-iter). +# +# Both UUIDs in this example are illustrative; real values come from +# the respective Tenant XRs' status.identity.zitadel.orgId fields. +apiVersion: auth.hops.ops.com.ai/v1alpha1 +kind: Grant +metadata: + name: pat-platform-admin + namespace: default +spec: + userId: "ILLUSTRATIVE-pat-user-uuid" + userOrgId: "ILLUSTRATIVE-pat-brand-org-uuid" + projectId: "373410628280264299" + projectOrgId: "373268222482392664" + roles: + - "platform-admin" + providerConfigRef: + name: zitadel-tenant-stack + kind: ProviderConfig diff --git a/examples/grants/same-org.yaml b/examples/grants/same-org.yaml new file mode 100644 index 0000000..d377567 --- /dev/null +++ b/examples/grants/same-org.yaml @@ -0,0 +1,18 @@ +# Same-Org Grant — the user's home Org and the project's Org are the +# same. Composes ONE user.zitadel.m.crossplane.io/Grant MR. Matches the +# existing hand-rolled pattern in local/zitadel-openpanel-platform-admin.yaml. +apiVersion: auth.hops.ops.com.ai/v1alpha1 +kind: Grant +metadata: + name: openpanel-provider-grant + namespace: default +spec: + userId: "373509289836287598" + userOrgId: "373268222482392664" + projectId: "373410628280264299" + projectOrgId: "373268222482392664" + roles: + - "openpanel:platform-admin" + providerConfigRef: + name: zitadel-tenant-stack + kind: ProviderConfig diff --git a/functions/grant/000-state-init.yaml.gotmpl b/functions/grant/000-state-init.yaml.gotmpl new file mode 100644 index 0000000..da4f989 --- /dev/null +++ b/functions/grant/000-state-init.yaml.gotmpl @@ -0,0 +1,52 @@ +# code: language=yaml +# +# Initialize $state. Mode discriminator computed from userOrgId vs projectOrgId. +# + +{{- $xr := getCompositeResource . }} +{{- $spec := $xr.spec | default dict }} +{{- $metadata := $xr.metadata | default dict }} + +{{- $name := $metadata.name }} +{{- $namespace := $metadata.namespace | default "default" }} + +{{- $userId := $spec.userId }} +{{- $userOrgId := $spec.userOrgId }} +{{- $projectId := $spec.projectId }} +{{- $projectOrgId := $spec.projectOrgId }} +{{- $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 }} +{{- $pcName := $pcSpec.name }} +{{- $pcKind := $pcSpec.kind | default "ProviderConfig" }} + +{{- $defaultLabels := dict + "hops.ops.com.ai/managed" "true" + "hops.ops.com.ai/auth-grant" $name +}} +{{- $labels := merge $defaultLabels ($spec.labels | default dict) }} + +{{- $state := dict + "name" $name + "namespace" $namespace + "mode" $mode + "userId" $userId + "userOrgId" $userOrgId + "projectId" $projectId + "projectOrgId" $projectOrgId + "roles" $roles + "managementPolicies" $managementPolicies + "labels" $labels + "providerConfigName" $pcName + "providerConfigKind" $pcKind + "observed" (dict) + "status" (dict) +}} diff --git a/functions/grant/010-state-status.yaml.gotmpl b/functions/grant/010-state-status.yaml.gotmpl new file mode 100644 index 0000000..39b9c97 --- /dev/null +++ b/functions/grant/010-state-status.yaml.gotmpl @@ -0,0 +1,46 @@ +# code: language=yaml +# +# Extract observed state for multi-iter gating + status emission. +# + +{{- $observed := $.observed.resources | default dict }} + +# Project Grant (only relevant in cross-org mode) +{{- $projectGrantId := "" }} +{{- $projectGrantReady := true }} +{{- if eq $state.mode "cross-org" }} + {{- $projectGrantReady = false }} + {{- $pgEntry := get $observed "project-grant" | default dict }} + {{- $pgResource := $pgEntry.resource | default dict }} + {{- $pgAtProvider := (($pgResource.status | default dict).atProvider | default dict) }} + {{- $projectGrantId = $pgAtProvider.id | default "" }} + {{- $pgConditions := (($pgResource.status | default dict).conditions | default list) }} + {{- range $pgConditions }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $projectGrantReady = true }} + {{- end }} + {{- end }} +{{- end }} + +# User Grant +{{- $userGrantId := "" }} +{{- $userGrantReady := false }} +{{- $ugEntry := get $observed "user-grant" | default dict }} +{{- $ugResource := $ugEntry.resource | default dict }} +{{- $ugAtProvider := (($ugResource.status | default dict).atProvider | default dict) }} +{{- $userGrantId = $ugAtProvider.id | default "" }} +{{- $ugConditions := (($ugResource.status | default dict).conditions | default list) }} +{{- range $ugConditions }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $userGrantReady = true }} + {{- end }} +{{- end }} + +{{- $ready := and $projectGrantReady $userGrantReady }} + +{{- $_ := set $state.observed "projectGrantId" $projectGrantId }} +{{- $_ := set $state.observed "userGrantId" $userGrantId }} +{{- $_ := set $state.status "ready" $ready }} +{{- $_ := set $state.status "mode" $state.mode }} +{{- $_ := 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 new file mode 100644 index 0000000..a849c42 --- /dev/null +++ b/functions/grant/100-project-grant.yaml.gotmpl @@ -0,0 +1,27 @@ +# code: language=yaml +# +# Project Grant — only composed for cross-Org mode. Authorizes the +# Project (in projectOrgId) to be granted to the user's home Org +# (userOrgId) with the requested role set. +# + +{{- if eq $state.mode "cross-org" }} +--- +apiVersion: project.zitadel.m.crossplane.io/v1alpha1 +kind: Grant +metadata: + name: {{ printf "%s-project-grant" $state.name }} + annotations: + {{ setResourceNameAnnotation "project-grant" }} + labels: {{ $state.labels | toJson }} +spec: + forProvider: + orgId: {{ $state.projectOrgId | quote }} + projectId: {{ $state.projectId | quote }} + grantedOrgId: {{ $state.userOrgId | quote }} + roleKeys: {{ $state.roles | toJson }} + managementPolicies: {{ $state.managementPolicies | toJson }} + providerConfigRef: + name: {{ $state.providerConfigName | quote }} + kind: {{ $state.providerConfigKind | quote }} +{{- end }} diff --git a/functions/grant/200-user-grant.yaml.gotmpl b/functions/grant/200-user-grant.yaml.gotmpl new file mode 100644 index 0000000..569f79c --- /dev/null +++ b/functions/grant/200-user-grant.yaml.gotmpl @@ -0,0 +1,40 @@ +# code: language=yaml +# +# User Grant — composed for both same-Org and cross-Org modes. +# +# - same-Org: emits unconditionally with projectGrantId omitted. +# - cross-Org: gated on observed project/Grant id; sets projectGrantId +# so the user/Grant pulls roles from the cross-Org Project Grant +# authorized in step 100. +# + +{{- $shouldEmit := true }} +{{- if eq $state.mode "cross-org" }} + {{- if eq $state.observed.projectGrantId "" }} + {{- $shouldEmit = false }} + {{- end }} +{{- end }} + +{{- if $shouldEmit }} +--- +apiVersion: user.zitadel.m.crossplane.io/v1alpha1 +kind: Grant +metadata: + name: {{ printf "%s-user-grant" $state.name }} + annotations: + {{ setResourceNameAnnotation "user-grant" }} + labels: {{ $state.labels | toJson }} +spec: + forProvider: + orgId: {{ $state.userOrgId | quote }} + projectId: {{ $state.projectId | quote }} + userId: {{ $state.userId | quote }} + {{- if eq $state.mode "cross-org" }} + projectGrantId: {{ $state.observed.projectGrantId | quote }} + {{- end }} + roleKeys: {{ $state.roles | toJson }} + managementPolicies: {{ $state.managementPolicies | toJson }} + providerConfigRef: + name: {{ $state.providerConfigName | quote }} + kind: {{ $state.providerConfigKind | quote }} +{{- end }} diff --git a/functions/grant/999-status.yaml.gotmpl b/functions/grant/999-status.yaml.gotmpl new file mode 100644 index 0000000..536a2c2 --- /dev/null +++ b/functions/grant/999-status.yaml.gotmpl @@ -0,0 +1,16 @@ +# code: language=yaml +# +# Output XR status. +# + +{{- $xr := getCompositeResource . }} +--- +apiVersion: {{ $xr.apiVersion }} +kind: {{ $xr.kind }} +status: + ready: {{ $state.status.ready }} + mode: {{ $state.status.mode | quote }} + userGrantId: {{ $state.status.userGrantId | default "" | quote }} + {{- if eq $state.mode "cross-org" }} + projectGrantId: {{ $state.status.projectGrantId | default "" | quote }} + {{- end }} diff --git a/tests/test-grant/kcl.mod b/tests/test-grant/kcl.mod new file mode 100644 index 0000000..e2b40d0 --- /dev/null +++ b/tests/test-grant/kcl.mod @@ -0,0 +1,6 @@ +[package] +name = "test-render" +version = "0.0.1" + +[dependencies] +models = { path = "./model" } diff --git a/tests/test-grant/main.k b/tests/test-grant/main.k new file mode 100644 index 0000000..d88d5f0 --- /dev/null +++ b/tests/test-grant/main.k @@ -0,0 +1,166 @@ +import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 + +# ============================================================================== +# Unit tests for Grant XRD (auth.hops.ops.com.ai) +# +# Verifies the polymorphic dispatch (same-org vs cross-org) and the +# multi-iter gating of user/Grant on observed project/Grant id. +# ============================================================================== + +_items = [ + # ========================================================================== + # Test 1: same-org — userOrgId == projectOrgId. Composes ONE + # user/Grant MR with no projectGrantId. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "same-org-emits-only-user-grant" + 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 = "openpanel-provider-grant", namespace = "default"} + spec = { + userId = "373509289836287598" + userOrgId = "373268222482392664" + projectId = "373410628280264299" + projectOrgId = "373268222482392664" + roles = ["openpanel:platform-admin"] + providerConfigRef = {name = "zitadel-tenant-stack", kind = "ProviderConfig"} + } + } + assertResources = [ + { + apiVersion = "user.zitadel.m.crossplane.io/v1alpha1" + kind = "Grant" + metadata.name = "openpanel-provider-grant-user-grant" + spec = { + forProvider = { + orgId = "373268222482392664" + projectId = "373410628280264299" + userId = "373509289836287598" + roleKeys = ["openpanel:platform-admin"] + } + providerConfigRef = {name = "zitadel-tenant-stack", kind = "ProviderConfig"} + } + } + ] + } + }, + + # ========================================================================== + # Test 2: cross-org iter 1 — userOrgId != projectOrgId. Composes the + # project/Grant MR; user/Grant gated until projectGrant.atProvider.id + # is observed. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "cross-org-iter1-only-project-grant" + 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 = "pat-platform-admin", namespace = "default"} + spec = { + userId = "pat-user-uuid" + userOrgId = "pat-brand-org-uuid" + projectId = "ops-com-ai-platform-services-uuid" + projectOrgId = "ops-com-ai-org-uuid" + roles = ["platform-admin"] + providerConfigRef = {name = "zitadel-tenant-stack", kind = "ProviderConfig"} + } + } + assertResources = [ + { + apiVersion = "project.zitadel.m.crossplane.io/v1alpha1" + kind = "Grant" + metadata.name = "pat-platform-admin-project-grant" + spec.forProvider = { + orgId = "ops-com-ai-org-uuid" + projectId = "ops-com-ai-platform-services-uuid" + grantedOrgId = "pat-brand-org-uuid" + roleKeys = ["platform-admin"] + } + } + ] + } + }, + + # ========================================================================== + # Test 3: multi-role grant — multiple roleKeys propagate to the + # user/Grant MR's forProvider.roleKeys list. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "same-org-multi-role" + 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 = { + userId = "alice-user-uuid" + userOrgId = "thrivendale-org-uuid" + projectId = "thrivendale-default-project-uuid" + projectOrgId = "thrivendale-org-uuid" + roles = ["tenant-admin", "tenant-member"] + providerConfigRef = {name = "zitadel-tenant-stack", kind = "ProviderConfig"} + } + } + assertResources = [ + { + apiVersion = "user.zitadel.m.crossplane.io/v1alpha1" + kind = "Grant" + metadata.name = "alice-grant-user-grant" + spec.forProvider.roleKeys = ["tenant-admin", "tenant-member"] + } + ] + } + }, + + # ========================================================================== + # Test 4: managementPolicies propagates to all composed MRs (same-org). + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "management-policies-propagated" + 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 = "adopt-grant", namespace = "default"} + spec = { + userId = "u" + userOrgId = "o" + projectId = "p" + projectOrgId = "o" + roles = ["r"] + providerConfigRef = {name = "zitadel-tenant-stack", kind = "ProviderConfig"} + managementPolicies = ["Observe", "Update", "LateInitialize"] + } + } + assertResources = [ + { + apiVersion = "user.zitadel.m.crossplane.io/v1alpha1" + kind = "Grant" + metadata.name = "adopt-grant-user-grant" + spec.managementPolicies = ["Observe", "Update", "LateInitialize"] + } + ] + } + }, +] + +items = _items diff --git a/tests/test-grant/model b/tests/test-grant/model new file mode 120000 index 0000000..faff6e4 --- /dev/null +++ b/tests/test-grant/model @@ -0,0 +1 @@ +../../.up/kcl/models \ No newline at end of file diff --git a/tests/test-grant/observed/cross-org-iter2.yaml b/tests/test-grant/observed/cross-org-iter2.yaml new file mode 100644 index 0000000..1cb09eb --- /dev/null +++ b/tests/test-grant/observed/cross-org-iter2.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: project.zitadel.m.crossplane.io/v1alpha1 +kind: Grant +metadata: + name: pat-platform-admin-project-grant + annotations: + crossplane.io/composition-resource-name: project-grant +status: + atProvider: + id: "cross-org-grant-uuid-xyz" + conditions: + - type: Ready + status: "True" diff --git a/upbound.yaml b/upbound.yaml index 5ec1c56..a9097d7 100644 --- a/upbound.yaml +++ b/upbound.yaml @@ -27,8 +27,8 @@ 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 — currently MachineUser (with opt-in PAT + AWS SM push pipeline); - Grant planned next. + Zitadel patterns — 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: |