diff --git a/modules/azure/entra-id-groups/backplane/README.md b/modules/azure/entra-id-groups/backplane/README.md new file mode 100644 index 00000000..010bb594 --- /dev/null +++ b/modules/azure/entra-id-groups/backplane/README.md @@ -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. +- **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`. +- 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. diff --git a/modules/azure/entra-id-groups/backplane/main.tf b/modules/azure/entra-id-groups/backplane/main.tf new file mode 100644 index 00000000..c9db9c0d --- /dev/null +++ b/modules/azure/entra-id-groups/backplane/main.tf @@ -0,0 +1,44 @@ +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 +} + +# 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 +} diff --git a/modules/azure/entra-id-groups/backplane/outputs.tf b/modules/azure/entra-id-groups/backplane/outputs.tf new file mode 100644 index 00000000..940d8f91 --- /dev/null +++ b/modules/azure/entra-id-groups/backplane/outputs.tf @@ -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 + } +} diff --git a/modules/azure/entra-id-groups/backplane/provider.tf b/modules/azure/entra-id-groups/backplane/provider.tf new file mode 100644 index 00000000..ab91b248 --- /dev/null +++ b/modules/azure/entra-id-groups/backplane/provider.tf @@ -0,0 +1,3 @@ +provider "azurerm" { + features {} +} diff --git a/modules/azure/entra-id-groups/backplane/variables.tf b/modules/azure/entra-id-groups/backplane/variables.tf new file mode 100644 index 00000000..59ad84b6 --- /dev/null +++ b/modules/azure/entra-id-groups/backplane/variables.tf @@ -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." +} diff --git a/modules/azure/entra-id-groups/backplane/versions.tf b/modules/azure/entra-id-groups/backplane/versions.tf new file mode 100644 index 00000000..4b5a7c6a --- /dev/null +++ b/modules/azure/entra-id-groups/backplane/versions.tf @@ -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" + } + } +} diff --git a/modules/azure/entra-id-groups/buildingblock/README.md b/modules/azure/entra-id-groups/buildingblock/README.md new file mode 100644 index 00000000..5fec044c --- /dev/null +++ b/modules/azure/entra-id-groups/buildingblock/README.md @@ -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 | ❌ | ✅ | diff --git a/modules/azure/entra-id-groups/buildingblock/logo.png b/modules/azure/entra-id-groups/buildingblock/logo.png new file mode 100644 index 00000000..86415560 Binary files /dev/null and b/modules/azure/entra-id-groups/buildingblock/logo.png differ diff --git a/modules/azure/entra-id-groups/buildingblock/main.tf b/modules/azure/entra-id-groups/buildingblock/main.tf new file mode 100644 index 00000000..41b5d2d7 --- /dev/null +++ b/modules/azure/entra-id-groups/buildingblock/main.tf @@ -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 +} diff --git a/modules/azure/entra-id-groups/buildingblock/outputs.tf b/modules/azure/entra-id-groups/buildingblock/outputs.tf new file mode 100644 index 00000000..eddfb431 --- /dev/null +++ b/modules/azure/entra-id-groups/buildingblock/outputs.tf @@ -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 } +} diff --git a/modules/azure/entra-id-groups/buildingblock/provider.tf b/modules/azure/entra-id-groups/buildingblock/provider.tf new file mode 100644 index 00000000..f2025dcd --- /dev/null +++ b/modules/azure/entra-id-groups/buildingblock/provider.tf @@ -0,0 +1 @@ +provider "azuread" {} diff --git a/modules/azure/entra-id-groups/buildingblock/variables.tf b/modules/azure/entra-id-groups/buildingblock/variables.tf new file mode 100644 index 00000000..4784bf54 --- /dev/null +++ b/modules/azure/entra-id-groups/buildingblock/variables.tf @@ -0,0 +1,51 @@ +variable "prefix" { + type = string + default = "" + 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." +} diff --git a/modules/azure/entra-id-groups/buildingblock/versions.tf b/modules/azure/entra-id-groups/buildingblock/versions.tf new file mode 100644 index 00000000..41eb1aaa --- /dev/null +++ b/modules/azure/entra-id-groups/buildingblock/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + azuread = { + source = "hashicorp/azuread" + version = "~> 3.8.0" + } + } +} diff --git a/modules/azure/entra-id-groups/meshstack_integration.tf b/modules/azure/entra-id-groups/meshstack_integration.tf new file mode 100644 index 00000000..6a45a88f --- /dev/null +++ b/modules/azure/entra-id-groups/meshstack_integration.tf @@ -0,0 +1,262 @@ +variable "azure_tenant_id" { + type = string + description = "Azure Entra tenant ID where groups will be created." +} + +variable "azure_scope" { + type = string + description = "Azure management group or subscription ID used as the backplane UAMI's role assignment scope." +} + +variable "azure_location" { + type = string + description = "Azure region for the backplane resource group and UAMI (e.g. 'westeurope')." +} + +variable "backplane_name" { + type = string + default = "azure-entra-id-groups" + description = "Name for the backplane resources (resource group, UAMI, role definition). Must match pattern ^[-a-z0-9]+$." + + validation { + condition = can(regex("^[-a-z0-9]+$", var.backplane_name)) + error_message = "Only lowercase alphanumeric characters and dashes are allowed." + } +} + +variable "notification_subscribers" { + type = list(string) + default = [] + description = "Email addresses notified on building block lifecycle events." +} + +variable "meshstack" { + type = object({ + owning_workspace_identifier = string + tags = optional(map(list(string)), {}) + }) + description = "Shared meshStack context. Tags are optional and propagated to building block definition metadata." +} + +variable "hub" { + type = object({ + git_ref = optional(string, "main") + bbd_draft = optional(bool, true) + }) + 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. + `bbd_draft`: If true, the building block definition version is kept in draft mode. + EOT +} + +output "building_block_definition" { + description = "BBD is consumed in building block compositions." + value = { + uuid = meshstack_building_block_definition.this.metadata.uuid + version_ref = var.hub.bbd_draft ? meshstack_building_block_definition.this.version_latest : meshstack_building_block_definition.this.version_latest_release + } +} + +data "meshstack_integrations" "integrations" {} + +module "backplane" { + source = "github.com/meshcloud/meshstack-hub//modules/azure/entra-id-groups/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}" + ] + } +} + +resource "meshstack_building_block_definition" "this" { + metadata = { + owned_by_workspace = var.meshstack.owning_workspace_identifier + tags = var.meshstack.tags + } + + spec = { + display_name = "Azure Entra ID Groups" + description = "Creates Entra security groups for meshStack project roles, with optional Administrative Unit membership." + support_url = "mailto:support@meshcloud.io" + documentation_url = "https://hub.meshcloud.io/platforms/azure/definitions/azure-entra-id-groups" + notification_subscribers = var.notification_subscribers + symbol = "https://raw.githubusercontent.com/meshcloud/meshstack-hub/main/modules/azure/entra-id-groups/buildingblock/logo.png" + target_type = "TENANT_LEVEL" + + readme = chomp(<<-EOT + 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 generated groups (automated via project membership) | ✅ | ❌ | + | Manage which users have which project roles | ❌ | ✅ | + | Use group IDs in downstream RBAC assignments | ❌ | ✅ | + EOT + ) + } + + version_spec = { + draft = var.hub.bbd_draft + + deletion_mode = "DELETE" + + implementation = { + terraform = { + terraform_version = "1.9.0" + repository_url = "https://github.com/meshcloud/meshstack-hub.git" + repository_path = "modules/azure/entra-id-groups/buildingblock" + ref_name = var.hub.git_ref + use_mesh_http_backend_fallback = true + } + } + + inputs = { + ARM_CLIENT_ID = { + type = "STRING" + display_name = "ARM Client ID" + description = "Client ID of the UAMI used to authenticate with Azure." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(module.backplane.identity.client_id) + } + ARM_TENANT_ID = { + type = "STRING" + display_name = "ARM Tenant ID" + description = "Azure Entra tenant ID for authentication." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(var.azure_tenant_id) + } + ARM_USE_OIDC = { + type = "STRING" + display_name = "ARM Use OIDC" + description = "Enables OIDC-based workload identity federation for the AzureAD provider." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode("true") + } + ARM_OIDC_TOKEN_FILE_PATH = { + type = "STRING" + display_name = "ARM OIDC Token File Path" + description = "Path to the OIDC token file used for workload identity federation." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode("/var/run/secrets/workload-identity/azure/token") + } + prefix = { + type = "STRING" + display_name = "Group Name Prefix" + description = "Optional prefix prepended to all group display names (e.g. 'plat'). Leave empty to omit." + assignment_type = "USER_INPUT" + argument = jsonencode("") + } + workspace_identifier = { + type = "STRING" + display_name = "Workspace Identifier" + description = "meshStack workspace identifier. Injected automatically from the platform context." + assignment_type = "PLATFORM_TENANT_WORKSPACE_IDENTIFIER" + } + project_identifier = { + type = "STRING" + display_name = "Project Identifier" + description = "meshStack project identifier. Injected automatically from the platform context." + assignment_type = "PLATFORM_TENANT_PROJECT_IDENTIFIER" + } + project_roles = { + type = "STRING" + display_name = "Project Roles" + description = "Comma-separated list of project role name suffixes. One Entra group is created per role. Defaults to the three standard meshStack roles." + assignment_type = "USER_INPUT" + argument = jsonencode("admin,user,reader") + } + administrative_unit_id = { + type = "STRING" + display_name = "Administrative Unit ID" + description = "Object ID of the Entra Administrative Unit to add the groups to. Leave empty to skip AU membership." + assignment_type = "USER_INPUT" + argument = jsonencode("") + } + users = { + type = "CODE" + display_name = "Users" + description = "Project members from meshStack with their assigned roles. Injected automatically by meshStack." + assignment_type = "USER_PERMISSIONS" + } + } + + outputs = { + group_object_ids = { + type = "STRING" + display_name = "Group Object IDs" + description = "JSON map of project role name to Entra group object ID." + assignment_type = "NONE" + } + group_display_names = { + type = "STRING" + display_name = "Group Display Names" + description = "JSON map of project role name to Entra group display name." + assignment_type = "NONE" + } + } + } +} + +terraform { + required_version = ">= 1.12.0" + + required_providers { + meshstack = { + source = "meshcloud/meshstack" + version = "~> 0.21.0" + } + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + azuread = { + source = "hashicorp/azuread" + version = "~> 3.8" + } + } +}