A CI identity that can only dry-run

RBAC authorizes a server-side dry-run exactly like a real write. One admission policy gives CI full validation against the cluster and no write access.

← Writing

The problem

I want pull-request CI to validate a release against the real cluster: schema, defaulting, and every admission webhook. kubectl apply --dry-run=server does exactly that and stores nothing.

But RBAC authorizes a dry-run like a real write. To dry-run a create, CI needs create. The identity that checks pull requests could also deploy them.

WHAT RBAC SEES
apply --dry-run=serververb: create
RBAC
allowednothing stored
applyverb: create
RBAC
allowedwritten to the cluster
RBAC matches verb and resource. It has no field for dry-run.

The options

OptionWhy not
Render only (helm template, schema lint)Skips admission. My webhook that rejects Deployments without resource requests never runs.
A separate validation clusterA second cluster to keep in sync. It drifts, and then it validates the wrong thing.
An admission webhook that denies CI writesOne more pod in the write path. If it’s down, every write fails.
A ValidatingAdmissionPolicyCEL, evaluated inside the API server. Nothing extra to run.

The decision

I gave CI the deployer’s verbs, then denied every CI request that isn’t a dry-run. RBAC can’t see dryRun. Admission can.

jenkins-ci · DRY-RUN
RBACdeployer verbs ✓
AdmissiondryRun == true ✓
Validatednothing stored
same identity, same verbs
jenkins-ci · REAL WRITE
RBACdeployer verbs ✓
AdmissiondryRun == false ✗
Deniednever reaches etcd
RBAC lets both through. The policy splits them.

The implementation

CI binds to the same deployer Role as the deploy identity (namespace.yaml). The limit is one policy (agent-rbac.yaml):

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: jenkins-ci-dry-run-only
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: ["*"]
        apiVersions: ["*"]
        operations: ["CREATE", "UPDATE", "DELETE"]
        resources: ["*/*"]
  matchConditions:
    - name: jenkins-ci-requests-only
      expression: request.userInfo.username == 'system:serviceaccount:jenkins:jenkins-ci'
  validations:
    - expression: request.dryRun == true
      message: jenkins-ci may only send dry-run requests
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: jenkins-ci-dry-run-only
spec:
  policyName: jenkins-ci-dry-run-only
  validationActions: [Deny]
  • matchConditions limits it to the CI service account.
  • */* covers every resource, including ones added later.
  • UPDATE covers patch too: admission sees a patch as an update.

CI validates a release with a plain server-side dry-run (validate-release.sh):

kubectl apply --dry-run=server -f release.yaml

A webhook only runs on a dry-run if it declares sideEffects: None. Otherwise the API server rejects the dry-run.

The proof

Every make environment-up sends a real write as CI. It fails unless the write is denied, and denied by this policy (environment/bin/up):

if denial="$(kubectl -n "${NAMESPACE}" create configmap delivery-probe \
  --from-literal=probe=1 \
  --as=system:serviceaccount:jenkins:jenkins-ci 2>&1)"; then
  kubectl -n "${NAMESPACE}" delete configmap delivery-probe >/dev/null
  fail 'jenkins-ci can write to the cluster'
fi

if [[ "${denial}" != *jenkins-ci-dry-run-only* ]]; then
  fail "unexpected CI denial: ${denial}"
fi

The second check matters. A write that fails for another reason, like a missing namespace, would otherwise pass as “CI is locked down”.

What’s still open

  • Reads aren’t covered. Admission only sees writes. CI can still get ConfigMaps and read pod logs in the namespaces it validates.
  • The policy keys on a username string. Rename the service account and the policy silently matches nothing. The check above is what catches that.

Takeaways

  • RBAC can’t express “dry-run only”. request.dryRun in admission can.
  • Prefer a ValidatingAdmissionPolicy to a webhook when CEL is enough: nothing extra to run.
  • Webhooks need sideEffects: None to run on dry-runs.
  • Test a denial on every run, and check which rule denied it.

Ask about this post

This post · Assistant

    AI generated — check anything you act on.