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. - Five independent controls cover who asks, whether apply is allowed, whether what applies is what was reviewed, 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 (plan / apply / unlock /<br/>drift-plan / lock-sweep)"]
ENV["Environment<br/>plan or apply"]
TOK["OIDC token<br/>sub: repo:org@id/repo@id: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, using the immutable-id subject form by
default (the name-only form below is the legacy fallback):
repo:org@<org-id>/repo@<repo-id>:environment:plan
repo:org@<org-id>/repo@<repo-id>:environment:apply
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 organization 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, plus
Storage Table Data Contributor at account scope for the cross-PR unit-lock
table (the engine creates the table on first use, so no narrower scope exists
at assignment time). 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"]
D["dispatch (PR caller)"]
R["resolve"]
G["gate"]
DR["drift report"]
TCRED["GitHub App tokens<br/>NO id-token"]
D --- TCRED
R --- TCRED
G --- TCRED
DR --- TCRED
end
subgraph A["Azure-facing jobs: plan / apply environment"]
RUN["plan matrix"]
APP["apply"]
UNL["unlock / lock-sweep"]
DP["drift-plan"]
ACRED["id-token: write<br/>OIDC to Azure"]
RUN --- ACRED
APP --- ACRED
UNL --- ACRED
DP --- ACRED
end
ENGINE["Engine binary + gate scripts<br/>pinned at the engine tag"]
PRCODE["Consumer PR code<br/>in the workspace, as data"]
ENGINE --> T
ENGINE --> A
PRCODE -.->|read only, discovery| T
PRCODE -->|planned / applied| A
Trusted jobs. On a push, the PR caller's dispatch job runs the pinned
dispatch action on a hosted runner: it parses the event, discovers the impacted
units over the PR's files, opens the pending merge gate and the per-unit check
rows with the checks App key, and dispatches the plan. On the comment path the
same trusted work runs in resolve, and gate writes the verdict; drift has
resolve and report. All of them run pinned engine code, never code from the
pull request branch, and none of them requests an id-token. They cannot reach
Azure at all.
Azure-facing jobs. The plan matrix, apply, unlock, drift-plan, and
drift's lock-sweep. They bind the plan or apply environment, request an
id-token, and log in with OIDC (the two lock jobs need it for the lock table
on the state account). The consumer's code sits in the workspace as data to
plan; the engine binary and 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 five controls¶
Five separate controls sit on the flow. Most are called gates, so name them precisely.
1. Command gate: who is asking¶
The gate has two layers. Every command must come from a comment whose
author_association is OWNER, MEMBER, or COLLABORATOR. On top of that,
/apply and /unlock require the commenter's real repository permission to be
write or admin, read back from the API. Anything else gets a 😕 reaction and a
reply saying which permission the command needs and which one the account has.
Association alone 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 organization,
which under read base permissions includes people with no write access here.
COLLABORATOR includes read and triage collaborators.
That is why the state-changing commands do not rely on it. /plan still
does: it reads rather than writes, and requiring write there would stop a read
collaborator asking for a re-plan on their own pull request.
The permission check fails closed. A permission that cannot be read refuses the
command, because "the API did not answer" and "this account has no write access"
are indistinguishable, and treating silence as consent would let the gate vanish
without a signal. The required level is configurable; write is the default.
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
TFPR_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 tfpr verify-head. 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 check run
carrying the review decision, so the requirement is visible from the first plan.
Never make it a required check.
3. Reviewed-plan guard: what applies is what was reviewed¶
Before applying a unit, the engine re-plans it and compares the fresh plan
against the plan summary embedded in the reviewed report. When they differ, the
apply is refused: the unit's check reads "Plan changed since review" and the
merge gate reads "Apply blocked: the plan changed since review". Re-run /plan,
review the new plan, then /apply again. This closes the window between the
review of a plan and the apply of a different one, whether the world moved or
the branch did.
4. Merge gate: can the branch move¶
The engine writes one required check run, tf-pr-ops / merge-gate, under the
org's checks App; the prefix is the brand every engine row carries, matching
the engine repository name nrit-tf-pr-ops. It opens queued when a run takes
the PR and concludes green only when the pull request has no Terraform, is a
no-op, or has been fully applied, and, since v3.4.0, only when every changed
Terraform path belongs to a planned unit (gate_ignore: in projects.yml
names the deliberate exceptions). Red otherwise, including when a unit is
locked by another pull request.
Make it a required 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.
5. Plan gates: policy, security, and cost¶
Three post_plan hooks run on every plan and every drift plan, and not again on
apply, whose plan the reviewed-plan guard has already matched to the gated one:
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. The two reusable-workflow callers (tf-pr-ops.yml and
drift.yml) 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. The two dispatch-action callers are the opposite: they pass only
the named credentials they need as inputs. See
Secrets and variables.
Permissions are a ceiling. A reusable workflow cannot ask for more than the
caller grants. The ops caller declares contents: read, pull-requests: write,
checks: write, id-token: write, actions: read. The drift caller is
different: contents: read, issues: write, id-token: write,
pull-requests: read for the lock sweep. The PR caller holds contents: read,
pull-requests: write, checks: write, actions: write and no id-token; the
unlock caller holds only contents: read and actions: 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 organization. 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 the engine's
secret scrubber before it is posted as a comment. Its rules cover basic-auth
URLs (the x-access-token:...@ form included), GitHub token prefixes, SAS
signatures, and storage connection-string keys.
What a compromised plan runner could reach: the Reader identity at management-group scope, read and write on the state container through Storage Blob Data Contributor, and the unit-lock table through Storage Table Data Contributor at account scope, so it could also forge or release cross-PR unit locks. 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 organization 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 organization audit log.
Rotation¶
OIDC has nothing to rotate. There is no client secret and no certificate behind the Azure login.
Four credentials do have a lifetime:
| Credential | Where it lives | Note |
|---|---|---|
| Engine GitHub App private key | ENGINE_APP_PRIVATE_KEY secret on the customer repository |
Required in reusable mode. Set by the bootstrap from TF_VAR_engine_app_private_key. |
| Checks App private key | TFPR_CHECKS_APP_PRIVATE_KEY secret on the customer repository |
Signs the engine's check rows; part of onboarding, set by the bootstrap from TF_VAR_checks_app_private_key. |
| 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.
/plantrusts organization membership. Under read base permissions, an organization member with no write access on this repository can type/plan. They cannot apply or unlock: since v1.9.2 those read the commenter's real repository permission and require write.- 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. Upload of the breakdown, which would carry the commit sha and the author's name, email, and message, is off, and since engine v1.10.0 the gate sets it off on every run rather than leaving it to a remote Infracost Cloud default. See Cost gate data residency.
- 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.