From bd2a79f9e374a9ade7b84e741912a2c186f3aa2d Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 20 Oct 2026 13:28:19 +0200 Subject: [PATCH 1/3] feat(kyverno): migrate restrict-arc-openbao-certificate to ValidatingPolicy Fixes #5123 Co-Authored-By: Claude Opus 5.5 --- .../restrict-arc-openbao-certificate.yaml | 56 +++++++++---------- tests/policy-failure-actions.json | 2 +- .../kyverno-test.yaml | 13 +++-- .../resources.yaml | 25 +++++++++ 4 files changed, 62 insertions(+), 34 deletions(-) diff --git a/k8s/providers/hetzner/infrastructure/cluster-policies/restrict-arc-openbao-certificate.yaml b/k8s/providers/hetzner/infrastructure/cluster-policies/restrict-arc-openbao-certificate.yaml index 4e4ce8e72f..3a2c707460 100644 --- a/k8s/providers/hetzner/infrastructure/cluster-policies/restrict-arc-openbao-certificate.yaml +++ b/k8s/providers/hetzner/infrastructure/cluster-policies/restrict-arc-openbao-certificate.yaml @@ -1,35 +1,35 @@ -apiVersion: kyverno.io/v1 -kind: ClusterPolicy +# The arc-openbao-ca ClusterIssuer signs OpenBao's transport (server) +# certificate. As a ClusterIssuer it is reachable from every namespace, so only +# Certificates and CertificateRequests in the openbao namespace may use it; a +# certificate it issued anywhere else could impersonate OpenBao. +apiVersion: policies.kyverno.io/v1 +kind: ValidatingPolicy metadata: name: restrict-arc-openbao-certificate annotations: policies.kyverno.io/title: Restrict ARC OpenBao Certificate Issuance policies.kyverno.io/category: Security policies.kyverno.io/severity: high + policies.kyverno.io/minversion: 1.17.0 spec: - background: true - rules: - - name: only-openbao-may-use-credential-ca - match: - any: - - resources: - kinds: - - cert-manager.io/v1/Certificate - - cert-manager.io/v1/CertificateRequest - preconditions: - all: - - key: "{{ request.object.spec.issuerRef.name || '' }}" - operator: Equals - value: arc-openbao-ca - - key: "{{ request.object.spec.issuerRef.kind || 'Issuer' }}" - operator: Equals - value: ClusterIssuer - validate: - failureAction: Enforce - message: The dedicated credential-service CA may issue only in OpenBao. - deny: - conditions: - any: - - key: "{{ request.object.metadata.namespace || '' }}" - operator: NotEquals - value: openbao + validationActions: + - Deny + evaluation: + background: + enabled: true + matchConstraints: + resourceRules: + - apiGroups: ["cert-manager.io"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["certificates", "certificaterequests"] + matchConditions: + # Only requests for the dedicated credential-service CA are in scope. An + # unset issuerRef.kind means a namespaced Issuer, which is a different CA. + - name: uses-credential-ca + expression: >- + object.spec.?issuerRef.?name.orValue('') == 'arc-openbao-ca' && + object.spec.?issuerRef.?kind.orValue('Issuer') == 'ClusterIssuer' + validations: + - expression: "object.metadata.?namespace.orValue('') == 'openbao'" + message: The dedicated credential-service CA may issue only in OpenBao. diff --git a/tests/policy-failure-actions.json b/tests/policy-failure-actions.json index c3715885fe..37991c19fd 100644 --- a/tests/policy-failure-actions.json +++ b/tests/policy-failure-actions.json @@ -1,6 +1,6 @@ { "restrict-explicit-dns-records/approved-platform-records": "Enforce", - "restrict-arc-openbao-certificate/only-openbao-may-use-credential-ca": "Enforce", + "restrict-arc-openbao-certificate": "Enforce", "restrict-arc-openbao-listener/public-listener-settings-only": "Enforce", "require-baseline-workers-for-hcloud-volumes/require-baseline-worker-affinity": "Enforce", "require-baseline-workers-for-hcloud-volumes/report-claim-lookup-failure": "Audit", diff --git a/tests/restrict-arc-openbao-certificate/kyverno-test.yaml b/tests/restrict-arc-openbao-certificate/kyverno-test.yaml index ac431ea03a..5380bca5e8 100644 --- a/tests/restrict-arc-openbao-certificate/kyverno-test.yaml +++ b/tests/restrict-arc-openbao-certificate/kyverno-test.yaml @@ -8,32 +8,35 @@ resources: - resources.yaml results: - policy: restrict-arc-openbao-certificate - rule: only-openbao-may-use-credential-ca resources: - openbao/server-certificate kind: Certificate result: pass - policy: restrict-arc-openbao-certificate - rule: only-openbao-may-use-credential-ca resources: - openbao/server-request kind: CertificateRequest result: pass - policy: restrict-arc-openbao-certificate - rule: only-openbao-may-use-credential-ca resources: - arc-runners/impersonated-server kind: Certificate result: fail - policy: restrict-arc-openbao-certificate - rule: only-openbao-may-use-credential-ca resources: - arc-runners/impersonated-request kind: CertificateRequest result: fail + # Same issuer name, but a namespaced Issuer (explicit or by default) is a + # different CA and out of scope. - policy: restrict-arc-openbao-certificate - rule: only-openbao-may-use-credential-ca resources: - cert-manager/unrelated-ca + - arc-runners/same-name-namespaced-issuer kind: Certificate result: skip + - policy: restrict-arc-openbao-certificate + resources: + - arc-runners/same-name-default-kind + kind: CertificateRequest + result: skip diff --git a/tests/restrict-arc-openbao-certificate/resources.yaml b/tests/restrict-arc-openbao-certificate/resources.yaml index 507e6e8b77..c76ef88b36 100644 --- a/tests/restrict-arc-openbao-certificate/resources.yaml +++ b/tests/restrict-arc-openbao-certificate/resources.yaml @@ -63,3 +63,28 @@ spec: name: unrelated-selfsigned kind: Issuer group: cert-manager.io +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: same-name-namespaced-issuer + namespace: arc-runners +spec: + secretName: synthetic-namespaced-tls + dnsNames: + - namespaced.arc-runners.svc.cluster.local + issuerRef: + name: arc-openbao-ca + kind: Issuer + group: cert-manager.io +--- +apiVersion: cert-manager.io/v1 +kind: CertificateRequest +metadata: + name: same-name-default-kind + namespace: arc-runners +spec: + request: dGVzdA== + issuerRef: + name: arc-openbao-ca + group: cert-manager.io From f01b6b124a55ee3764b94391e59865a8ef19f9a4 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 20 Oct 2026 13:35:55 +0200 Subject: [PATCH 2/3] fix(kyverno): keep every certificate's report result current Fold the issuer scope into the validation so out-of-scope certificates get a current pass instead of no result, matching the platform's report convention. Co-Authored-By: Claude Opus 5.5 --- .../restrict-arc-openbao-certificate.yaml | 12 ++++++++---- .../kyverno-test.yaml | 6 +++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/k8s/providers/hetzner/infrastructure/cluster-policies/restrict-arc-openbao-certificate.yaml b/k8s/providers/hetzner/infrastructure/cluster-policies/restrict-arc-openbao-certificate.yaml index 3a2c707460..c0fdc50ad1 100644 --- a/k8s/providers/hetzner/infrastructure/cluster-policies/restrict-arc-openbao-certificate.yaml +++ b/k8s/providers/hetzner/infrastructure/cluster-policies/restrict-arc-openbao-certificate.yaml @@ -23,13 +23,17 @@ spec: apiVersions: ["v1"] operations: ["CREATE", "UPDATE"] resources: ["certificates", "certificaterequests"] - matchConditions: - # Only requests for the dedicated credential-service CA are in scope. An + variables: + # Only requests for the dedicated credential-service CA are restricted. An # unset issuerRef.kind means a namespaced Issuer, which is a different CA. - - name: uses-credential-ca + # This is part of the validation rather than a matchCondition so every + # certificate keeps a current report result (docs/policy-reports.md). + - name: usesCredentialCA expression: >- object.spec.?issuerRef.?name.orValue('') == 'arc-openbao-ca' && object.spec.?issuerRef.?kind.orValue('Issuer') == 'ClusterIssuer' validations: - - expression: "object.metadata.?namespace.orValue('') == 'openbao'" + - expression: >- + !variables.usesCredentialCA || + object.metadata.?namespace.orValue('') == 'openbao' message: The dedicated credential-service CA may issue only in OpenBao. diff --git a/tests/restrict-arc-openbao-certificate/kyverno-test.yaml b/tests/restrict-arc-openbao-certificate/kyverno-test.yaml index 5380bca5e8..555bffd49a 100644 --- a/tests/restrict-arc-openbao-certificate/kyverno-test.yaml +++ b/tests/restrict-arc-openbao-certificate/kyverno-test.yaml @@ -28,15 +28,15 @@ results: kind: CertificateRequest result: fail # Same issuer name, but a namespaced Issuer (explicit or by default) is a - # different CA and out of scope. + # different CA and not restricted. - policy: restrict-arc-openbao-certificate resources: - cert-manager/unrelated-ca - arc-runners/same-name-namespaced-issuer kind: Certificate - result: skip + result: pass - policy: restrict-arc-openbao-certificate resources: - arc-runners/same-name-default-kind kind: CertificateRequest - result: skip + result: pass From e845f9514aad91c4c558a400f89750481810db13 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 20 Oct 2026 13:43:07 +0200 Subject: [PATCH 3/3] fix(arc): read the issuance restriction as a ValidatingPolicy in the transport proof The proof now fetches the ValidatingPolicy, compares its live spec with the source exactly (the kind adds no defaults) and reads readiness from status.conditionStatus. Its negative cases become the ValidatingPolicy equivalents: admission or background disabled, Audit instead of Deny, an added match condition, fail-open, an unknown field and a not-ready status. The listener policy's checks are unchanged. Fixes #5126 Co-Authored-By: Claude Opus 5.5 --- scripts/tests/test-verify-arc-transport.sh | 36 ++++++++++------------ scripts/verify-arc-transport.sh | 11 ++++--- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/scripts/tests/test-verify-arc-transport.sh b/scripts/tests/test-verify-arc-transport.sh index 9936c851b0..b671d4d46d 100755 --- a/scripts/tests/test-verify-arc-transport.sh +++ b/scripts/tests/test-verify-arc-transport.sh @@ -11,12 +11,13 @@ go build -o "$scratch/evidence" ./scripts/verify-arc-transport export PROOF_TEST_ROOT="$scratch" readonly head=1111111111111111111111111111111111111111 export GITHUB_ACTIONS=true GITHUB_REPOSITORY=devantler-tech/platform GITHUB_REF=refs/heads/main GITHUB_SHA="$head" GITHUB_RUN_ID=12345 GITHUB_RUN_ATTEMPT=1 -# Native Kyverno v1.19.1 admission adds both rule defaults. Keep the fixture -# independent of the verifier's expected-spec construction. +# The issuance restriction is a ValidatingPolicy: its CRD adds no spec defaults +# and it reports readiness in status.conditionStatus (as Kyverno v1.19.1 does). yq -o=json '.spec' k8s/providers/hetzner/infrastructure/cluster-policies/restrict-arc-openbao-certificate.yaml \ - | jq '.rules |= map(.skipBackgroundRequests=true | .validate.allowExistingViolations=true) | - {spec:(. + {admission:true,emitWarning:false,validationFailureAction:"Audit"}), - status:{conditions:[{type:"Ready",status:"True"}]}}' >"$scratch/policy.json" + | jq '{metadata:{uid:"issuance-policy-uid"},spec:.,status:{conditionStatus:{ready:true}}}' >"$scratch/policy.json" +# Native Kyverno v1.19.1 admission adds both rule defaults to the listener +# ClusterPolicy. Keep the fixture independent of the verifier's expected-spec +# construction. yq -o=json '.spec' k8s/bases/infrastructure/cluster-policies/best-practices/restrict-arc-openbao-listener.yaml \ | jq '.rules |= map(.skipBackgroundRequests=true | .validate.allowExistingViolations=true) | {metadata:{uid:"policy-uid"},spec:(. + {admission:true,emitWarning:false,validationFailureAction:"Audit"}), @@ -29,14 +30,12 @@ for file in k8s/providers/hetzner/infrastructure/cluster-policies/restrict-arc-o k8s/providers/hetzner/infrastructure/controllers/openbao/transport/config-map-listener.yaml; do mkdir -p "$scratch/source/$(dirname "$file")" cp "$file" "$scratch/source/$file" - if [[ "$file" == *cluster-policies* ]]; then + if [[ "$file" == *restrict-arc-openbao-listener* ]]; then yq -i '.spec.rules[].skipBackgroundRequests = false | .spec.rules[].validate.allowExistingViolations = false' "$scratch/source/$file" fi done -for policy in policy listener-policy; do - jq '.spec.rules |= map(.skipBackgroundRequests=false | .validate.allowExistingViolations=false)' \ - "$scratch/$policy.json" >"$scratch/explicit-$policy.json" -done +jq '.spec.rules |= map(.skipBackgroundRequests=false | .validate.allowExistingViolations=false)' \ + "$scratch/listener-policy.json" >"$scratch/explicit-listener-policy.json" cat >"$scratch/bin/gh" <<'MOCK' #!/usr/bin/env bash printf '%s\n' "${GITHUB_SHA}" @@ -103,15 +102,14 @@ case "$args" in elif [[ "${PROOF_CASE:-}" == listener_failure_open ]]; then jq '.spec.failurePolicy="Ignore"' "$PROOF_TEST_ROOT/listener-policy.json" elif [[ "${PROOF_CASE:-}" == listener_override ]]; then jq '.spec.rules[0].validate.failureActionOverrides=[{action:"Audit",namespaces:["openbao"]}]' "$PROOF_TEST_ROOT/listener-policy.json" else cat "$PROOF_TEST_ROOT/listener-policy.json"; fi ;; - 'get clusterpolicy '*) - if [[ "${PROOF_CASE:-}" == source_explicit_false ]]; then cat "$PROOF_TEST_ROOT/explicit-policy.json" - elif [[ "${PROOF_CASE:-}" == issuance_admission_disabled ]]; then jq '.spec.admission=false' "$PROOF_TEST_ROOT/policy.json" - elif [[ "${PROOF_CASE:-}" == issuance_background_drift ]]; then jq '.spec.rules[0].skipBackgroundRequests=false' "$PROOF_TEST_ROOT/policy.json" - elif [[ "${PROOF_CASE:-}" == issuance_existing_drift ]]; then jq '.spec.rules[0].validate.allowExistingViolations=false' "$PROOF_TEST_ROOT/policy.json" - elif [[ "${PROOF_CASE:-}" == issuance_unknown_field ]]; then jq '.spec.rules[0].unexpected=true' "$PROOF_TEST_ROOT/policy.json" - elif [[ "${PROOF_CASE:-}" == issuance_exclusion ]]; then jq '.spec.rules[0].exclude={any:[{resources:{namespaces:["arc-runners"]}}]}' "$PROOF_TEST_ROOT/policy.json" + 'get validatingpolicies.policies.kyverno.io restrict-arc-openbao-certificate '*) + if [[ "${PROOF_CASE:-}" == issuance_admission_disabled ]]; then jq '.spec.evaluation.admission={enabled:false}' "$PROOF_TEST_ROOT/policy.json" + elif [[ "${PROOF_CASE:-}" == issuance_background_drift ]]; then jq '.spec.evaluation.background.enabled=false' "$PROOF_TEST_ROOT/policy.json" + elif [[ "${PROOF_CASE:-}" == issuance_not_ready ]]; then jq '.status.conditionStatus.ready=false' "$PROOF_TEST_ROOT/policy.json" + elif [[ "${PROOF_CASE:-}" == issuance_unknown_field ]]; then jq '.spec.unexpected=true' "$PROOF_TEST_ROOT/policy.json" + elif [[ "${PROOF_CASE:-}" == issuance_exclusion ]]; then jq '.spec.matchConditions=[{name:"not-arc-runners",expression:"object.metadata.namespace != \"arc-runners\""}]' "$PROOF_TEST_ROOT/policy.json" elif [[ "${PROOF_CASE:-}" == issuance_failure_open ]]; then jq '.spec.failurePolicy="Ignore"' "$PROOF_TEST_ROOT/policy.json" - elif [[ "${PROOF_CASE:-}" == issuance_override ]]; then jq '.spec.rules[0].validate.failureActionOverrides=[{action:"Audit",namespaces:["arc-runners"]}]' "$PROOF_TEST_ROOT/policy.json" + elif [[ "${PROOF_CASE:-}" == issuance_override ]]; then jq '.spec.validationActions=["Audit"]' "$PROOF_TEST_ROOT/policy.json" else cat "$PROOF_TEST_ROOT/policy.json"; fi ;; 'get kustomization '*) printf '{"metadata":{"generation":1},"status":{"observedGeneration":1,"lastAppliedRevision":"latest@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","conditions":[{"type":"Ready","status":"True"}]}}' ;; 'get service '*) printf '{"spec":{"selector":{"app.kubernetes.io/name":"openbao","app.kubernetes.io/instance":"openbao","statefulset.kubernetes.io/pod-name":"openbao-2"},"ports":[{"name":"arc-tls","port":8204,"targetPort":8204,"protocol":"TCP"}]}}' ;; @@ -296,7 +294,7 @@ for name in sealed split_health stale_probe admit_privilege api_failure api_vali listener_content listener_churn listener_audit listener_admission_disabled listener_failure_open listener_override \ listener_admit listener_api_error issuance_admission_disabled issuance_exclusion issuance_failure_open issuance_override \ listener_background_drift listener_existing_drift listener_unknown_field \ - issuance_background_drift issuance_existing_drift issuance_unknown_field \ + issuance_background_drift issuance_not_ready issuance_unknown_field \ identity_stale identity_ordinal_only; do run_case "$name" 1; done run_case replacement 4 run_case cleanup_failure 4 diff --git a/scripts/verify-arc-transport.sh b/scripts/verify-arc-transport.sh index 3b77ee8124..ca2e055814 100755 --- a/scripts/verify-arc-transport.sh +++ b/scripts/verify-arc-transport.sh @@ -101,10 +101,13 @@ jq -e --arg server "https://$host:8204" '.spec.provider.vault.server == $server .spec.provider.vault.auth.kubernetes == {mountPath:"kubernetes",role:"arc-secret-reader",serviceAccountRef:{name:"arc-secret-reader"}}' "$scratch/store.json" >/dev/null kc -n "$namespace" get configmap arc-openbao-ca -o json >"$scratch/ca.json" jq -er '.data["ca.crt"] | select(startswith("-----BEGIN CERTIFICATE-----"))' "$scratch/ca.json" >"$scratch/ca.pem" -kc get clusterpolicy restrict-arc-openbao-certificate -o json >"$scratch/policy.json" -policy_spec k8s/providers/hetzner/infrastructure/cluster-policies/restrict-arc-openbao-certificate.yaml >"$scratch/issuance-spec.json" +# The issuance restriction is a ValidatingPolicy: its CRD adds no spec +# defaults, so the live spec must equal the source spec exactly, and its +# readiness lives in status.conditionStatus.ready. +kc get validatingpolicies.policies.kyverno.io restrict-arc-openbao-certificate -o json >"$scratch/policy.json" +yq -o=json '.spec' k8s/providers/hetzner/infrastructure/cluster-policies/restrict-arc-openbao-certificate.yaml >"$scratch/issuance-spec.json" jq -e --slurpfile expected "$scratch/issuance-spec.json" '.spec == $expected[0] and - any(.status.conditions[]; .type == "Ready" and .status == "True")' "$scratch/policy.json" >/dev/null + .status.conditionStatus.ready == true' "$scratch/policy.json" >/dev/null for layer in infrastructure infrastructure-controllers; do kc -n flux-system get kustomization "$layer" -o json | jq -ce 'select(.status.observedGeneration == .metadata.generation and any(.status.conditions[]; .type == "Ready" and .status == "True")) | .status.lastAppliedRevision | select(test("^latest@sha256:[0-9a-f]{64}$"))' >"$scratch/$layer-revision" @@ -299,7 +302,7 @@ for object in 'secretstore openbao' 'configmap arc-openbao-ca'; do jq -Sc '{uid:.metadata.uid,spec:.spec,data:.data}' "$scratch/$original.json" >"$scratch/before" cmp -s "$scratch/before" "$scratch/after" || fail transport-churn done -kc get clusterpolicy restrict-arc-openbao-certificate -o json | jq -Sc '{uid:.metadata.uid,spec:.spec}' >"$scratch/after" +kc get validatingpolicies.policies.kyverno.io restrict-arc-openbao-certificate -o json | jq -Sc '{uid:.metadata.uid,spec:.spec}' >"$scratch/after" jq -Sc '{uid:.metadata.uid,spec:.spec}' "$scratch/policy.json" >"$scratch/before" cmp -s "$scratch/before" "$scratch/after" || fail issuance-policy-churn kc -n external-secrets get pods -l app.kubernetes.io/name=external-secrets -o json | jq -Sc '[.items[]|{uid:.metadata.uid,ip:.status.podIP,node:.spec.nodeName,containers:.status.containerStatuses}] | sort_by(.uid)' >"$scratch/after"