Permission gate recognises inherited and group-granted roles¶
Motivation¶
The SPA disables Start/Stop/Delete/Submit/Build buttons and shows a tooltip such as:
You do not have permission to delete this cluster. You hold: no Azure RBAC role at this scope. You need: Owner or Azure Kubernetes Service RBAC Cluster Admin.
This is driven by GET /api/me/permissions
(api/services/me_permissions.py),
which enumerates the caller's role assignments and matches them against the
target scope. It is a UX affordance, not a security boundary — ARM still
enforces real authorization at submit time.
A subscription Owner in another tenant was shown the "no role" tooltip and
had Delete disabled even though they could delete the cluster. Reasoning from
the code (the affected tenant could not be inspected directly) surfaced two
independent defects, either of which reproduces the exact symptom
(degraded=false, zero matched roles):
- Group-granted roles were invisible. The enumeration filtered with
principalId eq '<oid>', which only matches assignments whose principal is the caller's own object id. An Owner granted through an Entra security group (the assignment's principal is the group's object id) produced zero rows. - Management-group / tenant-root inheritance was dropped. Azure inherits
roles down
tenant root "/" > management group > subscription > rg > resource. The ancestor check_is_ancestor_or_equalused a plainstartswithprefix test, so an assignment at/providers/Microsoft.Management/managementGroups/<id>(not a string prefix of/subscriptions/<sub>/...) and the tenant root/(collapsed to an empty string byrstrip('/')and then rejected by the empty-guard) were both discarded. The tenant's global admins — typically Owners at the management-group or tenant-root tier — fell straight into the "no role" branch.
User-facing change¶
- A subscription Owner (or any RBAC role) granted via an Entra group, a management group, or the tenant root now sees the correct enabled buttons and a tooltip listing the role they actually hold.
- No change for users who already held the role directly at the subscription / resource-group / resource scope — they were unaffected and stay unaffected.
- Still a UX affordance only: enumeration failures continue to degrade open
(
degraded=true, all capabilitiestrue) and ARM remains the real enforcement point at submit time.
API / IaC diff summary¶
- api/services/me_permissions.py
_enumerate_role_assignmentsnow filters withassignedTo('<oid>')instead ofprincipalId eq '<oid>'.assignedTo()is the filter the Azure CLI--include-groupsuses; it expands transitive group membership and returns direct + inherited assignments. The UUID guard oncaller_oidis unchanged, so the OData interpolation stays injection-safe._is_ancestor_or_equalnow accepts a tenant-root (/) assignment as an ancestor of any target, and a management-group-scope assignment as an ancestor of any subscription-or-below target. The enumeration is already subscription-scoped (list_for_subscription), so only management groups the subscription actually inherits are returned — no management-group hierarchy walk is needed.- api/services/access_review.py — the
"View my access" per-resource-group panel shares the same enumeration. It
already filtered with
assignedTo()and already accepted management-group scopes, but its_is_ancestor_or_equaldropped the tenant-root/scope (therstrip('/')empty-string gap). Fixed for parity so a tenant-root Owner is shown in the panel too. - Response schema is unchanged (same
CallerPermissionsfields), so the SPAusePermissionshook andPermissionGatecomponent need no changes. - No IaC change.
Validation evidence¶
uv run pytest -q api/tests/test_me_permissions.py— 15 passed, including the new cases:test_enumeration_uses_assigned_to_filter_for_group_transitivitytest_group_inherited_reader_is_recognizedtest_owner_inherited_from_management_group_grants_deletetest_owner_inherited_from_tenant_root_grants_deleteuv run pytest -q api/tests/test_access_review.py— includes the newtest_tenant_root_assignment_inherited.uv run pytest -q api/tests/test_me_permissions.py api/tests/test_access_review.py api/tests/test_me_route.py api/tests/test_persona_matrix.py— 76 passed (no persona-matrix regression).uv run ruff check api/services/me_permissions.py api/services/access_review.py api/tests/test_me_permissions.py api/tests/test_access_review.py— clean.- Real-tenant cross-check (
az restagainst the maintainer's own subscription): theassignedTo('<oid>')filter returns the tenant-root (/) and management-group Owner assignments thatprincipalId eqalso returned, confirming the filter is a superset and the ancestor logic now matches them.