Skip to content

Lane A Apply

How every Lane A (Tier 0) project is applied: CI plans, a person applies the saved plan with ./apply.ps1 under their own PIM-activated role. This page covers the script that's common to all of them. Each project's guide covers what to change; this one covers how the change goes live.

Why Lane A, and why the script

Why a person applies, not CI. Whatever applies a Tier 0 project can change who controls the tenant. If CI applied, the pipeline would hold that power permanently, and anyone who could influence it (a merge, a CI template, a runner) would hold it too. So in Lane A, CI's identity is read-only: it only plans. A named person applies, with PIM, MFA and a justification, and the power lasts only a limited time.

Why the script. A person applying brings a new risk: applying something other than what was reviewed, such as a plan made from local files, an unmerged branch or a stale checkout. apply.ps1 removes it. It applies exactly the plan CI saved on main for the merged commit, and nothing else. Local terraform plan and apply are blocked by ci-only-guard.tf, so the script is the only way to apply.

Full reasoning: Lane A and Lane B.

Which projects use it

Each Lane A project has its own copy of apply.ps1 in its folder, set up for that project's operator group:

Project Activate in PIM
azure-privileged-access-grants Manage Azure Privileged Access Grants (tier 0)
t0-azure-conditional-access Manage Azure Conditional Access (tier 0)
t0-azure-service-principals Manage Azure Service Principals (tier 0)
t0-azure-management-groups Manage Azure Management Groups (tier 0)
azure-entra-diagnostics Manage Azure Entra Diagnostics (tier 0)
subscription-vending-platform Manage Platform Subscriptions (tier 0)

Each project's operator group holds exactly what that project's apply needs, and nothing more. The script checks for that group by its object ID, so renaming the group doesn't break it.

Before you start

  • git and the Azure CLI.
  • GitLab access for downloading the saved plan: $env:GITLAB_TOKEN with read_api + read_repository, a signed-in glab, or the script prompts you for a token.
  • Windows PowerShell 5.1 or PowerShell 7.
  • No Terraform needed. If yours isn't the exact version in versions.tf, the script downloads that version from HashiCorp into .tools/ (gitignored) and checks its checksum.
  • Eligibility for the project's operator group, through its access package in MyAccess.

How to apply

  1. Merge the MR, then wait for the main pipeline to finish. Its plan job saves the plan for the merged commit.
  2. Activate the project's operator group in PIM → My roles → Groups, with a justification. Some changes need a second group as well; the project's guide says when.
  3. Sign in again, so your token carries the activation:

    az account clear; az login
    
  4. Update your checkout from the project folder:

    git checkout main; git pull
    
  5. Run the script:

    ./apply.ps1
    

    It runs its checks, shows the plan, and asks you to type apply. To see the plan without applying, run ./apply.ps1 -WhatIf.

  6. Deactivate your PIM groups, or let them expire.

What it checks

The script stops at the first check that fails and tells you how to fix it. The checks run in this order:

# Check If it fails
1 Your access. The operator group is active for you, and your Azure CLI sign-in happened after the activation Activate in PIM, then az account clear; az login
2 Your checkout. On the default branch, no local changes to tracked files, and level with origin git checkout main; git pull, or discard local changes
3 Where the change came from. The commit came from a merged MR. The script shows the MR Changes only reach main through an MR
4 The saved plan. The latest successful main pipeline is for exactly this commit, and its plan job saved a tfplan Wait for the pipeline, or re-run it
5 Terraform version. Matches the exact required_version in versions.tf Handled automatically (download)
6 Init. terraform init can reach the state backend Your activation gives state access: check it's active and you signed in after

Then it shows the plan and waits for you to type apply.

When the apply fails

The script reads Terraform's output and adds a hint:

You see Meaning Do this
"Saved plan is stale" State changed since CI planned. Nothing was applied Re-run the main pipeline (CI/CD → Pipelines → Run pipeline), wait, run the script again
A 403 (AuthorizationFailed, Authorization_RequestDenied, "Insufficient privileges") Your activation expired, or the change needs a permission the operator group doesn't hold Expired: re-activate, sign in again, re-run the pipeline, run again. Not expired: fix the permission in code, don't elevate further
"Part of the plan applied before the error" Some changes went through. The saved plan can't be reused Fix the cause and merge. The next main pipeline plans only what's left
"Terraform couldn't read the saved plan" CI and your Terraform versions differ TF_VERSION in .gitlab-ci.yml must equal required_version in versions.tf

Saved plans don't keep. CI keeps the plan for 7 days, and it goes stale as soon as state changes. When in doubt, re-run the main pipeline before you start.

Project differences

subscription-vending-platform:

  • Its GitLab token needs api scope, not just read_api, and Maintainer on deployments/workloads. The apply itself creates GitLab projects and CI/CD variables.
  • A plan that vends a new subscription seeds its GitLab project with /bin/sh and wget. Run that apply from WSL or Linux (pwsh); on Windows the script stops before applying such a plan. Plans that only change existing subscriptions run anywhere.
  • It forces core.autocrlf=false and re-fetches modules on every run, so a Windows checkout can't change the rendered templates CI planned.

azure-tenant-bootstrap is the exception: it has no apply.ps1 and uses the older apply-merged command (devops/ps-menu), run from each step's folder. apply-merged does checks 2–5 but doesn't check your PIM activation. Step 002's first-time bootstrap is the one planned local run; its own runbook covers it.

What it doesn't do

  • It isn't a lock. It's the documented way to apply. A person with the same access can still bypass it, for example by setting TF_VAR_running_in_ci locally. Review and audit logs cover that.
  • It doesn't decide what you're allowed to change. That's the operator group's permissions. A 403 means the change needs something the group doesn't hold, and the fix belongs in code.