diff --git a/.gitignore b/.gitignore index 391eecd9..e98b8dfd 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,8 @@ website/public/assets/*.json # Local terragrunt integration test files — not for committing terragrunt.hcl + +# Local dev scaffolding for reference architectures (provider blocks with secrets, local tfvars, module overrides) — not for committing +**/zz_local_dev* +**/zz_local_dev_override.tf +**/provider_tmp.tf diff --git a/modules/azure/hub-network/backplane/README.md b/modules/azure/hub-network/backplane/README.md new file mode 100644 index 00000000..44427f70 --- /dev/null +++ b/modules/azure/hub-network/backplane/README.md @@ -0,0 +1,62 @@ +# Azure Hub Network — Backplane + +Provisions the automation principal for the **Azure Hub Network** building block: a User-Assigned +Managed Identity (UAMI) federated to meshStack's workload identity federation, plus a custom role +definition and assignment at the connectivity scope that let it build and maintain the central hub. + +## What it provisions + +- **Resource group + UAMI** in the connectivity subscription (`subscription_id`), named after `name`. +- **Federated identity credentials** for the given WIF `subjects` so the building block run can + authenticate as the UAMI without any stored secret. +- **`-deploy` role definition + assignment** at `scope` (a management group or subscription — + typically the platform Connectivity scope), granting management of the hub resource group, the + hub vnet and its subnets, route tables, and the Azure Firewall with its public IPs. + +## Required permissions + +The identity applying this backplane needs, at `scope`, the ability to create custom role +definitions and role assignments (e.g. **Owner** or **User Access Administrator** + role definition +write), and **Managed Identity Contributor** in the connectivity subscription to create the UAMI. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [azurerm](#requirement\_azurerm) | >= 4.36.0 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [azurerm_federated_identity_credential.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/federated_identity_credential) | resource | +| [azurerm_resource_group.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | +| [azurerm_role_assignment.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | +| [azurerm_role_definition.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_definition) | resource | +| [azurerm_user_assigned_identity.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/user_assigned_identity) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [location](#input\_location) | Azure region for the UAMI resource group. | `string` | n/a | yes | +| [name](#input\_name) | Name for the building block identity, resource group and role definition. | `string` | n/a | yes | +| [scope](#input\_scope) | Connectivity scope where the hub network can be deployed (management group or subscription ID). The deploy role definition and assignment are applied here. | `string` | n/a | yes | +| [subscription\_id](#input\_subscription\_id) | Subscription (bare GUID) where the UAMI and its resource group are created. Typically the hub/connectivity subscription so the identity lives in a stable, platform-owned place. | `string` | n/a | yes | +| [workload\_identity\_federation](#input\_workload\_identity\_federation) | WIF issuer and subjects for federated authentication of the automation identity. |
object({
issuer = string
subjects = list(string)
})
| n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [identity](#output\_identity) | The managed identity used as the automation principal for this building block. | +| [role\_definition\_id](#output\_role\_definition\_id) | The ID of the role definition that enables deployment of the hub network to the connectivity scope. | +| [role\_definition\_name](#output\_role\_definition\_name) | The name of the role definition that enables deployment of the hub network to the connectivity scope. | +| [scope](#output\_scope) | The scope where the hub deploy role definition and role assignment are applied. | + \ No newline at end of file diff --git a/modules/azure/hub-network/backplane/main.tf b/modules/azure/hub-network/backplane/main.tf new file mode 100644 index 00000000..1f405a5e --- /dev/null +++ b/modules/azure/hub-network/backplane/main.tf @@ -0,0 +1,61 @@ +resource "azurerm_resource_group" "backplane" { + name = var.name + location = var.location +} + +resource "azurerm_user_assigned_identity" "backplane" { + name = var.name + location = var.location + resource_group_name = azurerm_resource_group.backplane.name +} + +resource "azurerm_federated_identity_credential" "backplane" { + for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s } + + name = "subject-${each.key}" + user_assigned_identity_id = azurerm_user_assigned_identity.backplane.id + audience = ["api://AzureADTokenExchange"] + issuer = var.workload_identity_federation.issuer + subject = each.value +} + +# +# Hub deploy role — grants the automation identity everything it needs to build and +# maintain the central hub in the connectivity scope: the hub resource group, the hub +# vnet and its subnets, the route table, and (optionally) the Azure Firewall with its +# public IPs. +# +resource "azurerm_role_definition" "backplane" { + name = "${var.name}-deploy" + description = "Enables deployment of the ${var.name} hub network building block to the connectivity scope" + scope = var.scope + + permissions { + actions = [ + # Register resource providers in Azure Resource Manager + "*/register/action", + "Microsoft.Resources/subscriptions/providers/read", + + # Hub resource group + "Microsoft.Resources/subscriptions/resourceGroups/read", + "Microsoft.Resources/subscriptions/resourceGroups/write", + "Microsoft.Resources/subscriptions/resourceGroups/delete", + + # Hub virtual network + subnets + peering (spokes peer in from the outside) + "Microsoft.Network/virtualNetworks/*", + "Microsoft.Network/routeTables/*", + + # Azure Firewall + its public IPs + "Microsoft.Network/publicIPAddresses/*", + "Microsoft.Network/publicIPPrefixes/*", + "Microsoft.Network/azureFirewalls/*", + "Microsoft.Network/firewallPolicies/*", + ] + } +} + +resource "azurerm_role_assignment" "backplane" { + scope = var.scope + role_definition_id = azurerm_role_definition.backplane.role_definition_resource_id + principal_id = azurerm_user_assigned_identity.backplane.principal_id +} diff --git a/modules/azure/hub-network/backplane/outputs.tf b/modules/azure/hub-network/backplane/outputs.tf new file mode 100644 index 00000000..6d147332 --- /dev/null +++ b/modules/azure/hub-network/backplane/outputs.tf @@ -0,0 +1,23 @@ +output "identity" { + value = { + client_id = azurerm_user_assigned_identity.backplane.client_id + principal_id = azurerm_user_assigned_identity.backplane.principal_id + tenant_id = azurerm_user_assigned_identity.backplane.tenant_id + } + description = "The managed identity used as the automation principal for this building block." +} + +output "role_definition_id" { + value = azurerm_role_definition.backplane.id + description = "The ID of the role definition that enables deployment of the hub network to the connectivity scope." +} + +output "role_definition_name" { + value = azurerm_role_definition.backplane.name + description = "The name of the role definition that enables deployment of the hub network to the connectivity scope." +} + +output "scope" { + value = var.scope + description = "The scope where the hub deploy role definition and role assignment are applied." +} diff --git a/modules/azure/hub-network/backplane/provider.tf b/modules/azure/hub-network/backplane/provider.tf new file mode 100644 index 00000000..9d11da14 --- /dev/null +++ b/modules/azure/hub-network/backplane/provider.tf @@ -0,0 +1,7 @@ +provider "azurerm" { + features {} + + # The UAMI + its resource group are created in this subscription. The role + # definition/assignment are unaffected — they use their explicit `scope`. + subscription_id = var.subscription_id +} diff --git a/modules/azure/hub-network/backplane/variables.tf b/modules/azure/hub-network/backplane/variables.tf new file mode 100644 index 00000000..08cb53ff --- /dev/null +++ b/modules/azure/hub-network/backplane/variables.tf @@ -0,0 +1,41 @@ +variable "name" { + type = string + nullable = false + description = "Name for the building block identity, resource group and role definition." + validation { + condition = can(regex("^[-a-z0-9]+$", var.name)) + error_message = "Only alphanumeric lowercase characters and dashes are allowed" + } +} + +variable "scope" { + type = string + nullable = false + description = "Connectivity scope where the hub network can be deployed (management group or subscription ID). The deploy role definition and assignment are applied here." +} + +variable "subscription_id" { + type = string + nullable = false + description = "Subscription (bare GUID) where the UAMI and its resource group are created. Typically the hub/connectivity subscription so the identity lives in a stable, platform-owned place." + + validation { + condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.subscription_id)) + error_message = "Must be a bare subscription GUID, not a '/subscriptions/' path." + } +} + +variable "location" { + type = string + nullable = false + description = "Azure region for the UAMI resource group." +} + +variable "workload_identity_federation" { + type = object({ + issuer = string + subjects = list(string) + }) + nullable = false + description = "WIF issuer and subjects for federated authentication of the automation identity." +} diff --git a/modules/azure/hub-network/backplane/versions.tf b/modules/azure/hub-network/backplane/versions.tf new file mode 100644 index 00000000..653c1354 --- /dev/null +++ b/modules/azure/hub-network/backplane/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4.36.0" + } + } +} diff --git a/modules/azure/hub-network/buildingblock/README.md b/modules/azure/hub-network/buildingblock/README.md new file mode 100644 index 00000000..f82dc87b --- /dev/null +++ b/modules/azure/hub-network/buildingblock/README.md @@ -0,0 +1,81 @@ +--- +name: Azure Hub Network +supportedPlatforms: + - azure +description: Provisions the central hub virtual network (resource group, hub vnet, GatewaySubnet and an optional Azure Firewall) that spoke networks peer into. +--- + +This building block provisions the **central hub** of a hub-and-spoke Azure network topology in the +platform's connectivity subscription: a resource group, the hub virtual network, a `GatewaySubnet` +for a future VPN/ExpressRoute gateway, and — optionally — an Azure Firewall with a static public IP +and an egress route table whose default route points at the firewall. + +It is the counterpart to the [`spoke-network`](../../spoke-network) building block: application +teams order a spoke network into their own subscription, which peers into the hub vnet this building +block creates. + +## 🎯 When to use it + +Order this once per connectivity environment (e.g. per hub subscription) to establish the hub that +all spoke networks connect to. It is a platform-team building block, not an application-team one. + +## Shared Responsibilities + +| Responsibility | Platform Team | Application Team | +| -------------- | :-----------: | :--------------: | +| Provision and operate the hub vnet and firewall | ✅ | ❌ | +| Choose the hub address space and firewall SKU | ✅ | ❌ | +| Peer spoke networks into the hub | ✅ | ❌ | +| Order spoke networks and use the connectivity | ❌ | ✅ | + +The user-facing readme is maintained inline in the `readme` field of the +`meshstack_building_block_definition` in +[`../meshstack_integration.tf`](../meshstack_integration.tf). + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.12.0 | +| [azurerm](#requirement\_azurerm) | >= 4.36.0 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [azurerm_firewall.hub](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/firewall) | resource | +| [azurerm_public_ip.firewall](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/public_ip) | resource | +| [azurerm_resource_group.hub](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | +| [azurerm_route_table.egress](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/route_table) | resource | +| [azurerm_subnet.firewall](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/subnet) | resource | +| [azurerm_subnet.gateway](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/subnet) | resource | +| [azurerm_virtual_network.hub](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [address\_space](#input\_address\_space) | Address space of the hub virtual network in CIDR notation, e.g. '10.0.0.0/22'. Must be large enough for the derived AzureFirewallSubnet and GatewaySubnet (a /22 gives four /24s). | `string` | n/a | yes | +| [create\_gateway\_subnet](#input\_create\_gateway\_subnet) | Create a GatewaySubnet for a future VPN/ExpressRoute gateway. | `bool` | `true` | no | +| [deploy\_firewall](#input\_deploy\_firewall) | Deploy an Azure Firewall into the hub, with an AzureFirewallSubnet, a static public IP and an egress route table with a default route pointing at the firewall. | `bool` | `false` | no | +| [firewall\_sku\_tier](#input\_firewall\_sku\_tier) | Azure Firewall SKU tier. Only Standard and Premium are supported (Basic requires a separate management subnet and IP). | `string` | `"Standard"` | no | +| [firewall\_threat\_intel\_mode](#input\_firewall\_threat\_intel\_mode) | Azure Firewall threat intelligence mode: Off, Alert or Deny. | `string` | `"Alert"` | no | +| [hub\_resource\_group\_name](#input\_hub\_resource\_group\_name) | Name of the resource group created in the connectivity subscription to host the hub vnet and firewall. | `string` | `"hub-network"` | no | +| [hub\_vnet\_name](#input\_hub\_vnet\_name) | Name of the central hub virtual network. Used as the basis for the firewall and route table resource names. | `string` | `"hub-vnet"` | no | +| [location](#input\_location) | Azure region where the hub resource group, vnet and firewall are created. | `string` | `"germanywestcentral"` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [firewall\_private\_ip](#output\_firewall\_private\_ip) | Private IP of the Azure Firewall, if deployed. Spokes route egress traffic here. | +| [resource\_group\_name](#output\_resource\_group\_name) | Name of the hub resource group. | +| [summary](#output\_summary) | Markdown summary of the created hub network. | +| [vnet\_id](#output\_vnet\_id) | Azure resource ID of the hub virtual network. | +| [vnet\_name](#output\_vnet\_name) | Name of the hub virtual network. Spoke networks peer into this vnet. | + diff --git a/modules/azure/hub-network/buildingblock/logo.png b/modules/azure/hub-network/buildingblock/logo.png new file mode 100644 index 00000000..83c118d7 Binary files /dev/null and b/modules/azure/hub-network/buildingblock/logo.png differ diff --git a/modules/azure/hub-network/buildingblock/main.tf b/modules/azure/hub-network/buildingblock/main.tf new file mode 100644 index 00000000..bb80fa27 --- /dev/null +++ b/modules/azure/hub-network/buildingblock/main.tf @@ -0,0 +1,78 @@ +resource "azurerm_resource_group" "hub" { + name = var.hub_resource_group_name + location = var.location +} + +resource "azurerm_virtual_network" "hub" { + name = var.hub_vnet_name + location = azurerm_resource_group.hub.location + resource_group_name = azurerm_resource_group.hub.name + address_space = [var.address_space] +} + +# GatewaySubnet — required by Azure for a VPN/ExpressRoute gateway, and a stable anchor even before +# a gateway is deployed. The name must be exactly "GatewaySubnet". +resource "azurerm_subnet" "gateway" { + count = var.create_gateway_subnet ? 1 : 0 + + name = "GatewaySubnet" + resource_group_name = azurerm_resource_group.hub.name + virtual_network_name = azurerm_virtual_network.hub.name + address_prefixes = [cidrsubnet(var.address_space, 2, 1)] +} + +# ── Optional Azure Firewall ── +# When enabled, the hub gets an AzureFirewallSubnet (name is fixed by Azure), a static public IP, +# an Azure Firewall, and a route table with a default route pointing at the firewall so spokes can +# egress through it. + +resource "azurerm_subnet" "firewall" { + count = var.deploy_firewall ? 1 : 0 + + name = "AzureFirewallSubnet" + resource_group_name = azurerm_resource_group.hub.name + virtual_network_name = azurerm_virtual_network.hub.name + address_prefixes = [cidrsubnet(var.address_space, 2, 0)] +} + +resource "azurerm_public_ip" "firewall" { + count = var.deploy_firewall ? 1 : 0 + + name = "${var.hub_vnet_name}-fw-pip" + location = azurerm_resource_group.hub.location + resource_group_name = azurerm_resource_group.hub.name + allocation_method = "Static" + sku = "Standard" +} + +resource "azurerm_firewall" "hub" { + count = var.deploy_firewall ? 1 : 0 + + name = "${var.hub_vnet_name}-fw" + location = azurerm_resource_group.hub.location + resource_group_name = azurerm_resource_group.hub.name + sku_name = "AZFW_VNet" + sku_tier = var.firewall_sku_tier + threat_intel_mode = var.firewall_threat_intel_mode + + ip_configuration { + name = "primary" + subnet_id = azurerm_subnet.firewall[0].id + public_ip_address_id = azurerm_public_ip.firewall[0].id + } +} + +resource "azurerm_route_table" "egress" { + count = var.deploy_firewall ? 1 : 0 + + name = "${var.hub_vnet_name}-egress-rt" + location = azurerm_resource_group.hub.location + resource_group_name = azurerm_resource_group.hub.name + + route { + name = "default-via-firewall" + address_prefix = "0.0.0.0/0" + next_hop_type = "VirtualAppliance" + next_hop_in_ip_address = azurerm_firewall.hub[0].ip_configuration[0].private_ip_address + } +} diff --git a/modules/azure/hub-network/buildingblock/outputs.tf b/modules/azure/hub-network/buildingblock/outputs.tf new file mode 100644 index 00000000..e36eb913 --- /dev/null +++ b/modules/azure/hub-network/buildingblock/outputs.tf @@ -0,0 +1,37 @@ +output "resource_group_name" { + value = azurerm_resource_group.hub.name + description = "Name of the hub resource group." +} + +output "vnet_id" { + value = azurerm_virtual_network.hub.id + description = "Azure resource ID of the hub virtual network." +} + +output "vnet_name" { + value = azurerm_virtual_network.hub.name + description = "Name of the hub virtual network. Spoke networks peer into this vnet." +} + +output "firewall_private_ip" { + value = var.deploy_firewall ? azurerm_firewall.hub[0].ip_configuration[0].private_ip_address : null + description = "Private IP of the Azure Firewall, if deployed. Spokes route egress traffic here." +} + +output "summary" { + description = "Markdown summary of the created hub network." + value = <<-EOT + # Azure Hub Network: **${azurerm_virtual_network.hub.name}** + + | Property | Value | + |----------|-------| + | **Resource Group** | `${azurerm_resource_group.hub.name}` | + | **Hub VNet** | `${azurerm_virtual_network.hub.name}` (`${var.address_space}`) | + | **Firewall** | ${var.deploy_firewall ? "deployed (${var.firewall_sku_tier})" : "not deployed"} | + %{if var.deploy_firewall~} + | **Firewall Private IP** | `${azurerm_firewall.hub[0].ip_configuration[0].private_ip_address}` | + %{endif~} + + Spoke networks peer into `${azurerm_virtual_network.hub.name}`. + EOT +} diff --git a/modules/azure/hub-network/buildingblock/provider.tf b/modules/azure/hub-network/buildingblock/provider.tf new file mode 100644 index 00000000..042d5b7b --- /dev/null +++ b/modules/azure/hub-network/buildingblock/provider.tf @@ -0,0 +1,5 @@ +provider "azurerm" { + features {} + # subscription_id + OIDC/workload-identity auth are supplied via ARM_* environment variables + # (see the STATIC is_environment inputs in ../meshstack_integration.tf). +} diff --git a/modules/azure/hub-network/buildingblock/variables.tf b/modules/azure/hub-network/buildingblock/variables.tf new file mode 100644 index 00000000..04e33061 --- /dev/null +++ b/modules/azure/hub-network/buildingblock/variables.tf @@ -0,0 +1,69 @@ +variable "hub_resource_group_name" { + type = string + nullable = false + default = "hub-network" + description = "Name of the resource group created in the connectivity subscription to host the hub vnet and firewall." +} + +variable "hub_vnet_name" { + type = string + nullable = false + default = "hub-vnet" + description = "Name of the central hub virtual network. Used as the basis for the firewall and route table resource names." +} + +variable "address_space" { + type = string + nullable = false + description = "Address space of the hub virtual network in CIDR notation, e.g. '10.0.0.0/22'. Must be large enough for the derived AzureFirewallSubnet and GatewaySubnet (a /22 gives four /24s)." + + validation { + condition = can(regex("^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$", var.address_space)) + error_message = "Address space must be a valid IPv4 CIDR range, e.g. '10.0.0.0/22'." + } +} + +variable "location" { + type = string + nullable = false + default = "germanywestcentral" + description = "Azure region where the hub resource group, vnet and firewall are created." +} + +variable "create_gateway_subnet" { + type = bool + nullable = false + default = true + description = "Create a GatewaySubnet for a future VPN/ExpressRoute gateway." +} + +variable "deploy_firewall" { + type = bool + nullable = false + default = false + description = "Deploy an Azure Firewall into the hub, with an AzureFirewallSubnet, a static public IP and an egress route table with a default route pointing at the firewall." +} + +variable "firewall_sku_tier" { + type = string + nullable = false + default = "Standard" + description = "Azure Firewall SKU tier. Only Standard and Premium are supported (Basic requires a separate management subnet and IP)." + + validation { + condition = contains(["Standard", "Premium"], var.firewall_sku_tier) + error_message = "firewall_sku_tier must be 'Standard' or 'Premium'." + } +} + +variable "firewall_threat_intel_mode" { + type = string + nullable = false + default = "Alert" + description = "Azure Firewall threat intelligence mode: Off, Alert or Deny." + + validation { + condition = contains(["Off", "Alert", "Deny"], var.firewall_threat_intel_mode) + error_message = "firewall_threat_intel_mode must be 'Off', 'Alert' or 'Deny'." + } +} diff --git a/modules/azure/hub-network/buildingblock/versions.tf b/modules/azure/hub-network/buildingblock/versions.tf new file mode 100644 index 00000000..c3f74a3a --- /dev/null +++ b/modules/azure/hub-network/buildingblock/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.12.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4.36.0" + } + } +} diff --git a/modules/azure/hub-network/meshstack_integration.tf b/modules/azure/hub-network/meshstack_integration.tf new file mode 100644 index 00000000..9a27c7cd --- /dev/null +++ b/modules/azure/hub-network/meshstack_integration.tf @@ -0,0 +1,285 @@ +variable "azure_tenant_id" { + type = string + description = "Azure Entra tenant ID used for the building block's ARM authentication (ARM_TENANT_ID)." +} + +variable "azure_connectivity_subscription_id" { + type = string + description = "Bare GUID of the connectivity subscription where the hub vnet, firewall and the backplane UAMI are created." + validation { + condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.azure_connectivity_subscription_id)) + error_message = "Must be a bare subscription GUID, not a '/subscriptions/' path." + } +} + +variable "azure_scope" { + type = string + description = "RBAC scope where the hub deploy role is granted — a full resource path: a management group ('/providers/Microsoft.Management/managementGroups/') or a subscription ('/subscriptions/'). Typically the platform Connectivity scope." + validation { + condition = can(regex("^/subscriptions/[0-9a-fA-F-]{36}$|^/providers/Microsoft\\.Management/managementGroups/.+$", var.azure_scope)) + error_message = "Must be a full resource path: '/subscriptions/' or '/providers/Microsoft.Management/managementGroups/', not a bare GUID." + } +} + +variable "azure_location" { + type = string + default = "germanywestcentral" + description = "Default Azure region where the hub resource group, vnet and firewall are created." +} + +variable "backplane_name" { + type = string + default = "azure-hub-network" + description = "Name for the backplane resources (identity, resource group, role definition). Must match pattern ^[-a-z0-9]+$." +} + +variable "notification_subscribers" { + type = list(string) + default = [] + description = "List of email addresses to notify on building block lifecycle events." +} + +variable "meshstack" { + type = object({ + owning_workspace_identifier = string + tags = optional(map(list(string)), {}) + }) + description = "Shared meshStack context. Tags are optional and propagated to building block definition metadata." +} + +variable "hub" { + type = object({ + git_ref = optional(string, "main") + bbd_draft = optional(bool, true) + }) + const = true + default = { + git_ref = "main" + bbd_draft = true + } + description = <<-EOT + `git_ref`: Hub release reference. Set to a tag (e.g. 'v1.2.3') or branch or commit sha of the meshstack-hub repo. + `bbd_draft`: If true, the building block definition version is kept in draft mode. + EOT +} + +output "building_block_definition" { + description = "BBD is consumed in building block compositions." + value = { + uuid = meshstack_building_block_definition.this.metadata.uuid + version_ref = var.hub.bbd_draft ? meshstack_building_block_definition.this.version_latest : meshstack_building_block_definition.this.version_latest_release + } +} + +data "meshstack_integrations" "integrations" {} + +module "backplane" { + source = "./backplane" + + name = var.backplane_name + scope = var.azure_scope + location = var.azure_location + subscription_id = var.azure_connectivity_subscription_id + + workload_identity_federation = { + issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer + subjects = [ + "${trimsuffix(data.meshstack_integrations.integrations.workload_identity_federation.replicator.subject, ":replicator")}:workspace.${var.meshstack.owning_workspace_identifier}.buildingblockdefinition.${meshstack_building_block_definition.this.metadata.uuid}" + ] + } +} + +resource "meshstack_building_block_definition" "this" { + metadata = { + owned_by_workspace = var.meshstack.owning_workspace_identifier + tags = var.meshstack.tags + } + + spec = { + display_name = "Azure Hub Network" + description = "Provisions the central hub vnet (resource group, hub vnet, GatewaySubnet and an optional Azure Firewall) that spoke networks peer into." + support_url = "mailto:support@meshcloud.io" + documentation_url = "https://hub.meshcloud.io/platforms/azure/definitions/azure-hub-network" + notification_subscribers = var.notification_subscribers + symbol = "https://raw.githubusercontent.com/meshcloud/meshstack-hub/main/modules/azure/hub-network/buildingblock/logo.png" + target_type = "WORKSPACE_LEVEL" + + readme = chomp(<<-EOT + This building block provisions the **central hub** of a hub-and-spoke Azure network topology: a + resource group, the hub virtual network, a `GatewaySubnet`, and an optional Azure Firewall with + a static public IP and an egress route table. Spoke networks ordered via the **Azure Spoke + Network** building block peer into this hub. + + ## 🎯 When to use it + + Order this once per connectivity environment to establish the hub that all spoke networks + connect to. This is a platform-team building block. + + ## Shared Responsibilities + + | Responsibility | Platform Team | Application Team | + | -------------- | :-----------: | :--------------: | + | Provision and operate the hub vnet and firewall | ✅ | ❌ | + | Choose the hub address space and firewall SKU | ✅ | ❌ | + | Order spoke networks and use the connectivity | ❌ | ✅ | + EOT + ) + } + + version_spec = { + draft = var.hub.bbd_draft + + deletion_mode = "DELETE" + + implementation = { + terraform = { + terraform_version = "1.12.5" + repository_url = "https://github.com/meshcloud/meshstack-hub.git" + repository_path = "modules/azure/hub-network/buildingblock" + ref_name = var.hub.git_ref + use_mesh_http_backend_fallback = true + } + } + + inputs = { + ARM_CLIENT_ID = { + type = "STRING" + display_name = "ARM Client ID" + description = "Client ID of the managed identity used to authenticate with Azure." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(module.backplane.identity.client_id) + } + ARM_TENANT_ID = { + type = "STRING" + display_name = "ARM Tenant ID" + description = "Azure Entra tenant ID for authentication." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(var.azure_tenant_id) + } + ARM_SUBSCRIPTION_ID = { + type = "STRING" + display_name = "ARM Subscription ID" + description = "The connectivity subscription where the hub is created." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(var.azure_connectivity_subscription_id) + } + ARM_USE_OIDC = { + type = "STRING" + display_name = "ARM Use OIDC" + description = "Enables OIDC-based workload identity federation for the Azure provider." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode("true") + } + ARM_OIDC_TOKEN_FILE_PATH = { + type = "STRING" + display_name = "ARM OIDC Token File Path" + description = "Path to the OIDC token file used for workload identity federation authentication." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode("/var/run/secrets/workload-identity/azure/token") + } + hub_resource_group_name = { + type = "STRING" + display_name = "Hub Resource Group" + description = "Name of the resource group created in the connectivity subscription to host the hub vnet and firewall." + assignment_type = "USER_INPUT" + default_value = jsonencode("hub-network") + } + hub_vnet_name = { + type = "STRING" + display_name = "Hub VNet Name" + description = "Name of the central hub virtual network." + assignment_type = "USER_INPUT" + default_value = jsonencode("hub-vnet") + } + address_space = { + type = "STRING" + display_name = "Address Space" + description = "Address space of the hub virtual network in CIDR notation, e.g. '10.0.0.0/22'. Needs room for the derived AzureFirewallSubnet and GatewaySubnet." + assignment_type = "USER_INPUT" + value_validation_regex = "^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$" + validation_regex_error_message = "Address space must be a valid IPv4 CIDR range, e.g. '10.0.0.0/22'." + } + location = { + type = "STRING" + display_name = "Location" + description = "The Azure region where the hub is created." + assignment_type = "STATIC" + argument = jsonencode(var.azure_location) + } + create_gateway_subnet = { + type = "BOOLEAN" + display_name = "Create Gateway Subnet" + description = "Create a GatewaySubnet for a future VPN/ExpressRoute gateway." + assignment_type = "USER_INPUT" + default_value = jsonencode(true) + } + deploy_firewall = { + type = "BOOLEAN" + display_name = "Deploy Firewall" + description = "Deploy an Azure Firewall with a public IP and an egress route table pointing at it." + assignment_type = "USER_INPUT" + default_value = jsonencode(false) + } + firewall_sku_tier = { + type = "STRING" + display_name = "Firewall SKU Tier" + description = "Azure Firewall SKU tier: Standard or Premium." + assignment_type = "USER_INPUT" + default_value = jsonencode("Standard") + } + } + + outputs = { + vnet_id = { + type = "STRING" + display_name = "Hub VNet ID" + description = "The Azure resource ID of the hub virtual network." + assignment_type = "NONE" + } + vnet_name = { + type = "STRING" + display_name = "Hub VNet Name" + description = "The name of the hub virtual network. Spoke networks peer into this vnet." + assignment_type = "NONE" + } + resource_group_name = { + type = "STRING" + display_name = "Hub Resource Group" + description = "The name of the hub resource group." + assignment_type = "NONE" + } + firewall_private_ip = { + type = "STRING" + display_name = "Firewall Private IP" + description = "Private IP of the Azure Firewall, if deployed." + assignment_type = "NONE" + } + summary = { + type = "STRING" + display_name = "Summary" + description = "Markdown summary of the created hub network." + assignment_type = "SUMMARY" + } + } + } +} + +terraform { + required_version = ">= 1.12.0" + + required_providers { + meshstack = { + source = "meshcloud/meshstack" + version = ">= 0.21.0" + } + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4.36" + } + } +} diff --git a/modules/azure/meshstack_integration.tf b/modules/azure/meshstack_integration.tf index 540b168a..9dbd2306 100644 --- a/modules/azure/meshstack_integration.tf +++ b/modules/azure/meshstack_integration.tf @@ -3,19 +3,34 @@ variable "azure_management_group" { description = "Azure management group used for platform integration." } -variable "azure_billing_account_name" { +variable "resource_name_prefix" { type = string - description = "MCA billing account name." + nullable = false + default = "" + description = "Prefix for the created meshStack service principals (replicator/metering/mca) and their custom role definitions, to keep their (tenant-unique) names distinct across deployments. e.g. 'flotest-az-'." } -variable "azure_billing_profile_name" { - type = string - description = "MCA billing profile name." -} - -variable "azure_invoice_section_name" { - type = string - description = "MCA invoice section name." +variable "azure_subscription_provisioning" { + type = object({ + pre_provisioned = optional(object({ + unused_subscription_name_prefix = optional(string, "unused-") + })) + customer_agreement = optional(object({ + billing_account_name = string + billing_profile_name = string + invoice_section_name = string + })) + }) + nullable = false + description = <<-EOT + Azure subscription provisioning model — set exactly one: + `pre_provisioned`: meshStack assigns subscriptions from a pool of existing ones whose name starts with `unused_subscription_name_prefix` (no MCA service principal is created). + `customer_agreement`: meshStack creates subscriptions on demand via the given MCA billing scope. + EOT + validation { + condition = (var.azure_subscription_provisioning.pre_provisioned != null) != (var.azure_subscription_provisioning.customer_agreement != null) + error_message = "Set exactly one of pre_provisioned or customer_agreement." + } } variable "azure_subscription_owner_object_ids" { @@ -24,13 +39,78 @@ variable "azure_subscription_owner_object_ids" { description = "Optional explicit subscription owner object IDs. If null, current principal is used." } +variable "landing_zones" { + type = map(object({ + management_group_id = string + display_name = string + description = optional(string, "") + mandatory_building_block_definition_uuids = optional(list(string), []) + })) + nullable = false + description = <<-EOT + Landing zones to create on the Azure platform, keyed by archetype (e.g. `corp`, `online`, `sandbox`). + Each entry points a meshStack landing zone at an existing Azure management group via `management_group_id`. + Landing zones inherit the platform-level role mappings. The landing zone name is `-`. + `mandatory_building_block_definition_uuids`: building block definitions every tenant in this landing + zone must have — meshStack requires ordering them (e.g. a spoke network on Corp). Defaults to none. + EOT +} + variable "meshstack" { type = object({ owning_workspace_identifier = string platform_name = optional(string, "azure") location_name = optional(string, "global") + tags = optional(map(list(string)), {}) }) - description = "meshStack ownership and naming settings for this platform integration." + description = "meshStack ownership and naming settings for this platform integration. `tags` are propagated to the created landing zones." +} + +variable "hub" { + type = object({ + git_ref = optional(string, "feature/azure-platfrom-ref") + bbd_draft = optional(bool, true) + }) + const = true + default = { + git_ref = "feature/azure-platfrom-ref" + bbd_draft = true + } + description = <<-EOT + `git_ref`: Hub release reference. Set to a tag (e.g. 'v1.2.3') or branch or commit sha of the meshstack-hub repo. + `bbd_draft`: If true, building block definitions sourced from this integration are kept in draft mode. + EOT +} + +output "platform" { + description = "The meshStack platform Azure subscriptions are created on. Use `uuid` as the `platform_ref` of a meshTenant." + value = { + uuid = meshstack_platform.azure.metadata.uuid + name = meshstack_platform.azure.metadata.name + } +} + +output "platform_ref" { + description = "Reference to the meshPlatform this integration creates, for compositions that create meshTenants on it." + value = { + uuid = meshstack_platform.azure.metadata.uuid + kind = "meshPlatform" + } +} + +output "landingzone_names" { + description = "meshStack landing zone names created per archetype, keyed by archetype." + value = { for key, lz in meshstack_landingzone.this : key => lz.metadata.name } +} + +output "landingzone_refs" { + description = "References to the created landing zones, keyed by archetype, for compositions that create meshTenants on them." + value = { + for key, lz in meshstack_landingzone.this : key => { + name = lz.metadata.name + kind = "meshLandingZone" + } + } } data "meshstack_integrations" "integrations" {} @@ -49,31 +129,39 @@ data "azurerm_role_definition" "reader" { name = "Reader" } -data "azurerm_billing_mca_account_scope" "subscriptions" { - billing_account_name = var.azure_billing_account_name - billing_profile_name = var.azure_billing_profile_name - invoice_section_name = var.azure_invoice_section_name +locals { + customer_agreement = var.azure_subscription_provisioning.customer_agreement + is_mca = local.customer_agreement != null } -data "azurerm_management_group" "parent" { - name = var.azure_management_group +data "azurerm_billing_mca_account_scope" "subscriptions" { + count = local.is_mca ? 1 : 0 + + billing_account_name = local.customer_agreement.billing_account_name + billing_profile_name = local.customer_agreement.billing_profile_name + invoice_section_name = local.customer_agreement.invoice_section_name } +# Scopes are built directly from the management group name (not looked up via a data source), so they +# stay known at plan time even when the management group is created in the same run (the reference +# architecture creates it). A data source read would be deferred to apply and break the meshplatform +# module's for_each over these scopes. + # Creates required resource in Azure module "azure_meshplatform" { source = "meshcloud/meshplatform/azure" version = ">= 0.14.0" replicator_enabled = true - replicator_service_principal_name = "meshstack-replicator" - replicator_custom_role_scope = data.azurerm_management_group.parent.name - replicator_assignment_scopes = [data.azurerm_management_group.parent.name] + replicator_service_principal_name = "${var.resource_name_prefix}meshstack-replicator" + replicator_custom_role_scope = var.azure_management_group + replicator_assignment_scopes = [var.azure_management_group] - can_cancel_subscriptions_in_scopes = [data.azurerm_management_group.parent.id] + can_cancel_subscriptions_in_scopes = ["/providers/Microsoft.Management/managementGroups/${var.azure_management_group}"] metering_enabled = true - metering_service_principal_name = "meshstack-metering" - metering_assignment_scopes = [data.azurerm_management_group.parent.name] + metering_service_principal_name = "${var.resource_name_prefix}meshstack-metering" + metering_assignment_scopes = [var.azure_management_group] create_passwords = false # Use only workload identity federation @@ -81,15 +169,16 @@ module "azure_meshplatform" { issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer replicator_subject = data.meshstack_integrations.integrations.workload_identity_federation.replicator.subject kraken_subject = data.meshstack_integrations.integrations.workload_identity_federation.metering.subject - mca_subject = data.meshstack_integrations.integrations.workload_identity_federation.replicator.subject + mca_subject = local.is_mca ? data.meshstack_integrations.integrations.workload_identity_federation.replicator.subject : null } - mca = { - billing_account_name = var.azure_billing_account_name - billing_profile_name = var.azure_billing_profile_name - invoice_section_name = var.azure_invoice_section_name - service_principal_names = ["meshstack-mca"] - } + # Only for the customer_agreement (MCA) model — pre-provisioned needs no MCA service principal. + mca = local.is_mca ? { + billing_account_name = local.customer_agreement.billing_account_name + billing_profile_name = local.customer_agreement.billing_profile_name + invoice_section_name = local.customer_agreement.invoice_section_name + service_principal_names = ["${var.resource_name_prefix}meshstack-mca"] + } : null } # Configure meshStack platform @@ -138,24 +227,33 @@ resource "meshstack_platform" "azure" { allow_hierarchical_management_group_assignment = false - provisioning = { - customer_agreement = { - billing_scope = data.azurerm_billing_mca_account_scope.subscriptions.id - - # This assumes the simple case where subscriptions are created in the same Entra tenant - # that meshStack is managing (source == destination). For cross-tenant setups, set these - # to the respective source and destination tenant IDs. - source_entra_tenant = module.azure_meshplatform.azure_ad_tenant_id - destination_entra_id = module.azure_meshplatform.azure_ad_tenant_id - - source_service_principal = { - client_id = module.azure_meshplatform.mca_service_principal["meshstack-mca"].Application_Client_ID - auth = {} # workload identity federation + # Exactly one of customer_agreement / pre_provisioned, selected by the provisioning model. + provisioning = merge( + { + subscription_owner_object_ids = var.azure_subscription_owner_object_ids != null ? var.azure_subscription_owner_object_ids : [data.azurerm_client_config.current.object_id] + }, + local.is_mca ? { + customer_agreement = { + billing_scope = data.azurerm_billing_mca_account_scope.subscriptions[0].id + + # This assumes the simple case where subscriptions are created in the same Entra tenant + # that meshStack is managing (source == destination). For cross-tenant setups, set these + # to the respective source and destination tenant IDs. + source_entra_tenant = module.azure_meshplatform.azure_ad_tenant_id + destination_entra_id = module.azure_meshplatform.azure_ad_tenant_id + + source_service_principal = { + client_id = module.azure_meshplatform.mca_service_principal["${var.resource_name_prefix}meshstack-mca"].Application_Client_ID + auth = {} # workload identity federation + } + subscription_creation_error_cooldown_sec = 900 + } + } : { + pre_provisioned = { + unused_subscription_name_prefix = var.azure_subscription_provisioning.pre_provisioned.unused_subscription_name_prefix } - subscription_creation_error_cooldown_sec = 900 } - subscription_owner_object_ids = var.azure_subscription_owner_object_ids != null ? var.azure_subscription_owner_object_ids : [data.azurerm_client_config.current.object_id] - } + ) azure_role_mappings = [ { @@ -217,32 +315,50 @@ resource "meshstack_platform" "azure" { } } -resource "meshstack_landingzone" "azure_default" { +# One meshStack landing zone per archetype, each pointing at an existing Azure management group. +resource "meshstack_landingzone" "this" { + for_each = var.landing_zones + metadata = { - name = "${var.meshstack.platform_name}-default" + name = "${var.meshstack.platform_name}-${each.key}" owned_by_workspace = var.meshstack.owning_workspace_identifier + tags = var.meshstack.tags } spec = { - display_name = "Azure Default" - description = "Default Azure landing zone" + display_name = each.value.display_name + description = each.value.description automate_deletion_approval = true automate_deletion_replication = true + # Building blocks every tenant in this landing zone must order (e.g. a spoke network on Corp). + mandatory_building_block_refs = [ + for uuid in each.value.mandatory_building_block_definition_uuids : { uuid = uuid } + ] + platform_ref = { uuid = meshstack_platform.azure.metadata.uuid } platform_properties = { azure = { - azure_management_group_id = var.azure_management_group + azure_management_group_id = each.value.management_group_id + # Landing zones inherit the platform-level role mappings. azure_role_mappings = [] } } } } +# The single fixed landing zone became a per-archetype map. A deployment that used the previous +# `azure-default` landing zone keeps it by passing a `default` archetype; `name` carries no +# RequiresReplace, so this updates in place instead of recreating the landing zone. +moved { + from = meshstack_landingzone.azure_default + to = meshstack_landingzone.this["default"] +} + terraform { required_version = ">= 1.12.0" diff --git a/modules/azure/spoke-network/meshstack_integration.tf b/modules/azure/spoke-network/meshstack_integration.tf index e7fd8da9..c02bf7c4 100644 --- a/modules/azure/spoke-network/meshstack_integration.tf +++ b/modules/azure/spoke-network/meshstack_integration.tf @@ -1,3 +1,8 @@ +variable "azure_tenant_id" { + type = string + description = "Azure Entra tenant ID used for the building block's ARM authentication (ARM_TENANT_ID)." +} + variable "azure_hub_subscription_id" { type = string description = "PROVIDER TARGET: hub subscription the azurerm provider reads the hub vnet from and creates the hub-side peering in. Bare GUID (e.g. '92eae5db-...'), NOT a '/subscriptions/...' path. Same sub as azure_hub_scope in a simple setup, but different format/purpose (that one is the RBAC scope)." diff --git a/reference-architectures/azure-landingzone/README.md b/reference-architectures/azure-landingzone/README.md new file mode 100644 index 00000000..d7df5ddd --- /dev/null +++ b/reference-architectures/azure-landingzone/README.md @@ -0,0 +1,137 @@ +--- +name: Azure Landing Zone +description: > + Onboards an Azure Subscription platform into meshStack over an Enterprise-Scale management group + hierarchy — existing, or provisioned by the architecture itself. Creates one landing zone per + archetype (Corp, Online, Sandbox), registers the hub-network, spoke-network, budget-alert and + storage-account building blocks, and optionally provisions the management group hierarchy, a + central hub vnet, Enterprise-Scale policies and platform resource groups. +cloudProviders: + - azure +buildingBlocks: + - path: azure + role: Registers the Azure Subscription platform and the Corp/Online/Sandbox landing zones. + - path: azure/hub-network + role: The central hub vnet (with optional firewall) — registered, and optionally provisioned via the foundation. + - path: azure/spoke-network + role: A spoke vnet peered into the hub — best paired with the Corp landing zone. + - path: azure/budget-alert + role: Consumption budget alerts application teams can order onto a subscription. + - path: azure/storage-account + role: Self-service Azure Storage Accounts. +--- + +# Azure Landing Zone + +## Overview + +The **Azure Landing Zone** reference architecture turns an existing Azure Enterprise-Scale +management group hierarchy into a self-service-ready meshStack platform in one run using its own +Terraform code. + +The **management group hierarchy** — Corp, Online, Sandbox and Connectivity — is by default +**provisioned by the architecture** under the bootstrap scope (the parent management group given at +registration), so end users configure nothing about management groups. Set +`azure_create_management_groups = false` to instead use an existing hierarchy (passed via the +`azure_*_management_group` inputs). The MCA billing setup and the connectivity subscription are +always assumed to exist. On top of the hierarchy the architecture wires meshStack and can lay the +remaining optional `foundation` (hub network, policies, resource groups). + +Running it once **always**: + +1. Registers the **Azure Subscription** platform in meshStack, with the replicator and metering + identities scoped to the landing-zones management group. +2. Creates one **landing zone per Enterprise-Scale archetype** — Corp (internal, hub-connected), + Online (internet-facing) and Sandbox (experimentation) — each pointing at its management group, + so a subscription ordered through a landing zone lands in the matching management group. +3. Registers the **Azure Hub Network**, **Azure Spoke Network**, **Azure Budget Alert** and + **Azure Storage Account** building blocks. Each creates its own backplane — a User-Assigned + Managed Identity federated to the building block definition — so it can be ordered into landing + zone subscriptions (or, for the hub, the connectivity subscription). + +And **optionally** (via the `foundation` input): + +4. Provisions the **management group hierarchy** — Landing Zones → Corp/Online/Sandbox plus + Connectivity — under a given parent (e.g. the tenant root group). The created groups are then + used everywhere instead of the `azure_*_management_group` inputs. +5. Provisions a central **hub vnet** (with an optional Azure Firewall) in the connectivity + subscription by ordering a Hub Network instance; spoke networks then peer into it. +6. Assigns curated **Enterprise-Scale policies** to the Corp/Online/Sandbox management groups + (Corp locked down, Online region-restricted, Sandbox audit-only). +7. Creates extra platform-owned **resource groups** in the platform subscription. + +**Target audience:** + +- **Platform engineers** onboarding an existing Enterprise-Scale Azure tenant into meshStack who + want landing zones and ready-to-order building blocks without hand-wiring the platform, + landing zones and backplanes separately. +- **Application teams** who request Azure subscriptions through a landing zone and order the + registered building blocks into them. + +## Architecture Diagram + +The left cluster is the **existing Azure hierarchy** — the landing-zones management group, the Corp, +Online and Sandbox management groups beneath it, and the central hub vnet. The right cluster is +**meshStack** — the platform, its three landing zones and the building block definitions. Dotted +edges across the boundary show how each meshStack construct maps onto its Azure counterpart: each +landing zone targets a management group, the platform replicates subscriptions into them, and the +spoke-network building block peers a spoke vnet into the hub. + +![Azure Landing Zone reference architecture](azure-landingzone.svg) + +## How It Works + +The architecture is a **one-time platform onboarding building block** — a one-click experience: + +1. A platform engineer applies [`meshstack_integration.tf`](meshstack_integration.tf) once (locally + with `az login`, or via an IaC runtime). This **registers the building block definition** in + meshStack **and** provisions a privileged [`bootstrap/`](bootstrap/) identity — a User-Assigned + Managed Identity federated to this definition, granted **Owner** on the given scope and Microsoft + Graph **`Application.ReadWrite.All`**. Applying this step requires an identity that is Owner on + the scope and can grant Graph app roles (Global Admin / Privileged Role Administrator) — a + deliberately privileged, one-time bootstrap. +2. The building block is then **ordered/run in meshStack**, which executes it **as the bootstrap + identity** (workload identity federation, no stored secret). That run does the actual work: it + creates the management group hierarchy (under the bootstrap scope), registers the Azure platform + and landing zones, and provisions the optional `foundation` (hub, policies, resource groups) plus + the building block backplanes. + +See [`buildingblock/`](buildingblock/) for the composition and [`bootstrap/`](bootstrap/) for the +identity. + +Once applied, application teams request Azure subscriptions through the Corp, Online or Sandbox +landing zone; each subscription is placed in the archetype's management group. They then order the +registered building blocks: + +- **Azure Spoke Network** deploys a spoke vnet into the ordering tenant's own subscription and peers + it into the hub — pair it with the **Corp** landing zone for hub-connected workloads. +- **Azure Budget Alert** and **Azure Storage Account** currently target the platform subscription + (`azure_platform_subscription_id`); see the buildingblock inputs. + +## Getting Started + +### Prerequisites + +| Requirement | Description | +|-------------|-------------| +| Management group hierarchy | A parent management group (the bootstrap scope) under which the architecture creates Corp/Online/Sandbox/Connectivity by default; or, with `azure_create_management_groups = false`, an existing hierarchy passed via the `azure_*_management_group` inputs. | +| MCA billing | Billing account, profile and invoice section names for subscription provisioning. | +| Network hub | An existing hub vnet (subscription, resource group and vnet name) for spoke networks to peer into. | +| Azure identity (to apply the integration) | Owner on the bootstrap `scope` and the ability to grant Microsoft Graph app roles (Global Administrator / Privileged Role Administrator), on the CLI (`az login`) or as an IaC runtime identity. Used once to create the bootstrap identity + register the definition; the ordered run itself then authenticates as the bootstrap identity via WIF. | + +### Deployment Order + +Apply the architecture once per workspace. It registers the platform, the three landing zones and +the three building blocks in a single run. Application teams can then request subscriptions and +order the building blocks. + +## Shared Responsibilities + +| Responsibility | Platform Team | Application Team | +|----------------|:---:|:---:| +| Maintain the management group hierarchy, billing and network hub | ✅ | ❌ | +| Register the Azure platform and the Corp/Online/Sandbox landing zones | ✅ | ❌ | +| Register the budget-alert, storage-account and spoke-network building blocks | ✅ | ❌ | +| Request Azure subscriptions through the landing zones | ❌ | ✅ | +| Order the registered building blocks into their subscriptions | ❌ | ✅ | +| Manage workloads inside the provisioned subscriptions | ❌ | ✅ | diff --git a/reference-architectures/azure-landingzone/azure-landingzone.dot b/reference-architectures/azure-landingzone/azure-landingzone.dot new file mode 100644 index 00000000..517fb4c8 --- /dev/null +++ b/reference-architectures/azure-landingzone/azure-landingzone.dot @@ -0,0 +1,80 @@ +/* + * Azure Landing Zone reference architecture. + * Conventions: .agents/references/diagrams.md — render with: task diagrams + */ +digraph azure_landingzone { + rankdir=TB + splines=ortho + forcelabels=true + bgcolor="white" + nodesep=0.55 + ranksep=0.85 + + node [shape=box style="rounded,filled" fontname="Helvetica" fontsize=11 + fillcolor="#ffffff" color="#a2abb8" penwidth=1.1 margin="0.20,0.11"] + edge [fontname="Helvetica" fontsize=9 fontcolor="#697180" color="#8b95a3" arrowsize=0.7] + + subgraph cluster_azure { + label=" Azure — Enterprise-Scale hierarchy (existing or provisioned)" + labeljust=l + fontname="Helvetica" fontsize=12 fontcolor="#697180" + style="rounded" color="#d5dae0" + margin=18 + + MG [label="🏛️ Landing Zones\nmanagement group" fillcolor="#ffffff" color="#a2abb8"] + CORP [label="🗂️ Corp MG" fillcolor="#eef2f6" color="#93a7bb"] + ONL [label="🗂️ Online MG" fillcolor="#eef2f6" color="#93a7bb"] + SBX [label="🗂️ Sandbox MG" fillcolor="#eef2f6" color="#93a7bb"] + HUB [label="🌐 Hub VNet\nprovisioned (foundation)" fillcolor="#eef2f6" color="#93a7bb"] + SUB [label="🗂️ Subscription ×N\nper project" fillcolor="#e5f2ea" color="#85bfa0"] + + { rank=same; CORP -> ONL -> SBX [style=invis] } + + MG -> CORP + MG -> ONL + MG -> SBX + CORP -> SUB [xlabel="contains"] + SUB -> HUB [xlabel="spoke peers into hub" style=dashed constraint=false] + } + + subgraph cluster_mesh { + label=" meshStack" + labeljust=l + fontname="Helvetica" fontsize=12 fontcolor="#697180" + style="rounded" color="#d5dae0" + margin=18 + + PLAT [label="🛰️ Azure Subscription platform" fillcolor="#ecedfb" color="#9aa2e6"] + LZC [label="🛬 Corp landing zone" fillcolor="#ecedfb" color="#9aa2e6"] + LZO [label="🛬 Online landing zone" fillcolor="#ecedfb" color="#9aa2e6"] + LZS [label="🛬 Sandbox landing zone" fillcolor="#ecedfb" color="#9aa2e6"] + BBH [label="📦 hub-network BBD" fillcolor="#ecedfb" color="#9aa2e6"] + BBN [label="📦 spoke-network BBD" fillcolor="#ecedfb" color="#9aa2e6"] + BBB [label="📦 budget-alert BBD" fillcolor="#ecedfb" color="#9aa2e6"] + BBS [label="📦 storage-account BBD" fillcolor="#ecedfb" color="#9aa2e6"] + POL [label="📜 ES policies\nfoundation (optional)" fillcolor="#ecedfb" color="#9aa2e6"] + PROJ [label="🗂️ Project ×N" fillcolor="#e5f2ea" color="#85bfa0"] + + { rank=same; LZC -> LZO -> LZS [style=invis] } + { rank=same; BBH -> BBN -> BBB -> BBS [style=invis] } + + PLAT -> LZC + PLAT -> LZO + PLAT -> LZS + LZC -> PROJ [xlabel="applies to"] + } + + # Cross-boundary mapping: each meshStack construct onto its Azure counterpart. + LZC -> CORP [xlabel="targets" style=dotted constraint=false] + LZO -> ONL [style=dotted constraint=false] + LZS -> SBX [style=dotted constraint=false] + PLAT -> SUB [xlabel="replicates subscriptions" style=dotted constraint=false] + PROJ -> SUB [xlabel="tenant subscription" style=dotted constraint=false] + BBH -> HUB [xlabel="provisions hub" style=dotted constraint=false] + BBN -> HUB [xlabel="peers spoke into hub" style=dotted constraint=false] + BBB -> SUB [xlabel="provision into" style=dotted constraint=false] + BBS -> SUB [style=dotted constraint=false] + POL -> CORP [xlabel="assigns policies" style=dotted constraint=false] + POL -> ONL [style=dotted constraint=false] + POL -> SBX [style=dotted constraint=false] +} diff --git a/reference-architectures/azure-landingzone/azure-landingzone.svg b/reference-architectures/azure-landingzone/azure-landingzone.svg new file mode 100644 index 00000000..35a6539e --- /dev/null +++ b/reference-architectures/azure-landingzone/azure-landingzone.svg @@ -0,0 +1,263 @@ + + + + +azure_landingzone + + +cluster_azure + +  Azure — Enterprise-Scale hierarchy (existing or provisioned) + + +cluster_mesh + +  meshStack + + + +MG + +🏛️ Landing Zones +management group + + + +CORP + +🗂️ Corp MG + + + +MG->CORP + + + + + +ONL + +🗂️ Online MG + + + +MG->ONL + + + + + +SBX + +🗂️ Sandbox MG + + + +MG->SBX + + + + + + +SUB + +🗂️ Subscription ×N +per project + + + +CORP->SUB + + +contains + + + + +HUB + +🌐 Hub VNet +provisioned (foundation) + + + +SUB->HUB + + +spoke peers into hub + + + +PLAT + +🛰️ Azure Subscription platform + + + +PLAT->SUB + + +replicates subscriptions + + + +LZC + +🛬 Corp landing zone + + + +PLAT->LZC + + + + + +LZO + +🛬 Online landing zone + + + +PLAT->LZO + + + + + +LZS + +🛬 Sandbox landing zone + + + +PLAT->LZS + + + + + +LZC->CORP + + +targets + + + + +PROJ + +🗂️ Project ×N + + + +LZC->PROJ + + +applies to + + + +LZO->ONL + + + + + + +LZS->SBX + + + + + +BBH + +📦 hub-network BBD + + + +BBH->HUB + + +provisions hub + + + +BBN + +📦 spoke-network BBD + + + + +BBN->HUB + + +peers spoke into hub + + + +BBB + +📦 budget-alert BBD + + + + +BBB->SUB + + +provision into + + + +BBS + +📦 storage-account BBD + + + + +BBS->SUB + + + + + +POL + +📜 ES policies +foundation (optional) + + + +POL->CORP + + +assigns policies + + + +POL->ONL + + + + + +POL->SBX + + + + + +PROJ->SUB + + +tenant subscription + + + diff --git a/reference-architectures/azure-landingzone/bootstrap/README.md b/reference-architectures/azure-landingzone/bootstrap/README.md new file mode 100644 index 00000000..132927a7 --- /dev/null +++ b/reference-architectures/azure-landingzone/bootstrap/README.md @@ -0,0 +1,22 @@ +# Azure Landing Zone — Bootstrap Identity + +Provisions the privileged identity meshStack runs the **Azure Landing Zone** reference architecture +as. It is sourced from [`../meshstack_integration.tf`](../meshstack_integration.tf) and applied once +by the platform engineer (locally with `az login`, or via an IaC runtime) together with the building +block definition registration. + +## What it provisions + +- A **User-Assigned Managed Identity** (+ its resource group) in `subscription_id`. +- **Federated identity credentials** binding the identity to the reference architecture's building + block definition (WIF subjects), so the ordered run authenticates as this identity with no secret. +- **Owner** at `scope` (Azure RBAC) — enough to create management groups beneath it, custom role + definitions and assignments, resource groups, UAMIs, and the hub vnet/firewall. +- **Microsoft Graph `Application.ReadWrite.All`** (Entra app role) — so the run can create the + meshStack platform service principals (replicator/metering/[mca]). + +## Required permissions to apply this + +The identity applying it needs to be **Owner** on `scope` (to grant Owner) and able to **grant +Microsoft Graph app roles** (e.g. Global Administrator or Privileged Role Administrator). This is a +one-time, deliberately privileged platform-bootstrap step. diff --git a/reference-architectures/azure-landingzone/bootstrap/main.tf b/reference-architectures/azure-landingzone/bootstrap/main.tf new file mode 100644 index 00000000..86c9e642 --- /dev/null +++ b/reference-architectures/azure-landingzone/bootstrap/main.tf @@ -0,0 +1,91 @@ +# Privileged bootstrap identity for the Azure Landing Zone reference architecture. +# +# The reference architecture's building block run creates management groups, the meshStack platform +# service principals (via terraform-azure-meshplatform), management-group role assignments, the hub +# network and the per-building-block backplanes. That run therefore needs a highly privileged Azure +# identity. This module provisions a User-Assigned Managed Identity federated to the reference +# architecture's building block definition, so meshStack executes the ordered run as this identity — +# no stored secret. Apply it (via meshstack_integration.tf) with an identity that is Owner on the +# scope and can grant Microsoft Graph app roles (Global Admin / Privileged Role Administrator). + +locals { + # Accept a full resource path as-is, a bare subscription GUID as a subscription scope, or any other + # bare value as a management group name — azurerm_role_assignment.scope needs the full path. + scope = startswith(var.scope, "/") ? var.scope : ( + can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.scope)) + ? "/subscriptions/${var.scope}" + : "/providers/Microsoft.Management/managementGroups/${var.scope}" + ) +} + +data "azuread_service_principal" "msgraph" { + client_id = "00000003-0000-0000-c000-000000000000" # Microsoft Graph +} + +resource "azurerm_resource_group" "bootstrap" { + name = var.name + location = var.location +} + +resource "azurerm_user_assigned_identity" "bootstrap" { + name = var.name + location = var.location + resource_group_name = azurerm_resource_group.bootstrap.name +} + +resource "azurerm_federated_identity_credential" "bootstrap" { + for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s } + + name = "subject-${each.key}" + user_assigned_identity_id = azurerm_user_assigned_identity.bootstrap.id + audience = ["api://AzureADTokenExchange"] + issuer = var.workload_identity_federation.issuer + subject = each.value +} + +# Azure RBAC: Owner at the scope so the run can create management groups beneath it, custom role +# definitions and role assignments, resource groups, User-Assigned Managed Identities, and the hub +# vnet/firewall. Set the scope high enough to cover everything the architecture provisions — e.g. +# the tenant root management group. +resource "azurerm_role_assignment" "owner" { + scope = local.scope + role_definition_name = "Owner" + principal_id = azurerm_user_assigned_identity.bootstrap.principal_id +} + +# Microsoft Entra: allow the run to create the meshStack platform service principals (replicator, +# metering and — for the MCA provisioning model — mca) that the meshplatform module registers. +resource "azuread_app_role_assignment" "graph_application_readwrite" { + app_role_id = data.azuread_service_principal.msgraph.app_role_ids["Application.ReadWrite.All"] + principal_object_id = azurerm_user_assigned_identity.bootstrap.principal_id + resource_object_id = data.azuread_service_principal.msgraph.object_id +} + +# Directory.Read.All — the platform integration reads the tenant's initial domain +# (data.azuread_domains) and the meshplatform module reads directory objects. +resource "azuread_app_role_assignment" "graph_directory_read" { + app_role_id = data.azuread_service_principal.msgraph.app_role_ids["Directory.Read.All"] + principal_object_id = azurerm_user_assigned_identity.bootstrap.principal_id + resource_object_id = data.azuread_service_principal.msgraph.object_id +} + +# AppRoleAssignment.ReadWrite.All — the meshplatform module grants the replicator service principal +# its own Graph app roles (directory/group/user read); assigning app roles to another SP requires +# this permission on the identity performing the run. +resource "azuread_app_role_assignment" "graph_approleassignment_readwrite" { + app_role_id = data.azuread_service_principal.msgraph.app_role_ids["AppRoleAssignment.ReadWrite.All"] + principal_object_id = azurerm_user_assigned_identity.bootstrap.principal_id + resource_object_id = data.azuread_service_principal.msgraph.object_id +} + +# Enterprise-Scale management group hierarchy, created here in the bootstrap phase so the management +# groups already EXIST when the ordered building block run reaches the meshplatform module (which +# looks them up via a data source — creating and reading them in the same run is a catch-22). The +# building block then adopts them via `import` blocks and manages them going forward. Uses the same +# parent (scope) + name_prefix as the building block so the names match for the import. +module "management_groups" { + source = "./modules/management-groups" + + parent_management_group_id = var.scope + name_prefix = var.management_group_name_prefix +} diff --git a/reference-architectures/azure-landingzone/bootstrap/modules/management-groups/README.md b/reference-architectures/azure-landingzone/bootstrap/modules/management-groups/README.md new file mode 100644 index 00000000..d4dd7291 --- /dev/null +++ b/reference-architectures/azure-landingzone/bootstrap/modules/management-groups/README.md @@ -0,0 +1,25 @@ +# Management Groups (foundation glue) + +Creates the Enterprise-Scale management group hierarchy for the Azure Landing Zone reference +architecture, under a given parent (typically the tenant root group): + +``` + +├── Landing Zones # platform replicator/metering + building block backplane scope +│ ├── Corp +│ ├── Online +│ └── Sandbox +└── Connectivity # hosts the hub subscription; hub-network backplane scope +``` + +Management groups are created with display names only; Azure generates their names (IDs). The module +outputs each group's `name` (for meshStack platform/landing-zone references) and, where needed, its +full `scope` path (for policy assignments and RBAC). + +This is **foundation glue** for the reference architecture. The caller (the reference architecture's +`buildingblock`) drives it from the `azure_management_groups` variable, whose +`parent_management_group_id` is pre-configured STATIC to the bootstrap scope — so the hierarchy is +created under the same management group the bootstrap identity owns. + +The applying identity needs permission to create management groups (Management Group Contributor at +the parent, or Owner on it — which the bootstrap identity has). diff --git a/reference-architectures/azure-landingzone/bootstrap/modules/management-groups/main.tf b/reference-architectures/azure-landingzone/bootstrap/modules/management-groups/main.tf new file mode 100644 index 00000000..bfcb29f1 --- /dev/null +++ b/reference-architectures/azure-landingzone/bootstrap/modules/management-groups/main.tf @@ -0,0 +1,49 @@ +locals { + # Accept either a bare management group name (e.g. the tenant ID for the tenant root group) or a + # full resource path, and normalise to the full path azurerm expects for a parent. + parent_id = startswith(var.parent_management_group_id, "/providers/Microsoft.Management/managementGroups/") ? var.parent_management_group_id : "/providers/Microsoft.Management/managementGroups/${var.parent_management_group_id}" + + # Explicit, deterministic management group names (IDs) — NOT Azure-generated GUIDs. This keeps the + # names known at plan time, which is required because the platform module (meshplatform) does a + # for_each over the landing-zones scope; an unknown (apply-time) name breaks that for_each. + # `name_prefix` keeps them unique across the tenant (e.g. "-"). + lz_name = "${var.name_prefix}landing-zones" + corp_name = "${var.name_prefix}corp" + online_name = "${var.name_prefix}online" + sandbox_name = "${var.name_prefix}sandbox" + connectivity_name = "${var.name_prefix}connectivity" +} + +# The "Landing Zones" management group holds the archetype groups and is the scope the platform's +# replicator/metering identities and the building block backplanes operate on. +resource "azurerm_management_group" "landing_zones" { + name = local.lz_name + display_name = var.landing_zones_display_name + parent_management_group_id = local.parent_id +} + +resource "azurerm_management_group" "corp" { + name = local.corp_name + display_name = var.corp_display_name + parent_management_group_id = azurerm_management_group.landing_zones.id +} + +resource "azurerm_management_group" "online" { + name = local.online_name + display_name = var.online_display_name + parent_management_group_id = azurerm_management_group.landing_zones.id +} + +resource "azurerm_management_group" "sandbox" { + name = local.sandbox_name + display_name = var.sandbox_display_name + parent_management_group_id = azurerm_management_group.landing_zones.id +} + +# Connectivity management group — the hub subscription lives here; the hub-network backplane role is +# scoped to it. A sibling of Landing Zones under the same parent. +resource "azurerm_management_group" "connectivity" { + name = local.connectivity_name + display_name = var.connectivity_display_name + parent_management_group_id = local.parent_id +} diff --git a/reference-architectures/azure-landingzone/bootstrap/modules/management-groups/outputs.tf b/reference-architectures/azure-landingzone/bootstrap/modules/management-groups/outputs.tf new file mode 100644 index 00000000..2a7d0915 --- /dev/null +++ b/reference-architectures/azure-landingzone/bootstrap/modules/management-groups/outputs.tf @@ -0,0 +1,37 @@ +# meshStack and the building block backplanes reference management groups by name; policy +# assignments and RBAC scopes need the full resource path. Expose both for each group. + +output "landing_zones_name" { + value = azurerm_management_group.landing_zones.name + description = "Name (ID) of the Landing Zones management group." +} + +output "landing_zones_scope" { + value = azurerm_management_group.landing_zones.id + description = "Full resource path of the Landing Zones management group." +} + +output "corp_name" { + value = azurerm_management_group.corp.name + description = "Name (ID) of the Corp management group." +} + +output "online_name" { + value = azurerm_management_group.online.name + description = "Name (ID) of the Online management group." +} + +output "sandbox_name" { + value = azurerm_management_group.sandbox.name + description = "Name (ID) of the Sandbox management group." +} + +output "connectivity_name" { + value = azurerm_management_group.connectivity.name + description = "Name (ID) of the Connectivity management group." +} + +output "connectivity_scope" { + value = azurerm_management_group.connectivity.id + description = "Full resource path of the Connectivity management group." +} diff --git a/reference-architectures/azure-landingzone/bootstrap/modules/management-groups/variables.tf b/reference-architectures/azure-landingzone/bootstrap/modules/management-groups/variables.tf new file mode 100644 index 00000000..611ce309 --- /dev/null +++ b/reference-architectures/azure-landingzone/bootstrap/modules/management-groups/variables.tf @@ -0,0 +1,47 @@ +variable "parent_management_group_id" { + type = string + nullable = false + description = "Parent management group under which the hierarchy is created — a bare management group name (e.g. the tenant ID for the tenant root group) or a full '/providers/Microsoft.Management/managementGroups/' path." +} + +variable "name_prefix" { + type = string + nullable = false + default = "" + description = "Prefix for the created management group names (IDs), e.g. '-', to keep them unique across the tenant. Must be known at plan time (do not derive it from apply-time values like a random suffix)." +} + +variable "landing_zones_display_name" { + type = string + nullable = false + default = "Landing Zones" + description = "Display name of the parent management group that holds the archetype groups." +} + +variable "corp_display_name" { + type = string + nullable = false + default = "Corp" + description = "Display name of the Corp management group." +} + +variable "online_display_name" { + type = string + nullable = false + default = "Online" + description = "Display name of the Online management group." +} + +variable "sandbox_display_name" { + type = string + nullable = false + default = "Sandbox" + description = "Display name of the Sandbox management group." +} + +variable "connectivity_display_name" { + type = string + nullable = false + default = "Connectivity" + description = "Display name of the Connectivity management group that hosts the hub subscription." +} diff --git a/reference-architectures/azure-landingzone/bootstrap/modules/management-groups/versions.tf b/reference-architectures/azure-landingzone/bootstrap/modules/management-groups/versions.tf new file mode 100644 index 00000000..c3f74a3a --- /dev/null +++ b/reference-architectures/azure-landingzone/bootstrap/modules/management-groups/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.12.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4.36.0" + } + } +} diff --git a/reference-architectures/azure-landingzone/bootstrap/outputs.tf b/reference-architectures/azure-landingzone/bootstrap/outputs.tf new file mode 100644 index 00000000..ffe798d2 --- /dev/null +++ b/reference-architectures/azure-landingzone/bootstrap/outputs.tf @@ -0,0 +1,8 @@ +output "identity" { + value = { + client_id = azurerm_user_assigned_identity.bootstrap.client_id + principal_id = azurerm_user_assigned_identity.bootstrap.principal_id + tenant_id = azurerm_user_assigned_identity.bootstrap.tenant_id + } + description = "The bootstrap managed identity meshStack runs the reference architecture as. Wire `client_id` into the building block definition's ARM_CLIENT_ID input." +} diff --git a/reference-architectures/azure-landingzone/bootstrap/provider.tf b/reference-architectures/azure-landingzone/bootstrap/provider.tf new file mode 100644 index 00000000..e8a223d5 --- /dev/null +++ b/reference-architectures/azure-landingzone/bootstrap/provider.tf @@ -0,0 +1,10 @@ +provider "azurerm" { + features {} + + # The bootstrap identity + its resource group are created in this subscription. The Owner role + # assignment is unaffected — it uses its explicit `scope`. + subscription_id = var.subscription_id +} + +# azuread is configured from ambient credentials (az login / ARM_* env) via required_providers — +# no explicit provider block needed (an empty one is deprecated in OpenTofu). diff --git a/reference-architectures/azure-landingzone/bootstrap/variables.tf b/reference-architectures/azure-landingzone/bootstrap/variables.tf new file mode 100644 index 00000000..2af7074e --- /dev/null +++ b/reference-architectures/azure-landingzone/bootstrap/variables.tf @@ -0,0 +1,49 @@ +variable "name" { + type = string + nullable = false + default = "azure-landingzone-bootstrap" + description = "Name for the bootstrap identity and its resource group. Must match pattern ^[-a-z0-9]+$." + validation { + condition = can(regex("^[-a-z0-9]+$", var.name)) + error_message = "Only alphanumeric lowercase characters and dashes are allowed" + } +} + +variable "scope" { + type = string + nullable = false + description = "Where the bootstrap identity is granted Owner — high enough to cover everything the architecture provisions. Accepts a bare management group name (e.g. `flo-test-ref-arch` or the tenant ID), a bare subscription GUID, or a full resource path. Typically the tenant root / a top management group." +} + +variable "subscription_id" { + type = string + nullable = false + description = "Subscription (bare GUID) where the bootstrap identity and its resource group are created." + + validation { + condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.subscription_id)) + error_message = "Must be a bare subscription GUID, not a '/subscriptions/' path." + } +} + +variable "location" { + type = string + nullable = false + description = "Azure region for the bootstrap identity's resource group." +} + +variable "management_group_name_prefix" { + type = string + nullable = false + default = "" + description = "Prefix for the created management group names/IDs (e.g. 'flotest-az-'), to keep them unique across the tenant. Must match the prefix the building block uses so it can import them." +} + +variable "workload_identity_federation" { + type = object({ + issuer = string + subjects = list(string) + }) + nullable = false + description = "WIF issuer and subjects for federated authentication of the bootstrap identity. The subject binds the identity to the reference architecture's building block definition." +} diff --git a/reference-architectures/azure-landingzone/bootstrap/versions.tf b/reference-architectures/azure-landingzone/bootstrap/versions.tf new file mode 100644 index 00000000..d8f6f275 --- /dev/null +++ b/reference-architectures/azure-landingzone/bootstrap/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.12.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4.36" + } + azuread = { + source = "hashicorp/azuread" + version = ">= 3.0" + } + } +} diff --git a/reference-architectures/azure-landingzone/buildingblock/README.md b/reference-architectures/azure-landingzone/buildingblock/README.md new file mode 100644 index 00000000..17234458 --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/README.md @@ -0,0 +1,106 @@ +--- +name: Azure Landing Zone +supportedPlatforms: + - azure +description: Onboards an Azure Subscription platform into meshStack on top of an existing Enterprise-Scale management group hierarchy, creates Corp/Online/Sandbox landing zones, and registers the budget-alert, storage-account and spoke-network building blocks. +--- + +This is the Terraform for the [Azure Landing Zone reference architecture](../README.md). It composes +the Azure platform integration and three Hub building blocks into a single onboarding run. + +It assumes the Azure **management group hierarchy already exists** (a parent "Landing Zones" +management group with Corp, Online and Sandbox management groups beneath it) and a central network +**hub** vnet is already in place. The run then: + +- sources [`modules/azure`](../../../modules/azure) to register the **Azure Subscription** platform + and one landing zone per archetype (Corp, Online, Sandbox), each pointing at its management group; +- sources [`modules/azure/budget-alert`](../../../modules/azure/budget-alert), + [`modules/azure/storage-account`](../../../modules/azure/storage-account) and + [`modules/azure/spoke-network`](../../../modules/azure/spoke-network), each of which creates its + own backplane (a User-Assigned Managed Identity federated to the building block definition, with a + deploy role scoped to the landing-zones management group) and registers the building block + definition. + +## Applying + +This is a one-time platform onboarding building block, ordered once in meshStack. meshStack runs it +as the privileged **bootstrap identity** provisioned by [`../bootstrap`](../bootstrap) — a UAMI +federated to the building block definition, so the run authenticates via workload identity +federation (no stored secret). The `azurerm`/`azuread` providers pick up the `ARM_*` OIDC +environment meshStack injects for that identity. (For local development you can also apply this +directly with an equivalently privileged `az login`.) + +The composed building blocks are then individually orderable by application teams; the budget-alert +and storage-account building blocks currently target the platform subscription +(`azure_platform_subscription_id`), while the spoke-network building block deploys into each +ordering tenant's own subscription. + +The user-facing readme is maintained inline in the `readme` field of the +`meshstack_building_block_definition` in +[`../meshstack_integration.tf`](../meshstack_integration.tf). + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.12.0 | +| [azuread](#requirement\_azuread) | >= 3.8 | +| [azurerm](#requirement\_azurerm) | >= 4.64 | +| [meshstack](#requirement\_meshstack) | >= 0.24.0 | +| [random](#requirement\_random) | >= 3.6.0 | + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [azure\_platform](#module\_azure\_platform) | github.com/meshcloud/meshstack-hub//modules/azure | main | +| [budget\_alert](#module\_budget\_alert) | github.com/meshcloud/meshstack-hub//modules/azure/budget-alert | main | +| [es\_policies](#module\_es\_policies) | ./modules/es-policies | n/a | +| [hub\_network](#module\_hub\_network) | github.com/meshcloud/meshstack-hub//modules/azure/hub-network | main | +| [management\_groups](#module\_management\_groups) | ./modules/management-groups | n/a | +| [spoke\_network](#module\_spoke\_network) | github.com/meshcloud/meshstack-hub//modules/azure/spoke-network | main | +| [storage\_account](#module\_storage\_account) | github.com/meshcloud/meshstack-hub//modules/azure/storage-account | main | + +## Resources + +| Name | Type | +| ---- | ---- | +| [azurerm_resource_group.foundation](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | +| [meshstack_building_block.hub](https://registry.terraform.io/providers/meshcloud/meshstack/latest/docs/resources/building_block) | resource | +| [meshstack_location.this](https://registry.terraform.io/providers/meshcloud/meshstack/latest/docs/resources/location) | resource | +| [random_string.playground_suffix](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [azure\_backplane\_subscription\_id](#input\_azure\_backplane\_subscription\_id) | Optional bare GUID of the subscription where the spoke-network backplane identity is created. Defaults to azure\_platform\_subscription\_id. Typically the hub subscription so the automation identity lives in a stable, platform-owned place. | `string` | `null` | no | +| [azure\_connectivity\_subscription\_id](#input\_azure\_connectivity\_subscription\_id) | Bare GUID of the connectivity subscription where the Azure Hub Network backplane identity lives and, when foundation.hub is set, the hub vnet and firewall are created. | `string` | n/a | yes | +| [azure\_location](#input\_azure\_location) | Azure region where the building block backplane resource groups and identities are created. | `string` | `"germanywestcentral"` | no | +| [azure\_management\_groups](#input\_azure\_management\_groups) | Enterprise-Scale management group hierarchy (Landing Zones → Corp/Online/Sandbox, plus
Connectivity) under `parent_management_group_id`, with names prefixed by `name_prefix`. The
bootstrap step already created these; this building block adopts them via `import` blocks and
manages them. Pre-configured STATIC by the platform team at registration (parent = bootstrap scope,
same name\_prefix as the bootstrap) — end users don't specify it. |
object({
parent_management_group_id = string
name_prefix = optional(string, "")
landing_zones_display_name = optional(string, "Landing Zones")
corp_display_name = optional(string, "Corp")
online_display_name = optional(string, "Online")
sandbox_display_name = optional(string, "Sandbox")
connectivity_display_name = optional(string, "Connectivity")
})
| n/a | yes | +| [azure\_platform\_subscription\_id](#input\_azure\_platform\_subscription\_id) | Bare GUID of a platform-owned subscription. The azurerm provider targets it, the budget-alert and storage-account backplanes are created in it, and (as written) those two building blocks deploy their resources into it. | `string` | n/a | yes | +| [azure\_subscription\_owner\_object\_ids](#input\_azure\_subscription\_owner\_object\_ids) | Optional explicit subscription owner object IDs. If null, the applying principal is used. | `list(string)` | `null` | no | +| [azure\_subscription\_provisioning](#input\_azure\_subscription\_provisioning) | Azure subscription provisioning model — set exactly one:
`pre_provisioned` (default): meshStack assigns subscriptions from a pool of existing ones whose name starts with `unused_subscription_name_prefix` (default `unused-`). No MCA service principal is created.
`customer_agreement`: meshStack creates subscriptions via the given MCA billing scope. |
object({
pre_provisioned = optional(object({
unused_subscription_name_prefix = optional(string, "unused-")
}))
customer_agreement = optional(object({
billing_account_name = string
billing_profile_name = string
invoice_section_name = string
}))
})
|
{
"pre_provisioned": {}
}
| no | +| [azure\_tenant\_id](#input\_azure\_tenant\_id) | Azure Entra tenant ID. Used as the ARM tenant for the building block backplanes. | `string` | n/a | yes | +| [foundation](#input\_foundation) | Optional Azure-side foundation this architecture provisions on top of the meshStack wiring. Leave
null to only register the platform, landing zones and building blocks. (The management group
hierarchy is configured separately via `azure_management_groups`.)
`hub`: when set, orders one Azure Hub Network instance — a hub vnet (with optional firewall) in the
connectivity subscription — that spoke networks peer into. The Azure Hub Network building block
itself is always registered.
`policies`: when true, assigns curated Enterprise-Scale policies to the Corp/Online/Sandbox
management groups.
`resource_groups`: extra platform-owned resource groups (name => { location }) created in the
platform subscription. |
object({
hub = optional(object({
address_space = optional(string, "10.0.0.0/22")
hub_vnet_name = optional(string, "hub-vnet")
hub_resource_group_name = optional(string, "hub-network")
create_gateway_subnet = optional(bool, true)
deploy_firewall = optional(bool, false)
firewall_sku_tier = optional(string, "Standard")
}))
policies = optional(bool, false)
resource_groups = optional(map(object({ location = string })), {})
})
| `null` | no | +| [hub](#input\_hub) | `git_ref`: meshstack-hub reference used to source the nested platform, budget-alert, storage-account and spoke-network integration modules. `const` so it can be interpolated into the module source at init time.
`bbd_draft`: Forwarded as-is to those nested integrations' own `hub.bbd_draft`, so their building block definition draft state tracks this architecture's own release state. |
object({
git_ref = optional(string, "main")
bbd_draft = optional(bool, true)
})
|
{
"bbd_draft": true,
"git_ref": "main"
}
| no | +| [platform\_identifier](#input\_platform\_identifier) | Identifier for the Azure platform created in meshStack (letters, digits and dashes only). Landing zone names are derived as `-`. | `string` | n/a | yes | +| [playground\_mode](#input\_playground\_mode) | Deploy a throwaway platform: the platform identifier gets a random suffix so it does not occupy a name for good across the meshStack instance. Set to false for a platform that is actually used. A playground platform and the building block definitions it registers are not meant to be published to other workspaces. | `bool` | n/a | yes | +| [tags](#input\_tags) | Tags forwarded to the nested integrations.
`landingzone` tags are applied to the created landing zones.
`building_block` tags are applied to the nested building block definitions (budget alert, storage account, spoke network). |
object({
landingzone = map(list(string))
building_block = map(list(string))
})
| n/a | yes | +| [use\_global\_location](#input\_use\_global\_location) | Use the global meshStack location instead of creating a dedicated location for this platform. | `bool` | n/a | yes | +| [workspace](#input\_workspace) | Identifier of the meshStack workspace that will own the created platform, location, landing zones and building block definitions. | `string` | n/a | yes | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [budget\_alert\_bbd](#output\_budget\_alert\_bbd) | Reference to the Azure Budget Alert building block definition registered by this architecture. | +| [hub\_network\_bbd](#output\_hub\_network\_bbd) | Reference to the Azure Hub Network building block definition registered by this architecture, or null when foundation.hub is not set. | +| [landingzone\_names](#output\_landingzone\_names) | meshStack landing zone names created per archetype. | +| [landingzone\_refs](#output\_landingzone\_refs) | References to the created landing zones, keyed by archetype (`corp`, `online`, `sandbox`). | +| [platform\_ref](#output\_platform\_ref) | Reference to the meshPlatform this architecture creates, for compositions that create meshTenants (subscriptions) on it. | +| [spoke\_network\_bbd](#output\_spoke\_network\_bbd) | Reference to the Azure Spoke Network building block definition registered by this architecture. | +| [storage\_account\_bbd](#output\_storage\_account\_bbd) | Reference to the Azure Storage Account building block definition registered by this architecture. | +| [summary](#output\_summary) | Summary of the meshStack resources created by this reference architecture. | + diff --git a/reference-architectures/azure-landingzone/buildingblock/SUMMARY.md.tftpl b/reference-architectures/azure-landingzone/buildingblock/SUMMARY.md.tftpl new file mode 100644 index 00000000..05c35ab4 --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/SUMMARY.md.tftpl @@ -0,0 +1,56 @@ +# Azure Landing Zone: **${platform_identifier}** + +%{ if playground_mode ~} +> **Playground mode.** The platform identifier carries a random suffix so this deployment does not +> occupy a name for good, and nothing here is protected against deletion. Do not publish this +> platform or the building block definitions it registered to other workspaces. Redeploy with +> `playground_mode` set to false for a platform that is actually used. + +%{ endif ~} +## Platform + +The **Azure Subscription** platform `${platform_identifier}` is registered in meshStack. Its +replicator and metering identities are scoped to the `${management_group}` management group. + +## Landing Zones + +| Archetype | Landing Zone | Management Group | +|-----------|--------------|------------------| +| Corp | `${landingzone_names["corp"]}` | `${corp_management_group}` | +| Online | `${landingzone_names["online"]}` | `${online_mgmt_group}` | +| Sandbox | `${landingzone_names["sandbox"]}` | `${sandbox_mgmt_group}` | + +Application teams request Azure subscriptions through one of these landing zones; each subscription +is placed in the archetype's management group. + +## Building Blocks + +The following building blocks are registered and available to order. Each has its own backplane +identity with a deploy role. + +- **Azure Hub Network** — the central hub vnet spoke networks peer into. +- **Azure Spoke Network** — a spoke vnet peered into the hub vnet `${hub_vnet}` + (resource group `${hub_resource_group}`). Best paired with the **Corp** landing zone. +- **Azure Budget Alert** — consumption budget alerts. +- **Azure Storage Account** — self-service storage accounts. + +Backplane identities for the budget-alert and storage-account building blocks live in subscription +`${platform_subscription}`; the spoke-network backplane identity lives in `${backplane_subscription}`. + +## Foundation + +%{ if hub_provisioned ~} +- **Hub network provisioned** — a hub vnet `${hub_vnet}` (resource group `${hub_resource_group}`) was + ordered in the connectivity subscription. Spoke networks peer into it. +%{ else ~} +- **Hub network not provisioned** — spoke networks peer into the existing hub configured via the + `azure_hub_*` inputs. Set `foundation.hub` to have this architecture provision a hub. +%{ endif ~} +%{ if policies_enabled ~} +- **Enterprise-Scale policies assigned** to the Corp, Online and Sandbox management groups. +%{ else ~} +- **Policies not assigned** — set `foundation.policies = true` to assign curated ES policies. +%{ endif ~} +%{ if length(foundation_rg_names) > 0 ~} +- **Platform resource groups created:** ${join(", ", formatlist("`%s`", foundation_rg_names))}. +%{ endif ~} diff --git a/reference-architectures/azure-landingzone/buildingblock/logo.svg b/reference-architectures/azure-landingzone/buildingblock/logo.svg new file mode 100644 index 00000000..71d042af --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/reference-architectures/azure-landingzone/buildingblock/main.tf b/reference-architectures/azure-landingzone/buildingblock/main.tf new file mode 100644 index 00000000..a4092249 --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/main.tf @@ -0,0 +1,292 @@ +locals { + # The identifier is unique across the whole meshStack instance and lands in every landing zone + # name, so a playground deployment suffixes it instead of occupying the plain name. + platform_identifier = var.playground_mode ? "${var.platform_identifier}-${random_string.playground_suffix.result}" : var.platform_identifier + + # ── Foundation ── + hub_enabled = try(var.foundation.hub, null) != null + policies_enabled = try(var.foundation.policies, false) + foundation_rgs = try(var.foundation.resource_groups, {}) + + # Management group identifiers come from the hierarchy this architecture creates under the + # bootstrap scope (azure_management_groups, pre-configured STATIC by the platform team). + lz_management_group = module.management_groups.landing_zones_name + corp_management_group = module.management_groups.corp_name + online_management_group = module.management_groups.online_name + sandbox_management_group = module.management_groups.sandbox_name + connectivity_scope = module.management_groups.connectivity_scope + + # Full resource-path scope for the building block backplanes' RBAC role assignments — the + # landing-zones management group, so one backplane per building block covers Corp, Online and + # Sandbox beneath it. + landing_zones_scope = module.management_groups.landing_zones_scope + + # The spoke-network backplane identity lives in a stable platform-owned subscription. Defaults to + # the platform subscription when no dedicated backplane subscription is given. + backplane_subscription_id = coalesce(var.azure_backplane_subscription_id, var.azure_platform_subscription_id) + + archetype_management_groups = { + corp = local.corp_management_group + online = local.online_management_group + sandbox = local.sandbox_management_group + } + + # Spoke networks peer into the hub in the connectivity subscription/scope (the Connectivity MG is + # always created). The hub vnet/RG names come from foundation.hub when a hub is provisioned, else + # the hub-network defaults — the platform team must provision the hub (foundation.hub) before app + # teams order spokes. + spoke_hub = { + subscription_id = var.azure_connectivity_subscription_id + scope = local.connectivity_scope + resource_group_name = try(var.foundation.hub.hub_resource_group_name, "hub-network") + vnet_name = try(var.foundation.hub.hub_vnet_name, "hub-vnet") + } +} + +# ── Enterprise-Scale management group hierarchy (created under the bootstrap scope) ── +module "management_groups" { + source = "./modules/management-groups" + + # Same parent + name_prefix the bootstrap used, so the names match and the import blocks below adopt + # the already-created management groups instead of trying to create duplicates. + name_prefix = var.azure_management_groups.name_prefix + parent_management_group_id = var.azure_management_groups.parent_management_group_id + landing_zones_display_name = var.azure_management_groups.landing_zones_display_name + corp_display_name = var.azure_management_groups.corp_display_name + online_display_name = var.azure_management_groups.online_display_name + sandbox_display_name = var.azure_management_groups.sandbox_display_name + connectivity_display_name = var.azure_management_groups.connectivity_display_name +} + +# Adopt the management groups the bootstrap step already created into this building block's state, so +# meshStack manages them going forward (and the meshplatform module finds them already existing). The +# import IDs match the names the bootstrap used (same parent + name_prefix). Import blocks must live +# in the root module, but can target resources inside module.management_groups. +import { + to = module.management_groups.azurerm_management_group.landing_zones + id = "/providers/Microsoft.Management/managementGroups/${var.azure_management_groups.name_prefix}landing-zones" +} +import { + to = module.management_groups.azurerm_management_group.corp + id = "/providers/Microsoft.Management/managementGroups/${var.azure_management_groups.name_prefix}corp" +} +import { + to = module.management_groups.azurerm_management_group.online + id = "/providers/Microsoft.Management/managementGroups/${var.azure_management_groups.name_prefix}online" +} +import { + to = module.management_groups.azurerm_management_group.sandbox + id = "/providers/Microsoft.Management/managementGroups/${var.azure_management_groups.name_prefix}sandbox" +} +import { + to = module.management_groups.azurerm_management_group.connectivity + id = "/providers/Microsoft.Management/managementGroups/${var.azure_management_groups.name_prefix}connectivity" +} + +resource "random_string" "playground_suffix" { + lifecycle { + enabled = var.playground_mode + } + + length = 6 + special = false + upper = false +} + +# Dedicated meshStack location for the platform, unless the global location is used. +resource "meshstack_location" "this" { + lifecycle { + enabled = !var.use_global_location + } + + metadata = { + name = local.platform_identifier + owned_by_workspace = var.workspace + } + + spec = { + display_name = local.platform_identifier + description = "Azure location created by the Azure Landing Zone reference architecture." + } +} + +# ── Azure platform + Corp/Online/Sandbox landing zones ── +# Registers the Azure Subscription platform in meshStack and creates one landing zone per +# Enterprise-Scale archetype, each pointing at the management group this architecture created under +# the bootstrap scope (see module.management_groups / local.*_management_group). +module "azure_platform" { + source = "github.com/meshcloud/meshstack-hub//modules/azure?ref=${var.hub.git_ref}" + + azure_management_group = local.lz_management_group + resource_name_prefix = var.azure_management_groups.name_prefix + azure_subscription_provisioning = var.azure_subscription_provisioning + azure_subscription_owner_object_ids = var.azure_subscription_owner_object_ids + + landing_zones = { + corp = { + management_group_id = local.corp_management_group + display_name = "Azure Corp" + description = "Corp-connected landing zone: subscriptions are placed in the Corp management group for internal, hub-connected workloads. The Azure Spoke Network building block is mandatory here, giving every tenant routed connectivity to the hub." + # Corp tenants must have a spoke network (hub connectivity) plus a budget alert. + mandatory_building_block_definition_uuids = [ + module.spoke_network.building_block_definition.uuid, + module.budget_alert.building_block_definition.uuid, + ] + } + online = { + management_group_id = local.online_management_group + display_name = "Azure Online" + description = "Internet-facing landing zone: subscriptions are placed in the Online management group for public-facing workloads without a mandatory hub connection." + # Online tenants must have a budget alert. + mandatory_building_block_definition_uuids = [ + module.budget_alert.building_block_definition.uuid, + ] + } + sandbox = { + management_group_id = local.sandbox_management_group + display_name = "Azure Sandbox" + description = "Experimentation landing zone: subscriptions are placed in the Sandbox management group with relaxed guardrails for trying things out." + # Sandbox tenants must have a budget alert. + mandatory_building_block_definition_uuids = [ + module.budget_alert.building_block_definition.uuid, + ] + } + } + + meshstack = { + owning_workspace_identifier = var.workspace + platform_name = local.platform_identifier + location_name = var.use_global_location ? "global" : meshstack_location.this.metadata.name + tags = var.tags.landingzone + } + + hub = var.hub +} + +# ── Building blocks rolled out for the platform ── +# Each integration creates its own backplane (a UAMI federated to the building block definition, +# with a deploy role scoped to the landing-zones management group) and registers the definition, so +# application teams can order these into subscriptions created through the landing zones. + +module "budget_alert" { + source = "github.com/meshcloud/meshstack-hub//modules/azure/budget-alert?ref=${var.hub.git_ref}" + + azure_tenant_id = var.azure_tenant_id + azure_subscription_id = var.azure_platform_subscription_id + azure_scope = local.landing_zones_scope + azure_location = var.azure_location + backplane_name = "${var.azure_management_groups.name_prefix}budget-alert" + + meshstack = { + owning_workspace_identifier = var.workspace + tags = var.tags.building_block + } + hub = var.hub +} + +module "storage_account" { + source = "github.com/meshcloud/meshstack-hub//modules/azure/storage-account?ref=${var.hub.git_ref}" + + azure_tenant_id = var.azure_tenant_id + azure_subscription_id = var.azure_platform_subscription_id + azure_scope = local.landing_zones_scope + azure_location = var.azure_location + backplane_name = "${var.azure_management_groups.name_prefix}storage-account" + + meshstack = { + owning_workspace_identifier = var.workspace + tags = var.tags.building_block + } + hub = var.hub +} + +module "spoke_network" { + source = "github.com/meshcloud/meshstack-hub//modules/azure/spoke-network?ref=${var.hub.git_ref}" + + azure_tenant_id = var.azure_tenant_id + azure_hub_subscription_id = local.spoke_hub.subscription_id + azure_scope = local.landing_zones_scope + azure_hub_scope = local.spoke_hub.scope + azure_location = var.azure_location + azure_hub_resource_group_name = local.spoke_hub.resource_group_name + azure_hub_vnet_name = local.spoke_hub.vnet_name + azure_backplane_subscription_id = local.backplane_subscription_id + backplane_name = "${var.azure_management_groups.name_prefix}spoke-network" + + meshstack = { + owning_workspace_identifier = var.workspace + tags = var.tags.building_block + } + hub = var.hub +} + +# ── Optional foundation ── +# Provisioned only when var.foundation is set. Leaving it null keeps the architecture to the +# meshStack-side wiring (platform, landing zones and the three building blocks above). + +# Extra platform-owned resource groups (e.g. management/connectivity groups) in the platform +# subscription. +resource "azurerm_resource_group" "foundation" { + for_each = local.foundation_rgs + + name = each.key + location = each.value.location +} + +# Enterprise-Scale policy assignments on the existing Corp/Online/Sandbox management groups. +module "es_policies" { + for_each = local.policies_enabled ? local.archetype_management_groups : {} + + source = "./modules/es-policies" + + management_group_id = "/providers/Microsoft.Management/managementGroups/${each.value}" + policy_path = "${path.module}/policies/${each.key}" + location = var.azure_location + template_file_variables = { default_location = var.azure_location } +} + +# Central hub network: registers the Azure Hub Network building block (the connectivity counterpart +# to spoke-network). Always registered — it cannot be gated with `enabled` because its backplane +# carries its own provider configuration. Whether a hub vnet is actually provisioned is controlled +# by ordering an instance below (meshstack_building_block.hub), gated by var.foundation.hub. +module "hub_network" { + source = "github.com/meshcloud/meshstack-hub//modules/azure/hub-network?ref=${var.hub.git_ref}" + + azure_tenant_id = var.azure_tenant_id + azure_connectivity_subscription_id = var.azure_connectivity_subscription_id + azure_scope = local.connectivity_scope + azure_location = var.azure_location + backplane_name = "${var.azure_management_groups.name_prefix}hub-network" + + meshstack = { + owning_workspace_identifier = var.workspace + tags = var.tags.building_block + } + hub = var.hub +} + +resource "meshstack_building_block" "hub" { + lifecycle { + enabled = local.hub_enabled + } + + wait_for_completion = true + depends_on = [module.hub_network] + + spec = { + building_block_definition_version_ref = { + uuid = module.hub_network.building_block_definition.version_ref.uuid + } + display_name = "Hub Network" + target_ref = { kind = "meshWorkspace", name = var.workspace } + + inputs = { + hub_resource_group_name = { value = jsonencode(try(var.foundation.hub.hub_resource_group_name, "hub-network")) } + hub_vnet_name = { value = jsonencode(try(var.foundation.hub.hub_vnet_name, "hub-vnet")) } + address_space = { value = jsonencode(try(var.foundation.hub.address_space, "10.0.0.0/22")) } + create_gateway_subnet = { value = jsonencode(try(var.foundation.hub.create_gateway_subnet, true)) } + deploy_firewall = { value = jsonencode(try(var.foundation.hub.deploy_firewall, false)) } + firewall_sku_tier = { value = jsonencode(try(var.foundation.hub.firewall_sku_tier, "Standard")) } + } + } +} diff --git a/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/README.md b/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/README.md new file mode 100644 index 00000000..b3a0f505 --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/README.md @@ -0,0 +1,20 @@ +# Enterprise-Scale Policies (foundation glue) + +A trimmed port of collie-hub's [`kit/azure/util/azure-policies`](https://github.com/meshcloud/collie-hub/tree/main/kit/azure/util/azure-policies) +helper. It reads policy JSON files from `policy_path` and applies them to a single management group: + +- `policy_definitions/*.json` → custom policy definitions +- `policy_assignments/*.tmpl.json` → management-group policy assignments (templated) + +Policy set (initiative) support from the upstream helper is intentionally omitted here: azurerm v5 +reworked `azurerm_policy_set_definition` and the shipped libs use built-in definitions only, so no +initiatives are needed. + +`*.tmpl.json` files are expanded with `templatefile()` using `template_file_variables` (e.g. +`default_location`), so assignments can reference scopes and regions. + +This is **foundation glue** for the Azure Landing Zone reference architecture — the caller +instantiates it once per archetype (Corp/Online/Sandbox), pointing `policy_path` at the archetype's +curated policy lib under [`../../policies/`](../../policies) and `management_group_id` at the +archetype's existing management group. The shipped libs use only built-in Azure policy definitions +(allowed locations; no public IPs on NICs for Corp), so no custom definitions are required. diff --git a/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/outputs.tf b/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/outputs.tf new file mode 100644 index 00000000..22744703 --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/outputs.tf @@ -0,0 +1,34 @@ +# we use the actual resource to generate outputs as this is what's actually deployed on Azure after all templating +# and interpolating is fully applied + +# We expose a subset of interesting data as this is mostly used to generate documentation. + +# Why jsondecode(paremeters)? +# For whatever reason azurerm really likes encoded json strings for parameters, which are a PITA to work with +# so we just decode them back to HCL + +output "policy_definitions" { + value = { for k, v in azurerm_policy_definition.enterprise_scale : + v.name => { + name = v.name, + display_name = v.display_name, + description = v.description, + parameters = jsondecode(coalesce(v.parameters, "{}")) + } + } +} + +output "policy_assignments" { + value = { for k, v in azurerm_management_group_policy_assignment.enterprise_scale : + v.name => { + id = v.id, + name = v.name, + display_name = v.display_name, + description = v.description, + identity = v.identity, + not_scopes = v.not_scopes + enforce = v.enforce, + parameters = jsondecode(coalesce(v.parameters, "{}")) + } + } +} diff --git a/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/resources.policy_assignments.tf b/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/resources.policy_assignments.tf new file mode 100644 index 00000000..a7ed6635 --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/resources.policy_assignments.tf @@ -0,0 +1,84 @@ + +locals { + # policy assignments need some lightweight templating to make sure they can reference the right definition ids, scopes etc. + # this is why we store them as tmpl.jsons and run them trough templatefile + policy_assignment_files = fileset(var.policy_path, "policy_assignments/*.tmpl.json") + policy_assignment_objects = { for f in local.policy_assignment_files : + f => jsondecode(templatefile("${var.policy_path}/${f}", var.template_file_variables)) + } + + policy_assignments = { for f, p in local.policy_assignment_objects : + f => { + name = p.name, + display_name = try(p.properties.displayName, null) + description = try(p.properties.description, null) + + policy_definition_id = try(p.properties.policyDefinitionId, null) + + parameters = try(p.properties.parameters, null) + not_scopes = try(p.properties.notScopes, null) + enforcement_mode = try(p.properties.enforce, null) + identity = try(p.identity, null) + override = try(p.properties.override, null) + policy_non_compliance_message_enabled = try(p.properties.nonComplianceMessages, null) + } + } +} + +resource "azurerm_management_group_policy_assignment" "enterprise_scale" { + for_each = local.policy_assignments + + # Mandatory resource attributes + # The policy assignment name length must not exceed '24' characters + # note that Terraform plan is unable to validate this in the plan stage + name = each.value.name + management_group_id = var.management_group_id + + policy_definition_id = each.value.policy_definition_id + + # Optional resource attributes + description = each.value.description + display_name = each.value.display_name + + location = var.location + not_scopes = each.value.not_scopes + parameters = length(each.value.parameters) > 0 ? jsonencode(each.value.parameters) : null + enforce = each.value.enforcement_mode + + # Dynamic configuration blocks for overrides + # More details can be found here: https://learn.microsoft.com/en-gb/azure/governance/policy/concepts/assignment-structure#overrides-preview + dynamic "overrides" { + for_each = try({ for i, override in each.value.override : i => override }, {}) + content { + value = overrides.value.value + dynamic "selectors" { + for_each = try({ for i, selector in overrides.value.selectors : i => selector }, {}) + content { + in = try(selectors.in, []) + not_in = try(selectors.not_in, []) + } + } + } + } + + # Dynamic configuration blocks + # The identity block only supports a single value + # for type = "SystemAssigned" so the following logic + # ensures the block is only created when this value + # is specified in the source template + dynamic "identity" { + for_each = { + for ik, iv in try(each.value.identity, {}) : + ik => iv + if lower(iv) == "systemassigned" + } + content { + type = "SystemAssigned" + } + } + + # deploy assignments after definitions + depends_on = [ + azurerm_policy_definition.enterprise_scale + ] +} diff --git a/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/resources.policy_definitions.tf b/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/resources.policy_definitions.tf new file mode 100644 index 00000000..3941f00b --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/resources.policy_definitions.tf @@ -0,0 +1,50 @@ +locals { + policy_definition_files = fileset(var.policy_path, "policy_definitions/*.json") + + policy_definition_objects = { for f in local.policy_definition_files : + f => jsondecode(file("${var.policy_path}/${f}")) + } + + policy_definitions = { for f, p in local.policy_definition_objects : + f => { + #related to policies only + policy_name = p.name + policy_rule = try(p.properties.policyRule, null) + mode = try(p.properties.mode, "All") + + # used local library attributes + display_name = try(p.properties.displayName, null) + description = try(p.properties.description, null) + + metadata = try(p.properties.metadata, {}) + + parameters = try(p.properties.parameters, null) + version = try(p.properties.metadata.version, "1.0.0") + category = try(p.properties.metadata.category, "General") + } + } +} + +resource "azurerm_policy_definition" "enterprise_scale" { + for_each = local.policy_definitions + + name = each.value.policy_name + display_name = each.value.display_name + description = each.value.description + policy_type = "Custom" + mode = each.value.mode + + management_group_id = var.management_group_id + + metadata = jsonencode(each.value.metadata) + parameters = length(each.value.parameters) > 0 ? jsonencode(each.value.parameters) : null + policy_rule = jsonencode(each.value.policy_rule) + + lifecycle { + create_before_destroy = true + } + + timeouts { + read = "10m" + } +} diff --git a/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/variables.tf b/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/variables.tf new file mode 100644 index 00000000..be557054 --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/variables.tf @@ -0,0 +1,20 @@ +variable "management_group_id" { + type = string + description = "The management group scope at which the policy will be defined. Defaults to current Subscription if omitted. Changing this forces a new resource to be created." +} + +variable "policy_path" { + type = string + description = "path of the json policies, sets or assignments" +} + +variable "location" { + type = string + description = "location for the policy assignment" +} + +variable "template_file_variables" { + type = map(string) + description = "variables for *.tmpl.json files, expanded with terraform templatefile() function" + +} \ No newline at end of file diff --git a/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/versions.tf b/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/versions.tf new file mode 100644 index 00000000..c3f74a3a --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/modules/es-policies/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.12.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4.36.0" + } + } +} diff --git a/reference-architectures/azure-landingzone/buildingblock/modules/management-groups/README.md b/reference-architectures/azure-landingzone/buildingblock/modules/management-groups/README.md new file mode 100644 index 00000000..d4dd7291 --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/modules/management-groups/README.md @@ -0,0 +1,25 @@ +# Management Groups (foundation glue) + +Creates the Enterprise-Scale management group hierarchy for the Azure Landing Zone reference +architecture, under a given parent (typically the tenant root group): + +``` + +├── Landing Zones # platform replicator/metering + building block backplane scope +│ ├── Corp +│ ├── Online +│ └── Sandbox +└── Connectivity # hosts the hub subscription; hub-network backplane scope +``` + +Management groups are created with display names only; Azure generates their names (IDs). The module +outputs each group's `name` (for meshStack platform/landing-zone references) and, where needed, its +full `scope` path (for policy assignments and RBAC). + +This is **foundation glue** for the reference architecture. The caller (the reference architecture's +`buildingblock`) drives it from the `azure_management_groups` variable, whose +`parent_management_group_id` is pre-configured STATIC to the bootstrap scope — so the hierarchy is +created under the same management group the bootstrap identity owns. + +The applying identity needs permission to create management groups (Management Group Contributor at +the parent, or Owner on it — which the bootstrap identity has). diff --git a/reference-architectures/azure-landingzone/buildingblock/modules/management-groups/main.tf b/reference-architectures/azure-landingzone/buildingblock/modules/management-groups/main.tf new file mode 100644 index 00000000..bfcb29f1 --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/modules/management-groups/main.tf @@ -0,0 +1,49 @@ +locals { + # Accept either a bare management group name (e.g. the tenant ID for the tenant root group) or a + # full resource path, and normalise to the full path azurerm expects for a parent. + parent_id = startswith(var.parent_management_group_id, "/providers/Microsoft.Management/managementGroups/") ? var.parent_management_group_id : "/providers/Microsoft.Management/managementGroups/${var.parent_management_group_id}" + + # Explicit, deterministic management group names (IDs) — NOT Azure-generated GUIDs. This keeps the + # names known at plan time, which is required because the platform module (meshplatform) does a + # for_each over the landing-zones scope; an unknown (apply-time) name breaks that for_each. + # `name_prefix` keeps them unique across the tenant (e.g. "-"). + lz_name = "${var.name_prefix}landing-zones" + corp_name = "${var.name_prefix}corp" + online_name = "${var.name_prefix}online" + sandbox_name = "${var.name_prefix}sandbox" + connectivity_name = "${var.name_prefix}connectivity" +} + +# The "Landing Zones" management group holds the archetype groups and is the scope the platform's +# replicator/metering identities and the building block backplanes operate on. +resource "azurerm_management_group" "landing_zones" { + name = local.lz_name + display_name = var.landing_zones_display_name + parent_management_group_id = local.parent_id +} + +resource "azurerm_management_group" "corp" { + name = local.corp_name + display_name = var.corp_display_name + parent_management_group_id = azurerm_management_group.landing_zones.id +} + +resource "azurerm_management_group" "online" { + name = local.online_name + display_name = var.online_display_name + parent_management_group_id = azurerm_management_group.landing_zones.id +} + +resource "azurerm_management_group" "sandbox" { + name = local.sandbox_name + display_name = var.sandbox_display_name + parent_management_group_id = azurerm_management_group.landing_zones.id +} + +# Connectivity management group — the hub subscription lives here; the hub-network backplane role is +# scoped to it. A sibling of Landing Zones under the same parent. +resource "azurerm_management_group" "connectivity" { + name = local.connectivity_name + display_name = var.connectivity_display_name + parent_management_group_id = local.parent_id +} diff --git a/reference-architectures/azure-landingzone/buildingblock/modules/management-groups/outputs.tf b/reference-architectures/azure-landingzone/buildingblock/modules/management-groups/outputs.tf new file mode 100644 index 00000000..2a7d0915 --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/modules/management-groups/outputs.tf @@ -0,0 +1,37 @@ +# meshStack and the building block backplanes reference management groups by name; policy +# assignments and RBAC scopes need the full resource path. Expose both for each group. + +output "landing_zones_name" { + value = azurerm_management_group.landing_zones.name + description = "Name (ID) of the Landing Zones management group." +} + +output "landing_zones_scope" { + value = azurerm_management_group.landing_zones.id + description = "Full resource path of the Landing Zones management group." +} + +output "corp_name" { + value = azurerm_management_group.corp.name + description = "Name (ID) of the Corp management group." +} + +output "online_name" { + value = azurerm_management_group.online.name + description = "Name (ID) of the Online management group." +} + +output "sandbox_name" { + value = azurerm_management_group.sandbox.name + description = "Name (ID) of the Sandbox management group." +} + +output "connectivity_name" { + value = azurerm_management_group.connectivity.name + description = "Name (ID) of the Connectivity management group." +} + +output "connectivity_scope" { + value = azurerm_management_group.connectivity.id + description = "Full resource path of the Connectivity management group." +} diff --git a/reference-architectures/azure-landingzone/buildingblock/modules/management-groups/variables.tf b/reference-architectures/azure-landingzone/buildingblock/modules/management-groups/variables.tf new file mode 100644 index 00000000..611ce309 --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/modules/management-groups/variables.tf @@ -0,0 +1,47 @@ +variable "parent_management_group_id" { + type = string + nullable = false + description = "Parent management group under which the hierarchy is created — a bare management group name (e.g. the tenant ID for the tenant root group) or a full '/providers/Microsoft.Management/managementGroups/' path." +} + +variable "name_prefix" { + type = string + nullable = false + default = "" + description = "Prefix for the created management group names (IDs), e.g. '-', to keep them unique across the tenant. Must be known at plan time (do not derive it from apply-time values like a random suffix)." +} + +variable "landing_zones_display_name" { + type = string + nullable = false + default = "Landing Zones" + description = "Display name of the parent management group that holds the archetype groups." +} + +variable "corp_display_name" { + type = string + nullable = false + default = "Corp" + description = "Display name of the Corp management group." +} + +variable "online_display_name" { + type = string + nullable = false + default = "Online" + description = "Display name of the Online management group." +} + +variable "sandbox_display_name" { + type = string + nullable = false + default = "Sandbox" + description = "Display name of the Sandbox management group." +} + +variable "connectivity_display_name" { + type = string + nullable = false + default = "Connectivity" + description = "Display name of the Connectivity management group that hosts the hub subscription." +} diff --git a/reference-architectures/azure-landingzone/buildingblock/modules/management-groups/versions.tf b/reference-architectures/azure-landingzone/buildingblock/modules/management-groups/versions.tf new file mode 100644 index 00000000..c3f74a3a --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/modules/management-groups/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.12.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4.36.0" + } + } +} diff --git a/reference-architectures/azure-landingzone/buildingblock/outputs.tf b/reference-architectures/azure-landingzone/buildingblock/outputs.tf new file mode 100644 index 00000000..b9df6a64 --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/outputs.tf @@ -0,0 +1,54 @@ +output "platform_ref" { + description = "Reference to the meshPlatform this architecture creates, for compositions that create meshTenants (subscriptions) on it." + value = module.azure_platform.platform_ref +} + +output "landingzone_refs" { + description = "References to the created landing zones, keyed by archetype (`corp`, `online`, `sandbox`)." + value = module.azure_platform.landingzone_refs +} + +output "landingzone_names" { + description = "meshStack landing zone names created per archetype." + value = module.azure_platform.landingzone_names +} + +output "budget_alert_bbd" { + description = "Reference to the Azure Budget Alert building block definition registered by this architecture." + value = module.budget_alert.building_block_definition +} + +output "storage_account_bbd" { + description = "Reference to the Azure Storage Account building block definition registered by this architecture." + value = module.storage_account.building_block_definition +} + +output "spoke_network_bbd" { + description = "Reference to the Azure Spoke Network building block definition registered by this architecture." + value = module.spoke_network.building_block_definition +} + +output "hub_network_bbd" { + description = "Reference to the Azure Hub Network building block definition registered by this architecture, or null when foundation.hub is not set." + value = local.hub_enabled ? module.hub_network.building_block_definition : null +} + +output "summary" { + description = "Summary of the meshStack resources created by this reference architecture." + value = templatefile("${path.module}/SUMMARY.md.tftpl", { + platform_identifier = local.platform_identifier + playground_mode = var.playground_mode + management_group = local.lz_management_group + corp_management_group = local.corp_management_group + online_mgmt_group = local.online_management_group + sandbox_mgmt_group = local.sandbox_management_group + landingzone_names = module.azure_platform.landingzone_names + hub_vnet = local.spoke_hub.vnet_name + hub_resource_group = local.spoke_hub.resource_group_name + platform_subscription = var.azure_platform_subscription_id + backplane_subscription = local.backplane_subscription_id + hub_provisioned = local.hub_enabled + policies_enabled = local.policies_enabled + foundation_rg_names = keys(local.foundation_rgs) + }) +} diff --git a/reference-architectures/azure-landingzone/buildingblock/policies/corp/policy_assignments/allowed_locations.tmpl.json b/reference-architectures/azure-landingzone/buildingblock/policies/corp/policy_assignments/allowed_locations.tmpl.json new file mode 100644 index 00000000..9c364794 --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/policies/corp/policy_assignments/allowed_locations.tmpl.json @@ -0,0 +1,14 @@ +{ + "name": "allowed-locations", + "properties": { + "displayName": "Corp: Allowed locations", + "description": "Restricts the regions Corp resources can be deployed to.", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c", + "enforce": true, + "parameters": { + "listOfAllowedLocations": { + "value": ["${default_location}"] + } + } + } +} diff --git a/reference-architectures/azure-landingzone/buildingblock/policies/corp/policy_assignments/deny_public_ip_nic.tmpl.json b/reference-architectures/azure-landingzone/buildingblock/policies/corp/policy_assignments/deny_public_ip_nic.tmpl.json new file mode 100644 index 00000000..a6d33e9e --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/policies/corp/policy_assignments/deny_public_ip_nic.tmpl.json @@ -0,0 +1,10 @@ +{ + "name": "deny-public-ip-nic", + "properties": { + "displayName": "Corp: Network interfaces should not have public IPs", + "description": "Denies network interfaces with a public IP so Corp workloads stay behind the hub.", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/83a86a26-fd1f-447c-b59d-e51f44264114", + "enforce": true, + "parameters": {} + } +} diff --git a/reference-architectures/azure-landingzone/buildingblock/policies/online/policy_assignments/allowed_locations.tmpl.json b/reference-architectures/azure-landingzone/buildingblock/policies/online/policy_assignments/allowed_locations.tmpl.json new file mode 100644 index 00000000..bfac9dc8 --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/policies/online/policy_assignments/allowed_locations.tmpl.json @@ -0,0 +1,14 @@ +{ + "name": "allowed-locations", + "properties": { + "displayName": "Online: Allowed locations", + "description": "Restricts the regions Online resources can be deployed to. Public endpoints are allowed for internet-facing workloads.", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c", + "enforce": true, + "parameters": { + "listOfAllowedLocations": { + "value": ["${default_location}"] + } + } + } +} diff --git a/reference-architectures/azure-landingzone/buildingblock/policies/sandbox/policy_assignments/allowed_locations_audit.tmpl.json b/reference-architectures/azure-landingzone/buildingblock/policies/sandbox/policy_assignments/allowed_locations_audit.tmpl.json new file mode 100644 index 00000000..3d6db06b --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/policies/sandbox/policy_assignments/allowed_locations_audit.tmpl.json @@ -0,0 +1,14 @@ +{ + "name": "allowed-locations-audit", + "properties": { + "displayName": "Sandbox: Allowed locations (audit)", + "description": "Audits the regions Sandbox resources are deployed to without blocking, keeping experimentation loose.", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c", + "enforce": false, + "parameters": { + "listOfAllowedLocations": { + "value": ["${default_location}"] + } + } + } +} diff --git a/reference-architectures/azure-landingzone/buildingblock/provider.tf b/reference-architectures/azure-landingzone/buildingblock/provider.tf new file mode 100644 index 00000000..46346cf8 --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/provider.tf @@ -0,0 +1,11 @@ +# The onboarding run authenticates with ambient Azure credentials: `az login` when a platform +# engineer applies this locally, or `ARM_*` environment variables (workload identity federation) +# when an IaC runtime executes it. The identity needs Owner on the landing-zones management group +# and Entra Application Administrator, because the run creates the platform service principals, +# management-group role assignments and the building block backplane identities. +provider "azurerm" { + features {} + subscription_id = var.azure_platform_subscription_id +} + +provider "azuread" {} diff --git a/reference-architectures/azure-landingzone/buildingblock/variables.tf b/reference-architectures/azure-landingzone/buildingblock/variables.tf new file mode 100644 index 00000000..8b50b0e8 --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/variables.tf @@ -0,0 +1,186 @@ +variable "workspace" { + type = string + nullable = false + description = "Identifier of the meshStack workspace that will own the created platform, location, landing zones and building block definitions." +} + +variable "use_global_location" { + type = bool + nullable = false + description = "Use the global meshStack location instead of creating a dedicated location for this platform." +} + +variable "platform_identifier" { + type = string + nullable = false + description = "Identifier for the Azure platform created in meshStack (letters, digits and dashes only). Landing zone names are derived as `-`." + + validation { + condition = can(regex("^[a-zA-Z0-9-]+$", var.platform_identifier)) + error_message = "platform_identifier must only contain letters, digits, and dashes." + } +} + +variable "playground_mode" { + type = bool + nullable = false + description = "Deploy a throwaway platform: the platform identifier gets a random suffix so it does not occupy a name for good across the meshStack instance. Set to false for a platform that is actually used. A playground platform and the building block definitions it registers are not meant to be published to other workspaces." +} + +variable "tags" { + type = object({ + landingzone = map(list(string)) + building_block = map(list(string)) + }) + nullable = false + description = <<-EOT + Tags forwarded to the nested integrations. + `landingzone` tags are applied to the created landing zones. + `building_block` tags are applied to the nested building block definitions (budget alert, storage account, spoke network). + EOT +} + +# ── Azure platform (existing management group hierarchy is assumed) ── + +variable "azure_tenant_id" { + type = string + nullable = false + description = "Azure Entra tenant ID. Used as the ARM tenant for the building block backplanes." +} + +variable "azure_subscription_provisioning" { + type = object({ + pre_provisioned = optional(object({ + unused_subscription_name_prefix = optional(string, "unused-") + })) + customer_agreement = optional(object({ + billing_account_name = string + billing_profile_name = string + invoice_section_name = string + })) + }) + nullable = false + default = { pre_provisioned = {} } + description = <<-EOT + Azure subscription provisioning model — set exactly one: + `pre_provisioned` (default): meshStack assigns subscriptions from a pool of existing ones whose name starts with `unused_subscription_name_prefix` (default `unused-`). No MCA service principal is created. + `customer_agreement`: meshStack creates subscriptions via the given MCA billing scope. + EOT + + validation { + condition = (var.azure_subscription_provisioning.pre_provisioned != null) != (var.azure_subscription_provisioning.customer_agreement != null) + error_message = "Set exactly one of pre_provisioned or customer_agreement." + } +} + +variable "azure_subscription_owner_object_ids" { + type = list(string) + default = null + description = "Optional explicit subscription owner object IDs. If null, the applying principal is used." +} + +variable "azure_location" { + type = string + nullable = false + default = "germanywestcentral" + description = "Azure region where the building block backplane resource groups and identities are created." +} + +# ── Building block target/backplane placement ── + +variable "azure_platform_subscription_id" { + type = string + nullable = false + description = "Bare GUID of a platform-owned subscription. The azurerm provider targets it, the budget-alert and storage-account backplanes are created in it, and (as written) those two building blocks deploy their resources into it." + + validation { + condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.azure_platform_subscription_id)) + error_message = "azure_platform_subscription_id must be a bare subscription GUID, not a '/subscriptions/' path." + } +} + +# ── Spoke network hub (existing central hub is assumed) ── + +variable "azure_connectivity_subscription_id" { + type = string + nullable = false + description = "Bare GUID of the connectivity subscription where the Azure Hub Network backplane identity lives and, when foundation.hub is set, the hub vnet and firewall are created." + + validation { + condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.azure_connectivity_subscription_id)) + error_message = "azure_connectivity_subscription_id must be a bare subscription GUID, not a '/subscriptions/' path." + } +} + +variable "azure_management_groups" { + type = object({ + parent_management_group_id = string + name_prefix = optional(string, "") + landing_zones_display_name = optional(string, "Landing Zones") + corp_display_name = optional(string, "Corp") + online_display_name = optional(string, "Online") + sandbox_display_name = optional(string, "Sandbox") + connectivity_display_name = optional(string, "Connectivity") + }) + nullable = false + description = <<-EOT + Enterprise-Scale management group hierarchy (Landing Zones → Corp/Online/Sandbox, plus + Connectivity) under `parent_management_group_id`, with names prefixed by `name_prefix`. The + bootstrap step already created these; this building block adopts them via `import` blocks and + manages them. Pre-configured STATIC by the platform team at registration (parent = bootstrap scope, + same name_prefix as the bootstrap) — end users don't specify it. + EOT +} + +variable "foundation" { + type = object({ + hub = optional(object({ + address_space = optional(string, "10.0.0.0/22") + hub_vnet_name = optional(string, "hub-vnet") + hub_resource_group_name = optional(string, "hub-network") + create_gateway_subnet = optional(bool, true) + deploy_firewall = optional(bool, false) + firewall_sku_tier = optional(string, "Standard") + })) + policies = optional(bool, false) + resource_groups = optional(map(object({ location = string })), {}) + }) + default = null + description = <<-EOT + Optional Azure-side foundation this architecture provisions on top of the meshStack wiring. Leave + null to only register the platform, landing zones and building blocks. (The management group + hierarchy is configured separately via `azure_management_groups`.) + `hub`: when set, orders one Azure Hub Network instance — a hub vnet (with optional firewall) in the + connectivity subscription — that spoke networks peer into. The Azure Hub Network building block + itself is always registered. + `policies`: when true, assigns curated Enterprise-Scale policies to the Corp/Online/Sandbox + management groups. + `resource_groups`: extra platform-owned resource groups (name => { location }) created in the + platform subscription. + EOT +} + +variable "azure_backplane_subscription_id" { + type = string + default = null + description = "Optional bare GUID of the subscription where the spoke-network backplane identity is created. Defaults to azure_platform_subscription_id. Typically the hub subscription so the automation identity lives in a stable, platform-owned place." + + validation { + condition = var.azure_backplane_subscription_id == null || can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.azure_backplane_subscription_id)) + error_message = "azure_backplane_subscription_id must be a bare subscription GUID, not a '/subscriptions/' path." + } +} + +variable "hub" { + type = object({ + git_ref = optional(string, "main") + bbd_draft = optional(bool, true) + }) + const = true + default = { git_ref = "main", bbd_draft = true } + + description = <<-EOT + `git_ref`: meshstack-hub reference used to source the nested platform, budget-alert, storage-account and spoke-network integration modules. `const` so it can be interpolated into the module source at init time. + `bbd_draft`: Forwarded as-is to those nested integrations' own `hub.bbd_draft`, so their building block definition draft state tracks this architecture's own release state. + EOT +} diff --git a/reference-architectures/azure-landingzone/buildingblock/versions.tf b/reference-architectures/azure-landingzone/buildingblock/versions.tf new file mode 100644 index 00000000..600db44f --- /dev/null +++ b/reference-architectures/azure-landingzone/buildingblock/versions.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">= 1.12.0" # const variables require OpenTofu >= 1.12 / Terraform >= 1.15 + + required_providers { + meshstack = { + source = "meshcloud/meshstack" + version = ">= 0.24.0" + } + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4.64" + } + azuread = { + source = "hashicorp/azuread" + version = ">= 3.8" + } + random = { + source = "hashicorp/random" + version = ">= 3.6.0" + } + } +} diff --git a/reference-architectures/azure-landingzone/logo.svg b/reference-architectures/azure-landingzone/logo.svg new file mode 100644 index 00000000..71d042af --- /dev/null +++ b/reference-architectures/azure-landingzone/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/reference-architectures/azure-landingzone/meshstack_integration.tf b/reference-architectures/azure-landingzone/meshstack_integration.tf new file mode 100644 index 00000000..61d6c07c --- /dev/null +++ b/reference-architectures/azure-landingzone/meshstack_integration.tf @@ -0,0 +1,388 @@ +variable "meshstack" { + type = object({ + owning_workspace_identifier = string + tags = optional(map(list(string)), {}) + }) + description = "Shared meshStack context. Tags are optional and propagated to building block definition metadata." +} + +variable "hub" { + type = object({ + git_ref = optional(string, "main") + bbd_draft = optional(bool, true) + }) + const = true + + default = { + git_ref = "main" + bbd_draft = true + } + + description = <<-EOT + `git_ref`: Hub release reference. Set to a tag (e.g. 'v1.2.3') or branch or commit sha of the meshstack-hub repo. + `bbd_draft`: If true, the building block definition version is kept in draft mode. + EOT +} + +variable "playground_mode" { + type = bool + nullable = false + default = true + + description = "Deploy a throwaway platform: the platform identifier gets a random suffix so it does not occupy a name for good, and nothing is protected against deletion. Set to false for a platform that is actually used. Passed to the building block as a STATIC input, so whoever orders the architecture cannot choose. A playground platform and the building block definitions it registers are not meant to be published to other workspaces." +} + +variable "azure_bootstrap_subscription_id" { + type = string + description = "Bare GUID of the subscription where the privileged bootstrap identity (that meshStack runs this architecture as) and its resource group are created." +} + +variable "azure_bootstrap_scope" { + type = string + description = "Full resource path where the bootstrap identity is granted Owner — high enough to cover everything the architecture provisions (management groups it creates, role assignments, subscriptions). Typically the tenant root management group." +} + +variable "azure_location" { + type = string + default = "germanywestcentral" + description = "Azure region for the bootstrap identity's resource group." +} + +variable "azure_management_group_prefix" { + type = string + default = "" + description = "Prefix for the created management group names/IDs (e.g. 'flotest-az-'), to keep them unique across the tenant. The bootstrap creates the MGs with this prefix; the building block imports/manages the same names." +} + +output "building_block_definition" { + description = "BBD is consumed in building block compositions." + value = { + uuid = meshstack_building_block_definition.this.metadata.uuid + version_ref = var.hub.bbd_draft ? meshstack_building_block_definition.this.version_latest : meshstack_building_block_definition.this.version_latest_release + } +} + +data "meshstack_integrations" "this" {} + +# Privileged identity meshStack runs the ordered building block as — federated to this very building +# block definition. Created once by whoever applies this integration (Owner on the scope + able to +# grant Microsoft Graph app roles). Its client id is wired into the ARM_CLIENT_ID input below. +module "bootstrap" { + source = "github.com/meshcloud/meshstack-hub//reference-architectures/azure-landingzone/bootstrap?ref=${var.hub.git_ref}" + + scope = var.azure_bootstrap_scope + subscription_id = var.azure_bootstrap_subscription_id + location = var.azure_location + management_group_name_prefix = var.azure_management_group_prefix + + workload_identity_federation = { + issuer = data.meshstack_integrations.this.workload_identity_federation.replicator.issuer + subjects = [ + "${trimsuffix(data.meshstack_integrations.this.workload_identity_federation.replicator.subject, ":replicator")}:workspace.${var.meshstack.owning_workspace_identifier}.buildingblockdefinition.${meshstack_building_block_definition.this.metadata.uuid}" + ] + } +} + +resource "meshstack_building_block_definition" "this" { + metadata = { + owned_by_workspace = var.meshstack.owning_workspace_identifier + tags = var.meshstack.tags + } + + spec = { + display_name = "Azure Landing Zone Reference Architecture" + symbol = "https://raw.githubusercontent.com/meshcloud/meshstack-hub/${var.hub.git_ref}/reference-architectures/azure-landingzone/buildingblock/logo.svg" + description = "Onboards an Azure Subscription platform into meshStack on top of an existing Enterprise-Scale management group hierarchy: creates Corp/Online/Sandbox landing zones and registers the budget-alert, storage-account and spoke-network building blocks." + support_url = "https://portal.azure.com" + target_type = "WORKSPACE_LEVEL" + run_transparency = true + + readme = chomp(<<-EOT + The **Azure Landing Zone** reference architecture turns an existing Azure Enterprise-Scale + management group hierarchy into a self-service-ready meshStack platform in one run. It assumes + the management groups (a parent "Landing Zones" group with Corp, Online and Sandbox beneath it) + and a central network hub already exist — it does not create them. + + Running it once: + - registers the **Azure Subscription** platform in meshStack, + - creates one landing zone per Enterprise-Scale archetype — **Corp** (internal, hub-connected), + **Online** (internet-facing) and **Sandbox** (experimentation) — each pointing at its + management group, and + - registers the **Azure Budget Alert**, **Azure Storage Account** and **Azure Spoke Network** + building blocks, each with its own backplane identity, so application teams can order them. + + ## 🎯 When to use it + + Use this building block when you have an Enterprise-Scale management group hierarchy and want to + onboard it into meshStack as a self-service Azure platform with ready-to-order building blocks, + without hand-wiring the platform, landing zones and backplanes separately. + + ## 💡 Usage + + A platform engineer runs this once for a workspace. It authenticates to Azure with the applying + identity, which needs **Owner** on the landing-zones management group and **Entra Application + Administrator** — for example running locally after `az login`, or through an IaC runtime with + workload-identity-federation `ARM_*` environment variables. + + Application teams then request Azure subscriptions through the Corp, Online or Sandbox landing + zone and order the registered building blocks into them. The spoke-network building block is + best paired with the Corp landing zone for hub-connected workloads. + + ## 🧪 Playground mode + + **Playground Mode** is fixed by whoever deployed this definition and cannot be chosen when + ordering. It defaults to `true`, which deploys a throwaway platform: the platform identifier + gets a random suffix so it does not occupy a name for good across the meshStack instance. Such a + platform and the building block definitions it registers are meant for the deploying workspace + only — do not publish them to other workspaces. Set it to `false` for a platform that is + actually used. + + ## 📊 Shared responsibility + + | Responsibility | Platform Team | Application Team | + |---|:---:|:---:| + | Provide the Azure credentials, management group IDs, billing details and hub network details | ✅ | ❌ | + | Register the Azure platform and the Corp/Online/Sandbox landing zones | ✅ | ❌ | + | Register the budget-alert, storage-account and spoke-network building blocks | ✅ | ❌ | + | Request Azure subscriptions through the landing zones | ❌ | ✅ | + | Order the registered building blocks into their subscriptions | ❌ | ✅ | + | Manage workloads inside the provisioned subscriptions | ❌ | ✅ | + EOT + ) + } + + version_spec = { + draft = var.hub.bbd_draft + deletion_mode = "DELETE" + + # Ephemeral API key permissions for the meshStack resources this building block and its nested + # platform/hub-network/budget-alert/storage-account/spoke-network integrations create. + permissions = [ + "INTEGRATION_LIST", + "BUILDINGBLOCKDEFINITION_LIST", + "BUILDINGBLOCKDEFINITION_SAVE", + "BUILDINGBLOCKDEFINITION_DELETE", + "BUILDINGBLOCK_LIST", + "BUILDINGBLOCK_SAVE", + "BUILDINGBLOCK_DELETE", + "LANDINGZONE_LIST", + "LANDINGZONE_SAVE", + "LANDINGZONE_DELETE", + "PLATFORMINSTANCE_LIST", + "PLATFORMINSTANCE_SAVE", + "PLATFORMINSTANCE_DELETE" + ] + + implementation = { + terraform = { + terraform_version = "1.12.5" + repository_url = "https://github.com/meshcloud/meshstack-hub.git" + repository_path = "reference-architectures/azure-landingzone/buildingblock" + ref_name = var.hub.git_ref + use_mesh_http_backend_fallback = true + } + } + + inputs = { + # ── Azure authentication ── + # The ordered run authenticates as the bootstrap identity via workload identity federation; + # meshStack injects its OIDC token. No secret is stored. + + ARM_CLIENT_ID = { + display_name = "ARM Client ID" + description = "Client ID of the bootstrap managed identity the run authenticates as." + type = "STRING" + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(module.bootstrap.identity.client_id) + } + ARM_TENANT_ID = { + display_name = "ARM Tenant ID" + description = "Azure Entra tenant ID of the bootstrap managed identity." + type = "STRING" + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(module.bootstrap.identity.tenant_id) + } + ARM_USE_OIDC = { + display_name = "ARM Use OIDC" + description = "Enables OIDC-based workload identity federation for the Azure provider." + type = "STRING" + assignment_type = "STATIC" + is_environment = true + argument = jsonencode("true") + } + ARM_OIDC_TOKEN_FILE_PATH = { + display_name = "ARM OIDC Token File Path" + description = "Path to the OIDC token file meshStack mounts for workload identity federation." + type = "STRING" + assignment_type = "STATIC" + is_environment = true + argument = jsonencode("/var/run/secrets/workload-identity/azure/token") + } + + hub = { + display_name = "Hub" + description = "HCL object with `git_ref` (meshstack-hub ref used to source the nested modules) and `bbd_draft` (forwarded to the nested definitions' draft state)." + type = "CODE" + assignment_type = "STATIC" + argument = jsonencode(jsonencode(var.hub)) + } + + # ── meshStack context ── + + workspace = { + display_name = "Workspace Identifier" + description = "Workspace that will own the created platform, location, landing zones and building block definitions." + type = "STRING" + assignment_type = "WORKSPACE_IDENTIFIER" + } + + platform_identifier = { + display_name = "Platform Identifier" + description = "Identifier for the Azure platform created in meshStack (letters, digits and dashes only)." + type = "STRING" + assignment_type = "USER_INPUT" + value_validation_regex = "^[a-zA-Z0-9-]+$" + validation_regex_error_message = "platform_identifier must only contain letters, digits, and dashes." + } + + use_global_location = { + display_name = "Use Global Location" + description = "If true, use the existing global meshStack location instead of creating a dedicated location for this platform." + type = "BOOLEAN" + assignment_type = "USER_INPUT" + default_value = jsonencode(false) + } + + tags = { + display_name = "Tags" + description = "HCL object of tag maps forwarded to the nested integrations: `landingzone` for the landing zones, `building_block` for the registered building block definitions." + type = "CODE" + assignment_type = "USER_INPUT" + updateable_by_consumer = true + + default_value = jsonencode(jsonencode({ + landingzone = {} + building_block = {} + })) + } + + playground_mode = { + display_name = "Playground Mode" + description = "Throwaway deployment: identifier gets a random suffix, nothing is protected from deletion. Do not publish such a platform to other workspaces. Set false for real use." + type = "BOOLEAN" + assignment_type = "STATIC" + argument = jsonencode(var.playground_mode) + } + + # ── Foundation — what Azure-side infra this run provisions (edit the template; remove blocks to skip) ── + + foundation = { + display_name = "Foundation" + description = "HCL object — fill in what to provision: `hub` (hub vnet), `policies` (ES policies), `resource_groups`. Remove a block to skip it. (Management groups are platform-configured, not here.)" + type = "CODE" + assignment_type = "USER_INPUT" + + # A ready-to-edit template (not null): adjust the hub CIDR, toggle policies, and delete any + # block you don't want provisioned. + default_value = jsonencode(jsonencode({ + hub = { + address_space = "10.0.0.0/22" + deploy_firewall = false + } + policies = true + resource_groups = {} + })) + } + + # ── Azure platform ── + + # STATIC — the tenant is the bootstrap identity's tenant, known at registration. + azure_tenant_id = { + display_name = "Azure Tenant ID" + description = "Azure Entra tenant ID (the bootstrap identity's tenant)." + type = "STRING" + assignment_type = "STATIC" + argument = jsonencode(module.bootstrap.identity.tenant_id) + } + + # Platform-configured (STATIC): creates the Corp/Online/Sandbox/Connectivity hierarchy under the + # bootstrap scope. End users don't configure management groups. + azure_management_groups = { + display_name = "Management Groups" + description = "Platform-configured: creates the Corp/Online/Sandbox/Connectivity management group hierarchy under the bootstrap scope." + type = "CODE" + assignment_type = "STATIC" + argument = jsonencode(jsonencode({ + parent_management_group_id = var.azure_bootstrap_scope + name_prefix = var.azure_management_group_prefix + })) + } + + azure_subscription_provisioning = { + display_name = "Subscription Provisioning" + description = "HCL object — set exactly one: `pre_provisioned` (assign from a pool named `unused-*`) or `customer_agreement` (create via MCA billing account/profile/invoice section)." + type = "CODE" + assignment_type = "USER_INPUT" + default_value = jsonencode(jsonencode({ pre_provisioned = { unused_subscription_name_prefix = "unused-" } })) + } + + azure_location = { + display_name = "Azure Location" + description = "Azure region where the building block backplane resource groups and identities are created." + type = "STRING" + assignment_type = "USER_INPUT" + default_value = jsonencode("germanywestcentral") + } + + azure_platform_subscription_id = { + display_name = "Platform Subscription ID" + description = "Bare GUID of a platform-owned subscription. Hosts the **Budget Alert** and **Storage Account** backplanes (and, as written, the resources they deploy)." + type = "STRING" + assignment_type = "USER_INPUT" + value_validation_regex = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + validation_regex_error_message = "Platform subscription ID must be a bare subscription GUID." + } + + # ── Connectivity (Azure Hub Network building block, always registered) ── + + azure_connectivity_subscription_id = { + display_name = "Connectivity Subscription ID" + description = "Bare GUID of the **connectivity** subscription. Hosts the Hub Network backplane and, when `foundation.hub` is set, the hub vnet and firewall." + type = "STRING" + assignment_type = "USER_INPUT" + value_validation_regex = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + validation_regex_error_message = "Connectivity subscription ID must be a bare subscription GUID." + } + + } + + outputs = { + summary = { + display_name = "Summary" + type = "STRING" + assignment_type = "SUMMARY" + } + } + } +} + +terraform { + required_version = ">= 1.12.0" + + required_providers { + meshstack = { + source = "meshcloud/meshstack" + version = ">= 0.24.0" + } + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4.36" + } + azuread = { + source = "hashicorp/azuread" + version = ">= 3.0" + } + } +}