Vend a Subscription¶
The engineer's guide to creating and changing Azure subscriptions. Use this runbook to:
- Vend a new workload subscription (Lane B, CI applies).
- Vend a new platform subscription (Lane A, you apply under PIM).
- Change an existing subscription: management group, tags, or the solution version it's pinned to.
- Get a vended subscription's outputs to hand to the workload team.
For what vending creates, resource by resource, and why, see Subscription Vending.
Which project?
Workload subscriptions (anything a team deploys an application into)
go in subscription-vending-workload. Platform subscriptions
(connectivity, identity, management) go in
subscription-vending-platform. Both call the same solution module, so
the subscription file looks the same in either. What differs is who
applies it.
The projects¶
| Project | Lane | Holds |
|---|---|---|
subscription-vending-workload |
B | One <name>.tf per workload subscription, placed under mg-landingzones or mg-sandboxes. CI plans on the MR and applies on merge, as sp-pla-tf-subscription-vending-rw. |
subscription-vending-platform |
A | One <name>.tf per platform subscription, placed under mg-connectivity, mg-identity or mg-management. CI only plans. You apply with ./apply.ps1 under pag-t0-platform-subscriptions. |
solution-manage-azure-subscription |
— | The module every subscription file calls. Pinned by ref in each <name>.tf. Bump the pin to pick up a fix or a new input. |
Before writing code, establish these parameters¶
Name: <workload>-<environment>-<region>-<sequence>, for example
payments-prod-westeu-00. Lowercase letters, numbers and hyphens, 3–32
characters. The name becomes the subscription's display name and alias, and
the base for everything else vending creates: sp-sub-tf-<name>-rw,
grp-<name>-*, rg-platform-<name>, the GitLab project and the budget.
It can't be changed later without vending a new subscription.
Management group: where the subscription sits. It decides which policy applies.
| Management group | For | Project |
|---|---|---|
mg-landingzones |
Standard workload subscriptions | workload |
mg-sandboxes |
Short-lived experiments. Not scanned by azure-mcsb-overrides. |
workload |
mg-connectivity |
Networking: hub VNet, DNS | platform |
mg-identity |
Identity services, e.g. VMs running AD DS | platform |
mg-management |
Platform management: monitoring, state, governance tooling | platform |
Environment: dev, test or prod. Written to the Environment tag.
Access: the Entra groups that will hold Contributor, RBAC Administrator and Reader. See Manage Subscription Access. You can leave all three empty and add them later.
Budget: the monthly amount and who gets the alerts. See Manage Subscription Budgets.
Vend a workload subscription¶
📁
subscription-vending-workload: 📃<name>.tf
- Copy
subscription-template.tf.exampleto<name>.tf. Check itsrefmatches the one the other subscription files pin. If it's behind, bump the template in the same MR. - Rename the module label and both outputs to the underscored name.
Set
nameto the hyphenated name. - Fill in
management_group,environment,rbacand the budget.
module "sub_payments_prod_westeu_00" { # underscores: Terraform labels can't hold hyphens
source = "git::https://gitlab.com/grinntec-cloud/terraform-modules/terraform-solutions/solution-manage-azure-subscription.git?ref=v8.4.2"
providers = {
azurerm = azurerm
azapi = azapi
azuread = azuread
gitlab = gitlab
random = random
}
name = "payments-prod-westeu-00"
gitlab_namespace_path = "grinntec-cloud/deployments/workloads"
management_group = "mg-landingzones"
environment = "prod"
tags = {}
rbac = {
contributor = { group_names = ["grp-payments-devs"] }
role-based-access-administrator = { group_names = ["grp-payments-leads"] }
reader = { group_names = [] }
}
budget_amount = 50
budget_alert_emails = ["neil@grinntec.net"]
# Workload state accounts live apart from Tier 0 state.
state_resource_group_name = "rg-terraform-state-workloads-prod"
}
output "payments_prod_westeu_00_gitlab_project_url" {
description = "Web URL of the GitLab deployment project created for payments-prod-westeu-00."
value = module.sub_payments_prod_westeu_00.gitlab_project_url
}
output "payments_prod_westeu_00" {
description = "Bootstrap outputs for payments-prod-westeu-00."
sensitive = true
value = {
gitlab_ci_variables = module.sub_payments_prod_westeu_00.gitlab_ci_variables
backend_config = module.sub_payments_prod_westeu_00.backend_config
tags = module.sub_payments_prod_westeu_00.tags
platform_resource_group_name = module.sub_payments_prod_westeu_00.platform_resource_group_name
}
}
Open an MR. CI posts the plan. For a new subscription expect 50+ adds and no changes or destroys to any other subscription's resources. Check:
- one
azurerm_subscriptionwith the name you expect; - one
azurerm_management_group_subscription_associationto the right management group; azuread_group_memberentries only for the groups you put inrbac.
Merge. CI applies.
Creating the subscription takes 10–30 minutes
MCA subscription creation is slow. The apply job waits up to 60 minutes.
If it times out, check whether the subscription was created, then re-run
the main pipeline. Terraform finds it again through its alias.
Vend a platform subscription¶
📁
subscription-vending-platform: 📃<name>.tf
Same file shape as a workload subscription, with two extra inputs that pin ownership. CI plans as a read-only identity and a person applies, so without them ownership would follow whoever happened to run Terraform.
# ... same inputs as a workload subscription, except:
management_group = "mg-connectivity"
owner_object_ids = local.vended_object_owner_ids # break-glass accounts own the app, SP and grp-*
alias_role_assignments = local.vended_alias_role_assignments # PIM apply group and plan SP get rights on the alias
}
Leave out state_resource_group_name: platform state stays in
rg-terraform-state.
- Open an MR. Check the plan as for a workload subscription.
- Merge. The
mainpipeline saves the plan. Nothing is applied yet. -
From WSL or Linux, activate
pag-t0-platform-subscriptionsin PIM and run the Lane A apply:az account clear az login git checkout main; git pull $env:GITLAB_TOKEN = "glpat-..." # api scope: the apply creates the GitLab project ./apply.ps1Seeding the new GitLab project runs
/bin/shandwget, so on Windowsapply.ps1stops before applying a plan that vends a subscription. Changes to existing subscriptions apply from anywhere. -
Remove the Owner role Azure gave you. Azure makes whoever creates a subscription its Owner. Here that's your own account, not the PIM group, and it's standing: it outlives your activation and isn't in Terraform. With the PIM group still active:
You also need a billing role
Billing roles sit outside PIM. To create a subscription you need
Billing account owner or contributor held standing, as well as
the PIM group. Without it the apply fails with UserNotAuthorized on
the subscription alias.
Hand the subscription to the workload team¶
Vending creates the team's GitLab project under
grinntec-cloud/deployments/workloads/<name>, sets ARM_CLIENT_ID and
ARM_SUBSCRIPTION_ID on it, and commits a starter scaffold once. After
that the team owns those files.
Get the values with the outputs:
terraform output payments_prod_westeu_00_gitlab_project_url
terraform output -json payments_prod_westeu_00
Send the team:
- the GitLab project URL;
- the
rg-platform-<name>resource group name, and that it's platform-owned and delete-locked; - the names of their three
grp-<name>-*groups, and that Contributor and RBAC Administrator need activating in PIM (Manage Subscription Access); - the budget amount.
Change an existing subscription¶
Edit its <name>.tf and go through the same MR → plan → merge (→ apply for
platform) as above.
| Change | What the plan should show |
|---|---|
management_group |
The association is replaced. Nothing else. Policy at the new management group applies within about 30 minutes. |
tags |
In-place update on the subscription and rg-platform-<name>. You can't override the provenance tags or FinOpsId. |
ref bump on the solution |
Read the solution's release notes first. A minor bump should show in-place updates only (at least IaCModuleVersion). Any destroy or replace needs a reason. |
rbac |
Adds or removes azuread_group_member only. |
budget_amount / budget_alert_emails |
In-place update to the budget (and the anomaly alert, for emails). |
name |
Don't. It replaces the subscription and everything vended from it. |
Removing a subscription file is not decommissioning
Deleting <name>.tf destroys everything vending created, including the
deployment identity, the groups and the state account. Follow
Subscription Decommissioning
instead: freeze, move to mg-decommissioned, hold for 30 days, and only
then remove the file.
Check it worked¶
# Exists, and in the right management group
az account management-group subscription show-sub-under-mg \
--name mg-landingzones --subscription <sub-id>
# The three access groups exist
az ad group list --filter "startswith(displayName,'grp-payments-prod-westeu-00')" \
--query "[].displayName" -o tsv
# The deployment identity can sign in: run the new GitLab project's pipeline.
In the portal: the subscription shows the 9 standard tags plus FinOpsId,
Cost Management → Budgets has budget-<name>-monthly, and Defender for
Cloud → Environment settings shows Foundational CSPM On.
When it fails¶
| Symptom | Cause | Fix |
|---|---|---|
Status=401 Code="UserNotAuthorized" on AliasGet |
The identity has no billing role on the invoice section. Workload: sp-pla-tf-subscription-vending-rw needs Azure subscription creator on the invoice section. Platform: you need Billing account owner/contributor, and the plan SP needs Billing account reader. |
Grant it in Cost Management + Billing → Invoice sections → Access control. MCA billing roles don't cascade down from the billing account. |
| 401 on every subscription, including ones you didn't touch | One subscription's alias in state can't be read, so every refresh fails | Check the subscription named in the error is still meant to be there. If its file was removed without a clean destroy, terraform state rm its addresses. |
azurerm_management_group_subscription_association ... already exists |
Azure placed the new subscription in a management group before Terraform did | The workload pipeline imports it automatically. For platform, import it by hand: terraform import 'module.sub_<name>.module.subscription.azurerm_management_group_subscription_association.this[0]' '/providers/Microsoft.Management/managementGroups/<mg>/subscriptions/<sub-id>' |
404 Subscription Not Registered on Microsoft.Security/pricings |
First apply raced the resource provider registration | Re-run the main pipeline (or apply.ps1 after a fresh plan). Registration is idempotent. |
RequestDisallowedByPolicy on the state storage account |
An old state account predates the storage policy and still uses Shared Key | Set state_shared_access_key_enabled = true on that subscription only, until the policy is remediated. |
apply.ps1 stops with a message about /bin/sh |
Platform plan vends a new subscription and you're on Windows | Run it from WSL or Linux. |