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..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 @@ -1,35 +1,39 @@ -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"] + variables: + # Only requests for the dedicated credential-service CA are restricted. An + # unset issuerRef.kind means a namespaced Issuer, which is a different 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: >- + !variables.usesCredentialCA || + object.metadata.?namespace.orValue('') == 'openbao' + message: The dedicated credential-service CA may issue only in OpenBao. 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" 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..555bffd49a 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 not restricted. - 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 + result: pass + - policy: restrict-arc-openbao-certificate + resources: + - arc-runners/same-name-default-kind + kind: CertificateRequest + result: pass 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