Skip to content

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:&lt;env&gt;"]
    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 reviewDecision itself and still refuses /apply without 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.

  • /plan trusts 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.yml hook commands run as shell on the runner in the plan environment. 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 plan and apply with 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 "the apply environment" 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: inherit has no allowlist. Anything stored on the repository is visible to the engine.