Skip to content

Managed Identity Key Vault Failure Lab

Reproduce Key Vault access denial by running a managed-identity-enabled app without the required RBAC role assignment.

This lab is now backed by a 4-gate Phase B bounded-falsification evidence pack in labs/managed-identity-key-vault-failure/evidence/README.md. The committed pack captures one fresh H1/H2 Azure cohort and re-validates the narrow claim ceiling offline: the missing Key Vault Secrets User assignment at the Key Vault scope is the mechanically observable trigger field for this reproduction.

Bounded-scope disclosure: the pack does not prove image byte-identity, pod UID continuity, exact RBAC propagation timing, or token-cache-only recovery without a revision restart. Those confounders are carried explicitly in Gate 17 explicit_drops, so readers can see exactly where the evidence ceiling stops.

Lab Metadata

Attribute Value
Difficulty Intermediate
Estimated Duration 25-35 minutes
Tier Consumption
Failure Mode App returns 500 when reading a secret because the managed identity lacks Key Vault Secrets User
Skills Practiced Managed identity validation, Key Vault RBAC diagnosis, revision restart verification

1) Background

This lab provisions a Container App with a system-assigned managed identity, an Azure Container Registry, and a Key Vault secret. The application uses the managed identity to request a token and read the secret at runtime. The failure occurs because the identity exists, but no RBAC role assignment grants secret-read access at the Key Vault scope.

Managed identity failures are easy to misread because the revision can stay healthy while the secret-dependent route fails with 401/403-derived application errors.

Architecture

sequenceDiagram
    participant User
    participant App as Container App
    participant MI as Managed Identity
    participant KV as Key Vault
    User->>App: Request secret-dependent endpoint
    App->>MI: Request token for Key Vault
    MI-->>App: Access token
    App->>KV: Read secret
    KV-->>App: 403 Forbidden (missing RBAC role)
    App-->>User: 500/authorization error path
    User->>Azure: Assign Key Vault Secrets User role
    App->>KV: Read secret again
    KV-->>App: 200 OK

Identity enabled does not mean authorized

System-assigned identity creation is only step one. Without role assignment at the correct scope, token retrieval can succeed while resource access still fails.

Verify scope explicitly

Assigning the role at the wrong scope, such as the resource group instead of the Key Vault resource ID, is a frequent cause of persistent 403 errors.

2) Hypothesis

IF a Container App uses a system-assigned managed identity to read a Key Vault secret but does not have the Key Vault Secrets User role on that vault, THEN the revision can remain running while the secret-dependent endpoint fails until the RBAC assignment is added and a new revision starts.

Variable Control State Experimental State
Managed identity authorization Key Vault Secrets User assigned at Key Vault scope No role assignment at Key Vault scope
Secret-dependent endpoint HTTP 200 HTTP 500 or authorization-related failure
Revision runtime state Running and healthy Running and healthy
App logs No Key Vault authorization errors 401/403-style authorization errors

3) Runbook

Deploy baseline infrastructure

Prerequisites:

  • Azure CLI with the Container Apps extension
  • Permissions for role assignments: Microsoft.Authorization/roleAssignments/write
az extension add --name containerapp --upgrade
az login

export RG="rg-aca-lab-kv"
export LOCATION="koreacentral"

az group create --name "$RG" --location "$LOCATION"

az deployment group create \
    --name "lab-kv" \
    --resource-group "$RG" \
    --template-file "./labs/managed-identity-key-vault-failure/infra/main.bicep" \
    --parameters baseName="labkv"
Command Why it is used
az extension add ... Installs or updates the Container Apps Azure CLI extension.

Expected output:

  • Resource group creation succeeds.
  • Deployment provisioningState is Succeeded.

Capture deployment outputs

export APP_NAME="$(az deployment group show \
    --resource-group "$RG" \
    --name "lab-kv" \
    --query "properties.outputs.containerAppName.value" \
    --output tsv)"

export ACR_NAME="$(az deployment group show \
    --resource-group "$RG" \
    --name "lab-kv" \
    --query "properties.outputs.containerRegistryName.value" \
    --output tsv)"

export ACA_ENV_NAME="$(az deployment group show \
    --resource-group "$RG" \
    --name "lab-kv" \
    --query "properties.outputs.environmentName.value" \
    --output tsv)"

export KV_NAME="$(az deployment group show \
    --resource-group "$RG" \
    --name "lab-kv" \
    --query "properties.outputs.keyVaultName.value" \
    --output tsv)"
Command Purpose
export APP_NAME="$(az deployment group show ... --query "properties.outputs.containerAppName.value" --output tsv)" Captures the deployed Container App name so later identity, logs, and role-assignment checks all point at the exact workload under test.
export ACR_NAME="$(az deployment group show ... --query "properties.outputs.containerRegistryName.value" --output tsv)" Captures the registry name the lab uses for the runtime image, which is needed before the two-step registry set + update --image flow can run.
export ACA_ENV_NAME="$(az deployment group show ... --query "properties.outputs.environmentName.value" --output tsv)" Captures the Container Apps environment name for any environment-scoped follow-up diagnostics.
export KV_NAME="$(az deployment group show ... --query "properties.outputs.keyVaultName.value" --output tsv)" Captures the Key Vault name from ARM outputs so the RBAC and secret-reference checks target the correct vault created by the lab.

Expected output:

  • Commands return no console output.
  • Environment variables resolve to the deployed app, registry, environment, and vault names.

Run the end-to-end capture workflow

bash ./labs/managed-identity-key-vault-failure/fix-and-capture.sh

fix-and-capture.sh is the canonical Phase A live workflow for this lab. It deploys or reuses the resource group, captures deployment outputs outside evidence/, builds and pushes the workload image, triggers H1, writes raw evidence files 01-... through 12-..., applies the H2 fix, runs the hermetic offline verifier, and starts az group delete --no-wait cleanup.

The script uses this Azure CLI 2.71.0-compatible two-step registry flow (see the warning below for context):

az acr build --registry "$ACR_NAME" --image "${APP_NAME}:v1" ./workload

az containerapp registry set \
    --name "$APP_NAME" \
    --resource-group "$RG" \
    --server "$ACR_LOGIN_SERVER" \
    --username "$ACR_USERNAME" \
    --password "$ACR_PASSWORD"

az containerapp update \
    --name "$APP_NAME" \
    --resource-group "$RG" \
    --image "${ACR_LOGIN_SERVER}/${APP_NAME}:v1"
Command Why it is used
az acr build --registry ... Builds and pushes the container image to Azure Container Registry.
az containerapp registry set ... Configures registry credentials on the Container App (required as a separate step on az CLI 2.71.0 and later).
az containerapp update --image ... Rolls the Container App to the new image; this starts a new revision that reads the Key Vault secret on startup.

Azure CLI 2.71.0+ rejects combined registry flags on containerapp update

On az CLI 2.71.0 and later, passing --registry-server, --registry-username, and --registry-password together with --image to a single az containerapp update call returns unrecognized arguments. Older versions of trigger.sh and similar scripts that issue the combined form must be updated to the two-step registry set + update --image sequence shown above.

Expected output:

  • The app is updated to an image that reads Key Vault at runtime.
  • Raw evidence files 01-... through 08-... are written under labs/managed-identity-key-vault-failure/evidence/.
  • The /health request does not return success before the fix.

Re-run the hermetic offline verifier

bash ./labs/managed-identity-key-vault-failure/verify.sh

verify.sh is now a hermetic offline verifier. It reads only the committed evidence files under labs/managed-identity-key-vault-failure/evidence/, emits Gate 14 through Gate 17 JSONs, and touches no Azure resources.

On a valid cohort, the verifier prints 17/17 gate passes. The Phase B gates prove four bounded claims:

  • Gate 14: cohort integrity (file presence, parseability, temporal bound, lineage coherence, README cross-references)
  • Gate 15: H1 trigger produces failure
  • Gate 16: H2 fix restores recovery
  • Gate 17: bounded falsification with explicit drops and claim ceiling

The pre-fix HTTP observation inside the evidence pack is a non-200 /health capture with ForbiddenByRbac in 05-http-response-pre-fix.json.

HTTP/2 500
...
Inner error: {"code": "ForbiddenByRbac"}

If you want to inspect the raw evidence directly after fix-and-capture.sh, use:

az containerapp show \
    --name "$APP_NAME" \
    --resource-group "$RG" \
    --query "identity" \
    --output json

export PRINCIPAL_ID="$(az containerapp show \
    --name "$APP_NAME" \
    --resource-group "$RG" \
    --query "identity.principalId" \
    --output tsv)"

az role assignment list \
    --assignee "$PRINCIPAL_ID" \
    --output table

az containerapp logs show \
    --name "$APP_NAME" \
    --resource-group "$RG" \
    --type system \
    --tail 20
Command Why it is used
az containerapp show ... Reads the Container App configuration so the documented setting can be verified.

Expected output:

  • identity.principalId is present.
  • No Key Vault Secrets User assignment exists yet at the Key Vault scope.
  • System logs show authorization-related behavior while the app still has a running revision.

Managed identity failures commonly present like this while the revision stays running:

Name               Active    TrafficWeight    Replicas    HealthState    RunningState
-----------------  --------  ---------------  ----------  -------------  ------------
ca-myapp--0000001  True      100              1           Healthy        Running

Apply the RBAC fix manually (optional)

If you want the direct fix command, use:

export KV_ID="$(az keyvault show \
    --name "$KV_NAME" \
    --resource-group "$RG" \
    --query "id" \
    --output tsv)"

az role assignment create \
    --assignee-object-id "$PRINCIPAL_ID" \
    --assignee-principal-type ServicePrincipal \
    --role "Key Vault Secrets User" \
    --scope "$KV_ID"
Command Why it is used
az keyvault show ... Creates or inspects Key Vault resources used by managed identity or secret references.

The live script rolls a new revision with:

az containerapp update \
    --name "$APP_NAME" \
    --resource-group "$RG" \
    --set-env-vars "RESTART_TOKEN=$(date +%s)"
Command Why it is used
az containerapp update ... Updates the existing Container App configuration without recreating the app.

Expected output:

  • The role assignment create command returns a role assignment object.
  • A new revision starts after the restart token update.

Verify recovery from the evidence pack

Re-run the offline verifier or inspect the post-fix raw files:

./labs/managed-identity-key-vault-failure/verify.sh

az role assignment list \
    --assignee "$PRINCIPAL_ID" \
    --scope "$KV_ID" \
    --output table
Command Purpose
az role assignment list --assignee "$PRINCIPAL_ID" --scope "$KV_ID" --output table Lists RBAC grants for one principal at one exact scope, which is the quickest way to verify whether the workload has the right permission on the dependency that is failing.

Expected output:

  • Gate 16 passes and 10-http-response-post-fix.json shows HTTP 200
  • The role assignment is visible at the Key Vault scope.
  • The secret-dependent endpoint succeeds.

4) Experiment Log

Step Action Expected Actual Pass/Fail
1 Deploy baseline infrastructure Deployment succeeds provisioningState: Succeeded in koreacentral; app ca-labkv-gtlopy, KV kv-labkv-gtlopy, ACR acrlabkvgtlopy provisioned. Pass
2 Capture outputs App, registry, environment, and vault names resolved All four names resolved from deployment outputs; FQDN ca-labkv-gtlopy.calmriver-ae84e755.koreacentral.azurecontainerapps.io. Pass
3 Run fix-and-capture.sh App starts with missing Key Vault RBAC and evidence capture begins Image built and pushed; the script captured 01-... through 08-... for the failing state. Pass
4 Inspect 05-http-response-pre-fix.json Non-200 response before RBAC assignment HTTP 500 body included ForbiddenByRbac and Assignment: (not found). Pass
5 Inspect 01-... / 02-... / 04-... Principal exists, required role missing, revision stays healthy identity.principalId present; no Key Vault Secrets User at Key Vault scope; active revision remained Healthy / Running. Pass
6 Apply Key Vault role assignment + revision restart Role assignment succeeds and a new revision starts 09-role-assignment-post-fix.json recorded the scoped role assignment; 11-revision-list-post-fix.json recorded the newer revision. Pass
7 Re-run verify.sh All 17 gates pass and post-fix HTTP 200 is preserved Gate 16 passed; 10-http-response-post-fix.json contained {"secretLength":"20","status":"ok"}. Pass

Expected Evidence

During failure

Evidence Source Expected State
az containerapp show --query "identity" System-assigned identity exists with a principal ID
az role assignment list --assignee "$PRINCIPAL_ID" No Key Vault Secrets User assignment at Key Vault scope
curl https://${FQDN}/health from scripts Non-200 response
az containerapp logs show --type system Authorization-related behavior during secret access
Revision status Running and healthy despite endpoint failure

After fix

Evidence Source Expected State
az role assignment list --assignee "$PRINCIPAL_ID" --scope "$KV_ID" Key Vault Secrets User assignment present
./labs/managed-identity-key-vault-failure/verify.sh PASS after RBAC assignment
Secret-dependent endpoint HTTP 200
Logs No continuing Key Vault authorization failure for the tested path

Observed Evidence (Live Azure Reproduction — 2026-06-03)

Environment: rg-aca-lab-kv in koreacentral, Consumption plan, Azure CLI 2.71.0. App: ca-labkv-gtlopy (system-assigned managed identity, principal <principal-id>). Key Vault: kv-labkv-gtlopy (RBAC authorization model — enableRbacAuthorization: true). FQDN: ca-labkv-gtlopy.calmriver-ae84e755.koreacentral.azurecontainerapps.io

During failure

[Observed] The Container App's Identity blade shows the system-assigned managed identity is enabled (Status: On) with an object (principal) ID assigned. Identity provisioning alone does not grant any resource access:

Container App Identity blade with system-assigned managed identity enabled

[Observed] The Key Vault's Access control (IAM) → Role assignments blade, filtered by the Container App principal name (ca-labkv-gtlopy), shows All (0) and No results. — confirming that no Key Vault Secrets User (or any other) role exists at the vault scope for this principal:

Key Vault IAM blade showing no role assignment for the Container App principal

[Observed] The Container App's Revisions and replicas blade shows the active revision is Running with replica count 1 throughout the incident — the platform considers the app healthy even while the secret-dependent endpoint returns HTTP 500:

Container App Revisions blade with the active revision Running during the incident

[Observed] The Container App's Metrics blade, with the Requests metric split by Status Code Category for the last 30 minutes, shows the pre-fix request volume dominated by 5xx responses (5xx = 86, 2xx = 1) — visible proof that the failure is a request-time error, not a startup or scaling failure:

Metrics blade showing 5xx-dominated request volume during the incident

[Not Proven from Portal] The 403 response from Key Vault itself is not directly visible in any Portal blade for this lab. Gunicorn is configured without access logs, and the Flask handler catches the exception and returns it as a JSON 500 — so the Log stream blade contains no useful application output. The ForbiddenByRbac evidence comes from the HTTP response body collected by the verification script (see Verification script output below). The Portal-side proof is the combination of (a) zero role assignments at the KV scope (capture #2) and (b) the 5xx-dominated request metric (capture #4).

After fix

[Observed] After running az role assignment create --role "Key Vault Secrets User" --assignee-object-id <principal-id> --scope <kv-resource-id>, the same Key Vault Access control (IAM) → Role assignments filter now shows exactly one result: Key Vault Secrets User assigned to ca-labkv-gtlopy at this vault's scope:

Key Vault IAM blade after fix showing Key Vault Secrets User assigned to the app principal

[Observed] The Container App Metrics blade with the same Requests / Status Code Category split, extended to capture both the incident window and the post-fix traffic, shows 2xx responses (116) rising alongside the prior 5xx population (86) — visualizing the clean recovery after the role assignment:

Metrics blade after fix showing 2xx recovery alongside the earlier 5xx population

Verification script output (non-Portal evidence)

The Portal captures above are paired with HTTP response bodies collected by verify.sh against the FQDN. These are CLI-side observations, not Portal evidence, and are recorded here separately so the Portal [Observed] bullets above remain strictly one-to-one with captures.

Pre-fix response body (HTTP 500):

Access denied or Key Vault read failed: (Forbidden) Caller is not authorized
to perform action on resource ...
Action: 'Microsoft.KeyVault/vaults/secrets/getSecret/action'
... Assignment: (not found) ... Inner error: ForbiddenByRbac

Post-fix response body (HTTP 200):

{"secretLength":"20","status":"ok"}

Causal analysis

[Inferred] Because (a) no infrastructure change other than the role assignment + identity restart was applied between captures #4 and #6, and (b) the response body changed from ForbiddenByRbac to {"status":"ok"} over the same code path, the failure is fully explained by the missing Key Vault Secrets User assignment at the vault scope. The fix is deterministic: assign the role, restart the revision, secret reads succeed.

[Strongly Suggested] On vaults configured with enableRbacAuthorization: true, legacy az keyvault set-policy calls are silently ignored by the data plane (the policy is stored but not consulted). Engineers reproducing similar incidents on RBAC-mode vaults must use az role assignment create, not set-policy.

Clean Up

az group delete --name "$RG" --yes --no-wait
Command Why it is used
az group delete ... Removes the lab resource group and its contained resources.

See Also

Sources