skip to content

In a Kubernetes AdmissionReview, which of object and oldObject are populated for CREATE, UPDATE and DELETE?

level: middleimportance: should knowfreq 58%

answer

  1. Two slots, rarely both full
  2. Create fills one, delete fills the other
  3. Delete inverts what you expect
  4. Only the stored copy survives a delete
  5. Reading object on a delete reads null

basics

~20 s

On CREATE, object holds the incoming object and oldObject is null. On UPDATE both are populated, new and stored. On DELETE it inverts: object is null and oldObject holds the object about to be removed. userInfo is populated in all three.

solid answer

~40 s

The two slots are rarely both full. `CREATE` fills `object` only; `UPDATE` fills both, which is what makes a diff possible; `DELETE` fills `oldObject` only, because nothing is being submitted. Everything else — `operation`, `resource`, `name`, `namespace`, `userInfo`, `dryRun` — is there throughout, so a delete guard has plenty to decide on: the state of the doomed object and the identity of whoever asked. The trap is a rule that reaches into `object.spec` or `object.metadata` unconditionally. On a `DELETE` that is a dereference of null, and depending on the policy language it either yields no result at all — which is not a denial, so the delete sails through — or raises an evaluation error, at which point the outcome is decided by the webhook's `failurePolicy` rather than by your rule.

code

json · 25 lines
json
{
  "apiVersion": "admission.k8s.io/v1",
  "kind": "AdmissionReview",
  "request": {
    "uid": "8f2c-...",
    "operation": "DELETE",
    "resource": { "group": "", "version": "v1", "resource": "persistentvolumeclaims" },
    "namespace": "payments",
    "name": "ledger-data-0",
    "userInfo": {
      "username": "[email protected]",
      "groups": ["system:authenticated", "platform-oncall"]
    },
    "object": null,
    "oldObject": {
      "apiVersion": "v1",
      "kind": "PersistentVolumeClaim",
      "metadata": {
        "name": "ledger-data-0",
        "annotations": { "backup.example.com/retain-until": "2027-01-31" }
      }
    },
    "dryRun": false
  }
}

go deeper

for a junior

Memorise the matrix and be able to recite it: create fills object, update fills both, delete fills only oldObject. Say plainly that a delete carries no incoming object.

for a middle

Explain why the verbs differ rather than just listing them, and describe what happens to a rule that reads object on a delete — no result, or an evaluation error handed to failurePolicy. Neither is the denial the author intended.

for a senior

Demonstrate that you test each verb against a captured payload instead of assuming a create-shaped test covers the rest, and that you can spot a silently non-firing rule during review rather than after an incident.

for a principal

Own the verification question: how does the platform prove a registered policy is actually deciding, rather than returning nothing on verbs nobody exercised in testing? That is a fleet-wide assurance problem, not a per-rule one.

## The matrix | Operation | `object` | `oldObject` | | --- | --- | --- | | `CREATE` | the incoming object | null | | `UPDATE` | the incoming object | the currently stored object | | `DELETE` | null | the object about to be removed | | `CONNECT` | the connect options object | null | Everything else in the request is populated regardless of the verb: `operation`, `kind` and `resource`, `subResource`, `name`, `namespace`, `userInfo`, `dryRun` and `options`. Read that table as a statement about what each verb *means*. A create submits something that does not exist yet, so there is no predecessor. An update submits a replacement for something that does, so both versions are present and the difference between them is available to you. A delete submits nothing at all — the client sends only options — so the only description of the affected resource is the stored copy, which the API server helpfully includes as `oldObject`. ## Why the delete case bites Most rules are written while thinking about creates. `object.spec.containers[...]`, `object.metadata.labels[...]` — the field paths all start at `object`, and they work fine in testing, because testing is done by applying manifests. Then the same policy is registered for the `DELETE` verb, someone deletes a resource, and nothing happens. What happened is that `object` was null and the rule's body never produced a decision. This is worth being precise about, because it is the single most common cause of a policy that appears installed and enforcing while blocking nothing. In a declarative policy language where a rule body that cannot be satisfied simply yields no result, an undefined body is **not** a denial — absence of a decision means the request is allowed to proceed as far as your rule is concerned. In an expression language that evaluates strictly, dereferencing null instead raises an evaluation error, and then the request's fate is decided by the webhook's `failurePolicy` — `Fail` closes and `Ignore` opens — which is a very different thing from your rule having made a judgment. Either way, the enforcement you believed you had is not the enforcement you have. The fix is to branch on `operation` explicitly, or to select the object you mean: for a delete guard, read `oldObject`; for a create-or-update state check, read `object` and register the policy only for those verbs. ## What a delete guard actually decides on Suppose a `PersistentVolumeClaim` carries a retention annotation recording that its data must be kept until a date, and the requirement is that such claims cannot be deleted casually. The payload gives you what you need: `oldObject.metadata.annotations` holds the retention marker, `oldObject.metadata.labels` and `spec` describe what the claim was, `namespace` and `name` identify it, and `userInfo.username` and `userInfo.groups` say who asked — which is how you carve an exemption for a break-glass identity if you want one. The decision is entirely about the past state and the requester, and the denial can quote the exact annotation and value straight out of the payload, so the person on the other end of `kubectl` knows what stopped them rather than guessing. What you still do not get is history or context: you cannot see whether this claim was already backed up, whether another claim holds the same data, or who last edited the annotation. The payload is one object and one requester. ## Subresources shift what object means One more wrinkle in the same field. When `subResource` is set, `object` is whatever that subresource accepts, not the parent resource. An update to a Deployment's `scale` subresource carries a `Scale` object — essentially a replica count — under `object` and the previous `Scale` under `oldObject`, not the Deployment and its pod template. A rule that expects to find containers there finds nothing. Check `subResource` before assuming the shape of what you were handed. ## Practical checklist - Decide which verbs the rule is registered for, and write only the field paths that exist for those verbs. - Never let a null dereference stand in for a decision; make the absent case explicit. - For deletes, read `oldObject` and `userInfo`. - Test each verb separately against a captured payload rather than assuming a create-shaped test covers them all.

  • On an update to a Deployment's scale subresource, what does object contain?
    A `Scale` object, not the Deployment. The request's `resource` still says deployments and `subResource` says `scale`, but the thing being submitted is the small replica-count representation, so `object` and `oldObject` hold the new and previous `Scale`. A rule reaching for `object.spec.template.spec.containers` finds nothing there — check `subResource` before assuming the shape.
  • If object is null on a DELETE, what can a delete guard actually decide on?
    `oldObject` plus `userInfo`, and the identifying fields. You have the full stored copy of the resource being removed — its labels, annotations and spec — the namespace and name, and the authenticated username and groups of whoever issued the delete. That is enough to allow or deny based on what the object was and who is asking, and enough to name the offending field in the message.
  • Is oldObject ever populated on a CREATE?
    No. There is no previously stored state, so it is null every time. This matters for any rule that compares the two versions: registered for both CREATE and UPDATE, it will hit the null on every create. Branch on `operation`, or handle the absent predecessor as its own explicit case with its own decision.

saying these in an interview costs you the question

  • Thinks a DELETE sends the doomed object under object
  • Writes one diff rule and registers it for CREATE too
  • Assumes a missing field evaluates to false
  • Believes userInfo is absent on DELETE requests
  • Assumes object holds the parent resource on a subresource call

context