Skip to content

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.


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:

gt-{workload}-{environment}-{region}[-{index}]
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

  1. Copy gt-mkdocs-prod-westeu.tf to gt-{name}.tf in the azure-subscriptions project
  2. Find and replace the subscription name (hyphenated and underscored variants) throughout the file
  3. Update management_group, project_path, and rbac in the locals and bootstrap block
  4. Open a Merge Request — the pipeline posts terraform plan as a comment
  5. Review and merge — the pipeline applies automatically on merge to main
  6. 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:

az account list --all --query "[?name=='{name}'].{name:name,id:id}" -o table
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 = true on every state storage account — accidental deletion is blocked at the Terraform layer
  • shared_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.