Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 & \
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
73 changes: 62 additions & 11 deletions apis/grants/definition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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: |
Expand Down Expand Up @@ -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

Expand All @@ -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
18 changes: 18 additions & 0 deletions examples/grants/referenced-same-org.yaml
Original file line number Diff line number Diff line change
@@ -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
52 changes: 40 additions & 12 deletions functions/grant/000-state-init.yaml.gotmpl
Original file line number Diff line number Diff line change
@@ -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 . }}
Expand All @@ -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 }}
Expand All @@ -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
Expand All @@ -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 }}
61 changes: 59 additions & 2 deletions functions/grant/010-state-status.yaml.gotmpl
Original file line number Diff line number Diff line change
@@ -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 }}
Expand Down Expand Up @@ -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 }}
27 changes: 25 additions & 2 deletions functions/grant/100-project-grant.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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 }}
Loading
Loading