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
5 changes: 4 additions & 1 deletion .github/workflows/on-pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ jobs:
[
{ "example": "examples/authstacks/minimal.yaml", "api_path": "apis/authstacks" },
{ "example": "examples/authstacks/standard.yaml", "api_path": "apis/authstacks" },
{ "example": "examples/authstacks/local-colima.yaml", "api_path": "apis/authstacks" }
{ "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" }
]
error_on_missing_schemas: true

Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/on-push-main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ jobs:
[
{ "example": "examples/authstacks/minimal.yaml", "api_path": "apis/authstacks" },
{ "example": "examples/authstacks/standard.yaml", "api_path": "apis/authstacks" },
{ "example": "examples/authstacks/local-colima.yaml", "api_path": "apis/authstacks" }
{ "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" }
]
error_on_missing_schemas: true

Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ build:
EXAMPLES := \
examples/authstacks/minimal.yaml:: \
examples/authstacks/standard.yaml:: \
examples/authstacks/local-colima.yaml::
examples/authstacks/local-colima.yaml:: \
examples/machineusers/minimal.yaml:: \
examples/machineusers/with-pat.yaml:: \
examples/machineusers/with-pat-push.yaml::

# Render all examples (parallel execution, output shown per-job when complete)
render\:all:
Expand Down
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,26 @@ status:

## Auth-group primitives

Per [[specs/identity-architecture]], the auth-group primitive XRDs that have substantive composition value-add — `HumanUser`, `MachineUser`, `Grant`, `IDP` — 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 — `MachineUser`, `Grant` — live in this repo alongside `AuthStack` under the `auth.hops.ops.com.ai` group.

Status:

| Kind | Plural | Composes | Status |
|---|---|---|---|
| `HumanUser` | `humanusers` | `HumanUser` + optional `UserIDPLink` MRs | TO WRITE |
| `MachineUser` | `machineusers` | `MachineUser` + `PAT` + AWS SM push pipeline | TO WRITE |
| `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 |
| `IDP` | `idps` | polymorphic over `GoogleIDP` / `GitHubIDP` / `OIDCIDP` / `SAMLIDP` | TO WRITE |

Operators apply raw Zitadel `Org` / `Project` / `Role` MRs (`org.zitadel.m.crossplane.io`, `project.zitadel.m.crossplane.io`) directly when they need them — the `Tenant` business kind in [[tenant-stack]] composes the initial set during Tenant scaffolding.
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.

### `MachineUser`

Declarative Zitadel machine identity for CI runners, Crossplane providers, cross-cluster syncs — anything that needs a long-lived credential to call a SaaS API.

The XRD's value-add is bundling. The `MachineUser` MR alone is one Zitadel resource. With `spec.pat.enabled: true` it adds an `AccessToken` MR (the PAT). With `spec.pat.pushToAwsSm: true` it adds an AWS Secrets Manager `Secret` MR + an ESO `PushSecret` Kubernetes Object — pushing the control-plane connection secret's `access_token` into AWS SM at the canonical path `push/<cluster>/<tenant>/<name>` per [[reference_aws_sm_push_tag_convention]]. Four resources, one declarative flag.

PAT generation is **opt-in by default** — `pat.enabled: false` means no long-lived token is minted. Adoption of an existing Zitadel machine user uses `spec.machineUserId` (propagates as `crossplane.io/external-name` on the underlying MR).

See `examples/machineusers/{minimal,with-pat,with-pat-push}.yaml`.

## Cross-Stack Integration

Expand Down
16 changes: 16 additions & 0 deletions apis/machineusers/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: machineusers.auth.hops.ops.com.ai
spec:
compositeTypeRef:
apiVersion: auth.hops.ops.com.ai/v1alpha1
kind: MachineUser
mode: Pipeline
pipeline:
- functionRef:
name: hops-ops-auth-stackmachineuser
step: machineuser
- functionRef:
name: crossplane-contrib-function-auto-ready
step: crossplane-contrib-function-auto-ready
265 changes: 265 additions & 0 deletions apis/machineusers/definition.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: machineusers.auth.hops.ops.com.ai
spec:
group: auth.hops.ops.com.ai
names:
kind: MachineUser
plural: machineusers
scope: Namespaced
versions:
- name: v1alpha1
referenceable: true
served: true
schema:
openAPIV3Schema:
description: |
MachineUser is a declarative Zitadel machine identity used by
CI runners, Crossplane providers, cross-cluster syncs — anything
that needs a long-lived credential to call a SaaS API.

Composes the underlying Zitadel MachineUser MR always. PAT
generation is OPT-IN (`spec.pat.enabled: true`) — operators
explicitly acknowledge they're minting a long-lived bearer
token. AWS Secrets Manager push (for cross-cluster pull via
ESO ExternalSecret) is a further opt-in
(`spec.pat.pushToAwsSm: true`).

Adoption: set spec.machineUserId to the existing Zitadel
machine-user UUID; it propagates as the composed MR's
crossplane.io/external-name. Pair with
managementPolicies excluding Create.

See [[specs/machine-user-xrd]] for the design.
type: object
properties:
spec:
description: MachineUserSpec defines the desired state.
type: object
properties:
orgId:
description: |
Underlying Zitadel Org UUID. Operator copies from the
parent Tenant's status.identity.zitadel.orgId.
type: string

userName:
description: |
Login name for the machine user. Defaults to metadata.name
when empty.
type: string
default: ""

displayName:
description: |
Human-readable name shown in Zitadel dashboard. Defaults
to metadata.name when empty.
type: string
default: ""

description:
description: |
Description of what this machine identity is used for —
e.g., "provider-upjet-zitadel reconciler post-bootstrap".
type: string
default: ""

accessTokenType:
description: |
Zitadel access-token type for this machine user. JWT is
self-contained + signed; BEARER is opaque + introspected.
type: string
enum: ["ACCESS_TOKEN_TYPE_JWT", "ACCESS_TOKEN_TYPE_BEARER"]
default: "ACCESS_TOKEN_TYPE_JWT"

machineUserId:
description: |
Existing Zitadel machine-user UUID. When set, propagates
as the composed MachineUser MR's
crossplane.io/external-name annotation (adoption pattern).
type: string
default: ""

pat:
description: |
Personal Access Token generation. DISABLED by default —
operators must explicitly opt in. PAT generation mints a
long-lived bearer token; the connection secret lands on
the control plane K8s (per Crossplane v2 connection-secret
semantics, NOT on whichever cluster provider-upjet-zitadel
targets).
type: object
properties:
enabled:
description: |
Set true to compose an AccessToken MR (the PAT).
Connection secret written to control-plane K8s
under key `attribute.token` (the upstream
provider-upjet-zitadel attribute name). Consumers
either read that key directly OR — for cross-cluster
pull — enable pushToAwsSm and the ESO PushSecret
renames to `access_token` per the conventional
Zitadel PC credential key. Default false.
type: boolean
default: false
expirationDate:
description: |
Optional ISO-8601 expiration timestamp for the PAT.
Empty = no expiration (Zitadel server default).
type: string
default: ""
pushToAwsSm:
description: |
When true (and pat.enabled is true), composes an AWS
Secrets Manager Secret MR + an ESO PushSecret
Kubernetes Object that pushes the control-plane K8s
Secret's access_token property to AWS SM. Consumer
clusters then pull via ESO ExternalSecret. Uses the
push/<cluster>/<tenant>/<name> path convention from
[[reference-aws-sm-push-tag-convention]].
type: boolean
default: false
awsSm:
description: |
AWS Secrets Manager push configuration. Required when
pushToAwsSm is true.
type: object
properties:
Comment on lines +112 to +128
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce PAT/AWS push invariants in the CRD schema.

pushToAwsSm can be true without schema-enforced prerequisites (pat.enabled, awsSm presence and core fields), which admits invalid specs and causes late reconcile failures.

Suggested schema hardening
               pat:
                 description: |
                   Personal Access Token generation. DISABLED by default —
@@
                 type: object
+                x-kubernetes-validations:
+                  - rule: "self.pushToAwsSm == false || self.enabled == true"
+                    message: "spec.pat.pushToAwsSm requires spec.pat.enabled=true"
+                  - rule: "self.pushToAwsSm == false || has(self.awsSm)"
+                    message: "spec.pat.awsSm is required when spec.pat.pushToAwsSm=true"
                 properties:
@@
                   awsSm:
@@
                     type: object
+                    required:
+                      - cluster
+                      - region
+                      - providerConfigRef
                     properties:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apis/machineusers/definition.yaml` around lines 107 - 123, The CRD currently
allows pushToAwsSm to be true without ensuring pat.enabled is true or awsSm and
its core fields exist; update apis/machineusers/definition.yaml to add schema
validation so that when pushToAwsSm is true (use an openapi v3 if/then or oneOf
pattern) it requires pat.enabled = true and requires the awsSm object and its
essential fields (e.g., the awsSm properties your controller needs to
reconcile); reference the pushToAwsSm boolean, the pat.enabled boolean, and the
awsSm properties in the conditional validation and mark those awsSm properties
as required so invalid specs are rejected at admission time.

cluster:
description: |
Destination cluster identifier (becomes part of
the path: push/<cluster>/<tenant>/<name>).
type: string
tenant:
description: |
Tenant identifier (becomes part of the path).
Defaults to metadata.namespace when empty.
type: string
default: ""
region:
description: AWS region for the SM Secret.
type: string
providerConfigRef:
description: |
ProviderConfig for provider-aws-secretsmanager.
type: object
properties:
name:
type: string
kind:
type: string
default: "ProviderConfig"
Comment on lines +143 to +152
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Require providerConfigRef.name for composed providers.

Both Zitadel and AWS provider refs allow empty name today. That permits invalid XR specs that render composed resources with unusable providerConfigRef.

Suggested required fields
               providerConfigRef:
@@
                 type: object
+                required:
+                  - name
                 properties:
                   name:
                     type: string
@@
                       providerConfigRef:
@@
                         type: object
+                        required:
+                          - name
                         properties:
                           name:
                             type: string

Also applies to: 184-194

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apis/machineusers/definition.yaml` around lines 138 - 147, Make
providerConfigRef.name required and non-empty for composed providers: update the
providerConfigRef schema (the object with properties name and kind used for
provider-aws-secretsmanager) to include required: ["name"] and add a constraint
such as minLength: 1 on the name property to prevent empty strings; apply the
same change to the other providerConfigRef block referenced (the second
occurrence around the alternate provider section).

eso:
description: |
ESO SecretStore config for the PushSecret.
type: object
properties:
storeName:
description: |
Name of the ClusterSecretStore. Defaults to
"aws-secrets-manager-platform".
type: string
default: "aws-secrets-manager-platform"
storeKind:
type: string
enum: ["SecretStore", "ClusterSecretStore"]
default: "ClusterSecretStore"
deletionPolicy:
description: |
ESO PushSecret deletion policy. Delete tears
down the AWS SM Secret when the PushSecret is
removed; Retain leaves it for audit.
type: string
enum: ["Delete", "Retain"]
default: "Delete"
kubernetesProviderConfigRef:
description: |
ProviderConfig for provider-kubernetes (the
cluster hosting ESO). Defaults to the same
cluster identifier in awsSm.cluster.
type: object
properties:
name:
type: string
kind:
type: string
default: "ProviderConfig"

providerConfigRef:
description: |
ProviderConfig for provider-upjet-zitadel. No convention
default — operator supplies explicitly.
type: object
properties:
name:
type: string
kind:
type: string
default: "ProviderConfig"

managementPolicies:
description: |
Crossplane managementPolicies for all composed resources.
Defaults to ["*"]. For adoption flows, set to
["Observe", "Update", "LateInitialize"].
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:
- orgId
- providerConfigRef

status:
type: object
properties:
userId:
description: Underlying Zitadel machine-user UUID.
type: string
loginName:
description: |
User's login name (typically userName + Org domain).
type: string
pat:
type: object
properties:
enabled:
type: boolean
tokenId:
description: Underlying Zitadel AccessToken UUID.
type: string
controlPlaneSecretRef:
description: |
Connection-secret ref on the control plane (where
provider-upjet-zitadel wrote the PAT under key
access_token).
type: object
properties:
name:
type: string
namespace:
type: string
awsSm:
description: |
AWS SM Secret ARN + path when pushToAwsSm: true.
type: object
properties:
secretArn:
type: string
secretName:
type: string
ready:
type: boolean
required:
- spec
17 changes: 17 additions & 0 deletions examples/machineusers/minimal.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Minimal MachineUser — composes the Zitadel MachineUser MR only.
# No PAT, no AWS SM push. Useful for service accounts that authenticate
# via JWT key (Zitadel's other mechanism) or for declarative tracking of
# external machine identities without minting credentials via Crossplane.
apiVersion: auth.hops.ops.com.ai/v1alpha1
kind: MachineUser
metadata:
name: zitadel-provider
namespace: default
spec:
orgId: "373268222482392664"
displayName: "zitadel-provider"
description: "provider-upjet-zitadel reconciler (post-bootstrap)"
accessTokenType: ACCESS_TOKEN_TYPE_JWT
providerConfigRef:
name: zitadel-tenant-stack
kind: ProviderConfig
Loading
Loading