skip to content

In a ValidatingAdmissionPolicy, how do you feed an approved-value allowlist to CEL without hard-coding it?

level: middleimportance: should knowfreq 45%

answer

  1. keep the data out of the expression
  2. the policy names a type, the binding names an instance
  3. read it as params in the validation
  4. paramKind plus paramRef
  5. parameterNotFoundAction is your fail-closed switch

basics

~20 s

Declare a paramKind on the policy, point the binding's paramRef at an instance of that kind, and read it in the expression as params. Changing the allowlist then means editing that resource, not the policy.

solid answer

~50 s

The policy declares `spec.paramKind` with the apiVersion and kind of a parameter resource — usually a small custom resource you define, sometimes a ConfigMap. The `ValidatingAdmissionPolicyBinding` then selects a concrete instance with `paramRef`, by name or by label selector, and the expression reads it through the `params` variable. So a rule that confines workloads to approved node classes compares `object.spec.template.spec.nodeSelector['node-class']` against `params.spec.allowedClasses` rather than an inline list. Two things follow. First, the allowlist becomes an ordinary Kubernetes object with its own RBAC, review and audit trail, and adding a node class is a data change rather than a policy edit. Second, one policy can be bound several times with different params — per-tenant or per-environment allowlists sharing a single rule. `paramRef.parameterNotFoundAction` decides what happens when the reference resolves to nothing: `Deny` fails closed, `Allow` fails open.

code

yaml · 26 lines
yaml
# ValidatingAdmissionPolicy
spec:
  paramKind:
    apiVersion: platform.example.com/v1
    kind: NodePlacementPolicy
  matchConstraints:
    resourceRules:
      - apiGroups: ["apps"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["deployments"]
  validations:
    - expression: >-
        !has(object.spec.template.spec.nodeSelector) ||
        !('node-class' in object.spec.template.spec.nodeSelector) ||
        object.spec.template.spec.nodeSelector['node-class'] in params.spec.allowedClasses
      message: "node-class must be one of the approved node classes"
---
# ValidatingAdmissionPolicyBinding
spec:
  policyName: approved-node-classes
  paramRef:
    name: platform-node-classes
    parameterNotFoundAction: Deny
  validationActions: ["Deny"]
...

go deeper

for a junior

Know that the approved values do not have to be typed into the expression: the policy can be pointed at a separate Kubernetes object and read it as params.

for a middle

Be ready to walk the wiring end to end — paramKind on the policy, paramRef on the binding, params in the expression — and to say what happens when the reference resolves to nothing.

for a senior

Show that you treat the parameter object as part of the control: its RBAC, its review path and its fail-closed behaviour are as much of the guardrail as the expression is.

for a principal

Own the split between who may change the rule and who may change the data it compares against, and be able to defend delegating the second without delegating the first.

## The problem with an inline list The naive rule embeds the approved values in the expression itself: `object.spec.template.spec.nodeSelector['node-class'] in ['general', 'memory-optimised']` It works, and it is the wrong shape for anything that changes. Every new node class becomes an edit to a security-owned policy object, reviewed by the security team, with the risk that an expression change also changes matching or cost behaviour. The data and the logic are welded together. ## paramKind and paramRef A `ValidatingAdmissionPolicy` may declare `spec.paramKind`, naming the apiVersion and kind of a *parameter resource*. The policy does not name an instance — it names a type. The `ValidatingAdmissionPolicyBinding` supplies the instance through `spec.paramRef`, either by `name` or by a label `selector`. Inside a validation, the resolved resource is available as `params`, and you address its fields exactly as they are laid out in the resource. Typically the parameter kind is a small CustomResourceDefinition you own — something like a `NodePlacementPolicy` with a `spec.allowedClasses` list — because a CRD gives you a schema, validation and clear RBAC. A ConfigMap works when you want no CRD, at the cost of everything being strings. A few mechanics worth knowing: - **Selector binding fans out.** If `paramRef` uses a label selector that matches several objects, the policy is evaluated once per matched parameter object, and all of them must pass. That is a way to compose several independently-owned allowlists, and also a way to surprise yourself. - **`parameterNotFoundAction`** decides the behaviour when the reference resolves to nothing: `Deny` treats the missing parameter as a failed validation, `Allow` skips the policy. This is the fail-closed/fail-open switch for your data, and it is a security decision, not a default to leave unread. - **Namespaced parameter kinds** let the same policy resolve a different parameter object per namespace, which is how you give each team its own allowlist under one shared rule. - **Binding reuse.** The policy is written once; the bindings decide who it applies to and with which data. Adding a second environment is a new binding plus a new parameter object, not a second policy. ## What this buys, and what it does not What it buys is a clean separation of the rule from the data it compares against, with the data living in the cluster as an object you can review, audit and roll back. It also lets a platform team hand the allowlist to whoever legitimately owns it while keeping the expression itself locked down. What it does not buy is the ability to reach *outside* the cluster. `params` is still a Kubernetes object that something had to write. If your allowed set is genuinely owned by a system outside the cluster, `params` only helps if you also run something that mirrors that system into the parameter resource — and then the decision is being made against a copy, with whatever staleness the mirror has. ## The RBAC consequence people miss Once the allowlist is a resource, write access to that resource is effectively write access to the policy's verdict. Someone who can append to `spec.allowedClasses` can approve a node class without touching anything the security team reviews. Grant that permission as deliberately as you grant permission to edit the `ValidatingAdmissionPolicy` itself, and audit changes to it the same way. ## A note on the example expression The expression below permits a workload that sets no `node-class` selector at all — the presence guards short-circuit to true. That is usually not what you want; requiring the key is a second, separate validation. Splitting "the field must be set" from "the value must be approved" also gives you two distinct denial messages, which is the difference between a developer fixing their manifest in a minute and filing a ticket.

  • What happens if the binding's paramRef matches no object?
    It depends on `parameterNotFoundAction`. `Deny` treats the missing parameter as a validation failure, so the rule fails closed and matching writes are rejected. `Allow` skips the policy entirely, so the guardrail silently disappears. Deleting a parameter object is therefore either an outage or a hole, and which one you get is a choice you make in advance.
  • Would you use a ConfigMap or a custom resource as the parameter kind?
    A custom resource in most cases: it gives the allowlist a schema, its own RBAC verbs, and a shape the expression can read as typed fields rather than parsing strings. A ConfigMap is fine for a throwaway or when you genuinely cannot add a CRD, but everything arrives as strings and nothing validates the contents.
  • How do you give two teams different allowlists under one policy?
    Write the policy once and create two bindings, each with its own `paramRef` and its own `matchResources` scope — for example a namespace selector per team. The rule stays single-sourced and reviewed once; only the data and the scope differ.

saying these in an interview costs you the question

  • Edits the policy expression every time a value is added
  • Thinks paramKind names a specific parameter object
  • Leaves parameterNotFoundAction unconsidered
  • Ignores that write access to the param resource changes the verdict
  • Assumes params can reach data outside the cluster

context