Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

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

Repository files navigation

☁️ Azure Cosmos DB SQL Role Definition Terraform Module

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.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • πŸ” 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 notDataActions property. Any action that isn't specified as an allowed dataAction is 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 ... executeQuery and ... readChangeFeed permissions." 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.
  • ⚠️ name is the editable display name; role_definition_id is the force-new identity. The reverse of nearly every resource in this library.
  • ⚠️ Both wildcards cover unmask, 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_actions as a set of long strings and assignable_scopes as 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.


❀️ Support this project

If this module saved you time:


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

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
Loading

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.


🧬 What this module builds

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
Loading
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.


βœ… Provider / Versions

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

  • πŸ”΄ name is the ARM contract's roleName β€” a display name β€” and it is EDITABLE. The identity is role_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_id is Optional, Computed and force-new. Omit it and the provider calls uuid.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.
  • ⚠️ type defaults to CustomRole and the closed set also accepts BuiltInRole, 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.
  • ⚠️ permissions is a SET of blocks each carrying only data_actions. Several blocks union their actions, so a single set of actions expresses every reachable configuration β€” this module takes data_actions directly and renders exactly one block.
  • ⚠️ Every string is validated only for non-emptiness. Neither assignable_scopes entries nor data_actions entries are checked for shape, spelling or existence.
  • ⚠️ Every create, update and delete takes an ACCOUNT-WIDE lock β€” locks.ByName(id.DatabaseAccountName, CosmosDbAccountResourceName). So a for_each over 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, ForDelete each 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: type is force-new, so it can never reach an update. Each of the three editable fields is sent behind its own HasChange guard.
  • βœ… 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 a container_id.
  • ⚠️ There is no SchemaVersion and no state upgrader.

πŸ”‘ Required Azure RBAC Roles / Permissions

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.

⚠️ A role definition alone grants nothing. It is inert until a role assignment pairs its GUID with a principal at a scope. That is a genuine safety property: the two permissions are separable, so a pipeline can be allowed to define roles without being allowed to assign them.

The scaffold this module replaced named plain Contributor and "the service-specific SQL/DB Contributor role", and said nothing about data access at all.


Azure Prerequisites

  1. Microsoft.DocumentDB registered in the subscription.
  2. πŸ”΄ 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.
  3. 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.
  4. πŸ”΄ 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.
  5. 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.
  6. 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.


πŸ“ Module Structure

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

βš™οΈ Quick Start

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. executeQuery alone would not let an SDK run a query β€” see example 3.

⚠️ This role grants nothing yet. A role assignment must pair role_definition_id with a principal.


πŸ”Œ Cross-Module Contract

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

πŸ“š Example Library

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."
  }
}

πŸ’‘ readMetadata is 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 what includes_read_metadata reports.

πŸ”’ 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_level reports container here.

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_unmask is the one worth stopping on. The unmask action "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 word unmask appears 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, and grants_a_wildcard exists 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 at readChangeFeed.

πŸ’‘ can_query_through_an_sdk is the positive form and is satisfied three ways: both actions, the container wildcard, or both plus more. declares_execute_query_without_read_change_feed is 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's id here 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 notDataActions property. Any action that isn't specified as an allowed dataAction is 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_deletes is emitted separately from grants_item_writes for 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 ...0001 and ...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 and name is not. Renaming OrdersReader to OrdersReadOnly is 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, and the_name_is_editable_and_the_guid_is_not exists 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 accepts BuiltInRole and 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 through declares_itself_a_built_in_role and given a check block a caller can adopt.

πŸ’‘ type is 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's role_definition_id takes the definition's Resource ID, not the bare GUID β€” the provider validates it with a role-definition ID validator. module.orders_reader.id is the value to pass; role_definition_id is the bare GUID for anything outside Terraform.

ℹ️ principal_id is 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 }
}

⚠️ This for_each does 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 writer role above is deliberately imperfect, and the assertion does not catch it: it grants writes but no items/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_subscription is 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 one grep -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_scopes entry 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's id is a real check a caller can make and the provider cannot.

⚠️ terraform-azurerm-cosmosdb-account predates the path convention and emits ids, so assertions against it compare id or name.

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's id. Every ordering edge is real.


πŸ“₯ Inputs

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.


🧾 Outputs

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.

🧠 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.


🧱 Design Principles

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.

⚠️ And its immutability claim is wrong in a new way. It said "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.

πŸš€ Runbook

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

Pin the module with ?ref=v1.0.0 and never a branch. This library is plan-only: a human applies from CI.


πŸ§ͺ Testing

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; executeQuery without readChangeFeed at container scope and with no readMetadata, firing two report flags together; and the item wildcard plus an explicit query pair and unmask, declared BuiltInRole, across several scopes.
  • Four locals were removed rather than fixture-covered. action_prefix, container_wildcard, item_wildcard and item_write_actions referenced 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 validate result was confirmed with the working directory printed, its .tf files listed, and a zz_negctl.tf negative control in the module's own directory: four errors with the control present, Success! with it removed.
  • The .tf files 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.


πŸ’¬ Example Output

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.


πŸ” Troubleshooting

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.

πŸ”— Related Docs


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