Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure Synapse Role Assignment Terraform Module

One Synapse RBAC role assignment, at a workspace scope or a Spark pool scope β€” targeting hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • πŸ”‘ Grants one of eleven Synapse RBAC roles to a directory principal, at a workspace or a Spark pool.
  • 🚨 States the fact that breaks access reviews: these are not Azure RBAC roles. They appear in no az role assignment list, in no IAM blade, and in no Azure RBAC export.
  • 🧡 Records that the Resource ID is not a Resource ID β€” it is {scope}|{assignmentId}, joined by a pipe.
  • 🎯 Enforces the provider's exactly-one-of scope rule, and explains why neither variable carries a default.
  • πŸͺœ Records that the role set is narrower at a Spark pool scope, and that the narrowing is resolved at apply.
  • πŸ•°οΈ Rejects the three legacy role names the provider accepts in state but not in configuration, naming each replacement.
  • 🏷️ Carries no tags and no location; the universal tail is timeouts only, with no update member.

πŸ’‘ Why it matters: an estate audit built on Azure RBAC reports zero Synapse role assignments no matter how many exist. Synapse Administrator is full control of a workspace's data plane and is invisible to every tool that looks at Microsoft.Authorization/roleAssignments. This module's outputs are written to be the input to the review that Azure RBAC will not produce.


❀️ Support this project

If this module saved you time:


πŸ—ΊοΈ Where this fits in the family

flowchart TB
  WS["terraform-azurerm-synapse-workspace"]
  SP["terraform-azurerm-synapse-spark-pool"]
  THIS["terraform-azurerm-synapse-role-assignment"]
  UAI["terraform-azurerm-user-assigned-identity"]
  ARM["terraform-azurerm-role-assignments, Azure RBAC"]
  AAD["terraform-azurerm-synapse-workspace-aad-admin"]

  WS -->|"id, one scope choice"| THIS
  SP -->|"id, the other scope choice, narrower role set"| THIS
  UAI -->|"principal_id, the OBJECT id"| THIS
  WS -->|"identity_principal_id"| ARM
  THIS -.->|"a different system: neither grants what the other does"| ARM
  WS -->|"synapse_workspace_id"| AAD

  classDef me fill:#0078D4,stroke:#004578,stroke-width:2px,color:#ffffff
  classDef target fill:#004578,stroke:#00243d,stroke-width:2px,color:#ffffff
  classDef sib fill:#eef3f8,stroke:#9db4c9,color:#1a2733

  class THIS me
  class WS target
  class SP,UAI,ARM,AAD sib
Loading

Two scope choices in, one principal in β€” and the dotted edge on the right is the whole point. Azure RBAC and Synapse RBAC are separate systems: neither grants what the other does, and most working setups need both.


🧬 What this module builds

flowchart TB
  WSIN["synapse_workspace_id, exactly one of"]
  SPIN["synapse_spark_pool_id, exactly one of"]
  PID["principal_id, an Entra OBJECT id"]
  PTYPE["principal_type, optional"]
  ROLE["role_name, eleven names"]
  THIS["azurerm_synapse_role_assignment.this"]
  OID["id, scope and a GUID joined by a pipe"]
  OSCOPE["parsed scope: workspace, spark pool, resource group"]
  ONOTARM["these are synapse rbac roles, not azure rbac"]
  ONARROW["the role set is narrower at a spark pool scope"]

  WSIN --> THIS
  SPIN --> THIS
  PID --> THIS
  PTYPE --> THIS
  ROLE --> THIS
  THIS --> OID
  THIS --> OSCOPE
  THIS --> ONOTARM
  THIS --> ONARROW

  classDef me fill:#0078D4,stroke:#004578,stroke-width:2px,color:#ffffff
  classDef target fill:#004578,stroke:#00243d,stroke-width:2px,color:#ffffff
  classDef sib fill:#eef3f8,stroke:#9db4c9,color:#1a2733

  class THIS me
  class OID target
  class WSIN,SPIN,PID,PTYPE,ROLE,OSCOPE,ONOTARM,ONARROW sib
Loading

Resource inventory

Resource Count Notes
azurerm_synapse_role_assignment 1 (this) one role, one principal, one scope
/subscriptions/SUB/resourceGroups/RG/providers/Microsoft.Synapse/workspaces/WS|11111111-2222-3333-4444-555555555555

That is the whole ID β€” a scope and a data-plane GUID joined by a pipe. It is not an Azure Resource ID.


βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module β€” the caller configures the provider, its authentication, and the mandatory features {} block
Resources created 1

Schema notes that bite

  • πŸ”΄ Synapse RBAC is not Azure RBAC. These grants live on the workspace's data plane. They appear in no az role assignment list, in no Access control (IAM) blade, and in no Azure RBAC export β€” and a Synapse role confers no Azure Resource Manager permission whatsoever.
  • πŸ”΄ The ID is {scope}|{assignmentId}. Not an ARM Resource ID; it cannot be used as an RBAC scope, a management-lock target or a policy scope, and any expression that treats it as a path must split it on the pipe first.
  • πŸ”΄ The role set is wider in the schema than at the scope. The provider validates role_name against eleven names offline, then lists the role definitions available at the scope and refuses anything not among them β€” role name %q invalid for scope %q. Available role names are .... A Spark pool scope offers fewer roles, and the failure is at apply.
  • πŸ”΄ Three legacy role names are accepted in STATE but not in CONFIGURATION. Workspace Admin, Apache Spark Admin and Sql Admin are migrated by a state upgrader and equated by a diff suppressor β€” but they are absent from the schema's list, so writing one is rejected outright.
  • πŸ”΄ ExactlyOneOf tests presence, not value. That is why neither scope variable carries a default: a default would make both present in configuration and every call would fail the provider's own check.
  • ⚠️ principal_id is checked only for being a UUID. A service principal's application ID is also a UUID β€” it passes every check here and grants nothing usable.
  • ⚠️ An absent principal_type reads back as an empty string, not as null.
  • ⚠️ The duplicate check is a LIST, not a Get β€” it matches on role, principal and scope together.
  • This is a data-plane resource: Terraform must be able to reach the workspace's Synapse endpoint.
  • Force-new: every argument. There is no update function and no update timeout.

πŸ”‘ Required Azure RBAC Roles / Permissions

Least-privilege, at the smallest scope that works:

  • Microsoft.Synapse/workspaces/read on the workspace, plus Synapse-plane rights to manage role assignments β€” in practice the Synapse Administrator role at the target scope. This resource is created through the workspace's own Synapse endpoint rather than through Azure Resource Manager, so ARM permissions alone are not sufficient.

⚠️ Write access here is the ability to grant Synapse Administrator β€” full control of the workspace's data plane β€” in a way that no Azure RBAC access review will report.

ℹ️ Plan access is not credential access. This resource holds no secret and the provider marks nothing on it sensitive.


Azure Prerequisites

  • An existing Synapse workspace, or a Spark pool within one.
  • A Microsoft Entra object ID for the principal β€” the object ID, not a name and not an application ID.
  • Network reachability from the apply environment to the workspace's Synapse endpoint.
  • An identity that already holds Synapse Administrator at the target scope, or equivalent.
  • The caller configures provider "azurerm" { features {} }, authentication and subscription.

πŸ“ Module Structure

terraform-azurerm-synapse-role-assignment/
β”œβ”€β”€ providers.tf     # required_version + pinned azurerm; no provider block
β”œβ”€β”€ variables.tf     # 6 inputs, 15 validations, including the exactly-one-of scope rule
β”œβ”€β”€ main.tf          # the single keystone `this`; both scopes passed through as null-or-set
β”œβ”€β”€ outputs.tf       # 30 outputs; the id first, then the scope taken apart, then the facts
β”œβ”€β”€ README.md        # this file
β”œβ”€β”€ SCOPE.md         # the cross-module contract
β”œβ”€β”€ LICENSE          # MIT
└── .gitignore

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "analyst_access" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-role-assignment.git?ref=v1.0.0"

  synapse_workspace_id = module.workspace.id

  principal_id   = data.azuread_group.analysts.object_id
  principal_type = "Group"
  role_name      = "Synapse Contributor"
}

ℹ️ The caller configures the provider, its authentication, and the mandatory features {} block. This module declares none of them.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source
synapse_workspace_id string terraform-azurerm-synapse-workspace output id
synapse_spark_pool_id string terraform-azurerm-synapse-spark-pool output id
principal_id string a Microsoft Entra object ID

Emits

Output Description Consumed by
id The composite {scope}|{assignmentId} review β€” not a Resource ID
scope The scope, from before the pipe access review
data_plane_assignment_id The assignment GUID, from after the pipe CLI cross-checks
synapse_workspace_id The workspace scope, or "" review
synapse_spark_pool_id The Spark pool scope, or "" review
effective_workspace_id The workspace affected, whichever scope was used access review, tagging
synapse_workspace_name Workspace name, parsed from the scope labelling
resource_group_name Resource group, parsed from the scope sibling modules
synapse_spark_pool_name Pool name, or null at a workspace scope labelling
is_scoped_to_a_spark_pool True for the narrower scope access review
principal_id The Entra object ID granted access review
principal_type The directory object kind, or "" access review
principal_type_was_not_supplied True when omitted review
role_name The Synapse role granted access review
is_an_administrator_role True for the three Administrator roles access review
these_are_synapse_rbac_roles_not_azure_rbac Constant true access review
the_id_is_not_an_azure_resource_id Constant true design review
is_a_data_plane_resource Constant true design review
the_role_set_is_narrower_at_a_spark_pool_scope Constant true design review
three_legacy_role_names_are_accepted_in_state_but_not_in_configuration Constant true migration review
legacy_role_name_replacements The three mappings, as data migration review
nothing_verifies_that_the_principal_exists Constant true access review
principal_id_is_an_object_id_not_an_application_id Constant true access review
has_no_update_at_all Constant true change planning
force_new_fields Every argument change planning
create_checks_for_a_duplicate_by_listing_assignments Constant true design rationale
the_import_guard_can_be_disabled_by_a_provider_feature Constant true design rationale
an_absent_principal_type_reads_back_as_an_empty_string Constant true drift review
fields_azure_returns_on_read Where drift is detectable drift review
this_resource_supports_no_azure_resource_tags Constant true tagging policy

πŸ“š Example Library

1 Β· A group at the workspace scope
module "analyst_access" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-role-assignment.git?ref=v1.0.0"

  synapse_workspace_id = module.workspace.id

  principal_id   = data.azuread_group.analysts.object_id
  principal_type = "Group"
  role_name      = "Synapse Contributor"
}

πŸ’‘ Grant to groups, not to users. Every argument here is force-new, so changing who has access by editing principal_id is a revoke and a re-grant; changing a group's membership is not a Terraform operation at all.

2 Β· A managed identity β€” which is a `ServicePrincipal`
module "pipeline_identity" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"
  name                = "id-synapse-pipeline"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

module "pipeline_identity_access" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-role-assignment.git?ref=v1.0.0"

  synapse_workspace_id = module.workspace.id

  principal_id   = module.pipeline_identity.principal_id
  principal_type = "ServicePrincipal" # NOT "ManagedIdentity" -- no such value here
  role_name      = "Synapse Artifact User"
}

⚠️ Passing "ManagedIdentity" is rejected with a message saying exactly this. Both system-assigned and user-assigned identities are service principals as far as Synapse RBAC is concerned.

πŸ’‘ Supplying principal_type is worth the keystrokes: Azure can take time to replicate a newly-created service principal, and stating the type is the conventional way to avoid a grant failing against a directory that has not caught up.

3 Β· The narrower Spark pool scope
module "spark_operator_access" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-role-assignment.git?ref=v1.0.0"

  # EXACTLY ONE scope. The workspace argument is simply absent.
  synapse_spark_pool_id = module.spark_pool.id

  principal_id   = data.azuread_group.data_engineers.object_id
  principal_type = "Group"
  role_name      = "Synapse Compute Operator"
}

πŸ”΄ the_role_set_is_narrower_at_a_spark_pool_scope is true. The schema accepts all eleven role names offline; the provider then lists the definitions available at this scope and refuses anything not among them, at apply. Its error message is the only place the valid set for a scope is ever stated.

ℹ️ The ARM type is bigDataPools even though the service calls it a Spark pool β€” that is what the ID looks like, and the module's pattern expects it.

4 Β· Exactly one scope β€” both, or neither, is rejected
module "broken_access" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-role-assignment.git?ref=v1.0.0"

  synapse_workspace_id  = module.workspace.id
  synapse_spark_pool_id = module.spark_pool.id # <-- both set

  principal_id = data.azuread_group.analysts.object_id
  role_name    = "Synapse User"
}
Error: Invalid value for variable

  Exactly one of synapse_workspace_id and synapse_spark_pool_id must be set --
  both were supplied, or neither was. The provider enforces this and tests
  whether each argument is PRESENT rather than what its value is, which is also
  why neither variable carries a default.

πŸ”’ Neither variable has a default on purpose. ExactlyOneOf tests presence, so giving either one a default β€” even an empty string β€” would make both present on every call and every call would fail. The module's main.tf passes both through unchanged for the same reason: a null attribute is absent.

5 Β· The object ID trap
module "app_access" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-role-assignment.git?ref=v1.0.0"

  synapse_workspace_id = module.workspace.id

  # WRONG: this is the application (client) ID. It is a UUID, so it passes
  # every validation -- and grants nothing usable.
  # principal_id = azuread_application.pipeline.client_id

  # RIGHT: the service principal's OBJECT id.
  principal_id   = azuread_service_principal.pipeline.object_id
  principal_type = "ServicePrincipal"
  role_name      = "Synapse Artifact Publisher"
}

πŸ”΄ principal_id_is_an_object_id_not_an_application_id is true, and this is the failure mode with no error at any stage: the assignment is created, reads back cleanly, and the principal cannot use it. az ad sp show --id <appId> --query id returns the one you want.

⚠️ A user principal name is rejected with its own message. An application ID cannot be β€” it is a well-formed UUID, and nothing offline can tell the two apart.

6 Β· Legacy role names
module "admin_access" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-role-assignment.git?ref=v1.0.0"

  synapse_workspace_id = module.workspace.id
  principal_id         = data.azuread_group.platform_admins.object_id
  principal_type       = "Group"
  role_name            = "Workspace Admin" # <-- the legacy name
}
Error: Invalid value for variable

  role_name is one of the three LEGACY role names. Use the modern equivalent:
  "Workspace Admin" is now "Synapse Administrator", "Apache Spark Admin" is now
  "Apache Spark Administrator", and "Sql Admin" is now "Synapse SQL
  Administrator". The provider migrates these three in STATE and treats them as
  equal when comparing, but does not accept them in a configuration.

πŸ’‘ The asymmetry is real and easy to misread: the provider carries a state upgrader and a diff suppressor for these three, so an old state does not force a replacement β€” but they are not in the schema's accepted list, so a configuration that still writes one is refused. The mapping is emitted as legacy_role_name_replacements for anyone rewriting an old configuration in bulk.

7 Β· A whole access model with `for_each`
module "monitoring_identity" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"
  name                = "id-synapse-monitoring"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

locals {
  synapse_access = {
    platform_admins = { principal = data.azuread_group.platform_admins.object_id, type = "Group", role = "Synapse Administrator" }
    data_engineers  = { principal = data.azuread_group.data_engineers.object_id, type = "Group", role = "Synapse Contributor" }
    analysts        = { principal = data.azuread_group.analysts.object_id, type = "Group", role = "Synapse User" }
    monitoring      = { principal = module.monitoring_identity.principal_id, type = "ServicePrincipal", role = "Synapse Monitoring Operator" }
  }
}

module "synapse_access" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-role-assignment.git?ref=v1.0.0"
  for_each = local.synapse_access

  synapse_workspace_id = module.workspace.id

  principal_id   = each.value.principal
  principal_type = each.value.type
  role_name      = each.value.role
}

πŸ’‘ Key the map by who, not by role. Every argument is force-new, so a stable key is what keeps a change to one grant from re-creating the others β€” each re-creation being a genuine, if brief, revoke.

8 Β· The access review Azure RBAC will not produce
output "synapse_rbac_review" {
  value = {
    for k, m in module.synapse_access : k => {
      principal    = m.principal_id
      type         = m.principal_type
      role         = m.role_name
      scope        = m.scope
      workspace    = m.synapse_workspace_name
      pool_scoped  = m.is_scoped_to_a_spark_pool
      is_admin     = m.is_an_administrator_role
      cli_id       = m.data_plane_assignment_id
    }
  }
}

output "synapse_administrators" {
  value = [for k, m in module.synapse_access : m.principal_id if m.is_an_administrator_role]
}

πŸ”΄ these_are_synapse_rbac_roles_not_azure_rbac is true. Nothing in az role assignment list will ever show these grants, so this output is the review. data_plane_assignment_id is included because it is the identifier the Synapse REST API and the CLI use to cross-check it.

9 Β· Grouping across scopes
locals {
  grants_by_workspace = {
    for wsid in distinct([for k, m in module.synapse_access : m.effective_workspace_id]) :
    wsid => [for k, m in module.synapse_access : k if m.effective_workspace_id == wsid]
  }
}

output "synapse_grants_per_workspace" {
  value = local.grants_by_workspace
}

πŸ’‘ effective_workspace_id is derived from the scope, so it is populated for both scope kinds β€” for a Spark pool grant it is the pool's parent workspace. The two scope arguments cannot be used for this: the provider writes an empty string into whichever one was not used, rather than null.

10 Β· Synapse RBAC and Azure RBAC are both needed
# Azure RBAC: lets the group SEE the workspace resource and open Synapse Studio.
module "arm_reader" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.workspace.id

  role_assignments = {
    analysts = {
      principal_id         = data.azuread_group.analysts.object_id
      role_definition_name = "Reader"
      principal_type       = "Group"
    }
  }
}

# Synapse RBAC: lets the same group actually DO anything inside it.
module "synapse_user" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-role-assignment.git?ref=v1.0.0"

  synapse_workspace_id = module.workspace.id

  principal_id   = data.azuread_group.analysts.object_id
  principal_type = "Group"
  role_name      = "Synapse User"
}

πŸ”’ Neither substitutes for the other. Reader on the workspace grants nothing on the data plane; Synapse User grants nothing in Azure Resource Manager. A group with only the second cannot find the workspace in the portal; a group with only the first can find it and do nothing.

11 Β· Explicit timeouts β€” and the one that is not there
module "analyst_access" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-role-assignment.git?ref=v1.0.0"

  synapse_workspace_id = module.workspace.id
  principal_id         = data.azuread_group.analysts.object_id
  principal_type       = "Group"
  role_name            = "Synapse Contributor"

  timeouts = {
    create = "30m"
    read   = "5m"
    delete = "30m"
    # there is no `update` -- the resource has no update function
  }
}

ℹ️ create covers four calls, none of them the slow one you would expect: listing the role definitions at the scope to resolve the name to a GUID, listing existing assignments to check for a duplicate, creating the assignment, and reading it back. read covers two β€” the assignment, then a second lookup of the role definition so that role_name can be reported as a name rather than a GUID.

12 Β· Revoking a grant
# Removing the module block is the revoke. There is no "disabled" state and no
# expiry: a Synapse role assignment exists or it does not.
#
# Note that `has_no_update_at_all` is true, so CHANGING a role is also a revoke
# and a re-grant -- Terraform destroys the old assignment and creates a new one,
# and there is a window between them.

output "changing_a_role_is_not_atomic" {
  value = {
    no_update   = module.analyst_access.has_no_update_at_all
    force_new   = module.analyst_access.force_new_fields
    implication = "a role change is a destroy and create, with a brief gap"
  }
}

⚠️ For a role that is being narrowed, the gap is harmless. For one being widened on an identity that is mid-pipeline, it is not β€” add the new assignment as a separate instance first, then remove the old one on a later apply.

13 Β· πŸ—οΈ End-to-end composition

Resource group + data lake + Synapse workspace + Spark pool + a full access model across both scopes and both RBAC systems.

provider "azurerm" {
  features {}
}

data "azuread_group" "platform_admins" {
  display_name = "Analytics Platform Admins"
}

data "azuread_group" "data_engineers" {
  display_name = "Analytics Data Engineers"
}

data "azuread_group" "analysts" {
  display_name = "Analytics Consumers"
}

module "rg" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
  name     = "rg-analytics-prod"
  location = "eastus2"
}

module "lake" {
  source                   = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
  name                     = "stanalyticsprod01"
  resource_group_name      = module.rg.name
  location                 = module.rg.location
  account_tier             = "Standard"
  account_replication_type = "ZRS"
  is_hns_enabled           = true
}

resource "azurerm_storage_data_lake_gen2_filesystem" "root" {
  name               = "synapse-root"
  storage_account_id = module.lake.id
}

module "workspace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-workspace.git?ref=v1.0.0"

  name                                 = "synw-analytics-prod"
  resource_group_name                  = module.rg.name
  location                             = module.rg.location
  storage_data_lake_gen2_filesystem_id = azurerm_storage_data_lake_gen2_filesystem.root.id

  azuread_authentication_only = true
  identity                    = { type = "SystemAssigned" }

  tags = {
    environment = "prod"
    workload    = "analytics"
  }
}

module "spark_pool" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-spark-pool.git?ref=v1.0.0"

  name                 = "sparkprod"
  synapse_workspace_id = module.workspace.id
  node_size_family     = "MemoryOptimized"
  node_size            = "Small"
  node_count           = 3
  spark_version        = "3.5"
}

# Azure RBAC -- so the groups can see the workspace at all.
module "arm_access" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.workspace.id

  role_assignments = {
    analysts = {
      principal_id         = data.azuread_group.analysts.object_id
      role_definition_name = "Reader"
      principal_type       = "Group"
    }
  }
}

# Synapse RBAC at the WORKSPACE scope.
locals {
  workspace_grants = {
    platform_admins = { principal = data.azuread_group.platform_admins.object_id, role = "Synapse Administrator" }
    data_engineers  = { principal = data.azuread_group.data_engineers.object_id, role = "Synapse Contributor" }
    analysts        = { principal = data.azuread_group.analysts.object_id, role = "Synapse User" }
  }
}

module "workspace_access" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-role-assignment.git?ref=v1.0.0"
  for_each = local.workspace_grants

  synapse_workspace_id = module.workspace.id

  principal_id   = each.value.principal
  principal_type = "Group"
  role_name      = each.value.role
}

# Synapse RBAC at the narrower SPARK POOL scope.
module "spark_pool_access" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-role-assignment.git?ref=v1.0.0"

  synapse_spark_pool_id = module.spark_pool.id

  principal_id   = data.azuread_group.data_engineers.object_id
  principal_type = "Group"
  role_name      = "Synapse Compute Operator"
}

output "synapse_rbac_review" {
  value = {
    workspace_scoped = {
      for k, m in module.workspace_access : k => {
        principal = m.principal_id
        role      = m.role_name
        is_admin  = m.is_an_administrator_role
        scope     = m.scope
      }
    }
    pool_scoped = {
      principal = module.spark_pool_access.principal_id
      role      = module.spark_pool_access.role_name
      pool      = module.spark_pool_access.synapse_spark_pool_name
      workspace = module.spark_pool_access.effective_workspace_id
    }
    administrators = [
      for k, m in module.workspace_access : m.principal_id if m.is_an_administrator_role
    ]
  }
}

πŸ”΄ Keep synapse_rbac_review somewhere an access review can find it. Not one of these grants appears in az role assignment list, in the IAM blade, or in any Azure RBAC export β€” this output is the only record the review will have.

ℹ️ Output names shown for sibling modules are the ones those modules emit at v1.0.0; align them with the versions you consume.


πŸ“₯ Inputs

Input Type Required Default
principal_id string βœ… β€”
role_name string βœ… β€”
synapse_workspace_id string exactly one of null (no default, deliberately)
synapse_spark_pool_id string exactly one of null (no default, deliberately)
principal_type string β€” null
timeouts object({ create, read, delete }) β€” null
Full schemas
variable "synapse_workspace_id" {
  type    = string
  default = null
  # Anchored at both ends. Rejects a Spark pool ID (which belongs in the other
  # argument) and a dedicated SQL pool ID (which is not a Synapse RBAC scope at
  # all), each with its own message.
}

variable "synapse_spark_pool_id" {
  type    = string
  default = null
  # Anchored at both ends on /bigDataPools/. Carries the exactly-one-of check,
  # because a Terraform validation may only reference its own variable.
}

variable "principal_id" {
  type = string
  # The Entra OBJECT ID, as a UUID -- mirrors the provider's check. Also
  # rejects a user principal name and the all-zero placeholder GUID. An
  # application ID cannot be rejected: it is a valid UUID.
}

variable "principal_type" {
  type    = string
  default = null
  # Exactly "User", "Group" or "ServicePrincipal", case-sensitively. A
  # managed-identity value is rejected with its own message: there is no
  # "ManagedIdentity" here.
}

variable "role_name" {
  type = string
  # One of eleven names, case-sensitively -- mirrors the provider's list. The
  # three LEGACY names are rejected with a message naming each replacement, and
  # surrounding whitespace is rejected separately because the role names
  # themselves contain spaces.
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    delete = optional(string)
  })
  default = null
  # NO `update` member: the resource has no update function. Provider
  # defaults: create 30m, read 5m, delete 30m.
}

🧾 Outputs

Output Description Notes
id The composite {scope}|{assignmentId} Emitted first; not a Resource ID
scope The scope, from before the pipe populated for both scope kinds
data_plane_assignment_id The assignment GUID what the CLI uses
synapse_workspace_id The workspace scope "" at a pool scope
synapse_spark_pool_id The Spark pool scope "" at a workspace scope
effective_workspace_id The workspace affected populated either way
synapse_workspace_name Workspace name parsed from the scope
resource_group_name Resource group parsed from the scope
synapse_spark_pool_name Pool name null at a workspace scope
is_scoped_to_a_spark_pool True for the narrower scope
principal_id The Entra object ID
principal_type The directory object kind "" when absent, not null
principal_type_was_not_supplied True when omitted
role_name The Synapse role read back from the service
is_an_administrator_role True for the three Administrator roles a review filter
these_are_synapse_rbac_roles_not_azure_rbac Constant true
the_id_is_not_an_azure_resource_id Constant true
is_a_data_plane_resource Constant true
the_role_set_is_narrower_at_a_spark_pool_scope Constant true enforced at apply
three_legacy_role_names_are_accepted_in_state_but_not_in_configuration Constant true
legacy_role_name_replacements The three mappings as data
nothing_verifies_that_the_principal_exists Constant true
principal_id_is_an_object_id_not_an_application_id Constant true
has_no_update_at_all Constant true
force_new_fields Every argument
create_checks_for_a_duplicate_by_listing_assignments Constant true matches role+principal+scope
the_import_guard_can_be_disabled_by_a_provider_feature Constant true the toggle is the caller's
an_absent_principal_type_reads_back_as_an_empty_string Constant true
fields_azure_returns_on_read Where drift is detectable role_name genuinely is
this_resource_supports_no_azure_resource_tags Constant true tag the workspace instead

No secret is emitted, because this resource holds none.


🧠 Architecture Notes

Two access-control systems, one workspace. Azure RBAC governs the workspace as an ARM resource β€” who can see it, redeploy it, delete it. Synapse RBAC governs what happens inside it β€” who can publish an artifact, run a notebook, read a linked service's credential. They share nothing: a grant here appears in no az role assignment list and confers no ARM permission, and an ARM Owner on the workspace has no Synapse role at all until someone grants one. The practical consequence is that an access review built on Azure RBAC alone reports zero Synapse grants no matter how many exist, which is why this module's outputs are shaped as review material rather than as plumbing.

An ID that is not an ID. The provider stores {scope}|{assignmentId} β€” the ARM scope and the data-plane assignment GUID, joined by a pipe. Every parsed output in this module therefore splits on the pipe first and only then on the slash; an expression that splits on / directly reads the GUID as part of the last path segment. The string is not usable as an RBAC scope, a management-lock target or a policy scope, and the_id_is_not_an_azure_resource_id is emitted so a composition does not have to discover that.

Two role sets, one of them invisible. role_name is validated against eleven names offline. The provider then resolves the name to a GUID by listing the role definitions available at the scope, and fails when the role is not among them β€” naming the ones that are. A Spark pool scope offers fewer roles than a workspace scope, and no schema, document or module states which. That is why the narrowing is emitted as a fact rather than enforced as a rule: enumerating it would mean guessing, and a wrong guess in a validation {} block would reject legal input and block terraform destroy besides.

Why neither scope variable has a default. The provider's ExactlyOneOf tests whether an argument is present in configuration, not what its value is. A default β€” even "" β€” would make both present on every call, and every call would fail. The same reasoning governs main.tf, which passes both variables straight through rather than wrapping either in a conditional: a null attribute is absent, an empty string is not.

Where each check actually fires. The scope patterns, the exactly-one-of rule, the UUID check, the principal-type set, the role-name list and the legacy-name rejections are module validation {} blocks: they refuse a bad configuration at terraform plan, offline. The provider's own validators fire there too. Whether the principal exists, whether the role is offered at the scope, and whether Terraform can reach the Synapse endpoint are all decided at apply.

for_each key stability. Every argument is force-new and there is no update function, so each change is a genuine revoke and re-grant with a window between them. Keying the map by who rather than by role means a change to one grant never re-creates the others β€” and, for a widening, adding the new assignment as a separate instance before removing the old one avoids the window entirely.

The features {} dependence. The provider will not initialize without a caller-side provider "azurerm" { features {} } block. That block also carries the toggle that can disable this resource's duplicate check.


🧱 Design Principles

Concern Default in this module Opt-out
Scope no default on either β€” exactly one must be stated none; a default breaks every call
Role name the provider's eleven names mirrored exactly none
Legacy role names rejected, each naming its replacement use the modern name
Scope-narrowed role sets reported, never enforced β€” the set is unpublished read the provider's apply-time error
principal_type optional, but supplying it is recommended omit it
Managed identities ServicePrincipal; a managed-identity value is rejected none
principal_id UUID, with a user principal name and the null GUID rejected none; an application ID cannot be detected
timeouts.update not offered, because the resource has no update β€”
Secrets none accepted, none emitted β€”
Tags not supported by the resource tag the workspace

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module with ?ref=v1.0.0 β€” never a branch. This module is authored and verified plan-only; a human applies from CI.


πŸ§ͺ Testing

What validate and fmt cover, offline and without credentials

  • All 15 validation {} blocks β€” three on the workspace scope, three on the pool scope including the exactly-one-of rule, three on principal_id, two on principal_type, three on role_name and one on timeouts β€” each proven to fire from a deliberately bad .tfvars file, and each proven not to fire from two fully-populated valid ones (a workspace-scoped grant and a pool-scoped one).
  • The exactly-one-of check is proven from both directions β€” both scopes set, and neither set β€” each with the other variable valid, so it is proven to fire on its own rather than being masked.
  • The provider's own UUID, enum and Resource ID validators.
  • Every derived output expression, lifted into a console harness and driven at both scope kinds, so the pipe-then-slash parsing and the effective_workspace_id slice are proven for a nine-element scope and an eleven-element one.

What only terraform plan or apply exercises

  • Whether the principal exists, and whether the UUID is an object ID rather than an application ID.
  • Whether the role is offered at the scope β€” the narrowing that only the provider's error message states.
  • Whether Terraform can reach the workspace's Synapse endpoint.
  • The list-based duplicate check, and whether the caller's features {} block has disabled it.

πŸ’¬ Example Output

id                                    = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-analytics-prod/providers/Microsoft.Synapse/workspaces/synw-analytics-prod|aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
scope                                 = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-analytics-prod/providers/Microsoft.Synapse/workspaces/synw-analytics-prod"
data_plane_assignment_id              = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
effective_workspace_id                = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-analytics-prod/providers/Microsoft.Synapse/workspaces/synw-analytics-prod"
synapse_workspace_name                = "synw-analytics-prod"
resource_group_name                   = "rg-analytics-prod"
synapse_spark_pool_name               = null
is_scoped_to_a_spark_pool             = false
principal_id                          = "11111111-2222-3333-4444-555555555555"
principal_type                        = "Group"
role_name                             = "Synapse Administrator"
is_an_administrator_role              = true
force_new_fields                      = ["synapse_workspace_id", "synapse_spark_pool_id", "principal_id", "principal_type", "role_name"]

πŸ” Troubleshooting

Symptom Cause Fix
The grant exists in Terraform but no Azure RBAC report shows it Synapse RBAC is a separate system from Azure RBAC Expected. Use this module's synapse_rbac_review output, or the Synapse REST API
role name %q invalid for scope %q at apply The role is not offered at that scope β€” usually a Spark pool Read the "Available role names are ..." list in the error; it is the only place the set is stated
Invalid value for variable ... Exactly one of Both scopes were set, or neither Set exactly one; neither variable has a default on purpose
Invalid value for variable ... LEGACY role names An old configuration still writes Workspace Admin and similar Use the modern name; legacy_role_name_replacements has all three
The assignment applied but the principal can do nothing The UUID is an application ID rather than an object ID az ad sp show --id <appId> --query id; the module cannot detect this
A newly-created service principal's grant failed Directory replication lag Supply principal_type = "ServicePrincipal" and retry
A management lock or role assignment on this module's id fails The ID is {scope}|{guid}, not a Resource ID Target the workspace instead
Changing a role produced a destroy and create Every argument is force-new; there is no update Expected; for a widening, add the new instance first and remove the old one later
An apply fails with a connectivity error before any Azure error Terraform cannot reach the workspace's Synapse endpoint Run the apply from a network that can reach it
A second module instance failed with an import error The duplicate check matches role, principal and scope together Two instances granting the same role to the same principal at the same scope collide by design

πŸ”— Related Docs


πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."