From 5ebae67777284440e0db630e11ca0d35ba2d3eb0 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 19 May 2026 15:47:05 +0200 Subject: [PATCH 01/11] feat: clarify use of UAMIs as preferred over SPNs --- .github/copilot-instructions.md | 151 ++++++++++++ tools/scorecard/scorecard.mjs | 425 ++++++++++++++++++++++++-------- 2 files changed, 480 insertions(+), 96 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0166f936..9af2602f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -167,6 +167,157 @@ resource "meshstack_building_block_definition" "this" { --- +## Azure Backplane Identity Conventions + +Azure backplanes **must** use **User-Assigned Managed Identities (UAMIs)** as the automation +principal for building block execution. Do **not** create Service Principals (SPNs) via +`azuread_application` + `azuread_service_principal`. + +### Rationale + +- **Self-service**: Platform engineers can deploy UAMIs without invoking a central Entra admin team. + Creating a UAMI requires only `Managed Identity Contributor` on the subscription — no Entra ID + `Application.ReadWrite.All` or `Application Administrator` role needed. +- **WIF-native**: UAMIs support federated identity credentials (`azurerm_federated_identity_credential`) + for meshStack's workload identity federation out of the box. +- **Management Group scope**: UAMIs can hold Azure RBAC role assignments at any scope including + Management Groups. They can also be assigned Entra directory roles (e.g. Directory Readers). +- **CI/CD testability**: E2E smoke tests run under a GHA UAMI with GitHub WIF in a static + subscription. Using UAMIs in backplanes means the same identity model is used end-to-end, + and `tofu test` can deploy and destroy `meshstack_integration.tf` without Entra app registration + privileges. +- **No secrets rotation**: Unlike SPNs with client secrets, UAMIs with WIF produce no secrets to + manage or rotate. + +### Implementation Pattern + +```hcl +# backplane/main.tf — UAMI-based automation principal + +resource "azurerm_user_assigned_identity" "buildingblock" { + name = var.name + location = var.location + resource_group_name = var.resource_group_name +} + +resource "azurerm_federated_identity_credential" "buildingblock" { + for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s } + + name = "subject-${each.key}" + resource_group_name = var.resource_group_name + parent_id = azurerm_user_assigned_identity.buildingblock.id + audience = ["api://AzureADTokenExchange"] + issuer = var.workload_identity_federation.issuer + subject = each.value +} + +resource "azurerm_role_definition" "buildingblock" { + name = "${var.name}-deploy" + description = "Enables deployment of the ${var.name} building block" + scope = var.scope + permissions { actions = [ /* ... */ ] } +} + +resource "azurerm_role_assignment" "buildingblock" { + scope = var.scope + role_definition_id = azurerm_role_definition.buildingblock.role_definition_resource_id + principal_id = azurerm_user_assigned_identity.buildingblock.principal_id +} +``` + +### Backplane Variables (Azure) + +Every Azure backplane must accept these variables: + +```hcl +variable "name" { + type = string + nullable = false + description = "Name for the building block identity and role definition." +} + +variable "scope" { + type = string + nullable = false + description = "Scope for role assignment (management group or subscription ID)." +} + +variable "location" { + type = string + description = "Azure region for the UAMI resource." +} + +variable "resource_group_name" { + type = string + description = "Resource group where the UAMI will be created." +} + +variable "workload_identity_federation" { + type = object({ + issuer = string + subjects = list(string) + }) + nullable = false + description = "WIF issuer and subjects for federated authentication." +} +``` + +### Backplane Outputs (Azure) + +```hcl +output "identity" { + value = { + client_id = azurerm_user_assigned_identity.buildingblock.client_id + principal_id = azurerm_user_assigned_identity.buildingblock.principal_id + tenant_id = azurerm_user_assigned_identity.buildingblock.tenant_id + } +} +``` + +### What to Avoid + +- ❌ `azuread_application` / `azuread_service_principal` — do not create SPNs +- ❌ `azuread_application_password` — no client secrets +- ❌ `existing_principal_ids` / `create_service_principal_name` toggle pattern — unnecessary complexity +- ❌ Conditional WIF-vs-secret logic — always use WIF with UAMIs + +### `meshstack_integration.tf` Wiring (Azure) + +In the integration file, pass the UAMI client ID as the `ARM_CLIENT_ID` environment variable: + +```hcl +module "backplane" { + source = "github.com/meshcloud/meshstack-hub//modules/azure//backplane?ref=${var.hub.git_ref}" + + name = var.backplane_name + scope = var.azure_scope + location = var.azure_location + resource_group_name = var.azure_resource_group_name + + 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}" + ] + } +} +``` + +The `meshstack_integration.tf` must include `azure_resource_group_name` and `azure_location` +variables (flat, provider-prefixed) for the UAMI placement. + +### Checklist for Azure Backplanes + +- [ ] Uses `azurerm_user_assigned_identity` (not `azuread_application`) +- [ ] Uses `azurerm_federated_identity_credential` (not `azuread_application_federated_identity_credential`) +- [ ] No `azuread_application_password` resources +- [ ] No `create_service_principal_name` / `existing_principal_ids` toggle +- [ ] `workload_identity_federation` variable is non-nullable (always required) +- [ ] Outputs `identity.client_id`, `identity.principal_id`, `identity.tenant_id` +- [ ] `meshstack_integration.tf` includes `azure_resource_group_name` and `azure_location` variables + +--- + ## Documentation Requirements **`buildingblock/README.md`** — must include YAML front-matter: diff --git a/tools/scorecard/scorecard.mjs b/tools/scorecard/scorecard.mjs index ca73e5d3..2364cdb9 100755 --- a/tools/scorecard/scorecard.mjs +++ b/tools/scorecard/scorecard.mjs @@ -4,8 +4,13 @@ * * Scans every building block module and assesses maturity based on * deterministic criteria derived from the repository conventions. + * Checks are organized into categories with conditional applicability: + * - Core Structure: applies to all modules + * - Integration: applies only to modules with meshstack_integration.tf + * - Azure Backplane: applies only to Azure modules with a backplane/ + * - Testing: applies to all modules (aspirational) * - * Usage: node tools/scorecard/scorecard.mjs + * Usage: node tools/scorecard/scorecard.mjs [--category=] [--provider=] */ import { readFileSync, existsSync, readdirSync, statSync } from "fs"; @@ -14,12 +19,44 @@ import { join, relative } from "path"; const ROOT = new URL("../../", import.meta.url).pathname.replace(/\/$/, ""); const MODULES_DIR = join(ROOT, "modules"); +// ─── Category definitions ─────────────────────────────────────────────────── + +const CATEGORIES = { + core: { + id: "core", + name: "Core Structure", + description: "Basic module file structure and documentation", + appliesTo: () => true, + }, + integration: { + id: "integration", + name: "Integration", + description: "meshstack_integration.tf conventions", + appliesTo: (mod) => existsSync(join(mod.path, "meshstack_integration.tf")), + }, + azure_backplane: { + id: "azure_backplane", + name: "Azure Backplane", + description: "Azure UAMI-based automation principal conventions", + appliesTo: (mod) => + mod.provider === "azure" && existsSync(join(mod.path, "backplane")), + }, + testing: { + id: "testing", + name: "Testing", + description: "End-to-end test coverage", + appliesTo: () => true, + }, +}; + // ─── Detector functions ───────────────────────────────────────────────────── // Each detector returns { pass: boolean, detail?: string } const detectors = [ + // ─── Core Structure ───────────────────────────────────────────────────── { id: "buildingblock_dir", + category: "core", name: "buildingblock/ directory exists", emoji: "📦", fn: (mod) => ({ @@ -28,6 +65,7 @@ const detectors = [ }, { id: "meshstack_integration", + category: "core", name: "meshstack_integration.tf present", emoji: "🔗", fn: (mod) => ({ @@ -36,6 +74,7 @@ const detectors = [ }, { id: "readme_frontmatter", + category: "core", name: "buildingblock/README.md with YAML front-matter", emoji: "📝", fn: (mod) => { @@ -54,14 +93,43 @@ const detectors = [ }, { id: "logo", + category: "core", name: "buildingblock/logo.png included", emoji: "🖼️", fn: (mod) => ({ pass: existsSync(join(mod.path, "buildingblock", "logo.png")), }), }, + { + id: "versions_tf", + category: "core", + name: "buildingblock/versions.tf present", + emoji: "📌", + fn: (mod) => ({ + pass: existsSync(join(mod.path, "buildingblock", "versions.tf")), + }), + }, + { + id: "provider_pinned", + category: "core", + name: "Provider versions pinned (~>)", + emoji: "🔒", + fn: (mod) => { + const versionsPath = join(mod.path, "buildingblock", "versions.tf"); + if (!existsSync(versionsPath)) return { pass: false, detail: "no versions.tf" }; + const content = readFileSync(versionsPath, "utf-8"); + const versionLines = content.match(/version\s*=\s*"[^"]+"/g); + if (!versionLines || versionLines.length === 0) + return { pass: true, detail: "no version constraints" }; + const allPinned = versionLines.every((l) => l.includes("~>")); + return { pass: allPinned }; + }, + }, + + // ─── Integration ──────────────────────────────────────────────────────── { id: "variable_hub", + category: "integration", name: 'variable "hub" in integration', emoji: "🏷️", fn: (mod) => { @@ -72,6 +140,7 @@ const detectors = [ }, { id: "variable_meshstack", + category: "integration", name: 'variable "meshstack" in integration', emoji: "🏢", fn: (mod) => { @@ -82,6 +151,7 @@ const detectors = [ }, { id: "output_bbd", + category: "integration", name: "building_block_definition output exposed", emoji: "📤", fn: (mod) => { @@ -94,6 +164,7 @@ const detectors = [ }, { id: "required_providers_meshstack", + category: "integration", name: "meshcloud/meshstack in required_providers", emoji: "🔌", fn: (mod) => { @@ -106,24 +177,24 @@ const detectors = [ }, { id: "variable_hub_const", + category: "integration", name: 'variable "hub" has const = true', emoji: "🔐", fn: (mod) => { const content = readIntegrationTf(mod); if (!content) return { pass: false, detail: "no integration file" }; if (!/variable\s+"hub"/.test(content)) return { pass: false, detail: 'no variable "hub"' }; - // const = true must appear somewhere in the file (it's only valid on a variable) return { pass: /^\s*const\s*=\s*true/m.test(content) }; }, }, { id: "backplane_source_hub_git_ref", + category: "integration", name: "backplane source uses var.hub.git_ref", emoji: "📎", fn: (mod) => { const content = readIntegrationTf(mod); if (!content) return { pass: false, detail: "no integration file" }; - // If no backplane module source exists, treat as passing (backplane is optional) const hasBackplaneSource = /source\s*=\s*"[^"]*\/backplane[^"]*"/.test(content); if (!hasBackplaneSource) return { pass: true, detail: "no backplane module" }; return { @@ -134,12 +205,12 @@ const detectors = [ }, { id: "ref_name_hub_git_ref", + category: "integration", name: "ref_name uses var.hub.git_ref", emoji: "🔀", fn: (mod) => { const content = readIntegrationTf(mod); if (!content) return { pass: false, detail: "no integration file" }; - // Check that ref_name references var.hub.git_ref, not a hardcoded string const hasRefName = /ref_name\s*=/.test(content); if (!hasRefName) return { pass: false, detail: "no ref_name found" }; return { pass: /ref_name\s*=\s*var\.hub\.git_ref/.test(content) }; @@ -147,6 +218,7 @@ const detectors = [ }, { id: "bbd_draft", + category: "integration", name: "version_spec.draft uses var.hub.bbd_draft", emoji: "📋", fn: (mod) => { @@ -157,6 +229,7 @@ const detectors = [ }, { id: "bbd_tags_forwarded", + category: "integration", name: "BBD metadata.tags forwards var.meshstack.tags", emoji: "🏷️", fn: (mod) => { @@ -167,6 +240,7 @@ const detectors = [ }, { id: "bbd_readme", + category: "integration", name: "BBD readme field present", emoji: "📖", fn: (mod) => { @@ -175,8 +249,143 @@ const detectors = [ return { pass: /readme\s*=/.test(content) }; }, }, + + // ─── Azure Backplane ──────────────────────────────────────────────────── + { + id: "azure_uses_uami", + category: "azure_backplane", + name: "Uses azurerm_user_assigned_identity", + emoji: "🪪", + fn: (mod) => { + const mainTf = readBackplaneTf(mod); + if (!mainTf) return { pass: false, detail: "no backplane main.tf" }; + return { + pass: /resource\s+"azurerm_user_assigned_identity"/.test(mainTf), + }; + }, + }, + { + id: "azure_no_azuread_application", + category: "azure_backplane", + name: "No azuread_application resources", + emoji: "🚫", + fn: (mod) => { + const allTf = readAllBackplaneTf(mod); + if (!allTf) return { pass: true, detail: "no backplane tf files" }; + return { + pass: !/resource\s+"azuread_application"/.test(allTf), + detail: "azuread_application found — migrate to azurerm_user_assigned_identity", + }; + }, + }, + { + id: "azure_no_spn", + category: "azure_backplane", + name: "No azuread_service_principal resources", + emoji: "🚫", + fn: (mod) => { + const allTf = readAllBackplaneTf(mod); + if (!allTf) return { pass: true, detail: "no backplane tf files" }; + return { + pass: !/resource\s+"azuread_service_principal"/.test(allTf), + detail: "azuread_service_principal found — migrate to UAMI", + }; + }, + }, + { + id: "azure_no_app_password", + category: "azure_backplane", + name: "No azuread_application_password resources", + emoji: "🔑", + fn: (mod) => { + const allTf = readAllBackplaneTf(mod); + if (!allTf) return { pass: true, detail: "no backplane tf files" }; + return { + pass: !/resource\s+"azuread_application_password"/.test(allTf), + detail: "client secret found — UAMIs with WIF need no secrets", + }; + }, + }, + { + id: "azure_federated_identity_credential", + category: "azure_backplane", + name: "Uses azurerm_federated_identity_credential", + emoji: "🔗", + fn: (mod) => { + const allTf = readAllBackplaneTf(mod); + if (!allTf) return { pass: false, detail: "no backplane tf files" }; + return { + pass: /resource\s+"azurerm_federated_identity_credential"/.test(allTf), + }; + }, + }, + { + id: "azure_wif_nonnullable", + category: "azure_backplane", + name: "workload_identity_federation is non-nullable", + emoji: "⚡", + fn: (mod) => { + const varsTf = readBackplaneFile(mod, "variables.tf"); + if (!varsTf) return { pass: false, detail: "no variables.tf" }; + const hasWifVar = /variable\s+"workload_identity_federation"/.test(varsTf); + if (!hasWifVar) return { pass: false, detail: "variable not found" }; + const hasNullableFalse = /nullable\s*=\s*false/.test(varsTf); + const hasDefaultNull = /variable\s+"workload_identity_federation"[\s\S]*?default\s*=\s*null/.test(varsTf); + return { + pass: hasNullableFalse || !hasDefaultNull, + detail: hasDefaultNull ? "default = null makes WIF optional" : undefined, + }; + }, + }, + { + id: "azure_no_create_spn_toggle", + category: "azure_backplane", + name: "No create_service_principal_name toggle", + emoji: "🧹", + fn: (mod) => { + const varsTf = readBackplaneFile(mod, "variables.tf"); + if (!varsTf) return { pass: true, detail: "no variables.tf" }; + return { + pass: !/variable\s+"create_service_principal_name"/.test(varsTf), + detail: "legacy toggle pattern — remove in favour of UAMI", + }; + }, + }, + { + id: "azure_identity_output", + category: "azure_backplane", + name: 'Outputs identity (client_id, principal_id, tenant_id)', + emoji: "📤", + fn: (mod) => { + const outputsTf = readBackplaneFile(mod, "outputs.tf"); + if (!outputsTf) return { pass: false, detail: "no outputs.tf" }; + return { + pass: /output\s+"identity"/.test(outputsTf), + detail: 'missing output "identity" block', + }; + }, + }, + { + id: "azure_integration_rg_location", + category: "azure_backplane", + name: "Integration has azure_resource_group_name & azure_location", + emoji: "📍", + fn: (mod) => { + const content = readIntegrationTf(mod); + if (!content) return { pass: false, detail: "no integration file" }; + const hasRg = /variable\s+"azure_resource_group_name"/.test(content); + const hasLocation = /variable\s+"azure_location"/.test(content); + return { + pass: hasRg && hasLocation, + detail: !hasRg ? "missing azure_resource_group_name" : "missing azure_location", + }; + }, + }, + + // ─── Testing ──────────────────────────────────────────────────────────── { id: "backplane", + category: "testing", name: "backplane/ directory (optional tier)", emoji: "⚙️", fn: (mod) => ({ @@ -185,6 +394,7 @@ const detectors = [ }, { id: "e2e_tests", + category: "testing", name: "e2e/ test directory exists", emoji: "🧪", fn: (mod) => ({ @@ -193,6 +403,7 @@ const detectors = [ }, { id: "e2e_tftest", + category: "testing", name: "e2e/ contains .tftest.hcl files", emoji: "✅", fn: (mod) => { @@ -202,30 +413,6 @@ const detectors = [ return { pass: files.some((f) => f.endsWith(".tftest.hcl")) }; }, }, - { - id: "versions_tf", - name: "buildingblock/versions.tf present", - emoji: "📌", - fn: (mod) => ({ - pass: existsSync(join(mod.path, "buildingblock", "versions.tf")), - }), - }, - { - id: "provider_pinned", - name: "Provider versions pinned (~>)", - emoji: "🔒", - fn: (mod) => { - const versionsPath = join(mod.path, "buildingblock", "versions.tf"); - if (!existsSync(versionsPath)) return { pass: false, detail: "no versions.tf" }; - const content = readFileSync(versionsPath, "utf-8"); - // Check if there are version constraints and they use ~> - const versionLines = content.match(/version\s*=\s*"[^"]+"/g); - if (!versionLines || versionLines.length === 0) - return { pass: true, detail: "no version constraints" }; - const allPinned = versionLines.every((l) => l.includes("~>")); - return { pass: allPinned }; - }, - }, ]; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -236,6 +423,26 @@ function readIntegrationTf(mod) { return readFileSync(p, "utf-8"); } +function readBackplaneTf(mod) { + const p = join(mod.path, "backplane", "main.tf"); + if (!existsSync(p)) return null; + return readFileSync(p, "utf-8"); +} + +function readBackplaneFile(mod, filename) { + const p = join(mod.path, "backplane", filename); + if (!existsSync(p)) return null; + return readFileSync(p, "utf-8"); +} + +function readAllBackplaneTf(mod) { + const backplaneDir = join(mod.path, "backplane"); + if (!existsSync(backplaneDir)) return null; + const files = readdirSync(backplaneDir).filter((f) => f.endsWith(".tf")); + if (files.length === 0) return null; + return files.map((f) => readFileSync(join(backplaneDir, f), "utf-8")).join("\n"); +} + function discoverModules() { const modules = []; const providers = readdirSync(MODULES_DIR).filter((d) => @@ -252,8 +459,6 @@ function discoverModules() { for (const service of services) { const modulePath = join(providerDir, service); - // Only consider directories that have a buildingblock/ subdirectory - // or a meshstack_integration.tf (these are actual modules) const hasBB = existsSync(join(modulePath, "buildingblock")); const hasIntegration = existsSync( join(modulePath, "meshstack_integration.tf") @@ -275,18 +480,46 @@ function discoverModules() { // ─── Main ─────────────────────────────────────────────────────────────────── function main() { - const modules = discoverModules(); + const args = process.argv.slice(2); + const filterCategory = args.find((a) => a.startsWith("--category="))?.split("=")[1]; + const filterProvider = args.find((a) => a.startsWith("--provider="))?.split("=")[1]; + + let modules = discoverModules(); + if (filterProvider) { + modules = modules.filter((m) => m.provider === filterProvider); + } + const results = []; for (const mod of modules) { - const checks = detectors.map((d) => ({ - ...d, - result: d.fn(mod), - })); - const passed = checks.filter((c) => c.result.pass).length; - const total = checks.length; - const score = Math.round((passed / total) * 100); - results.push({ mod, checks, passed, total, score }); + const categoryResults = {}; + + for (const [catId, cat] of Object.entries(CATEGORIES)) { + if (filterCategory && catId !== filterCategory) continue; + + const applicable = cat.appliesTo(mod); + const catDetectors = detectors.filter((d) => d.category === catId); + const checks = catDetectors.map((d) => ({ + ...d, + result: applicable ? d.fn(mod) : { pass: null, detail: "not applicable" }, + })); + + const applicableChecks = checks.filter((c) => c.result.pass !== null); + const passed = applicableChecks.filter((c) => c.result.pass).length; + const total = applicableChecks.length; + const score = total > 0 ? Math.round((passed / total) * 100) : null; + + categoryResults[catId] = { checks, passed, total, score, applicable }; + } + + const allApplicableChecks = Object.values(categoryResults) + .flatMap((cr) => cr.checks) + .filter((c) => c.result.pass !== null); + const totalPassed = allApplicableChecks.filter((c) => c.result.pass).length; + const totalChecks = allApplicableChecks.length; + const overallScore = totalChecks > 0 ? Math.round((totalPassed / totalChecks) * 100) : null; + + results.push({ mod, categoryResults, passed: totalPassed, total: totalChecks, score: overallScore }); } // ─── Render Report ────────────────────────────────────────────────────── @@ -294,80 +527,80 @@ function main() { lines.push("# 📊 meshstack-hub Module Scorecard"); lines.push(""); lines.push( - `> Generated: ${new Date().toISOString().split("T")[0]} | Modules scanned: **${modules.length}** | Criteria: **${detectors.length}**` + `> Generated: ${new Date().toISOString().split("T")[0]} | Modules scanned: **${modules.length}** | Categories: **${Object.keys(CATEGORIES).length}**` ); lines.push(""); - // Legend - lines.push("## Legend"); - lines.push(""); - lines.push("| Emoji | Criterion |"); - lines.push("|-------|-----------|"); - for (const d of detectors) { - lines.push(`| ${d.emoji} | ${d.name} |`); - } - lines.push(""); + const categoriesToRender = filterCategory + ? { [filterCategory]: CATEGORIES[filterCategory] } + : CATEGORIES; - // Per-module results table - lines.push("## Module Scores"); - lines.push(""); + for (const [catId, cat] of Object.entries(categoriesToRender)) { + const catDetectors = detectors.filter((d) => d.category === catId); + const applicableModules = results.filter((r) => r.categoryResults[catId]?.applicable); - const headerCols = detectors.map((d) => d.emoji).join(" | "); - lines.push(`| Module | Score | ${headerCols} |`); - lines.push( - `|--------|-------|${detectors.map(() => "---").join("|")}|` - ); + lines.push(`## ${cat.name}`); + lines.push(""); + lines.push(`*${cat.description}* — applies to **${applicableModules.length}** modules`); + lines.push(""); - for (const r of results) { - const checkMarks = r.checks - .map((c) => (c.result.pass ? "✅" : "❌")) - .join(" | "); - const scoreEmoji = r.score >= 80 ? "🟢" : r.score >= 50 ? "🟡" : "🔴"; + if (applicableModules.length === 0) { + lines.push("No applicable modules."); + lines.push(""); + continue; + } + + const headerCols = catDetectors.map((d) => d.emoji).join(" | "); + lines.push(`| Module | Score | ${headerCols} |`); lines.push( - `| \`${r.mod.id}\` | ${scoreEmoji} ${r.score}% | ${checkMarks} |` + `|--------|-------|${catDetectors.map(() => "---").join("|")}|` ); - } - lines.push(""); - // ─── Summary Statistics ───────────────────────────────────────────────── - lines.push("## 📈 Summary Statistics"); - lines.push(""); - - const totalModules = results.length; + for (const r of applicableModules) { + const cr = r.categoryResults[catId]; + const checkMarks = cr.checks + .map((c) => (c.result.pass === null ? "➖" : c.result.pass ? "✅" : "❌")) + .join(" | "); + const scoreEmoji = cr.score >= 80 ? "🟢" : cr.score >= 50 ? "🟡" : "🔴"; + lines.push( + `| \`${r.mod.id}\` | ${scoreEmoji} ${cr.score}% | ${checkMarks} |` + ); + } + lines.push(""); - for (const d of detectors) { - const passing = results.filter( - (r) => r.checks.find((c) => c.id === d.id).result.pass - ).length; - const pct = Math.round((passing / totalModules) * 100); - const bar = pct >= 80 ? "🟢" : pct >= 50 ? "🟡" : "🔴"; - lines.push( - `| ${d.emoji} | ${d.name} | **${passing}/${totalModules}** modules | ${bar} ${pct}% |` - ); + lines.push(`### ${cat.name} — Summary`); + lines.push(""); + lines.push("| Emoji | Criterion | Coverage | Status |"); + lines.push("|-------|-----------|----------|--------|"); + for (const d of catDetectors) { + const passing = applicableModules.filter( + (r) => r.categoryResults[catId].checks.find((c) => c.id === d.id)?.result.pass + ).length; + const pct = Math.round((passing / applicableModules.length) * 100); + const bar = pct >= 80 ? "🟢" : pct >= 50 ? "🟡" : "🔴"; + lines.push( + `| ${d.emoji} | ${d.name} | **${passing}/${applicableModules.length}** | ${bar} ${pct}% |` + ); + } + lines.push(""); } - // Table header for summary - const summaryHeader = [ - "| Emoji | Criterion | Coverage | Status |", - "|-------|-----------|----------|--------|", - ]; - // Re-insert header before data rows - const summaryStart = lines.lastIndexOf("## 📈 Summary Statistics"); - lines.splice(summaryStart + 2, 0, ...summaryHeader); - + // ─── Overall Summary ──────────────────────────────────────────────────── + lines.push("## 📈 Overall Summary"); lines.push(""); - // Overall average - const avgScore = Math.round( - results.reduce((s, r) => s + r.score, 0) / totalModules - ); + // When filtering by category, only count modules where that category applies + const scoredResults = results.filter((r) => r.total > 0); + const totalModules = scoredResults.length; + const avgScore = totalModules > 0 + ? Math.round(scoredResults.reduce((s, r) => s + (r.score || 0), 0) / totalModules) + : 0; lines.push(`### Overall Average Score: **${avgScore}%**`); lines.push(""); - // Score distribution - const high = results.filter((r) => r.score >= 80).length; - const mid = results.filter((r) => r.score >= 50 && r.score < 80).length; - const low = results.filter((r) => r.score < 50).length; + const high = scoredResults.filter((r) => r.score >= 80).length; + const mid = scoredResults.filter((r) => r.score >= 50 && r.score < 80).length; + const low = scoredResults.filter((r) => r.score < 50).length; lines.push("### Score Distribution"); lines.push(""); lines.push(`- 🟢 High maturity (≥80%): **${high}** modules`); From 9e6434d578f7d89c354ac51c10b3b860362e55f4 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 19 May 2026 15:56:29 +0200 Subject: [PATCH 02/11] docs: document agent instructions for azure modules standardize on AGENTS.md format for all agent instructions, and add detailed conventions for Azure backplanes based on our learnings from the first few implementations. This includes the rationale for using UAMIs, the implementation pattern, required variables/outputs, wiring in `meshstack_integration.tf`, and a checklist for Azure backplanes. --- .../azure-backplane.instructions.md | 152 ++++++++++++++++++ .github/copilot-instructions.md => AGENTS.md | 150 +---------------- 2 files changed, 155 insertions(+), 147 deletions(-) create mode 100644 .github/instructions/azure-backplane.instructions.md rename .github/copilot-instructions.md => AGENTS.md (74%) diff --git a/.github/instructions/azure-backplane.instructions.md b/.github/instructions/azure-backplane.instructions.md new file mode 100644 index 00000000..6f44a04c --- /dev/null +++ b/.github/instructions/azure-backplane.instructions.md @@ -0,0 +1,152 @@ +--- +applyTo: modules/azure/** +--- + +# Azure Backplane Identity Conventions + +Azure backplanes **must** use **User-Assigned Managed Identities (UAMIs)** as the automation +principal for building block execution. Do **not** create Service Principals (SPNs) via +`azuread_application` + `azuread_service_principal`. + +## Rationale + +- **Self-service**: Platform engineers can deploy UAMIs without invoking a central Entra admin team. + Creating a UAMI requires only `Managed Identity Contributor` on the subscription — no Entra ID + `Application.ReadWrite.All` or `Application Administrator` role needed. +- **WIF-native**: UAMIs support federated identity credentials (`azurerm_federated_identity_credential`) + for meshStack's workload identity federation out of the box. +- **Management Group scope**: UAMIs can hold Azure RBAC role assignments at any scope including + Management Groups. They can also be assigned Entra directory roles (e.g. Directory Readers). +- **CI/CD testability**: E2E smoke tests run under a GHA UAMI with GitHub WIF in a static + subscription. Using UAMIs in backplanes means the same identity model is used end-to-end, + and `tofu test` can deploy and destroy `meshstack_integration.tf` without Entra app registration + privileges. +- **No secrets rotation**: Unlike SPNs with client secrets, UAMIs with WIF produce no secrets to + manage or rotate. + +## Implementation Pattern + +```hcl +# backplane/main.tf — UAMI-based automation principal + +resource "azurerm_user_assigned_identity" "buildingblock" { + name = var.name + location = var.location + resource_group_name = var.resource_group_name +} + +resource "azurerm_federated_identity_credential" "buildingblock" { + for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s } + + name = "subject-${each.key}" + resource_group_name = var.resource_group_name + parent_id = azurerm_user_assigned_identity.buildingblock.id + audience = ["api://AzureADTokenExchange"] + issuer = var.workload_identity_federation.issuer + subject = each.value +} + +resource "azurerm_role_definition" "buildingblock" { + name = "${var.name}-deploy" + description = "Enables deployment of the ${var.name} building block" + scope = var.scope + permissions { actions = [ /* ... */ ] } +} + +resource "azurerm_role_assignment" "buildingblock" { + scope = var.scope + role_definition_id = azurerm_role_definition.buildingblock.role_definition_resource_id + principal_id = azurerm_user_assigned_identity.buildingblock.principal_id +} +``` + +## Backplane Variables (Azure) + +Every Azure backplane must accept these variables: + +```hcl +variable "name" { + type = string + nullable = false + description = "Name for the building block identity and role definition." +} + +variable "scope" { + type = string + nullable = false + description = "Scope for role assignment (management group or subscription ID)." +} + +variable "location" { + type = string + description = "Azure region for the UAMI resource." +} + +variable "resource_group_name" { + type = string + description = "Resource group where the UAMI will be created." +} + +variable "workload_identity_federation" { + type = object({ + issuer = string + subjects = list(string) + }) + nullable = false + description = "WIF issuer and subjects for federated authentication." +} +``` + +## Backplane Outputs (Azure) + +```hcl +output "identity" { + value = { + client_id = azurerm_user_assigned_identity.buildingblock.client_id + principal_id = azurerm_user_assigned_identity.buildingblock.principal_id + tenant_id = azurerm_user_assigned_identity.buildingblock.tenant_id + } +} +``` + +## What to Avoid + +- ❌ `azuread_application` / `azuread_service_principal` — do not create SPNs +- ❌ `azuread_application_password` — no client secrets +- ❌ `existing_principal_ids` / `create_service_principal_name` toggle pattern — unnecessary complexity +- ❌ Conditional WIF-vs-secret logic — always use WIF with UAMIs + +## `meshstack_integration.tf` Wiring (Azure) + +In the integration file, pass the UAMI client ID as the `ARM_CLIENT_ID` environment variable: + +```hcl +module "backplane" { + source = "github.com/meshcloud/meshstack-hub//modules/azure//backplane?ref=${var.hub.git_ref}" + + name = var.backplane_name + scope = var.azure_scope + location = var.azure_location + resource_group_name = var.azure_resource_group_name + + 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}" + ] + } +} +``` + +The `meshstack_integration.tf` must include `azure_resource_group_name` and `azure_location` +variables (flat, provider-prefixed) for the UAMI placement. + +## Checklist for Azure Backplanes + +- [ ] Uses `azurerm_user_assigned_identity` (not `azuread_application`) +- [ ] Uses `azurerm_federated_identity_credential` (not `azuread_application_federated_identity_credential`) +- [ ] No `azuread_application_password` resources +- [ ] No `create_service_principal_name` / `existing_principal_ids` toggle +- [ ] `workload_identity_federation` variable is non-nullable (always required) +- [ ] Outputs `identity.client_id`, `identity.principal_id`, `identity.tenant_id` +- [ ] `meshstack_integration.tf` includes `azure_resource_group_name` and `azure_location` variables diff --git a/.github/copilot-instructions.md b/AGENTS.md similarity index 74% rename from .github/copilot-instructions.md rename to AGENTS.md index 9af2602f..33458105 100644 --- a/.github/copilot-instructions.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# GitHub Copilot Instructions — meshstack-hub +# meshstack-hub — Agent Instructions ## Purpose of this Repository @@ -169,152 +169,7 @@ resource "meshstack_building_block_definition" "this" { ## Azure Backplane Identity Conventions -Azure backplanes **must** use **User-Assigned Managed Identities (UAMIs)** as the automation -principal for building block execution. Do **not** create Service Principals (SPNs) via -`azuread_application` + `azuread_service_principal`. - -### Rationale - -- **Self-service**: Platform engineers can deploy UAMIs without invoking a central Entra admin team. - Creating a UAMI requires only `Managed Identity Contributor` on the subscription — no Entra ID - `Application.ReadWrite.All` or `Application Administrator` role needed. -- **WIF-native**: UAMIs support federated identity credentials (`azurerm_federated_identity_credential`) - for meshStack's workload identity federation out of the box. -- **Management Group scope**: UAMIs can hold Azure RBAC role assignments at any scope including - Management Groups. They can also be assigned Entra directory roles (e.g. Directory Readers). -- **CI/CD testability**: E2E smoke tests run under a GHA UAMI with GitHub WIF in a static - subscription. Using UAMIs in backplanes means the same identity model is used end-to-end, - and `tofu test` can deploy and destroy `meshstack_integration.tf` without Entra app registration - privileges. -- **No secrets rotation**: Unlike SPNs with client secrets, UAMIs with WIF produce no secrets to - manage or rotate. - -### Implementation Pattern - -```hcl -# backplane/main.tf — UAMI-based automation principal - -resource "azurerm_user_assigned_identity" "buildingblock" { - name = var.name - location = var.location - resource_group_name = var.resource_group_name -} - -resource "azurerm_federated_identity_credential" "buildingblock" { - for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s } - - name = "subject-${each.key}" - resource_group_name = var.resource_group_name - parent_id = azurerm_user_assigned_identity.buildingblock.id - audience = ["api://AzureADTokenExchange"] - issuer = var.workload_identity_federation.issuer - subject = each.value -} - -resource "azurerm_role_definition" "buildingblock" { - name = "${var.name}-deploy" - description = "Enables deployment of the ${var.name} building block" - scope = var.scope - permissions { actions = [ /* ... */ ] } -} - -resource "azurerm_role_assignment" "buildingblock" { - scope = var.scope - role_definition_id = azurerm_role_definition.buildingblock.role_definition_resource_id - principal_id = azurerm_user_assigned_identity.buildingblock.principal_id -} -``` - -### Backplane Variables (Azure) - -Every Azure backplane must accept these variables: - -```hcl -variable "name" { - type = string - nullable = false - description = "Name for the building block identity and role definition." -} - -variable "scope" { - type = string - nullable = false - description = "Scope for role assignment (management group or subscription ID)." -} - -variable "location" { - type = string - description = "Azure region for the UAMI resource." -} - -variable "resource_group_name" { - type = string - description = "Resource group where the UAMI will be created." -} - -variable "workload_identity_federation" { - type = object({ - issuer = string - subjects = list(string) - }) - nullable = false - description = "WIF issuer and subjects for federated authentication." -} -``` - -### Backplane Outputs (Azure) - -```hcl -output "identity" { - value = { - client_id = azurerm_user_assigned_identity.buildingblock.client_id - principal_id = azurerm_user_assigned_identity.buildingblock.principal_id - tenant_id = azurerm_user_assigned_identity.buildingblock.tenant_id - } -} -``` - -### What to Avoid - -- ❌ `azuread_application` / `azuread_service_principal` — do not create SPNs -- ❌ `azuread_application_password` — no client secrets -- ❌ `existing_principal_ids` / `create_service_principal_name` toggle pattern — unnecessary complexity -- ❌ Conditional WIF-vs-secret logic — always use WIF with UAMIs - -### `meshstack_integration.tf` Wiring (Azure) - -In the integration file, pass the UAMI client ID as the `ARM_CLIENT_ID` environment variable: - -```hcl -module "backplane" { - source = "github.com/meshcloud/meshstack-hub//modules/azure//backplane?ref=${var.hub.git_ref}" - - name = var.backplane_name - scope = var.azure_scope - location = var.azure_location - resource_group_name = var.azure_resource_group_name - - 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}" - ] - } -} -``` - -The `meshstack_integration.tf` must include `azure_resource_group_name` and `azure_location` -variables (flat, provider-prefixed) for the UAMI placement. - -### Checklist for Azure Backplanes - -- [ ] Uses `azurerm_user_assigned_identity` (not `azuread_application`) -- [ ] Uses `azurerm_federated_identity_credential` (not `azuread_application_federated_identity_credential`) -- [ ] No `azuread_application_password` resources -- [ ] No `create_service_principal_name` / `existing_principal_ids` toggle -- [ ] `workload_identity_federation` variable is non-nullable (always required) -- [ ] Outputs `identity.client_id`, `identity.principal_id`, `identity.tenant_id` -- [ ] `meshstack_integration.tf` includes `azure_resource_group_name` and `azure_location` variables +See [.github/instructions/azure-backplane.instructions.md](.github/instructions/azure-backplane.instructions.md) for the full Azure backplane identity conventions, including UAMI patterns, WIF wiring, required variables/outputs, and the Azure backplane checklist. --- @@ -515,3 +370,4 @@ Pass `module..building_block_definition.version_ref` **directly** — do n - [ ] `meshstack` and `hub` variables are at the end of the variable section - [ ] `logo.png` included in `buildingblock/` - [ ] No trailing whitespace +- [ ] **Azure modules**: also follow the [Azure Backplane Checklist](.github/instructions/azure-backplane.instructions.md#checklist-for-azure-backplanes) From 25c9ddc15bfe86eda162b270228e6d26cddb4cf5 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 19 May 2026 15:57:39 +0200 Subject: [PATCH 03/11] feat: extend the scorecard with a category summary per module this allows us to see maturity at a glance --- tools/scorecard/scorecard.mjs | 38 +++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/tools/scorecard/scorecard.mjs b/tools/scorecard/scorecard.mjs index 2364cdb9..077f5709 100755 --- a/tools/scorecard/scorecard.mjs +++ b/tools/scorecard/scorecard.mjs @@ -494,9 +494,9 @@ function main() { for (const mod of modules) { const categoryResults = {}; + // Always compute all categories so the per-module summary is always complete. + // The filterCategory only controls which sections are *rendered*. for (const [catId, cat] of Object.entries(CATEGORIES)) { - if (filterCategory && catId !== filterCategory) continue; - const applicable = cat.appliesTo(mod); const catDetectors = detectors.filter((d) => d.category === catId); const checks = catDetectors.map((d) => ({ @@ -512,8 +512,10 @@ function main() { categoryResults[catId] = { checks, passed, total, score, applicable }; } - const allApplicableChecks = Object.values(categoryResults) - .flatMap((cr) => cr.checks) + // For overall score, only include categories matching the active filter (if any). + const allApplicableChecks = Object.entries(categoryResults) + .filter(([catId]) => !filterCategory || catId === filterCategory) + .flatMap(([, cr]) => cr.checks) .filter((c) => c.result.pass !== null); const totalPassed = allApplicableChecks.filter((c) => c.result.pass).length; const totalChecks = allApplicableChecks.length; @@ -535,6 +537,34 @@ function main() { ? { [filterCategory]: CATEGORIES[filterCategory] } : CATEGORIES; + // ─── Per-Module Category Summary ──────────────────────────────────────── + lines.push("## 📋 Per-Module Category Summary"); + lines.push(""); + lines.push("Score per category per building block. `n/a` = category does not apply to this module."); + lines.push(""); + + const summaryCategories = Object.entries(CATEGORIES); + const catHeaderCols = summaryCategories.map(([, cat]) => cat.name).join(" | "); + lines.push(`| Module | Overall | ${catHeaderCols} |`); + lines.push(`|--------|---------|${summaryCategories.map(() => "---").join("|")}|`); + + for (const r of results) { + const overallCell = r.total > 0 + ? `${r.score >= 80 ? "🟢" : r.score >= 50 ? "🟡" : "🔴"} ${r.score}%` + : "—"; + + const catCells = summaryCategories.map(([catId]) => { + const cr = r.categoryResults[catId]; + if (!cr || !cr.applicable) return "n/a"; + if (cr.score === null) return "—"; + const emoji = cr.score >= 80 ? "🟢" : cr.score >= 50 ? "🟡" : "🔴"; + return `${emoji} ${cr.score}%`; + }); + + lines.push(`| \`${r.mod.id}\` | ${overallCell} | ${catCells.join(" | ")} |`); + } + lines.push(""); + for (const [catId, cat] of Object.entries(categoriesToRender)) { const catDetectors = detectors.filter((d) => d.category === catId); const applicableModules = results.filter((r) => r.categoryResults[catId]?.applicable); From 13073b88c01914064ed2992c1a0e925086d57bac Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 19 May 2026 16:31:18 +0200 Subject: [PATCH 04/11] fix(azure/budget-alert): migrate backplane to UAMI + WIF pattern - Replace azuread_application/service_principal with azurerm_user_assigned_identity - Replace azuread_application_federated_identity_credential with azurerm_federated_identity_credential - Remove azuread_application_password and directory role assignments - Remove create_service_principal_name/existing_principal_ids toggle pattern - Add location and resource_group_name variables for UAMI placement - Add identity output (client_id, principal_id, tenant_id) - Update meshstack_integration.tf: add const=true to hub var, use var.hub.git_ref in backplane source - Add azure_resource_group_name and azure_location integration variables - Fix ARM_CLIENT_ID to reference module.backplane.identity.client_id - Fix time provider version to ~> 0.11 in buildingblock/versions.tf - Remove azuread provider from integration and e2e - Add azure_resource_group_name to e2e test fixtures --- .../azure/budget-alert/backplane/README.md | 32 +++------ .../budget-alert/backplane/documentation.tf | 7 +- modules/azure/budget-alert/backplane/main.tf | 70 ++++--------------- .../azure/budget-alert/backplane/outputs.tf | 66 +++-------------- .../azure/budget-alert/backplane/variables.tf | 27 ++++--- .../budget-alert/buildingblock/README.md | 4 +- .../budget-alert/buildingblock/versions.tf | 2 +- modules/azure/budget-alert/e2e/main.tf | 12 ++-- modules/azure/budget-alert/e2e/provider.tf | 4 -- modules/azure/budget-alert/e2e/terraform.tf | 3 - .../budget-alert/meshstack_integration.tf | 34 +++++---- 11 files changed, 82 insertions(+), 179 deletions(-) diff --git a/modules/azure/budget-alert/backplane/README.md b/modules/azure/budget-alert/backplane/README.md index f7a67efe..5c343655 100644 --- a/modules/azure/budget-alert/backplane/README.md +++ b/modules/azure/budget-alert/backplane/README.md @@ -23,40 +23,28 @@ No modules. | Name | Type | |------|------| -| [azuread_application.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/application) | resource | -| [azuread_application_federated_identity_credential.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/application_federated_identity_credential) | resource | -| [azuread_application_password.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/application_password) | resource | -| [azuread_directory_role.directory_readers](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/directory_role) | resource | -| [azuread_directory_role_assignment.directory_readers_created](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/directory_role_assignment) | resource | -| [azuread_directory_role_assignment.directory_readers_existing](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/directory_role_assignment) | resource | -| [azuread_service_principal.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/service_principal) | resource | -| [azurerm_role_assignment.created_principal](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | -| [azurerm_role_assignment.existing_principals](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | +| [azurerm_federated_identity_credential.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/federated_identity_credential) | resource | +| [azurerm_role_assignment.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | | [azurerm_role_definition.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_definition) | resource | -| [azurerm_subscription.current](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/subscription) | data source | +| [azurerm_user_assigned_identity.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/user_assigned_identity) | resource | ## Inputs | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [create\_service\_principal\_name](#input\_create\_service\_principal\_name) | if set, creates a new service principal with the given name for deploying the building block | `string` | `null` | no | -| [existing\_principal\_ids](#input\_existing\_principal\_ids) | set of existing principal ids that will be granted permissions to deploy the building block | `set(string)` | `[]` | no | -| [name](#input\_name) | name of the building block, used for naming resources | `string` | `"budget-alert"` | no | -| [scope](#input\_scope) | Scope where the building block should be deployable, typically the parent of all Landing Zones. | `string` | n/a | yes | -| [workload\_identity\_federation](#input\_workload\_identity\_federation) | if set, configures workload identity federation for the created service principal |
object({
issuer = string
subject = string
})
| `null` | no | +| [location](#input\_location) | Azure region for the UAMI resource. | `string` | n/a | yes | +| [name](#input\_name) | Name for the building block identity and role definition. | `string` | `"budget-alert"` | no | +| [resource\_group\_name](#input\_resource\_group\_name) | Resource group where the UAMI will be created. | `string` | n/a | yes | +| [scope](#input\_scope) | Scope for role assignment (management group or subscription ID). | `string` | n/a | yes | +| [workload\_identity\_federation](#input\_workload\_identity\_federation) | WIF issuer and subjects for federated authentication. |
object({
issuer = string
subjects = list(string)
})
| n/a | yes | ## Outputs | Name | Description | |------|-------------| -| [application\_password](#output\_application\_password) | Information about the created application password (excludes the actual password value for security). | -| [created\_application](#output\_created\_application) | Information about the created Azure AD application. | -| [created\_service\_principal](#output\_created\_service\_principal) | Information about the created service principal. | | [documentation\_md](#output\_documentation\_md) | Markdown documentation with information about the Budget Alert building block backplane | -| [role\_assignment\_ids](#output\_role\_assignment\_ids) | The IDs of the role assignments for all service principals. | -| [role\_assignment\_principal\_ids](#output\_role\_assignment\_principal\_ids) | The principal IDs of all service principals that have been assigned the role. | +| [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 building block. | | [role\_definition\_name](#output\_role\_definition\_name) | The name of the role definition that enables deployment of the building block. | -| [scope](#output\_scope) | The scope where the role definition and role assignments are applied. | -| [workload\_identity\_federation](#output\_workload\_identity\_federation) | Information about the created workload identity federation credential. | +| [scope](#output\_scope) | The scope where the role definition and role assignment are applied. | diff --git a/modules/azure/budget-alert/backplane/documentation.tf b/modules/azure/budget-alert/backplane/documentation.tf index 49d32d2c..46102d6c 100644 --- a/modules/azure/budget-alert/backplane/documentation.tf +++ b/modules/azure/budget-alert/backplane/documentation.tf @@ -11,7 +11,8 @@ specific needs of their application and infrastructure. # 💰 Budget Alert Building Block Backplane -This module automates the deployment of a Budget Alert building block within Azure. It utilizes the common [Azure Building Blocks Automation Infrastructure](./azure-buildingblocks-automation) +This module automates the deployment of a Budget Alert building block within Azure using a +User-Assigned Managed Identity (UAMI) with Workload Identity Federation. ## 🛠️ Role Definition @@ -20,11 +21,11 @@ This module automates the deployment of a Budget Alert building block within Azu | --- | --- | | ${azurerm_role_definition.buildingblock_deploy.name} | ${azurerm_role_definition.buildingblock_deploy.id} | -## 📝 Role Assignments +## 📝 Role Assignment | 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] : []))} | +| ${azurerm_role_assignment.buildingblock.principal_id} | ## 🎯 Scope diff --git a/modules/azure/budget-alert/backplane/main.tf b/modules/azure/budget-alert/backplane/main.tf index 057ddc2e..10603f51 100644 --- a/modules/azure/budget-alert/backplane/main.tf +++ b/modules/azure/budget-alert/backplane/main.tf @@ -1,34 +1,18 @@ -data "azurerm_subscription" "current" { +resource "azurerm_user_assigned_identity" "buildingblock" { + name = var.name + location = var.location + resource_group_name = var.resource_group_name } -resource "azuread_application" "buildingblock_deploy" { - count = var.create_service_principal_name != null ? 1 : 0 +resource "azurerm_federated_identity_credential" "buildingblock" { + for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s } - 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" + name = "subject-${each.key}" + resource_group_name = var.resource_group_name + parent_id = azurerm_user_assigned_identity.buildingblock.id + audience = ["api://AzureADTokenExchange"] + issuer = var.workload_identity_federation.issuer + subject = each.value } resource "azurerm_role_definition" "buildingblock_deploy" { @@ -51,34 +35,8 @@ resource "azurerm_role_definition" "buildingblock_deploy" { } } -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 +resource "azurerm_role_assignment" "buildingblock" { 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 + principal_id = azurerm_user_assigned_identity.buildingblock.principal_id } diff --git a/modules/azure/budget-alert/backplane/outputs.tf b/modules/azure/budget-alert/backplane/outputs.tf index 2810be8f..314a86e4 100644 --- a/modules/azure/budget-alert/backplane/outputs.tf +++ b/modules/azure/budget-alert/backplane/outputs.tf @@ -1,3 +1,12 @@ +output "identity" { + value = { + client_id = azurerm_user_assigned_identity.buildingblock.client_id + principal_id = azurerm_user_assigned_identity.buildingblock.principal_id + tenant_id = azurerm_user_assigned_identity.buildingblock.tenant_id + } + description = "The managed identity used as the automation principal for this building block." +} + 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." @@ -8,62 +17,7 @@ output "role_definition_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." + description = "The scope where the role definition and role assignment are applied." } diff --git a/modules/azure/budget-alert/backplane/variables.tf b/modules/azure/budget-alert/backplane/variables.tf index d0254192..29160b94 100644 --- a/modules/azure/budget-alert/backplane/variables.tf +++ b/modules/azure/budget-alert/backplane/variables.tf @@ -2,7 +2,7 @@ variable "name" { type = string nullable = false default = "budget-alert" - description = "name of the building block, used for naming resources" + description = "Name for the building block identity and role definition." validation { condition = can(regex("^[-a-z0-9]+$", var.name)) error_message = "Only alphanumeric lowercase characters and dashes are allowed" @@ -12,29 +12,26 @@ variable "name" { variable "scope" { type = string nullable = false - description = "Scope where the building block should be deployable, typically the parent of all Landing Zones." + description = "Scope for role assignment (management group or subscription ID)." } -variable "existing_principal_ids" { - type = set(string) +variable "location" { + type = string nullable = false - default = [] - description = "set of existing principal ids that will be granted permissions to deploy the building block" + description = "Azure region for the UAMI resource." } -variable "create_service_principal_name" { +variable "resource_group_name" { type = string - nullable = true - default = null - description = "if set, creates a new service principal with the given name for deploying the building block" + nullable = false + description = "Resource group where the UAMI will be created." } variable "workload_identity_federation" { type = object({ - issuer = string - subject = string + issuer = string + subjects = list(string) }) - nullable = true - default = null - description = "if set, configures workload identity federation for the created service principal" + nullable = false + description = "WIF issuer and subjects for federated authentication." } \ No newline at end of file diff --git a/modules/azure/budget-alert/buildingblock/README.md b/modules/azure/budget-alert/buildingblock/README.md index ddd2a23f..54ed8de2 100644 --- a/modules/azure/budget-alert/buildingblock/README.md +++ b/modules/azure/budget-alert/buildingblock/README.md @@ -22,7 +22,7 @@ Please reference the [backplane implementation](../backplane/) for the required |------|---------| | [terraform](#requirement\_terraform) | >= 1.0 | | [azurerm](#requirement\_azurerm) | ~> 4.64 | -| [time](#requirement\_time) | 0.11.1 | +| [time](#requirement\_time) | ~> 0.11 | ## Modules @@ -33,7 +33,7 @@ No modules. | Name | Type | |------|------| | [azurerm_consumption_budget_subscription.subscription_budget](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/consumption_budget_subscription) | resource | -| [time_static.start_date](https://registry.terraform.io/providers/hashicorp/time/0.11.1/docs/resources/static) | resource | +| [time_static.start_date](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/static) | resource | | [azurerm_subscription.subscription](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/subscription) | data source | ## Inputs diff --git a/modules/azure/budget-alert/buildingblock/versions.tf b/modules/azure/budget-alert/buildingblock/versions.tf index 664ceec0..7e57bcfa 100644 --- a/modules/azure/budget-alert/buildingblock/versions.tf +++ b/modules/azure/budget-alert/buildingblock/versions.tf @@ -8,7 +8,7 @@ terraform { } time = { source = "hashicorp/time" - version = "0.11.1" + version = "~> 0.11" } } } diff --git a/modules/azure/budget-alert/e2e/main.tf b/modules/azure/budget-alert/e2e/main.tf index 07e81af6..84a662d8 100644 --- a/modules/azure/budget-alert/e2e/main.tf +++ b/modules/azure/budget-alert/e2e/main.tf @@ -6,8 +6,9 @@ variable "test_context" { fixtures = object({ azure = object({ - subscription_uuid = string - entra_tenant_id = string + subscription_uuid = string + entra_tenant_id = string + resource_group_name = string }) }) }) @@ -36,9 +37,10 @@ module "budget_alert" { bbd_draft = true } - azure_tenant_id = var.test_context.fixtures.azure.entra_tenant_id - azure_subscription_id = var.test_context.fixtures.azure.subscription_uuid - azure_scope = local.azure_scope + azure_tenant_id = var.test_context.fixtures.azure.entra_tenant_id + azure_subscription_id = var.test_context.fixtures.azure.subscription_uuid + azure_scope = local.azure_scope + azure_resource_group_name = var.test_context.fixtures.azure.resource_group_name # Unique backplane name per test run so role definitions don't clash across concurrent/retried runs. backplane_name = "hub-e2e-budget-${var.test_context.name_suffix}" diff --git a/modules/azure/budget-alert/e2e/provider.tf b/modules/azure/budget-alert/e2e/provider.tf index 50f8fdfa..69325904 100644 --- a/modules/azure/budget-alert/e2e/provider.tf +++ b/modules/azure/budget-alert/e2e/provider.tf @@ -4,7 +4,3 @@ provider "azurerm" { features {} } - -provider "azuread" { - tenant_id = var.test_context.fixtures.azure.entra_tenant_id -} diff --git a/modules/azure/budget-alert/e2e/terraform.tf b/modules/azure/budget-alert/e2e/terraform.tf index 8d29dc32..39e32bf6 100644 --- a/modules/azure/budget-alert/e2e/terraform.tf +++ b/modules/azure/budget-alert/e2e/terraform.tf @@ -8,8 +8,5 @@ terraform { azurerm = { source = "hashicorp/azurerm" } - azuread = { - source = "hashicorp/azuread" - } } } diff --git a/modules/azure/budget-alert/meshstack_integration.tf b/modules/azure/budget-alert/meshstack_integration.tf index b97619ed..0072ff2a 100644 --- a/modules/azure/budget-alert/meshstack_integration.tf +++ b/modules/azure/budget-alert/meshstack_integration.tf @@ -13,6 +13,17 @@ variable "azure_scope" { description = "Azure management group or subscription scope for backplane role assignment." } +variable "azure_resource_group_name" { + type = string + description = "Resource group where the backplane UAMI will be created." +} + +variable "azure_location" { + type = string + default = "germanywestcentral" + description = "Azure region where the backplane UAMI will be created." +} + variable "backplane_name" { type = string default = "azure-budget-alert" @@ -38,6 +49,7 @@ variable "hub" { git_ref = optional(string, "main") bbd_draft = optional(bool, true) }) + const = 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. @@ -56,16 +68,18 @@ output "building_block_definition" { data "meshstack_integrations" "integrations" {} module "backplane" { - source = "github.com/meshcloud/meshstack-hub//modules/azure/budget-alert/backplane?ref=226e58cb166002c7e505d6deb9e7bbf7e9c9edd1" + source = "github.com/meshcloud/meshstack-hub//modules/azure/budget-alert/backplane?ref=${var.hub.git_ref}" - name = var.backplane_name - scope = var.azure_scope - - create_service_principal_name = var.backplane_name + name = var.backplane_name + scope = var.azure_scope + location = var.azure_location + resource_group_name = var.azure_resource_group_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}" + 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}" + ] } } @@ -128,7 +142,7 @@ resource "meshstack_building_block_definition" "this" { 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) + argument = jsonencode(module.backplane.identity.client_id) } ARM_TENANT_ID = { type = "STRING" @@ -227,9 +241,5 @@ terraform { source = "hashicorp/azurerm" version = "~> 4.64" } - azuread = { - source = "hashicorp/azuread" - version = "~> 3.8" - } } } From 910b0d4b7977e7c3afc8205abbbcb82b05c4869d Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 19 May 2026 16:39:57 +0200 Subject: [PATCH 05/11] fix(azure/storage-account): migrate backplane to UAMI + WIF pattern - Replace azuread_application/service_principal with azurerm_user_assigned_identity - Replace azuread_application_federated_identity_credential with azurerm_federated_identity_credential - Remove azuread_application_password resources - Remove create_service_principal_name/existing_principal_ids toggle pattern - Add location and resource_group_name variables for UAMI placement - Add identity output (client_id, principal_id, tenant_id) - Update meshstack_integration.tf: add const=true to hub var, use var.hub.git_ref in backplane source - Add azure_resource_group_name integration variable - Fix ARM_CLIENT_ID to reference module.backplane.identity.client_id - Remove azuread provider from backplane/versions.tf, integration, and e2e - Add azure_resource_group_name to e2e test fixtures --- .../azure/storage-account/backplane/README.md | 29 +++----- .../backplane/documentation.tf | 4 +- .../azure/storage-account/backplane/main.tf | 61 ++++------------- .../storage-account/backplane/outputs.tf | 66 +++---------------- .../storage-account/backplane/variables.tf | 27 ++++---- .../storage-account/backplane/versions.tf | 4 -- modules/azure/storage-account/e2e/main.tf | 12 ++-- modules/azure/storage-account/e2e/provider.tf | 4 -- .../azure/storage-account/e2e/terraform.tf | 3 - .../storage-account/meshstack_integration.tf | 22 ++++--- 10 files changed, 66 insertions(+), 166 deletions(-) diff --git a/modules/azure/storage-account/backplane/README.md b/modules/azure/storage-account/backplane/README.md index 283078cd..6583240c 100644 --- a/modules/azure/storage-account/backplane/README.md +++ b/modules/azure/storage-account/backplane/README.md @@ -108,7 +108,6 @@ module "storage_account_backplane" { | Name | Version | |------|---------| | [terraform](#requirement\_terraform) | >= 1.0 | -| [azuread](#requirement\_azuread) | ~> 3.8 | | [azurerm](#requirement\_azurerm) | ~> 4.64 | ## Modules @@ -119,36 +118,28 @@ No modules. | Name | Type | |------|------| -| [azuread_application.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/application) | resource | -| [azuread_application_federated_identity_credential.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/application_federated_identity_credential) | resource | -| [azuread_application_password.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/application_password) | resource | -| [azuread_service_principal.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/service_principal) | resource | -| [azurerm_role_assignment.created_principal](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | -| [azurerm_role_assignment.existing_principals](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | +| [azurerm_federated_identity_credential.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/federated_identity_credential) | resource | +| [azurerm_role_assignment.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | | [azurerm_role_definition.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_definition) | resource | +| [azurerm_user_assigned_identity.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/user_assigned_identity) | resource | ## Inputs | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [create\_service\_principal\_name](#input\_create\_service\_principal\_name) | name of a service principal to create and grant permissions to deploy the building block | `string` | `null` | no | -| [existing\_principal\_ids](#input\_existing\_principal\_ids) | set of existing principal ids that will be granted permissions to deploy the building block | `set(string)` | `[]` | no | -| [name](#input\_name) | name of the building block, used for naming resources | `string` | n/a | yes | -| [scope](#input\_scope) | Scope where the building block should be deployable, typically the parent of all Landing Zones. | `string` | n/a | yes | -| [workload\_identity\_federation](#input\_workload\_identity\_federation) | Configuration for workload identity federation. If not provided, an application password will be created instead. Supports multiple subjects. |
object({
issuer = string
subjects = list(string)
})
| `null` | no | +| [location](#input\_location) | Azure region for the UAMI resource. | `string` | n/a | yes | +| [name](#input\_name) | Name for the building block identity and role definition. | `string` | n/a | yes | +| [resource\_group\_name](#input\_resource\_group\_name) | Resource group where the UAMI will be created. | `string` | n/a | yes | +| [scope](#input\_scope) | Scope for role assignment (management group or subscription ID). | `string` | n/a | yes | +| [workload\_identity\_federation](#input\_workload\_identity\_federation) | WIF issuer and subjects for federated authentication. |
object({
issuer = string
subjects = list(string)
})
| n/a | yes | ## Outputs | Name | Description | |------|-------------| -| [application\_password](#output\_application\_password) | Information about the created application password (excludes the actual password value for security). | -| [created\_application](#output\_created\_application) | Information about the created Azure AD application. | -| [created\_service\_principal](#output\_created\_service\_principal) | Information about the created service principal. | | [documentation\_md](#output\_documentation\_md) | Markdown documentation with information about the Storage Account Building Block building block backplane | -| [role\_assignment\_ids](#output\_role\_assignment\_ids) | The IDs of the role assignments for all service principals. | -| [role\_assignment\_principal\_ids](#output\_role\_assignment\_principal\_ids) | The principal IDs of all service principals that have been assigned the role. | +| [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 building block to subscriptions. | | [role\_definition\_name](#output\_role\_definition\_name) | The name of the role definition that enables deployment of the building block to subscriptions. | -| [scope](#output\_scope) | The scope where the role definition and role assignments are applied. | -| [workload\_identity\_federation](#output\_workload\_identity\_federation) | Information about the created workload identity federation credentials. | +| [scope](#output\_scope) | The scope where the role definition and role assignment are applied. | diff --git a/modules/azure/storage-account/backplane/documentation.tf b/modules/azure/storage-account/backplane/documentation.tf index e169f0a8..d77d3d18 100644 --- a/modules/azure/storage-account/backplane/documentation.tf +++ b/modules/azure/storage-account/backplane/documentation.tf @@ -6,8 +6,8 @@ The Storage Account Building Block configures a storage account for your subscri ## Automation -We automate the deployment of a Storage Account Building Block building block using the common [Azure Building Blocks Automation Infrastructure](../automation.md). -In order to deploy this building block, this infrastructure receives the following roles. +We automate the deployment of a Storage Account Building Block using a User-Assigned Managed +Identity (UAMI) with Workload Identity Federation. The UAMI receives the following roles. | Role Name | Description | Permissions | |-----------|-------------|-------------| diff --git a/modules/azure/storage-account/backplane/main.tf b/modules/azure/storage-account/backplane/main.tf index cf3533f8..5b57b378 100644 --- a/modules/azure/storage-account/backplane/main.tf +++ b/modules/azure/storage-account/backplane/main.tf @@ -1,41 +1,20 @@ -# Service Principal Creation -resource "azuread_application" "buildingblock_deploy" { - count = var.create_service_principal_name != null ? 1 : 0 - - display_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 "azurerm_user_assigned_identity" "buildingblock" { + name = var.name + location = var.location + resource_group_name = var.resource_group_name } +resource "azurerm_federated_identity_credential" "buildingblock" { + for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s } -# Create federated identity credentials (one per subject) -# Use a map with static numeric string keys so that for_each keys are known at plan time, -# even when subject values contain apply-time unknowns (e.g. building block definition UUIDs). -resource "azuread_application_federated_identity_credential" "buildingblock_deploy" { - for_each = var.create_service_principal_name != null && var.workload_identity_federation != null ? { - for i, s in var.workload_identity_federation.subjects : tostring(i) => s - } : {} - - application_id = azuread_application.buildingblock_deploy[0].id - display_name = "subject-${each.key}" - audiences = ["api://AzureADTokenExchange"] - issuer = var.workload_identity_federation.issuer - subject = each.value -} -# Create application password (when not using workload identity federation) -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" + name = "subject-${each.key}" + resource_group_name = var.resource_group_name + parent_id = azurerm_user_assigned_identity.buildingblock.id + audience = ["api://AzureADTokenExchange"] + issuer = var.workload_identity_federation.issuer + subject = each.value } -# Role Definition resource "azurerm_role_definition" "buildingblock_deploy" { name = "${var.name}-deploy" description = "Enables deployment of the ${var.name} building block to subscriptions" @@ -72,20 +51,8 @@ resource "azurerm_role_definition" "buildingblock_deploy" { } } -# Role Assignments for existing principals -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 +resource "azurerm_role_assignment" "buildingblock" { scope = var.scope -} - -# Role Assignment for created service principal -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 + principal_id = azurerm_user_assigned_identity.buildingblock.principal_id } diff --git a/modules/azure/storage-account/backplane/outputs.tf b/modules/azure/storage-account/backplane/outputs.tf index e924ea8b..4820b071 100644 --- a/modules/azure/storage-account/backplane/outputs.tf +++ b/modules/azure/storage-account/backplane/outputs.tf @@ -1,3 +1,12 @@ +output "identity" { + value = { + client_id = azurerm_user_assigned_identity.buildingblock.client_id + principal_id = azurerm_user_assigned_identity.buildingblock.principal_id + tenant_id = azurerm_user_assigned_identity.buildingblock.tenant_id + } + description = "The managed identity used as the automation principal for this building block." +} + 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 to subscriptions." @@ -8,63 +17,8 @@ output "role_definition_name" { description = "The name of the role definition that enables deployment of the building block to subscriptions." } -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 ? [ - for wif in azuread_application_federated_identity_credential.buildingblock_deploy : { - credential_id = wif.credential_id - display_name = wif.display_name - issuer = wif.issuer - subject = wif.subject - audiences = wif.audiences - }] : null - description = "Information about the created workload identity federation credentials." -} - -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." + description = "The scope where the role definition and role assignment are applied." } diff --git a/modules/azure/storage-account/backplane/variables.tf b/modules/azure/storage-account/backplane/variables.tf index 488952bf..7cdede56 100644 --- a/modules/azure/storage-account/backplane/variables.tf +++ b/modules/azure/storage-account/backplane/variables.tf @@ -1,7 +1,7 @@ variable "name" { type = string nullable = false - description = "name of the building block, used for naming resources" + description = "Name for the building block identity and role definition." validation { condition = can(regex("^[-a-z0-9]+$", var.name)) error_message = "Only alphanumeric lowercase characters and dashes are allowed" @@ -11,24 +11,19 @@ variable "name" { variable "scope" { type = string nullable = false - description = "Scope where the building block should be deployable, typically the parent of all Landing Zones." + description = "Scope for role assignment (management group or subscription ID)." } -variable "existing_principal_ids" { - type = set(string) - default = [] - description = "set of existing principal ids that will be granted permissions to deploy the building block" +variable "location" { + type = string + nullable = false + description = "Azure region for the UAMI resource." } -variable "create_service_principal_name" { +variable "resource_group_name" { type = string - default = null - description = "name of a service principal to create and grant permissions to deploy the building block" - - validation { - condition = var.create_service_principal_name == null ? true : can(regex("^[-a-zA-Z0-9_]+$", var.create_service_principal_name)) - error_message = "Service principal name can only contain alphanumeric characters, hyphens, and underscores" - } + nullable = false + description = "Resource group where the UAMI will be created." } variable "workload_identity_federation" { @@ -36,6 +31,6 @@ variable "workload_identity_federation" { issuer = string subjects = list(string) }) - default = null - description = "Configuration for workload identity federation. If not provided, an application password will be created instead. Supports multiple subjects." + nullable = false + description = "WIF issuer and subjects for federated authentication." } diff --git a/modules/azure/storage-account/backplane/versions.tf b/modules/azure/storage-account/backplane/versions.tf index 630c0652..67c6c566 100644 --- a/modules/azure/storage-account/backplane/versions.tf +++ b/modules/azure/storage-account/backplane/versions.tf @@ -6,10 +6,6 @@ terraform { source = "hashicorp/azurerm" version = "~> 4.64" } - azuread = { - source = "hashicorp/azuread" - version = "~> 3.8" - } } } diff --git a/modules/azure/storage-account/e2e/main.tf b/modules/azure/storage-account/e2e/main.tf index c619c745..d002aec2 100644 --- a/modules/azure/storage-account/e2e/main.tf +++ b/modules/azure/storage-account/e2e/main.tf @@ -6,8 +6,9 @@ variable "test_context" { fixtures = object({ azure = object({ - subscription_uuid = string - entra_tenant_id = string + subscription_uuid = string + entra_tenant_id = string + resource_group_name = string }) }) }) @@ -37,9 +38,10 @@ module "storage_account" { bbd_draft = true } - azure_tenant_id = var.test_context.fixtures.azure.entra_tenant_id - azure_subscription_id = var.test_context.fixtures.azure.subscription_uuid - azure_scope = local.azure_scope + azure_tenant_id = var.test_context.fixtures.azure.entra_tenant_id + azure_subscription_id = var.test_context.fixtures.azure.subscription_uuid + azure_scope = local.azure_scope + azure_resource_group_name = var.test_context.fixtures.azure.resource_group_name # Unique backplane name per test run so role definitions don't clash across concurrent/retried runs. backplane_name = "hub-e2e-stg-${var.test_context.name_suffix}" diff --git a/modules/azure/storage-account/e2e/provider.tf b/modules/azure/storage-account/e2e/provider.tf index 50f8fdfa..69325904 100644 --- a/modules/azure/storage-account/e2e/provider.tf +++ b/modules/azure/storage-account/e2e/provider.tf @@ -4,7 +4,3 @@ provider "azurerm" { features {} } - -provider "azuread" { - tenant_id = var.test_context.fixtures.azure.entra_tenant_id -} diff --git a/modules/azure/storage-account/e2e/terraform.tf b/modules/azure/storage-account/e2e/terraform.tf index 8d29dc32..39e32bf6 100644 --- a/modules/azure/storage-account/e2e/terraform.tf +++ b/modules/azure/storage-account/e2e/terraform.tf @@ -8,8 +8,5 @@ terraform { azurerm = { source = "hashicorp/azurerm" } - azuread = { - source = "hashicorp/azuread" - } } } diff --git a/modules/azure/storage-account/meshstack_integration.tf b/modules/azure/storage-account/meshstack_integration.tf index 9b2ea684..fe1a1382 100644 --- a/modules/azure/storage-account/meshstack_integration.tf +++ b/modules/azure/storage-account/meshstack_integration.tf @@ -19,6 +19,11 @@ variable "azure_location" { description = "Default Azure region where storage accounts will be created." } +variable "azure_resource_group_name" { + type = string + description = "Resource group where the backplane UAMI will be created." +} + variable "backplane_name" { type = string default = "azure-storage-account" @@ -44,6 +49,7 @@ variable "hub" { git_ref = optional(string, "main") bbd_draft = optional(bool, true) }) + const = 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. @@ -62,12 +68,12 @@ output "building_block_definition" { data "meshstack_integrations" "integrations" {} module "backplane" { - source = "github.com/meshcloud/meshstack-hub//modules/azure/storage-account/backplane?ref=0a6d313e509e1c9052712f0d9c41c2d0a96f9a39" + source = "github.com/meshcloud/meshstack-hub//modules/azure/storage-account/backplane?ref=${var.hub.git_ref}" - name = var.backplane_name - scope = var.azure_scope - - create_service_principal_name = var.backplane_name + name = var.backplane_name + scope = var.azure_scope + location = var.azure_location + resource_group_name = var.azure_resource_group_name workload_identity_federation = { issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer @@ -136,7 +142,7 @@ resource "meshstack_building_block_definition" "this" { 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) + argument = jsonencode(module.backplane.identity.client_id) } ARM_TENANT_ID = { type = "STRING" @@ -222,9 +228,5 @@ terraform { source = "hashicorp/azurerm" version = "~> 4.64" } - azuread = { - source = "hashicorp/azuread" - version = "~> 3.8" - } } } From dea865d1c426a16a1215129f4272ce06fdbb2946 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 19 May 2026 18:32:10 +0200 Subject: [PATCH 06/11] feat: deploy azure backplanes into named resource groups Derive resource group names automatically from backplane name inputs. In e2e tests, this is automatically randomized by the test context name suffix. --- .../azure-backplane.instructions.md | 26 +++++++++---------- .../azure/budget-alert/backplane/README.md | 2 +- modules/azure/budget-alert/backplane/main.tf | 9 +++++-- .../azure/budget-alert/backplane/variables.tf | 6 ----- modules/azure/budget-alert/e2e/main.tf | 12 ++++----- .../budget-alert/meshstack_integration.tf | 12 +++------ .../azure/storage-account/backplane/README.md | 2 +- .../azure/storage-account/backplane/main.tf | 9 +++++-- .../storage-account/backplane/variables.tf | 6 ----- modules/azure/storage-account/e2e/main.tf | 12 ++++----- .../storage-account/meshstack_integration.tf | 12 +++------ 11 files changed, 44 insertions(+), 64 deletions(-) diff --git a/.github/instructions/azure-backplane.instructions.md b/.github/instructions/azure-backplane.instructions.md index 6f44a04c..fb4fb280 100644 --- a/.github/instructions/azure-backplane.instructions.md +++ b/.github/instructions/azure-backplane.instructions.md @@ -29,17 +29,22 @@ principal for building block execution. Do **not** create Service Principals (SP ```hcl # backplane/main.tf — UAMI-based automation principal +resource "azurerm_resource_group" "buildingblock" { + name = var.name + location = var.location +} + resource "azurerm_user_assigned_identity" "buildingblock" { name = var.name location = var.location - resource_group_name = var.resource_group_name + resource_group_name = azurerm_resource_group.buildingblock.name } resource "azurerm_federated_identity_credential" "buildingblock" { for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s } name = "subject-${each.key}" - resource_group_name = var.resource_group_name + resource_group_name = azurerm_resource_group.buildingblock.name parent_id = azurerm_user_assigned_identity.buildingblock.id audience = ["api://AzureADTokenExchange"] issuer = var.workload_identity_federation.issuer @@ -82,11 +87,6 @@ variable "location" { description = "Azure region for the UAMI resource." } -variable "resource_group_name" { - type = string - description = "Resource group where the UAMI will be created." -} - variable "workload_identity_federation" { type = object({ issuer = string @@ -124,10 +124,9 @@ In the integration file, pass the UAMI client ID as the `ARM_CLIENT_ID` environm module "backplane" { source = "github.com/meshcloud/meshstack-hub//modules/azure//backplane?ref=${var.hub.git_ref}" - name = var.backplane_name - scope = var.azure_scope - location = var.azure_location - resource_group_name = var.azure_resource_group_name + name = var.backplane_name + scope = var.azure_scope + location = var.azure_location workload_identity_federation = { issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer @@ -138,8 +137,7 @@ module "backplane" { } ``` -The `meshstack_integration.tf` must include `azure_resource_group_name` and `azure_location` -variables (flat, provider-prefixed) for the UAMI placement. +The `meshstack_integration.tf` must include `azure_location` variable (flat, provider-prefixed) for the UAMI placement. The resource group is derived from and managed by the backplane using `var.name`. ## Checklist for Azure Backplanes @@ -149,4 +147,4 @@ variables (flat, provider-prefixed) for the UAMI placement. - [ ] No `create_service_principal_name` / `existing_principal_ids` toggle - [ ] `workload_identity_federation` variable is non-nullable (always required) - [ ] Outputs `identity.client_id`, `identity.principal_id`, `identity.tenant_id` -- [ ] `meshstack_integration.tf` includes `azure_resource_group_name` and `azure_location` variables +- [ ] `meshstack_integration.tf` includes `azure_location` variable (no separate `azure_resource_group_name` — the backplane manages its own resource group named after `var.name`) diff --git a/modules/azure/budget-alert/backplane/README.md b/modules/azure/budget-alert/backplane/README.md index 5c343655..56dd5700 100644 --- a/modules/azure/budget-alert/backplane/README.md +++ b/modules/azure/budget-alert/backplane/README.md @@ -24,6 +24,7 @@ No modules. | Name | Type | |------|------| | [azurerm_federated_identity_credential.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/federated_identity_credential) | resource | +| [azurerm_resource_group.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | | [azurerm_role_assignment.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | | [azurerm_role_definition.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_definition) | resource | | [azurerm_user_assigned_identity.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/user_assigned_identity) | resource | @@ -34,7 +35,6 @@ No modules. |------|-------------|------|---------|:--------:| | [location](#input\_location) | Azure region for the UAMI resource. | `string` | n/a | yes | | [name](#input\_name) | Name for the building block identity and role definition. | `string` | `"budget-alert"` | no | -| [resource\_group\_name](#input\_resource\_group\_name) | Resource group where the UAMI will be created. | `string` | n/a | yes | | [scope](#input\_scope) | Scope for role assignment (management group or subscription ID). | `string` | n/a | yes | | [workload\_identity\_federation](#input\_workload\_identity\_federation) | WIF issuer and subjects for federated authentication. |
object({
issuer = string
subjects = list(string)
})
| n/a | yes | diff --git a/modules/azure/budget-alert/backplane/main.tf b/modules/azure/budget-alert/backplane/main.tf index 10603f51..03fbf4ae 100644 --- a/modules/azure/budget-alert/backplane/main.tf +++ b/modules/azure/budget-alert/backplane/main.tf @@ -1,14 +1,19 @@ +resource "azurerm_resource_group" "buildingblock" { + name = var.name + location = var.location +} + resource "azurerm_user_assigned_identity" "buildingblock" { name = var.name location = var.location - resource_group_name = var.resource_group_name + resource_group_name = azurerm_resource_group.buildingblock.name } resource "azurerm_federated_identity_credential" "buildingblock" { for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s } name = "subject-${each.key}" - resource_group_name = var.resource_group_name + resource_group_name = azurerm_resource_group.buildingblock.name parent_id = azurerm_user_assigned_identity.buildingblock.id audience = ["api://AzureADTokenExchange"] issuer = var.workload_identity_federation.issuer diff --git a/modules/azure/budget-alert/backplane/variables.tf b/modules/azure/budget-alert/backplane/variables.tf index 29160b94..f18925e8 100644 --- a/modules/azure/budget-alert/backplane/variables.tf +++ b/modules/azure/budget-alert/backplane/variables.tf @@ -21,12 +21,6 @@ variable "location" { description = "Azure region for the UAMI resource." } -variable "resource_group_name" { - type = string - nullable = false - description = "Resource group where the UAMI will be created." -} - variable "workload_identity_federation" { type = object({ issuer = string diff --git a/modules/azure/budget-alert/e2e/main.tf b/modules/azure/budget-alert/e2e/main.tf index 84a662d8..07e81af6 100644 --- a/modules/azure/budget-alert/e2e/main.tf +++ b/modules/azure/budget-alert/e2e/main.tf @@ -6,9 +6,8 @@ variable "test_context" { fixtures = object({ azure = object({ - subscription_uuid = string - entra_tenant_id = string - resource_group_name = string + subscription_uuid = string + entra_tenant_id = string }) }) }) @@ -37,10 +36,9 @@ module "budget_alert" { bbd_draft = true } - azure_tenant_id = var.test_context.fixtures.azure.entra_tenant_id - azure_subscription_id = var.test_context.fixtures.azure.subscription_uuid - azure_scope = local.azure_scope - azure_resource_group_name = var.test_context.fixtures.azure.resource_group_name + azure_tenant_id = var.test_context.fixtures.azure.entra_tenant_id + azure_subscription_id = var.test_context.fixtures.azure.subscription_uuid + azure_scope = local.azure_scope # Unique backplane name per test run so role definitions don't clash across concurrent/retried runs. backplane_name = "hub-e2e-budget-${var.test_context.name_suffix}" diff --git a/modules/azure/budget-alert/meshstack_integration.tf b/modules/azure/budget-alert/meshstack_integration.tf index 0072ff2a..077d6450 100644 --- a/modules/azure/budget-alert/meshstack_integration.tf +++ b/modules/azure/budget-alert/meshstack_integration.tf @@ -13,11 +13,6 @@ variable "azure_scope" { description = "Azure management group or subscription scope for backplane role assignment." } -variable "azure_resource_group_name" { - type = string - description = "Resource group where the backplane UAMI will be created." -} - variable "azure_location" { type = string default = "germanywestcentral" @@ -70,10 +65,9 @@ data "meshstack_integrations" "integrations" {} module "backplane" { source = "github.com/meshcloud/meshstack-hub//modules/azure/budget-alert/backplane?ref=${var.hub.git_ref}" - name = var.backplane_name - scope = var.azure_scope - location = var.azure_location - resource_group_name = var.azure_resource_group_name + name = var.backplane_name + scope = var.azure_scope + location = var.azure_location workload_identity_federation = { issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer diff --git a/modules/azure/storage-account/backplane/README.md b/modules/azure/storage-account/backplane/README.md index 6583240c..fd9be434 100644 --- a/modules/azure/storage-account/backplane/README.md +++ b/modules/azure/storage-account/backplane/README.md @@ -119,6 +119,7 @@ No modules. | Name | Type | |------|------| | [azurerm_federated_identity_credential.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/federated_identity_credential) | resource | +| [azurerm_resource_group.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | | [azurerm_role_assignment.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | | [azurerm_role_definition.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_definition) | resource | | [azurerm_user_assigned_identity.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/user_assigned_identity) | resource | @@ -129,7 +130,6 @@ No modules. |------|-------------|------|---------|:--------:| | [location](#input\_location) | Azure region for the UAMI resource. | `string` | n/a | yes | | [name](#input\_name) | Name for the building block identity and role definition. | `string` | n/a | yes | -| [resource\_group\_name](#input\_resource\_group\_name) | Resource group where the UAMI will be created. | `string` | n/a | yes | | [scope](#input\_scope) | Scope for role assignment (management group or subscription ID). | `string` | n/a | yes | | [workload\_identity\_federation](#input\_workload\_identity\_federation) | WIF issuer and subjects for federated authentication. |
object({
issuer = string
subjects = list(string)
})
| n/a | yes | diff --git a/modules/azure/storage-account/backplane/main.tf b/modules/azure/storage-account/backplane/main.tf index 5b57b378..63942b5f 100644 --- a/modules/azure/storage-account/backplane/main.tf +++ b/modules/azure/storage-account/backplane/main.tf @@ -1,14 +1,19 @@ +resource "azurerm_resource_group" "buildingblock" { + name = var.name + location = var.location +} + resource "azurerm_user_assigned_identity" "buildingblock" { name = var.name location = var.location - resource_group_name = var.resource_group_name + resource_group_name = azurerm_resource_group.buildingblock.name } resource "azurerm_federated_identity_credential" "buildingblock" { for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s } name = "subject-${each.key}" - resource_group_name = var.resource_group_name + resource_group_name = azurerm_resource_group.buildingblock.name parent_id = azurerm_user_assigned_identity.buildingblock.id audience = ["api://AzureADTokenExchange"] issuer = var.workload_identity_federation.issuer diff --git a/modules/azure/storage-account/backplane/variables.tf b/modules/azure/storage-account/backplane/variables.tf index 7cdede56..f33ee46b 100644 --- a/modules/azure/storage-account/backplane/variables.tf +++ b/modules/azure/storage-account/backplane/variables.tf @@ -20,12 +20,6 @@ variable "location" { description = "Azure region for the UAMI resource." } -variable "resource_group_name" { - type = string - nullable = false - description = "Resource group where the UAMI will be created." -} - variable "workload_identity_federation" { type = object({ issuer = string diff --git a/modules/azure/storage-account/e2e/main.tf b/modules/azure/storage-account/e2e/main.tf index d002aec2..c619c745 100644 --- a/modules/azure/storage-account/e2e/main.tf +++ b/modules/azure/storage-account/e2e/main.tf @@ -6,9 +6,8 @@ variable "test_context" { fixtures = object({ azure = object({ - subscription_uuid = string - entra_tenant_id = string - resource_group_name = string + subscription_uuid = string + entra_tenant_id = string }) }) }) @@ -38,10 +37,9 @@ module "storage_account" { bbd_draft = true } - azure_tenant_id = var.test_context.fixtures.azure.entra_tenant_id - azure_subscription_id = var.test_context.fixtures.azure.subscription_uuid - azure_scope = local.azure_scope - azure_resource_group_name = var.test_context.fixtures.azure.resource_group_name + azure_tenant_id = var.test_context.fixtures.azure.entra_tenant_id + azure_subscription_id = var.test_context.fixtures.azure.subscription_uuid + azure_scope = local.azure_scope # Unique backplane name per test run so role definitions don't clash across concurrent/retried runs. backplane_name = "hub-e2e-stg-${var.test_context.name_suffix}" diff --git a/modules/azure/storage-account/meshstack_integration.tf b/modules/azure/storage-account/meshstack_integration.tf index fe1a1382..9932842d 100644 --- a/modules/azure/storage-account/meshstack_integration.tf +++ b/modules/azure/storage-account/meshstack_integration.tf @@ -19,11 +19,6 @@ variable "azure_location" { description = "Default Azure region where storage accounts will be created." } -variable "azure_resource_group_name" { - type = string - description = "Resource group where the backplane UAMI will be created." -} - variable "backplane_name" { type = string default = "azure-storage-account" @@ -70,10 +65,9 @@ data "meshstack_integrations" "integrations" {} module "backplane" { source = "github.com/meshcloud/meshstack-hub//modules/azure/storage-account/backplane?ref=${var.hub.git_ref}" - name = var.backplane_name - scope = var.azure_scope - location = var.azure_location - resource_group_name = var.azure_resource_group_name + name = var.backplane_name + scope = var.azure_scope + location = var.azure_location workload_identity_federation = { issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer From 869ea3fef950cd0ef89b2f96e7ee4e10ed5ef505 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Wed, 20 May 2026 09:09:26 +0200 Subject: [PATCH 07/11] fix: wait for tofu 1.12+ to enable const for hub module source variable --- modules/azure/budget-alert/meshstack_integration.tf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/azure/budget-alert/meshstack_integration.tf b/modules/azure/budget-alert/meshstack_integration.tf index 077d6450..42e965d7 100644 --- a/modules/azure/budget-alert/meshstack_integration.tf +++ b/modules/azure/budget-alert/meshstack_integration.tf @@ -44,7 +44,8 @@ variable "hub" { git_ref = optional(string, "main") bbd_draft = optional(bool, true) }) - const = true + # not supported until we upgrade to tofu 1.12+ + # const = 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. From f87c673e128ad2cdf95adce5c54a60d504046d49 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Wed, 20 May 2026 09:55:38 +0200 Subject: [PATCH 08/11] refactor(azure/backplane): rename resources from 'buildingblock' to 'backplane' for consistency --- .../azure-backplane.instructions.md | 26 +++++++++---------- .../azure/budget-alert/backplane/README.md | 10 +++---- modules/azure/budget-alert/backplane/main.tf | 20 +++++++------- .../azure/budget-alert/backplane/outputs.tf | 10 +++---- .../azure/storage-account/backplane/README.md | 10 +++---- .../azure/storage-account/backplane/main.tf | 20 +++++++------- .../storage-account/backplane/outputs.tf | 10 +++---- 7 files changed, 53 insertions(+), 53 deletions(-) diff --git a/.github/instructions/azure-backplane.instructions.md b/.github/instructions/azure-backplane.instructions.md index fb4fb280..df32769c 100644 --- a/.github/instructions/azure-backplane.instructions.md +++ b/.github/instructions/azure-backplane.instructions.md @@ -29,39 +29,39 @@ principal for building block execution. Do **not** create Service Principals (SP ```hcl # backplane/main.tf — UAMI-based automation principal -resource "azurerm_resource_group" "buildingblock" { +resource "azurerm_resource_group" "backplane" { name = var.name location = var.location } -resource "azurerm_user_assigned_identity" "buildingblock" { +resource "azurerm_user_assigned_identity" "backplane" { name = var.name location = var.location - resource_group_name = azurerm_resource_group.buildingblock.name + resource_group_name = azurerm_resource_group.backplane.name } -resource "azurerm_federated_identity_credential" "buildingblock" { +resource "azurerm_federated_identity_credential" "backplane" { for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s } name = "subject-${each.key}" - resource_group_name = azurerm_resource_group.buildingblock.name - parent_id = azurerm_user_assigned_identity.buildingblock.id + resource_group_name = azurerm_resource_group.backplane.name + parent_id = azurerm_user_assigned_identity.backplane.id audience = ["api://AzureADTokenExchange"] issuer = var.workload_identity_federation.issuer subject = each.value } -resource "azurerm_role_definition" "buildingblock" { +resource "azurerm_role_definition" "backplane" { name = "${var.name}-deploy" description = "Enables deployment of the ${var.name} building block" scope = var.scope permissions { actions = [ /* ... */ ] } } -resource "azurerm_role_assignment" "buildingblock" { +resource "azurerm_role_assignment" "backplane" { scope = var.scope - role_definition_id = azurerm_role_definition.buildingblock.role_definition_resource_id - principal_id = azurerm_user_assigned_identity.buildingblock.principal_id + role_definition_id = azurerm_role_definition.backplane.role_definition_resource_id + principal_id = azurerm_user_assigned_identity.backplane.principal_id } ``` @@ -102,9 +102,9 @@ variable "workload_identity_federation" { ```hcl output "identity" { value = { - client_id = azurerm_user_assigned_identity.buildingblock.client_id - principal_id = azurerm_user_assigned_identity.buildingblock.principal_id - tenant_id = azurerm_user_assigned_identity.buildingblock.tenant_id + 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 } } ``` diff --git a/modules/azure/budget-alert/backplane/README.md b/modules/azure/budget-alert/backplane/README.md index 56dd5700..dfd74539 100644 --- a/modules/azure/budget-alert/backplane/README.md +++ b/modules/azure/budget-alert/backplane/README.md @@ -23,11 +23,11 @@ No modules. | Name | Type | |------|------| -| [azurerm_federated_identity_credential.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/federated_identity_credential) | resource | -| [azurerm_resource_group.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | -| [azurerm_role_assignment.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | -| [azurerm_role_definition.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_definition) | resource | -| [azurerm_user_assigned_identity.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/user_assigned_identity) | resource | +| [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 diff --git a/modules/azure/budget-alert/backplane/main.tf b/modules/azure/budget-alert/backplane/main.tf index 03fbf4ae..86d0cbdb 100644 --- a/modules/azure/budget-alert/backplane/main.tf +++ b/modules/azure/budget-alert/backplane/main.tf @@ -1,26 +1,26 @@ -resource "azurerm_resource_group" "buildingblock" { +resource "azurerm_resource_group" "backplane" { name = var.name location = var.location } -resource "azurerm_user_assigned_identity" "buildingblock" { +resource "azurerm_user_assigned_identity" "backplane" { name = var.name location = var.location - resource_group_name = azurerm_resource_group.buildingblock.name + resource_group_name = azurerm_resource_group.backplane.name } -resource "azurerm_federated_identity_credential" "buildingblock" { +resource "azurerm_federated_identity_credential" "backplane" { for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s } name = "subject-${each.key}" - resource_group_name = azurerm_resource_group.buildingblock.name - parent_id = azurerm_user_assigned_identity.buildingblock.id + resource_group_name = azurerm_resource_group.backplane.name + parent_id = azurerm_user_assigned_identity.backplane.id audience = ["api://AzureADTokenExchange"] issuer = var.workload_identity_federation.issuer subject = each.value } -resource "azurerm_role_definition" "buildingblock_deploy" { +resource "azurerm_role_definition" "backplane" { name = "${var.name}-deploy" description = "Enables deployment of the ${var.name} building block to subscriptions" scope = var.scope @@ -40,8 +40,8 @@ resource "azurerm_role_definition" "buildingblock_deploy" { } } -resource "azurerm_role_assignment" "buildingblock" { +resource "azurerm_role_assignment" "backplane" { scope = var.scope - role_definition_id = azurerm_role_definition.buildingblock_deploy.role_definition_resource_id - principal_id = azurerm_user_assigned_identity.buildingblock.principal_id + 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/budget-alert/backplane/outputs.tf b/modules/azure/budget-alert/backplane/outputs.tf index 314a86e4..22b826f7 100644 --- a/modules/azure/budget-alert/backplane/outputs.tf +++ b/modules/azure/budget-alert/backplane/outputs.tf @@ -1,19 +1,19 @@ output "identity" { value = { - client_id = azurerm_user_assigned_identity.buildingblock.client_id - principal_id = azurerm_user_assigned_identity.buildingblock.principal_id - tenant_id = azurerm_user_assigned_identity.buildingblock.tenant_id + 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.buildingblock_deploy.id + value = azurerm_role_definition.backplane.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 + value = azurerm_role_definition.backplane.name description = "The name of the role definition that enables deployment of the building block." } diff --git a/modules/azure/storage-account/backplane/README.md b/modules/azure/storage-account/backplane/README.md index fd9be434..05791f9e 100644 --- a/modules/azure/storage-account/backplane/README.md +++ b/modules/azure/storage-account/backplane/README.md @@ -118,11 +118,11 @@ No modules. | Name | Type | |------|------| -| [azurerm_federated_identity_credential.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/federated_identity_credential) | resource | -| [azurerm_resource_group.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | -| [azurerm_role_assignment.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | -| [azurerm_role_definition.buildingblock_deploy](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_definition) | resource | -| [azurerm_user_assigned_identity.buildingblock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/user_assigned_identity) | resource | +| [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 diff --git a/modules/azure/storage-account/backplane/main.tf b/modules/azure/storage-account/backplane/main.tf index 63942b5f..267ae7fc 100644 --- a/modules/azure/storage-account/backplane/main.tf +++ b/modules/azure/storage-account/backplane/main.tf @@ -1,26 +1,26 @@ -resource "azurerm_resource_group" "buildingblock" { +resource "azurerm_resource_group" "backplane" { name = var.name location = var.location } -resource "azurerm_user_assigned_identity" "buildingblock" { +resource "azurerm_user_assigned_identity" "backplane" { name = var.name location = var.location - resource_group_name = azurerm_resource_group.buildingblock.name + resource_group_name = azurerm_resource_group.backplane.name } -resource "azurerm_federated_identity_credential" "buildingblock" { +resource "azurerm_federated_identity_credential" "backplane" { for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s } name = "subject-${each.key}" - resource_group_name = azurerm_resource_group.buildingblock.name - parent_id = azurerm_user_assigned_identity.buildingblock.id + resource_group_name = azurerm_resource_group.backplane.name + parent_id = azurerm_user_assigned_identity.backplane.id audience = ["api://AzureADTokenExchange"] issuer = var.workload_identity_federation.issuer subject = each.value } -resource "azurerm_role_definition" "buildingblock_deploy" { +resource "azurerm_role_definition" "backplane" { name = "${var.name}-deploy" description = "Enables deployment of the ${var.name} building block to subscriptions" scope = var.scope @@ -56,8 +56,8 @@ resource "azurerm_role_definition" "buildingblock_deploy" { } } -resource "azurerm_role_assignment" "buildingblock" { +resource "azurerm_role_assignment" "backplane" { scope = var.scope - role_definition_id = azurerm_role_definition.buildingblock_deploy.role_definition_resource_id - principal_id = azurerm_user_assigned_identity.buildingblock.principal_id + 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/storage-account/backplane/outputs.tf b/modules/azure/storage-account/backplane/outputs.tf index 4820b071..5d79c804 100644 --- a/modules/azure/storage-account/backplane/outputs.tf +++ b/modules/azure/storage-account/backplane/outputs.tf @@ -1,19 +1,19 @@ output "identity" { value = { - client_id = azurerm_user_assigned_identity.buildingblock.client_id - principal_id = azurerm_user_assigned_identity.buildingblock.principal_id - tenant_id = azurerm_user_assigned_identity.buildingblock.tenant_id + 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.buildingblock_deploy.id + value = azurerm_role_definition.backplane.id description = "The ID of the role definition that enables deployment of the building block to subscriptions." } output "role_definition_name" { - value = azurerm_role_definition.buildingblock_deploy.name + value = azurerm_role_definition.backplane.name description = "The name of the role definition that enables deployment of the building block to subscriptions." } From 800a56b1aca7a35851e3fa887013a91c20314c46 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Wed, 20 May 2026 10:45:25 +0200 Subject: [PATCH 09/11] chore: remove outdated documentation for Budget Alert and Storage Account backplanes --- .../budget-alert/backplane/documentation.tf | 37 ------------------- .../backplane/documentation.tf | 19 ---------- 2 files changed, 56 deletions(-) delete mode 100644 modules/azure/budget-alert/backplane/documentation.tf delete mode 100644 modules/azure/storage-account/backplane/documentation.tf diff --git a/modules/azure/budget-alert/backplane/documentation.tf b/modules/azure/budget-alert/backplane/documentation.tf deleted file mode 100644 index 46102d6c..00000000 --- a/modules/azure/budget-alert/backplane/documentation.tf +++ /dev/null @@ -1,37 +0,0 @@ -output "documentation_md" { - value = <", formatlist("- `%s`", azurerm_role_definition.buildingblock_deploy.permissions[0].actions))} | - -EOF - description = "Markdown documentation with information about the Storage Account Building Block building block backplane" -} - From 90d7cb87dd9e117a980da3c9cf290d63d439b107 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Wed, 20 May 2026 16:52:33 +0200 Subject: [PATCH 10/11] fix: comment out const variable in hub definition not yet supported until we upgrade to tofu 1.12+ --- modules/azure/storage-account/meshstack_integration.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/azure/storage-account/meshstack_integration.tf b/modules/azure/storage-account/meshstack_integration.tf index 9932842d..5b3e6f71 100644 --- a/modules/azure/storage-account/meshstack_integration.tf +++ b/modules/azure/storage-account/meshstack_integration.tf @@ -44,7 +44,7 @@ variable "hub" { git_ref = optional(string, "main") bbd_draft = optional(bool, true) }) - const = true + # const = 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. From 1e650a32aac378ba0c9d6a43ec531376fe00cd83 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Wed, 20 May 2026 16:58:31 +0200 Subject: [PATCH 11/11] docs: update README.md to remove documentation_md output --- modules/azure/budget-alert/backplane/README.md | 1 - modules/azure/storage-account/backplane/README.md | 1 - 2 files changed, 2 deletions(-) diff --git a/modules/azure/budget-alert/backplane/README.md b/modules/azure/budget-alert/backplane/README.md index dfd74539..77957642 100644 --- a/modules/azure/budget-alert/backplane/README.md +++ b/modules/azure/budget-alert/backplane/README.md @@ -42,7 +42,6 @@ No modules. | Name | Description | |------|-------------| -| [documentation\_md](#output\_documentation\_md) | Markdown documentation with information about the Budget Alert building block backplane | | [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 building block. | | [role\_definition\_name](#output\_role\_definition\_name) | The name of the role definition that enables deployment of the building block. | diff --git a/modules/azure/storage-account/backplane/README.md b/modules/azure/storage-account/backplane/README.md index 05791f9e..bafcb7d5 100644 --- a/modules/azure/storage-account/backplane/README.md +++ b/modules/azure/storage-account/backplane/README.md @@ -137,7 +137,6 @@ No modules. | Name | Description | |------|-------------| -| [documentation\_md](#output\_documentation\_md) | Markdown documentation with information about the Storage Account Building Block building block backplane | | [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 building block to subscriptions. | | [role\_definition\_name](#output\_role\_definition\_name) | The name of the role definition that enables deployment of the building block to subscriptions. |