Home› Blog› Week 4 — Vending a Subscription Takes Four Minutes…
Azure Weekly Lab Azure Terraform

Week 4 — Vending a Subscription Takes Four Minutes. Getting Rid of One Takes Three Days.

Creating a landing zone is four API calls and finishes while you watch. Deleting one is a waiting period you cannot shorten — and that asymmetry is the part worth designing for.

Subscription Alias API Management Groups Azure Policy Cost Management Azure RBAC Terraform
Azure Platform Engineering Lab · Week 4 of 52

Lab Overview

The bootstrap for this lab created three subscriptions by hand, one at a time, watching each one finish. That is fine for three. It is not fine for an organisation where a team asks for a landing zone on Tuesday and expects to deploy into it on Wednesday, and it is especially not fine as the answer to "which management group is this one in, and who has access?" — because the answer is whatever the person who clicked through the portal remembered to do.

Subscription vending is the pipeline version: a request goes in, and a landing zone comes out created, placed, budgeted and granted. No portal step, no case-by-case judgement, and the same decisions applied to the tenth landing zone as to the first.

What makes it a genuinely interesting week is not the creating. It is that a subscription is the only object in Azure this lab can create and cannot delete — so a vending pipeline has to have an answer for the end of a landing zone's life, and that answer cannot be "destroy it".

Vending is not one operation. It is four, across three different APIs, and only the first creates anything you would call a subscription. The other three are what turn an empty subscription into a landing zone — and the second one is the one pipelines forget.

DifficultyAdvanced — four resources, but they span three APIs and a billing account
Time to completeAbout an hour. Subscription creation is asynchronous and placement is not instant
DeploymentTerraform (azurerm), two stages, state in HCP Terraform, local execution
Measured cost$0.00 — an empty subscription and a budget both cost nothing
Full lab guideLAB.md — prerequisites, variables, commands, expected outputs, validation and cleanup

Read this before running it. This week creates a real Azure subscription, and a subscription cannot be deleted — only cancelled, after which it lingers for a retention window. vend_new_subscription defaults to false for exactly that reason, and the cleanup section is about decommissioning rather than removal.

Cloud Services and Concepts

Subscription vending is handing a team a new subscription that is already governed on arrival — placed in the hierarchy, budgeted, and with the right people holding the right roles — instead of an empty one and a wiki page. Five facts shape how:

  • The Subscription Alias API is the only programmatic way to create a subscription on a Microsoft Customer Agreement. It is tenant-level, slow (minutes, not seconds), and not undoable.
  • A management group is a folder for subscriptions, and it is where governance lives — policy and access granted on one are inherited by everything beneath it. A subscription sits in exactly one.
  • Placement is a separate call. The alias API creates every subscription under the Tenant Root Group and does not move it. A subscription that was never moved inherits none of the policy you think governs it, and looks completely healthy until something is supposed to stop you and doesn't. This is the single most important fact in the week.
  • A Cost Management budget alerts; it does not cap. Spend continues past it. Worth being precise about, because "the landing zone has a budget" is heard as a spending limit by everyone who did not build it.
  • Azure RBAC at subscription scope is what a landing zone owner holds on day one — decided once, for every landing zone, more or less forever.
  • Cancelling is not deleting. A cancelled subscription enters a retention window, stays visible, and cannot be removed on demand — which is why decommission and delete are different words.

Architecture and Workflow

Diagram: subscription vending as four operations across three APIs - create through the Subscription Alias API, place through the Management Groups API as a separate call, then budget and grant - followed by the two stages config and vend, the lifecycle constraint that a cancelled subscription cannot be deleted for three days, and the decommission path of move, prove the deny, then cancel
Vending is four operations across three APIs. Create through the Subscription Alias API, place through Management Groups, grant through RBAC, cap through Cost Management. Nothing does all four, which is why the module market exists.

Four operations, measured on the real thing:

OperationAPIMeasured
1createSubscription Alias2m16s
2placeManagement Groups5s
3budgetConsumption7s
4grantAuthorization (RBAC)45s

All four, in code. The alias call carries a 60-minute timeout because the provider default is shorter than Azure sometimes is:

In main.tf.

The budget and the grant are declared once and applied to every target, so "what does a landing zone get" has one answer that cannot drift between the rehearsal and the real thing:

In main.tf.

Step 2 is the one that gets skipped, and its absence is invisible

A subscription created but never placed is billed correctly, reports healthy, and appears in every list you would look at. It also inherits no policy and no role assignments from the tree it was supposed to land in. Nothing looks wrong until something that should have been stopped is not — which is the worst possible time to find out.

So placement is a separate Terraform resource here rather than an attribute, and the validation checks it from both directions: that the management group lists the subscription, and that the subscription reports the management group as its parent. One query answering correctly is not the same as the association existing.

Azure portal management group tree showing the vended subscription lz-vend-demo under Landing Zones - Dev
Checkpoint: the vended subscription under Landing Zones — Dev. Placement is what makes every policy on that branch apply to it, so this is the step that turns a new subscription into a governed one.

Prerequisites

  • A Microsoft Customer Agreement billing account, plus the billing profile and invoice section the new subscription bills to. All three are required variables with no defaults. They identify your billing arrangement, so they live in terraform.tfvars, which is gitignored, and never in a .tf file.
  • Rights to create subscriptions on that invoice section. This is a billing permission, separate from Entra and from Azure RBAC — being Owner of a tenant does not grant it.
  • User Access Administrator (or Owner), because the lab creates a role assignment at subscription scope.
  • Write access to the management group hierarchy the subscription is placed into. Week 01 builds one; any hierarchy works.
  • Terraform ≥ 1.10 with azurerm ~> 5.2. Microsoft's own Azure/lz-vending/azurerm module cannot be used with it — see Troubleshooting.
  • Two resource providers: Microsoft.Consumption and Microsoft.CostManagement.
  • An individual billing account cannot use the DevTest rate. The alias call refuses it outright, so the workload is Production whether or not that is what you wanted.

Every variable and both stages are in the lab guide, which is the reproducible half of this write-up.

Implementation

0. How any of this gets deployed

All of it is Terraform. State lives in HCP Terraform, the apply runs locally against whatever az login session is on the machine — HCP defaults a new workspace to remote execution, which fails here because there are no Azure credentials stored in the workspace.

terraform {
  cloud {
    organization = "Katta"
    workspaces {
      name    = "azure-week-04-dev"
      project = "Azure Platform Lab"
    }
  }
}

1. Two stages, one payload

./scripts/deploy.sh config    # vend onto an existing subscription — repeatable
./scripts/deploy.sh vend      # create a subscription too — one-way

config is the default, and that default is deliberate: the destructive stage should be the one you have to ask for. It applies steps 2–4 to a subscription that already exists, which is both the safe rehearsal and the realistic case — a mature estate vends onto existing subscriptions far more often than it creates new ones.

The payload is defined once and shared by both stages, so "what does a landing zone get" has a single answer that cannot drift between the rehearsal and the real thing. A $25 monthly budget with both an actual 80% alert and a forecast 100% alert — because an actual alert on a monthly budget arrives when 80% is already spent, and the forecast alert arrives while the month can still be changed.

Azure portal Cost Management budgets blade for the vended subscription showing a 25 dollar monthly budget
The cap, attached at vend time rather than later. A $25 monthly budget, scoped to the vended subscription — created by the same run that created the subscription, which is the point. A budget added when someone gets round to it is the one that is missing when it matters.

Validation after the vend: 7 passed, 0 failed, every check read from Azure rather than from Terraform state — because state is a record of what Terraform believes it did.

Validation and Evidence

A successful apply proves an alias call returned. It does not prove the subscription landed in the right management group, that the budget attached, or that the grant is at the subscription rather than inherited from above — and that last distinction is most of what vending means.

TestExpectedActualStatus
A budget exists on every vended targeta budget on each25.0 on both the existing and the vended subscriptionPASS
The landing-zone grant exists at subscription scopegranted at the subscription, not inherited1 Reader on each, at the subscription itselfPASS
Placement, from the management group's sidethe group lists the subscriptionmg-lz-dev lists itPASS
Placement, from the subscription's own sideits parent is that groupparent = .../managementGroups/mg-lz-devPASS
Deployed state matches configurationno driftnonePASS

Checks 3 and 4 ask the same question from both ends, deliberately. A management group listing a child and a subscription naming its parent are stored separately, and a vending run that half-finishes can satisfy one without the other.

Security and Cost

Security

  • The grant is Reader by default, and the default is the point. Vending decides what a landing zone owner holds on day one, for every landing zone. The interesting failure is not "they could not deploy" — that is a request away — it is a pipeline that hands out Owner on every subscription it creates because Owner was the value in the example.
  • The grant is checked at subscription scope specifically, not with a query that would also match a grant inherited from a management group. An inherited grant is not something vending did.
  • Placement is the security control. Policy inheritance flows from the management group; a subscription in the wrong place is a subscription outside your guardrails, whatever the assignments say.
  • Budgets alert, they do not cap. Stated here because it is routinely misunderstood as a spending limit.

Cost

Nothing in this week has a per-hour price. A subscription costs nothing to exist, and a budget and a role assignment are free. The bill is whatever gets deployed into a landing zone, which is deliberately the next week's problem. $0 while running, $0 after.

Troubleshooting and Lessons

Microsoft's own vending module could not be used at all.

  • Problem: terraform init refused to resolve at all — no available releases match the given constraints ~> 4.0, ~> 5.2.
  • Root cause: Azure/lz-vending/azurerm is the obvious choice: Microsoft's own, 1.4 million downloads, inputs that map exactly onto what vending means. Its role_definitions and cached_data submodules require hashicorp/azurerm ~> 4.0; this lab standardised on ~> 5.2. The constraints have no overlap, and 7.0.3 — newest of its 37 releases — predates azurerm 5. Checked with azurerm at 5.5.0.
  • Resolution: write the four resources directly. Vending is an alias call, a management group association, a role assignment and a budget; the module's value is in everything it adds after that.
  • Lesson learned: a version pin is a compatibility claim about the whole dependency tree, not a preference. Week 03's post was about what a pin does to a consumer; this week opened by being that consumer.

DevTest is not available on an individual billing account

The obvious choice for a lab is the cheaper DevTest rate. The alias call refuses it:

Code="NotAllowed" Message="Can't create DevTest Azure plans for individual
billing account. Please contact Azure Support."   code: "InvalidSku"

The fix is one argument, and it is now a validated variable rather than a literal buried in the resource:

variable "subscription_workload" {
  type    = string
  default = "Production"    # DevTest is refused on an MCA Individual account

  validation {
    condition     = contains(["Production", "DevTest"], var.subscription_workload)
    error_message = "subscription_workload must be Production or DevTest."
  }
}

Nothing in that message names the offending field. The only hint is the word Sku in the error code, and the outer error is StatusCode=0 — the same signature as the client-side timeout this lab saw during bootstrap, so the obvious first guess is "it timed out again" and the obvious first fix is to raise the timeout, which does nothing.

The bootstrap never hit this because it omits workload entirely and takes the Production default. Being more explicit than the working code is what broke it.

A freshly vended subscription is invisible to the CLI

Immediately after vending, every query against the new subscription returned:

Subscription '<guid>' not found. Check the spelling and casing and try again.

Which reads as "vending failed". It had not — Terraform had created a budget and a role assignment inside that subscription and returned their resource IDs. The Azure CLI answers from a locally cached account list built at login, and a subscription created minutes ago is not in it. az account list --refresh fixes it. Three validation checks reported failure against resources that demonstrably existed.

A related one, later: after cancellation the subscription listed as Enabled while ARM refused writes on it as disabled. Trust the write path, not the list.

Cleanup and Key Takeaways

Cleanup — the interesting half

The payload tears down completely: budgets and role assignments are ordinary resources and terraform destroy removes them. Six destroyed.

The subscription does not. This is the constraint the whole week is shaped around:

  • cancelling stops billing immediately
  • the Delete option does not appear for three days
  • Azure removes it automatically only after ninety days

So cleanup for a subscription means decommission, not delete: move it out of the landing zone tree, into the management group whose job is to refuse everything, and cancel it. The move matters more than the cancel — a subscription left in the landing zone tree with "we'll cancel it next week" attached is a subscription somebody deploys into.

The order matters, and I had it wrong

terraform destroy on azurerm_subscription cancels the subscription. It does not merely drop it from state, which is what I had assumed — the cleanup script said so in its own output. The alias destroy took 1m39s, and afterwards ARM refused every write:

(ReadOnlyDisabledSubscription) The subscription ... is disabled and therefore
marked as read only.

Which meant the deny probe that ran next never reached policy evaluation. It was refused for being cancelled, and proved nothing. The explicit cancel then failed too, with NotAllowed — "Subscription is not in active state". Move and probe first; destroy last.

The deny-all, finally proven

The decommissioned management group in this lab has carried an enforcing deny-all since the bootstrap, and it had never denied anything — the scope had no subscription in it, so there was nothing to attempt. It was the one control in the estate asserted rather than demonstrated.

With a subscription in scope, attempting to create a resource group:

(RequestDisallowedByPolicy) Resource 'rg-decomm-probe-001' was disallowed by
policy. Reasons: 'This subscription is decommissioned and is waiting out its
retention window. Nothing may be created in it. If you need these resources,
move the subscription out of the decommissioned management group first.'

The message is the one written into the policy at bootstrap, which is the point: a deny is only useful if the person who hits it is told what to do next.

Azure portal management group tree after decommission, showing the Decommissioned management group now containing the vended subscription
What cleanup actually looks like here. The subscription has moved into Decommissioned, not disappeared — a subscription cannot be deleted. Moving it is what puts it under the enforcing deny-all.

One last detail worth knowing, because no document phrases it this way. A cancelled subscription in its retention window does not report as Disabled. It reports as Warned — Azure's own word for "on its way out but not gone".

References

Key takeaways

  • Vending is four operations, not one, and the second — placement — is the one that gets skipped. Its absence is invisible until a guardrail that should have fired does not.
  • Check placement from both directions. The management group listing the subscription and the subscription reporting its parent are two different questions, and only one of them being right is a bug.
  • Make the irreversible stage opt-in. The default should be the repeatable one. Anything that cannot be undone on the day should require somebody to type it.
  • Design for the end of a landing zone's life, not just the start. You cannot delete a subscription on demand, so "decommissioned" has to be a real state with real enforcement rather than a note in a spreadsheet.
  • A control you have never seen fire is not a control. That deny-all had been correct in every visible property for weeks, and completely untested, because nothing had ever been in scope for it.

Comments

How was your experience?
Your feedback helps improve this site.
PoorExcellent