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.
- 🔐 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.
If this module saves you time:
- ⭐ Star the repository — it helps others find it.
- 💼 Connect on LinkedIn — linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee — buymeacoffee.com/microsoftexpert
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
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
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_cosmosdb_sql_role_assignment |
1 (this) |
Five arguments, of which exactly one is editable in place |
| 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_idandscope. Onlyrole_definition_idis 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_idis the definition's Resource ID, not its GUID. The provider validates it with a role-definition ID validator. - 🔴
scopeis a data-plane path —/dbs/and/colls/, never/sqlDatabases/and/containers/. ⚠️ nameis 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 providerfeaturesblock, turning a refusal to overwrite into a silent overwrite.⚠️ All fourtimeoutskeys are honored — each CRUD path uses its matching helper. Not true of every sibling in this family.
| 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/writeon 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.
- The
Microsoft.DocumentDBresource 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.
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
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.
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 |
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, soscope_covers_the_whole_accountistrue.
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
}ℹ️
namemust 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_subscriptionisnull.
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 whyscope_covers_the_whole_accountis worth reading in a plan review alongside the definition's ownis_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, notmodule.orders_reader.role_definition_id. The definition module emits both:idis the Resource ID this argument takes, androle_definition_idis the bare GUID for anything outside Terraform. Passing the GUID is rejected here with a message naming the right output.ℹ️ Note the
data_actionsabove use the control-plane spellingsqlDatabases/containers— that is correct for a definition's action strings. The scope grammar is the one that usesdbs/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_idis well-formed, passes every validation here, applies cleanly — and produces an assignment that grants access to nobody. That is whatthis_module_verifies_neither_the_principal_nor_the_scopeis 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 — seethe_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_idis 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
checkblock 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 isfalsefor 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
createis 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 = falseon 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.idis the account's Resource ID, which is also the absolute scope prefix — so the scope and the definition'sassignable_scopesare built from the same value here, which is the simplest way to keep them consistent.
| 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
tagsvariable.azurerm_cosmosdb_sql_role_assignmentexposes notagsattribute — 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.
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.
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.
| 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.
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module with ?ref=v1.0.0 — never a branch. This module is plan-only in this library; a human applies from CI.
| 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.
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
| 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 |
azurerm_cosmosdb_sql_role_assignment— the provider resourceazurerm_cosmosdb_sql_role_definition— the other half of the pair- Connect to Azure Cosmos DB for NoSQL using role-based access control and Microsoft Entra ID — the scope grammar, disabling key-based authentication, and the portal limitation
terraform-azurerm-cosmosdb-sql-role-definition— the sibling module that defines what may be grantedterraform-azurerm-cosmosdb-account— the account, and itslocal_authentication_enabledinputterraform-azurerm-user-assigned-identity— a principal to assign to- This module's
SCOPE.md
💙 "Infrastructure as Code should be standardized, consistent, and secure."