Prod's UniFi VM has no disk. `DataVolume unifi/unifi-rootdisk` stuck in
ImportScheduled for hours, because Kyverno denied CDI's own generated pod:
validate.kyverno.svc-fail denied Pod/unifi/importer-prime-<uid>
require-auth-mode-label (R-IDP-02)
require-egress-class-label (R-NET-05)
Both policies match any Pod in an `app-namespace: "true"` namespace with no
exclusion, and CDI generates `importer-*` itself — it cannot carry repo labels.
A COLD-START defect, not a prod quirk: dev's identical import Succeeded 9 days
ago, before the policies enforced, so dev has never exercised it and would hit it
on any rebuild. Fourth bug of this shape in two days.
MECHANISM: a MUTATE ClusterPolicy that ADDS
`egress-class: internet` + `auth-mode: none` (add-if-absent anchor) to Pods on
CREATE in app namespaces WHEN the requesting subject is ServiceAccount
`cdi/cdi-sa`. Both validate policies are untouched — no exclude, no exception,
`background: true` preserved on them.
Why mutate beats exempting: the requirement stays literally true ("every pod in an
app namespace declares both labels") and the values are HONEST — the importer does
pull over the default WAN path, and `internet` is the least-reach class (it does
not grant the VPN path); a transient worker pod with no UI genuinely is
`auth-mode: none`.
THE MATCH IS IDENTITY, NOT A NAME. `subjects: ServiceAccount cdi/cdi-sa` is the
AdmissionReview userInfo, taken from the real prod denial
(`system:serviceaccount:cdi:cdi-sa`, name importer-prime-…, op CREATE). A name
prefix is attacker-choosable; a requesting ServiceAccount is not — and the test
proves it, with a fixture `importer-prime-forged-by-an-app` that copies both the
name prefix AND CDI's labels and is still DENIED. Covers every CDI worker kind
(importer/uploader/cloner) without enumerating names that change per release.
Confirmed on prod that cdi-sa is the only cdi-namespace SA that can create pods.
REJECTED, and the first one is a trap worth recording:
* PolicyException — the CRD EXISTS (v2/v2beta1, from the pre-applied bundle) but
all controllers run `--enablePolicyException=false` (verified live on prod:
admission, background and reports controllers all carry the flag). An exception
would apply cleanly through Flux and be SILENTLY IGNORED. Enabling the gate is a
cluster-wide capability change for no gain over the mutate.
* `exclude` scoped to cdi-sa — sound identity, worse consequences: any userInfo
match forces `background: false`, which would drop background scanning and
policy-report coverage of R-NET-05/R-IDP-02 for ALL EXISTING PODS to fix one pod
class. It also leaves the importer with no labels at all — the unlabelled
fall-through that require-egress-class-label's own header calls a fail-open leak.
* name-prefix / CDI-label matching — attacker-choosable, and proven walkable.
TWO-WAY PROOF, non-vacuous by construction — same resource, different requester,
opposite outcomes (verified by me, not just reported):
userinfo cdi-sa -> mutate pass, require-egress-class pass, require-auth-mode pass
userinfo ordinary -> mutate SKIP, require-egress-class FAIL, require-auth-mode FAIL
Kyverno L0 went 17 -> 29 tests passed, 0 failed. The mutate policy is deliberately
kept OUT of the existing tests/kyverno-test.yaml: that suite declares no userinfo,
and without userinfo the offline engine does not discriminate on subjects — it
mutates everything, which would have silently turned its negative rows into passes.
platform-config/kustomization.yaml is the live apply path for these ClusterPolicies;
without it the policy never reaches a cluster.
RESIDUAL TRADE-OFF, reversible by deleting one file: anything created by cdi-sa in
an app namespace now has these labels supplied rather than being rejected for not
declaring them — including a pod that is not a CDI worker. That identity already
holds `pods create/delete` cluster-wide via ClusterRole `cdi`, so it is not a new
capability; what changes is that for that one identity the declaration is assumed
rather than demanded. restrict-host-namespaces, the namespace default-deny baseline
and the CiliumNetworkPolicies all still apply.
FIRST resource-mutating policy on this cluster: kyverno-resource-mutating-webhook-cfg
holds 0 webhooks today; with autoUpdateWebhooks=true Kyverno registers
mutate.kyverno.svc-fail for Pods within seconds of this applying. Prod's
forceFailurePolicyIgnore=false makes it failurePolicy: Fail — same posture as the
validating webhook already in that path.
CDI retries importer creation on a loop, so the stuck import should proceed once
this lands. The detached prime-be3f9d1c-… 40 GiB scratch PVC is separate operator
cleanup; untouched, as is the DataVolume.
VERIFIED: full CI gate PASS. Nothing applied to any cluster.
Refs R-IDP-02, R-NET-05, R-PLAT-01.
Co-Authored-By: Claude Opus 5 <[email protected]>
137 lines
8.8 KiB
YAML
137 lines
8.8 KiB
YAML
# Serves: R-NET-05 (egress is a declared property) + R-IDP-02 (auth mode is declared) —
|
|
# this policy does NOT weaken either requirement. It makes the one class of pod that
|
|
# CANNOT carry the labels from git SATISFY them, instead of being exempted from them.
|
|
#
|
|
# THE DEFECT IT FIXES (observed on LIVE prod, 2026-09-05/06):
|
|
# `DataVolume unifi/unifi-rootdisk` sat in ImportScheduled for hours holding a detached
|
|
# 40Gi `prime-…` scratch PVC. `kubectl -n cdi logs deploy/cdi-deployment` showed:
|
|
# admission webhook "validate.kyverno.svc-fail" denied the request: resource
|
|
# Pod/unifi/importer-prime-<uid> was blocked due to the following policies
|
|
# require-auth-mode-label / require-egress-class-label
|
|
# CDI generates its own short-lived worker pods (importer-*, uploader-*, cloner-*/
|
|
# clone-source-*) from inside the cdi-deployment controller. Those pods are not in git,
|
|
# so nothing can put the platform labels on them, and both label policies match ANY Pod
|
|
# in a namespace labelled homelab.keithjackson.dev/app-namespace=true. Result: the VM
|
|
# never gets a disk. This is a COLD-START defect, not a prod quirk — dev's identical
|
|
# import Succeeded before the policies were enforcing, so dev has never exercised it and
|
|
# would hit it on any rebuild.
|
|
#
|
|
# WHY MUTATE (and not an exception/exclusion):
|
|
# The honest reading of R-NET-05/R-IDP-02 is "every pod's egress path and auth posture is
|
|
# DECLARED". A CDI importer pod has truthful values for both:
|
|
# egress-class: internet — it pulls the image from the public internet over https
|
|
# (cloud-images.ubuntu.com / quay.io) and must take the
|
|
# default WAN path, which is exactly what Cilium's egress
|
|
# gateway does for `internet` (ADR-0013/ADR-0015). It is NOT
|
|
# a `vpn` workload.
|
|
# auth-mode: none — the pod serves no UI and terminates when the import ends;
|
|
# there is nothing to put an IdP in front of. docs/13 permits
|
|
# `none` for internal, non-public workloads (the
|
|
# ServiceDefinition policy forbids only public + none).
|
|
# So the requirement is SATISFIED rather than bypassed: the invariant "every pod in an app
|
|
# namespace carries both labels" stays literally true cluster-wide, both label policies
|
|
# stay untouched (no `exclude`, no PolicyException, background scanning still on), and the
|
|
# labels are functional — the pod is now selectable by the Cilium egress fabric instead of
|
|
# being an unlabelled pod that silently falls through to the default path.
|
|
#
|
|
# THE MATCH IS THE GENERATING ServiceAccount, NOT A NAME PREFIX:
|
|
# `subjects: ServiceAccount cdi/cdi-sa` is the identity in the AdmissionReview's userInfo —
|
|
# the controller that issued the CREATE. Verified on prod from the Kyverno admission log of
|
|
# the blocked request:
|
|
# user={"username":"system:serviceaccount:cdi:cdi-sa", ...} roles=["cdi:cdi-deployment"]
|
|
# name=importer-prime-be3f9d1c-… namespace=unifi operation=CREATE
|
|
# A pod NAME (`importer-*`) or CDI's own labels (`cdi.kubevirt.io`) are attacker-CHOOSABLE:
|
|
# anyone who can create a pod in the namespace can name it `importer-prime-x` and copy the
|
|
# labels, and would then inherit the exemption. The requesting ServiceAccount cannot be
|
|
# chosen by the pod author — only by someone who already holds cdi-sa's token, which already
|
|
# confers "create any pod in any namespace" cluster-wide. So this adds no reachable
|
|
# privilege. It is also VERSION-ROBUST: it covers every CDI worker kind (importer, uploader,
|
|
# cloner/clone-source) without enumerating names that change between CDI releases —
|
|
# confirmed on prod that cdi-sa is the ONLY cdi-namespace ServiceAccount that can create
|
|
# pods (`kubectl auth can-i create pods -n unifi --as …`: cdi-sa yes; cdi-apiserver no;
|
|
# cdi-uploadproxy no).
|
|
#
|
|
# DELIBERATELY NOT COVERED — KubeVirt's own generated pods:
|
|
# virt-launcher-* pods INHERIT the VM's pod-template labels, so a VM authored in this repo
|
|
# already passes (verified on dev: virt-launcher-unifi-22dg7 carries egress-class=internet,
|
|
# auth-mode=native from apps/vms/unifi/30-virtualmachine.yaml). Exempting
|
|
# kubevirt-controller would REMOVE that useful enforcement on VM authors, so it is not done.
|
|
# KubeVirt's hotplug (`hp-volume-*`) and export (`virt-export-*`) pods do NOT inherit those
|
|
# labels, but nothing in this repo uses volume hotplug or VirtualMachineExport; if either is
|
|
# adopted, extend this policy then — with the same ServiceAccount-scoped shape.
|
|
#
|
|
# OPERATOR NOTE — this is the cluster's FIRST resource-MUTATING policy. Verified on prod
|
|
# 2026-09-06 that `kyverno-resource-mutating-webhook-cfg` currently holds 0 webhooks; with the
|
|
# chart's `autoUpdateWebhooks=true` Kyverno registers `mutate.kyverno.svc-fail` for Pods within
|
|
# seconds of this policy being applied. Prod runs `forceFailurePolicyIgnore=false`, so that
|
|
# webhook is failurePolicy: Fail — the same posture as the validating webhook that is ALREADY in
|
|
# the path for these pods, so no new fail-open surface and no new blocking surface. CDI retries
|
|
# the importer pod on a loop, so the stuck import proceeds once the policy lands (the detached
|
|
# `prime-…` scratch PVC left behind by the blocked attempts is a separate operator cleanup).
|
|
#
|
|
# RESIDUAL TRADE-OFF (state it plainly, it is reversible by deleting this file):
|
|
# Anything created by `cdi:cdi-sa` in an app namespace now gets `egress-class: internet` +
|
|
# `auth-mode: none` supplied for it instead of being rejected for not declaring them. Whoever
|
|
# holds cdi-sa's token can therefore run a pod in an app namespace without declaring its egress
|
|
# class. That identity can already create arbitrary pods in every namespace (ClusterRole `cdi`:
|
|
# pods create/delete cluster-wide), so it is not a new capability — but the declaration is now
|
|
# assumed rather than demanded for that one identity. Nothing else changes: the pod is still
|
|
# subject to restrict-host-namespaces, the namespace default-deny baseline, and every other
|
|
# policy, and `internet` is the LEAST-reach of the two egress classes (it does not grant the VPN
|
|
# path).
|
|
#
|
|
# PROOF (offline, no cluster — platform/kyverno/tests/controller-worker-pods/):
|
|
# (a) a CDI importer pod created by cdi:cdi-sa is mutated and then PASSES both policies;
|
|
# (b) the SAME namespace's ordinary pod — including one that FORGES the importer name and
|
|
# CDI's labels — is NOT mutated and is still DENIED by both policies.
|
|
apiVersion: kyverno.io/v1
|
|
kind: ClusterPolicy
|
|
metadata:
|
|
name: label-cdi-worker-pods
|
|
annotations:
|
|
policies.kyverno.io/title: Label CDI-generated worker pods
|
|
policies.kyverno.io/category: Network Isolation
|
|
policies.kyverno.io/severity: medium
|
|
homelab.keithjackson.dev/requirements: R-NET-05,R-IDP-02
|
|
policies.kyverno.io/description: >-
|
|
Adds homelab.keithjackson.dev/egress-class=internet and auth-mode=none to the
|
|
short-lived worker Pods that the CDI controller (ServiceAccount cdi/cdi-sa)
|
|
generates in application namespaces, so they satisfy require-egress-class-label
|
|
and require-auth-mode-label instead of being exempted from them.
|
|
spec:
|
|
# No validationFailureAction: this is a MUTATE policy, there is nothing to fail.
|
|
# background MUST be false: `match.subjects` is userInfo, which exists only on an
|
|
# AdmissionReview. Kyverno rejects a userInfo match with background scanning enabled
|
|
# ("invalid variable used at path ... policy contains 'userInfo'"), and a background scan
|
|
# of an already-created pod has no requester to match anyway.
|
|
background: false
|
|
rules:
|
|
- name: add-worker-pod-labels
|
|
match:
|
|
# One filter entry: the resource conditions AND the subject must both match.
|
|
all:
|
|
- resources:
|
|
kinds:
|
|
- Pod
|
|
operations:
|
|
- CREATE
|
|
namespaceSelector:
|
|
matchLabels:
|
|
homelab.keithjackson.dev/app-namespace: "true"
|
|
subjects:
|
|
# The CDI controller (cdi-deployment) ServiceAccount — the identity that
|
|
# CREATEs importer-*/uploader-*/cloner-* pods. Not the pods' own SA.
|
|
- kind: ServiceAccount
|
|
namespace: cdi
|
|
name: cdi-sa
|
|
mutate:
|
|
patchStrategicMerge:
|
|
metadata:
|
|
labels:
|
|
# `+(key)` is Kyverno's ADD-IF-ABSENT anchor: an explicit value already on the
|
|
# pod is never overwritten (so a future CDI worker that legitimately needs
|
|
# egress-class: vpn can be given it, and virt-launcher-style inherited labels
|
|
# would survive).
|
|
+(homelab.keithjackson.dev/egress-class): internet
|
|
+(homelab.keithjackson.dev/auth-mode): none
|