One Synapse RBAC role assignment, at a workspace scope or a Spark pool scope β targeting
hashicorp/azurerm ~> 4.0.
- π 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
tagsand nolocation; the universal tail istimeoutsonly, with noupdatemember.
π‘ Why it matters: an estate audit built on Azure RBAC reports zero Synapse role assignments no matter how many exist.
Synapse Administratoris full control of a workspace's data plane and is invisible to every tool that looks atMicrosoft.Authorization/roleAssignments. This module's outputs are written to be the input to the review that Azure RBAC will not produce.
If this module saved you time:
- β Star the repository β it helps other people find it.
- πΌ Connect on LinkedIn β linkedin.com/in/microsoftexpert
- β Buy me a coffee β buymeacoffee.com/microsoftexpert
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
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.
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
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.
| 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_nameagainst 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 AdminandSql Adminare 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. - π΄
ExactlyOneOftests 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_idis 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 absentprincipal_typereads 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
updatetimeout.
Least-privilege, at the smallest scope that works:
Microsoft.Synapse/workspaces/readon 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.
- 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.
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
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.
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 |
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_idis 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_typeis 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_scopeistrue. 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
bigDataPoolseven 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.
ExactlyOneOftests 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'smain.tfpasses 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_idistrue, 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 idreturns 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_replacementsfor 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_rbacistrue. Nothing inaz role assignment listwill ever show these grants, so this output is the review.data_plane_assignment_idis 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_idis 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.
Readeron the workspace grants nothing on the data plane;Synapse Usergrants 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
}
}βΉοΈ
createcovers 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.readcovers two β the assignment, then a second lookup of the role definition so thatrole_namecan 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_reviewsomewhere an access review can find it. Not one of these grants appears inaz 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.
| 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.
}| 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.
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.
| 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 |
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module with ?ref=v1.0.0 β never a branch. This module is authored and verified plan-only; a
human applies from CI.
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 onprincipal_id, two onprincipal_type, three onrole_nameand one ontimeoutsβ each proven to fire from a deliberately bad.tfvarsfile, 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_idslice 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.
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"]
| 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 |
azurerm_synapse_role_assignmentazurerm_synapse_workspaceazurerm_synapse_spark_pool- Sibling modules:
terraform-azurerm-synapse-workspace,terraform-azurerm-synapse-spark-pool,terraform-azurerm-role-assignments,terraform-azurerm-user-assigned-identity,terraform-azurerm-synapse-workspace-aad-admin - This module's
SCOPE.md
π "Infrastructure as Code should be standardized, consistent, and secure."