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..b95d9dd 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:: \ @@ -97,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 b797cab..3887af8 100644 --- a/README.md +++ b/README.md @@ -242,3 +242,76 @@ 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 existing Grant instances by importing their native +user-grant IDs into Authorization, with environment-owned delegation declared +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 +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. 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/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..578affe --- /dev/null +++ b/functions/relationship/40-binding.yaml.gotmpl @@ -0,0 +1,52 @@ +# 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 }} + {{- 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/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) diff --git a/tests/security/test_relationships.py b/tests/security/test_relationships.py new file mode 100644 index 0000000..6076bbd --- /dev/null +++ b/tests/security/test_relationships.py @@ -0,0 +1,225 @@ +"""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['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()]) + 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'}}}} + 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: |