Skip to content

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.


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

  1. Waits for the grant. Checks once a minute, 5 times. If the role never shows up it stops without touching anything.
  2. Applies the saved plan from the same pipeline's plan job — exactly what was merged and reviewed. If state changed since, Terraform refuses it.
  3. Cleans up after itself, even if the apply failed. Azure gives Owner on a new management group to whoever creates it. The job removes that Owner from 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

  1. Vend the write identity in t0-azure-service-principals: oidc_trust = "main_only", no azure_role_assignments, state_rbac on its container. See Create a Service Principal.
  2. Allow the grant in solution-privileged-access-allowlist: a conditioned Role Based Access Control Administrator entry limited to that role and that identity's object ID. Release, then bump it in azure-privileged-access-grants and give it to the project's operator group.
  3. Switch the project's ID token subject to ["project_id", "ref_type", "ref"], and give its read-only identity a flexible (any-branch) credential.
  4. Add the CI pieces: per-job ARM_CLIENT_ID ($ARM_CLIENT_ID_RO / $ARM_CLIENT_ID_RW, the RW one protected), a manual apply job on main that waits for the grant and cleans up, and authorize-apply.ps1. Copy them from t0-azure-management-groups.
  5. Prove it with a no-change apply before the first real one.