2026-05-25 — Auto-grant runtime RBAC on AKS cluster RG¶
Motivation¶
Both production environments (env A ca-elb-dashboard-01 and env B
ca-elb-dashboard) shipped with a structural gap in their deployed
RBAC:
infra/modules/controlPlaneRoles.bicepgrants the shared dashboard managed identityContributor+User Access Administratoron the deployment resource group only (resourceGroup()scope).- The OpenAPI deploy task
api/tasks/openapi/rbac.py::setup_workload_identityreaches into the AKS cluster's RG (rg-elb-cluster) and tries to: - Create the workload MI
id-elb-openapi - Create a Federated Identity Credential under it
- Assign
Contributor/Storage Blob Data Contributor/Azure Kubernetes Service Cluster User Roleto that MI az aks get-credentials --admin+kubectl applythe manifests
Without Contributor + UAA on rg-elb-cluster, step 1 immediately
fails with:
azure.core.exceptions.HttpResponseError: (AuthorizationFailed)
The client 'e4f4e63d-…' with object id '3f06c475-…' does not have
authorization to perform action
'Microsoft.ManagedIdentity/userAssignedIdentities/write' over scope
'/subscriptions/…/resourceGroups/rg-elb-cluster/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id-elb-openapi'
and the SPA shows
workload identity setup failed; OpenAPI pod would have no AZURE_CLIENT_ID.
Discovered today on env B (ca-elb-dashboard) when an operator clicked
"Deploy elb-openapi". Env A had the same gap (zero role assignments on
rg-elb-cluster for both dashboard MIs).
User-facing change¶
- New helper
scripts/dev/grant-runtime-rbac.sh— workstation-driven, idempotent grant ofContributor+User Access Administratoron the AKS cluster RG to the deployed dashboard MI. Auto-detects the MI principal id fromaz containerapp showand the AKS cluster RG fromaz aks list(unambiguous "exactly one" case). Supports--dry-run,--yes,--container-app,--rg,--subscription,--cluster-rg,--principal-idoverrides. cli-upgrade.shnow runs the grant as a preflight step (best-effort: a failure logs a recovery hint but does not block the api/frontend/terminal image rollout itself). Add--skip-rbac-grantto bypass.- Documented in
docs/operate/cli-upgrade.mdpreflight checklist.
API / IaC diff summary¶
- No API change — pure operational/ops tooling + IaC.
- Bicep: new sibling module
infra/modules/workloadClusterRoles.bicep - two new
main.bicepparams (aksClusterResourceGroup,assignWorkloadClusterRoles). WhenaksClusterResourceGroupis set (e.g.rg-elb-cluster),azd provisionnow grants the sameContributor+User Access Administratorpair on the AKS cluster RG thatcontrolPlaneRoles.bicepalready grants on the deployment RG. The assignmentnameisguid(resourceGroup().id, uamiPrincipalId, roleId)so a redeploy against an RG where the helper already created the assignment is a no-op (Bicep adopts the existing assignment in place). postprovision.shnow callsgrant-runtime-rbac.shas a best-effort self-heal at the end ofazd provision— covers the "firstazd uphad no AKS RG yet, SPA created AKS later, never re-ranazd provision" case.- Runtime contract preserved:
api/tasks/openapi/rbac.pyandapi/tasks/openapi/deploy.pyalready raise loudly with the exact error string the SPA displays; nothing in those files changed.
Manual recovery applied to running environments¶
Both env A and env B already had the gap before this PR. The new helper was run against both:
bash scripts/dev/grant-runtime-rbac.sh --yes \
--container-app ca-elb-dashboard-01 --rg rg-elb-dashboard-01 \
--subscription 00000000-0000-0000-0000-0000000000a1
# created=2 skipped=0 failed=0 (Contributor, User Access Administrator)
bash scripts/dev/grant-runtime-rbac.sh --yes \
--container-app ca-elb-dashboard --rg rg-elb-dashboard \
--subscription 00000000-0000-0000-0000-0000000000a1
# created=2 skipped=0 failed=0
Both MIs now show the expected pair on rg-elb-cluster:
$ az role assignment list --assignee <oid> --scope /subscriptions/.../resourceGroups/rg-elb-cluster --query "[].roleDefinitionName" -o tsv
Contributor
User Access Administrator
(Env A MI oid 2fef9815-d8ac-4956-bbdb-1bf937392b30,
env B MI oid 3f06c475-95ee-45f3-85e8-751f740e123f.)
Validation evidence¶
# bash syntax + dry-run from clean tree (env A azd default)
bash -n scripts/dev/grant-runtime-rbac.sh && echo syntax OK
bash -n scripts/dev/cli-upgrade.sh && echo syntax OK
bash scripts/dev/grant-runtime-rbac.sh --dry-run --yes \
--container-app ca-elb-dashboard-01 --rg rg-elb-dashboard-01 \
--subscription 00000000-0000-0000-0000-0000000000a1
# Subscription: 00000000-0000-0000-0000-0000000000a1
# Container App: ca-elb-dashboard-01 (rg-elb-dashboard-01)
# Dashboard MI: 2fef9815-d8ac-4956-bbdb-1bf937392b30
# AKS cluster RG: rg-elb-cluster
# (dry-run — no role assignments will be created)
# [dry ] would assign Contributor at /subscriptions/.../resourceGroups/rg-elb-cluster
# [dry ] would assign User Access Administrator at /subscriptions/.../resourceGroups/rg-elb-cluster
bash scripts/dev/cli-upgrade.sh api --dry-run --allow-dirty --yes 2>&1 | grep grant-runtime
# ==> (dry-run) would call grant-runtime-rbac.sh --container-app ca-elb-dashboard-01 --rg rg-elb-dashboard-01
Re-running the helper after the manual recovery (round-trip idempotency):
$ bash scripts/dev/grant-runtime-rbac.sh --yes \
--container-app ca-elb-dashboard --rg rg-elb-dashboard \
--subscription 00000000-0000-0000-0000-0000000000a1
[skip] Contributor already assigned at /subscriptions/.../resourceGroups/rg-elb-cluster
[skip] User Access Administrator already assigned at /subscriptions/.../resourceGroups/rg-elb-cluster
Summary: created=0 skipped=2 failed=0
Follow-up¶
Bicep follow-up: extendDone in this PR — seeinfra/modules/controlPlaneRoles.bicep(or add a sibling moduleworkloadClusterRoles.bicep) so new deployments grant the workload-cluster-RG roles at provision time andgrant-runtime-rbac.shbecomes a pure self-healing safety net.infra/modules/workloadClusterRoles.bicep- the
aksClusterResourceGroup/assignWorkloadClusterRolesparams ininfra/main.bicep. The shell helper is now formally the self-healing safety net (also invoked frompostprovision.sh). - To opt new deployments into the Bicep grant after AKS exists, set:
The new
aksClusterResourceGroupBicep param is wired intoinfra/main.parameters.jsonas${AKS_CLUSTER_RESOURCE_GROUP=}so the standard azd env-to-bicep binding picks it up. When the env var is unset the module is skipped (default empty string). Thepostprovision.shself-heal call covers the case where operators forget to set it. - RBAC propagation typically takes 1-5 min after
az role assignment create. If the SPA's "Deploy elb-openapi" still returnsAuthorizationFailedimmediately after the grant, retry after ~2 min.