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 zero write access.

← Writing

The pain point

Pull-request CI should check a release against the real cluster, not just render YAML. kubectl apply --dry-run=server does that: schema validation, defaulting, and every admission webhook, with nothing stored.

The catch: RBAC authorizes a dry-run exactly like a real write. To dry-run a create, CI needs create. So the identity that validates pull requests can 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", so it can't tell these two apart.

The decision

Give CI the deployer’s verbs, then deny every request from CI that isn’t a dry-run. RBAC can’t see dryRun. Admission can: it’s a field on every admission request.

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 admission policy is what splits them.

CI is bound to the same deployer Role as the deploy identity (namespace.yaml). The limit is one ValidatingAdmissionPolicy (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 scopes it to the CI service account. Nobody else is affected.
  • It matches every group and resource, so a new resource type can’t slip past it.
  • It’s CEL, evaluated inside the API server. No webhook pod to go down, and failurePolicy: Fail covers the rest.

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

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

That request goes through the whole admission chain, including the webhook that rejects Deployments without CPU and memory requests. A bad chart fails on the pull request, not at deploy time.

One requirement for that: a webhook only runs on dry-run requests if it declares sideEffects: None. If it doesn’t, the API server rejects the dry-run outright.

Proving it holds

A policy you never test is a policy you assume. Every make environment-up sends a real write as CI, and fails unless the denial comes from 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

Checking the denial’s source matters. A write that fails for another reason, like a missing namespace, would otherwise pass as “CI is locked down”.

Summary

  • RBAC can’t express “dry-run only”. Admission can: request.dryRun.
  • Give CI the verbs it needs to dry-run, and deny everything else in one CEL rule scoped to its service account.
  • Webhooks in the chain need sideEffects: None to run on dry-runs.
  • Test the denial on every run, and check it came from the policy.

Ask about this post

This post · Assistant

    AI generated — check anything you act on.