Subscription Vending¶
Subscription vending is the end-to-end process of taking a billing scope and producing a fully configured, pipeline-ready Azure subscription. It is the primary product of the platform team — what workload teams receive when they are ready to build.
- GRINNTEC: Platform Engineering
- GRINNTEC: Management Group Hierarchy
- GRINNTEC: Platform Identity & RBAC
- Azure Subscriptions
- CAF Subscription vending
What a workload team receives¶
When a new subscription is vended, the workload team gets:
- An Azure subscription placed in the correct management group
- A dedicated Terraform state storage account (isolated per subscription)
- A service principal (
sp-terraform-{name}) with OIDC federated credentials — no passwords - Three Entra ID security groups (
grp-{name}-owner/contributor/reader) for human access - A GitLab project pre-configured with
ARM_CLIENT_ID,ARM_SUBSCRIPTION_ID, and the backend config - A monthly budget alert at a configured spend threshold
The workload team does not need to configure authentication, state storage, or RBAC. It is all done.
The vending model¶
Subscription vending is broken into discrete chunks. This table shows what is implemented and where.
| # | Chunk | Status | Where |
|---|---|---|---|
| 1 | Subscription creation | ✅ Done | azure-subscriptions deployment |
| 2 | Management group placement | ✅ Done | azure-subscriptions deployment |
| 3 | IaC identity (OIDC) | ✅ Done | terraform-azurerm-subscription-bootstrap module |
| 4 | State storage | ✅ Done | SA in deployment; container + RBAC in bootstrap module |
| 5 | Pipeline project | ✅ Done | terraform-gitlab-deployment-project module |
| 6 | Human access (RBAC) | ✅ Done | terraform-azurerm-subscription-bootstrap module |
| 7 | Networking (spoke VNet, hub peering) | ❌ Not started | — |
| 8 | Private DNS zone links | ❌ Not started | — |
| 9 | Policy assignments | ❌ Not started | — |
| 10 | Monitoring (activity log export) | ❌ Not started | — |
| 11 | Cost management (budget) | ✅ Done | terraform-azurerm-subscription-budget module |
| 12 | Security baseline (Defender) | ❌ Not started | — |
How a subscription is defined¶
Each subscription has its own .tf file in the azure-subscriptions deployment project. Everything for that subscription — creation, placement, state storage, OIDC identity, RBAC groups, GitLab project, and budget — lives in one file.
# SUBSCRIPTION CONFIGURATION
locals {
gt_payments_prod_westeu = {
name = "gt-payments-prod-westeu"
management_group = "mg-online"
project_path = "grinntec-cloud/terraform-deployments/workloads/gt-payments-prod-westeu"
}
}
# SUBSCRIPTION
resource "azurerm_subscription" "gt_payments_prod_westeu" {
subscription_name = local.gt_payments_prod_westeu.name
billing_scope_id = var.billing_scope_id
}
# MANAGEMENT GROUP PLACEMENT
resource "azurerm_management_group_subscription_association" "gt_payments_prod_westeu" {
management_group_id = "/providers/Microsoft.Management/managementGroups/mg-online"
subscription_id = "/subscriptions/${azurerm_subscription.gt_payments_prod_westeu.subscription_id}"
}
# STATE STORAGE
resource "azurerm_storage_account" "state_gt_payments_prod_westeu" {
name = "sttfstate${random_id.state_gt_payments_prod_westeu.hex}"
resource_group_name = azurerm_resource_group.state.name
...
}
# BOOTSTRAP
module "bootstrap_gt_payments_prod_westeu" {
source = "git::https://...terraform-azurerm-subscription-bootstrap.git?ref=v4.1.0"
subscription_name = local.gt_payments_prod_westeu.name
subscription_id = azurerm_subscription.gt_payments_prod_westeu.subscription_id
state_storage_account_id = azurerm_storage_account.state_gt_payments_prod_westeu.id
gitlab_project_path = local.gt_payments_prod_westeu.project_path
default_branch = "main"
rbac = {
owner = { user_upns = ["alice@grinntec.net"], group_names = [] }
contributor = { user_upns = ["bob@grinntec.net"], group_names = ["grp-payments-engineers"] }
reader = { user_upns = [], group_names = ["grp-finops-readers"] }
}
}
# GITLAB PROJECT
module "gitlab_gt_payments_prod_westeu" {
source = "git::https://...terraform-gitlab-deployment-project.git?ref=v1.0.1"
project_name = local.gt_payments_prod_westeu.name
namespace_path = "grinntec-cloud/terraform-deployments/workloads"
arm_client_id = module.bootstrap_gt_payments_prod_westeu.gitlab_ci_variables["ARM_CLIENT_ID"]
arm_subscription_id = module.bootstrap_gt_payments_prod_westeu.gitlab_ci_variables["ARM_SUBSCRIPTION_ID"]
backend_config = module.bootstrap_gt_payments_prod_westeu.backend_config
create_initial_files = false
}
# BUDGET
module "budget_gt_payments_prod_westeu" {
source = "git::https://...terraform-azurerm-subscription-budget.git?ref=v1.0.0"
subscription_id = azurerm_subscription.gt_payments_prod_westeu.subscription_id
subscription_name = local.gt_payments_prod_westeu.name
amount = 500
start_date = "2026-05-01"
alert_emails = ["neil@grinntec.net"]
}
This single file approach means adding or removing a subscription is a one-file change — no cross-file coordination required.
Subscription naming¶
All Grinntec subscriptions follow this pattern:
| Component | Values | Example |
|---|---|---|
| Prefix | gt |
gt |
| Workload | Short workload name | payments, mkdocs, sandbox |
| Environment | prod, dev, test |
prod |
| Region | Azure region abbreviation | westeu |
| Index | Optional sequence number | 01, 02 |
Examples: gt-payments-prod-westeu-01, gt-mkdocs-prod-westeu, gt-sandbox-dev-westeu-01
Current subscriptions¶
| Subscription | Management Group | Status |
|---|---|---|
gt-platform-prod-westeu-01 |
mg-platform |
Bootstrap subscription — created manually |
gt-connectivity-prod-westeu |
mg-connectivity |
✅ Vended |
gt-mkdocs-prod-westeu |
mg-online |
✅ Vended |
gt-sandbox-dev-westeu-01 |
mg-sandboxes |
✅ Vended |
Adding a new subscription¶
- Copy
gt-mkdocs-prod-westeu.tftogt-{name}.tfin theazure-subscriptionsproject - Find and replace the subscription name (hyphenated and underscored variants) throughout the file
- Update
management_group,project_path, andrbacin the locals and bootstrap block - Open a Merge Request — the pipeline posts
terraform planas a comment - Review and merge — the pipeline applies automatically on merge to
main - Run
terraform output {name}to retrieve the GitLab CI variable values if needed manually
Note
Azure MCA subscription creation can take 10–30 minutes. If the pipeline times out, check whether the subscription was created before retrying:
If it exists, re-trigger the pipeline — Terraform will pick up the existing subscription.What the bootstrap module creates¶
graph TD
SA["State Storage Account<br/>(created in deployment)"]
SA --> CONT["Blob Container<br/>name = subscription-name"]
APP["App Registration<br/>sp-terraform-{name}"]
APP --> SP["Service Principal"]
SP --> FC1["Federated Credential: main branch<br/>(terraform apply)"]
SP --> FC2["Federated Credential: all branches<br/>(terraform plan on MRs)"]
SP --> RA1["Contributor — target subscription"]
SP --> RA2["Storage Blob Data Contributor — state container"]
SP --> RA3["Reader — rg-terraform-state"]
GRP_O["grp-{name}-owner"] --> RA_O["Owner — target subscription"]
GRP_C["grp-{name}-contributor"] --> RA_C["Contributor — target subscription"]
GRP_R["grp-{name}-reader"] --> RA_R["Reader — target subscription"]
The OIDC federated credential model means the pipeline authenticates to Azure using a short-lived JWT issued by GitLab. There are no client secrets. Credentials are scoped to a specific GitLab project path — a stolen token from one project cannot authenticate as another subscription's service principal.
State storage design¶
Each subscription gets its own dedicated storage account in rg-terraform-state on the platform subscription. Isolation is deliberate:
- A corrupted state file in one subscription cannot affect another
- RBAC is scoped to a single blob container — the workload SP cannot read another subscription's state
prevent_destroy = trueon every state storage account — accidental deletion is blocked at the Terraform layershared_access_key_enabled = false— all access is via Entra ID, no storage account keys
The storage account name is sttfstate{8-hex-chars} where the hex is derived from the subscription name via a stable random_id resource. The name will not change unless the keeper value changes.