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.
  • 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:&lt;owner&gt;/&lt;repo&gt;: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:

repo:<owner>/<repo>:environment:plan
repo:<owner>/<repo>: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 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 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 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.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.
  • 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.