Naming, Identity & RBAC
Two hundred-plus resources, five environments, a dozen services that all need to reach each other. Without conventions that would be an unnavigable sprawl of hand-named resources and copy-pasted permissions. Three conventions hold it together: everything is named the same way, everything acts as a managed identity, and every permission is a scoped, least- privilege role assignment. Get these and the stack stays legible at any size.
Naming: one template, centralized
Every resource name is derived from a single base template in locals.tf — no name is ever
hand-typed in a module block. The base carries replaceable resource and component tokens:
naming_convention = format("%s-resource-%s-component-%s-%s",
lower(local.location_abbr), # e.g. azw2
lower(var.tags.Ownership), # alertca
lower(var.tags.ApplicationName),
lower(var.environment)) # dev | uat | prd | …
Each resource type then derives its own name by substituting the resource token — and, for
shared resources, dropping the component token entirely:
naming_convention_app_service = replace(local.naming_convention, "/(resource)/", "app")
naming_convention_resource_group = replace(replace(local.naming_convention, "/(resource)/", "rg"), "component-", "")
The result is the platform-wide shape
{location}-{resourceType}-{ownership}-{application}-{environment} (plus an identifier),
e.g. azw2-app-alertca-frontend-dev. Because the environment is baked into every name,
you can never confuse a dev resource for a prod one at a glance — and because the logic lives
in one place, names can't drift.
It's tempting to write name = "my-thing-dev" directly in a main.<domain>.tf block. Don't —
it breaks the convention, escapes the environment substitution, and creates a name nothing else
can derive. Add or reuse a naming_convention_* local and reference it. Names are computed, not
written.
Identity: everything acts as a managed identity
No service in this platform authenticates to another with a password or connection string. Every app, function, and web app gets a system-assigned managed identity — an Azure-managed principal with no secret to leak or rotate — and service-to-service access is granted to that principal.
locals.identities.tf is the registry: it aggregates the principal ids that the module blocks
output into one nested map, ready to be granted roles:
locals {
identities = {
functions = {
function_image_acq = module.windows_function_app["image_acquisition"].identity_principal_ids[0]
function_notification = module.windows_function_app["notification"].identity_principal_ids[0]
}
backend = { admin_api = module.backend_apis["admin_api"].principal_ids[0] }
frontend_api = { frontend_api = module.backend_apis["frontend_api"].principal_ids[0] }
entra_id = { alertca_devs = data.azuread_group.alertca_devs.object_id }
}
}
Collecting identities in one place is what makes the next step — granting roles — a data operation instead of a scavenger hunt.
RBAC: least privilege, map-driven, precisely scoped
main.roles.tf turns those identities into access. Each role is an azurerm_role_assignment
that for_eaches over a slice of local.identities, granting a built-in Azure role,
scoped to a specific resource id — never the whole subscription:
resource "azurerm_role_assignment" "app_configuration_data_reader" {
for_each = merge( /* the identities that read config */ )
scope = module.app_configuration.id # this App Config store only
role_definition_name = "App Configuration Data Reader" # read, not write
principal_id = each.value
}
Three principles are visible in that one block, and they hold throughout the file:
- Least privilege —
App Configuration Data Reader, notOwner. A component gets the narrowest role that lets it do its job (data-plane Cosmos contributor on one account, blob-data contributor on one storage account, and so on). - Precise scope —
scopeis a resource id, so the grant can't reach anything else. - Map-driven — one assignment resource covers every identity that needs that role; adding a consumer is adding it to the merge, not writing a new resource.
Further reading
Wrapping up
Three conventions keep the stack coherent: names are computed from one centralized template with the environment baked in; every service acts as a secretless managed identity aggregated into one registry; and access is granted as least-privilege, precisely-scoped, map-driven role assignments. Add a component and you inherit all three by following the pattern — never by hand-naming or over-granting.
The last lesson covers the sharp edges — the state operations and pins that protect all of the above: State, Pinning & Gotchas.