diff --git a/modules/azure/resource-group/backplane/documentation.tf b/modules/azure/resource-group/backplane/documentation.tf new file mode 100644 index 00000000..26031d9f --- /dev/null +++ b/modules/azure/resource-group/backplane/documentation.tf @@ -0,0 +1,31 @@ +output "documentation_md" { + value = <-`, +ensuring consistent naming across all landing zones. + +# Azure Resource Group Building Block Backplane + +This module automates the IAM setup required for the Resource Group building block within Azure. + +## Role Definition + +| Name | ID | +| --- | --- | +| ${azurerm_role_definition.buildingblock_deploy.name} | ${azurerm_role_definition.buildingblock_deploy.id} | + +## Role Assignments + +| Principal ID | +| --- | +| ${join("\n", concat([for assignment in azurerm_role_assignment.existing_principals : assignment.principal_id], var.create_service_principal_name != null ? [azurerm_role_assignment.created_principal[0].principal_id] : []))} | + +## Scope + +- **Scope**: `${var.scope}` + +EOF + description = "Markdown documentation with information about the Resource Group building block backplane." +} diff --git a/modules/azure/resource-group/backplane/main.tf b/modules/azure/resource-group/backplane/main.tf new file mode 100644 index 00000000..15774b7c --- /dev/null +++ b/modules/azure/resource-group/backplane/main.tf @@ -0,0 +1,82 @@ +data "azurerm_subscription" "current" {} + +resource "azuread_application" "buildingblock_deploy" { + count = var.create_service_principal_name != null ? 1 : 0 + + display_name = "${var.name}-${var.create_service_principal_name}" +} + +resource "azuread_service_principal" "buildingblock_deploy" { + count = var.create_service_principal_name != null ? 1 : 0 + + client_id = azuread_application.buildingblock_deploy[0].client_id + app_role_assignment_required = false +} + +resource "azuread_application_federated_identity_credential" "buildingblock_deploy" { + count = var.create_service_principal_name != null && var.workload_identity_federation != null ? 1 : 0 + + application_id = azuread_application.buildingblock_deploy[0].id + display_name = var.create_service_principal_name + audiences = ["api://AzureADTokenExchange"] + issuer = var.workload_identity_federation.issuer + subject = var.workload_identity_federation.subject +} + +resource "azuread_application_password" "buildingblock_deploy" { + count = var.create_service_principal_name != null && var.workload_identity_federation == null ? 1 : 0 + + application_id = azuread_application.buildingblock_deploy[0].id + display_name = "${var.create_service_principal_name}-password" +} + +resource "azurerm_role_definition" "buildingblock_deploy" { + name = "${var.name}-deploy" + description = "Enables deployment of the ${var.name} building block to subscriptions" + scope = var.scope + + permissions { + actions = [ + # Register resource providers in Azure Resource Manager + "*/register/action", + + # Resource Groups - full lifecycle management + "Microsoft.Resources/subscriptions/resourceGroups/*", + + # Read subscription providers + "Microsoft.Resources/subscriptions/providers/read", + ] + } +} + +resource "azurerm_role_assignment" "existing_principals" { + for_each = var.existing_principal_ids + + role_definition_id = azurerm_role_definition.buildingblock_deploy.role_definition_resource_id + principal_id = each.value + scope = var.scope +} + +resource "azurerm_role_assignment" "created_principal" { + count = var.create_service_principal_name != null ? 1 : 0 + + role_definition_id = azurerm_role_definition.buildingblock_deploy.role_definition_resource_id + principal_id = azuread_service_principal.buildingblock_deploy[0].object_id + scope = var.scope +} + +resource "azuread_directory_role" "directory_readers" { + display_name = "Directory Readers" +} + +resource "azuread_directory_role_assignment" "directory_readers_existing" { + for_each = var.existing_principal_ids + role_id = azuread_directory_role.directory_readers.template_id + principal_object_id = each.value +} + +resource "azuread_directory_role_assignment" "directory_readers_created" { + count = var.create_service_principal_name != null ? 1 : 0 + role_id = azuread_directory_role.directory_readers.template_id + principal_object_id = azuread_service_principal.buildingblock_deploy[0].object_id +} diff --git a/modules/azure/resource-group/backplane/outputs.tf b/modules/azure/resource-group/backplane/outputs.tf new file mode 100644 index 00000000..2810be8f --- /dev/null +++ b/modules/azure/resource-group/backplane/outputs.tf @@ -0,0 +1,69 @@ +output "role_definition_id" { + value = azurerm_role_definition.buildingblock_deploy.id + description = "The ID of the role definition that enables deployment of the building block." +} + +output "role_definition_name" { + value = azurerm_role_definition.buildingblock_deploy.name + description = "The name of the role definition that enables deployment of the building block." +} + +output "role_assignment_ids" { + value = concat( + [for id in azurerm_role_assignment.existing_principals : id.id], + var.create_service_principal_name != null ? [azurerm_role_assignment.created_principal[0].id] : [] + ) + description = "The IDs of the role assignments for all service principals." +} + +output "role_assignment_principal_ids" { + value = concat( + [for id in azurerm_role_assignment.existing_principals : id.principal_id], + var.create_service_principal_name != null ? [azurerm_role_assignment.created_principal[0].principal_id] : [] + ) + description = "The principal IDs of all service principals that have been assigned the role." +} + +output "created_service_principal" { + value = var.create_service_principal_name != null ? { + object_id = azuread_service_principal.buildingblock_deploy[0].object_id + client_id = azuread_service_principal.buildingblock_deploy[0].client_id + display_name = azuread_service_principal.buildingblock_deploy[0].display_name + name = var.create_service_principal_name + } : null + description = "Information about the created service principal." +} + +output "created_application" { + value = var.create_service_principal_name != null ? { + object_id = azuread_application.buildingblock_deploy[0].object_id + client_id = azuread_application.buildingblock_deploy[0].client_id + display_name = azuread_application.buildingblock_deploy[0].display_name + } : null + description = "Information about the created Azure AD application." +} + +output "workload_identity_federation" { + value = var.create_service_principal_name != null && var.workload_identity_federation != null ? { + credential_id = azuread_application_federated_identity_credential.buildingblock_deploy[0].credential_id + display_name = azuread_application_federated_identity_credential.buildingblock_deploy[0].display_name + issuer = azuread_application_federated_identity_credential.buildingblock_deploy[0].issuer + subject = azuread_application_federated_identity_credential.buildingblock_deploy[0].subject + audiences = azuread_application_federated_identity_credential.buildingblock_deploy[0].audiences + } : null + description = "Information about the created workload identity federation credential." +} + +output "application_password" { + value = var.create_service_principal_name != null && var.workload_identity_federation == null ? { + key_id = azuread_application_password.buildingblock_deploy[0].key_id + display_name = azuread_application_password.buildingblock_deploy[0].display_name + } : null + description = "Information about the created application password (excludes the actual password value for security)." + sensitive = true +} + +output "scope" { + value = var.scope + description = "The scope where the role definition and role assignments are applied." +} diff --git a/modules/azure/resource-group/backplane/provider.tf b/modules/azure/resource-group/backplane/provider.tf new file mode 100644 index 00000000..ab91b248 --- /dev/null +++ b/modules/azure/resource-group/backplane/provider.tf @@ -0,0 +1,3 @@ +provider "azurerm" { + features {} +} diff --git a/modules/azure/resource-group/backplane/variables.tf b/modules/azure/resource-group/backplane/variables.tf new file mode 100644 index 00000000..99442892 --- /dev/null +++ b/modules/azure/resource-group/backplane/variables.tf @@ -0,0 +1,40 @@ +variable "name" { + type = string + nullable = false + default = "resource-group" + description = "Name of the building block, used for naming Azure resources." + 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 = "Scope where the building block should be deployable, typically the parent management group of all landing zones." +} + +variable "existing_principal_ids" { + type = set(string) + nullable = false + default = [] + description = "Set of existing principal IDs that will be granted permissions to deploy the building block." +} + +variable "create_service_principal_name" { + type = string + nullable = true + default = null + description = "If set, creates a new service principal with the given name for deploying the building block." +} + +variable "workload_identity_federation" { + type = object({ + issuer = string + subject = string + }) + nullable = true + default = null + description = "If set, configures workload identity federation for the created service principal." +} diff --git a/modules/azure/resource-group/backplane/versions.tf b/modules/azure/resource-group/backplane/versions.tf new file mode 100644 index 00000000..f08f9770 --- /dev/null +++ b/modules/azure/resource-group/backplane/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.64" + } + azuread = { + source = "hashicorp/azuread" + version = "~> 3.8" + } + } +} diff --git a/modules/azure/resource-group/buildingblock/main.tf b/modules/azure/resource-group/buildingblock/main.tf new file mode 100644 index 00000000..153eb7ce --- /dev/null +++ b/modules/azure/resource-group/buildingblock/main.tf @@ -0,0 +1,8 @@ +locals { + resource_group_name = "rg-${var.workspace_identifier}-${var.project_identifier}" +} + +resource "azurerm_resource_group" "this" { + name = local.resource_group_name + location = var.location +} diff --git a/modules/azure/resource-group/buildingblock/outputs.tf b/modules/azure/resource-group/buildingblock/outputs.tf new file mode 100644 index 00000000..be1ccd36 --- /dev/null +++ b/modules/azure/resource-group/buildingblock/outputs.tf @@ -0,0 +1,9 @@ +output "resource_group_name" { + value = azurerm_resource_group.this.name + description = "The name of the created resource group (e.g. 'rg-myworkspace-myproject')." +} + +output "resource_group_id" { + value = azurerm_resource_group.this.id + description = "The Azure resource ID of the created resource group." +} diff --git a/modules/azure/resource-group/buildingblock/provider.tf b/modules/azure/resource-group/buildingblock/provider.tf new file mode 100644 index 00000000..cf75d7ec --- /dev/null +++ b/modules/azure/resource-group/buildingblock/provider.tf @@ -0,0 +1,4 @@ +provider "azurerm" { + subscription_id = var.subscription_id + features {} +} diff --git a/modules/azure/resource-group/buildingblock/variables.tf b/modules/azure/resource-group/buildingblock/variables.tf new file mode 100644 index 00000000..932b79d6 --- /dev/null +++ b/modules/azure/resource-group/buildingblock/variables.tf @@ -0,0 +1,20 @@ +variable "subscription_id" { + type = string + description = "The Azure subscription ID where the resource group will be created." +} + +variable "workspace_identifier" { + type = string + description = "The meshStack workspace identifier. Used to generate the resource group name." +} + +variable "project_identifier" { + type = string + description = "The meshStack project identifier. Used to generate the resource group name." +} + +variable "location" { + type = string + description = "The Azure region where the resource group will be created (e.g. 'westeurope', 'eastus')." + default = "westeurope" +} diff --git a/modules/azure/resource-group/buildingblock/versions.tf b/modules/azure/resource-group/buildingblock/versions.tf new file mode 100644 index 00000000..c8f251b0 --- /dev/null +++ b/modules/azure/resource-group/buildingblock/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.64" + } + } +} diff --git a/modules/azure/resource-group/meshstack_integration.tf b/modules/azure/resource-group/meshstack_integration.tf new file mode 100644 index 00000000..f6bddab8 --- /dev/null +++ b/modules/azure/resource-group/meshstack_integration.tf @@ -0,0 +1,232 @@ +variable "azure_tenant_id" { + type = string + description = "Azure Entra tenant ID used for provider authentication." +} + +variable "azure_subscription_id" { + type = string + description = "Azure subscription ID used for provider authentication of the backplane service principal." +} + +variable "azure_scope" { + type = string + description = "Azure management group or subscription scope for backplane role assignment." +} + +variable "backplane_name" { + type = string + default = "azure-resource-group" + description = "Name for the backplane resources (service principal, 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) + }) + default = {} + 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 + + create_service_principal_name = var.backplane_name + + workload_identity_federation = { + issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer + subject = "${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 Resource Group" + description = "Creates an empty Azure Resource Group for a project. The resource group name is automatically generated as 'rg--'." + support_url = "mailto:support@meshcloud.io" + documentation_url = "https://hub.meshcloud.io/platforms/azure/definitions/azure-resource-group" + notification_subscribers = var.notification_subscribers + symbol = "https://raw.githubusercontent.com/meshcloud/meshstack-hub/main/modules/azure/resource-group/buildingblock/logo.png" + target_type = "TENANT_LEVEL" + supported_platforms = [{ name = "AZURE" }] + + readme = chomp(<<-EOT + ## Azure Resource Group + + This building block provisions an empty **Azure Resource Group** in a target subscription. + The resource group name is automatically derived from the meshStack context following the schema: + + ``` + rg-- + ``` + + ## When to use it? + + Use this building block when your application team needs a dedicated Azure Resource Group + to deploy resources into, with a consistent naming convention enforced across all projects. + + ## Shared Responsibilities + + | Responsibility | Platform Team | Application Team | + | --------------------------------------- | :-----------: | :--------------: | + | Set up backplane service principal | ✅ | ❌ | + | Define management group scope | ✅ | ❌ | + | Choose Azure region (location) | ❌ | ✅ | + | Deploy resources into the group | ❌ | ✅ | + EOT + ) + } + + version_spec = { + draft = var.hub.bbd_draft + + deletion_mode = "DELETE" + + implementation = { + terraform = { + terraform_version = "1.9.0" + repository_url = "https://github.com/meshcloud/meshstack-hub.git" + repository_path = "modules/azure/resource-group/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 service principal used to authenticate with Azure." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(module.backplane.created_service_principal.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 Azure subscription ID used for provider authentication." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(var.azure_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") + } + subscription_id = { + type = "STRING" + display_name = "Subscription ID" + description = "The Azure subscription ID in which the resource group will be created." + assignment_type = "PLATFORM_TENANT_ID" + } + workspace_identifier = { + type = "STRING" + display_name = "Workspace Identifier" + description = "The meshStack workspace identifier. Used to generate the resource group name (rg--)." + assignment_type = "USER_INPUT" + } + project_identifier = { + type = "STRING" + display_name = "Project Identifier" + description = "The meshStack project identifier. Used to generate the resource group name (rg--)." + assignment_type = "USER_INPUT" + } + location = { + type = "STRING" + display_name = "Location" + description = "The Azure region where the resource group will be created (e.g. 'westeurope', 'eastus')." + assignment_type = "USER_INPUT" + default_value = jsonencode("westeurope") + } + } + + outputs = { + resource_group_name = { + type = "STRING" + display_name = "Resource Group Name" + description = "The name of the created resource group (e.g. 'rg-myworkspace-myproject')." + assignment_type = "NONE" + } + resource_group_id = { + type = "STRING" + display_name = "Resource Group ID" + description = "The Azure resource ID of the created resource group." + assignment_type = "NONE" + } + } + } +} + +terraform { + required_version = ">= 1.11.0" + + required_providers { + meshstack = { + source = "meshcloud/meshstack" + version = "~> 0.20.0" + } + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.64" + } + azuread = { + source = "hashicorp/azuread" + version = "~> 3.8" + } + } +}