Tier 0 Just-in-Time CI Apply¶
How a Tier 0 project can be applied by CI without CI ever holding standing Tier 0 access. CI's write identity has no roles at all until a PIM-activated senior engineer gives it one role, on one scope, for 30 minutes. Azure takes it away again by itself. Nobody revokes anything.
Live on
t0-azure-management-groups(pilot, proven end to end on 2026-10-30). Every other Tier 0 project is still applied by a person — see Lane A and Lane B.
- Tier 0 Pipeline Design: why a pipeline must never hold Tier 0 access.
- Manage Management Groups: the engineer's guide to this flow.
The problem it solves¶
In Lane A, a person applies Tier 0 changes from their own machine with
./apply.ps1. That keeps the pipeline powerless, but it has costs:
- Only a senior can make the change go live, even when a junior wrote it and it was reviewed.
- The apply runs on a laptop. Terraform version, line endings, local tools and the network all vary.
- The senior holds write access for the whole activation, not just for the minute the apply needs.
Plain CI apply fixes all three but puts standing Tier 0 access on a pipeline identity, which Tier 0 Pipeline Design rules out.
Just-in-time CI apply keeps the rule — no standing Tier 0 access on any pipeline — and still lets CI run the apply.
How it works¶
sequenceDiagram
autonumber
actor Junior as Engineer (any level)
actor Senior as Senior engineer
participant GitLab as GitLab CI
participant PIM as Entra ID PIM
participant Azure as Azure
Junior->>GitLab: Open MR
GitLab->>Azure: plan (read-only identity)
Senior->>GitLab: Review and merge
GitLab->>Azure: main: plan again, save tfplan
Note over GitLab: Pipeline stops at the manual 'apply' job
Senior->>GitLab: Check the saved plan
Senior->>PIM: Activate the project's Tier 0 group (MFA, justification)
Senior->>Azure: ./authorize-apply.ps1<br/>write identity gets one role for 30 min
Junior->>GitLab: Click 'apply'
GitLab->>Azure: Wait until the role is active, then apply tfplan
GitLab->>Azure: Remove any Owner Azure gave it on new groups
Note over Azure: 30 min later, PIM removes the role by itself
Two identities, split by Entra, not by YAML¶
| Identity | Trusts | Holds | Used by |
|---|---|---|---|
Read-only (sp-pla-tf-*-ro, or a legacy name) |
Any branch | Reader at the scopes it plans; Storage Blob Data Reader on its state |
Every job except apply |
Write (sp-pla-tf-t0-*-rw) |
main only |
Nothing standing. Storage Blob Data Contributor on its own state container only |
The manual apply job |
The write identity's federated credential trusts only the main branch of
that one project (by project ID, so renames don't break it). An MR branch
can't sign in as it, whatever the YAML says.
The grant: a PIM assignment with an end time¶
The senior's Tier 0 group holds a conditioned Role Based Access Control
Administrator at the tenant root. The condition lets it do exactly two
things:
- give one role (for management groups:
Management Group Contributor) to one principal (the write identity); - remove that role, or
Owner, from that same principal.
Any other role or principal is refused by Azure itself.
authorize-apply.ps1 uses that right to make a PIM active assignment
(AdminAssign) that expires after 30 minutes (-Minutes 10..60). Because
PIM holds the end time, nothing has to remember to revoke it — not a person,
not a script, not a scheduled job.
The apply job¶
- Waits for the grant. Checks once a minute, 5 times. If the role never shows up it stops without touching anything.
- Applies the saved plan from the same pipeline's
planjob — exactly what was merged and reviewed. If state changed since, Terraform refuses it. - Cleans up after itself, even if the apply failed. Azure gives
Owneron a new management group to whoever creates it. The job removes thatOwnerfrom the write identity, so nothing outlives the 30 minutes.
Why this is still Tier 0 safe¶
| Concern | How it's met |
|---|---|
| No standing privilege (NIST AC-2(6)) | The write identity holds nothing between applies. Each grant is time-boxed by PIM. |
| Named, authorised person (AC-6(5)) | Access is only granted by a named senior with a PIM-activated group (MFA, justification, access reviews). |
| Separation of duties (AC-5) | Writing the change, reviewing it, and letting it go live are separate acts. The junior can't grant; the senior grants only after checking the saved plan. |
| Pipeline can't widen its own access | The grant right lives on the human's group, not the pipeline. Its condition is defined in solution-privileged-access-allowlist, a separate repo. |
| Applies only what was reviewed | Only main can sign in as the write identity, and it applies the plan saved by the same pipeline. |
| No new attack surface | No Function App, Logic App, webhook or stored secret. Only Entra, PIM and the pipeline that already existed. |
What it doesn't stop: during the 30 minutes, any main pipeline job of
that project could use the write identity. Keep the window short, and grant
only when the plan is checked and the apply is about to run.
Where it applies¶
A Tier 0 project can use this pattern when:
- one Azure RBAC role at one scope covers everything its apply does, and
- that role can't grant access to anyone (no
roleAssignments/write).
Management Group Contributor fits: its actions are
Microsoft.Management/managementGroups/* and read. Projects that need Entra
directory roles or Graph write permissions (service principals, Conditional
Access, privileged access grants) don't fit yet, because PIM can't time-box a
Graph application permission. They stay on human apply.
Setting it up for another project¶
- Vend the write identity in
t0-azure-service-principals:oidc_trust = "main_only", noazure_role_assignments,state_rbacon its container. See Create a Service Principal. - Allow the grant in
solution-privileged-access-allowlist: a conditionedRole Based Access Control Administratorentry limited to that role and that identity's object ID. Release, then bump it inazure-privileged-access-grantsand give it to the project's operator group. - Switch the project's ID token subject to
["project_id", "ref_type", "ref"], and give its read-only identity a flexible (any-branch) credential. - Add the CI pieces: per-job
ARM_CLIENT_ID($ARM_CLIENT_ID_RO/$ARM_CLIENT_ID_RW, the RW one protected), a manualapplyjob onmainthat waits for the grant and cleans up, andauthorize-apply.ps1. Copy them fromt0-azure-management-groups. - Prove it with a no-change apply before the first real one.