Git, CI/CD & GitOpsXCI · Least Privilege CI/CDIdentities
The validation versus deployment identity — two identities, two boundaries
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
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:
- 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.
- The deployment boundary. The trust policy matches
only the production workflow on the production
branch, with a
job_workflow_refconstraint naming the deploy file. A fork-PR job cannot assume the deployment identity.
Production discipline
- One identity per stage class. Validation identities for reads; deployment identities for writes. No identity spans both.
- Match the trust policy to the legitimate workflow
file.
StringEqualsonsubandjob_workflow_ref. The wildcard is a security incident waiting for an exploit. - Audit the identities quarterly. New workflows and commands expand the blast radius.
- 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
StringEqualsversusStringLikeand thejob_workflow_refconstraint. - Part XC-04 (Terraform state access from CI) covers the read-only state pattern for plan jobs.
Quiz
Knowledge check · 4 questions
Q1. Why does a CI/CD pipeline need two cloud identities rather than one?
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.
Q3. Name the two identities a least-privilege pipeline needs, the permission each should hold, and the trust policy each should match.
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.