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
4 changes: 3 additions & 1 deletion .github/workflows/on-pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/on-push-main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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`.
Expand Down
16 changes: 16 additions & 0 deletions apis/grants/composition.yaml
Original file line number Diff line number Diff line change
@@ -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
122 changes: 122 additions & 0 deletions apis/grants/definition.yaml
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions examples/grants/cross-org.yaml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions examples/grants/same-org.yaml
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions functions/grant/000-state-init.yaml.gotmpl
Original file line number Diff line number Diff line change
@@ -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)
}}
46 changes: 46 additions & 0 deletions functions/grant/010-state-status.yaml.gotmpl
Original file line number Diff line number Diff line change
@@ -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 }}
27 changes: 27 additions & 0 deletions functions/grant/100-project-grant.yaml.gotmpl
Original file line number Diff line number Diff line change
@@ -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 }}
Loading
Loading