Phase In a Binding, Not a Policy
With native VAP, the grace period lives in the binding's validationActions, never in the policy object. The same four violating Pods still report as failures under [Warn, Audit] — but the run exits 0, and phase 3 is opt-in by namespace label.
Every admission control needs a grace period. You do not turn on a registry allow-list and block half the cluster on a Tuesday; you warn for a month, fix what surfaces, then enforce. The question is where that phase lives in the config — because whatever you edit to change phase is a thing you can get wrong under time pressure.
Native VAP puts it in the binding. The policy object never changes.
The same failures, a different exit code
Running the registry policy with the phase-1 binding against the four violating Pods:
Applying 1 policy rule(s) to 4 resource(s)...
policy poc-imgsec-vap-restrict-image-registries -> resource .../fail-dockerhub-bare failed:
1 - these images are not from an approved registry: nginx:1.29.4
(allowed prefixes: registry.private.example.com/, registry.gitlab.example.com/, harbor.poc.local/)
...
pass: 0, fail: 4, warn: 0, error: 0, skip: 0
EXIT_CODE=0Four failures, still detected, still named — and EXIT_CODE=0. The evaluation is identical to the enforcing case; only the consequence changed. That is the whole mechanism: validationActions on the binding decides what the API server does with a verdict it has already reached.
The three values compose:
- Warn — the API server returns a
kubectlwarning to the developer. - Audit — an annotation is written into the API-server audit log.
- Deny — the request is rejected.
One policy, three bindings. Changing phase never touches the object that does the evaluating.
The three phases
Phase 1 is what you apply on day one: validationActions: [Warn, Audit], with a namespaceSelector whose matchExpressions exclude the platform namespaces outright — kube-system, kube-node-lease, kube-public, flux-system, kyverno, trivy, monitoring, registry, gitlab, argocd, cert-manager, openbao, external-secrets. Nothing is blocked. Teams see the message; deployments still work. Meanwhile the audit log fills with exactly the inventory you need to scope phase 2.
Phase 2 scopes Deny to the namespaces that are already clean, using matchResources.namespaceSelector, and keeps [Warn, Audit] everywhere else. Two bindings, one policy.
Phase 3 is [Deny, Audit] — and the version worth shipping makes it opt-in by label:
spec:
policyName: poc-imgsec-vap-restrict-image-registries
validationActions: [Deny, Audit]
matchResources:
namespaceSelector:
matchLabels:
image-policy: enforceA team enters enforcement by labelling their own namespace. Rollout becomes a sequence of one-line commits owned by the teams being rolled out to, rather than a single flag flip owned by the platform.
Why the location matters
Switching phase is an edit to a binding: a one-line, instantly revertible change that cannot itself break policy evaluation. Worst case, you bind wrong and nothing is enforced — you are back to phase 0, not to a wedged admission path.
With Kyverno the equivalent edit is inside the policy object, as validate.failureAction per rule. It works, but rollout and logic share a file: the change you make while the cluster is half-broken is a change to the same object that decides whether Pods are admitted. Separating the verdict from its consequence is worth more than it sounds, precisely because phase changes happen on bad days.
Next: server-side CEL type-checking as a CI lint — what --dry-run=server catches that client-side validation waves straight through.