Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure Cosmos DB SQL Role Assignment Terraform Module

Grants a Microsoft Entra ID principal a Cosmos DB data-plane role over a chosen path in a NoSQL account. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • 🔐 Creates one azurerm_cosmosdb_sql_role_assignment — the record that pairs a role definition with a principal at a scope.
  • 🎯 Accepts the scope in either documented form, absolute or relative, and reports which was used.
  • 🧭 Rejects the two mistakes that are actually detectable: the control-plane scope spelling, and the definition's bare GUID where its Resource ID belongs.
  • 📊 Emits 26 outputs, including how far the grant reaches and the facts that produce no error at all.
  • 🔎 Reads and emits no keys and no secrets.

💡 Why it matters: a Cosmos DB role definition permits nothing on its own. This resource is the half that hands the role to an identity, so creating one is the moment data access begins — and Azure will not show it to you in the portal.


❤️ Support this project

If this module saves you time:


🗺️ Where this fits in the family

flowchart TB
  RG["terraform-azurerm-resource-group"]
  KV["terraform-azurerm-key-vault: the customer-managed key, if the account uses one"]
  VNET["terraform-azurerm-virtual-network: a delegated subnet, for the MANAGED CASSANDRA service only"]

  ACCT["terraform-azurerm-cosmosdb-account: the account, PLUS its sql_database and sql_container children"]
  KEYSPACE["terraform-azurerm-cosmosdb-cassandra-keyspace"]
  CASSTABLE["terraform-azurerm-cosmosdb-cassandra-table"]
  MONGODB["terraform-azurerm-cosmosdb-mongo-database"]
  MONGOCOLL["terraform-azurerm-cosmosdb-mongo-collection"]
  GREMDB["terraform-azurerm-cosmosdb-gremlin-database"]
  GREMGRAPH["terraform-azurerm-cosmosdb-gremlin-graph"]
  PGCLUSTER["terraform-azurerm-cosmosdb-postgresql-cluster"]
  MICLUSTER["terraform-azurerm-cosmosdb-cassandra-cluster: MANAGED INSTANCE for Apache Cassandra, a DIFFERENT service from the Cassandra API"]
  MICHILD["terraform-azurerm-cosmosdb-cassandra-datacenter: the NODES of a managed cluster, with its own region and its own delegated subnet"]
  SQLTRIGGER["terraform-azurerm-cosmosdb-sql-trigger: JavaScript that the client must ASK FOR on each request, so registering it does not put it into effect"]
  SQLFUNC["terraform-azurerm-cosmosdb-sql-function: a SIDE-EFFECT-FREE query extension, invoked only from query text as udf.name, with no context object at all"]
  SQLSPROC["terraform-azurerm-cosmosdb-sql-stored-procedure: TRANSACTIONAL JavaScript on the primary replica, scoped to ONE logical partition, and the only container child taking FOUR NAMES instead of a container id"]
  SQLROLEDEF["terraform-azurerm-cosmosdb-sql-role-definition: ENTRA ID data-plane RBAC. Defines what may be granted, and grants nothing by itself"]
  SQLROLEASSIGN["terraform-azurerm-cosmosdb-sql-role-assignment: the half that HANDS THE ROLE TO AN ENTRA PRINCIPAL. Creating one is the moment data access begins"]
  MONGOROLEDEF["terraform-azurerm-cosmosdb-mongo-role-definition: MONGO-NATIVE RBAC, a wholly different mechanism from the Entra ID RBAC above. Roles live INSIDE a database"]
  MONGOUSERDEF["terraform-azurerm-cosmosdb-mongo-user-definition: the MONGO USER that holds a role, authenticating with SCRAM rather than Entra ID. The ONLY module in this family carrying a PASSWORD, and Azure never returns it"]
  SQLGATEWAY["terraform-azurerm-cosmosdb-sql-dedicated-gateway: a SINGLETON SERVICE on the account, not a child record. Provisions the integrated cache, is BILLED HOURLY whether used, and does nothing until a client switches to gateway mode"]
  TABLE["terraform-azurerm-cosmosdb-table: the Table API, and the ONLY child with NO intermediate layer -- a table sits directly on the account, so two names are the whole path"]

  API["ONE ACCOUNT SERVES ONE API, chosen by capabilities: EnableCassandra, EnableMongo, EnableGremlin, EnableTable, or none for the SQL API. This is why each API's children are separate modules and not more children of the account composite"]

  PGKIDS["not yet authored, and DELIBERATELY SO: azurerm_cosmosdb_postgresql_role, azurerm_cosmosdb_postgresql_firewall_rule, azurerm_cosmosdb_postgresql_node_configuration and azurerm_cosmosdb_postgresql_coordinator_configuration. Microsoft documents Cosmos DB for PostgreSQL as on a retirement path, so these await a maintainer decision rather than effort"]

  RG -->|"resource_group_name and location"| ACCT
  RG -->|"resource_group_name"| MICLUSTER
  RG -->|"resource_group_name"| PGCLUSTER
  KV -->|"key id, for account encryption"| ACCT
  VNET -->|"a DELEGATED subnet id, required by the managed service and by nothing else here"| MICLUSTER
  VNET -->|"a SECOND delegated subnet, which must sit in the datacenter's own region and must be able to ROUTE to the cluster's management subnet"| MICHILD
  KV -->|"two VERSIONED key uris, for backup storage and for the nodes' managed disks"| MICHILD

  ACCT -->|"capabilities decide which of the children below are even legal"| API

  ACCT -->|"NAME plus resource group, so the subscription comes from the provider and Terraform builds no edge from a literal"| KEYSPACE
  ACCT -->|"name plus resource group, the same convention"| MONGODB
  ACCT -->|"name plus resource group, the same convention again"| GREMDB
  ACCT -->|"THREE names and NO intermediate layer: the Table API has no database, so account plus table name is the entire hierarchy. Every other API here interposes a database, keyspace or graph"| TABLE

  KEYSPACE -->|"id: the child takes an ID where the parent takes names, and the provider rebuilds that ID using the PROVIDER's subscription, so a cross-subscription id is silently relocated"| CASSTABLE
  MONGODB -->|"THREE names at once: this parent emits name, account_name AND resource_group_name, so the four-name child wires entirely from one module"| MONGOCOLL
  GREMDB -->|"three names, and this parent emits all three for exactly that reason. The child cannot express a HIERARCHICAL partition key at all"| GREMGRAPH
  MICLUSTER -->|"cluster id: the provider takes the subscription, resource group AND cluster name from this id and nothing from its own configuration, so a cross-subscription id is honored rather than relocated. The exact opposite of the keyspace edge above"| MICHILD
  MONGODB -->|"the DATABASE id: the provider reads the subscription, resource group, account AND database name out of it and takes nothing from its own configuration, so a cross-subscription id is HONORED rather than relocated. The same convention as the cluster to datacenter edge, and the opposite of the keyspace to table edge"| MONGOROLEDEF
  MONGODB -->|"the same DATABASE id, on the same convention. This database is also the authSource a client must authenticate against"| MONGOUSERDEF
  MONGOROLEDEF -->|"the ROLE NAME, from its role_name output. A role confers nothing until a user holds it, so this edge is where Mongo-native access actually begins"| MONGOUSERDEF
  ACCT -->|"container id: the provider parses the resource group, account, database and container out of it and then takes the SUBSCRIPTION FROM ITS OWN CONFIGURATION, so a cross-subscription container id is silently relocated. The same trap as the keyspace-to-table edge, and the opposite of the cluster-to-datacenter edge"| SQLTRIGGER
  ACCT -->|"container id: the SAME subscription substitution as the trigger edge. Its child is a QUERY EXTENSION rather than an operation hook, so it shares the id trap and none of the invocation story"| SQLFUNC
  ACCT -->|"FOUR NAMES: resource group, account, database and container. So the caller never supplies a subscription and the provider has nothing to override -- the relocation hazard its two siblings carry is IMPOSSIBLE here rather than silent"| SQLSPROC
  ACCT -->|"THREE names, and an allow-list of data actions. Nothing here is a container child: a role definition belongs to the ACCOUNT and names the databases and containers it may be assigned over, using the DATA-PLANE scope grammar with dbs and colls rather than sqlDatabases and containers"| SQLROLEDEF
  ACCT -->|"TWO names only, resource group and account, and BOTH are force-new. The assignment's own name is an optional GUID rather than a label, so it is not a third name from the parent"| SQLROLEASSIGN
  SQLROLEDEF -->|"the definition's RESOURCE ID, not its bare GUID: Terraform validates this argument as a role-definition id, so the id output is the one to wire and the role_definition_id output is for things outside Terraform"| SQLROLEASSIGN
  ACCT -->|"the ACCOUNT id, and the only child here that takes one. The gateway appends services/SqlDedicatedGateway to it, so there is no name to supply and exactly ONE gateway per account"| SQLGATEWAY
  SQLGATEWAY -.->|"routes through, and caches for, whichever containers a client reads via the dedicated endpoint"| ACCT
  PGCLUSTER --> PGKIDS

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff
  classDef keystone fill:#004578,stroke:#002438,color:#ffffff
  classDef sibling fill:#F3F6F9,stroke:#8A9BA8,color:#1B1F23
  class KEYSPACE,CASSTABLE,MONGOCOLL,GREMDB,GREMGRAPH,ACCT,MONGODB,PGCLUSTER,MICLUSTER,MICHILD,SQLTRIGGER,SQLFUNC,SQLSPROC,SQLROLEDEF,SQLROLEASSIGN,MONGOROLEDEF,MONGOUSERDEF,SQLGATEWAY,TABLE me
  class API keystone
  class RG,KV,VNET,PGKIDS sibling
Loading

🧬 What this module builds

flowchart TB
  IN_NAMES["resource_group_name and account_name: FORCE-NEW. The account must serve the NoSQL API, the only API supporting data-plane RBAC"]
  IN_NAME["name: the assignment's own GUID and its identity, FORCE-NEW and OPTIONAL. Omit it and the provider generates one, so the Resource ID is unknown until apply. The REVERSE of the role definition, whose name is an editable label"]
  IN_PRINCIPAL["principal_id: the OBJECT id of an Entra ID principal, FORCE-NEW. An application id is also a GUID and nothing can tell them apart, so the wrong one grants access to nobody without error"]
  IN_RDID["role_definition_id: the definition's RESOURCE ID, not its bare GUID. THE ONLY ARGUMENT ON THIS RESOURCE THAT IS EDITABLE IN PLACE"]
  IN_SCOPE["scope: the data-plane path, with dbs and colls rather than sqlDatabases and containers. ABSOLUTE and RELATIVE forms are both documented by Microsoft and both accepted here. FORCE-NEW"]

  LOCK["every create, update and delete takes an ACCOUNT-WIDE lock, so a for_each over assignments on one account serializes"]
  FEATURE["the caller's provider features block can switch OFF the pre-create existence check, turning a refusal into a silent overwrite of an existing grant"]

  THIS["azurerm_cosmosdb_sql_role_assignment.this"]

  DEF["the ROLE DEFINITION decides WHAT is permitted. This resource decides WHO holds it and WHERE"]
  KEYS["the account KEYS still reach the data until local authentication is disabled on the account, so this role model is additive to key access rather than a replacement for it"]

  OUT_ID["id, name, and the five arguments as applied"]
  OUT_PATHS["account_path_within_the_subscription and role_definition_account_path_within_the_subscription: the family's shared comparable form, on both sides of the pairing"]
  OUT_SCOPE["scope_level, scope_is_relative, scope_covers_the_whole_account, scope_database_name, scope_container_name"]
  OUT_DISAGREE["scope_disagrees_with_the_named_account: detectable in the absolute form ONLY, so it is reported rather than rejected"]
  OUT_EDIT["editable_without_replacement: role_definition_id, and nothing else"]
  OUT_CONST["seven constants, including that the portal cannot manage these at all and that nothing here verifies the principal exists"]

  IN_NAMES --> THIS
  IN_NAME --> THIS
  IN_PRINCIPAL --> THIS
  IN_RDID --> THIS
  IN_SCOPE --> THIS
  LOCK -.->|"serializes"| THIS
  FEATURE -.->|"can defeat the guard"| THIS

  DEF -->|"referenced by Resource ID, and its permissions are NOT readable from here"| THIS
  THIS --> KEYS
  THIS --> OUT_ID
  THIS --> OUT_PATHS
  THIS --> OUT_SCOPE
  THIS --> OUT_DISAGREE
  THIS --> OUT_EDIT
  THIS --> OUT_CONST

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff
  classDef keystone fill:#004578,stroke:#002438,color:#ffffff
  classDef sibling fill:#F3F6F9,stroke:#8A9BA8,color:#1B1F23
  class THIS keystone
  class OUT_ID,OUT_PATHS,OUT_SCOPE,OUT_DISAGREE,OUT_EDIT,OUT_CONST me
  class IN_NAMES,IN_NAME,IN_PRINCIPAL,IN_RDID,IN_SCOPE,LOCK,FEATURE,DEF,KEYS sibling
Loading

Resource inventory

Resource Count Notes
azurerm_cosmosdb_sql_role_assignment 1 (this) Five arguments, of which exactly one is editable in place

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None here. The caller configures the provider, its authentication, and its mandatory features {} block
ARM resource type Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments

Schema notes that bite

  • 🔴 Five of the six arguments are force-new: name, resource_group_name, account_name, principal_id and scope. Only role_definition_id is editable.
  • 🔴 The create and update payloads carry an identical field list. Both send the principal, the definition and the scope — but since two of those three are force-new, the update path is reachable for exactly one change: swapping the role.
  • 🔴 role_definition_id is the definition's Resource ID, not its GUID. The provider validates it with a role-definition ID validator.
  • 🔴 scope is a data-plane path — /dbs/ and /colls/, never /sqlDatabases/ and /containers/.
  • ⚠️ name is Optional, Computed and force-new. Omitted, the provider generates a GUID, so the Resource ID is unknown until apply.
  • ⚠️ Every create, update and delete takes an account-wide lock. Reads do not.
  • ⚠️ The pre-create existence check can be switched off by the caller's provider features block, turning a refusal to overwrite into a silent overwrite.
  • ⚠️ All four timeouts keys are honored — each CRUD path uses its matching helper. Not true of every sibling in this family.

🔑 Required Azure RBAC Roles / Permissions

Permission Scope Why
Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments/write the Cosmos DB account Create and update
Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments/read the Cosmos DB account Refresh, and the pre-create existence check
Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments/delete the Cosmos DB account Destroy
Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions/read the Cosmos DB account Not needed by Terraform, but needed by any human confirming what the assigned role permits

🔒 This is a control-plane permission that produces a data-plane grant. Whoever can run this module can give any principal — including themselves — whatever the referenced definition permits over the data. Scope and review sqlRoleAssignments/write on that basis.

ℹ️ Plan access is not credential access here. This module reads no keys and emits no secrets, so a refresh needs only the read permission above.


Azure Prerequisites

  • The Microsoft.DocumentDB resource provider registered in the subscription.
  • An existing Cosmos DB account serving the NoSQL (SQL) API. Data-plane RBAC is unavailable on the other APIs, and nothing here checks which API an account serves.
  • An existing role definition on that same account, and its Resource ID.
  • An existing Entra ID principal, and its object ID resolved out of band.
  • The database and container named in scope, if the scope reaches that far.

📁 Module Structure

terraform-azurerm-cosmosdb-sql-role-assignment/
├── providers.tf     # required_version + the pinned azurerm. No provider block.
├── variables.tf     # 7 variables, 14 validations
├── main.tf          # 20 locals + the single keystone resource
├── outputs.tf       # 26 outputs
├── README.md        # this file
├── SCOPE.md         # the cross-module contract
├── LICENSE          # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

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

  resource_group_name = "rg-data-eastus"
  account_name        = "cosmos-orders-eastus"
  principal_id        = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
  role_definition_id  = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data-eastus/providers/Microsoft.DocumentDB/databaseAccounts/cosmos-orders-eastus/sqlRoleDefinitions/11111111-2222-3333-4444-555555555555"
  scope               = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data-eastus/providers/Microsoft.DocumentDB/databaseAccounts/cosmos-orders-eastus/dbs/orders"
}

ℹ️ 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
resource_group_name string terraform-azurerm-resource-group → name
account_name string terraform-azurerm-cosmosdb-account → name
role_definition_id string terraform-azurerm-cosmosdb-sql-role-definition → id
principal_id string terraform-azurerm-user-assigned-identity → principal_id
scope string composed from database and container names
name string optional; generated when omitted

Emits

Output Description
id The assignment's Resource ID (first)
name Its GUID; generated when the argument was omitted
role_definition_guid The definition's bare GUID, for anything outside Terraform
scope_level, scope_covers_the_whole_account How far the grant reaches
scope_is_relative Which of the two accepted forms was used
scope_disagrees_with_the_named_account Detectable in the absolute form only
editable_without_replacement role_definition_id, and nothing else

📚 Example Library

1 · Minimal — an account-wide grant
module "reader_everywhere" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-assignment.git?ref=v1.0.0"

  resource_group_name = "rg-data-eastus"
  account_name        = "cosmos-orders-eastus"
  principal_id        = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
  role_definition_id  = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data-eastus/providers/Microsoft.DocumentDB/databaseAccounts/cosmos-orders-eastus/sqlRoleDefinitions/11111111-2222-3333-4444-555555555555"
  scope               = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data-eastus/providers/Microsoft.DocumentDB/databaseAccounts/cosmos-orders-eastus"
}

⚠️ There is no safe empty call here. Every argument that decides how much access is granted is required by the provider, so the module cannot default to a narrow grant — it validates the values and reports the reach instead. This scope names no database, so scope_covers_the_whole_account is true.

2 · Pinning the name for a predictable Resource ID
module "pinned" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-assignment.git?ref=v1.0.0"

  name                = "cccccccc-dddd-eeee-ffff-000000000001"
  resource_group_name = "rg-data-eastus"
  account_name        = "cosmos-orders-eastus"
  principal_id        = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
  role_definition_id  = local.reader_definition_id
  scope               = local.orders_database_scope
}

output "id_known_at_plan_time" {
  value = module.pinned.name_was_supplied # true
}

ℹ️ name must be a GUID — it is an identifier, not a label. Omit it and the provider generates one, which makes the Resource ID unknowable until apply.

3 · A database-scoped grant
module "orders_only" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-assignment.git?ref=v1.0.0"

  resource_group_name = "rg-data-eastus"
  account_name        = "cosmos-orders-eastus"
  principal_id        = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
  role_definition_id  = local.reader_definition_id
  scope               = "${local.account_scope}/dbs/orders"
}

output "reach" {
  value = {
    level    = module.orders_only.scope_level          # "database"
    database = module.orders_only.scope_database_name  # "orders"
    account  = module.orders_only.scope_covers_the_whole_account
  }
}
4 · The narrowest grant — one container
module "items_only" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-assignment.git?ref=v1.0.0"

  resource_group_name = "rg-data-eastus"
  account_name        = "cosmos-orders-eastus"
  principal_id        = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
  role_definition_id  = local.reader_definition_id
  scope               = "${local.account_scope}/dbs/orders/colls/lineitems"
}

output "narrow" {
  value = {
    level     = module.items_only.scope_level           # "container"
    container = module.items_only.scope_container_name  # "lineitems"
  }
}

🔒 Prefer the narrowest scope that works. A container-scoped assignment is the least-privilege shape, and the derived flags make that visible in review without anyone parsing the path.

5 · The relative scope form
module "relative" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-assignment.git?ref=v1.0.0"

  resource_group_name = "rg-data-eastus"
  account_name        = "cosmos-orders-eastus"
  principal_id        = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
  role_definition_id  = local.reader_definition_id
  scope               = "/dbs/orders/colls/lineitems"
}

output "form" {
  value = {
    relative     = module.relative.scope_is_relative                             # true
    account_path = module.relative.scope_account_path_within_the_subscription    # null
  }
}

ℹ️ Both forms are accepted. Microsoft documents the absolute form in its PowerShell and ARM guidance and the relative form in its CLI guidance, and this suite has not established that the ARM contract refuses either — so neither is refused here. A relative scope names no account, which is why scope_account_path_within_the_subscription is null.

6 · The bare slash — the whole account, relatively
module "whole_account_relative" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-assignment.git?ref=v1.0.0"

  resource_group_name = "rg-data-eastus"
  account_name        = "cosmos-orders-eastus"
  principal_id        = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
  role_definition_id  = local.reader_definition_id
  scope               = "/"
}

⚠️ A single / is the widest grant this resource can express. It grants whatever the definition permits, everywhere in the account — which is why scope_covers_the_whole_account is worth reading in a plan review alongside the definition's own is_read_only_in_effect.

7 · Wiring the definition module — the ID, not the GUID
module "orders_reader" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"

  name                = "Orders Reader"
  resource_group_name = "rg-data-eastus"
  account_name        = "cosmos-orders-eastus"
  assignable_scopes   = ["${local.account_scope}/dbs/orders"]
  data_actions = [
    "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
    "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/executeQuery",
    "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/readChangeFeed",
  ]
}

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

  resource_group_name = "rg-data-eastus"
  account_name        = "cosmos-orders-eastus"
  principal_id        = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"

  role_definition_id = module.orders_reader.id # <-- the RESOURCE ID
  scope              = "${local.account_scope}/dbs/orders"
}

🔴 Use module.orders_reader.id, not module.orders_reader.role_definition_id. The definition module emits both: id is the Resource ID this argument takes, and role_definition_id is the bare GUID for anything outside Terraform. Passing the GUID is rejected here with a message naming the right output.

ℹ️ Note the data_actions above use the control-plane spelling sqlDatabases/containers — that is correct for a definition's action strings. The scope grammar is the one that uses dbs/colls. The two are genuinely different, in the same pair of modules.

8 · Wiring a managed identity — the object ID, not the client ID
module "app_identity" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"

  name                = "id-orders-api"
  resource_group_name = "rg-data-eastus"
  location            = "eastus"
}

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

  resource_group_name = "rg-data-eastus"
  account_name        = "cosmos-orders-eastus"

  principal_id = module.app_identity.principal_id # <-- the OBJECT id
  # principal_id = module.app_identity.client_id  # <-- WRONG, and undetectable

  role_definition_id = module.orders_reader.id
  scope              = "${local.account_scope}/dbs/orders"
}

🔴 Both are GUIDs, and no check can tell them apart. client_id is well-formed, passes every validation here, applies cleanly — and produces an assignment that grants access to nobody. That is what this_module_verifies_neither_the_principal_nor_the_scope is reporting.

9 · `for_each` over several principals
locals {
  readers = {
    orders_api = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
    reporting  = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"
    analytics  = "cccccccc-dddd-eeee-ffff-000000000000"
  }
}

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

  resource_group_name = "rg-data-eastus"
  account_name        = "cosmos-orders-eastus"
  principal_id        = each.value
  role_definition_id  = module.orders_reader.id
  scope               = "${local.account_scope}/dbs/orders"
}

output "assignment_ids" {
  value = { for k, m in module.readers : k => m.id }
}

⚠️ These do not apply in parallel. Every create, update and delete takes an account-wide lock, so three assignments on one account run one after another. That is invisible in the plan and shows up only as elapsed time — see the_account_is_locked_for_the_duration_of_every_write.

10 · One principal, several containers
locals {
  containers = ["lineitems", "shipments", "invoices"]
}

module "per_container" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-assignment.git?ref=v1.0.0"
  for_each = toset(local.containers)

  resource_group_name = "rg-data-eastus"
  account_name        = "cosmos-orders-eastus"
  principal_id        = module.app_identity.principal_id
  role_definition_id  = module.orders_reader.id
  scope               = "${local.account_scope}/dbs/orders/colls/${each.value}"
}

output "reach_per_container" {
  value = { for k, m in module.per_container : k => m.scope_container_name }
}

💡 Three container-scoped assignments are more auditable than one database-scoped assignment when the container list is meant to be exhaustive — the plan shows exactly which containers are covered, and adding a fourth container is a visible change rather than a silent widening.

11 · Swapping the role in place
module "orders_writer" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"

  name                = "Orders Writer"
  resource_group_name = "rg-data-eastus"
  account_name        = "cosmos-orders-eastus"
  assignable_scopes   = ["${local.account_scope}/dbs/orders"]
  data_actions = [
    "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
    "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/create",
    "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/replace",
  ]
}

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

  name                = "cccccccc-dddd-eeee-ffff-000000000002"
  resource_group_name = "rg-data-eastus"
  account_name        = "cosmos-orders-eastus"
  principal_id        = module.app_identity.principal_id
  scope               = "${local.account_scope}/dbs/orders"

  # Change this from the reader to the writer definition: an in-place update.
  role_definition_id = module.orders_writer.id
}

output "what_can_change" {
  value = module.swappable.editable_without_replacement # ["role_definition_id"]
}

ℹ️ role_definition_id is the only argument that can change without replacement. Changing the principal or the scope destroys and recreates the assignment. If a gap in access is unacceptable, create the replacement before removing the original rather than editing in place.

12 · Reviewing the reach of every grant
output "grant_review" {
  value = {
    for k, m in module.readers : k => {
      account_wide  = m.scope_covers_the_whole_account
      level         = m.scope_level
      definition    = m.role_definition_guid
      wrong_account = m.scope_disagrees_with_the_named_account
    }
  }
}

check "no_account_wide_grants" {
  assert {
    condition     = alltrue([for m in values(module.readers) : !m.scope_covers_the_whole_account])
    error_message = "An account-wide data-plane grant was requested. Narrow the scope to a database or container."
  }
}

💡 The derived flags exist so a policy can be written against the reach of a grant without parsing paths. A check block is the right place for a house rule the provider has no opinion about.

13 · An absolute scope naming a different account
module "mismatched" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-assignment.git?ref=v1.0.0"

  resource_group_name = "rg-data-eastus"
  account_name        = "cosmos-orders-eastus"
  principal_id        = module.app_identity.principal_id
  role_definition_id  = module.orders_reader.id
  scope               = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-other/providers/Microsoft.DocumentDB/databaseAccounts/cosmos-elsewhere/dbs/orders"
}

output "flagged" {
  value = module.mismatched.scope_disagrees_with_the_named_account # true
}

⚠️ Reported, not rejected. The relative form names no account at all, so a mismatch is only detectable in one of the two accepted forms — refusing it would mean refusing a form Microsoft documents. The flag is false for every relative scope, which is a limit of the check rather than a clean bill of health.

🔒 Contrast this with role_definition_id, where the account is rejected on mismatch: a definition Resource ID always carries its account, so that check has no blind spot.

14 · Timeouts
module "with_timeouts" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-assignment.git?ref=v1.0.0"

  resource_group_name = "rg-data-eastus"
  account_name        = "cosmos-orders-eastus"
  principal_id        = module.app_identity.principal_id
  role_definition_id  = module.orders_reader.id
  scope               = "${local.account_scope}/dbs/orders"

  timeouts = {
    create = "45m"
    update = "20m"
    delete = "45m"
  }
}

ℹ️ All four keys are honored — each CRUD path uses its matching timeout helper. Raising create is worth doing when several assignments queue behind the account-wide lock. An undeclared fifth key would be silently discarded by Terraform's object-type conversion, with no error.

15 · 🏗️ End-to-end composition
provider "azurerm" {
  features {}
}

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

  name     = "rg-data-eastus"
  location = "eastus"
}

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

  name                = "cosmos-orders-eastus"
  resource_group_name = module.rg.name
  location            = module.rg.location

  # The step that makes the role model authoritative rather than additive.
  local_authentication_enabled = false
}

module "app_identity" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"

  name                = "id-orders-api"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

module "orders_reader" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"

  name                = "Orders Reader"
  resource_group_name = module.rg.name
  account_name        = module.cosmos.name
  assignable_scopes   = ["${module.cosmos.id}/dbs/orders"]
  data_actions = [
    "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
    "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/executeQuery",
    "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/readChangeFeed",
  ]
}

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

  resource_group_name = module.rg.name
  account_name        = module.cosmos.name

  principal_id       = module.app_identity.principal_id
  role_definition_id = module.orders_reader.id
  scope              = "${module.cosmos.id}/dbs/orders"
}

output "grant_summary" {
  value = {
    assignment      = module.grant.id
    reach           = module.grant.scope_level
    account_wide    = module.grant.scope_covers_the_whole_account
    role_is_read_only = module.orders_reader.is_read_only_in_effect
    keys_still_work = module.grant.key_based_access_remains_available_until_disabled_on_the_account
    accounts_agree  = module.grant.account_path_within_the_subscription == module.orders_reader.account_path_within_the_subscription
  }
}

🔒 The two halves plus the third step. The definition says what is permitted, the assignment says who holds it, and local_authentication_enabled = false on the account is what stops an account key from bypassing both. Omit that third step and the grant is an addition to key access rather than a replacement for it.

ℹ️ module.cosmos.id is the account's Resource ID, which is also the absolute scope prefix — so the scope and the definition's assignable_scopes are built from the same value here, which is the simplest way to keep them consistent.


📥 Inputs

Group Variables
Identity name (the assignment's GUID, optional and generated when omitted)
Placement resource_group_name, account_name
The grant principal_id, role_definition_id, scope
Tail timeouts

ℹ️ No tags variable. azurerm_cosmosdb_sql_role_assignment exposes no tags attribute — confirmed against the provider schema. Tag the account instead.

Full schemas
Variable Type Default Notes
name string null Force-new. Must be a GUID. Generated by the provider when omitted
resource_group_name string — Force-new
account_name string — Force-new. ^[-a-z0-9]{3,50}$ — lowercase only
principal_id string — Force-new. The object ID of an Entra ID principal
role_definition_id string — The definition's Resource ID. The only editable argument
scope string — Force-new. Data-plane path, absolute or relative
timeouts object({create, read, update, delete}) {} All four honored

14 validations, listed rather than totaled. One on name. Two on resource_group_name. Two on account_name. One on principal_id. Four on role_definition_id. Three on scope. One on timeouts.

The four on role_definition_id are worth naming, because one of them is a cross-field rule: non-blank; not a bare GUID; the anchored role-definition ID shape; and the definition's own resource group and account matching this assignment's. That last one is carried by role_definition_id because a validation must reference its own variable — which is why its message names two arguments the caller may not have been editing.


🧾 Outputs

26 outputs: 7 passthrough, 12 derived, 7 constant.

Output Description
id The assignment's Resource ID
name Its GUID. Known only after apply when generated
resource_group_name, account_name, principal_id, role_definition_id, scope The arguments as applied
account_path_within_the_subscription The account, in this family's shared comparable form
role_definition_account_path_within_the_subscription The same form for the definition's account
role_definition_guid The definition's bare GUID, parsed from the Resource ID
name_was_supplied Pinned versus generated
scope_level "container", "database" or "account"
scope_is_relative Which documented form was used
scope_covers_the_whole_account The scope names neither a database nor a container
scope_database_name, scope_container_name Parsed from the path; null when absent
scope_account_path_within_the_subscription null for a relative scope
scope_disagrees_with_the_named_account Absolute form only; always false for a relative scope
editable_without_replacement ["role_definition_id"]
this_assignment_is_what_grants_the_principal_data_access Constant true
role_assignments_cannot_be_managed_in_the_azure_portal Constant true
the_account_is_locked_for_the_duration_of_every_write Constant true
there_are_no_deny_assignments Constant true
this_module_verifies_neither_the_principal_nor_the_scope Constant true
the_existence_check_can_be_disabled_by_a_provider_feature Constant true
key_based_access_remains_available_until_disabled_on_the_account Constant true

ℹ️ Nothing here is sensitive. A principal's object ID is an identifier, not a credential, and redacting it would break plan review while protecting nothing.


🧠 Architecture Notes

The pair, and which half does what. A role definition declares an allow-list of data actions and the scopes it may be assigned over; it grants nothing to anybody. This resource is the record that pairs that definition with a principal at a path — so creating one is the moment access begins, and deleting it is how access ends. There are no deny assignments: access is withdrawn by removing the record or narrowing the definition, never by adding a second record to override the first.

Identity is the reverse of the definition's. On the definition, name is an editable display label and the GUID is the force-new identity. Here, name is the GUID and the identity, and there is no display label at all. Two resources in the same pair, using the same argument name for opposite things — which is why this module validates name as a GUID and says so in the error message.

One editable argument, and the payload proves it. The provider's create and update paths build an identical payload of principal, definition and scope. Since the principal and the scope are force-new, the update path is reachable only for role_definition_id. That makes swapping a role cheap and re-pointing a grant at another identity a destroy-and-create — worth planning around when access must not lapse.

Two grammars in one feature. A definition's data_actions use the control-plane spelling (sqlDatabases, containers). An assignment's scope uses the data-plane spelling (dbs, colls). Both appear in the same composition, they are not interchangeable, and the control-plane spelling in a scope is rejected here because it is the mistake a caller reaching for a container module's id would actually make.

Where the module stops. principal_id is checked for shape only — no directory lookup happens here, and an application ID is a GUID indistinguishable from an object ID, so the wrong one applies cleanly and grants nothing. Whether the scope falls inside the definition's assignable_scopes is invisible from here, because the definition arrives as an ID. Both limits are emitted as facts rather than left implicit.

Two things the plan will not show you. Every write takes an account-wide lock, so a for_each serializes — visible only as elapsed time. And the pre-create check that refuses to overwrite an existing assignment lives behind a provider features switch in the caller's root module; turning it on converts that refusal into a silent overwrite of a live grant. Neither is expressible in this module's configuration, which is why both ship as constant outputs.

sensitive = true is absent on purpose, and that is not a gap. This module handles no credential. It is worth stating plainly that marking a value sensitive would redact plan output without encrypting state anyway — the control that matters for Terraform state is an encrypted, access-controlled backend, never a local file in a repository.


🧱 Design Principles

Concern This module's default The caller's opt-out
Scope breadth No default — required by the provider. The reach is reported via scope_level and scope_covers_the_whole_account —
The control-plane scope spelling Rejected — a real, detectable mistake none; fix the path
The definition's bare GUID Rejected, with a message naming the correct sibling output none; wire id
Definition on another account Rejected — a definition ID always carries its account, so the check has no blind spot none
Absolute versus relative scope Both accepted, because Microsoft documents both. Reported via scope_is_relative —
An absolute scope naming another account Reported, not rejected — undetectable in the relative form —
Account keys Untouched. Disabling them is the account module's local_authentication_enabled = false leave keys enabled
Secrets None accepted, none emitted —

🔒 Secure-by-default has no empty call to protect here. Every argument deciding how much access is granted is required by the provider, so there is no safe default to supply. Per this suite's convention the module compensates three ways: it validates each value set, it names the restrictive choice in the error messages, and it emits the reach of the grant as derived flags so a review can see it without reading a path.


🚀 Runbook

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

Pin the module with ?ref=v1.0.0 — never a branch. This module is plan-only in this library; a human applies from CI.


🧪 Testing

Covered by What it proves
terraform validate The resource, its arguments and every expression parse and type-check against the pinned provider schema
terraform fmt -check Canonical formatting
terraform console with a root-module fixture Variable validations actually fire. All 14 rules were driven by deliberately bad inputs, and every local was driven to more than one value by good ones
terraform plan (needs credentials) Whether the account exists, serves the NoSQL API, and whether the principal and scope resolve
Nothing offline Whether the principal object ID belongs to anything, and whether the scope sits inside the definition's assignable_scopes

⚠️ Terraform skips a validation whose referenced variable has already failed, so a short error list is not proof a rule is missing. Fix the first failure and re-run.


💬 Example Output

id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data-eastus/providers/Microsoft.DocumentDB/databaseAccounts/cosmos-orders-eastus/sqlRoleAssignments/cccccccc-dddd-eeee-ffff-000000000001"
name = "cccccccc-dddd-eeee-ffff-000000000001"
principal_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
role_definition_guid = "11111111-2222-3333-4444-555555555555"
account_path_within_the_subscription = "/resourceGroups/rg-data-eastus/providers/Microsoft.DocumentDB/databaseAccounts/cosmos-orders-eastus"
role_definition_account_path_within_the_subscription = "/resourceGroups/rg-data-eastus/providers/Microsoft.DocumentDB/databaseAccounts/cosmos-orders-eastus"
scope_level = "database"
scope_database_name = "orders"
scope_container_name = null
scope_is_relative = false
scope_covers_the_whole_account = false
scope_disagrees_with_the_named_account = false
name_was_supplied = true
editable_without_replacement = [
  "role_definition_id",
]
key_based_access_remains_available_until_disabled_on_the_account = true
role_assignments_cannot_be_managed_in_the_azure_portal = true

🔍 Troubleshooting

Symptom Cause Fix
role_definition_id is a bare GUID... Wired the definition module's role_definition_id output Use its id output
role_definition_id must be a full role DEFINITION Resource ID... Passed an assignment ID, or a truncated path The ID must end at /sqlRoleDefinitions/<guid>
role_definition_id names a resource group or account different from... The definition belongs to another account A definition can only be assigned on its own account
scope uses the CONTROL-PLANE spelling... Reached for a container module's id Rewrite with /dbs/ and /colls/
principal_id must be a GUID... Passed a name, UPN or Resource ID Resolve the object ID out of band
name must be a GUID... Treated name as a label It is the identity here. Omit it to have one generated
Applied cleanly, but the app still gets 403 The client_id was passed instead of the object ID — both are GUIDs Use the identity module's principal_id
Applied cleanly, but the app still gets 403 The scope is outside the definition's assignable_scopes Compare the definition's assignable_scopes against this scope; neither module can see the other's
The assignment is nowhere in the portal Data-plane RBAC is not manageable in the portal Expected. Use Terraform, the CLI, PowerShell or ARM
A key-based connection string still works Assigning roles does not disable keys Set local_authentication_enabled = false on the account module
Several assignments applied slowly, one at a time The account-wide lock Expected. Raise the create timeout if needed
An existing assignment was silently overwritten The skip-import-check provider feature is enabled Turn it off in the root module's features block
The Resource ID is unknown until apply name was omitted, so the provider generates a GUID Pin name

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."