diff --git a/Makefile b/Makefile index 0a3a9ee..66a61bd 100644 --- a/Makefile +++ b/Makefile @@ -129,6 +129,11 @@ validate\:%: test: up test run $(RENDER_TESTS) +# Includes rejection cases that CompositionTest cannot express. +.PHONY: test-security +test-security: + python3 -m unittest discover -s tests/security -v + e2e: up test run $(E2E_TESTS) --e2e diff --git a/README.md b/README.md index 970e2f1..e313a6c 100644 --- a/README.md +++ b/README.md @@ -168,3 +168,57 @@ See [[specs/auth-stack-zitadel]] for the design and open questions. - Spec: `[[specs/auth-stack-zitadel]]` - Task: `[[tasks/auth-stack]]` - Upstream chart: `zitadel/zitadel` 9.34.1 (ships Zitadel v4) + +## System API and instance metadata + +AuthStack accepts optional `systemAPIUsers`: each entry has `id`, +`publicKeySecretRef: {name, key}`, and optional `memberships` entries +(`memberType`, `roles`, optional `aggregateId`). Keys must exist in the target +install namespace. Only public key files are mounted; generate and persist +private keys outside AuthStack and deliver them to provider consumers via ESO. +No user or hostname is enabled by default. Omitting memberships grants Zitadel's +SYSTEM_OWNER default; specify memberships explicitly when narrower access fits. + +Set `instanceDiscovery.enabled: true` with an explicit `internalURL` to publish +`status.instanceId`. A retry-bounded Job reads the chart-generated IAM PAT, queries +instance metadata, and patches only its named ConfigMap. It never mutates +Zitadel. `firstInstance.enabled` must remain enabled for this discovery mode. +The ConfigMap is observed without Update management so reconcile cannot erase +the probe's result. Recreating an instance requires recreating the discovery +Job/ConfigMap alongside it. The default discovery image is Python 3.13.7 Alpine; +set `instanceDiscovery.image` to an approved mirror/digest when required. +Discovery configuration (including its image and chart overrides) is an +administrator-level interface and must not be delegated to untrusted tenants. + +`internalURL` must match the chart's actual Zitadel Service and configured port, +using `..svc` or `..svc.cluster.local`, +without credentials, path, query or fragment. For example: +`https://identity-zitadel.identity.svc:8080`. The origin is validated before +rendering the credential-consuming Job. Redirects and environment proxies are +disabled. HTTPS verifies the certificate chain and Service hostname; configure +server TLS through chart values and optionally set +`caCertSecretRef: {name: zitadel-ca, key: ca.crt}` for a private CA. + +Only a trusted local cluster with plaintext Service traffic should set +`allowInsecureHTTP: true`. This explicit exception sends the PAT in cleartext +inside the cluster; browser/gateway HTTPS does not encrypt that hop. It is false +by default and is not a cloud default. Enabling HTTPS requires the Service itself +to support TLS, not merely TLS termination at the ingress. + +The pod can wait for the chart-generated PAT without a Job wall-clock deadline. +Once started it makes at most 110 attempts (two 10-second request timeouts and a +5-second sleep per attempt), with Kubernetes backoffLimit 3. A terminal API +failure still requires retrying the Job after fixing the cause; waiting for a +missing first-install Secret no longer consumes that retry budget. PAT rotation +alone does not recreate the Job. Endpoint, image, domain, admin username, or CA +reference changes do. + +These additions let consumers use provider CustomDomain/TrustedDomain resources +without carrying instance IDs in git. Project domains and browser ingress remain +consumer/platform responsibilities. `spec.domain` is always supplied by the +installation: cloud examples use real domains; only local CLI templates use +localhost. Existing installations have both features disabled unless requested. + +Discovery security regressions: run `make test-security` with the same Docker/ +`up` setup as render tests. It checks rejected origins and the actual Job +transport code (TLS identity verification, no redirects, no environment proxies). diff --git a/apis/authstacks/definition.yaml b/apis/authstacks/definition.yaml index 423c168..53c0719 100644 --- a/apis/authstacks/definition.yaml +++ b/apis/authstacks/definition.yaml @@ -36,6 +36,66 @@ spec: description: AuthStackSpec defines the desired state. type: object properties: + systemAPIUsers: + description: Optional System API users. Public keys must exist in Secrets in the Zitadel install namespace; private keys belong to API consumers. + type: array + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: [id] + items: + type: object + required: [id, publicKeySecretRef] + properties: + id: + type: string + pattern: '^[a-z0-9][a-z0-9-]*$' + maxLength: 63 + publicKeySecretRef: + type: object + required: [name, key] + properties: + name: {type: string, minLength: 1} + key: {type: string, minLength: 1} + memberships: + description: Explicit System API memberships; defaults to SYSTEM_OWNER when omitted (Zitadel behavior). + type: array + items: + type: object + required: [memberType, roles] + properties: + memberType: {type: string, enum: [System, IAM, Organization]} + aggregateId: {type: string} + roles: + type: array + minItems: 1 + items: {type: string} + instanceDiscovery: + description: Opt-in read-only discovery of the bootstrapped instance ID. Uses the chart-generated IAM admin PAT and publishes only non-secret metadata. + type: object + properties: + enabled: {type: boolean, default: false} + internalURL: + description: Origin of this stack's Zitadel Service (HTTPS by default), with explicit service port and no path. Host must match the rendered chart Service in the install namespace. + type: string + pattern: '^https?://[a-z0-9-]+[.][a-z0-9-]+[.]svc([.]cluster[.]local)?:[0-9]+$' + allowInsecureHTTP: + description: Explicit opt-in for trusted local clusters with plaintext Service traffic. Never enable on an untrusted network; the IAM PAT is transmitted in cleartext. + type: boolean + default: false + caCertSecretRef: + description: Optional CA bundle for verifying the Zitadel Service certificate. The certificate must cover the Service hostname. Secret must be in the install namespace. + type: object + required: [name, key] + properties: + name: {type: string, minLength: 1} + key: {type: string, minLength: 1} + image: + type: string + default: python:3.13.7-alpine3.22 + x-kubernetes-validations: + - rule: '!self.enabled || has(self.internalURL)' + message: instanceDiscovery.internalURL is required when enabled + - rule: '!has(self.internalURL) || self.internalURL.startsWith("https://") || (has(self.allowInsecureHTTP) && self.allowInsecureHTTP)' + message: instanceDiscovery requires HTTPS unless allowInsecureHTTP is explicitly enabled clusterName: description: Name of the target cluster. Used as default for provider config refs. type: string @@ -478,6 +538,9 @@ spec: description: AuthStackStatus surfaces the OIDC contract and chart-managed bootstrap secret refs. type: object properties: + instanceId: + description: Instance ID observed through the Zitadel API, populated when instanceDiscovery is enabled. + type: string ready: description: Overall readiness of the auth stack. type: boolean diff --git a/functions/render/000-state-init.yaml.gotmpl b/functions/render/000-state-init.yaml.gotmpl index 3645135..fd5497f 100644 --- a/functions/render/000-state-init.yaml.gotmpl +++ b/functions/render/000-state-init.yaml.gotmpl @@ -219,6 +219,8 @@ "labels" $labels "helmProviderConfigRef" $helmProviderConfigRef "kubernetesProviderConfigRef" $k8sProviderConfigRef + "systemAPIUsers" ($spec.systemAPIUsers | default list) + "instanceDiscovery" ($spec.instanceDiscovery | default dict) "domain" $domain "externalSecure" $externalSecure "chartVersion" $chartVersion diff --git a/functions/render/010-state-status.yaml.gotmpl b/functions/render/010-state-status.yaml.gotmpl index 97155c0..a81bba7 100644 --- a/functions/render/010-state-status.yaml.gotmpl +++ b/functions/render/010-state-status.yaml.gotmpl @@ -148,3 +148,12 @@ "ready" $smtp.render ) ) }} + +# The ConfigMap is written by a read-only metadata probe, not a desired input. +{{- $discoveryEntry := get $observed "instance-metadata" | default dict }} +{{- $instanceId := dig "resource" "status" "atProvider" "manifest" "data" "instanceId" "" $discoveryEntry }} +{{- $_ := set $state.status "instanceId" $instanceId }} + +{{- if $state.instanceDiscovery.enabled }} +{{- $_ := set $state.status "ready" (and $relReady (ne $instanceId "")) }} +{{- end }} diff --git a/functions/render/200-helm-release-zitadel.yaml.gotmpl b/functions/render/200-helm-release-zitadel.yaml.gotmpl index a255022..c92c93d 100644 --- a/functions/render/200-helm-release-zitadel.yaml.gotmpl +++ b/functions/render/200-helm-release-zitadel.yaml.gotmpl @@ -190,6 +190,34 @@ "env" $dbEnv }} +# System API users are explicit, environment-neutral inputs. Mount only public +# keys into Zitadel; provider credentials/private keys never enter Helm values. +{{- $systemUsers := dict }} +{{- $systemVolumes := list }} +{{- $systemMounts := list }} +{{- range $user := $state.systemAPIUsers }} + {{- $volumeName := printf "system-api-%s" ($user.id | sha256sum | trunc 16) }} + {{- $mountPath := printf "/system-api/%s" $user.id }} + {{- $config := dict "Path" (printf "%s/public.pem" $mountPath) }} + {{- $memberships := list }} + {{- range ($user.memberships | default list) }} + {{- $membership := dict "MemberType" .memberType "Roles" .roles }} + {{- if .aggregateId }}{{- $_ := set $membership "AggregateID" .aggregateId }}{{- end }} + {{- $memberships = append $memberships $membership }} + {{- end }} + {{- if $memberships }}{{- $_ := set $config "Memberships" $memberships }}{{- end }} + {{- $_ := set $systemUsers $user.id $config }} + {{- $systemVolumes = append $systemVolumes (dict "name" $volumeName "secret" (dict + "secretName" $user.publicKeySecretRef.name + "items" (list (dict "key" $user.publicKeySecretRef.key "path" "public.pem")) )) }} + {{- $systemMounts = append $systemMounts (dict "name" $volumeName "mountPath" $mountPath "readOnly" true) }} +{{- end }} +{{- if $systemUsers }} + {{- $_ := set $cmCfg "SystemAPIUsers" $systemUsers }} + {{- $_ := set $chartDefaults "extraVolumes" $systemVolumes }} + {{- $_ := set $chartDefaults "extraVolumeMounts" $systemMounts }} +{{- end }} + # Gateway API wiring (HTTPRoute + GRPCRoute drive off chart-native fields) {{- if $gw.enabled }} {{- $parentRef := dict "name" $gw.parentRef.name }} diff --git a/functions/render/210-instance-discovery.yaml.gotmpl b/functions/render/210-instance-discovery.yaml.gotmpl new file mode 100644 index 0000000..5a95ca1 --- /dev/null +++ b/functions/render/210-instance-discovery.yaml.gotmpl @@ -0,0 +1,165 @@ +# Read-only API discovery. The Job can patch only its metadata ConfigMap. +# No domain/user/project API writes and no credential values in status. +{{- if $state.instanceDiscovery.enabled }} +{{- if not $state.firstInstance.enabled }}{{- fail "instanceDiscovery requires firstInstance.enabled" }}{{- end }} +{{- $discoveryName := printf "%s-instance" $state.name }} +{{- $ns := $state.namespace }} +# Resolve the chart's Service name using its fullname helper rules. An origin +# supplied by a consumer must not redirect the admin PAT to another service. +{{- $discoveryValues := $state.chartValues }} +{{- if $state.overrideAllValues }}{{- $discoveryValues = $state.overrideAllValues }}{{- end }} +{{- $releaseName := printf "%s-zitadel" $state.name }} +{{- $chartName := $discoveryValues.nameOverride | default "zitadel" }} +{{- $serviceName := $releaseName }} +{{- if not (contains $chartName $releaseName) }}{{- $serviceName = printf "%s-%s" $releaseName $chartName }}{{- end }} +{{- $serviceName = ($discoveryValues.fullnameOverride | default $serviceName) | trunc 63 | trimSuffix "-" }} +{{- $servicePort := dig "service" "port" 8080 $discoveryValues | int }} +{{- $authority := printf "%s.%s.svc:%d" $serviceName $ns $servicePort }} +{{- $fqdnAuthority := printf "%s.%s.svc.cluster.local:%d" $serviceName $ns $servicePort }} +{{- $url := $state.instanceDiscovery.internalURL | default "" }} +{{- $allowed := list (printf "https://%s" $authority) (printf "https://%s" $fqdnAuthority) }} +{{- if $state.instanceDiscovery.allowInsecureHTTP }} + {{- $allowed = concat $allowed (list (printf "http://%s" $authority) (printf "http://%s" $fqdnAuthority)) }} +{{- end }} +{{- if not (has $url $allowed) }}{{- fail "instanceDiscovery.internalURL must target this stack's Zitadel Service and port over HTTPS (HTTP requires explicit allowInsecureHTTP)" }}{{- end }} +{{- $ca := $state.instanceDiscovery.caCertSecretRef | default dict }} +{{- $jobRevision := dict "domain" $state.domain "url" $state.instanceDiscovery.internalURL "image" ($state.instanceDiscovery.image | default "python:3.13.7-alpine3.22") "admin" $state.firstInstance.iamAdmin.username "ca" $ca "transportVersion" 2 | toJson | sha256sum | trunc 8 }} +{{- $sa := dict "apiVersion" "v1" "kind" "ServiceAccount" "metadata" (dict "name" $discoveryName "namespace" $ns) }} +{{- $cm := dict "apiVersion" "v1" "kind" "ConfigMap" "metadata" (dict "name" $discoveryName "namespace" $ns) }} +{{- $role := dict "apiVersion" "rbac.authorization.k8s.io/v1" "kind" "Role" "metadata" (dict "name" $discoveryName "namespace" $ns) + "rules" (list (dict "apiGroups" (list "") "resources" (list "configmaps") "resourceNames" (list $discoveryName) "verbs" (list "get" "patch"))) }} +{{- $binding := dict "apiVersion" "rbac.authorization.k8s.io/v1" "kind" "RoleBinding" "metadata" (dict "name" $discoveryName "namespace" $ns) + "roleRef" (dict "apiGroup" "rbac.authorization.k8s.io" "kind" "Role" "name" $discoveryName) + "subjects" (list (dict "kind" "ServiceAccount" "name" $discoveryName "namespace" $ns)) }} +{{- range $key, $manifest := dict "instance-serviceaccount" $sa "instance-metadata" $cm "instance-role" $role "instance-rolebinding" $binding }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-{{ $key }} + annotations: + {{ setResourceNameAnnotation $key }} + labels: {{ $state.labels | toJson }} +spec: + {{- if eq $key "instance-metadata" }} + # The probe owns data. Crossplane creates and observes but never overwrites it. + {{- if has "*" $state.managementPolicies }} + managementPolicies: [Create, Observe, Delete] + {{- else }} + managementPolicies: {{ without $state.managementPolicies "Update" "LateInitialize" | toJson }} + {{- end }} + readiness: + policy: DeriveFromCelQuery + celQuery: 'has(object.data) && "instanceId" in object.data && object.data.instanceId != ""' + {{- else }} + managementPolicies: {{ $state.managementPolicies | toJson }} + {{- end }} + forProvider: + manifest: {{ $manifest | toJson }} + providerConfigRef: {{ $state.kubernetesProviderConfigRef | toJson }} +{{- end }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-instance-discovery + annotations: + {{ setResourceNameAnnotation "instance-discovery" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: batch/v1 + kind: Job + metadata: + # Endpoint, image, domain, admin username or CA reference changes create a new Job. + # PAT rotation alone does not change the Job name. + name: {{ printf "%s-discover-%s" $state.name $jobRevision }} + namespace: {{ $ns }} + spec: + # Missing PAT Secrets can hold the pod pending throughout first install. + # Bound execution with the script retry budget, not a wall-clock deadline + # that expires before the chart has even created the credential. + backoffLimit: 3 + template: + spec: + serviceAccountName: {{ $discoveryName }} + restartPolicy: OnFailure + securityContext: + runAsNonRoot: true + runAsUser: 65532 + fsGroup: 65532 + seccompProfile: {type: RuntimeDefault} + containers: + - name: discover + image: {{ $state.instanceDiscovery.image | default "python:3.13.7-alpine3.22" }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: [ALL]} + resources: + requests: {cpu: 10m, memory: 24Mi} + limits: {cpu: 100m, memory: 64Mi} + env: + - {name: ZITADEL_URL, value: {{ $state.instanceDiscovery.internalURL | quote }}} + - {name: ZITADEL_CA_FILE, value: {{ ternary "/zitadel-ca/ca.crt" "" (not (empty $ca)) | quote }}} + - {name: ZITADEL_HOST, value: {{ $state.domain | quote }}} + - {name: METADATA_NAME, value: {{ $discoveryName | quote }}} + command: [python3, -c] + args: + - | + import json, os, pathlib, ssl, time, urllib.request + base = pathlib.Path('/var/run/secrets/kubernetes.io/serviceaccount') + namespace = (base / 'namespace').read_text().strip() + tls = ssl.create_default_context(cafile=str(base / 'ca.crt')) + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise ValueError('discovery redirects are forbidden') + # Verify certificates and Service DNS names; never inherit proxies. + zitadel_tls = ssl.create_default_context(cafile=os.environ.get('ZITADEL_CA_FILE') or None) + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), NoRedirect(), + urllib.request.HTTPSHandler(context=zitadel_tls)) + api_opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), NoRedirect(), + urllib.request.HTTPSHandler(context=tls)) + for attempt in range(110): + try: + pat = pathlib.Path('/zitadel-pat/pat').read_text().strip() + request = urllib.request.Request(os.environ['ZITADEL_URL'].rstrip('/') + '/admin/v1/instances/me', headers={ + 'Authorization': 'Bearer ' + pat, 'Host': os.environ['ZITADEL_HOST']}) + with opener.open(request, timeout=10) as response: + instance_id = json.load(response)['instance']['id'] + if not instance_id: + raise ValueError('empty instance id') + url = 'https://kubernetes.default.svc/api/v1/namespaces/' + namespace + '/configmaps/' + os.environ['METADATA_NAME'] + request = urllib.request.Request(url, method='PATCH', data=json.dumps({'data': {'instanceId': instance_id}}).encode(), headers={ + 'Authorization': 'Bearer ' + (base / 'token').read_text().strip(), 'Content-Type': 'application/merge-patch+json'}) + with api_opener.open(request, timeout=10) as response: + response.read() + print('Instance metadata published') + break + except Exception as error: + # Never print requests, bodies, or bearer credentials. + print('Waiting for instance metadata: ' + type(error).__name__, flush=True) + time.sleep(5) + else: + raise SystemExit('Instance discovery did not converge') + volumeMounts: + - {name: admin-pat, mountPath: /zitadel-pat, readOnly: true} + {{- if $ca }} + - {name: zitadel-ca, mountPath: /zitadel-ca, readOnly: true} + {{- end }} + volumes: + - name: admin-pat + secret: + secretName: {{ printf "%s-pat" $state.firstInstance.iamAdmin.username }} + {{- if $ca }} + - name: zitadel-ca + secret: + secretName: {{ $ca.name }} + items: [{key: {{ $ca.key | quote }}, path: ca.crt}] + {{- end }} + providerConfigRef: {{ $state.kubernetesProviderConfigRef | toJson }} +{{- end }} diff --git a/functions/render/999-status.yaml.gotmpl b/functions/render/999-status.yaml.gotmpl index 5685800..04352a6 100644 --- a/functions/render/999-status.yaml.gotmpl +++ b/functions/render/999-status.yaml.gotmpl @@ -10,6 +10,9 @@ apiVersion: {{ $xr.apiVersion }} kind: {{ $xr.kind }} status: ready: {{ $s.ready }} + {{- if $s.instanceId }} + instanceId: {{ $s.instanceId | quote }} + {{- end }} oidc: issuerURL: {{ $s.oidc.issuerURL | quote }} discoveryURL: {{ $s.oidc.discoveryURL | quote }} diff --git a/tests/security/test_discovery.py b/tests/security/test_discovery.py new file mode 100644 index 0000000..3269f6c --- /dev/null +++ b/tests/security/test_discovery.py @@ -0,0 +1,104 @@ +"""Credential destination regressions; run with python3 -m unittest discover -s tests/security. + +Requires the same Docker/up setup as render tests. UP_COMMAND can name a wrapper +for running up inside a container on hosts with a remote Docker daemon. +""" +import ast +import json +import os +from pathlib import Path +import shlex +import ssl +import subprocess +import tempfile +import textwrap +import unittest +import urllib.request + +ROOT = Path(__file__).resolve().parents[2] + + +class DiscoveryOriginTests(unittest.TestCase): + def render(self, url, insecure=False): + claim = { + 'apiVersion': 'hops.ops.com.ai/v1alpha1', 'kind': 'AuthStack', + 'metadata': {'name': 'identity'}, + 'spec': { + 'namespace': 'identity', 'domain': 'auth.example.com', + 'firstInstance': {'masterkey': {'secretRef': {'name': 'masterkey'}}}, + 'database': {'external': {'dsnSecretRef': {'name': 'db', 'key': 'dsn'}}}, + 'instanceDiscovery': { + 'enabled': True, 'internalURL': url, 'allowInsecureHTTP': insecure, + }, + }, + } + # Keep the input under the source root so containerized renderers see it. + with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as directory: + path = Path(directory) / 'claim.json' + path.write_text(json.dumps(claim)) + command = shlex.split(os.environ.get('UP_COMMAND', 'up')) + return subprocess.run(command + [ + 'composition', 'render', 'apis/authstacks/composition.yaml', str(path), + ], cwd=ROOT, capture_output=True, text=True, timeout=180) + + @classmethod + def setUpClass(cls): + (ROOT / '.tmp').mkdir(exist_ok=True) + + def test_untrusted_origins_fail_before_rendering_the_pat_job(self): + for url, insecure in [ + ('https://attacker.example:8080', False), + ('https://other.identity.svc:8080', False), + ('https://identity-zitadel.other.svc:8080', False), + ('https://identity-zitadel.identity.svc:8443', False), + ('https://identity-zitadel.identity.svc:8080@attacker.example', False), + ('https://identity-zitadel.identity.svc:8080/path', False), + ('http://identity-zitadel.identity.svc:8080', False), + ('http://attacker.identity.svc:8080', True), + ]: + with self.subTest(url=url, insecure=insecure): + result = self.render(url, insecure) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("must target this stack's Zitadel Service", result.stderr + result.stdout) + + +class DiscoveryTransportTests(unittest.TestCase): + def setUp(self): + template = (ROOT / 'functions/render/210-instance-discovery.yaml.gotmpl').read_text() + script = template.split(' - |\n', 1)[1].split(' volumeMounts:', 1)[0] + tree = ast.parse(textwrap.dedent(script)) + # Execute the actual Job transport setup, excluding filesystem/credential + # reads and the API retry loop. No network or real PAT is needed here. + self.scope = {'ssl': ssl, 'urllib': __import__('urllib'), 'os': os, + 'tls': ssl.create_default_context()} + setup = [] + for node in tree.body: + if isinstance(node, ast.ClassDef) or ( + isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name) + and node.targets[0].id in ('zitadel_tls', 'opener', 'api_opener') + ): + setup.append(node) + from unittest.mock import patch + with patch.dict(os.environ, {'ZITADEL_CA_FILE': '', 'https_proxy': 'http://attacker:8080'}): + exec(compile(ast.Module(body=setup, type_ignores=[]), '', 'exec'), self.scope) + + def test_tls_verifies_service_identity(self): + self.assertTrue(self.scope['zitadel_tls'].check_hostname) + self.assertEqual(self.scope['zitadel_tls'].verify_mode, ssl.CERT_REQUIRED) + + def test_redirects_cannot_forward_bearer_headers(self): + handler = self.scope['NoRedirect']() + request = urllib.request.Request('https://identity-zitadel.identity.svc:8080', + headers={'Authorization': 'Bearer fake-test-token'}) + for status in (301, 302, 303, 307, 308): + with self.subTest(status=status), self.assertRaises(ValueError): + handler.redirect_request(request, None, status, '', {}, 'https://attacker.example') + + def test_proxy_environment_is_ignored_for_both_credentials(self): + for name in ('opener', 'api_opener'): + self.assertFalse(any(isinstance(handler, urllib.request.ProxyHandler) + and handler.proxies for handler in self.scope[name].handlers)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test-render/main.k b/tests/test-render/main.k index 5f328bf..99e6705 100644 --- a/tests/test-render/main.k +++ b/tests/test-render/main.k @@ -13,6 +13,70 @@ _ready_conditions = [ # ============================================================================== _items = [ + metav1alpha1.CompositionTest { + metadata.name = "system-api-public-key-and-instance-discovery" + spec = { + compositionPath = "apis/authstacks/composition.yaml" + xrdPath = "apis/authstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "AuthStack" + metadata.name = "identity" + spec = { + clusterName = "cloud" + namespace = "identity" + domain = "auth.example.com" + firstInstance.masterkey.secretRef.name = "masterkey" + database.external.dsnSecretRef = {name = "db", key = "dsn"} + systemAPIUsers = [{ + id = "domain-controller" + publicKeySecretRef = {name = "system-public-key", key = "public.pem"} + memberships = [{memberType = "System", roles = ["SYSTEM_OWNER"]}] + }] + instanceDiscovery = {enabled = True, internalURL = "https://identity-zitadel.identity.svc:8080", caCertSecretRef = {name = "identity-ca", key = "bundle.pem"}} + } + } + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "identity-zitadel" + spec.forProvider.values = { + zitadel.configmapConfig.SystemAPIUsers = { + "domain-controller" = { + Path = "/system-api/domain-controller/public.pem" + Memberships = [{MemberType = "System", Roles = ["SYSTEM_OWNER"]}] + } + } + extraVolumes = [{name = "system-api-78625ef78567ecc7", secret = { + secretName = "system-public-key", items = [{key = "public.pem", path = "public.pem"}] + }}] + } + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "identity-instance-metadata" + spec.managementPolicies = ["Create", "Observe", "Delete"] + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "identity-instance-discovery" + spec.forProvider.manifest.spec = { + backoffLimit = 3 + template.spec.volumes = [ + {name = "admin-pat", secret.secretName = "iam-admin-pat"} + {name = "zitadel-ca", secret = {secretName = "identity-ca", items = [{key = "bundle.pem", path = "ca.crt"}]}} + ] + } + } + ] + } + } + # ========================================================================== # Test 1: Masterkey secretRef wires masterkeySecretName # ========================================================================== @@ -879,4 +943,40 @@ _items = [ } ] -items = _items +# Plaintext is available only as an explicit choice for a trusted local cluster. +_local_discovery = metav1alpha1.CompositionTest { + metadata.name = "local-discovery-explicit-http-and-service-override" + spec = { + compositionPath = "apis/authstacks/composition.yaml" + xrdPath = "apis/authstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "AuthStack" + metadata.name = "local-auth" + spec = { + namespace = "auth" + domain = "auth.hops.localhost" + firstInstance.masterkey.secretRef.name = "masterkey" + database.external.dsnSecretRef = {name = "db", key = "dsn"} + chartValues = {fullnameOverride = "zitadel", service.port = 9090} + instanceDiscovery = { + enabled = True + internalURL = "http://zitadel.auth.svc.cluster.local:9090" + allowInsecureHTTP = True + } + } + } + assertResources = [{ + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "local-auth-instance-discovery" + spec.forProvider.manifest = { + metadata.namespace = "auth" + spec.backoffLimit = 3 + } + }] + } +} +items = _items + [_local_discovery]