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, tagged to the ADR-0001 standard
  • A dedicated Terraform state storage account (isolated per subscription) with a private state container
  • A service principal (sp-sub-tf-{name}-rw) with a single OIDC federated credential — no passwords
  • Three Entra ID security groups (grp-{name}-owner / -contributor / -reader) for human access
  • A GitLab deployment project pre-configured with ARM_CLIENT_ID, ARM_SUBSCRIPTION_ID, and the backend config
  • A monthly budget with actual and forecast spend alerts
  • Defender for Cloud configured (free posture layer + security contact, plus any paid plans requested)
  • An empty rg-platform-{name} resource group and a baseline set of registered resource providers

The workload team does not configure authentication, state storage, or RBAC. It is all done.


What gets created, resource by resource

Everything below is produced by one call to the solution-manage-azure-subscription module (Tier 2), which composes five pinned modules and adds a few solution-level resources. {name} is the subscription name, e.g. gt-mkdocs-prod-westeu.

Subscription and placement — terraform-azurerm-subscription

Resource Name / identifier What it is and why
azurerm_subscription {name} The MCA subscription itself, created against billing_scope_id. Given a deterministic alias equal to {name} so it can be looked up directly instead of by random GUID. Creation takes 10–30 min.
azurerm_management_group_subscription_association mg-{purpose} Moves the subscription out of the tenant root group into its target management group, so it inherits that group's policy and RBAC. Owned here, not by the bootstrap module.

Terraform state storage — terraform-azurerm-storage-account

Resource Name / identifier What it is and why
azurerm_storage_account sttfstate{8-hex} A storage account dedicated to this subscription's Terraform state, created in the shared rg-terraform-state resource group on the platform subscription. The 8 hex characters are derived from a stable random_id keyed on {name} — the name only changes if {name} changes. Baseline security: TLS 1.2 min, HTTPS only, no public blob access, no cross-tenant replication. Blob versioning on; 30-day delete retention on blobs and containers.

Shared Key auth is disabled by default (shared_access_key_enabled = false); a few pre-policy accounts still run with it enabled until they can be remediated.

Identity, state container, and RBAC — terraform-azurerm-subscription-bootstrap

Resource Name / identifier What it is and why
azurerm_storage_container {name} (in sttfstate…) Private blob container holding terraform.tfstate for the workload deployment. One container per subscription — RBAC is scoped to it.
azuread_application sp-sub-tf-{name}-rw App Registration for the workload deployment pipeline. sp = service principal, sub = a subscription's own deployment identity, tf = Terraform, rw = read/write.
azuread_service_principal (for the app above) The directory principal that role assignments and the federated credential attach to.
azuread_application_federated_identity_credential gitlab-project-path The OIDC trust. Subject is project_path:{gitlab project path} only — deliberately ref-agnostic, so one credential covers both plan on any MR branch and apply on main. Requires the GitLab project's ci_id_token_sub_claim_components = ["project_path"]. No client secret exists.
azurerm_role_assignment — Contributor scope: target subscription Lets Terraform create/modify/delete resources in the subscription. Scoped to this subscription only.
azurerm_role_definitionGrinntec - Lock Contributor scope: target subscription Custom role granting only Microsoft.Authorization/locks/*. Needed because Contributor's NotActions exclude lock writes, and deployments that create management locks (most do, via solution-manage-resource-group) would otherwise fail. Avoids handing the SP Owner / User Access Administrator.
azurerm_role_assignment — Lock Contributor scope: target subscription Assigns the custom role above to the deployment SP.
azurerm_role_assignment — Storage Blob Data Contributor scope: the state container Read/write access to the state blob — Entra ID auth, no storage keys. Scoped to this subscription's container only.
azurerm_role_assignment — Reader scope: rg-terraform-state terraform init reads storage account metadata in the state resource group.
azuread_group ×3 grp-{name}-owner, grp-{name}-contributor, grp-{name}-reader Security groups (mail-disabled) for human access to the subscription.
azurerm_role_assignment ×3 Owner / Contributor / Reader → each group, scope: target subscription Binds each group to its matching built-in role on the subscription.
azuread_group_member (0..n) Adds the users (by UPN) and nested groups (by name) from the rbac block into the three groups. Empty lists make no membership changes.
azurerm_role_assignment (0..n) extra Owner / Contributor / Reader groups Optional additional groups passed via owner_group_ids / contributor_group_ids / reader_group_ids (e.g. a break-glass or central security-reader group).

GitLab deployment project — terraform-gitlab-deployment-project

Resource Name / identifier What it is and why
gitlab_project {name} under terraform-deployments/workloads The private repo the workload team commits Terraform to. Merge (not rebase) method, shared runners on. Sets ci_id_token_sub_claim_components = ["project_path"] so pipeline ID tokens match the federated credential.
gitlab_project_variableARM_CLIENT_ID masked, unprotected The deployment SP's client ID. Unprotected on purpose — the plan job runs on unprotected MR branches and a protected variable would be withheld.
gitlab_project_variableARM_SUBSCRIPTION_ID masked, unprotected The target subscription ID.
gitlab_repository_file ×4 .gitlab-ci.yml, versions.tf, main.tf, README.md Seed commits so the repo is pipeline-ready on day one. Only created when create_initial_files = true; skipped for imported existing projects. The module never updates them after creation — the deployment repo owns its content.

Cost management — terraform-azurerm-subscription-budget

Resource Name / identifier What it is and why
azurerm_consumption_budget_subscription {name} Monthly budget at budget_amount (default 10). Three notifications: 80% actual, 100% actual, and 100% forecast — each emailing budget_alert_emails. start_date is frozen after first apply so month rollovers never force a replacement.

Solution-level resources (added directly by solution-manage-azure-subscription)

Resource Name / identifier What it is and why
random_uuid.finops An immutable cost-allocation UUID. Generated once, never regenerated, prevent_destroy on. Surfaces only as the FinOpsId tag on the subscription and platform RG.
azapi_resource.foundational_cspm FoundationalCspm pricing The free Defender for Cloud posture layer — Secure Score, recommendations, asset inventory, MCSB. On by default.
azapi_resource.security_contact default Defender for Cloud security contact — where high-severity alert and posture notifications go. Subscription Owners are notified too.
azapi_resource.defender_plan (0..n) one per defender_plans entry Paid Defender plans (e.g. StorageAccounts = "on" → Standard tier with the DefenderForStorageV2 sub-plan). Default {} = no paid plans. Removing an entry reverts that plan to Free.
azapi_resource.platform_resource_group rg-platform-{name} An empty resource group inside the vended subscription — a known home for platform-owned bits (diagnostic targets, shared identities) so workloads don't scatter them into ad-hoc RGs. On by default. Tagged with the same ADR-0001 set as the subscription.
azapi_resource_action.register_resource_provider (n) baseline provider list Registers ~20 resource providers (Compute, Network, KeyVault, Insights, ManagedIdentity, …) so the first workload deploy doesn't hit MissingSubscriptionRegistration. Idempotent; removing an entry does not unregister it.
azurerm_pim_eligible_role_assignment (0..n) one per pim_eligible_role_assignments entry Optional JIT-activatable (eligible, not active) roles at subscription scope. Default {} = none. The permanent grp-{name}-{tier} group assignments are unaffected.

Tags applied to the subscription (and rg-platform-{name})

Caller tags < always-on IaC provenance < FinOpsId (last wins). The provenance keys — which a caller cannot override — are:

ManagedBy = terraform, ResourceType = azure-subscription, IaCModule, IaCModuleVersion, IaCRepo, Environment, Workload (derived from {name}), WorkloadOwner, PlatformOwner = platform-team, LastChangedBy, LastChangedDate, FinOpsId.


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 terraform-azurerm-subscription (via the solution)
2 Management group placement ✅ Done terraform-azurerm-subscription (via the solution)
3 IaC identity (OIDC) ✅ Done terraform-azurerm-subscription-bootstrap
4 State storage ✅ Done terraform-azurerm-storage-account (account) + bootstrap (container + RBAC)
5 Pipeline project ✅ Done terraform-gitlab-deployment-project
6 Human access (RBAC) ✅ Done terraform-azurerm-subscription-bootstrap
7 Cost management (budget) ✅ Done terraform-azurerm-subscription-budget
8 Security baseline (Defender for Cloud) ✅ Done solution azapi resources (CSPM, security contact, optional paid plans)
9 Platform resource group ✅ Done solution azapi resource (rg-platform-{name})
10 Resource provider registration ✅ Done solution azapi action
11 Policy assignments ➖ Inherited From the target management group's EPAC assignments — none applied per-subscription
12 Networking (spoke VNet, hub peering) ❌ Not started
13 Private DNS zone links ❌ Not started
14 Monitoring (activity log export) ❌ Not started

How a subscription is defined

Each subscription has its own .tf file in the subscription-vending deployment project (terraform-deployments/azure-platform/subscription-vending). One module call vends the whole thing.

module "sub_gt_mkdocs_prod_westeu" {
  source = "git::https://gitlab.com/grinntec-cloud/terraform-solutions/solution-manage-azure-subscription.git?ref=v4.1.2"

  providers = {
    azurerm = azurerm.iac_state
    azapi   = azapi
    azuread = azuread
    gitlab  = gitlab
    random  = random
  }

  name             = "gt-mkdocs-prod-westeu"
  management_group = "mg-online"
  billing_scope_id = var.billing_scope_id

  environment     = "prod"
  workload_owner  = "platform-team"
  last_changed_by = var.last_changed_by

  tags = {}

  # Optional — shown with their defaults:
  # state_shared_access_key_enabled = false   # true only for a pre-policy state account
  # state_retention_days            = 30
  # budget_amount                   = 10
  # budget_start_date               = null    # null -> first of the current month
  # budget_alert_emails             = ["neil@grinntec.net"]
  # foundational_cspm               = "on"    # free Defender for Cloud posture layer
  # security_contact_email          = "neil@grinntec.net"
  # defender_plans                  = {}      # { StorageAccounts = "on" } to enable a paid plan
  # platform_resource_group_enabled = true    # empty rg-platform-{name}
  # pim_eligible_role_assignments   = {}      # JIT-eligible roles at subscription scope

  rbac = {
    owner       = { user_upns = ["mark.morriss@grinntec.net"], group_names = [] }
    contributor = { user_upns = ["richard.ashcroft@grinntec.net"], group_names = [] }
    reader      = { user_upns = ["tim.burgess@grinntec.net"], group_names = [] }
  }
}

output "gt_mkdocs_prod_westeu" {
  description = "Bootstrap outputs for gt-mkdocs-prod-westeu."
  sensitive   = true
  value = {
    gitlab_ci_variables          = module.sub_gt_mkdocs_prod_westeu.gitlab_ci_variables
    backend_config               = module.sub_gt_mkdocs_prod_westeu.backend_config
    tags                         = module.sub_gt_mkdocs_prod_westeu.tags
    platform_resource_group_name = module.sub_gt_mkdocs_prod_westeu.platform_resource_group_name
  }
}

One file per subscription means adding or removing a subscription is a one-file change — no cross-file coordination.


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 subscription-template.tf.example to gt-{name}.tf in the subscription-vending project
  2. Find and replace SUB-NAME (hyphenated) and SUB_NAME (underscored) throughout the file
  3. Set management_group, the rbac block, and any non-default options
  4. Open a Merge Request — the pipeline posts terraform plan as a comment
  5. Review and merge — the pipeline applies on merge to main
  6. Run terraform output {SUB_NAME} to retrieve the GitLab CI variable and backend 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 via its deterministic alias.


What the bootstrap and solution create

graph TD
    SUB["azurerm_subscription<br/>{name}"] --> MG["Management group association<br/>mg-{purpose}"]

    SA["State Storage Account<br/>sttfstate{8-hex} (in rg-terraform-state)"]
    SA --> CONT["Blob Container<br/>name = {name}"]

    APP["App Registration<br/>sp-sub-tf-{name}-rw"]
    APP --> SP["Service Principal"]
    SP --> FC["Federated Credential: gitlab-project-path<br/>subject = project_path:{path} (plan + apply)"]
    SP --> RA1["Contributor — target subscription"]
    SP --> RA2["Grinntec - Lock Contributor (custom) — target subscription"]
    SP --> RA3["Storage Blob Data Contributor — state container"]
    SP --> RA4["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"]

    GL["GitLab project<br/>terraform-deployments/workloads/{name}"] --> CIV["CI vars: ARM_CLIENT_ID, ARM_SUBSCRIPTION_ID"]

    SOL["Solution extras"] --> BUD["Budget — monthly, 80/100% actual + 100% forecast"]
    SOL --> DEF["Defender for Cloud — CSPM + security contact (+ optional paid plans)"]
    SOL --> PRG["rg-platform-{name} (empty)"]
    SOL --> RPR["Resource provider registration (baseline set)"]
    SOL --> FIN["FinOpsId tag (immutable UUID)"]

The OIDC federated credential model means the pipeline authenticates to Azure using a short-lived JWT issued by GitLab. There are no client secrets. The credential subject is 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
  • Blob versioning is on, with 30-day delete retention on blobs and containers, so a bad write can be rolled back
  • Shared Key auth is disabled by default — access is via Entra ID; a few pre-policy accounts are exceptions until remediated

The storage account name is sttfstate{8-hex-chars}, where the hex is derived from the subscription name via a stable random_id. The name does not change unless the subscription name changes.

The state accounts are protected operationally rather than by a Terraform prevent_destroy guard — a calling module cannot inject lifecycle into a composed module. The shared rg-terraform-state resource group itself does carry prevent_destroy = true.