Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions .github/instructions/azure-backplane.instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
---
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_resource_group" "backplane" {
name = var.name
location = var.location
}

resource "azurerm_user_assigned_identity" "backplane" {
name = var.name
location = var.location
resource_group_name = azurerm_resource_group.backplane.name
}

resource "azurerm_federated_identity_credential" "backplane" {
for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s }

name = "subject-${each.key}"
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" "backplane" {
name = "${var.name}-deploy"
description = "Enables deployment of the ${var.name} building block"
scope = var.scope
permissions { actions = [ /* ... */ ] }
}

resource "azurerm_role_assignment" "backplane" {
scope = var.scope
role_definition_id = azurerm_role_definition.backplane.role_definition_resource_id
principal_id = azurerm_user_assigned_identity.backplane.principal_id
}
```

## 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 "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.backplane.client_id
principal_id = azurerm_user_assigned_identity.backplane.principal_id
tenant_id = azurerm_user_assigned_identity.backplane.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/<service>/backplane?ref=${var.hub.git_ref}"

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
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_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

- [ ] 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_location` variable (no separate `azure_resource_group_name` — the backplane manages its own resource group named after `var.name`)
9 changes: 8 additions & 1 deletion .github/copilot-instructions.md → AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# GitHub Copilot Instructions — meshstack-hub
# meshstack-hub — Agent Instructions

## Purpose of this Repository

Expand Down Expand Up @@ -167,6 +167,12 @@ resource "meshstack_building_block_definition" "this" {

---

## Azure Backplane Identity Conventions

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.

---

## Documentation Requirements

**`buildingblock/README.md`** — must include YAML front-matter:
Expand Down Expand Up @@ -364,3 +370,4 @@ Pass `module.<name>.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)
35 changes: 11 additions & 24 deletions modules/azure/budget-alert/backplane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,40 +23,27 @@ 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_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_federated_identity_credential.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/federated_identity_credential) | resource |
| [azurerm_resource_group.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource |
| [azurerm_role_assignment.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource |
| [azurerm_role_definition.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_definition) | resource |
| [azurerm_user_assigned_identity.backplane](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/user_assigned_identity) | resource |

## Inputs

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| <a name="input_create_service_principal_name"></a> [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 |
| <a name="input_existing_principal_ids"></a> [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 |
| <a name="input_name"></a> [name](#input\_name) | name of the building block, used for naming resources | `string` | `"budget-alert"` | no |
| <a name="input_scope"></a> [scope](#input\_scope) | Scope where the building block should be deployable, typically the parent of all Landing Zones. | `string` | n/a | yes |
| <a name="input_workload_identity_federation"></a> [workload\_identity\_federation](#input\_workload\_identity\_federation) | if set, configures workload identity federation for the created service principal | <pre>object({<br/> issuer = string<br/> subject = string<br/> })</pre> | `null` | no |
| <a name="input_location"></a> [location](#input\_location) | Azure region for the UAMI resource. | `string` | n/a | yes |
| <a name="input_name"></a> [name](#input\_name) | Name for the building block identity and role definition. | `string` | `"budget-alert"` | no |
| <a name="input_scope"></a> [scope](#input\_scope) | Scope for role assignment (management group or subscription ID). | `string` | n/a | yes |
| <a name="input_workload_identity_federation"></a> [workload\_identity\_federation](#input\_workload\_identity\_federation) | WIF issuer and subjects for federated authentication. | <pre>object({<br/> issuer = string<br/> subjects = list(string)<br/> })</pre> | n/a | yes |

## Outputs

| Name | Description |
|------|-------------|
| <a name="output_application_password"></a> [application\_password](#output\_application\_password) | Information about the created application password (excludes the actual password value for security). |
| <a name="output_created_application"></a> [created\_application](#output\_created\_application) | Information about the created Azure AD application. |
| <a name="output_created_service_principal"></a> [created\_service\_principal](#output\_created\_service\_principal) | Information about the created service principal. |
| <a name="output_documentation_md"></a> [documentation\_md](#output\_documentation\_md) | Markdown documentation with information about the Budget Alert building block backplane |
| <a name="output_role_assignment_ids"></a> [role\_assignment\_ids](#output\_role\_assignment\_ids) | The IDs of the role assignments for all service principals. |
| <a name="output_role_assignment_principal_ids"></a> [role\_assignment\_principal\_ids](#output\_role\_assignment\_principal\_ids) | The principal IDs of all service principals that have been assigned the role. |
| <a name="output_identity"></a> [identity](#output\_identity) | The managed identity used as the automation principal for this building block. |
| <a name="output_role_definition_id"></a> [role\_definition\_id](#output\_role\_definition\_id) | The ID of the role definition that enables deployment of the building block. |
| <a name="output_role_definition_name"></a> [role\_definition\_name](#output\_role\_definition\_name) | The name of the role definition that enables deployment of the building block. |
| <a name="output_scope"></a> [scope](#output\_scope) | The scope where the role definition and role assignments are applied. |
| <a name="output_workload_identity_federation"></a> [workload\_identity\_federation](#output\_workload\_identity\_federation) | Information about the created workload identity federation credential. |
| <a name="output_scope"></a> [scope](#output\_scope) | The scope where the role definition and role assignment are applied. |
<!-- END_TF_DOCS -->
36 changes: 0 additions & 36 deletions modules/azure/budget-alert/backplane/documentation.tf

This file was deleted.

75 changes: 19 additions & 56 deletions modules/azure/budget-alert/backplane/main.tf
Original file line number Diff line number Diff line change
@@ -1,37 +1,26 @@
data "azurerm_subscription" "current" {
resource "azurerm_resource_group" "backplane" {
name = var.name
location = var.location
}

resource "azuread_application" "buildingblock_deploy" {
count = var.create_service_principal_name != null ? 1 : 0

display_name = "${var.name}-${var.create_service_principal_name}"
resource "azurerm_user_assigned_identity" "backplane" {
name = var.name
location = var.location
resource_group_name = azurerm_resource_group.backplane.name
}

resource "azuread_service_principal" "buildingblock_deploy" {
count = var.create_service_principal_name != null ? 1 : 0
resource "azurerm_federated_identity_credential" "backplane" {
for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s }

client_id = azuread_application.buildingblock_deploy[0].client_id
app_role_assignment_required = false
name = "subject-${each.key}"
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 "azuread_application_federated_identity_credential" "buildingblock_deploy" {
count = var.create_service_principal_name != null && var.workload_identity_federation != null ? 1 : 0

application_id = azuread_application.buildingblock_deploy[0].id
display_name = var.create_service_principal_name
audiences = ["api://AzureADTokenExchange"]
issuer = var.workload_identity_federation.issuer
subject = var.workload_identity_federation.subject
}

resource "azuread_application_password" "buildingblock_deploy" {
count = var.create_service_principal_name != null && var.workload_identity_federation == null ? 1 : 0

application_id = azuread_application.buildingblock_deploy[0].id
display_name = "${var.create_service_principal_name}-password"
}

resource "azurerm_role_definition" "buildingblock_deploy" {
resource "azurerm_role_definition" "backplane" {
name = "${var.name}-deploy"
description = "Enables deployment of the ${var.name} building block to subscriptions"
scope = var.scope
Expand All @@ -51,34 +40,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
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
resource "azurerm_role_assignment" "backplane" {
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
role_definition_id = azurerm_role_definition.backplane.role_definition_resource_id
principal_id = azurerm_user_assigned_identity.backplane.principal_id
}
Loading
Loading