From c41d6cbc85e45e6c8a8d598dc0f2369c095a187b Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 10:33:14 -0500 Subject: [PATCH 1/3] feat: add authorization and administrator relationship XRs Implements [[tasks/harmony-2506]]. Preserve existing APIs and remote identities while native provider support is reviewed. --- .github/workflows/on-pr.yaml | 4 + .github/workflows/on-push-main.yaml | 4 + .gitignore | 3 + Makefile | 4 + README.md | 71 +++++ apis/administrators/composition.yaml | 46 +++ apis/administrators/definition.yaml | 286 ++++++++++++++++++ apis/authorizations/composition.yaml | 46 +++ apis/authorizations/definition.yaml | 270 +++++++++++++++++ examples/administrators/referenced.yaml | 17 ++ examples/authorizations/referenced.yaml | 18 ++ .../relationship/00-state-init.yaml.gotmpl | 49 +++ .../relationship/10-observed.yaml.gotmpl | 31 ++ .../relationship/20-computed.yaml.gotmpl | 97 ++++++ functions/relationship/40-binding.yaml.gotmpl | 53 ++++ functions/relationship/99-status.yaml.gotmpl | 26 ++ tests/security/test_relationships.py | 223 ++++++++++++++ tests/test-relationships/kcl.mod | 6 + tests/test-relationships/main.k | 52 ++++ tests/test-relationships/model | 1 + .../observed/administrator.yaml | 32 ++ .../observed/authorization.yaml | 32 ++ upbound.yaml | 18 +- 23 files changed, 1378 insertions(+), 11 deletions(-) create mode 100644 apis/administrators/composition.yaml create mode 100644 apis/administrators/definition.yaml create mode 100644 apis/authorizations/composition.yaml create mode 100644 apis/authorizations/definition.yaml create mode 100644 examples/administrators/referenced.yaml create mode 100644 examples/authorizations/referenced.yaml create mode 100644 functions/relationship/00-state-init.yaml.gotmpl create mode 100644 functions/relationship/10-observed.yaml.gotmpl create mode 100644 functions/relationship/20-computed.yaml.gotmpl create mode 100644 functions/relationship/40-binding.yaml.gotmpl create mode 100644 functions/relationship/99-status.yaml.gotmpl create mode 100644 tests/security/test_relationships.py create mode 100644 tests/test-relationships/kcl.mod create mode 100644 tests/test-relationships/main.k create mode 120000 tests/test-relationships/model create mode 100644 tests/test-relationships/observed/administrator.yaml create mode 100644 tests/test-relationships/observed/authorization.yaml diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml index ad6233a..036f597 100644 --- a/.github/workflows/on-pr.yaml +++ b/.github/workflows/on-pr.yaml @@ -32,6 +32,10 @@ jobs: with: examples: | [ + { "example": "examples/authorizations/referenced.yaml", "api_path": "apis/authorizations" }, + { "example": "examples/authorizations/referenced.yaml", "api_path": "apis/authorizations", "observed_resources": "tests/test-relationships/observed/authorization.yaml" }, + { "example": "examples/administrators/referenced.yaml", "api_path": "apis/administrators" }, + { "example": "examples/administrators/referenced.yaml", "api_path": "apis/administrators", "observed_resources": "tests/test-relationships/observed/administrator.yaml" }, { "example": "examples/authstacks/minimal.yaml", "api_path": "apis/authstacks" }, { "example": "examples/authstacks/standard.yaml", "api_path": "apis/authstacks" }, { "example": "examples/authstacks/with-smtp.yaml", "api_path": "apis/authstacks" }, diff --git a/.github/workflows/on-push-main.yaml b/.github/workflows/on-push-main.yaml index 72633cc..f048aa0 100644 --- a/.github/workflows/on-push-main.yaml +++ b/.github/workflows/on-push-main.yaml @@ -27,6 +27,10 @@ jobs: with: examples: | [ + { "example": "examples/authorizations/referenced.yaml", "api_path": "apis/authorizations" }, + { "example": "examples/authorizations/referenced.yaml", "api_path": "apis/authorizations", "observed_resources": "tests/test-relationships/observed/authorization.yaml" }, + { "example": "examples/administrators/referenced.yaml", "api_path": "apis/administrators" }, + { "example": "examples/administrators/referenced.yaml", "api_path": "apis/administrators", "observed_resources": "tests/test-relationships/observed/administrator.yaml" }, { "example": "examples/authstacks/minimal.yaml", "api_path": "apis/authstacks" }, { "example": "examples/authstacks/standard.yaml", "api_path": "apis/authstacks" }, { "example": "examples/authstacks/with-smtp.yaml", "api_path": "apis/authstacks" }, diff --git a/.gitignore b/.gitignore index 6f004b0..bf29bea 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ node_modules/ apis/**/configuration.yaml **/aws-creds tests/**/secrets/ + +# Python render-test bytecode +__pycache__/ diff --git a/Makefile b/Makefile index 94d605a..969fd47 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,10 @@ build: # Format: example_path::observed_resources_path (observed_resources_path is optional) # api_path is derived from example_path via the api-dir macro (examples//... → apis//). EXAMPLES := \ + examples/authorizations/referenced.yaml:: \ + examples/authorizations/referenced.yaml::tests/test-relationships/observed/authorization.yaml \ + examples/administrators/referenced.yaml:: \ + examples/administrators/referenced.yaml::tests/test-relationships/observed/administrator.yaml \ examples/authstacks/minimal.yaml:: \ examples/authstacks/standard.yaml:: \ examples/authstacks/local-colima.yaml:: \ diff --git a/README.md b/README.md index b797cab..73a8a4d 100644 --- a/README.md +++ b/README.md @@ -242,3 +242,74 @@ No localhost, plaintext transport or secret backend is defaulted by this API. The package dependency now requires provider v0.3.0+, so upgrade that provider before applying this configuration. Recreate only the observer when intentionally replacing an installed instance; preserve Helm/database resources otherwise. + +## Native identities and relationship XRs (release pending) + +Use native `Organization`, `HumanUser`, `MachineUser`, `ProjectV2` and +`Application` resources for identities and registrations. The new +`Authorization` and `Administrator` XRs solve the provider's polymorphic +human/machine user reference limitation. They own one assignment and its +complete role set. They never create users, organizations, projects or shared +project grants. Application environments own those declarations and credential +exports; preview environments receive them through application promotion. + +These additions depend on the [Crossplane provider PR70](https://github.com/crossplane-contrib/provider-upjet-zitadel/pull/70) and +[Terraform provider PR474](https://github.com/zitadel/terraform-provider-zitadel/pull/474). +**Provider v0.3.0 does not implement their native resource types.** This change +must remain a draft until the upstream provider is released, the dependency +constraint is advanced and full validation passes against that release. + +Start with [Authorization](examples/authorizations/referenced.yaml) for application +role keys, or [Administrator](examples/administrators/referenced.yaml) for +administrative roles. Administrator requires exactly one explicit target: +`organizationRef`, `projectRef`, `projectGrantRef`, or `instance: true`. Instance +scope has no default. Project-grant targets resolve the project and grantee +organization from an environment-owned native Grant. Roles remain subject to +Zitadel's validation for the selected target scope. + +References default to the XR namespace and accept native provider resources. +An explicit `namespace` requires `allowCrossNamespaceReferences: true`. This is +an opt-in declaration, not a security boundary: restrict XR and ProviderConfig +access using RBAC. All references and the ProviderConfig must address the same +Zitadel instance. For Authorization, `organizationRef` identifies the +organization where the authorization is granted; it is independent of the user's +home organization. A `projectGrantRef` can pin and protect an existing delegation; +its observed project and grantee organization must match the assignment. + +An observed reference ID is sufficient to keep rendering during a temporary +Ready failure. A missing/deleting reference or changed remote identity retains +the existing native object and external ID, suspends Create/Update/LateInitialize, +and sets `ReferencesResolved=False` and `Ready=False`. Explicit Observe and Delete +permissions are preserved; intentional teardown can still remove the assignment +if Delete was enabled. Existing Usage locks remain during readiness loss. New +Usages wait for ready resources and protect referenced principals/targets until +the native assignment is removed. The owner of shared delegation is responsible +for protecting its own project/organization dependencies. + +To adopt an existing assignment, set `spec.binding.externalName` and start with +`managementPolicies: [Observe]`. Authorization imports by its existing assignment +ID. Imported assignments automatically remain Observe-only until their observed +user/target identity matches the resolved references. A mismatch disables +delete as well, preserving the unrelated remote assignment. Administrator imports by `user:organization:org`, `user:project:project`, +`user:project_grant:project:grantee-org`, or `user:instance` (percent-encode colons +in IDs). `binding.resourceName` controls the stable native Kubernetes name. +Preserve the existing remote assignment while removing its previous declarative +owner without Delete; confirm the new observer reports the same identity and +roles before enabling writes. Never let two owners reconcile one complete role +set simultaneously. References and provider selection are immutable; identity +replacement requires a separate XR/adoption step. + +The old HumanUser, MachineUser, Grant and OIDCClient XRDs remain packaged for +compatibility. Migrate the nine local Grant instances by importing their native +user-grant IDs into Authorization, with environment-owned delegation declared +separately. Migrate the two active EKS OIDCClient registrations to native client +resources using their current application IDs and Secret contracts. Do not +remove these XRDs or the terminating preview objects as part of introducing the +new APIs. PAT v2 and the Actions v2 groups-claim webhook migration are separate +provider/runtime work. + +The relationship regression suite is included in `make test-security`. It checks +both subject kinds, all administrator targets, cross-namespace references, +missing/replaced references, current readiness, import and deletion locks. Local +render tests change no cluster resources. Live e2e/adoption verification remains +a release prerequisite. diff --git a/apis/administrators/composition.yaml b/apis/administrators/composition.yaml new file mode 100644 index 0000000..ef59ad8 --- /dev/null +++ b/apis/administrators/composition.yaml @@ -0,0 +1,46 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: administrators.auth.hops.ops.com.ai +spec: + compositeTypeRef: + apiVersion: auth.hops.ops.com.ai/v1alpha1 + kind: Administrator + mode: Pipeline + pipeline: + - step: resolve-resource-references + functionRef: + name: crossplane-contrib-function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: "{{- $xr := getCompositeResource . }}\n{{- $spec := $xr.spec }}\n{{- $target := $spec }}\n{{- if eq $xr.kind\ + \ \"Administrator\" }}{{- $target = $spec.target }}{{- end }}\n{{- $refs := dict \"subject\" (mergeOverwrite (dict\ + \ \"kind\" \"HumanUser\" \"apiVersion\" \"user.zitadel.m.crossplane.io/v1alpha1\") (deepCopy $spec.subjectRef))\ + \ }}\n{{- range $key, $defaults := dict\n \"project\" (dict \"kind\" \"ProjectV2\" \"apiVersion\" \"project.zitadel.m.crossplane.io/v1alpha1\"\ + )\n \"organization\" (dict \"kind\" \"Organization\" \"apiVersion\" \"org.zitadel.m.crossplane.io/v1alpha1\")\n\ + \ \"projectGrant\" (dict \"kind\" \"Grant\" \"apiVersion\" \"project.zitadel.m.crossplane.io/v1alpha1\") }}\n \ + \ {{- $ref := get $target (printf \"%sRef\" $key) | default dict }}\n {{- if $ref.name }}{{- $_ := set $refs $key\ + \ (mergeOverwrite $defaults (deepCopy $ref)) }}{{- end }}\n{{- end }}\napiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1\n\ + kind: ExtraResources\nrequirements:\n {{- range $key, $ref := $refs }}\n {{ $key }}:\n apiVersion: {{ $ref.apiVersion\ + \ | quote }}\n kind: {{ $ref.kind | quote }}\n matchName: {{ $ref.name | quote }}\n namespace: {{ $ref.namespace\ + \ | default $xr.metadata.namespace | quote }}\n {{- end }}\n" + - step: relationship + functionRef: + name: hops-ops-auth-stackrelationship + - step: auto-ready + functionRef: + name: crossplane-contrib-function-auto-ready + - step: relationship-readiness + functionRef: + name: crossplane-contrib-function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: "{{- $xr := getCompositeResource . }}\n{{- $desiredStatus := .desired.composite.resource.status | default\ + \ dict }}\napiVersion: {{ $xr.apiVersion }}\nkind: {{ $xr.kind }}\nmetadata:\n annotations:\n gotemplating.fn.crossplane.io/ready:\ + \ {{ ternary \"True\" \"False\" ($desiredStatus.ready | default false) | quote }}\n" diff --git a/apis/administrators/definition.yaml b/apis/administrators/definition.yaml new file mode 100644 index 0000000..7938e8a --- /dev/null +++ b/apis/administrators/definition.yaml @@ -0,0 +1,286 @@ +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: administrators.auth.hops.ops.com.ai +spec: + group: auth.hops.ops.com.ai + names: + kind: Administrator + plural: administrators + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + description: Administrator resolves a human or machine user and owns only its native v2 assignment. References and + provider permissions must address the same Zitadel instance. Existing assignments are retained and writes suspended + while references are unresolved or change identity. + properties: + spec: + type: object + properties: + subjectRef: + type: object + description: Reference to an existing native managed resource. Omit namespace for this XR namespace; an explicit + namespace requires allowCrossNamespaceReferences. IDs are read from observed status, never from Kubernetes + UIDs. + properties: + name: + type: string + minLength: 1 + namespace: + type: string + minLength: 1 + kind: + type: string + minLength: 1 + default: HumanUser + enum: + - HumanUser + - MachineUser + apiVersion: + type: string + minLength: 1 + default: user.zitadel.m.crossplane.io/v1alpha1 + enum: + - user.zitadel.m.crossplane.io/v1alpha1 + required: + - name + x-kubernetes-validations: + - rule: self == oldSelf + message: subjectRef is immutable; create another binding for a different subject/target + - rule: self == oldSelf + message: subjectRef is immutable; create another binding for a different subject/target + allowCrossNamespaceReferences: + type: boolean + default: false + description: Explicit opt-in to resolve named native resources in other namespaces. Restrict XR and ProviderConfig + access to trusted authors; this is intent validation, not a replacement for RBAC. + roles: + type: array + items: + type: string + minLength: 1 + x-kubernetes-list-type: set + minItems: 1 + description: Complete assignment role set. One XR must own the whole role set for this subject/target tuple; + omitted roles are revoked. + providerConfigRef: + type: object + properties: + name: + type: string + minLength: 1 + kind: + type: string + minLength: 1 + enum: + - ProviderConfig + - ClusterProviderConfig + default: ProviderConfig + required: + - name + - kind + x-kubernetes-validations: + - rule: self == oldSelf + message: providerConfigRef is immutable; adopt in another XR when changing the provider + managementPolicies: + type: array + items: + type: string + minLength: 1 + enum: + - '*' + - Create + - Observe + - Update + - Delete + - LateInitialize + default: + - '*' + x-kubernetes-list-type: set + labels: + type: object + additionalProperties: + type: string + x-kubernetes-preserve-unknown-fields: true + binding: + type: object + description: Native assignment naming and import controls. Resource name is immutable. externalName adopts + an existing remote assignment; begin with Observe-only and remove the previous owner without Delete before + enabling writes. + properties: + resourceName: + type: string + minLength: 1 + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$ + x-kubernetes-validations: + - rule: self == oldSelf + message: resourceName is immutable + externalName: + type: string + minLength: 1 + forProvider: + type: object + x-kubernetes-preserve-unknown-fields: true + description: Native provider options. Resolved identity and complete role fields take precedence; use binding.externalName + for import. + target: + type: object + description: Exactly one explicit administrator scope. ProjectGrant resolves the project and grantee organization + from an existing native Grant; no shared delegation is created. + properties: + instance: + type: boolean + enum: + - true + organizationRef: + type: object + description: Reference to an existing native managed resource. Omit namespace for this XR namespace; an + explicit namespace requires allowCrossNamespaceReferences. IDs are read from observed status, never + from Kubernetes UIDs. + properties: + name: + type: string + minLength: 1 + namespace: + type: string + minLength: 1 + kind: + type: string + minLength: 1 + default: Organization + enum: + - Organization + apiVersion: + type: string + minLength: 1 + default: org.zitadel.m.crossplane.io/v1alpha1 + enum: + - org.zitadel.m.crossplane.io/v1alpha1 + required: + - name + x-kubernetes-validations: + - rule: self == oldSelf + message: organizationRef is immutable; create another binding for a different subject/target + projectRef: + type: object + description: Reference to an existing native managed resource. Omit namespace for this XR namespace; an + explicit namespace requires allowCrossNamespaceReferences. IDs are read from observed status, never + from Kubernetes UIDs. + properties: + name: + type: string + minLength: 1 + namespace: + type: string + minLength: 1 + kind: + type: string + minLength: 1 + default: ProjectV2 + enum: + - ProjectV2 + - Project + apiVersion: + type: string + minLength: 1 + default: project.zitadel.m.crossplane.io/v1alpha1 + enum: + - project.zitadel.m.crossplane.io/v1alpha1 + required: + - name + x-kubernetes-validations: + - rule: self == oldSelf + message: projectRef is immutable; create another binding for a different subject/target + projectGrantRef: + type: object + description: Reference to an existing native managed resource. Omit namespace for this XR namespace; an + explicit namespace requires allowCrossNamespaceReferences. IDs are read from observed status, never + from Kubernetes UIDs. + properties: + name: + type: string + minLength: 1 + namespace: + type: string + minLength: 1 + kind: + type: string + minLength: 1 + default: Grant + enum: + - Grant + apiVersion: + type: string + minLength: 1 + default: project.zitadel.m.crossplane.io/v1alpha1 + enum: + - project.zitadel.m.crossplane.io/v1alpha1 + required: + - name + x-kubernetes-validations: + - rule: self == oldSelf + message: projectGrantRef is immutable; create another binding for a different subject/target + x-kubernetes-validations: + - rule: '(has(self.instance) ? 1 : 0) + (has(self.organizationRef) ? 1 : 0) + (has(self.projectRef) ? 1 : + 0) + (has(self.projectGrantRef) ? 1 : 0) == 1' + message: 'select exactly one target: instance, organizationRef, projectRef or projectGrantRef' + - rule: self == oldSelf + message: target is immutable; create another binding for a different subject/target + required: + - subjectRef + - roles + - providerConfigRef + - target + x-kubernetes-validations: + - rule: '!(has(self.subjectRef)) || !has(self.subjectRef.namespace) || self.allowCrossNamespaceReferences' + message: 'an explicit reference namespace requires allowCrossNamespaceReferences: true' + - rule: '!(has(self.target) && has(self.target.organizationRef)) || !has(self.target.organizationRef.namespace) + || self.allowCrossNamespaceReferences' + message: 'an explicit reference namespace requires allowCrossNamespaceReferences: true' + - rule: '!(has(self.target) && has(self.target.projectRef)) || !has(self.target.projectRef.namespace) || self.allowCrossNamespaceReferences' + message: 'an explicit reference namespace requires allowCrossNamespaceReferences: true' + - rule: '!(has(self.target) && has(self.target.projectGrantRef)) || !has(self.target.projectGrantRef.namespace) + || self.allowCrossNamespaceReferences' + message: 'an explicit reference namespace requires allowCrossNamespaceReferences: true' + - rule: '!has(oldSelf.binding) || !has(oldSelf.binding.resourceName) || (has(self.binding) && has(self.binding.resourceName) + && self.binding.resourceName == oldSelf.binding.resourceName)' + message: binding.resourceName cannot change or be removed once set + - rule: has(self.binding) == has(oldSelf.binding) && (!has(self.binding) || self.binding == oldSelf.binding) + message: binding naming and import controls must be set at creation and are immutable + status: + type: object + properties: + ready: + type: boolean + bindingId: + type: string + description: Observed native assignment identity (authorization ID or administrator user/target tuple). + referencesResolved: + type: boolean + conditions: + type: array + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: + - type + items: + type: object + x-kubernetes-preserve-unknown-fields: true + properties: + type: + type: string + status: + type: string + required: + - type + - status + bindingIdentityVerified: + type: boolean + description: Whether the observed native user/target identities match the resolved references. + required: + - spec diff --git a/apis/authorizations/composition.yaml b/apis/authorizations/composition.yaml new file mode 100644 index 0000000..4c3b93b --- /dev/null +++ b/apis/authorizations/composition.yaml @@ -0,0 +1,46 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: authorizations.auth.hops.ops.com.ai +spec: + compositeTypeRef: + apiVersion: auth.hops.ops.com.ai/v1alpha1 + kind: Authorization + mode: Pipeline + pipeline: + - step: resolve-resource-references + functionRef: + name: crossplane-contrib-function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: "{{- $xr := getCompositeResource . }}\n{{- $spec := $xr.spec }}\n{{- $target := $spec }}\n{{- if eq $xr.kind\ + \ \"Administrator\" }}{{- $target = $spec.target }}{{- end }}\n{{- $refs := dict \"subject\" (mergeOverwrite (dict\ + \ \"kind\" \"HumanUser\" \"apiVersion\" \"user.zitadel.m.crossplane.io/v1alpha1\") (deepCopy $spec.subjectRef))\ + \ }}\n{{- range $key, $defaults := dict\n \"project\" (dict \"kind\" \"ProjectV2\" \"apiVersion\" \"project.zitadel.m.crossplane.io/v1alpha1\"\ + )\n \"organization\" (dict \"kind\" \"Organization\" \"apiVersion\" \"org.zitadel.m.crossplane.io/v1alpha1\")\n\ + \ \"projectGrant\" (dict \"kind\" \"Grant\" \"apiVersion\" \"project.zitadel.m.crossplane.io/v1alpha1\") }}\n \ + \ {{- $ref := get $target (printf \"%sRef\" $key) | default dict }}\n {{- if $ref.name }}{{- $_ := set $refs $key\ + \ (mergeOverwrite $defaults (deepCopy $ref)) }}{{- end }}\n{{- end }}\napiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1\n\ + kind: ExtraResources\nrequirements:\n {{- range $key, $ref := $refs }}\n {{ $key }}:\n apiVersion: {{ $ref.apiVersion\ + \ | quote }}\n kind: {{ $ref.kind | quote }}\n matchName: {{ $ref.name | quote }}\n namespace: {{ $ref.namespace\ + \ | default $xr.metadata.namespace | quote }}\n {{- end }}\n" + - step: relationship + functionRef: + name: hops-ops-auth-stackrelationship + - step: auto-ready + functionRef: + name: crossplane-contrib-function-auto-ready + - step: relationship-readiness + functionRef: + name: crossplane-contrib-function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: "{{- $xr := getCompositeResource . }}\n{{- $desiredStatus := .desired.composite.resource.status | default\ + \ dict }}\napiVersion: {{ $xr.apiVersion }}\nkind: {{ $xr.kind }}\nmetadata:\n annotations:\n gotemplating.fn.crossplane.io/ready:\ + \ {{ ternary \"True\" \"False\" ($desiredStatus.ready | default false) | quote }}\n" diff --git a/apis/authorizations/definition.yaml b/apis/authorizations/definition.yaml new file mode 100644 index 0000000..290f4b4 --- /dev/null +++ b/apis/authorizations/definition.yaml @@ -0,0 +1,270 @@ +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: authorizations.auth.hops.ops.com.ai +spec: + group: auth.hops.ops.com.ai + names: + kind: Authorization + plural: authorizations + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + description: Authorization resolves a human or machine user and owns only its native v2 assignment. References and + provider permissions must address the same Zitadel instance. Existing assignments are retained and writes suspended + while references are unresolved or change identity. + properties: + spec: + type: object + properties: + subjectRef: + type: object + description: Reference to an existing native managed resource. Omit namespace for this XR namespace; an explicit + namespace requires allowCrossNamespaceReferences. IDs are read from observed status, never from Kubernetes + UIDs. + properties: + name: + type: string + minLength: 1 + namespace: + type: string + minLength: 1 + kind: + type: string + minLength: 1 + default: HumanUser + enum: + - HumanUser + - MachineUser + apiVersion: + type: string + minLength: 1 + default: user.zitadel.m.crossplane.io/v1alpha1 + enum: + - user.zitadel.m.crossplane.io/v1alpha1 + required: + - name + x-kubernetes-validations: + - rule: self == oldSelf + message: subjectRef is immutable; create another binding for a different subject/target + allowCrossNamespaceReferences: + type: boolean + default: false + description: Explicit opt-in to resolve named native resources in other namespaces. Restrict XR and ProviderConfig + access to trusted authors; this is intent validation, not a replacement for RBAC. + roles: + type: array + items: + type: string + minLength: 1 + x-kubernetes-list-type: set + minItems: 1 + description: Complete assignment role set. One XR must own the whole role set for this subject/target tuple; + omitted roles are revoked. + providerConfigRef: + type: object + properties: + name: + type: string + minLength: 1 + kind: + type: string + minLength: 1 + enum: + - ProviderConfig + - ClusterProviderConfig + default: ProviderConfig + required: + - name + - kind + x-kubernetes-validations: + - rule: self == oldSelf + message: providerConfigRef is immutable; adopt in another XR when changing the provider + managementPolicies: + type: array + items: + type: string + minLength: 1 + enum: + - '*' + - Create + - Observe + - Update + - Delete + - LateInitialize + default: + - '*' + x-kubernetes-list-type: set + labels: + type: object + additionalProperties: + type: string + x-kubernetes-preserve-unknown-fields: true + binding: + type: object + description: Native assignment naming and import controls. Resource name is immutable. externalName adopts + an existing remote assignment; begin with Observe-only and remove the previous owner without Delete before + enabling writes. + properties: + resourceName: + type: string + minLength: 1 + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$ + x-kubernetes-validations: + - rule: self == oldSelf + message: resourceName is immutable + externalName: + type: string + minLength: 1 + forProvider: + type: object + x-kubernetes-preserve-unknown-fields: true + description: Native provider options. Resolved identity and complete role fields take precedence; use binding.externalName + for import. + projectRef: + type: object + description: Reference to an existing native managed resource. Omit namespace for this XR namespace; an explicit + namespace requires allowCrossNamespaceReferences. IDs are read from observed status, never from Kubernetes + UIDs. + properties: + name: + type: string + minLength: 1 + namespace: + type: string + minLength: 1 + kind: + type: string + minLength: 1 + default: ProjectV2 + enum: + - ProjectV2 + - Project + apiVersion: + type: string + minLength: 1 + default: project.zitadel.m.crossplane.io/v1alpha1 + enum: + - project.zitadel.m.crossplane.io/v1alpha1 + required: + - name + x-kubernetes-validations: + - rule: self == oldSelf + message: projectRef is immutable; create another binding for a different subject/target + organizationRef: + type: object + description: Reference to an existing native managed resource. Omit namespace for this XR namespace; an explicit + namespace requires allowCrossNamespaceReferences. IDs are read from observed status, never from Kubernetes + UIDs. + properties: + name: + type: string + minLength: 1 + namespace: + type: string + minLength: 1 + kind: + type: string + minLength: 1 + default: Organization + enum: + - Organization + apiVersion: + type: string + minLength: 1 + default: org.zitadel.m.crossplane.io/v1alpha1 + enum: + - org.zitadel.m.crossplane.io/v1alpha1 + required: + - name + x-kubernetes-validations: + - rule: self == oldSelf + message: organizationRef is immutable; create another binding for a different subject/target + projectGrantRef: + type: object + description: Reference to an existing native managed resource. Omit namespace for this XR namespace; an explicit + namespace requires allowCrossNamespaceReferences. IDs are read from observed status, never from Kubernetes + UIDs. + properties: + name: + type: string + minLength: 1 + namespace: + type: string + minLength: 1 + kind: + type: string + minLength: 1 + default: Grant + enum: + - Grant + apiVersion: + type: string + minLength: 1 + default: project.zitadel.m.crossplane.io/v1alpha1 + enum: + - project.zitadel.m.crossplane.io/v1alpha1 + required: + - name + x-kubernetes-validations: + - rule: self == oldSelf + message: projectGrantRef is immutable; create another binding for a different subject/target + required: + - subjectRef + - roles + - providerConfigRef + - projectRef + - organizationRef + x-kubernetes-validations: + - rule: '!(has(self.subjectRef)) || !has(self.subjectRef.namespace) || self.allowCrossNamespaceReferences' + message: 'an explicit reference namespace requires allowCrossNamespaceReferences: true' + - rule: '!(has(self.projectRef)) || !has(self.projectRef.namespace) || self.allowCrossNamespaceReferences' + message: 'an explicit reference namespace requires allowCrossNamespaceReferences: true' + - rule: '!(has(self.organizationRef)) || !has(self.organizationRef.namespace) || self.allowCrossNamespaceReferences' + message: 'an explicit reference namespace requires allowCrossNamespaceReferences: true' + - rule: '!(has(self.projectGrantRef)) || !has(self.projectGrantRef.namespace) || self.allowCrossNamespaceReferences' + message: 'an explicit reference namespace requires allowCrossNamespaceReferences: true' + - rule: '!has(oldSelf.binding) || !has(oldSelf.binding.resourceName) || (has(self.binding) && has(self.binding.resourceName) + && self.binding.resourceName == oldSelf.binding.resourceName)' + message: binding.resourceName cannot change or be removed once set + - rule: has(self.projectGrantRef) == has(oldSelf.projectGrantRef) + message: projectGrantRef cannot be added or removed; create another XR for a different delegation contract + - rule: has(self.binding) == has(oldSelf.binding) && (!has(self.binding) || self.binding == oldSelf.binding) + message: binding naming and import controls must be set at creation and are immutable + status: + type: object + properties: + ready: + type: boolean + bindingId: + type: string + description: Observed native assignment identity (authorization ID or administrator user/target tuple). + referencesResolved: + type: boolean + conditions: + type: array + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: + - type + items: + type: object + x-kubernetes-preserve-unknown-fields: true + properties: + type: + type: string + status: + type: string + required: + - type + - status + bindingIdentityVerified: + type: boolean + description: Whether the observed native user/target identities match the resolved references. + required: + - spec diff --git a/examples/administrators/referenced.yaml b/examples/administrators/referenced.yaml new file mode 100644 index 0000000..7ac0c5c --- /dev/null +++ b/examples/administrators/referenced.yaml @@ -0,0 +1,17 @@ +apiVersion: auth.hops.ops.com.ai/v1alpha1 +metadata: + name: reader + namespace: app +spec: + subjectRef: + name: alice + kind: HumanUser + roles: + - ORG_USER_MANAGER + providerConfigRef: + name: zitadel + kind: ProviderConfig + target: + organizationRef: + name: app-org +kind: Administrator diff --git a/examples/authorizations/referenced.yaml b/examples/authorizations/referenced.yaml new file mode 100644 index 0000000..c10aede --- /dev/null +++ b/examples/authorizations/referenced.yaml @@ -0,0 +1,18 @@ +apiVersion: auth.hops.ops.com.ai/v1alpha1 +metadata: + name: reader + namespace: app +spec: + subjectRef: + name: alice + kind: HumanUser + roles: + - reader + providerConfigRef: + name: zitadel + kind: ProviderConfig + projectRef: + name: product + organizationRef: + name: app-org +kind: Authorization diff --git a/functions/relationship/00-state-init.yaml.gotmpl b/functions/relationship/00-state-init.yaml.gotmpl new file mode 100644 index 0000000..ca4bd5a --- /dev/null +++ b/functions/relationship/00-state-init.yaml.gotmpl @@ -0,0 +1,49 @@ +# code: language=yaml +{{- $xr := getCompositeResource . }} +{{- $raw := $xr.spec | default dict }} +{{- $kind := $xr.kind }} +{{- $name := $xr.metadata.name }} +{{- $namespace := $xr.metadata.namespace }} +{{- $binding := $raw.binding | default dict }} +{{- $nativeAPI := "authorization.zitadel.m.crossplane.io/v1alpha1" }} +{{- if eq $kind "Administrator" }} + {{- $nativeAPI = "internalpermission.zitadel.m.crossplane.io/v1alpha1" }} +{{- end }} +{{- $labels := mergeOverwrite (dict "hops.ops.com.ai/managed" "true" (printf "hops.ops.com.ai/auth-%s" (lower $kind)) $name) ($raw.labels | default dict) }} +{{- $eff := dict + "roles" $raw.roles + "providerConfigRef" $raw.providerConfigRef + "managementPolicies" (get $raw "managementPolicies") + "forProvider" ($raw.forProvider | default dict) + "externalName" ($binding.externalName | default "") + "resourceName" ($binding.resourceName | default (printf "%s-%s" $name (lower $kind))) + "labels" $labels +}} +{{- if not (hasKey $raw "managementPolicies") }} + {{- $_ := set $eff "managementPolicies" (list "*") }} +{{- end }} +{{- $refs := dict "subject" (mergeOverwrite (dict "kind" "HumanUser" "apiVersion" "user.zitadel.m.crossplane.io/v1alpha1") (deepCopy $raw.subjectRef)) }} +{{- $target := $raw }} +{{- if eq $kind "Administrator" }} + {{- $target = $raw.target }} +{{- end }} +{{- range $key, $defaults := dict + "project" (dict "kind" "ProjectV2" "apiVersion" "project.zitadel.m.crossplane.io/v1alpha1") + "organization" (dict "kind" "Organization" "apiVersion" "org.zitadel.m.crossplane.io/v1alpha1") + "projectGrant" (dict "kind" "Grant" "apiVersion" "project.zitadel.m.crossplane.io/v1alpha1") }} + {{- $ref := get $target (printf "%sRef" $key) | default dict }} + {{- if $ref.name }} + {{- $_ := set $refs $key (mergeOverwrite $defaults (deepCopy $ref)) }} + {{- end }} +{{- end }} +{{- range $key, $ref := $refs }} + {{- $_ := set $ref "namespace" ($ref.namespace | default $namespace) }} + {{- if and (ne $ref.namespace $namespace) (not ($raw.allowCrossNamespaceReferences | default false)) }} + {{- fail "cross-namespace references require allowCrossNamespaceReferences: true" }} + {{- end }} +{{- end }} +{{- $state := dict + "name" $name "namespace" $namespace "kind" $kind "nativeAPI" $nativeAPI + "spec" (dict "raw" $raw "effective" $eff) + "refs" $refs "observed" (dict) "binding" (dict) "status" (dict) +}} diff --git a/functions/relationship/10-observed.yaml.gotmpl b/functions/relationship/10-observed.yaml.gotmpl new file mode 100644 index 0000000..710e41c --- /dev/null +++ b/functions/relationship/10-observed.yaml.gotmpl @@ -0,0 +1,31 @@ +# code: language=yaml +{{- range $key, $ref := $state.refs }} + {{- $found := dict }} + {{- $resources := get ($.context | default dict) "apiextensions.crossplane.io/extra-resources" | default dict }} + {{- $items := (get $resources $key | default dict).items | default list }} + {{- range $items }} + {{- $obj := .resource | default . }} + {{- if and (eq ($obj.metadata.name | default "") $ref.name) (eq ($obj.metadata.namespace | default "") $ref.namespace) (eq ($obj.kind | default "") $ref.kind) (eq ($obj.apiVersion | default "") $ref.apiVersion) }} + {{- $found = $obj }} + {{- end }} + {{- end }} + {{- $status := $found.status | default dict }} + {{- $at := $status.atProvider | default dict }} + {{- $ready := false }} + {{- range ($status.conditions | default list) }} + {{- if and (eq .type "Ready") (eq .status "True") }}{{- $ready = true }}{{- end }} + {{- end }} + {{- $_ := set $state.observed $key (dict "resource" $found "atProvider" $at "id" ($at.id | default "") "ready" $ready "deleting" (not (empty (($found.metadata | default dict).deletionTimestamp)))) }} +{{- end }} +{{- $native := getComposedResource . "binding" | default dict }} +{{- $status := $native.status | default dict }} +{{- $at := $status.atProvider | default dict }} +{{- $ready := false }} +{{- $synced := false }} +{{- $metadata := $native.metadata | default dict }} +{{- range ($status.conditions | default list) }} + {{- $current := or (not (hasKey . "observedGeneration")) (eq (int64 (.observedGeneration | default 0)) (int64 ($metadata.generation | default 0))) }} + {{- if and $current (eq .type "Ready") (eq .status "True") }}{{- $ready = true }}{{- end }} + {{- if and $current (eq .type "Synced") (eq .status "True") }}{{- $synced = true }}{{- end }} +{{- end }} +{{- $_ := set $state.observed "binding" (dict "resource" $native "id" ($at.id | default "") "ready" (and $ready $synced (not (empty $at.id)) (empty $metadata.deletionTimestamp))) }} diff --git a/functions/relationship/20-computed.yaml.gotmpl b/functions/relationship/20-computed.yaml.gotmpl new file mode 100644 index 0000000..b742d9e --- /dev/null +++ b/functions/relationship/20-computed.yaml.gotmpl @@ -0,0 +1,97 @@ +# code: language=yaml +{{- $eff := $state.spec.effective }} +{{- $fp := deepCopy $eff.forProvider }} +{{- /* Only the XR's typed references may select assignment identities. */}} +{{- range $key := list "userIdRef" "userIdSelector" "projectIdRef" "projectIdSelector" "orgIdRef" "orgIdSelector" }} + {{- $_ := unset $fp $key }} +{{- end }} +{{- $resolved := true }} +{{- range $key, $obs := $state.observed }} + {{- if ne $key "binding" }} + {{- if or (empty $obs.id) $obs.deleting }}{{- $resolved = false }}{{- end }} + {{- end }} +{{- end }} +{{- $_ := set $fp "userId" $state.observed.subject.id }} +{{- if eq $state.kind "Authorization" }} + {{- $_ := set $fp "projectId" $state.observed.project.id }} + {{- $_ := set $fp "orgId" $state.observed.organization.id }} + {{- $_ := set $fp "roleKeys" $eff.roles }} + {{- if hasKey $state.refs "projectGrant" }} + {{- $grant := $state.observed.projectGrant.atProvider }} + {{- if or (ne ($grant.projectId | default "") $state.observed.project.id) (ne ($grant.grantedOrgId | default "") $state.observed.organization.id) }} + {{- $resolved = false }} + {{- end }} + {{- end }} +{{- else }} + {{- /* The v2 administrator API takes a union; never leak stale target + fields from pass-through options into another scope. */}} + {{- $_ := unset $fp "orgId" }} + {{- $_ := unset $fp "projectId" }} + {{- $_ := set $fp "roles" $eff.roles }} + {{- $scope := "instance" }} + {{- if hasKey $state.refs "organization" }} + {{- $scope = "organization" }} + {{- $_ := set $fp "orgId" $state.observed.organization.id }} + {{- else if hasKey $state.refs "project" }} + {{- $scope = "project" }} + {{- $_ := set $fp "projectId" $state.observed.project.id }} + {{- else if hasKey $state.refs "projectGrant" }} + {{- $scope = "project_grant" }} + {{- $grant := $state.observed.projectGrant.atProvider }} + {{- $_ := set $fp "projectId" ($grant.projectId | default "") }} + {{- $_ := set $fp "orgId" ($grant.grantedOrgId | default "") }} + {{- if or (empty $fp.projectId) (empty $fp.orgId) }}{{- $resolved = false }}{{- end }} + {{- end }} + {{- $_ := set $fp "resourceType" $scope }} +{{- end }} +{{- $reason := "Resolved" }} +{{- $message := "All referenced native identities are resolved." }} +{{- if not $resolved }} + {{- $reason = "WaitingForReferences" }} + {{- $message = "A reference has no observed ID, is deleting, or the project grant does not match the requested project and organization. Existing binding is retained without create/update operations." }} +{{- end }} +{{- $old := $state.observed.binding.resource }} +{{- $oldSpec := $old.spec | default dict }} +{{- $oldFP := $oldSpec.forProvider | default dict }} +{{- if and $resolved (not (empty $old)) }} + {{- range $key := list "userId" "projectId" "orgId" "resourceType" }} + {{- if ne (get $fp $key | default "") (get $oldFP $key | default "") }} + {{- $resolved = false }} + {{- $reason = "IdentityChanged" }} + {{- $message = "A referenced resource now has a different remote identity. The existing binding is retained without create/update operations; create another XR for reassignment." }} + {{- end }} + {{- end }} +{{- end }} +{{- $remote := ($old.status | default dict).atProvider | default dict }} +{{- $identityVerified := not (empty $remote.id) }} +{{- $cachedIdentitySafe := not (empty $remote.id) }} +{{- range $key := list "userId" "projectId" "orgId" "resourceType" }} + {{- if ne (get $fp $key | default "") (get $remote $key | default "") }}{{- $identityVerified = false }}{{- end }} + {{- if ne (get $oldFP $key | default "") (get $remote $key | default "") }}{{- $cachedIdentitySafe = false }}{{- end }} +{{- end }} +{{- $importPending := and (not (empty $eff.externalName)) (not $cachedIdentitySafe) }} +{{- $remoteMismatch := and (not (empty $remote.id)) (not $cachedIdentitySafe) }} +{{- $holdWrites := or (not $resolved) $importPending $remoteMismatch }} +{{- $identityReason := "WaitingForObservation" }} +{{- if $identityVerified }}{{- $identityReason = "MatchesReferences" }}{{- end }} +{{- if $remoteMismatch }}{{- $identityReason = "BindingIdentityMismatch" }}{{- end }} +{{- $nativeSpec := dict "forProvider" $fp "providerConfigRef" $eff.providerConfigRef "managementPolicies" $eff.managementPolicies }} +{{- $name := $eff.resourceName }} +{{- $externalName := $eff.externalName }} +{{- if and $holdWrites (not (empty $old)) }} + {{- $nativeSpec = deepCopy $oldSpec }} + {{- $name = $old.metadata.name }} + {{- $externalName = index ($old.metadata.annotations | default dict) "crossplane.io/external-name" | default "" }} + {{- /* Preserve explicit Delete permission for intentional XR teardown, but + suspend creates and updates from cached reference values. */}} + {{- $policies := $nativeSpec.managementPolicies | default list }} + {{- $retainedPolicies := list }} + {{- if or (has "*" $policies) (has "Observe" $policies) }}{{- $retainedPolicies = append $retainedPolicies "Observe" }}{{- end }} + {{- if and (not $remoteMismatch) (not $importPending) (or (has "*" $policies) (has "Delete" $policies)) }}{{- $retainedPolicies = append $retainedPolicies "Delete" }}{{- end }} + {{- $_ := set $nativeSpec "managementPolicies" $retainedPolicies }} +{{- end }} +{{- if and $holdWrites (empty $old) }} + {{- $_ := set $nativeSpec "managementPolicies" (list "Observe") }} +{{- end }} +{{- $_ := set $state "binding" (dict "render" (or $resolved (not (empty $old))) "spec" $nativeSpec "name" $name "externalName" $externalName) }} +{{- $_ := set $state "status" (dict "referencesResolved" $resolved "reason" $reason "message" $message "identityVerified" $identityVerified "identityReason" $identityReason "ready" (and $resolved $identityVerified (not $remoteMismatch) $state.observed.binding.ready) "bindingId" $state.observed.binding.id) }} diff --git a/functions/relationship/40-binding.yaml.gotmpl b/functions/relationship/40-binding.yaml.gotmpl new file mode 100644 index 0000000..958fdf1 --- /dev/null +++ b/functions/relationship/40-binding.yaml.gotmpl @@ -0,0 +1,53 @@ +# code: language=yaml +{{- if $state.binding.render }} +--- +apiVersion: {{ $state.nativeAPI }} +kind: {{ $state.kind }} +metadata: + name: {{ $state.binding.name }} + namespace: {{ $state.namespace }} + labels: {{ $state.spec.effective.labels | toJson }} + annotations: + {{ setResourceNameAnnotation "binding" }} + gotemplating.fn.crossplane.io/ready: {{ ternary "True" "False" $state.status.ready | quote }} + {{- if $state.binding.externalName }} + crossplane.io/external-name: {{ $state.binding.externalName | quote }} + {{- end }} +spec: {{ $state.binding.spec | toJson }} +{{- end }} +{{- range $key, $ref := $state.refs }} + {{- $usageKey := printf "usage-%s" $key }} + {{- $oldUsage := getComposedResource $ $usageKey | default dict }} + {{- $canCreate := and $state.status.referencesResolved $state.observed.binding.ready (get $state.observed $key).ready }} + {{- if or $canCreate (not (empty $oldUsage)) }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ printf "%s-delete-%s-before-%s" $state.name (lower $state.kind) (lower $key) }} + namespace: {{ $state.namespace }} + labels: {{ $state.spec.effective.labels | toJson }} + annotations: + {{ setResourceNameAnnotation $usageKey }} + gotemplating.fn.crossplane.io/ready: "True" +spec: + {{- if not (empty $oldUsage) }} + {{- /* Keep an established deletion lock during temporary readiness loss. */}} + {{- $oldUsage.spec | toYaml | nindent 2 }} + {{- else }} + replayDeletion: true + of: + apiVersion: {{ $ref.apiVersion }} + kind: {{ $ref.kind }} + resourceRef: + name: {{ $ref.name | quote }} + namespace: {{ $ref.namespace | quote }} + by: + apiVersion: {{ $state.nativeAPI }} + kind: {{ $state.kind }} + resourceRef: + name: {{ $state.binding.name | quote }} + namespace: {{ $state.namespace | quote }} + {{- end }} + {{- end }} +{{- end }} diff --git a/functions/relationship/99-status.yaml.gotmpl b/functions/relationship/99-status.yaml.gotmpl new file mode 100644 index 0000000..dfa8a43 --- /dev/null +++ b/functions/relationship/99-status.yaml.gotmpl @@ -0,0 +1,26 @@ +# code: language=yaml +--- +apiVersion: auth.hops.ops.com.ai/v1alpha1 +kind: {{ $state.kind }} +metadata: + annotations: + gotemplating.fn.crossplane.io/ready: {{ ternary "True" "False" $state.status.ready | quote }} +status: + ready: {{ $state.status.ready }} + referencesResolved: {{ $state.status.referencesResolved }} + bindingIdentityVerified: {{ $state.status.identityVerified }} + bindingId: {{ $state.status.bindingId | quote }} +--- +apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 +kind: ClaimConditions +conditions: +- type: ReferencesResolved + status: {{ ternary "True" "False" $state.status.referencesResolved | quote }} + reason: {{ $state.status.reason }} + message: {{ $state.status.message | quote }} + target: Composite +- type: BindingIdentityVerified + status: {{ ternary "True" "False" $state.status.identityVerified | quote }} + reason: {{ $state.status.identityReason }} + message: "Native observed user and target identities must match before imported assignments can be changed." + target: Composite diff --git a/tests/security/test_relationships.py b/tests/security/test_relationships.py new file mode 100644 index 0000000..04e2959 --- /dev/null +++ b/tests/security/test_relationships.py @@ -0,0 +1,223 @@ +"""Exercise relationship reference resolution, retention and adoption through render.""" +import json +import os +from pathlib import Path +import shlex +import subprocess +import tempfile +import unittest + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +USER_API = 'user.zitadel.m.crossplane.io/v1alpha1' +PROJECT_API = 'project.zitadel.m.crossplane.io/v1alpha1' +ORG_API = 'org.zitadel.m.crossplane.io/v1alpha1' +XR_API = 'auth.hops.ops.com.ai/v1alpha1' + + +class RelationshipTests(unittest.TestCase): + def xr(self, kind='Authorization', subject='HumanUser'): + spec = {'subjectRef': {'name': 'person', 'kind': subject}, 'roles': ['reader'], + 'providerConfigRef': {'name': 'zitadel', 'kind': 'ProviderConfig'}} + if kind == 'Authorization': + spec.update(projectRef={'name': 'product'}, organizationRef={'name': 'tenant'}) + else: + spec.update(roles=['ORG_USER_MANAGER'], target={'organizationRef': {'name': 'tenant'}}) + return {'apiVersion': XR_API, 'kind': kind, + 'metadata': {'name': 'binding', 'namespace': 'app'}, 'spec': spec} + + def sources(self, subject='HumanUser', namespace='app', ready=True): + def obj(api, kind, name, values): + return {'apiVersion': api, 'kind': kind, 'metadata': {'name': name, 'namespace': namespace}, + 'status': {'atProvider': values, 'conditions': [{'type': 'Ready', 'status': str(ready)}]}} + return [obj(USER_API, subject, 'person', {'id': 'user-id', 'orgId': 'user-home-org'}), + obj(PROJECT_API, 'ProjectV2', 'product', {'id': 'project-id', 'orgId': 'owner-org'}), + obj(ORG_API, 'Organization', 'tenant', {'id': 'grantee-org'}), + obj(PROJECT_API, 'Grant', 'delegation', {'id': 'grant-id', 'projectId': 'project-id', 'grantedOrgId': 'grantee-org'})] + + def binding(self, kind='Authorization', policies=None): + fp = {'userId': 'user-id', 'projectId': 'project-id', 'orgId': 'grantee-org', 'roleKeys': ['old-role']} + api = 'authorization.zitadel.m.crossplane.io/v1alpha1' + if kind == 'Administrator': + api = 'internalpermission.zitadel.m.crossplane.io/v1alpha1' + fp = {'userId': 'user-id', 'orgId': 'grantee-org', 'resourceType': 'organization', 'roles': ['ORG_USER_MANAGER']} + return {'apiVersion': api, 'kind': kind, + 'metadata': {'name': 'binding-' + kind.lower(), 'namespace': 'app', 'generation': 3, + 'annotations': {'crossplane.io/composition-resource-name': 'binding', 'crossplane.io/external-name': 'existing-id'}}, + 'spec': {'forProvider': fp, 'managementPolicies': policies if policies is not None else ['*'], + 'providerConfigRef': {'name': 'zitadel', 'kind': 'ProviderConfig'}}, + 'status': {'atProvider': {'id': 'existing-id', **fp}, 'conditions': [ + {'type': 'Ready', 'status': 'True', 'observedGeneration': 3}, + {'type': 'Synced', 'status': 'True', 'observedGeneration': 3}]}} + + def render(self, xr, extra=None, observed=None, success=True): + (ROOT / '.tmp').mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as directory: + p = Path(directory) + (p / 'xr.json').write_text(json.dumps(xr)) + plural = 'authorizations' if xr['kind'] == 'Authorization' else 'administrators' + cmd = shlex.split(os.environ.get('RELATIONSHIP_RENDER_COMMAND', 'up composition render --quiet')) + [ + '--xrd=apis/' + plural + '/definition.yaml', + 'apis/' + plural + '/composition.yaml', str(p / 'xr.json')] + if os.environ.get('RELATIONSHIP_FUNCTIONS_FILE'): + cmd = ['crossplane', 'render', str(p / 'xr.json'), + 'apis/' + plural + '/composition.yaml', os.environ['RELATIONSHIP_FUNCTIONS_FILE'], + '--xrd=apis/' + plural + '/definition.yaml'] + for flag, resources in [('extra-resources', extra), ('observed-resources', observed)]: + if resources: + (p / (flag + '.yaml')).write_text(yaml.safe_dump_all(resources)) + cmd.append('--' + flag + '=' + str(p / (flag + '.yaml'))) + result = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=240) + if not success: + self.assertNotEqual(result.returncode, 0, result.stdout) + return + self.assertEqual(result.returncode, 0, result.stderr) + return [d for d in yaml.safe_load_all(result.stdout) if d] + + def native(self, docs, kind='Authorization'): + return next(d for d in docs if d['kind'] == kind and d['apiVersion'] != XR_API) + + def status(self, docs): + return next(d['status'] for d in docs if d['apiVersion'] == XR_API) + + def assert_not_ready(self, docs): + status = self.status(docs) + self.assertFalse(status['ready']) + self.assertEqual(next(c['status'] for c in status['conditions'] if c['type'] == 'Ready'), 'False') + + def test_missing_initial_references_never_create_a_binding(self): + docs = self.render(self.xr()) + self.assertEqual(len(docs), 1) + self.assert_not_ready(docs) + self.assertFalse(self.status(docs)['referencesResolved']) + + def test_both_subject_kinds_resolve_observed_ids_and_own_only_assignment(self): + for subject in ['HumanUser', 'MachineUser']: + with self.subTest(subject=subject): + xr = self.xr(subject=subject) + xr['spec']['forProvider'] = {'userId': 'wrong-user', 'orgId': 'wrong-org', 'roleKeys': ['wrong-role'], 'orgIdRef': {'name': 'wrong-org', 'policy': {'resolve': 'Always'}}} + xr['spec']['labels'] = {'team': 'identity', 'hops.ops.com.ai/managed': 'custom'} + docs = self.render(xr, self.sources(subject)) + native = self.native(docs) + self.assertEqual(native['spec']['forProvider'], {'userId': 'user-id', 'projectId': 'project-id', 'orgId': 'grantee-org', 'roleKeys': ['reader']}) + self.assertEqual(native['metadata']['labels']['hops.ops.com.ai/managed'], 'custom') + self.assertEqual(len(docs), 2) + self.assert_not_ready(docs) + + def test_all_administrator_scopes(self): + for scope, target, fields in [ + ('organization', {'organizationRef': {'name': 'tenant'}}, {'orgId': 'grantee-org'}), + ('project', {'projectRef': {'name': 'product'}}, {'projectId': 'project-id'}), + ('project_grant', {'projectGrantRef': {'name': 'delegation'}}, {'projectId': 'project-id', 'orgId': 'grantee-org'}), + ('instance', {'instance': True}, {}), + ]: + with self.subTest(scope=scope): + xr = self.xr('Administrator', 'MachineUser') + xr['spec']['target'] = target + xr['spec']['forProvider'] = {'orgId': 'stale-org', 'projectId': 'stale-project', 'resourceType': 'stale'} + docs = self.render(xr, self.sources('MachineUser')) + self.assertEqual(self.native(docs, 'Administrator')['spec']['forProvider'], + {'userId': 'user-id', 'resourceType': scope, 'roles': ['ORG_USER_MANAGER'], **fields}) + self.assertFalse(any(d['kind'] == 'Grant' for d in docs)) + + def test_cross_namespace_requires_opt_in_and_protects_exact_sources(self): + xr = self.xr() + for key in ['subjectRef', 'projectRef', 'organizationRef']: + xr['spec'][key]['namespace'] = 'identity' + self.render(xr, self.sources(namespace='identity'), success=False) + xr['spec']['allowCrossNamespaceReferences'] = True + docs = self.render(xr, self.sources(namespace='identity'), [self.binding()]) + self.assertTrue(self.status(docs)['ready']) + usages = [d for d in docs if d['kind'] == 'Usage'] + self.assertEqual(len(usages), 3) + for usage in usages: + self.assertEqual(usage['spec']['of']['resourceRef']['namespace'], 'identity') + self.assertEqual(usage['spec']['by']['resourceRef']['namespace'], 'app') + + def test_unready_reference_with_id_does_not_remove_assignment(self): + docs = self.render(self.xr(), self.sources(ready=False), [self.binding()]) + self.assertEqual(self.native(docs)['spec']['forProvider']['roleKeys'], ['reader']) + self.assertTrue(self.status(docs)['referencesResolved']) + self.assertFalse(any(d['kind'] == 'Usage' for d in docs)) + + def test_missing_or_replaced_reference_retains_old_binding_without_writes(self): + for kind in ['Authorization', 'Administrator']: + for changed in [False, True]: + with self.subTest(kind=kind, changed=changed): + extra = self.sources() + if changed: + extra[0]['status']['atProvider']['id'] = 'replacement-user' + else: + extra = extra[1:] + old = self.binding(kind) + docs = self.render(self.xr(kind), extra, [old]) + native = self.native(docs, kind) + self.assertEqual(native['spec']['forProvider'], old['spec']['forProvider']) + self.assertEqual(native['spec']['managementPolicies'], ['Observe', 'Delete']) + self.assertEqual(native['metadata']['annotations']['crossplane.io/external-name'], 'existing-id') + self.assert_not_ready(docs) + self.assertFalse(self.status(docs)['referencesResolved']) + condition = next(c for c in self.status(docs)['conditions'] if c['type'] == 'ReferencesResolved') + self.assertEqual(condition['reason'], 'IdentityChanged' if changed else 'WaitingForReferences') + + def test_existing_usage_survives_reference_loss_and_stale_native_is_not_ready(self): + usage = {'apiVersion': 'protection.crossplane.io/v1beta1', 'kind': 'Usage', + 'metadata': {'name': 'binding-delete-authorization-before-subject', 'namespace': 'app', + 'annotations': {'crossplane.io/composition-resource-name': 'usage-subject'}}, + 'spec': {'replayDeletion': True, 'of': {'apiVersion': USER_API, 'kind': 'HumanUser', 'resourceRef': {'name': 'person', 'namespace': 'app'}}, + 'by': {'apiVersion': self.binding()['apiVersion'], 'kind': 'Authorization', 'resourceRef': {'name': 'binding-authorization', 'namespace': 'app'}}}} + docs = self.render(self.xr(), self.sources()[1:], [self.binding(), usage]) + self.assertEqual(next(d['spec'] for d in docs if d['kind'] == 'Usage'), usage['spec']) + self.assert_not_ready(docs) + old = self.binding() + old['status']['conditions'][1]['observedGeneration'] = 2 + docs = self.render(self.xr(), self.sources(), [old]) + self.assert_not_ready(docs) + self.assertTrue(self.status(docs)['referencesResolved']) + + def test_import_and_retention_policies(self): + xr = self.xr() + xr['spec'].update(binding={'resourceName': 'adopt-existing', 'externalName': 'remote-existing'}, managementPolicies=['Observe']) + docs = self.render(xr, self.sources()) + native = self.native(docs) + self.assertEqual(native['metadata']['name'], 'adopt-existing') + self.assertEqual(native['metadata']['annotations']['crossplane.io/external-name'], 'remote-existing') + self.assertEqual(native['spec']['managementPolicies'], ['Observe']) + docs = self.render(self.xr(), self.sources()[1:], [self.binding(policies=['Observe', 'Update'])]) + self.assertEqual(self.native(docs)['spec']['managementPolicies'], ['Observe']) + + def test_import_waits_for_verified_observation_before_writes(self): + xr = self.xr() + xr['spec']['binding'] = {'externalName': 'existing-id'} + docs = self.render(xr, self.sources()) + self.assertEqual(self.native(docs)['spec']['managementPolicies'], ['Observe']) + self.assert_not_ready(docs) + old = self.binding() + docs = self.render(xr, self.sources(), [old]) + self.assertEqual(self.native(docs)['spec']['managementPolicies'], ['*']) + self.assertTrue(self.status(docs)['bindingIdentityVerified']) + missing = self.render(xr, self.sources()[1:], [old]) + self.assertEqual(self.native(missing)['spec']['managementPolicies'], ['Observe', 'Delete']) + old['status']['atProvider']['userId'] = 'unexpected-remote-user' + docs = self.render(xr, self.sources(), [old]) + self.assertEqual(self.native(docs)['spec']['managementPolicies'], ['Observe']) + self.assertFalse(self.status(docs)['bindingIdentityVerified']) + self.assert_not_ready(docs) + + def test_project_delegation_must_match_and_is_never_created_per_user(self): + xr = self.xr() + xr['spec']['projectGrantRef'] = {'name': 'delegation'} + extra = self.sources() + extra[-1]['status']['atProvider']['grantedOrgId'] = 'wrong-grantee' + docs = self.render(xr, extra, [self.binding()]) + self.assert_not_ready(docs) + self.assertEqual(self.native(docs)['spec']['managementPolicies'], ['Observe', 'Delete']) + extra[-1]['status']['atProvider']['grantedOrgId'] = 'grantee-org' + docs = self.render(xr, extra, [self.binding()]) + self.assertEqual(len([d for d in docs if d['kind'] == 'Usage']), 4) + self.assertFalse(any(d['kind'] == 'Grant' for d in docs)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test-relationships/kcl.mod b/tests/test-relationships/kcl.mod new file mode 100644 index 0000000..e2b40d0 --- /dev/null +++ b/tests/test-relationships/kcl.mod @@ -0,0 +1,6 @@ +[package] +name = "test-render" +version = "0.0.1" + +[dependencies] +models = { path = "./model" } diff --git a/tests/test-relationships/main.k b/tests/test-relationships/main.k new file mode 100644 index 0000000..179f4c9 --- /dev/null +++ b/tests/test-relationships/main.k @@ -0,0 +1,52 @@ +import models.io.upbound.dev.meta.v1alpha1 as meta + +# Typed subject resolution is the composition's value; other identity lifecycle +# belongs to native resources and the owning application environment. +_subjects = ["HumanUser", "MachineUser"] +items = [meta.CompositionTest { + metadata.name = "authorization-resolves-" + subject.lower() + spec = { + compositionPath = "apis/authorizations/composition.yaml" + xrdPath = "apis/authorizations/definition.yaml" + timeoutSeconds = 90 + validate = False + xr = { + apiVersion = "auth.hops.ops.com.ai/v1alpha1" + kind = "Authorization" + metadata = {name = "reader", namespace = "app"} + spec = { + subjectRef = {name = "person", kind = subject} + projectRef = {name = "project"} + organizationRef = {name = "tenant"} + roles = ["reader"] + providerConfigRef = {name = "zitadel", kind = "ProviderConfig"} + } + } + extraResources = [ + { + apiVersion = "user.zitadel.m.crossplane.io/v1alpha1" + kind = subject + metadata = {name = "person", namespace = "app"} + status.atProvider.id = "user-id" + }, + { + apiVersion = "project.zitadel.m.crossplane.io/v1alpha1" + kind = "ProjectV2" + metadata = {name = "project", namespace = "app"} + status.atProvider = {id = "project-id", orgId = "org-id"} + }, + { + apiVersion = "org.zitadel.m.crossplane.io/v1alpha1" + kind = "Organization" + metadata = {name = "tenant", namespace = "app"} + status.atProvider.id = "org-id" + } + ] + assertResources = [{ + apiVersion = "authorization.zitadel.m.crossplane.io/v1alpha1" + kind = "Authorization" + metadata.name = "reader-authorization" + spec.forProvider = {userId = "user-id", projectId = "project-id", orgId = "org-id", roleKeys = ["reader"]} + }] + } +} for subject in _subjects] diff --git a/tests/test-relationships/model b/tests/test-relationships/model new file mode 120000 index 0000000..faff6e4 --- /dev/null +++ b/tests/test-relationships/model @@ -0,0 +1 @@ +../../.up/kcl/models \ No newline at end of file diff --git a/tests/test-relationships/observed/administrator.yaml b/tests/test-relationships/observed/administrator.yaml new file mode 100644 index 0000000..cea923b --- /dev/null +++ b/tests/test-relationships/observed/administrator.yaml @@ -0,0 +1,32 @@ +apiVersion: internalpermission.zitadel.m.crossplane.io/v1alpha1 +kind: Administrator +metadata: + name: reader-administrator + namespace: app + annotations: + crossplane.io/composition-resource-name: binding + crossplane.io/external-name: user-id:organization:org-id +spec: + providerConfigRef: + name: zitadel + kind: ProviderConfig + managementPolicies: + - '*' + forProvider: + userId: user-id + orgId: org-id + resourceType: organization + roles: &id001 + - ORG_USER_MANAGER +status: + atProvider: + id: user-id:organization:org-id + userId: user-id + orgId: org-id + resourceType: organization + roles: *id001 + conditions: + - type: Ready + status: 'True' + - type: Synced + status: 'True' diff --git a/tests/test-relationships/observed/authorization.yaml b/tests/test-relationships/observed/authorization.yaml new file mode 100644 index 0000000..c73a8ac --- /dev/null +++ b/tests/test-relationships/observed/authorization.yaml @@ -0,0 +1,32 @@ +apiVersion: authorization.zitadel.m.crossplane.io/v1alpha1 +kind: Authorization +metadata: + name: reader-authorization + namespace: app + annotations: + crossplane.io/composition-resource-name: binding + crossplane.io/external-name: existing-authorization +spec: + providerConfigRef: + name: zitadel + kind: ProviderConfig + managementPolicies: + - '*' + forProvider: + userId: user-id + orgId: org-id + projectId: project-id + roleKeys: &id001 + - reader +status: + atProvider: + id: existing-authorization + userId: user-id + orgId: org-id + projectId: project-id + roleKeys: *id001 + conditions: + - type: Ready + status: 'True' + - type: Synced + status: 'True' diff --git a/upbound.yaml b/upbound.yaml index e74299d..924bcd0 100644 --- a/upbound.yaml +++ b/upbound.yaml @@ -7,7 +7,7 @@ spec: - apiVersion: pkg.crossplane.io/v1 kind: Function package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready - version: '^v0' + version: ^v0 - apiVersion: pkg.crossplane.io/v1 kind: Function package: xpkg.crossplane.io/crossplane-contrib/function-go-templating @@ -15,11 +15,11 @@ spec: - apiVersion: pkg.crossplane.io/v1 kind: Provider package: xpkg.crossplane.io/crossplane-contrib/provider-helm - version: '^v1' + version: ^v1 - apiVersion: pkg.crossplane.io/v1 kind: Provider package: xpkg.crossplane.io/crossplane-contrib/provider-kubernetes - version: '^v1' + version: ^v1 - apiVersion: pkg.crossplane.io/v1 kind: Provider package: xpkg.crossplane.io/crossplane-contrib/provider-upjet-zitadel @@ -27,14 +27,10 @@ spec: - apiVersion: pkg.crossplane.io/v1 kind: Provider package: xpkg.crossplane.io/crossplane-contrib/provider-aws-secretsmanager - version: '^v2' - 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 — HumanUser (organization-reference resolution), MachineUser - (with opt-in PAT + AWS SM push pipeline), Grant (polymorphic same-Org/cross-Org - user-to-project-role assignment), and OIDCClient (preview-safe Zitadel web - application lifecycle with namespaced credential delivery). + version: ^v2 + description: AuthStack installs Zitadel, database and routing integrations. Authorization + and Administrator resolve human/machine subjects into native v2 assignments. Legacy + HumanUser, MachineUser, Grant and OIDCClient APIs remain for adoption. license: Apache-2.0 maintainer: Patrick Lee Scott readme: | From 39b4bb9ad147e81fd42fbb56d9f83b82f6a39c76 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 13:23:03 -0500 Subject: [PATCH 2/3] fix: keep Usage consumers in the local namespace Signed-off-by: Patrick Lee Scott --- functions/relationship/40-binding.yaml.gotmpl | 1 - tests/security/test_relationships.py | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/functions/relationship/40-binding.yaml.gotmpl b/functions/relationship/40-binding.yaml.gotmpl index 958fdf1..578affe 100644 --- a/functions/relationship/40-binding.yaml.gotmpl +++ b/functions/relationship/40-binding.yaml.gotmpl @@ -47,7 +47,6 @@ spec: kind: {{ $state.kind }} resourceRef: name: {{ $state.binding.name | quote }} - namespace: {{ $state.namespace | quote }} {{- end }} {{- end }} {{- end }} diff --git a/tests/security/test_relationships.py b/tests/security/test_relationships.py index 04e2959..6076bbd 100644 --- a/tests/security/test_relationships.py +++ b/tests/security/test_relationships.py @@ -133,7 +133,9 @@ def test_cross_namespace_requires_opt_in_and_protects_exact_sources(self): self.assertEqual(len(usages), 3) for usage in usages: self.assertEqual(usage['spec']['of']['resourceRef']['namespace'], 'identity') - self.assertEqual(usage['spec']['by']['resourceRef']['namespace'], 'app') + self.assertEqual(usage['metadata']['namespace'], 'app') + # Usage.by is local to the Usage; its schema has no namespace field. + self.assertNotIn('namespace', usage['spec']['by']['resourceRef']) def test_unready_reference_with_id_does_not_remove_assignment(self): docs = self.render(self.xr(), self.sources(ready=False), [self.binding()]) @@ -166,7 +168,7 @@ def test_existing_usage_survives_reference_loss_and_stale_native_is_not_ready(se 'metadata': {'name': 'binding-delete-authorization-before-subject', 'namespace': 'app', 'annotations': {'crossplane.io/composition-resource-name': 'usage-subject'}}, 'spec': {'replayDeletion': True, 'of': {'apiVersion': USER_API, 'kind': 'HumanUser', 'resourceRef': {'name': 'person', 'namespace': 'app'}}, - 'by': {'apiVersion': self.binding()['apiVersion'], 'kind': 'Authorization', 'resourceRef': {'name': 'binding-authorization', 'namespace': 'app'}}}} + 'by': {'apiVersion': self.binding()['apiVersion'], 'kind': 'Authorization', 'resourceRef': {'name': 'binding-authorization'}}}} docs = self.render(self.xr(), self.sources()[1:], [self.binding(), usage]) self.assertEqual(next(d['spec'] for d in docs if d['kind'] == 'Usage'), usage['spec']) self.assert_not_ready(docs) From c9bb05b28b7e667112c8e96cc29146e99da6e4d8 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 15:28:57 -0500 Subject: [PATCH 3/3] test: add API-verified relationship end-to-end coverage --- Makefile | 9 +- README.md | 10 +- tests/live/.gitignore | 1 + tests/live/README.md | 41 +++++++ tests/live/live.py | 198 +++++++++++++++++++++++++++++++ tests/live/test_relationships.py | 129 ++++++++++++++++++++ 6 files changed, 382 insertions(+), 6 deletions(-) create mode 100644 tests/live/.gitignore create mode 100644 tests/live/README.md create mode 100644 tests/live/live.py create mode 100644 tests/live/test_relationships.py diff --git a/Makefile b/Makefile index 969fd47..b95d9dd 100644 --- a/Makefile +++ b/Makefile @@ -101,8 +101,13 @@ test: test-security: python3 -m unittest discover -s tests/security -v -e2e: - up test run $(E2E_TESTS) --e2e +e2e: e2e-relationships + @if [ -n "$(E2E_TESTS)" ]; then up test run $(E2E_TESTS) --e2e; fi + +.PHONY: e2e-relationships +e2e-relationships: + @test -n "$(ZITADEL_E2E_CONTEXT)" -a -n "$(ZITADEL_E2E_API_URL)" -a -n "$(ZITADEL_E2E_CREDENTIALS_SECRET)" || (echo "Set ZITADEL_E2E_CONTEXT, ZITADEL_E2E_API_URL and ZITADEL_E2E_CREDENTIALS_SECRET"; exit 1) + python3 tests/live/test_relationships.py publish: @if [ -z "$(tag)" ]; then echo "Error: tag is not set. Usage: make publish tag="; exit 1; fi diff --git a/README.md b/README.md index 73a8a4d..3887af8 100644 --- a/README.md +++ b/README.md @@ -300,9 +300,9 @@ set simultaneously. References and provider selection are immutable; identity replacement requires a separate XR/adoption step. The old HumanUser, MachineUser, Grant and OIDCClient XRDs remain packaged for -compatibility. Migrate the nine local Grant instances by importing their native +compatibility. Migrate existing Grant instances by importing their native user-grant IDs into Authorization, with environment-owned delegation declared -separately. Migrate the two active EKS OIDCClient registrations to native client +separately. Migrate OIDCClient registrations to native client resources using their current application IDs and Secret contracts. Do not remove these XRDs or the terminating preview objects as part of introducing the new APIs. PAT v2 and the Actions v2 groups-claim webhook migration are separate @@ -311,5 +311,7 @@ provider/runtime work. The relationship regression suite is included in `make test-security`. It checks both subject kinds, all administrator targets, cross-namespace references, missing/replaced references, current readiness, import and deletion locks. Local -render tests change no cluster resources. Live e2e/adoption verification remains -a release prerequisite. +render tests change no cluster resources. The opt-in [live relationship suite](tests/live/README.md) +(`make e2e-relationships`) exercises the running compositions/controllers and +checks Zitadel APIs after lifecycle/import/deletion operations. Live verification +against a containing provider/configuration release remains a release prerequisite. diff --git a/tests/live/.gitignore b/tests/live/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/tests/live/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/tests/live/README.md b/tests/live/README.md new file mode 100644 index 0000000..a0a3bda --- /dev/null +++ b/tests/live/README.md @@ -0,0 +1,41 @@ +# Live relationship end-to-end tests + +These tests exercise the full XR -> composition functions -> native provider -> +Zitadel chain on an existing test cluster. The fixture helper is self-contained; +no checkout of the provider's test repository is needed. + +Coverage: human/machine and delegated Authorizations, all four Administrator +targets, full role replacement preserving binding IDs, Observe-only import, +mismatched-subject import rejection, explicit cross-namespace Observe imports, +dependency Usage locks, and remote deletion. +Each case checks real Authorization/InternalPermission v2 API responses rather +than trusting Ready conditions alone. The shared project delegation is an +independent fixture, not composed separately for each relationship. + +Requirements: Python 3 (standard library), kubectl, Zitadel 4.x, and healthy source +or containing-release provider/AuthStack packages with the new APIs. The +cluster-scoped ProviderConfig and the API credentials must address the same +instance with permissions to manage all disposable fixture resources/scopes. + +```sh +export ZITADEL_E2E_CONTEXT= +export ZITADEL_E2E_API_URL=https://id.example.com +export ZITADEL_E2E_CREDENTIALS_SECRET=/ +# Optional: ZITADEL_E2E_PROVIDER_CONFIG (default default), ZITADEL_E2E_CA_FILE +make e2e-relationships # also included by make e2e +``` + +The Secret's `credentials` key contains provider JSON with `access_token` and +optional `transport_headers`; credentials stay in memory. The Make target fails +without the opt-in settings; direct unittest discovery explicitly skips instead. +HTTPS validates certificates. An explicit loopback HTTP port-forward is also +supported. Tests use unique namespaces/identities and delete bindings before +their dependencies. Cleanup failure fails the suite and retains its namespace. +The requested long-lived application examples are not used or modified. + +This suite does not install AuthStack's Helm/database/routing infrastructure or +run automatically in the reusable Upbound `tests/e2e*` CI job. That job has no +KCL E2ETest definitions for these relationships. Existing render/security tests +and successful empty e2e jobs do not establish live coverage. Run this target +against an explicitly prepared source/containing-release test cluster before +release, and publish its result separately from render tests. diff --git a/tests/live/live.py b/tests/live/live.py new file mode 100644 index 0000000..90fd7b2 --- /dev/null +++ b/tests/live/live.py @@ -0,0 +1,198 @@ +# SPDX-FileCopyrightText: 2026 The Crossplane Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Opt-in live-cluster helpers. No credentials or existing application IDs are stored.""" +import base64 +import json +import os +import ssl +import subprocess +import time +import unittest +import urllib.request +import uuid + + +class LiveSuite(unittest.TestCase): + @classmethod + def setUpClass(cls): + required = ('ZITADEL_E2E_CONTEXT', 'ZITADEL_E2E_API_URL', 'ZITADEL_E2E_CREDENTIALS_SECRET') + missing = [key for key in required if not os.environ.get(key)] + if missing: + raise unittest.SkipTest('live cluster opt-in required: ' + ', '.join(missing)) + cls.context = os.environ['ZITADEL_E2E_CONTEXT'] + cls.url = os.environ['ZITADEL_E2E_API_URL'].rstrip('/') + cls.namespace = 'zitadel-bindings-e2e-' + uuid.uuid4().hex[:10] + cls.owned = [] + cls.namespaces = [cls.namespace] + cls.provider = {'name': os.environ.get('ZITADEL_E2E_PROVIDER_CONFIG', 'default'), 'kind': 'ClusterProviderConfig'} + secret_namespace, secret_name = os.environ['ZITADEL_E2E_CREDENTIALS_SECRET'].split('/', 1) + secret = cls.kube('get', 'secret', secret_name, '-n', secret_namespace, '-o', 'json') + credentials = json.loads(base64.b64decode(secret['data']['credentials'])) + cls.headers = dict(credentials.get('transport_headers', {})) + cls.headers.update({'Authorization': 'Bearer ' + credentials['access_token'], + 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1'}) + cls.tls = ssl.create_default_context(cafile=os.environ.get('ZITADEL_E2E_CA_FILE')) + cls.kube('create', 'namespace', cls.namespace) + print('Disposable fixture namespace: ' + cls.namespace, flush=True) + cls.addClassCleanup(cls.cleanup) + cls.owner = cls.managed('Organization', 'owner', 'org', {'name': cls.namespace + '-owner'}) + cls.grantee = cls.managed('Organization', 'grantee', 'org', {'name': cls.namespace + '-grantee'}) + cls.human = cls.managed('HumanUser', 'human', 'user', { + 'orgIdRef': {'name': 'owner'}, 'userName': cls.namespace + '-human', 'firstName': 'E2E', + 'lastName': 'User', 'email': cls.namespace + '@example.com'}) + cls.machine = cls.managed('MachineUser', 'machine', 'user', { + 'orgIdRef': {'name': 'owner'}, 'userName': cls.namespace + '-machine', 'name': 'E2E machine'}) + cls.project = cls.managed('ProjectV2', 'product', 'project', { + 'orgIdRef': {'name': 'owner'}, 'name': 'E2E product'}) + for role in ('reader', 'writer'): + cls.managed('Role', role, 'project', {'orgId': cls.remote_id(cls.owner), + 'projectId': cls.remote_id(cls.project), 'roleKey': role, 'displayName': role}) + cls.delegation = cls.managed('Grant', 'shared', 'project', { + 'orgId': cls.remote_id(cls.owner), 'projectId': cls.remote_id(cls.project), + 'grantedOrgId': cls.remote_id(cls.grantee), 'roleKeys': ['reader', 'writer']}) + + @classmethod + def kube(cls, *args, body=None): + p = subprocess.run(['kubectl', '--context', cls.context, '--request-timeout=30s', *args], + input=json.dumps(body) if body is not None else None, text=True, capture_output=True, timeout=40) + if p.returncode: + raise RuntimeError('kubectl failed: ' + p.stderr) + return json.loads(p.stdout) if p.stdout.strip().startswith(('{', '[')) else p.stdout + + @classmethod + def api(cls, service, method, body): + req = urllib.request.Request(cls.url + '/zitadel.' + service + '/' + method, + data=json.dumps(body).encode(), headers=cls.headers, method='POST') + with urllib.request.urlopen(req, context=cls.tls, timeout=30) as response: + return json.load(response) + + @staticmethod + def remote_id(resource): + return resource['status']['atProvider']['id'] + + @classmethod + def wait(cls, check, description, timeout=180): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = check() + if result: + return result + time.sleep(1) + raise AssertionError('Timed out: ' + description) + + @classmethod + def get(cls, resource, name, namespace=None): + result = cls.kube('get', resource, name, '-n', namespace or cls.namespace, + '--ignore-not-found', '-o', 'json') + return result if isinstance(result, dict) else None + + @classmethod + def ready(cls, resource, name, namespace=None): + def observed(): + x = cls.get(resource, name, namespace) + if not x: + return False + conditions = {c['type']: c for c in x.get('status', {}).get('conditions', [])} + synced = conditions.get('Synced', {}) + return x if conditions.get('Ready', {}).get('status') == 'True' and \ + synced.get('status') == 'True' and synced.get('observedGeneration') == x['metadata']['generation'] else False + return cls.wait(observed, resource + '/' + name + ' Ready/Synced') + + @classmethod + def apply(cls, obj): + resource = obj['kind'] + '.' + obj['apiVersion'].split('/')[0] + ns, name = obj['metadata']['namespace'], obj['metadata']['name'] + key = (resource, name, ns) + if key not in cls.owned: + cls.owned.append(key) + cls.kube('apply', '-f', '-', body=obj) + return resource, name + + @classmethod + def managed(cls, kind, name, group, parameters, policies=None, external_name=None): + obj = {'apiVersion': group + '.zitadel.m.crossplane.io/v1alpha1', 'kind': kind, + 'metadata': {'name': name, 'namespace': cls.namespace}, 'spec': { + 'managementPolicies': policies or ['*'], 'forProvider': parameters, + 'providerConfigRef': cls.provider}} + if external_name: + obj['metadata']['annotations'] = {'crossplane.io/external-name': external_name} + resource, name = cls.apply(obj) + result = cls.ready(resource, name) + print(kind + '/' + name + ' Ready/Synced', flush=True) + return result + + @classmethod + def delete(cls, resource, name, namespace=None): + ns = namespace or cls.namespace + before = cls.get(resource, name, ns) or {} + children = before.get('spec', {}).get('crossplane', {}).get('resourceRefs', []) + cls.kube('delete', resource, name, '-n', ns, '--ignore-not-found', '--wait=false') + cls.wait(lambda: not cls.get(resource, name, ns), 'deletion of ' + resource + '/' + name) + # XR removal can precede garbage collection of its native binding and + # Usages. Wait for both before attempting to delete protected parents. + for child in children: + child_type = child['kind'] + '.' + child['apiVersion'].split('/')[0] + cls.wait(lambda child=child, child_type=child_type: + not cls.get(child_type, child['name'], ns), 'composed-resource cleanup') + key = (resource, name, ns) + if key in cls.owned: + cls.owned.remove(key) + + @classmethod + def cleanup(cls): + errors = [] + # Reverse dependency order: bindings before delegation, roles, project, + # users and organizations. Do not remove the namespace to hide failures. + for resource, name, ns in list(reversed(cls.owned)): + try: + cls.delete(resource, name, ns) + except Exception as error: + errors.append(str(error)) + break + if errors: + raise AssertionError('Cleanup failed; retained namespace ' + cls.namespace + ': ' + '; '.join(errors)) + for ns in reversed(cls.namespaces): + cls.kube('delete', 'namespace', ns, '--wait=false') + cls.wait(lambda ns=ns: not cls.kube('get', 'namespace', ns, '--ignore-not-found', '-o', 'json'), 'namespace cleanup') + + @classmethod + def authorizations(cls, identifier): + return cls.api('authorization.v2.AuthorizationService', 'ListAuthorizations', { + 'pagination': {'limit': 2}, 'filters': [{'authorizationIds': {'ids': [identifier]}}]}).get('authorizations', []) + + def assert_authorization(self, identifier, parameters, roles): + rows = self.authorizations(identifier) + self.assertEqual(len(rows), 1) + r = rows[0] + self.assertEqual((r['id'], r['user']['id'], r['project']['id'], r['organization']['id']), + (identifier, parameters['userId'], parameters['projectId'], parameters['orgId'])) + self.assertEqual({r['key'] for r in r['roles']}, set(roles)) + + @classmethod + def administrators(cls, parameters): + scope = parameters['resourceType'] + target = {'instance': True} if scope == 'instance' else \ + {'organizationId': parameters['orgId']} if scope == 'organization' else \ + {'projectId': parameters['projectId']} if scope == 'project' else \ + {'projectGrant': {'projectId': parameters['projectId'], 'organizationId': parameters['orgId']}} + return cls.api('internal_permission.v2.InternalPermissionService', 'ListAdministrators', { + 'pagination': {'limit': 2}, 'filters': [ + {'inUserIdsFilter': {'ids': [parameters['userId']]}}, {'resource': target}]}).get('administrators', []) + + def assert_administrator(self, parameters, roles): + rows = self.administrators(parameters) + self.assertEqual(len(rows), 1) + r = rows[0] + self.assertEqual(r['user']['id'], parameters['userId']) + self.assertEqual(set(r['roles']), set(roles)) + scope = parameters['resourceType'] + if scope == 'instance': + self.assertTrue(r['instance']) + elif scope == 'organization': + self.assertEqual(r['organization']['id'], parameters['orgId']) + elif scope == 'project': + self.assertEqual(r['project']['id'], parameters['projectId']) + else: + self.assertEqual(r['projectGrant']['projectId'], parameters['projectId']) + self.assertEqual(r['projectGrant']['grantedOrganizationId'], parameters['orgId']) diff --git a/tests/live/test_relationships.py b/tests/live/test_relationships.py new file mode 100644 index 0000000..b3f7ea3 --- /dev/null +++ b/tests/live/test_relationships.py @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: 2026 The Crossplane Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Live XR -> native provider -> Zitadel checks, with isolated disposable identities.""" +import unittest +from live import LiveSuite + + +class Relationships(LiveSuite): + def relationship(self, kind, name, spec, namespace=None, wait=True): + ns = namespace or self.namespace + obj = {'apiVersion': 'auth.hops.ops.com.ai/v1alpha1', 'kind': kind, + 'metadata': {'name': name, 'namespace': ns}, + 'spec': dict(spec, providerConfigRef=self.provider)} + resource, _ = self.apply(obj) + return self.ready(resource, name, ns) if wait else self.get(resource, name, ns) + + def native(self, xr): + kind = xr['kind'] + group = 'authorization' if kind == 'Authorization' else 'internalpermission' + name = xr['metadata']['name'] + '-' + kind.lower() + return self.ready(kind + '.' + group + '.zitadel.m.crossplane.io', name, xr['metadata']['namespace']) + + def assert_locks(self, xr, count): + refs = xr['spec']['crossplane']['resourceRefs'] + locks = [r for r in refs if r['kind'] == 'Usage'] + self.assertEqual(len(locks), count) + for r in locks: + lock = self.get('usages.protection.crossplane.io', r['name'], xr['metadata']['namespace']) + self.assertEqual(lock['spec']['by']['resourceRef']['name'], xr['metadata']['name'] + '-' + xr['kind'].lower()) + self.assertNotIn('namespace', lock['spec']['by']['resourceRef']) + + def test_authorizations(self): + resource = 'authorizations.auth.hops.ops.com.ai' + for name, kind, organization in [('human', 'HumanUser', 'owner'), + ('machine', 'MachineUser', 'owner'), + ('delegated', 'MachineUser', 'grantee')]: + with self.subTest(subject=name): + spec = {'subjectRef': {'name': 'human' if kind == 'HumanUser' else 'machine', 'kind': kind}, + 'projectRef': {'name': 'product'}, 'organizationRef': {'name': organization}, 'roles': ['reader']} + if organization == 'grantee': + spec['projectGrantRef'] = {'name': 'shared'} + x = self.relationship('Authorization', name, spec) + self.assertTrue(x['status']['bindingIdentityVerified']) + n = self.native(x) + fp = n['spec']['forProvider'] + identifier = x['status']['bindingId'] + self.assert_authorization(identifier, fp, ['reader']) + self.assert_locks(x, 4 if organization == 'grantee' else 3) + spec['roles'] = ['writer'] + x = self.relationship('Authorization', name, spec) + self.assertEqual(x['status']['bindingId'], identifier) + self.assert_authorization(identifier, fp, ['writer']) + observer = dict(spec, managementPolicies=['Observe'], binding={'externalName': identifier}) + observed = self.relationship('Authorization', name + '-import', observer) + self.assertTrue(observed['status']['bindingIdentityVerified']) + self.assertEqual(observed['status']['bindingId'], identifier) + self.delete(resource, name + '-import') + self.assert_authorization(identifier, fp, ['writer']) + if name == 'machine': + # Import an existing assignment using the wrong user. It must + # remain unready, Observe-only and unable to delete it. + bad = dict(observer, subjectRef={'name': 'human', 'kind': 'HumanUser'}) + self.relationship('Authorization', 'mismatch', bad, wait=False) + bad_xr = self.wait(lambda: self.mismatch_ready(), 'mismatched import rejection') + bad_native = self.get('authorizations.authorization.zitadel.m.crossplane.io', 'mismatch-authorization') + self.assertEqual(bad_native['spec']['managementPolicies'], ['Observe']) + self.assertFalse(bad_xr['status']['ready']) + self.delete(resource, 'mismatch') + self.assert_authorization(identifier, fp, ['writer']) + # Explicit cross-namespace opt-in must resolve identities + # and create Usages protecting resources in their source ns. + other = self.namespace + '-observer' + self.kube('create', 'namespace', other) + self.namespaces.append(other) + cross = dict(observer, allowCrossNamespaceReferences=True, + subjectRef=dict(spec['subjectRef'], namespace=self.namespace), + projectRef=dict(spec['projectRef'], namespace=self.namespace), + organizationRef=dict(spec['organizationRef'], namespace=self.namespace)) + imported = self.relationship('Authorization', 'cross-namespace', cross, namespace=other) + self.assertTrue(imported['status']['bindingIdentityVerified']) + self.assertEqual(imported['status']['bindingId'], identifier) + self.assert_locks(imported, 3) + for ref in imported['spec']['crossplane']['resourceRefs']: + if ref['kind'] == 'Usage': + lock = self.get('usages.protection.crossplane.io', ref['name'], other) + self.assertEqual(lock['spec']['of']['resourceRef']['namespace'], self.namespace) + self.delete(resource, 'cross-namespace', other) + self.assert_authorization(identifier, fp, ['writer']) + self.delete(resource, name) + self.wait(lambda: not self.authorizations(identifier), 'remote authorization deletion') + + def mismatch_ready(self): + x = self.get('authorizations.auth.hops.ops.com.ai', 'mismatch') + if x and x.get('status', {}).get('bindingId') and x['status'].get('bindingIdentityVerified') is False: + return x + return False + + def test_administrators(self): + resource = 'administrators.auth.hops.ops.com.ai' + cases = [ + ('instance', {'instance': True}, 'IAM_OWNER_VIEWER', 'IAM_USER_MANAGER', 1), + ('organization', {'organizationRef': {'name': 'owner'}}, 'ORG_USER_MANAGER', 'ORG_OWNER_VIEWER', 2), + ('project', {'projectRef': {'name': 'product'}}, 'PROJECT_OWNER_VIEWER', 'PROJECT_OWNER', 2), + ('project-grant', {'projectGrantRef': {'name': 'shared'}}, 'PROJECT_GRANT_OWNER_VIEWER', 'PROJECT_GRANT_OWNER', 2)] + for name, target, first, second, locks in cases: + with self.subTest(scope=name): + spec = {'subjectRef': {'name': 'machine', 'kind': 'MachineUser'}, 'target': target, 'roles': [first]} + x = self.relationship('Administrator', name, spec) + identifier = x['status']['bindingId'] + fp = self.native(x)['spec']['forProvider'] + self.assert_administrator(fp, [first]) + self.assert_locks(x, locks) + spec['roles'] = [second] + x = self.relationship('Administrator', name, spec) + self.assertEqual(x['status']['bindingId'], identifier) + self.assert_administrator(fp, [second]) + observer = dict(spec, managementPolicies=['Observe'], binding={'externalName': identifier}) + imported = self.relationship('Administrator', name + '-import', observer) + self.assertTrue(imported['status']['bindingIdentityVerified']) + self.assertEqual(imported['status']['bindingId'], identifier) + self.delete(resource, name + '-import') + self.assert_administrator(fp, [second]) + self.delete(resource, name) + self.wait(lambda: not self.administrators(fp), 'remote administrator deletion') + + +if __name__ == '__main__': + unittest.main(verbosity=2)