Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXCI · Least Privilege CI/CDIdentities

The validation versus deployment identity — two identities, two boundaries

Advanced⏱ ~24 mingit

What you'll learn

  • Distinguish the validation identity from the deployment identity
  • Explain why merging them is the most common CI/CD privilege mistake
  • Map pipeline stages to the identity that should hold the privilege
  • Identify the two trust boundaries a least-privilege pipeline draws

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

A pipeline that holds a single cloud identity with both read and write permissions is a pipeline that an attacker can turn into a production incident. The fix is structural: split the cloud identity into two — a validation identity that can only read, and a deployment identity that can only act on the system it owns. The two boundaries are what make the runner safe to expose to untrusted code; the single identity is what turns a fork-PR exploit into a production compromise.

The two identities

flowchart LR
    J["Pipeline job"] --> V["Validation identity\n(read-only)"]
    J --> D["Deployment identity\n(write to declared targets)"]
    V --> S["Read state, list resources,\nplan-only APIs"]
    D --> T["Apply, push, restart,\nnarrow write APIs"]

The validation identity exists for everything the pipeline does before the change is approved: terraform plan, kubectl get, aws s3 ls. It holds read-only permission to the state bucket, the cluster, and the cloud account. The deployment identity exists for the apply step after approval: terraform apply, kubectl apply, helm upgrade. It holds the minimum write permission for the specific target — one S3 prefix, one namespace, one stack.

Why merging them is the antipattern

The single-identity pipeline is the default because it is simpler. One role, one trust policy, one policy document. The cost is paid in the incident: the same identity that runs terraform plan in pull-request validation is the identity that runs terraform apply in production deployment. An attacker who controls a step in the pipeline controls both reads and writes.

flowchart TB
    subgraph BAD["Single identity (antipattern)"]
        B1["plan job"] --> R["read + write role"]
        B2["apply job"] --> R
        B3["fork-PR exploit"] --> R
        R -->|"plan OR apply\nat attacker choice"| X["Production"]
    end
    subgraph GOOD["Two identities"]
        G1["plan job"] --> RV["read-only role"]
        G2["apply job"] --> RW["narrow-write role"]
        G3["fork-PR exploit"] --> RV
        RV -->|"read only"| SAFE["State"]
        RW -->|"write to\ndeclared target"| X
    end

The blast radius of a single identity is the union of read and write. The blast radius of two identities is the smaller of the two, because the exploit path has to reach the write identity, and the write identity is gated by a different trust policy that does not match the fork-PR workflow.

Mapping stages to identities

The pipeline maps stages to identities by what the stage needs to do, not what it could do:

  • Lint and format. No identity.
  • Tests. No identity, or a test identity over fixtures only.
  • Static analysis and policy checks. No identity, or read-only if the checks query cloud state.
  • Plan (terraform plan, helm template, kubectl diff). Validation identity, read-only.
  • Apply (terraform apply, kubectl apply, helm upgrade). Deployment identity, write to declared target.
  • Post-deploy verification. Validation identity, read-only.

The two trust boundaries

Two identities draw two trust boundaries:

  1. The validation boundary. The trust policy matches every workflow on every branch, because validation is safe regardless of who triggered it. A fork-PR job that reads state is not a production risk.
  2. The deployment boundary. The trust policy matches only the production workflow on the production branch, with a job_workflow_ref constraint naming the deploy file. A fork-PR job cannot assume the deployment identity.

Production discipline

  1. One identity per stage class. Validation identities for reads; deployment identities for writes. No identity spans both.
  2. Match the trust policy to the legitimate workflow file. StringEquals on sub and job_workflow_ref. The wildcard is a security incident waiting for an exploit.
  3. Audit the identities quarterly. New workflows and commands expand the blast radius.
  4. Treat identity sprawl as a smell. A pipeline that needs ten identities needs to be split, not given more trust policies.

Cross-course references

  • Part XLIII-04 (OIDC in AWS) covers the trust policy and the four pieces that must agree.
  • Part XLIII-06 (OIDC trust policy deep dive) covers StringEquals versus StringLike and the job_workflow_ref constraint.
  • Part XC-04 (Terraform state access from CI) covers the read-only state pattern for plan jobs.

Quiz

Knowledge check · 4 questions

  1. Q1. Why does a CI/CD pipeline need two cloud identities rather than one?

  2. Q2. A single IAM role that holds both read-only plan permission and apply permission is not necessarily acceptable just because the trust policy gates the workflow file.

  3. Q3. Name the two identities a least-privilege pipeline needs, the permission each should hold, and the trust policy each should match.

  4. Q4. Diagnose the antipattern and prescribe the split.

    Team T's deploy pipeline uses one IAM role, ci-deploy, with AdministratorAccess on the production account. The trust policy allows the role from any branch in the org/* pattern. A fork PR opens with a workflow that reads $AWS_ROLE_ARN from the secrets and calls sts:AssumeRole. The role assumes successfully because the trust policy matches the org/* pattern.

Passing score: 75%. Answers are checked in this browser.