AKS provision RBAC hardening + progress visibility¶
Motivation¶
Audit of the provision flow uncovered five gaps in the ensuring_rbac step
that the dashboard runs after AKS managed_clusters.begin_create_or_update
returns:
- Silent runtime-RBAC failure.
ensure_aks_runtime_rbacrecordedAcrPull/Storage Blob Data Contributorfailures intoroles_failedand theprovision_akstask still finished ascompleted. The cluster then shipped to the SPA as "Cluster ready", but the kubelet identity silently lacked AcrPull (ImagePullBackOff at first BLAST submit) or Storage Blob Data Contributor (AuthorizationPermissionMismatch on first data-plane call). - Preflight did not verify User Access Administrator (UAA). The
provision task itself needs UAA on the ACR / Storage scopes to assign
the kubelet identity its runtime roles. The preflight checked only
Contributoron the cluster RG (formanagedClusters/write). - Duplicate
managed_clusters.getround trips.attach_acrandgrant_storage_blob_contributor_to_akseach fetched the cluster to readkubelet_oid, adding ~2-3 s of avoidable latency to the step. - No retry on Entra ID propagation. The freshly-minted kubelet
identity occasionally hits
PrincipalNotFoundfrom the Authorization service for the first few seconds; the assignment failed instead of waiting for the standard ~30-60 s propagation window. - No sub-phase progress. The UI banner sat on "Granting role assignments" with the same elapsed timer for the entire RBAC step, making it indistinguishable from a hang.
User-facing change¶
- The provision task now fails fast when any kubelet role assignment
fails. The cluster card surfaces the canonical error card with the role
names, the assigned-so-far list, and the remediation pointer (run
/api/aks/assign-rolesafter granting UAA on the right scope) instead of "Cluster ready" hiding a broken cluster. - The
+ Add Clusterpreflight modal gains a new Runtime RBAC row that reportsok/warnfor "User Access Administrator on AcrPull / Storage Blob Data Contributor target scopes".warndoes not block submit (Azure RBAC is still the ground truth and we may not see a covering grant), but it tells the operator up-front when theensuring_rbacstep is going to fail. - The provisioning banner shows sub-phase labels during the RBAC step
(
Granting AcrPull to AKS kubelet on <acr>/Granting Storage Blob Data Contributor on <storage>) under the same "Step ⅘" indicator, so the user sees the role currently being granted instead of a blank pause. - Transient
PrincipalNotFoundduring the kubelet-identity propagation window is now absorbed by an exponential-backoff retry (capped at 60 s total, 10 s max delay). - The standalone
/api/aks/{cluster}/assign-rolestask (Re-assign roles affordance) also surfaces partial failures as taskFAILUREinstead of returningstatus: completedwith a quietroles_failed[].
API / IaC diff summary¶
- api/tasks/azure/rbac.py
- New
_resolve_kubelet_oidhelper — single clustergetcall shared by the two role-assignment helpers. - New
_create_role_assignment_with_retryhelper — idempotency (RoleAssignmentExists/Conflict→ success) plus exponential backoff retry onPrincipalNotFound/ "does not exist in the directory" with a 60 s deadline. attach_acr/grant_storage_blob_contributor_to_aksnow accept an optional keyword-onlykubelet_oidso the caller can pass the pre-resolved value; fall back to the legacy lookup when omitted (preserves the standalone-call signature).ensure_aks_runtime_rbacresolveskubelet_oidonce, threads it through both helpers, and accepts aprogress_callback(phase, msg)so the provision task can publish sub-phase progress without coupling the helper to Celery internals. The old non-fatal failure path is intentional — the caller is now expected to fail fast on a non-emptyroles_failed.assign_aks_rolesCelery task raisesRuntimeErrorwhenroles_failedis non-empty so the SPA polling/api/tasks/{id}seesFAILUREinstead of "completed".- api/tasks/azure/provision.py
- New
_RBAC_SUB_PHASESdict;_publishresolves sub-phase names to the parentensuring_rbacstep number so they render under "Step ⅘" in the banner. - Provision-task RBAC site now passes
progress_callback=_rbac_progressinto_ensure_aks_runtime_rbac. A non-emptyroles_failedemits a_publish("failed", ...)and raisesRuntimeErrorso Celery marks the taskFAILURE(replaces the oldrbac_ensure_failed_nonfatalnon-fatal advisory). - api/services/rbac_preflight.py
- Added
_ROLE_USER_ACCESS_ADMINISTRATORand_ROLE_ASSIGNMENT_WRITE_ROLESconstants. - New
aks_runtime_rbac_check(...)function — verifies UAA (or Owner) covers every requested runtime-RBAC target scope. Emits arbac_runtimepreflight row (statusok/warn, neverfail), with a copy-pasteableaz role assignment createremediation indetails.missing[]. - api/services/aks_availability.py
run_provision_preflighttakes new optionalacr_*/storage_*kwargs and appends the runtime RBAC row to the existingchecks[].- api/routes/aks/preflight.py
- Route forwards
acr_resource_group,acr_name,storage_resource_group,storage_accountfrom the request body to the preflight service. - web/src/api/aks.ts
AksPreflightRequestgains optionalacr_*/storage_*fields.- web/src/components/cards/ClusterCard/useClusterProvisioning.ts
- Both preflight call sites (debounced auto-preflight and the explicit Create click) include ACR / Storage targets.
- web/src/components/cards/ClusterCard/ProvisioningBanner.tsx
PHASE_LABELSadds entries forensuring_rbac_acr/ensuring_rbac_storage.
Validation¶
uv run pytest -q api/tests— 1517 passed, no regressions (coverstest_azure_tasks.py::test_ensure_aks_runtime_rbac_*+ 6 new tests for sub-phase publish, narrowed-TypeError fallback,PrincipalNotFoundretry, idempotent conflict, andassign_aks_rolestask fail-fast;test_rbac_preflight.py+ 6 new tests foraks_runtime_rbac_checkincluding the prefix-match regression;test_azure_provision_aks.py,test_aks_availability.py::test_run_provision_preflight_*,test_warmup_route.py).uv run ruff check api— All checks passed.cd web && npm run build— clean build.
Self-review fixes (post-implementation)¶
target_scope.startswith(grant_scope)path-segment bug — a UAA grant at/...resourceGroups/rgwas matching a target inside/...resourceGroups/rg-acr/...(two unrelated RGs share a string prefix). Replaced with strict equality ORtarget == grant + "/..."path-segment check. Regression test:test_runtime_rbac_prefix_match_does_not_false_positive.- Broad
except TypeErrorfallback inensure_aks_runtime_rbac— was retrying ANY TypeError as if it were a legacy-signature mismatch, masking genuine bugs insideattach_acr/grant_storage_*. Refactored into_call_with_optional_kubelet_oidhelper that only falls back when the message contains"kubelet_oid". Regression test:test_ensure_aks_runtime_rbac_does_not_swallow_internal_typeerror. - Case-inconsistency in
_is_principal_propagation_error— mixed case-sensitive ("principalId" in msg) and case-insensitive checks. Normalised to lowercase-once-then-substring-check. No behaviour regression test needed since the function was simplified.
Out of scope¶
- No Bicep change. Existing deployments that already grant the dashboard
UAMI UAA at the dashboard RG (via
controlPlaneRoles.bicep) and at the workload cluster RG (viaworkloadClusterRoles.bicep) continue to work; the newrbac_runtimerow staysokfor them. start_aksand itsauto_openapifollow-on are unchanged — they enqueueassign_aks_roles, which now propagates failure as CeleryFAILUREautomatically.