Skip to content
Open
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
29 changes: 29 additions & 0 deletions modules/azure/entra-id-groups/backplane/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Azure Entra ID Groups — Backplane

This backplane creates the automation identity used to provision Entra security groups for meshStack project roles.

## What it provisions

- **Resource Group** — hosts the UAMI in the configured Azure region.
- **User-Assigned Managed Identity (UAMI)** — the automation principal that runs the building block. No client secrets.
Comment on lines +7 to +8

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

f: just swap to have acronum UAMI well defined:

Suggested change
- **Resource Group**hosts the UAMI in the configured Azure region.
- **User-Assigned Managed Identity (UAMI)** — the automation principal that runs the building block. No client secrets.
- **User-Assigned Managed Identity (UAMI)** — the automation principal that runs the building block. No client secrets.
- **Resource Group**hosts the UAMI in the configured Azure region.

- **Workload Identity Federation credentials** — bind the UAMI to the meshStack replicator's OIDC issuer and subject, enabling secret-free authentication.
- **Microsoft Graph app roles** on the UAMI:
- `User.Read.All` — look up users by UPN or primary mail address to resolve object IDs for group membership.
- `Group.ReadWrite.All` — create and manage Entra security groups.
- `AdministrativeUnit.ReadWrite.All` — add groups to Administrative Units (used when `administrative_unit_id` is supplied at building block runtime).

## Required permissions to deploy

The platform engineer running this backplane needs:

| Permission | Scope | Why |
|---|---|---|
| `Managed Identity Contributor` | Target subscription | Create and update the UAMI |
| `Owner` or `User Access Administrator` | `var.scope` | Create role assignments on the UAMI |
| `Privileged Role Administrator` (Entra) | Tenant | Grant admin-consented Microsoft Graph app roles |

## Operational notes

- The UAMI principal ID maps to a service principal in Entra. The `User.Read.All`, `Group.ReadWrite.All`, and `AdministrativeUnit.ReadWrite.All` app role assignments require **admin consent** — ensure a Global Administrator or Privileged Role Administrator approves the assignments in the Entra portal after the first `apply`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

d: I remember having this admin consent thing automated in the past, as this can become really annoying as admins need to approve stuff. maybe worthwhile to discuss this f2f or at least mention it.

- No secrets are created; the UAMI authenticates via OIDC token exchange.
- The backplane resource group is named after `var.name` and must be unique within the subscription.
44 changes: 44 additions & 0 deletions modules/azure/entra-id-groups/backplane/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
resource "azurerm_resource_group" "backplane" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

f:

Suggested change
resource "azurerm_resource_group" "backplane" {
resource "azurerm_resource_group" "this" {

würde das nich doppelt benennen, durch das module hat die resource address schon das wort backplane drin

name = var.name
location = var.location
}

resource "azurerm_user_assigned_identity" "backplane" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
resource "azurerm_user_assigned_identity" "backplane" {
resource "azurerm_user_assigned_identity" "this" {

name = var.name
location = var.location
resource_group_name = azurerm_resource_group.backplane.name
}

resource "azurerm_federated_identity_credential" "backplane" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
resource "azurerm_federated_identity_credential" "backplane" {
resource "azurerm_federated_identity_credential" "this" {

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
}

# Grant Microsoft Graph app roles so the UAMI can read users, manage groups, and manage Administrative Unit members.
data "azuread_service_principal" "msgraph" {
client_id = "00000003-0000-0000-c000-000000000000" # Microsoft Graph
}

resource "azuread_app_role_assignment" "user_read_all" {
app_role_id = data.azuread_service_principal.msgraph.app_role_ids["User.Read.All"]
principal_object_id = azurerm_user_assigned_identity.backplane.principal_id
resource_object_id = data.azuread_service_principal.msgraph.object_id
}

resource "azuread_app_role_assignment" "group_readwrite_all" {
app_role_id = data.azuread_service_principal.msgraph.app_role_ids["Group.ReadWrite.All"]
principal_object_id = azurerm_user_assigned_identity.backplane.principal_id
resource_object_id = data.azuread_service_principal.msgraph.object_id
}

resource "azuread_app_role_assignment" "administrative_unit_readwrite_all" {
app_role_id = data.azuread_service_principal.msgraph.app_role_ids["AdministrativeUnit.ReadWrite.All"]
principal_object_id = azurerm_user_assigned_identity.backplane.principal_id
resource_object_id = data.azuread_service_principal.msgraph.object_id
}
8 changes: 8 additions & 0 deletions modules/azure/entra-id-groups/backplane/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
output "identity" {
description = "UAMI identity attributes consumed by meshstack_integration.tf as static inputs."
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
}
}
3 changes: 3 additions & 0 deletions modules/azure/entra-id-groups/backplane/provider.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
provider "azurerm" {
features {}
}
25 changes: 25 additions & 0 deletions modules/azure/entra-id-groups/backplane/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
variable "name" {
type = string
nullable = false
description = "Name for the UAMI and related backplane resources. Must match pattern ^[-a-z0-9]+$."

validation {
condition = can(regex("^[-a-z0-9]+$", var.name))
error_message = "Only lowercase alphanumeric characters and dashes are allowed."
}
}

variable "location" {
type = string
nullable = false
description = "Azure region for the backplane resource group and UAMI."
}

variable "workload_identity_federation" {
type = object({
issuer = string
subjects = list(string)
})
nullable = false
description = "WIF issuer and subjects for federated authentication from the meshStack replicator."
}
14 changes: 14 additions & 0 deletions modules/azure/entra-id-groups/backplane/versions.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
terraform {
required_version = ">= 1.0.0"

required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

f: use >= constraints, see #199

}
azuread = {
source = "hashicorp/azuread"
version = "~> 3.8"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

f: use >= constraints, see #199

}
}
}
47 changes: 47 additions & 0 deletions modules/azure/entra-id-groups/buildingblock/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
name: Azure Entra ID Groups
supportedPlatforms:
- azure
description: Creates Entra security groups for meshStack project roles, with optional Administrative Unit membership.
---

Automatically provision Entra ID security groups for every role in a meshStack project. Groups are named consistently using the workspace identifier, project identifier, an optional prefix, and the role name as suffix — giving your teams a predictable, auditable group structure in Azure Active Directory.

## When to use it

Use this building block when you want to:
- Map meshStack project roles (admin, user, reader, or custom roles) to Entra security groups for RBAC assignments in Azure.
- Enforce a standard naming scheme across all projects in your platform.
- Optionally scope groups inside a dedicated Entra Administrative Unit to isolate tenant-level identities from the rest of the directory.

## Usage examples

**Default meshStack roles (admin / user / reader):**

A project `my-project` in workspace `my-workspace` with prefix `plat` produces three groups:
- `plat-my-workspace-my-project-admin`
- `plat-my-workspace-my-project-user`
- `plat-my-workspace-my-project-reader`

**Custom roles:**

Set *Project Roles* to `devops,qa,readonly` to create:
- `plat-my-workspace-my-project-devops`
- `plat-my-workspace-my-project-qa`
- `plat-my-workspace-my-project-readonly`

**With Administrative Unit:**

Provide the object ID of an existing Entra Administrative Unit. All generated groups are added as members of that AU, restricting who can manage them in the directory.

## Shared Responsibilities

| Responsibility | Platform Team | Application Team |
|---|:---:|:---:|
| Deploy and configure the backplane identity | ✅ | ❌ |
| Define the group naming prefix | ✅ | ❌ |
| Create and delete Entra groups | ✅ | ❌ |
| Add the Administrative Unit (optional) | ✅ | ❌ |
| Choose which project roles get groups | ❌ | ✅ |
| Assign users to the generated groups | ❌ | ✅ |
| Use group IDs in downstream RBAC assignments | ❌ | ✅ |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
52 changes: 52 additions & 0 deletions modules/azure/entra-id-groups/buildingblock/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
locals {
roles = [for r in split(",", var.project_roles) : trimspace(r) if trimspace(r) != ""]
au_id = var.administrative_unit_id != "" ? var.administrative_unit_id : null
name_parts = compact([var.prefix, var.workspace_identifier, var.project_identifier])

unique_user_euids = toset([for user in var.users : user.euid])

user_role_assignments = {
for pair in flatten([
for user in var.users : [
for role in user.roles : {
key = "${user.euid}-${role}"
euid = user.euid
role = role
}
]
]) : pair.key => pair
if contains(local.roles, pair.role)
}
}

data "azuread_user" "by_upn" {
for_each = var.user_lookup_attribute == "upn" ? local.unique_user_euids : toset([])
user_principal_name = each.value
}

data "azuread_user" "by_email" {
for_each = var.user_lookup_attribute == "email" ? local.unique_user_euids : toset([])
mail = each.value
}

resource "azuread_group" "project_role" {
for_each = toset(local.roles)

display_name = join("-", concat(local.name_parts, [each.value]))
security_enabled = true
mail_enabled = false
}

resource "azuread_administrative_unit_member" "project_role" {
for_each = local.au_id != null ? toset(local.roles) : toset([])

administrative_unit_object_id = local.au_id
member_object_id = azuread_group.project_role[each.value].object_id
}

resource "azuread_group_member" "project_role" {
for_each = local.user_role_assignments

group_object_id = azuread_group.project_role[each.value.role].object_id
member_object_id = var.user_lookup_attribute == "upn" ? data.azuread_user.by_upn[each.value.euid].object_id : data.azuread_user.by_email[each.value.euid].object_id
}
9 changes: 9 additions & 0 deletions modules/azure/entra-id-groups/buildingblock/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
output "group_object_ids" {
description = "Map of project role name to Entra group object ID."
value = { for role, g in azuread_group.project_role : role => g.object_id }
}

output "group_display_names" {
description = "Map of project role name to Entra group display name."
value = { for role, g in azuread_group.project_role : role => g.display_name }
}
1 change: 1 addition & 0 deletions modules/azure/entra-id-groups/buildingblock/provider.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
provider "azuread" {}
51 changes: 51 additions & 0 deletions modules/azure/entra-id-groups/buildingblock/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
variable "prefix" {
type = string
default = ""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

f: I'm inclined to never have defaults defined for buildingblock module vars, as they should also be shipped by the BBD in meshstack_integration.tf. defaults then simply mask some missing BBD input, taking an undesired value accidently. also, consider adding nullable = false (Which is true by default IIRC).

see also other vars here in this file and make it consistent. also check if skill / AI instructions (score card review) can be extended accordingly.

description = "Optional prefix prepended to all group display names. Leave empty to omit."
}

variable "workspace_identifier" {
type = string
description = "meshStack workspace identifier included in the group name."
}

variable "project_identifier" {
type = string
description = "meshStack project identifier included in the group name."
}

variable "project_roles" {
type = string
default = "admin,user,reader"
description = "Comma-separated list of project role name suffixes. One Entra group is created per role. Defaults to the three standard meshStack roles: admin, user, reader."
}

variable "administrative_unit_id" {
type = string
default = ""
description = "Object ID of the Entra Administrative Unit to add the groups to. Leave empty to skip AU membership."
}

variable "user_lookup_attribute" {
type = string
default = "upn"
description = "Azure AD attribute used to look up users. 'upn' matches on User Principal Name; 'email' matches on the primary mail address."
validation {
condition = contains(["upn", "email"], var.user_lookup_attribute)
error_message = "Must be 'upn' or 'email'."
}
}

variable "users" {
type = list(object({
meshIdentifier = string
username = string
firstName = string
lastName = string
email = string
euid = string
roles = list(string)
}))
default = []
description = "Project members from meshStack with their assigned roles. Each user is added to the group matching their role."
}
8 changes: 8 additions & 0 deletions modules/azure/entra-id-groups/buildingblock/versions.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
terraform {
required_providers {
azuread = {
source = "hashicorp/azuread"
version = "~> 3.8.0"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

f: use >= constraints, see #199

}
}
}
Loading
Loading