Background
Sections
IntroductionFoundations1. Resource Hierarchy2. Resource Manager3. Identity and RBAC4. Regions and Availability5. Naming and TaggingVirtual Machines1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetVirtual Network1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetBlob Storage1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure SQL Database1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Kubernetes Service1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Container Registry1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetMicrosoft Entra ID1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure RBAC1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Functions1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAPI Management1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure App Configuration1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Machine Learning1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Monitor1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure AI Foundry1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and Cheatsheet

4. Getting Started

10 min read

The task, three ways. Give a web app's managed identity permission to read blobs in one container — and nothing else. It's the smallest exercise that demonstrates the thing that actually matters: that a data-plane role is a different animal from Owner, and that a managed identity needs no secret anywhere.

Everything here is throwaway. Hard-coded names, no variables, no remote state, no pipeline. The production-shaped version lives in Deployment.

[Image Prompt: 2D minimalistic diagram comparing three provisioning paths — Azure Portal, Azure CLI, and Terraform — converging on the same outcome, a role assignment binding a web app managed identity to the Storage Blob Data Reader role scoped to a single blob container, flat design, clean vector art style, white background]


Before you start

You need permission to grant permission, which is its own gotcha. Writing a role assignment requires Microsoft.Authorization/roleAssignments/write, which Contributor does not have. You need Owner, User Access Administrator, or Role Based Access Control Administrator at the scope.

az login
az account set --subscription "<your-subscription-name-or-id>"

# Confirm you can actually assign roles here
az role assignment list --assignee $(az ad signed-in-user show --query id -o tsv) \
  --scope "/subscriptions/$(az account show --query id -o tsv)" -o table

If that shows only Contributor, stop — the exercise will fail at the last step and the error will be AuthorizationFailed on roleAssignments/write, which is confusing precisely because everything before it worked.

Pick a globally unique storage account name; st plus something random is the convention.


Path 1 — Azure Portal

The portal path is the fastest way to build intuition about scope, because the Access control (IAM) blade appears on every scope and looks identical each time. That repetition is the mental model.

  • Create a resource group named rg-rbac-demo in your region, then a storage account inside it and a container named uploads.
  • Create a Web App in the same resource group, open its Identity blade, and turn System assigned to On. Note the Object (principal) ID it shows you — that's the principal you're about to grant.
  • Navigate to the container (not the storage account): open the storage account → ContainersuploadsAccess control (IAM). Choosing the container here rather than the account is the whole lesson in scope.
  • AddAdd role assignment → search for Storage Blob Data ReaderMembersManaged identity → pick your web app → Review + assign.
  • Confirm it: back on Access control (IAM), open Check access, select the web app's identity, and read what it's permitted to do here.

Now do the negative test, because it's the point of the exercise. Give yourself Owner on the resource group, then try to read blob contents in the portal's storage browser. You'll be offered "switch to Microsoft Entra user account" and refused — Owner carries no dataActions. Assign yourself Storage Blob Data Reader and try again.

Portal navigation changes often, so treat the blade names above (Identity, Access control (IAM), Check access) as the durable part and the exact button labels as approximate.


Path 2 — Azure CLI

Copy-pasteable, and this is the version worth keeping in your notes.

# --- variables -------------------------------------------------------------
RG=rg-rbac-demo
LOC=uksouth
STG=strbacdemo$RANDOM          # must be globally unique, lowercase, no hyphens
APP=app-rbac-demo-$RANDOM
PLAN=plan-rbac-demo

# --- throwaway resource group first: the cleanest teardown Azure gives you --
az group create -n $RG -l $LOC

# --- the resource whose data we're protecting ------------------------------
az storage account create -n $STG -g $RG -l $LOC --sku Standard_LRS \
  --allow-shared-key-access false          # RBAC becomes the ONLY door
az storage container create --account-name $STG -n uploads --auth-mode login

# --- the consumer, with a system-assigned managed identity ----------------
az appservice plan create -n $PLAN -g $RG --sku B1 --is-linux
az webapp create -n $APP -g $RG -p $PLAN --runtime "PYTHON:3.11"
OID=$(az webapp identity assign -n $APP -g $RG --query principalId -o tsv)
echo "principal object id: $OID"

Note --allow-shared-key-access false in there. Without it the account keys remain a valid credential and the role assignment you're about to make is advisory. This is the single most important flag on the page.

Now the grant itself. Three fields, and the CLI makes all three explicit:

# The narrowest scope that works: one container, not the account
SCOPE="$(az storage account show -g $RG -n $STG --query id -o tsv)/blobServices/default/containers/uploads"

az role assignment create \
  --assignee-object-id "$OID" \
  --assignee-principal-type ServicePrincipal \
  --role "Storage Blob Data Reader" \
  --scope "$SCOPE"

Always pass --assignee-principal-type for a managed identity or service principal. Without it, the CLI looks the principal up in Graph, which fails or retries slowly for an identity created seconds ago — and it's the reason half the "role assignment create is flaky" reports exist. Use --assignee-object-id rather than --assignee for the same reason: skip the lookup entirely.

Verify, from three angles:

# 1. Does the assignment exist, and at what scope?
az role assignment list --scope "$SCOPE" -o table

# 2. What does this principal hold anywhere in the subscription?
az role assignment list --assignee "$OID" --all -o table

# 3. Why does anyone have access to this resource group?
az role assignment list -g $RG --include-inherited -o table

Prove the control-plane / data-plane split

This is the part to actually run, because reading about it doesn't land the same way.

# Give yourself Owner on the resource group — the broadest control-plane role there is
ME=$(az ad signed-in-user show --query id -o tsv)
az role assignment create --assignee-object-id "$ME" --assignee-principal-type User \
  --role Owner -g $RG

echo "hello rbac" > hello.txt

# As Owner, try to write a blob. This FAILS.
az storage blob upload --account-name $STG -c uploads -f hello.txt -n hello.txt --auth-mode login
# → AuthorizationPermissionMismatch. Owner has no dataActions.

# Grant the data role at the container scope, wait for propagation, retry
az role assignment create --assignee-object-id "$ME" --assignee-principal-type User \
  --role "Storage Blob Data Contributor" --scope "$SCOPE"

sleep 60   # assignments are eventually consistent; a fast retest tells you nothing
az storage blob upload --account-name $STG -c uploads -f hello.txt -n hello.txt --auth-mode login
az storage blob list --account-name $STG -c uploads --auth-mode login -o table

Two lessons in one script: Owner is not enough for data, and a failed retest thirty seconds after a grant is not evidence of anything. Both are in Architecture, and both are worth having felt.

PowerShell Az equivalent

Included here because identity and RBAC work is one of the genuinely PowerShell-first corners of Azure — the Entra and Windows-estate tooling lives there, and mixed az / Az-module scripts are normal in these shops.

Connect-AzAccount
$rg    = 'rg-rbac-demo'
$stg   = 'strbacdemo1234'
$oid   = (Get-AzWebApp -ResourceGroupName $rg -Name 'app-rbac-demo-1234').Identity.PrincipalId
$scope = "$((Get-AzStorageAccount -ResourceGroupName $rg -Name $stg).Id)/blobServices/default/containers/uploads"

New-AzRoleAssignment -ObjectId $oid `
  -RoleDefinitionName 'Storage Blob Data Reader' `
  -Scope $scope

Get-AzRoleAssignment -Scope $scope | Format-Table DisplayName, RoleDefinitionName, Scope

Path 3 — Terraform (minimal)

Same outcome, declaratively. Deliberately unparameterised — the module shape is in Deployment.

terraform {
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
  }
}

provider "azurerm" {
  features {}
}

resource "azurerm_resource_group" "demo" {
  name     = "rg-rbac-demo"
  location = "uksouth"
}

resource "azurerm_storage_account" "demo" {
  name                     = "strbacdemo91742"   # must be globally unique
  resource_group_name      = azurerm_resource_group.demo.name
  location                 = azurerm_resource_group.demo.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
  shared_access_key_enabled = false              # RBAC is the only door
}

resource "azurerm_storage_container" "uploads" {
  name                  = "uploads"
  storage_account_id    = azurerm_storage_account.demo.id
  container_access_type = "private"
}

resource "azurerm_service_plan" "demo" {
  name                = "plan-rbac-demo"
  resource_group_name = azurerm_resource_group.demo.name
  location            = azurerm_resource_group.demo.location
  os_type             = "Linux"
  sku_name            = "B1"
}

resource "azurerm_linux_web_app" "demo" {
  name                = "app-rbac-demo-91742"
  resource_group_name = azurerm_resource_group.demo.name
  location            = azurerm_service_plan.demo.location
  service_plan_id     = azurerm_service_plan.demo.id

  identity {
    type = "SystemAssigned"        # see the trap note below
  }

  site_config {
    application_stack { python_version = "3.11" }
  }
}

# The grant: three fields, one resource
resource "azurerm_role_assignment" "app_reads_uploads" {
  scope                = azurerm_storage_container.uploads.resource_manager_id
  role_definition_name = "Storage Blob Data Reader"
  principal_id         = azurerm_linux_web_app.demo.identity[0].principal_id
  principal_type       = "ServicePrincipal"     # skips the Graph lookup — set it
}

output "app_principal_id" {
  value = azurerm_linux_web_app.demo.identity[0].principal_id
}
terraform init
terraform plan
terraform apply

Four things in that file are worth more than the rest of it:

scope uses resource_manager_id, not id. For azurerm_storage_container the id is a data-plane URL; the ARM resource ID needed for a role assignment is a separate attribute. Getting this wrong produces a confusing scope-parse error.

principal_type = "ServicePrincipal" skips a Graph lookup that frequently races a just-created identity. Set it whenever you know it.

identity { type = "SystemAssigned" } is the trap flagged in Core Concepts. Any change forcing replacement of the web app produces a new object ID; the role assignment then points at a dead principal, apply still succeeds, and the app 403s at runtime. Real modules use a user-assigned identity for exactly this reason.

The assignment's own name is a GUID. Terraform generates a random one unless you set name explicitly. That's fine here, and it matters for Bicep and for re-runnability — see Deployment.

The propagation dependency Terraform can't see

If a later resource needs the assignment to be effective — an AKS cluster pulling from ACR, a Function App reading a secret at startup — depends_on is not enough. The assignment exists before it propagates.

resource "time_sleep" "wait_for_rbac" {
  depends_on      = [azurerm_role_assignment.app_reads_uploads]
  create_duration = "60s"
}

Inelegant, widely used, and honest about a real distributed-systems property. Discussed further in Deployment.


Teardown

Delete the resource group and everything in it goes. This is a genuine advantage over AWS, where the equivalent cleanup is a scavenger hunt — and it's worth pointing out once.

az group delete -n rg-rbac-demo --yes --no-wait
terraform destroy

What that does not remove, and you should check each one:

  • The Owner and Storage Blob Data Contributor assignments you gave yourself on the resource group — those go with the group, but any you made at subscription scope while experimenting do not.
  • Role assignments created outside your Terraform state. If you made one in the portal, destroy doesn't know about it.
  • User-assigned managed identities in another resource group, if you used one.
  • Custom role definitions, which are tenant-level objects and survive every resource group deletion.
# Sweep for anything you left behind at subscription scope
az role assignment list --assignee $(az ad signed-in-user show --query id -o tsv) --all -o table

# And for orphaned assignments pointing at deleted principals
az role assignment list --all --include-inherited \
  --query "[?principalName==null].{role:roleDefinitionName, scope:scope, oid:principalId}" -o table

That second query is the one to keep. Half of all surprise cloud bills come from forgotten demo resources; most audit findings come from forgotten demo permissions, and they don't show up on an invoice.


Next: Deployment →

← Back to the Azure RBAC overview · ← Previous: Architecture