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, 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_definition — Grinntec - 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_variable — ARM_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_variable — ARM_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:
| 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
subscription-template.tf.exampletogt-{name}.tfin thesubscription-vendingproject - Find and replace
SUB-NAME(hyphenated) andSUB_NAME(underscored) throughout the file - Set
management_group, therbacblock, and any non-default options - Open a Merge Request — the pipeline posts
terraform planas a comment - Review and merge — the pipeline applies on merge to
main - 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:
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.