Skip to main content
5 min read·910 words

Mutating Admission Policy in Kubernetes

Required knowledge for the CKS certification.

MutatingAdmissionPolicy is a declarative, in-process alternative to mutating admission webhooks, stable and enabled by default as of Kubernetes v1.36. Where a MutatingWebhookConfiguration requires a running webhook server, valid TLS certificates, and a network round-trip on every matching request, a MutatingAdmissionPolicy evaluates CEL expressions directly inside the API server process. No external server, no network hop.

Security teams use MutatingAdmissionPolicy to enforce defaults that workload authors may omit: classification labels, required sidecar containers, or missing annotations that downstream network policies and audit rules depend on. Because the policy runs at admission time — before the object is persisted — mutations are applied before the object is visible to any controller or workload.


1. How MutatingAdmissionPolicy Differs from Webhooks

Before: Enforcing a mutation at admission time required a MutatingWebhookConfiguration pointing to a running webhook server. That server needed a valid TLS certificate, a Service and Deployment in the cluster, and a startup sequence that guaranteed availability before the API server needed it. An unavailable webhook server, depending on its failurePolicy, could block all matching admission requests.

After: MutatingAdmissionPolicy defines mutations as CEL expressions stored in a Kubernetes API object. The API server evaluates them in-process. No webhook server, no TLS setup, no network round-trip.

The tradeoff: CEL's expression language is intentionally restricted. Mutations that require external state lookups, complex imperative logic, or access to resources outside the CEL variable set still require a webhook.


2. Policy Components

A complete policy requires two resources. A third (the parameter resource) is optional.

ResourcePurpose
MutatingAdmissionPolicyDefines the mutation logic using CEL expressions
MutatingAdmissionPolicyBindingBinds the policy to a scope (namespaces, resource selectors) and optionally references a parameter object
Parameter resourceAny Kubernetes object type; provides configuration available in CEL via the params variable

The parameter resource is only required when spec.paramKind is set in the policy. Without it, the policy uses only the variables derived from the incoming object itself.


3. Mutation Types

Mutations are declared as a list under spec.mutations. Each entry specifies a patchType of either ApplyConfiguration or JSONPatch.

ApplyConfiguration

Uses the server-side apply merge strategy. The CEL expression evaluates to a partial resource object constructed with Object{} initializers. Fields absent from the expression are left unchanged on the incoming resource. ApplyConfiguration cannot modify atomic structs, maps, or arrays.

mutations:
- patchType: "ApplyConfiguration"
applyConfiguration:
expression: >
Object{
spec: Object.spec{
initContainers: [
Object.spec.initContainers{
name: "security-proxy",
image: "registry.example.com/security-proxy:v1.0.0",
args: ["proxy", "sidecar"],
restartPolicy: "Always"
}
]
}
}

JSONPatch

The CEL expression evaluates to an array of JSONPatch values. Supports standard operations: add, remove, replace, move, copy, test. Use jsonpatch.escapeKey() for path segments that contain / or ~.

mutations:
- patchType: "JSONPatch"
jsonPatch:
expression: >
[
JSONPatch{
op: "add",
path: "/metadata/labels/" + jsonpatch.escapeKey("security.example.com/classification"),
value: "internal"
}
]

4. Security Use Cases

Inject a Security Sidecar

This policy injects a security proxy initContainer into every Pod that does not already carry one. A matchConditions expression prevents double-injection on re-admission.

MutatingAdmissionPolicy:

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicy
metadata:
name: "sidecar-injection.security.example.com"
spec:
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE"]
resources: ["pods"]
matchConditions:
- name: does-not-already-have-sidecar
expression: "!object.spec.initContainers.exists(ic, ic.name == 'security-proxy')"
failurePolicy: Fail
reinvocationPolicy: IfNeeded
mutations:
- patchType: "ApplyConfiguration"
applyConfiguration:
expression: >
Object{
spec: Object.spec{
initContainers: [
Object.spec.initContainers{
name: "security-proxy",
image: "registry.example.com/security-proxy:v1.0.0",
args: ["proxy", "sidecar"],
restartPolicy: "Always"
}
]
}
}

MutatingAdmissionPolicyBinding:

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicyBinding
metadata:
name: "sidecar-injection-binding"
spec:
policyName: "sidecar-injection.security.example.com"
matchResources:
namespaceSelector:
matchLabels:
sidecar-injection: "enabled"

The binding restricts injection to namespaces carrying the sidecar-injection: "enabled" label, making rollout gradual and auditable. Label each namespace when it is ready to receive the sidecar, rather than enabling the policy cluster-wide at once.

Verify the mutation applies:

kubectl run test-pod --image=nginx -n <labeled-namespace> --dry-run=server -o yaml | \
grep -A5 initContainers

The --dry-run=server flag sends the request through the full admission chain, including active MutatingAdmissionPolicy objects, and returns the mutated object without persisting it.

Enforce Security Classification Labels

This policy uses JSONPatch to add a required classification label to every Pod that is missing one. Labels are used by downstream NetworkPolicy, RBAC rules, and audit log queries that select on workload classification.

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicy
metadata:
name: "enforce-classification.security.example.com"
spec:
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
matchConditions:
- name: missing-classification
expression: >
!has(object.metadata.labels) ||
!('security.example.com/classification' in object.metadata.labels)
failurePolicy: Fail
reinvocationPolicy: IfNeeded
mutations:
- patchType: "JSONPatch"
jsonPatch:
expression: >
[
JSONPatch{
op: "add",
path: "/metadata/labels/" + jsonpatch.escapeKey("security.example.com/classification"),
value: "internal"
}
]
---
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicyBinding
metadata:
name: "enforce-classification-binding"
spec:
policyName: "enforce-classification.security.example.com"
matchResources: {}

An empty matchResources in the binding means the policy applies cluster-wide, to all namespaces.


5. Failure and Reinvocation Policies

failurePolicy

ValueBehavior
FailThe admission request is rejected if the policy cannot be evaluated. Use for security-critical mutations.
IgnoreThe request proceeds even if the policy fails to evaluate. Use only for best-effort mutations that must never block admission.

For any policy intended to enforce a security invariant, set failurePolicy: Fail. A CEL evaluation error with Ignore silently skips the mutation — the object is admitted without it.

reinvocationPolicy

When multiple admission plugins modify the same object in sequence, a later mutation may change a field that an earlier policy already evaluated. reinvocationPolicy: IfNeeded causes the policy to re-run after any subsequent mutation to the same object.

ValueBehavior
IfNeededRe-evaluate the policy if a later admission plugin modifies the object. Ensures the mutation takes effect even when plugin ordering is non-deterministic.
NeverThe policy runs exactly once per request.

Use IfNeeded for sidecar injection and label enforcement, where the presence of the mutation matters regardless of what other policies run after.


6. CEL Variables in Mutation Expressions

VariableDescription
objectThe incoming resource object (null for DELETE requests)
oldObjectThe existing resource object before the operation (null for CREATE requests)
requestAPI request attributes: user, groups, resource kind, verb, namespace
paramsThe parameter resource referenced by the binding's paramRef field
namespaceObjectThe Namespace object for namespaced resources (null for cluster-scoped resources)
variablesMap of pre-computed variables defined in spec.variables for expression reuse
authorizerCEL Authorizer that can perform authorization checks inside the expression

7. RBAC for Policy Management

MutatingAdmissionPolicy and MutatingAdmissionPolicyBinding are cluster-scoped resources in the admissionregistration.k8s.io group. A principal that can write these resources controls which mutations run at admission time across all matching namespaces. Restrict this access explicitly:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: admission-policy-admin
rules:
- apiGroups: ["admissionregistration.k8s.io"]
resources: ["mutatingadmissionpolicies", "mutatingadmissionpolicybindings"]
verbs: ["create", "update", "delete", "patch"]

Audit who holds this ClusterRole:

kubectl get clusterrolebindings -o json | \
jq '.items[] | select(.roleRef.name == "admission-policy-admin") | {binding: .metadata.name, subjects: .subjects}'

Apply the same audit to any ClusterRole that grants * verbs on admissionregistration.k8s.io resources.


8. Auditing Active Policies

List all active policies and their bindings:

kubectl get mutatingadmissionpolicies
kubectl get mutatingadmissionpolicybindings

Inspect the full mutation logic and match conditions for a specific policy:

kubectl describe mutatingadmissionpolicy <policy-name>

Test whether a specific policy's mutations are applied to a new object by using server-side dry-run:

kubectl run verify --image=nginx --dry-run=server -o yaml

The server processes the dry-run request through all active admission plugins, including MutatingAdmissionPolicy objects, and returns the mutated object as it would be stored. This is the authoritative way to confirm a mutation takes effect before deploying the policy to production.



References

This article is based on information from the following official sources:

  1. Mutating Admission Policy - Kubernetes
  2. Kubernetes v1.36 Release - Kubernetes Blog