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

- **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" "this" {
name = var.name
location = var.location
}

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

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.this.name
parent_id = azurerm_user_assigned_identity.this.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.this.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.this.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.this.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.this.client_id
principal_id = azurerm_user_assigned_identity.this.principal_id
tenant_id = azurerm_user_assigned_identity.this.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"
}
azuread = {
source = "hashicorp/azuread"
version = ">= 3.8"
}
}
}
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.
46 changes: 46 additions & 0 deletions modules/azure/entra-id-groups/buildingblock/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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
administrative_unit_ids = local.au_id != null ? [local.au_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" {}
53 changes: 53 additions & 0 deletions modules/azure/entra-id-groups/buildingblock/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
variable "prefix" {
type = string
nullable = false
description = "Optional prefix prepended to all group display names. Leave empty to omit."
}

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

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

variable "project_roles" {
type = string
nullable = false
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
nullable = false
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
nullable = false
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)
}))
nullable = false
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"
}
}
}
Loading
Loading