Data-plane RBAC for Cosmos DB for NoSQL β an allow-list of data actions with no deny, no portal, and no way to subtract. Targets
hashicorp/azurerm ~> 4.0.
- π Manages
azurerm_cosmosdb_sql_role_definitionβ a custom data-plane role on a Cosmos DB for NoSQL account, listing the actions a principal may perform against the items in a container. - π΄ This module grants DATA access, and that inverts what its siblings say about themselves. A trigger, a user-defined function and a stored procedure are control-plane records whose creation grants no data access. Paired with a role assignment, this one lets a principal read, query and write items β so control-plane write here is the ability to grant data-plane access to any identity.
- π΄ There is no deny. Microsoft: Cosmos DB for NoSQL's native RBAC "doesn't support the
notDataActionsproperty. Any action that isn't specified as an alloweddataActionis excluded automatically." A role cannot subtract β the only way to narrow one is to list fewer actions. - π΄ Querying needs TWO actions. Microsoft: "you must have both the ...
executeQueryand ...readChangeFeedpermissions." A role with one and not the other looks queryable and is not. No schema can express a rule spanning two set members, so the module computes it. - π΄ The scope grammar is
/dbs/and/colls/, not/sqlDatabases/and/containers/. Every other module in this family takes control-plane IDs using the longer spellings, which makes the wrong one the easy mistake here β so it is rejected. β οΈ nameis the editable display name;role_definition_idis the force-new identity. The reverse of nearly every resource in this library.β οΈ Both wildcards coverunmask, so a role can bypass Dynamic Data Masking without that word appearing anywhere in its configuration.β οΈ The portal cannot manage these at all, and every write takes an account-wide lock.
π‘ Why it matters: this is the first module in the family whose whole purpose is to hand out access rather than to manage a record, and almost nothing about its blast radius is legible in a plan. A plan prints
data_actionsas a set of long strings andassignable_scopesas another; it does not say that one of those strings is a wildcard, that the wildcard silently includes reading past data masking, that the scope has no/dbs/segment and therefore covers every container in the account, or that the role cannot run a query at all. So the module computes each of those and names them for what they let somebody do.
If this module saved you time:
- β Star the repository β it helps others find it.
- πΌ Connect on LinkedIn β linkedin.com/in/microsoftexpert
- β Buy me a coffee β buymeacoffee.com/microsoftexpert
This is the shared cosmosdb family DAG. The fourteen blue nodes are the authored modules; the single dark node is not a module at all β it is the constraint that decides which children are even legal on a given account.
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
Note where this module attaches. It is not a container child: a role definition belongs to the account and merely names the databases and containers it may be assigned over.
flowchart TB
IN_NAMES["resource_group_name and account_name: FORCE-NEW. The account must serve the NoSQL API, which is the only API supporting data-plane RBAC"]
IN_GUID["role_definition_id: the real IDENTITY, FORCE-NEW, and OPTIONAL. Omit it and the provider generates one, which is then knowable only after apply"]
IN_NAME["name: the DISPLAY name, and EDITABLE. The reverse of nearly every resource in this library"]
IN_SCOPES["assignable_scopes: the DATA-PLANE grammar, with dbs and colls rather than sqlDatabases and containers. Editable"]
IN_ACTIONS["data_actions: an ALLOW-LIST WITH NO SUBTRACTION. notDataActions is unsupported, so anything unlisted is excluded automatically"]
PROV["the provider supplies the subscription from its own configuration, and the caller supplies none, so nothing can be relocated"]
LOCK["every create, update and delete takes an ACCOUNT-WIDE lock, so a for_each over role definitions does not run in parallel"]
THIS["azurerm_cosmosdb_sql_role_definition.this"]
ASSIGN["a ROLE ASSIGNMENT pairs this definition with a principal at a scope, referencing it by RESOURCE ID rather than by GUID. Until one exists, this role grants nothing to anybody"]
GRANTED["and then the principal can read, query and WRITE THE ITEMS. This is the module in the family that grants DATA access"]
OUT_ID["id, role_definition_id and name"]
OUT_PATHS["account_path_within_the_subscription: the family's shared comparable form, built forward from two names"]
OUT_SCOPE["assignable_at_the_whole_account and widest_assignable_scope_level: how far the role can reach"]
OUT_GRANT["grants_a_wildcard, grants_item_writes, grants_item_deletes, grants_unmask, is_read_only_in_effect"]
OUT_QUERY["can_query_through_an_sdk and declares_execute_query_without_read_change_feed: a rule spanning TWO set members, which no schema can express"]
OUT_CONST["eight constants, including that there is no deny and that the portal cannot manage these at all"]
IN_NAMES --> THIS
IN_GUID --> THIS
IN_NAME --> THIS
IN_SCOPES --> THIS
IN_ACTIONS --> THIS
PROV -->|"the subscription, with nothing to override"| THIS
LOCK -.->|"serializes"| THIS
THIS --> ASSIGN
ASSIGN --> GRANTED
THIS --> OUT_ID
THIS --> OUT_PATHS
THIS --> OUT_SCOPE
THIS --> OUT_GRANT
THIS --> OUT_QUERY
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_GRANT,OUT_QUERY,OUT_CONST me
class IN_NAMES,IN_GUID,IN_NAME,IN_SCOPES,IN_ACTIONS,PROV,LOCK,ASSIGN,GRANTED sibling
| Resource | Cardinality | Note |
|---|---|---|
azurerm_cosmosdb_sql_role_definition |
single (this) |
The keystone. One permissions block plus timeouts. |
A standalone module: one keystone resource, no for_each children.
| Item | Value |
|---|---|
| Terraform | >= 1.12.0 |
| Provider | hashicorp/azurerm ~> 4.0 (validated against 4.81.0) |
| Provider block | None here. The caller configures provider "azurerm" { features {} }, auth and subscription. |
| ARM API type | Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions |
tags |
Not supported β this resource has no tags and no location. |
| Timeouts | Provider defaults: 30 minutes create, update and delete; 5 minutes read. All four are read correctly β see below. |
Schema notes that bite
- π΄
nameis the ARM contract'sroleNameβ a display name β and it is EDITABLE. The identity isrole_definition_id, a GUID forming the last ID segment, which is force-new. So renaming a role is free and re-identifying it is a replacement that also breaks every assignment referencing the old GUID. - π΄
role_definition_idis Optional, Computed and force-new. Omit it and the provider callsuuid.GenerateUUID(), so the value is knowable only after apply. Microsoft notes the identifier "is unique across all role definitions in your account" β including the built-ins, which occupy...0001(Data Reader) and...0002(Data Contributor). Both are rejected here. β οΈ typedefaults toCustomRoleand the closed set also acceptsBuiltInRole, validated case-sensitively. Declaring a role Terraform is creating as built-in is almost certainly wrong, and the provider accepts it β so the module reports rather than refuses.β οΈ permissionsis a SET of blocks each carrying onlydata_actions. Several blocks union their actions, so a single set of actions expresses every reachable configuration β this module takesdata_actionsdirectly and renders exactly one block.β οΈ Every string is validated only for non-emptiness. Neitherassignable_scopesentries nordata_actionsentries are checked for shape, spelling or existence.β οΈ Every create, update and delete takes an ACCOUNT-WIDE lock βlocks.ByName(id.DatabaseAccountName, CosmosDbAccountResourceName). So afor_eachover role definitions serializes against the whole account, including against the account resource and against role assignments.- β
All four timeout helpers are correct β
ForCreate,ForRead,ForUpdate,ForDeleteeach in their own function. Worth stating because the sibling stored-procedure resource's update path calls the create helper instead. - β
The UPDATE payload omits
Type, and that is correct rather than a defect:typeis force-new, so it can never reach an update. Each of the three editable fields is sent behind its ownHasChangeguard. - β
There IS an import check, gated on
Features.SkipImportCheckOnCreateAndAllowOverwritingExistingResources. β οΈ The subscription comes from provider configuration and the caller supplies none, so nothing can be relocated β unlike the two container children that take acontainer_id.β οΈ There is noSchemaVersionand no state upgrader.
Microsoft states these explicitly, and they are narrower than Contributor on the account:
| Operation | Control-plane actions required |
|---|---|
| Read an existing role definition | Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions/read |
| Create or update one (this module) | .../sqlRoleDefinitions/read and .../sqlRoleDefinitions/write |
| Create a role assignment (the sibling) | .../sqlRoleDefinitions/read, .../sqlRoleAssignments/read, .../sqlRoleAssignments/write |
π΄ This is the module where "plan access is not credential access" stops being true, and in the other direction. The last several modules in this family could honestly say that creating them granted no data access. This resource exists to grant it: whoever can write role definitions on an account can define a role carrying .../containers/items/* and, with the assignment permission, hand it to any principal. No Azure role narrows that, and the account's own keys are not involved β which is precisely why data-plane RBAC is worth using, and precisely why write access to it is a privileged operation.
The scaffold this module replaced named plain Contributor and "the service-specific SQL/DB Contributor role", and said nothing about data access at all.
Microsoft.DocumentDBregistered in the subscription.- π΄ An existing Cosmos DB account serving the NoSQL (SQL) API. Microsoft, asked which APIs support data-plane RBAC: "As of now, only the NoSQL API is supported." This resource is meaningless on a Cassandra, Mongo, Gremlin or Table account, and nothing here checks which API the named account serves.
- The databases and containers named in
assignable_scopes, if the role is to be assignable anywhere useful. A scope naming a container that does not exist is accepted and grants nothing. - π΄ A role assignment, to make the role do anything β
terraform-azurerm-cosmosdb-sql-role-assignment. Until one exists this resource is a definition and no principal holds it. - A client authenticating with Microsoft Entra ID. Data-plane RBAC governs token-based access; it does not restrict the account's own keys. If key access is still enabled, a key holder bypasses every role defined here.
- An SDK that supports it β Microsoft lists the .NET V3, Java V4, JavaScript V3 and Python V4.3+ SDKs.
There is no location and no tags on this resource.
terraform-azurerm-cosmosdb-sql-role-definition/
βββ providers.tf # required_version and the pinned azurerm; no provider block
βββ variables.tf # 8 variables, 17 validations
βββ main.tf # the keystone `this`, one permissions block, and 32 derived locals
βββ outputs.tf # 34 outputs: 7 passthrough, 19 derived, 8 constant
βββ README.md # this file
βββ SCOPE.md # the cross-module contract
βββ LICENSE # MIT
βββ .gitignore
module "orders_reader" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"
name = "OrdersReader"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
# The DATA-PLANE grammar: /dbs/, not /sqlDatabases/.
assignable_scopes = [
"${module.sales_account.id}/dbs/sales",
]
data_actions = [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/executeQuery",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/readChangeFeed",
]
}
output "grants" {
value = {
read_only = module.orders_reader.is_read_only_in_effect
can_query = module.orders_reader.can_query_through_an_sdk
role_guid = module.orders_reader.role_definition_id
}
}π‘ Both query actions are present on purpose.
executeQueryalone would not let an SDK run a query β see example 3.
β οΈ This role grants nothing yet. A role assignment must pairrole_definition_idwith a principal.
Consumes
| Input | Type | Source |
|---|---|---|
resource_group_name |
string |
terraform-azurerm-resource-group output name |
account_name |
string |
terraform-azurerm-cosmosdb-account output name |
assignable_scopes |
set(string) |
built from that account's id plus /dbs/β¦/colls/β¦ |
| the subscription | β | the caller's provider. Nothing here expresses one, and nothing can override one |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
The role definition's Resource ID (first) | an import, a report |
role_definition_id |
The bare GUID, for anything outside Terraform. A role assignment built here takes id instead |
external tooling |
can_query_through_an_sdk |
The two-action pairing rule | check blocks |
is_read_only_in_effect |
Named for the effect, not the actions | check blocks |
grants_a_wildcard, grants_unmask, grants_item_deletes |
What the role really permits | security review |
widest_assignable_scope_level |
account, database or container |
security review |
| eight constants | Facts that produce no error when they bite | check blocks, runbooks |
1 Β· A least-privilege reader
module "orders_reader" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"
name = "OrdersReader"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
# Narrowest useful scope: ONE container.
assignable_scopes = [
"${module.sales_account.id}/dbs/sales/colls/orders",
]
data_actions = [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/executeQuery",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/readChangeFeed",
]
}
check "the_reader_really_is_read_only" {
assert {
condition = module.orders_reader.is_read_only_in_effect
error_message = "This role is named as a reader and grants at least one item write. Read grants_item_writes and grants_a_wildcard: a container or item wildcard grants every write without any of create, replace, upsert or delete appearing in the configuration."
}
}
check "the_reader_can_actually_query" {
assert {
condition = module.orders_reader.can_query_through_an_sdk
error_message = "This role cannot run a NoSQL query through any SDK. Microsoft requires BOTH executeQuery and readChangeFeed, or a container wildcard covering them."
}
}π‘
readMetadatais not optional in practice. Microsoft describes it as the metadata "required ... for data plane operations" and every built-in data-plane role includes it. A role without it is unlikely to be usable at all, which is whatincludes_read_metadatareports.π The container scope is the narrowest of the three levels β account, database, container β and it is the one to reach for by default.
widest_assignable_scope_levelreportscontainerhere.
2 Β· What a wildcard actually grants
module "orders_contributor" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"
name = "OrdersContributor"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
assignable_scopes = ["${module.sales_account.id}/dbs/sales"]
# TWO strings. One of them is nearly everything.
data_actions = [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*",
]
}
output "what_that_wildcard_bought" {
value = {
wildcard = module.orders_contributor.grants_a_wildcard
writes = module.orders_contributor.grants_item_writes
deletes = module.orders_contributor.grants_item_deletes
unmask = module.orders_contributor.grants_unmask # <- true, and never written down
procedures = module.orders_contributor.grants_stored_procedure_execution
can_query = module.orders_contributor.can_query_through_an_sdk
}
}π΄ Every one of those outputs is
true, from two strings. Microsoft documents.../containers/*as permission to "perform all container-specific operations like executing queries, reading the change feed, managing conflicts, and executing stored procedures", and it covers the item actions beneath it too.π΄
grants_unmaskis the one worth stopping on. Theunmaskaction "Reads items bypassing Dynamic Data Masking to reveal original values" β so a masking policy configured elsewhere on the account stops applying to any principal holding this role. The wordunmaskappears nowhere in the configuration above. A wildcard is how it gets granted by accident.
β οΈ A wildcard also grants actions Microsoft adds in future, without this role ever being edited. That is the argument for enumerating actions on anything sensitive, andgrants_a_wildcardexists so a review can find the roles that did not.
3 Β· The query rule no schema can express
# LEGAL, applies cleanly, and cannot run a single query.
module "broken_query_role" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"
name = "OrdersQueryOnly"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
assignable_scopes = ["${module.sales_account.id}/dbs/sales"]
data_actions = [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/executeQuery",
# readChangeFeed is missing.
]
}
check "no_role_declares_half_the_query_pair" {
assert {
condition = !module.broken_query_role.declares_execute_query_without_read_change_feed
error_message = "This role grants executeQuery without readChangeFeed and without a container wildcard. Microsoft: 'To perform NoSQL queries using the software development kits (SDKs), you must have both the ... executeQuery and ... readChangeFeed permissions.' So this role looks queryable and is not -- and the failure arrives at runtime as an authorization error naming neither action. Add readChangeFeed."
}
}π΄ This is the highest-value thing the module computes, because it is a relationship between two members of a set β exactly the class a schema language cannot express, so the provider has no way to check it and never will.
β οΈ The failure is at runtime and misleading. The role exists, the assignment exists, the principal authenticates, and the query fails with an authorization error that does not name the missing action. Nothing in a plan, an apply or the Azure portal points atreadChangeFeed.π‘
can_query_through_an_sdkis the positive form and is satisfied three ways: both actions, the container wildcard, or both plus more.declares_execute_query_without_read_change_feedis the negative form and fires only on the specific defect.
4 Β· Scope, and the grammar this family gets wrong
# REJECTED by this module: the CONTROL-PLANE spelling.
# assignable_scopes = ["${module.sales_account.id}/sqlDatabases/sales/containers/orders"]
#
# ACCEPTED: the DATA-PLANE grammar.
module "scoped_three_ways" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"
name = "OrdersReader"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
assignable_scopes = [
module.sales_account.id, # the whole ACCOUNT
"${module.sales_account.id}/dbs/sales", # one DATABASE
"${module.sales_account.id}/dbs/sales/colls/orders", # one CONTAINER
]
data_actions = [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
]
}
check "no_role_is_assignable_account_wide" {
assert {
condition = !module.scoped_three_ways.assignable_at_the_whole_account
error_message = "This role may be assigned at the whole account, so an assignment can grant it over every database and container that exists now or later. The narrower scopes listed beside it do not reduce that -- the widest scope wins. Read widest_assignable_scope_level, which reports 'account' here."
}
}π΄ The data-plane grammar is
/dbs/and/colls/. Every other module in this family consumes control-plane Resource IDs, which spell the same things/sqlDatabases/and/containers/β so reaching for a container module'sidhere produces a scope Azure will never match. The provider validates each entry only as a non-empty string, so it applies cleanly and grants nothing.
β οΈ The widest scope wins, which is why the module reports the widest rather than a list. Three scopes where one is account-wide is an account-wide role; listing narrower ones alongside is decorative.π‘ Note the account scope is just the account's Resource ID with nothing appended. Microsoft also documents a relative
/meaning the whole account, which this module accepts.
5 Β· There is no deny
# There is no way to express "everything on items EXCEPT delete".
# This is the only way to withhold delete: enumerate what you do allow.
module "no_deletes" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"
name = "OrdersWriterNoDeletes"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
assignable_scopes = ["${module.sales_account.id}/dbs/sales/colls/orders"]
data_actions = [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/create",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/replace",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/upsert",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/executeQuery",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/readChangeFeed",
# items/delete deliberately absent. There is no notDataActions to exclude it with.
]
}
check "the_writer_cannot_delete" {
assert {
condition = !module.no_deletes.grants_item_deletes
error_message = "This role can delete items. Deletion is separated from the other writes because it is the one that is not recoverable from the data itself -- and note that EITHER wildcard grants it, so check grants_a_wildcard too."
}
}
output "the_model" {
value = module.no_deletes.there_is_no_deny_only_an_allow_list
}π΄ Microsoft: the native RBAC "doesn't support the
notDataActionsproperty. Any action that isn't specified as an alloweddataActionis excluded automatically." So the mental model from Azure's control-plane RBAC β allow broadly, then subtract β does not transfer. There is no subtraction at all.π‘ The practical consequence is that a wildcard is a one-way door.
containers/items/*minus delete is not expressible; a role that must withhold anything has to enumerate. That is why this module reports wildcards so prominently.
β οΈ grants_item_deletesis emitted separately fromgrants_item_writesfor exactly this example's reason: of the four writes, deletion is the one whose consequence is not recoverable from the remaining data.
6 Β· The GUID, and the two you cannot use
# Let the provider generate it -- the usual choice.
module "generated" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"
name = "OrdersReader"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
assignable_scopes = ["${module.sales_account.id}/dbs/sales"]
data_actions = ["Microsoft.DocumentDB/databaseAccounts/readMetadata"]
}
# Pin it when something outside Terraform must reference the role by GUID.
module "pinned" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"
name = "OrdersReaderPinned"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
role_definition_id = "11111111-2222-3333-4444-555555555555"
assignable_scopes = ["${module.sales_account.id}/dbs/sales"]
data_actions = ["Microsoft.DocumentDB/databaseAccounts/readMetadata"]
}
# REJECTED by this module -- both are Azure's BUILT-IN data-plane roles:
# role_definition_id = "00000000-0000-0000-0000-000000000001" # Built-in Data Reader
# role_definition_id = "00000000-0000-0000-0000-000000000002" # Built-in Data Contributor
output "guid_facts" {
value = {
pinned_here = module.pinned.role_definition_id_was_supplied
generated_here = module.generated.role_definition_id_was_supplied # false
name_editable = module.generated.the_name_is_editable_and_the_guid_is_not
}
}π‘ Omitting the GUID is the normal path and the provider generates one. Pin it when a value must survive a state rebuild or be referenced from configuration Terraform does not own β a generated GUID is knowable only after apply.
π΄ Microsoft: a role definition's identifier "is unique across all role definitions in your account" β and the built-in data-plane roles occupy
...0001and...0002. Neither can be reused for a custom role, so both are rejected with a message naming which is which.
β οΈ The GUID is force-new andnameis not. RenamingOrdersReadertoOrdersReadOnlyis an in-place update; changing the GUID destroys and recreates, which breaks every role assignment referencing the old one. That is the reverse of nearly every resource in this library, andthe_name_is_editable_and_the_guid_is_notexists to say so.
7 Β· `type`, and the value that is almost always wrong
module "custom" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"
name = "OrdersReader"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
assignable_scopes = ["${module.sales_account.id}/dbs/sales"]
data_actions = ["Microsoft.DocumentDB/databaseAccounts/readMetadata"]
# type omitted -> "CustomRole", which is what a role you are creating should be.
}
# LEGAL per the provider's closed set, and almost certainly a mistake.
module "declares_builtin" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"
name = "PretendBuiltIn"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
assignable_scopes = ["${module.sales_account.id}/dbs/sales"]
data_actions = ["Microsoft.DocumentDB/databaseAccounts/readMetadata"]
type = "BuiltInRole"
}
# REJECTED by this module: the set is case-sensitive.
# type = "customrole"
check "no_role_claims_to_be_built_in" {
assert {
condition = !module.declares_builtin.declares_itself_a_built_in_role
error_message = "This role definition declares type = \"BuiltInRole\". Built-in data-plane roles are defined by Azure, not by a caller, so a definition Terraform creates should be a CustomRole. The provider's closed set accepts BuiltInRole, which is why this is reported rather than refused."
}
}
β οΈ The provider acceptsBuiltInRoleand this module does not refuse it, because refusing legal input is the worse error β the value is in the provider's own closed set. It is reported throughdeclares_itself_a_built_in_roleand given acheckblock a caller can adopt.π‘
typeis force-new, which is also why it is correctly absent from the provider's update payload β a checked-and-clean result rather than the silent no-op this library has found elsewhere.
8 Β· A role definition grants nothing on its own
module "orders_reader" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"
name = "OrdersReader"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
assignable_scopes = ["${module.sales_account.id}/dbs/sales"]
data_actions = [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
]
}
# The role does nothing until an ASSIGNMENT pairs it with a principal at a scope.
module "app_assignment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-assignment.git?ref=v1.0.0"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
# The definition's RESOURCE ID, not its bare GUID.
role_definition_id = module.orders_reader.id
# The identity that will hold the role, and where.
principal_id = var.application_principal_object_id
scope = "${module.sales_account.id}/dbs/sales"
}
output "the_two_halves" {
value = {
definition_grants_nothing_alone = module.orders_reader.this_role_grants_data_access_not_control_plane_access
definition_guid = module.orders_reader.role_definition_id
}
}π The separation is a safety property worth using. Defining a role and assigning it need different control-plane permissions, so a pipeline can be trusted to define roles without being trusted to grant them to principals. Splitting those two across different identities is the least-privilege shape here.
β οΈ The assignment'srole_definition_idtakes the definition's Resource ID, not the bare GUID β the provider validates it with a role-definition ID validator.module.orders_reader.idis the value to pass;role_definition_idis the bare GUID for anything outside Terraform.βΉοΈ
principal_idis an object ID, and it is force-new on the assignment. Re-pointing a role at a different identity is a create-and-destroy, so a brief gap in access is the default unless the new assignment is created first.
9 Β· Several roles on one account, and why they are slow
locals {
roles = {
reader = {
actions = [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/executeQuery",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/readChangeFeed",
]
}
writer = {
actions = [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/create",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/upsert",
]
}
reporter = {
actions = [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/executeQuery",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/readChangeFeed",
]
}
}
}
module "roles" {
for_each = local.roles
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"
name = "Orders${title(each.key)}"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
assignable_scopes = ["${module.sales_account.id}/dbs/sales/colls/orders"]
data_actions = each.value.actions
}
check "no_role_in_the_set_is_a_broken_query_role" {
assert {
condition = alltrue([
for r in module.roles : !r.declares_execute_query_without_read_change_feed
])
error_message = "A role in this set grants executeQuery without readChangeFeed and cannot query through an SDK."
}
}
output "role_guids" {
value = { for k, r in module.roles : k => r.role_definition_id }
}
β οΈ Thisfor_eachdoes NOT run in parallel. Every create, update and delete takes an account-wide lock, so three role definitions are three sequential long-running operations regardless of Terraform's parallelism β and they also serialize against the account resource itself and against any role assignments.π‘ The
writerrole above is deliberately imperfect, and the assertion does not catch it: it grants writes but noitems/read, so a principal holding it can create and upsert without reading. That is legal and occasionally intended, and no output flags it β worth noting that the module reports what it can compute, not everything a reviewer should think about.βΉοΈ The keys drive the display names and the GUIDs are generated, so the output map is the thing to feed into role assignments.
10 Β· Data-plane RBAC does not replace key access
module "orders_reader" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"
name = "OrdersReader"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
assignable_scopes = ["${module.sales_account.id}/dbs/sales"]
data_actions = [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
]
}
output "what_this_does_not_restrict" {
description = "Named for the gap between a role and the account's keys."
value = {
nosql_api_only = module.orders_reader.data_plane_rbac_is_supported_on_the_nosql_api_only
no_portal = module.orders_reader.role_definitions_cannot_be_managed_in_the_azure_portal
grants_data = module.orders_reader.this_role_grants_data_access_not_control_plane_access
}
}π΄ A role restricts token-based access and nothing else. Data-plane RBAC governs what a Microsoft Entra ID principal may do; it does not restrict the account's own primary and secondary keys. Anyone holding a key bypasses every role defined here entirely β so a least-privilege role is only meaningful on an account where key access has been disabled, which is the account module's setting rather than this one's.
π΄ And there is no portal fallback. Microsoft: "Managing data plane role-based access control isn't supported in the Azure portal." So Terraform, the CLI, PowerShell and ARM are the only surfaces β you cannot inspect or repair one of these by hand in the portal, which raises the cost of getting a definition wrong.
β οΈ NoSQL only. Microsoft: "As of now, only the NoSQL API is supported." Nothing here checks the named account's API, so a role defined on a Mongo account applies cleanly and does nothing.
11 Β· The comparable path, and one account
# A sibling on the same account, declared here because it is this example's other operand: it derives
# the identical path string from its own four names.
module "bulk_import" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-stored-procedure.git?ref=v1.0.0"
name = "spBulkImport"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
database_name = "sales"
container_name = "orders"
body = file("${path.module}/procedures/bulk-import.js")
}
check "the_role_is_on_the_account_this_composition_created" {
assert {
condition = (
module.orders_reader.account_path_within_the_subscription ==
module.bulk_import.account_path_within_the_subscription
)
error_message = "The role definition and the stored procedure resolve to different accounts. Each builds this string independently -- the role definition forward from two names, the stored procedure forward from its own four -- so a mismatch is a real disagreement."
}
}
check "every_assignable_scope_names_this_account" {
assert {
condition = alltrue([
for s in module.orders_reader.assignable_scopes :
startswith(s, module.sales_account.id)
])
error_message = "An assignable scope does not begin with this account's Resource ID, so it names a different account -- or a malformed scope. The provider validates each entry only as a non-empty string, so nothing else catches it."
}
}
output "paths" {
value = {
account = module.orders_reader.account_path_within_the_subscription
scopes = module.orders_reader.assignable_scopes
widest = module.orders_reader.widest_assignable_scope_level
}
}π
account_path_within_the_subscriptionis emitted in the same subscription-less form by every module in this family that emits it, and proved byte-identical across all of them offline. That set is onegrep -rl '^output "account_path_within_the_subscription"'away. This module builds it forward from two names.π‘ The second assertion is the one this resource specifically needs. Nothing validates an
assignable_scopesentry beyond non-emptiness, so a scope naming the wrong account is accepted and silently grants nothing. Comparing each entry's prefix against the account module'sidis a real check a caller can make and the provider cannot.
β οΈ terraform-azurerm-cosmosdb-accountpredates the path convention and emits ids, so assertions against it compareidorname.
12 Β· ποΈ End-to-end composition β least privilege on a real account
locals {
platform_tags = {
workload = "sales"
managed = "terraform"
}
}
module "data_rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-sales-prod"
location = "eastus"
tags = local.platform_tags
}
module "sales_account" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-account.git?ref=v1.0.0"
name = "cosmos-sales-prod"
resource_group_name = module.data_rg.name
location = module.data_rg.location
# No capabilities: the NoSQL API, the only one supporting data-plane RBAC.
sql_databases = {
sales = {
containers = {
orders = {
partition_key_paths = ["/customerId"]
}
}
}
}
tags = local.platform_tags
}
# The application's role: read and query ONE container, and nothing else.
module "orders_reader" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-definition.git?ref=v1.0.0"
name = "OrdersReader"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
# Narrowest scope, data-plane grammar.
assignable_scopes = [
"${module.sales_account.id}/dbs/sales/colls/orders",
]
# Enumerated rather than wildcarded, so nothing is granted by accident -- including unmask.
data_actions = [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/executeQuery",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/readChangeFeed",
]
}
# The role does nothing until it is assigned.
module "app_assignment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-role-assignment.git?ref=v1.0.0"
resource_group_name = module.data_rg.name
account_name = module.sales_account.name
role_definition_id = module.orders_reader.id
principal_id = var.application_principal_object_id
scope = "${module.sales_account.id}/dbs/sales/colls/orders"
}
check "the_role_is_least_privilege" {
assert {
condition = alltrue([
module.orders_reader.is_read_only_in_effect,
module.orders_reader.can_query_through_an_sdk,
module.orders_reader.includes_read_metadata,
!module.orders_reader.grants_a_wildcard,
!module.orders_reader.grants_unmask,
!module.orders_reader.assignable_at_the_whole_account,
])
error_message = "This role is not the least-privilege reader it is named as. All six conditions matter: read-only in effect, actually able to query, carrying readMetadata, no wildcard, no unmask, and not assignable account-wide. The wildcard and unmask checks are the ones that fail silently, because a single wildcard string grants both without either word appearing."
}
}
output "what_this_composition_accepts" {
description = "Named for what a successful apply does NOT guarantee."
value = {
keys_still_bypass_this = module.orders_reader.this_role_grants_data_access_not_control_plane_access
no_deny_available = module.orders_reader.there_is_no_deny_only_an_allow_list
nosql_api_unchecked = module.orders_reader.data_plane_rbac_is_supported_on_the_nosql_api_only
no_portal_repair = module.orders_reader.role_definitions_cannot_be_managed_in_the_azure_portal
account_wide_lock = module.orders_reader.the_account_is_locked_for_the_duration_of_every_write
scopes_unverified = module.orders_reader.nothing_verifies_the_account_or_the_assignable_scopes
widest_scope = module.orders_reader.widest_assignable_scope_level
}
}π The six-part assertion is the point of this example. A least-privilege role is not one condition, it is six β and two of them (
grants_a_wildcard,grants_unmask) fail silently because a single wildcard string grants everything beneath it without any of the granted words appearing in the configuration.π΄ The closing output is named for what the apply does not guarantee. The biggest item is the first: this role restricts Entra ID principals and does not restrict the account's keys. A least-privilege role on an account where key access is still enabled is a control that anyone with the key ignores β and that setting belongs to the account module, not this one.
π‘ Actions are enumerated rather than wildcarded on purpose, and the four listed are exactly Microsoft's own recipe for a reader: metadata, item read, and both halves of the query pair.
βΉοΈ No
depends_on: the role references the resource group and account modules by attribute, and the assignment references the role'sid. Every ordering edge is real.
| Group | Variables |
|---|---|
| Identity | name (display, editable), role_definition_id (the GUID, force-new) |
| Placement | resource_group_name, account_name |
| Kind | type |
| What it grants | assignable_scopes, data_actions |
| Universal tail | timeouts (no tags β the resource has none) |
Full input schemas
| Variable | Type | Default | Notes |
|---|---|---|---|
name |
string |
β | Editable. The ARM contract's roleName. Blank and Resource-ID rules. |
resource_group_name |
string |
β | Force-new. Blank and Resource-ID rules. |
account_name |
string |
β | Force-new. Blank rule plus ^[-a-z0-9]{3,50}$, restated because the provider's message omits the lowercase rule. |
role_definition_id |
string |
null |
Force-new, generated when omitted. GUID rule, plus a rule rejecting Azure's two built-in role GUIDs. |
type |
string |
"CustomRole" |
Force-new. Closed set, case-sensitive. |
assignable_scopes |
set(string) |
β | Editable. Four rules: non-empty set, no blank entry, no control-plane spelling, each entry absolute. |
data_actions |
set(string) |
β | Editable. Three rules: non-empty set, no blank entry, and a case near-miss check against Microsoft's published action list. |
timeouts |
object({ create, read, update, delete }) |
{} β 30m / 5m / 30m / 30m |
All four read correctly by this resource. |
17 validations, listed rather than totaled. Two on name. Two on resource_group_name. Two on account_name. Two on role_definition_id. One on type. Four on assignable_scopes. Three on data_actions. One on timeouts.
Grouped by what each one adds, listed rather than totaled. Six are coverage the provider has no equivalent for at all: rejecting a Resource ID in name and in resource_group_name, rejecting the built-in GUIDs, rejecting the control-plane scope spelling, requiring an absolute scope, and the data_actions case near-miss. Five catch whitespace that the provider's own non-empty checks accept β one each on name, resource_group_name, account_name, and on the entries of both sets. Six restate a rule the provider does apply, so that the message names the argument and the legal values: the account regex, the GUID shape, the type set, both non-empty-set requirements, and the duration format.
data_actions deliberately allows an unrecognized action through. Microsoft's published list grows, so only a case-mismatched near miss of a known action is rejected β the standing rule against inventing a closed set.
The permissions block is flattened to data_actions. The provider models it as a set of blocks each carrying only data_actions, and several blocks union their actions β so one set expresses every reachable configuration and the module renders exactly one block.
34 outputs: 7 passthrough, 19 derived, 8 constant. None is sensitive; this module accepts and emits no secret.
Nineteen derived, because nothing about what a role grants is legible in a plan. A plan prints two sets of long strings; it does not say that one of them is a wildcard, that the wildcard includes bypassing data masking, or that the role cannot run a query.
| Output | Description |
|---|---|
id, role_definition_id, name, type |
Identity and kind. The GUID is the identity; name is a label. |
resource_group_name, account_name, assignable_scopes |
As supplied. |
account_path_within_the_subscription |
The family's shared comparable form. |
role_definition_id_was_supplied |
Pinned versus generated. |
is_custom_role, declares_itself_a_built_in_role |
The declared kind. |
assignable_scope_count, assignable_at_the_whole_account, widest_assignable_scope_level |
How far the role can reach. |
data_action_count |
How many actions. |
grants_the_container_wildcard, grants_the_item_wildcard, grants_a_wildcard |
The single thing most worth spotting. |
includes_read_metadata |
Without it the role is unlikely to work at all. |
can_query_through_an_sdk, declares_execute_query_without_read_change_feed |
The two-action rule no schema can express. |
grants_item_writes, grants_item_deletes, grants_unmask, grants_stored_procedure_execution |
What it really permits. |
is_read_only_in_effect |
Named for the effect, so a reviewer need not evaluate strings against a wildcard. |
| eight constants | Enumerated in Architecture Notes. |
This is the module in the family that grants data access, and saying so plainly is the deliverable. Its siblings are control-plane records: creating a trigger, a user-defined function or a stored procedure grants nobody any access to any item. This resource, paired with a role assignment, lets a principal read, query and write the items in a container. So the honest permissions statement inverts: control-plane write on role definitions is the ability to grant data-plane access to any identity, and no Azure role narrows it. The separation between defining a role and assigning one is the mitigation, and it is real β the two need different control-plane actions, so a pipeline can define without being able to grant.
There is no deny, which breaks the mental model people bring from Azure RBAC. Microsoft: the native implementation "doesn't support the notDataActions property. Any action that isn't specified as an allowed dataAction is excluded automatically." Allow-broadly-then-subtract is not available. The practical consequence is that a wildcard is a one-way door: containers/items/* minus delete cannot be expressed, so a role that must withhold anything has to enumerate. That is why the module reports wildcards so prominently and separates grants_item_deletes from the other writes.
Both wildcards silently include unmask. Microsoft describes that action as "Reads items bypassing Dynamic Data Masking to reveal original values", and it sits beneath containers/items/* and containers/*. So a role can defeat a masking policy configured elsewhere on the account without the word unmask appearing anywhere in its configuration, and without any plan output hinting at it. grants_unmask is computed through the wildcards precisely so that a review can find it.
Querying requires two actions, which is a relationship a schema language cannot express. Microsoft: "To perform NoSQL queries using the software development kits (SDKs), you must have both the ... executeQuery and ... readChangeFeed permissions." A role granting one is legal, applies cleanly, and fails at runtime with an authorization error naming neither action. This is the class of constraint this library keeps finding to be the highest-value addition: bounds on one field are what a provider tends to have, relationships between two are what it cannot.
The scope grammar is not the one the rest of this family uses. Data-plane scopes spell things /dbs/ and /colls/; every other module in the family consumes control-plane Resource IDs spelling them /sqlDatabases/ and /containers/. Reaching for a container module's id here produces a scope that Azure will never match, and the provider validates each entry only as a non-empty string β so it applies and grants nothing. The module rejects the control-plane spellings and requires each entry to be absolute, which is as far as static checking can go.
The widest scope wins, so the module reports the widest rather than a list. A role assignable at the account level is account-wide however many narrower scopes sit beside it. assignable_at_the_whole_account and widest_assignable_scope_level exist because a plan prints scope strings without interpreting the absence of a /dbs/ segment.
Identity and label are the reverse of usual. name is the ARM roleName β a display name, editable in place. role_definition_id is the GUID forming the last ID segment, force-new, and generated by the provider when omitted. So renaming is free and re-identifying is a replacement that breaks every assignment referencing the old GUID. Microsoft notes the identifier is unique across all role definitions in the account, which is why the two built-in GUIDs are rejected here.
Two checked-and-clean results worth stating, because this library has found their opposites elsewhere. All four CRUD functions use their matching timeout helper, unlike the sibling stored procedure whose update reads the create timeout. And the update payload omits Type β which is correct rather than a silent no-op, because type is force-new and can never reach an update. Diffing the create payload against the update and checking every missing field is force-new is what turns that from an assumption into a result.
Every write serializes against the whole account. The provider takes locks.ByName(id.DatabaseAccountName, ...) on create, update and delete, so a for_each over role definitions is sequential regardless of Terraform's parallelism, and it contends with the account resource and with role assignments. A large set of roles is a slow apply rather than a wide one.
And a role restricts tokens, not keys. Data-plane RBAC governs Microsoft Entra ID principals. It does not restrict the account's primary and secondary keys, so a least-privilege role on an account where key access remains enabled is a control that any key holder ignores. That setting lives on the account, not here β but a reader of this module needs to know the boundary, which is why the composition example's closing output leads with it.
There is no secure-defaults table on this module, and for once the reason is the opposite of usual. Everywhere else in this family the answer is "nothing here gates exposure". Here everything gates exposure β but there is no default to make safe, because assignable_scopes and data_actions are both required and there is no empty call. What the module does instead is compute the blast radius and name it. The scaffold this replaced asserted "the empty call yields the hardened resource" with public access, weaker TLS, disabled protections as the opt-outs, none of which exists, and described "cross-cutting concerns (diagnostics, private endpoints, RBAC) composed from sibling modules" β on the resource that is the RBAC.
name and resource_group_name are effectively immutable". Both are real arguments here, unlike three earlier modules in this family whose scaffolds named a resource_group_name that did not exist β but name is the one argument on this resource that is editable. So the boilerplate has now been false by inventing an argument, accurate, and false by inverting one's mutability.
| Decision | Value | Reasoning |
|---|---|---|
| The two built-in GUIDs | Rejected | Microsoft documents identifiers as unique across all definitions in an account, so neither is available. A real conflict, not a preference. |
| The control-plane scope spelling | Rejected | /sqlDatabases/ and /containers/ never match a data-plane scope, and every sibling module hands out IDs that use them. |
An unrecognized data_actions entry |
Allowed | Microsoft's list grows. Only a case near-miss is rejected β the standing rule against inventing a closed set. |
type = "BuiltInRole" |
Reported, not rejected | It is in the provider's own closed set. Refusing legal input is the worse error. |
The permissions block |
Flattened to data_actions |
The block carries exactly one field and several blocks union, so one set expresses every reachable configuration. |
| Wildcard reporting | Three flags | A wildcard is the single thing most worth spotting, and it grants future actions too. |
grants_unmask computed through wildcards |
Yes | Otherwise a role bypasses data masking with no evidence anywhere in its configuration. |
| The query pairing rule | Computed, both ways | A relationship between two set members, which no schema can express. Positive and negative forms both emitted. |
is_read_only_in_effect |
Named for the effect | A reviewer asking "is this read-only" should not have to evaluate strings against a wildcard. |
widest_assignable_scope_level |
Widest, not a list | The widest scope is the role's actual reach. |
| The keys-versus-tokens boundary | Stated in prose and prerequisites | It is the account's setting, not this module's, and it decides whether any of this matters. |
No tags variable |
Correct | The resource exposes neither tags nor location. |
terraform init -backend=false
terraform fmt -check
terraform validatePin the module with ?ref=v1.0.0 and never a branch. This library is plan-only: a human applies from CI.
terraform validate proves the configuration parses. It does not fire a variable validation when this module is called from another configuration β terraform console with a -var-file is the offline harness for that.
What was proven offline for this module:
- All 17 validations fired, each by a fixture built to fail it, and zero condition-evaluation errors on the first run.
- All 32 locals were driven to more than one value across four good fixtures, arranged so every derived flag takes both values: a read-only role with explicit actions, a pinned GUID and no query pair; the container wildcard at account scope with a generated GUID;
executeQuerywithoutreadChangeFeedat container scope and with noreadMetadata, firing two report flags together; and the item wildcard plus an explicit query pair andunmask, declaredBuiltInRole, across several scopes. - Four locals were removed rather than fixture-covered.
action_prefix,container_wildcard,item_wildcardanditem_write_actionsreferenced no variable, so they could not vary and the harness could never exercise them. Each action string is now written out in full at its point of use β verbose, and provable. The module was then grepped for all four removed names. - The output tuple was computed mechanically before this README was written, and a disagreement was diffed rather than assumed. The classifier reported 33 outputs where an inline count reported 34. The cause was this module's own fault: one output name contained uppercase (
..._DATA_access_...), and the tool's regex matches lowercase names only, so it silently skipped it. Renaming to lowercase β which is the library's convention anyway β reconciled both to 34, and a library-wide grep confirmed it was the only non-lowercase output name anywhere. - The group-size audit ran as one mechanical table before the diagrams were substituted.
- The
terraform validateresult was confirmed with the working directory printed, its.tffiles listed, and azz_negctl.tfnegative control in the module's own directory: four errors with the control present,Success!with it removed. - The
.tffiles were swept for non-ASCII under an explicit UTF-8 encoding immediately after writing.
What only an apply exercises: whether the account exists and serves the NoSQL API, and whether the databases and containers named in the scopes exist. What no apply exercises: whether the actions add up to a role that works, and whether key access is still enabled on the account.
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-sales-prod/providers/Microsoft.DocumentDB/databaseAccounts/cosmos-sales-prod/sqlRoleDefinitions/8f2a1c4b-9d3e-4f5a-6b7c-8d9e0f1a2b3c"
role_definition_id = "8f2a1c4b-9d3e-4f5a-6b7c-8d9e0f1a2b3c"
name = "OrdersReader"
type = "CustomRole"
account_path_within_the_subscription = "/resourceGroups/rg-sales-prod/providers/Microsoft.DocumentDB/databaseAccounts/cosmos-sales-prod"
role_definition_id_was_supplied = false
assignable_scope_count = 1
assignable_at_the_whole_account = false
widest_assignable_scope_level = "container"
data_action_count = 4
grants_a_wildcard = false
includes_read_metadata = true
can_query_through_an_sdk = true
declares_execute_query_without_read_change_feed = false
grants_item_writes = false
grants_item_deletes = false
grants_unmask = false
is_read_only_in_effect = true
Read it as a least-privilege checklist: container scope, no wildcard, metadata present, queries work, no writes, no unmask. Six lines, and a wildcard would have flipped four of them.
| Symptom | Cause | Fix |
|---|---|---|
| A query fails with an authorization error naming no action | The role has executeQuery but not readChangeFeed |
Add readChangeFeed, or use the container wildcard. Read declares_execute_query_without_read_change_feed. |
| Every data operation fails for a principal that holds the role | readMetadata is missing |
Read includes_read_metadata. Microsoft describes it as required for data-plane operations. |
| A role grants more than intended and the extra actions are not written down | A wildcard | Read grants_a_wildcard, then grants_unmask and grants_item_deletes. Enumerate instead. |
| Masked fields are returned unmasked | The role grants unmask, directly or through a wildcard |
Read grants_unmask. There is no way to grant a wildcard minus unmask. |
| The role appears to apply and grants nothing | An assignable_scopes entry uses the control-plane spelling, names another account, or names a container that does not exist |
Rejected here for the first case. For the others, assert each entry's prefix against the account's id. |
An assignable_scopes entry uses the CONTROL-PLANE spelling |
A container or database Resource ID from a sibling module | Data-plane scopes use /dbs/ and /colls/. |
role_definition_id is one of Azure's BUILT-IN data-plane role definition GUIDs |
...0001 or ...0002 |
Those are Data Reader and Data Contributor. Omit the argument or use a different GUID. |
type must be exactly "CustomRole" or "BuiltInRole" |
The set is case-sensitive | Match the capitalization. |
| A role assignment cannot find the definition | The assignment needs the definition's Resource ID, not the bare GUID | Pass module.<x>.id. |
| A rename broke every assignment | A changed GUID, not a changed name |
name is editable; role_definition_id is force-new. |
| An apply over many role definitions is very slow | Every write takes an account-wide lock | Expected. They serialize regardless of parallelism. |
| A key holder ignores the role entirely | Data-plane RBAC restricts tokens, not keys | Disable key access on the account. That is the account module's setting. |
| The role cannot be found or edited in the Azure portal | Microsoft: portal management of data-plane RBAC is not supported | Use Terraform, the CLI, PowerShell or ARM. |
A caller wants prevent_destroy on this module |
lifecycle is not valid in a module block |
Use a CanNotDelete management lock, which prevents deletion, not replacement. |
azurerm_cosmosdb_sql_role_definitionazurerm_cosmosdb_sql_role_assignmentβ the other half of the pairterraform-azurerm-cosmosdb-sql-role-assignmentβ the sibling module that assigns a role defined here- Azure Cosmos DB for NoSQL data plane security reference β the action list, the wildcards, the built-in roles, and the
notDataActionswarning - Connect to Azure Cosmos DB for NoSQL using role-based access control β the scope grammar, the required control-plane permissions, and the query-pair rule
- Cosmos DB FAQ, Security β NoSQL-API-only, no portal management, and the supported SDK versions
- Sibling modules:
terraform-azurerm-cosmosdb-account(which owns key access),terraform-azurerm-cosmosdb-sql-stored-procedure,terraform-azurerm-cosmosdb-sql-trigger,terraform-azurerm-cosmosdb-sql-function,terraform-azurerm-resource-group - This module's
SCOPE.md
π "Infrastructure as Code should be standardized, consistent, and secure."