Security model¶
This page is the whole security picture in one place: how jobs authenticate, what runs with which credential, what blocks an apply, and what is left as accepted risk. The detail pages link back here rather than repeating it.
Summary¶
- No cloud secret exists. Azure access is Entra workload-identity OIDC, bound to a GitHub environment. There is nothing to rotate and nothing to leak.
- Two identities, two privilege levels. Plan runs as Reader, apply runs as Owner, both at management-group scope. A plan job cannot reach the apply identity.
- PR code never runs with write credentials. Jobs are split by trust. The jobs
that hold the GitHub App token run pinned engine code and hold no
id-token. - Four independent gates control who asks, whether apply is allowed, whether the branch can merge, and whether the plan passes policy, security, and cost.
- State is Entra-only. Shared access keys are disabled on the storage account, and on the private posture the data plane is default-deny.
Identity and OIDC¶
Azure access uses Entra workload identity federation. A job binds a GitHub environment, GitHub mints an OIDC token whose subject carries the repository and the environment, and Entra matches that subject to a federated credential on a user-assigned managed identity.
flowchart LR
subgraph GH["GitHub Actions"]
JOB["Job (run / apply / drift-plan)"]
ENV["Environment<br/>plan or apply"]
TOK["OIDC token<br/>sub: repo:<owner>/<repo>:environment:<env>"]
JOB --> ENV --> TOK
end
subgraph AAD["Entra ID"]
FC["Federated credential<br/>(one per environment)"]
UAMI_P["UAMI: plan"]
UAMI_A["UAMI: apply"]
TOK --> FC
FC --> UAMI_P
FC --> UAMI_A
end
subgraph AZ["Azure RBAC"]
RD["Reader<br/>at management-group scope"]
OW["Owner<br/>at management-group scope"]
UAMI_P --> RD
UAMI_A --> OW
end
The bootstrap (nrit-alz-bootstrap) creates both identities in identity.tf,
each with one federated credential:
Apply is Owner because the foundation creates role assignments and policy, which Contributor cannot do. Plan is Reader, so a plan job cannot change anything even if the Terraform in the pull request asks it to.
The daily drift sweep runs its drift-plan job in the plan environment, on the
Reader identity. One credential covers plan and drift.
Each environment carries its own AZURE_CLIENT_ID variable pointing at the
matching identity. A repository-level value would hand the plan jobs the Owner
identity, or the apply jobs the Reader one.
A pull_request subject does not work
When a job binds an environment, GitHub puts the environment in the token
subject. A repo:<owner>/<repo>:pull_request federated credential never
matches, and the login fails before any Terraform runs. The same applies to a
renamed environment: the subject embeds the name, so a rename breaks every
Azure job at login.
No secrets to rotate. No client secret or certificate is stored anywhere. The token is minted per job, lives for minutes, and is scoped to one repository and one environment.
Immutable repository ids. GitHub renders the sub claim of newly created
repositories with immutable ids, repo:org@id/repo@id:environment:env. The
bootstrap builds the subjects in that format by default
(oidc_subject_uses_repo_ids), so renaming the organisation or the repository does
not break the trust. It falls back to the legacy name-only form only for
repositories created before that rollout.
The subject binds two claims and no more. The workflow file and the engine tag are deliberately left out, so bumping the engine version needs no re-bootstrap. Entra's flexible federated credential caps its claim-matching expression at 128 characters, which repository plus environment plus workflow ref plus tag exceeds.
State access. The state storage account has shared_access_key_enabled = false,
so there are no account keys to steal. root.hcl sets use_azuread_auth = true
and both identities hold Storage Blob Data Contributor on the container. On the
self_hosted_private posture the account also carries a default-deny network rule
that allows only the runner's egress IP. On the github_hosted posture there is no
network rule, and Entra authentication is the only control.
The trust split¶
The PR-ops engine (nrit-tf-pr-ops) splits jobs by trust. Pull request code must
never run in a job holding a credential it should not have.
flowchart TB
subgraph T["Trusted jobs: hosted runners"]
R["resolve"]
G["gate"]
DR["drift report"]
TCRED["Read-only GitHub App token<br/>NO id-token"]
R --- TCRED
G --- TCRED
DR --- TCRED
end
subgraph A["Azure-facing jobs: plan / apply environment"]
RUN["run (plan)"]
APP["apply"]
DP["drift-plan"]
ACRED["id-token: write<br/>OIDC to Azure"]
RUN --- ACRED
APP --- ACRED
DP --- ACRED
end
ENGINE["Engine scripts pinned at engine_ref<br/>in .tfpr-engine"]
PRCODE["Consumer PR code<br/>in the workspace, as data"]
ENGINE --> T
ENGINE --> A
PRCODE -.->|read only, discovery| R
PRCODE -->|planned / applied| A
Trusted jobs. resolve and gate on the plan and apply path, resolve and
report on drift. They run engine scripts checked out at the pinned engine_ref,
never code from the pull request branch. They hold a read-only GitHub App token,
used to check the private engine repository out, and they never request an
id-token. They cannot reach Azure at all.
Azure-facing jobs. run, apply, and drift-plan. They bind the plan or
apply environment, request an id-token, and log in with OIDC. The consumer's
code sits in the workspace as data to plan; the engine scripts stay pinned in
.tfpr-engine.
On the comment path, resolve checks out the pull request head into the workspace
so that a unit added by the pull request is discoverable. That is a data checkout.
The scripts doing the discovery still come from engine_ref, and resolve holds
no id-token, so nothing is widened by it. A pull request can shape what resolve
finds. It cannot change the code that does the finding.
The same property covers the gate scripts. Hooks in projects.yml call them
through $TFPR_ENGINE_DIR, which points at the pinned engine checkout, so a pull
request cannot edit a gate script to weaken its own gate.
The four gates¶
Four separate controls sit on the flow. They are all called gates, so name them precisely.
1. Command gate: who is asking¶
/plan and /apply are accepted only from a comment whose author_association is
OWNER, MEMBER, or COLLABORATOR. Anything else gets a 😕 reaction and a reply
saying why.
This is not an authorization check
author_association is what GitHub reports on the comment, not the author's
permission on the repository. MEMBER means any member of the organisation,
which under read base permissions includes people with no write access here.
COLLABORATOR includes read and triage collaborators. Treat it as a filter
that keeps drive-by outsiders from driving the engine. The approval gate is
what decides whether an apply may run.
The check exists only on the comment path. pull_request, workflow_dispatch, and
workflow_call runs carry no author_association. See
Who can run a command.
2. Approval gate: is the change approved¶
/apply runs only when GitHub's reviewDecision on the pull request is
APPROVED. REVIEW_REQUIRED and CHANGES_REQUESTED skip the apply and post a
comment explaining why.
An empty reviewDecision means the repository requires no approving reviews at
all, and the engine refuses that too. An empty decision is indistinguishable from a
repository that lost its required-review ruleset, so treating it as consent would
let the gate disappear with no signal. A repository that deliberately requires no
reviews opts back in with the repository variable
TF_PR_OPS_ALLOW_UNREVIEWED_APPLY set to true. Leave it unset everywhere else.
The apply job requires the gate verdict to be exactly true, so a path that never
reached the gate is refused rather than allowed.
Apply is pinned to the approved commit. resolve records the head SHA it gated
on. The apply job checks out that exact SHA, not the moving head ref, and then
re-reads the current head with verify-head.sh. A push landing during environment
approval or runner queueing stops the run instead of applying code nobody approved.
Every run also publishes an informational tf-pr-ops / approval commit status
carrying the review decision, so the requirement is visible from the first plan.
Never make it a required status check.
3. Merge gate: can the branch move¶
The engine sets one commit status, tf-pr-ops / merge-gate. The prefix is the
caller workflow name, which matches the engine repository name nrit-tf-pr-ops. It
is green only when the pull request has no Terraform, is a no-op, or has been fully
applied. Red otherwise.
Make it a required status check on the default branch. That is what produces the apply-before-merge property: the main branch always matches applied infrastructure.
The bootstrap does not require it by default
The require-approved-pr-to-main ruleset carries required status checks only
when the bootstrap's required_status_checks variable is set, and it is empty
by default. Set it to ["tf-pr-ops / merge-gate"] before running the
bootstrap, or add the check by hand afterwards. Check for the requirement
rather than assuming it.
The ruleset also requires an approving pull request review. It gates the merge, not the apply. The engine gates the apply separately on the same review decision, so in practice an unapproved change can neither apply nor merge.
4. Plan gates: policy, security, and cost¶
Three post_plan hooks run on every plan and every drift plan: conftest for custom
Rego policy, checkov for the security baseline, and infracost for the cost delta.
They are report-only by default and their findings are auto-expanded in the run
comment when anything is flagged. See
Policy, security, and cost gates.
Secrets and blast radius¶
secrets: inherit. Both caller workflows pass every secret in the repository to
the reusable workflow, with no per-secret allowlist. That keeps callers stable when
the engine starts using a new secret, and it means the engine sees any secret you
add to the repository for an unrelated reason. The blast radius is exactly the set
of secrets the caller repository holds, so keep unrelated credentials out of a
landing-zone repository. See
Secrets and variables.
Permissions are a ceiling. A reusable workflow cannot ask for more than the
caller grants. The plan and apply caller declares contents: read,
pull-requests: write, checks: write, statuses: write, id-token: write. The
drift caller is narrower: contents: read, issues: write, id-token: write. Each
engine job then requests only the subset it needs, and the trusted jobs request no
id-token at all.
The engine app token is read-only and scrubbed. The GitHub App token exists to
check the private engine repository out and, on Azure jobs, to let Terraform and
Terragrunt fetch private sources in the organisation. Git's insteadOf rewrite
bakes that token into fetch URLs, and Actions masks secrets in job logs but not in
an API request body. So every captured tool output passes through scrub_secrets
in lib.sh before it is posted as a comment, which redacts the
x-access-token:...@ form.
What a compromised plan runner could reach: the Reader identity at management-group scope, and read and write on the state container through Storage Blob Data Contributor. State write access is the real exposure on the plan side, because state locking needs it.
What it could not reach: the apply identity. The OIDC subject is
environment-scoped, so a token minted in the plan environment matches only the
plan federated credential. There is no personal access token in the customer
repository, and no long-lived cloud credential to lift.
Break-glass and bypass¶
The require-approved-pr-to-main ruleset carries one bypass actor:
OrganizationAdmin, with bypass_mode = always. An organisation admin can merge
without an approved pull request. That is the accepted break-glass path, and it is
deliberate: a ruleset with no bypass can wedge a repository with no way back.
Two limits on it are worth stating plainly.
- Bypassing the ruleset bypasses the merge, not the apply. The engine reads
reviewDecisionitself and still refuses/applywithout an approving review. An admin who bypasses to merge has merged code that was never applied, which the daily drift sweep then reports. - The bypass is on the ruleset, so it is visible: rule bypasses appear in the organisation audit log.
Rotation¶
OIDC has nothing to rotate. There is no client secret and no certificate behind the Azure login.
Three credentials do have a lifetime:
| Credential | Where it lives | Note |
|---|---|---|
| Engine GitHub App private key | CATALOG_APP_PRIVATE_KEY secret on the customer repository |
Required in reusable mode. The name is historical. |
| Infracost API key | INFRACOST_API_KEY secret on the customer repository |
You set it; the bootstrap never does. The cost gate skips without it. |
| Bootstrap GitHub PAT | Operator environment only, GITHUB_TOKEN / TF_VAR_github_runners_token |
Never in a tfvars file. Used only when the bootstrap runs. |
For the procedures, see the runbook.
Residual risks and known limits¶
Stated honestly, because a reviewer will find them anyway.
- The command gate trusts organisation membership. Under read base permissions,
an organisation member with no write access on this repository can type
/plan. They cannot apply, because the approval gate reads the review decision, not the commenter. - A member can run plan against pull request code. The plan job runs the
consumer's code, and
projects.ymlhook commands run as shell on the runner in theplanenvironment. A pull request can change those hooks. The exposure is the Reader identity plus state write access, on a runner that is inside the customer network on the private posture. The engine's own scripts are pinned and cannot be edited this way, but the hook list is consumer-owned. - The cost gate calls a hosted API. infracost sends per-resource region and SKU to its pricing API. The Terraform itself does not leave. This is an accepted trade-off; the self-hosted pricing API is behind a paid plan.
- GitHub environments carry no reviewer protection. The bootstrap creates
planandapplywith no protection rules, because GitHub does not offer environment required-reviewers on private repositories on the Team plan. Approval is enforced in two portable places instead: the ruleset on the merge, and the engine's review-decision check on the apply. Do not read "theapplyenvironment" as a second approval prompt; it is not one here. - State is one account for every unit. Units are isolated by blob key, not by separate accounts, and both identities can read and write the whole container.
secrets: inherithas no allowlist. Anything stored on the repository is visible to the engine.