2026-05-26 — Bootstrap-mode RBAC for fresh-subscription cluster creation¶
Motivation¶
After commit bc0fcf1 (2026-05-23, "fix(aks): ensure resource group exists
before AKS provisioning") the AKS provision Celery task calls
rc.resource_groups.create_or_update(<cluster_rg>, ...) as its first ARM
action under the dashboard MI. The MI has only Reader at subscription
scope (intentional, per infra/modules/subscriptionRoles.bicep), so on a
fresh subscription where rg-elb-cluster does not exist yet the user hits:
(AuthorizationFailed) The client '…' with object id '<oid>' does not have
authorization to perform action 'Microsoft.Resources/subscriptions/
resourcegroups/write' over scope '/subscriptions/<sub>/resourcegroups/
rg-elb-cluster' or the scope is invalid.
cli-upgrade.sh full did not surface or fix this gap because its sole RBAC
preflight grant-runtime-rbac.sh:
- silently returned
0with"no AKS cluster found in subscription; skipping (nothing to grant)"when no AKS existed yet (= every first-time deploy); died when--cluster-rgreferenced a not-yet-existing RG; and- only ever granted at existing AKS RG scope, never the future cluster RG.
So a user that did git fetch && git checkout main && cli-upgrade.sh full
--yes and then clicked Create Cluster in the SPA got an actionable-
looking but dead-end error card (the CTA was a generic Microsoft Learn
link). This change closes the gap end-to-end without escalating the MI to
subscription-scope Contributor.
User-facing change¶
grant-runtime-rbac.shgains a bootstrap mode:--region <region>(alias--location) — when--cluster-rgrefers to a non-existent RG, the script creates it in<region>and then grantsContributor+User Access Administratoron that RG.- When AKS list is empty and no
--cluster-rgwas passed, the script no longer silent-skips. It prints the exactbash scripts/dev/ grant-runtime-rbac.sh --cluster-rg … --region … --yesinvocation that will pre-create the RG and grant the roles, then exits0. - When
--cluster-rgrefers to a missing RG and no--regionwas passed, the script dies with exit3and prints the corrective one-liner — instead of the old generic"AKS cluster RG '<x>' not found"die. - Help text and usage block expanded;
usage()slice range updated. cli-upgrade.shdetects the "no AKS cluster" silent-skip branch fromgrant-runtime-rbac.shoutput and re-prints a clear First-time- cluster-create RBAC note at the very end of a successful upgrade, with the same actionable command. The note is suppressed when the preflight grant was satisfied.armErrorClassifier.tsrg_permissionbranch:- parses
with object id '<oid>'andover scope '/subscriptions/ <sub>/resource[gG]roups/<rg>'out of the raw ARM error; - emits a new
command-kind action containing the fully-bakedaz role assignment create … --role Contributor --scope …/<rg>snippet (plus an optionalaz group createstep with a<region>placeholder); - keeps the docs link, but renames it to "Grant Contributor role (docs)" so the primary CTA is the copy-command button.
ProvisionErrorCard.tsxrenderscommand-kind actions as a clipboard-copy button (navigator.clipboard.writeTextwith anexecCommand("copy")fallback) and shows a transient"Copied!"label so the operator gets immediate feedback. Tooltips expose the full command on hover.
API / IaC diff summary¶
| File | Change |
|---|---|
scripts/dev/grant-runtime-rbac.sh |
Add --region / --location flag, bootstrap-mode az group create branch, actionable AKS-0 hint, updated usage block, usage() slice range. |
scripts/dev/cli-upgrade.sh |
Init NEEDS_BOOTSTRAP_NOTE=0; capture preflight output and detect the AKS-0 branch; new emit_bootstrap_note_if_needed called at success exit. |
web/src/components/cards/ClusterCard/armErrorClassifier.ts |
Extend ArmErrorAction.kind with "command"; new parseAuthFailure + buildGrantContributorCommand helpers; rg_permission branch rewritten to include the concrete command action. |
web/src/components/cards/ClusterCard/ProvisionErrorCard.tsx |
Import Check/Copy/useState; new CommandActionButton (clipboard copy with fallback); actions.map switches on kind === "command". |
web/src/components/cards/ClusterCard/armErrorClassifier.test.ts |
New test case pinning the concrete-command output for the canonical fresh-subscription error message; the existing 5 cases still pass unchanged. |
| No Bicep changes. | Subscription-scope RBAC stays at Reader-only by design (least privilege). |
Validation evidence¶
bash -n scripts/dev/grant-runtime-rbac.sh && bash scripts/dev/ grant-runtime-rbac.sh --help— usage block renders the new bootstrap examples.bash -n scripts/dev/cli-upgrade.sh— syntax OK.cd web && npx vitest run src/components/cards/ClusterCard/ armErrorClassifier.test.ts→ 6 / 6 passed (5 existing + 1 new).cd web && npx eslint src/components/cards/ClusterCard/{armErrorClassifier.ts, ProvisionErrorCard.tsx,armErrorClassifier.test.ts}→ clean.uv run pytest -q api/tests/test_azure_provision_aks.py→ 7 / 7 passed (regression guard on therg.create_or_update → aks.begin_create_or_updateordering introduced bybc0fcf1; unchanged by this PR).
Out of scope¶
- No change to Bicep RBAC. Subscription-scope Contributor for the MI was considered and rejected — it would let any compromised api / worker sidecar create arbitrary resources across the whole subscription.
- No automation of
--regionselection insidecli-upgrade.sh. The operator picks the region; the script refuses to guess. - The SPA still does not run shell commands directly — the rg_permission
CTA copies the command to clipboard and the user runs it from their
own terminal with their own
az login(which is, in the recurring case, an Owner / UAA holder who can grant the role).