Skip to content

feat: allow a PodAccessRequest to override the container image - #460

Open
dnguy078 wants to merge 1 commit into
mainfrom
feat/pod-access-request-image-override
Open

feat: allow a PodAccessRequest to override the container image#460
dnguy078 wants to merge 1 commit into
mainfrom
feat/pod-access-request-image-override

Conversation

@dnguy078

@dnguy078 dnguy078 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Problem

The Pod that Oz launches runs the same image as the workload its PodAccessTemplate points at. This allows developers to test various test images with ozctl

Today the only ways around it are for an administrator to author a dedicated PodAccessTemplate, or to hand-write a patchSpecOperations JSON patch against /spec/containers/N/image. Either way a human is in the loop for what is otherwise a self-service operation.

What this does

Adds an optional spec.image to PodAccessRequest and an --image flag to ozctl, so a developer can request a different image against an existing template:

$ ozctl create PodAccessRequest deployment-example \
    --image registry.example.com/team/debug:v1

The template is still required — its accessConfig is the authorization model (who may request what, for how long). Only the image becomes self-service.

Opt-in, controller-scoped allow-list

The image is honored only if it matches an allow-list configured at deploy time via the new controllerManager.manager.allowedImagePatterns Helm value:

controllerManager:
  manager:
    allowedImagePatterns:
      - registry.example.com/team/*

The allow-list lives on the controller, not on a PodAccessTemplate, deliberately: the set of registries an organization will run code from is a cluster-wide security boundary, and letting template authors widen it would defeat the point of having one.

The default is [], which disables the feature entirely — this change is inert until an administrator opts in.

Pattern semantics

* matches within a single path segment; ** matches across segments. This is a security property, not a stylistic one — under naive "* matches everything" globbing, *.dkr.ecr.*.amazonaws.com/team/* would also match:

evil.example.com/x.dkr.ecr.us-west-2.amazonaws.com/team/backdoor

because the leading wildcard would swallow an untrusted registry host. There's a regression test for exactly this.

Enforcement in two places

Where Why
Validating webhook Immediate feedback to the developer at kubectl apply / ozctl time
PodAccessBuilder The only check guaranteed to run — the ValidatingWebhookConfiguration is optional (webhook.create can disable it)

The second check is not redundant. Without it, a cluster running with webhooks disabled would apply an unvalidated image.

Implementation notes

  • spec.image is immutable. The Pod is built once, on first reconcile, so a later edit would silently do nothing while leaving the resource claiming to run an image it does not.
  • The default container is resolved against the unmutated PodSpec, matching what PatchPodTemplateSpec() already does. A template's patchSpecOperations may rename containers, so resolving afterwards can fail to find a container that existed when the template author named it. (A test in this PR caught this — the existing fixture renames its container to oz.) getDefaultContainerID() is extracted into an exported GetDefaultContainerID() to share the logic with callers that have no PodTemplateSpecMutationConfig.
  • Only the image is replaced. The Pod still inherits the target workload's service account, secrets, environment and network identity, so any allow-listed image can run code with that workload's privileges. Called out prominently in the chart values, the CRD field docs and the README.
  • imagePullSecrets are inherited too, so an image from a registry the target workload cannot pull from will simply fail to start — documented rather than worked around.

Testing

  • New internal/imagepolicy package with table-driven tests covering the prefix-smuggling bypass, * vs ** semantics, digests, tag-less refs, over-long input and control characters.
  • Builder tests: allowed override applies (and the template's other mutations still take effect), disallowed override fails with no Pod created, and override rejected when no policy is configured.
  • Webhook tests: reject when disabled, accept when allow-listed, reject outside the allow-list, and immutability on update.
  • go test -race across all non-e2e packages, revive and golangci-lint all clean; API.md, chart README and CRDs regenerated.

Note for reviewers

config/crd/bases/crds.wizardofoz.co_podaccesstemplates.yaml has pre-existing drift from the k8s.io v0.35.4 bump (#457) that make manifests wants to rewrite. I reverted it to keep this diff focused — it's worth a separate regeneration commit.

🤖 Generated with Claude Code

By default the Pod that Oz launches runs the same image as the workload
its PodAccessTemplate points at. That image is often the wrong tool for
the job - a distroless production image has no shell, and a schema
migration may need tooling deliberately kept out of the production
build. Today the only way around it is for an administrator to author a
dedicated PodAccessTemplate (or hand-write a patchSpecOperations JSON
patch against /spec/containers/N/image), which puts a human in the loop
for what is otherwise a self-service operation.

This adds an optional `spec.image` to PodAccessRequest, plus an
`--image` flag to `ozctl create PodAccessRequest`, so a developer can
ask for a different image against an existing template.

The image is only honored if it matches an allow-list configured on the
controller at deploy time (`--allowed-image-patterns`, exposed as the
`controllerManager.manager.allowedImagePatterns` Helm value). The
allow-list lives on the controller rather than on a PodAccessTemplate on
purpose: the set of registries an organization will run code from is a
cluster-wide security boundary, and letting template authors widen it
would defeat the point of having one. With no patterns configured the
feature is off and any `spec.image` is rejected, so this is inert until
an administrator opts in.

Pattern matching confines `*` to a single path segment and offers `**`
for matching across segments. This is what stops a leading wildcard from
swallowing an untrusted registry host: under naive glob semantics
`*.dkr.ecr.*.amazonaws.com/team/*` would also match
`evil.example.com/x.dkr.ecr.us-west-2.amazonaws.com/team/backdoor`.

Enforcement happens in two places. The validating webhook rejects the
request at admission time so the developer gets immediate feedback, and
the PodAccessBuilder re-validates before building the Pod. The second
check is not redundant: the ValidatingWebhookConfiguration is optional
(the chart's `webhook.create` value can disable it), so the builder is
the only check guaranteed to run.

Notes on the implementation:

- `spec.image` is immutable. The Pod is built once, on first reconcile,
  so a later edit would silently do nothing while leaving the resource
  claiming to run an image it does not.
- The override resolves the "default" container against the
  *unmutated* PodSpec, matching what PatchPodTemplateSpec() already
  does. A template's patchSpecOperations may rename containers, so
  resolving afterwards can fail to find a container that existed when
  the template author named it. getDefaultContainerID() is extracted
  into an exported GetDefaultContainerID() to share that logic with
  callers that have no PodTemplateSpecMutationConfig at all.
- Only the image is replaced. The Pod still inherits the target
  workload's service account, secrets, environment and network
  identity, so any allow-listed image can run code with that workload's
  privileges - documented prominently in the chart values and README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation go Pull requests that update Go code repo labels Jul 30, 2026
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 30587074353

Coverage increased (+2.2%) to 38.092%

Details

  • Coverage increased (+2.2%) from the base build.
  • Patch coverage: 44 uncovered changes across 5 files (132 of 176 lines covered, 75.0%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
internal/cmd/manager/main.go 29 0 0.0%
internal/builders/podaccessbuilder/create_access_resources.go 38 31 81.58%
internal/imagepolicy/imagepolicy.go 78 74 94.87%
internal/api/v1alpha1/pod_access_request_webhook.go 22 19 86.36%
internal/cmd/ozctl/cmd/create_pod_access_request.go 3 2 66.67%
Total (6 files) 176 132 75.0%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 3061
Covered Lines: 1166
Line Coverage: 38.09%
Coverage Strength: 2.54 hits per line

💛 - Coveralls

@dnguy078
dnguy078 requested a review from LaikaN57 July 30, 2026 22:35
@dnguy078
dnguy078 marked this pull request as ready for review July 30, 2026 22:35
@dnguy078
dnguy078 requested a review from diranged as a code owner July 30, 2026 22:35
@LaikaN57
LaikaN57 requested a review from stlava July 31, 2026 04:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation go Pull requests that update Go code repo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants