Skip to content

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

  1. Copy subscription-template.tf.example to <name>.tf. Check its ref matches the one the other subscription files pin. If it's behind, bump the template in the same MR.
  2. Rename the module label and both outputs to the underscored name. Set name to the hyphenated name.
  3. Fill in management_group, environment, rbac and 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_subscription with the name you expect;
  • one azurerm_management_group_subscription_association to the right management group;
  • azuread_group_member entries only for the groups you put in rbac.

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.

az account list --all --query "[?name=='payments-prod-westeu-00'].{name:name, id:id}" -o table

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.

  1. Open an MR. Check the plan as for a workload subscription.
  2. Merge. The main pipeline saves the plan. Nothing is applied yet.
  3. From WSL or Linux, activate pag-t0-platform-subscriptions in 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.ps1
    

    Seeding the new GitLab project runs /bin/sh and wget, so on Windows apply.ps1 stops before applying a plan that vends a subscription. Changes to existing subscriptions apply from anywhere.

  4. 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:

    $me = az ad signed-in-user show --query id -o tsv
    az role assignment delete --assignee $me --role Owner --scope /subscriptions/<new-subscription-id>
    

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.