Lane A Apply¶
How every Lane A (Tier 0) project is applied: CI plans, a person applies the saved plan with
./apply.ps1under 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_TOKENwithread_api+read_repository, a signed-inglab, 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¶
- Merge the MR, then wait for the
mainpipeline to finish. Itsplanjob saves the plan for the merged commit. - 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.
-
Sign in again, so your token carries the activation:
-
Update your checkout from the project folder:
-
Run the script:
It runs its checks, shows the plan, and asks you to type
apply. To see the plan without applying, run./apply.ps1 -WhatIf. -
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
apiscope, not justread_api, and Maintainer ondeployments/workloads. The apply itself creates GitLab projects and CI/CD variables. - A plan that vends a new subscription seeds its GitLab project with
/bin/shandwget. 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=falseand 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_cilocally. 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.