Skip to content

sec(iac): scope Azure purchase role to onboarded subscription - #1658

Merged
cristim merged 10 commits into
mainfrom
sec/1545-arm-role-scope
Aug 4, 2026
Merged

cristim merged 10 commits into
mainfrom
sec/1545-arm-role-scope

Conversation

@cristim

@cristim cristim commented Jul 28, 2026 •

Copy link
Copy Markdown
Member

Problem

arm/CUDly-CrossSubscription/template.json is deployed by customers into their own Azure tenants. Alongside the intended subscription-scope assignment it declared two grants that reached past the subscription being onboarded:

  1. A second roleAssignment with "scope": "/providers/Microsoft.Capacity" — as an absolute path that denotes tenant-wide reservation orders.
  2. /providers/Microsoft.Capacity in the custom role definition's assignableScopes — declaring the role eligible for assignment at tenant scope by anyone who can create role assignments there.

Verified live on current origin/main (887d51fd6), at template.json:64-67 and :86-100.

What the grant actually did

Worth recording, because it is narrower than the template reads. Running az deployment sub validate on the pre-fix template resolves the tenant assignment to:

/subscriptions/<subId>/providers/providers/Microsoft.Capacity/providers/Microsoft.Authorization/roleAssignments/<guid>

Note the doubled providers/providers. In a subscription-scoped deployment ARM appends the scope value beneath the subscription rather than treating it as absolute, so the likely apply-time outcome is a malformed target rather than a live tenant-wide grant. That has not been confirmed either way without applying to a real tenant, so this treats the grant as possibly live and tells operators to verify and revoke. Item (2) is a real widening regardless of how (1) resolved.

Before / after

Before After
assignableScopes /subscriptions/{id} + /providers/Microsoft.Capacity /subscriptions/{id} only
role assignments 3 subscription-scoped + 1 at /providers/Microsoft.Capacity 3 subscription-scoped

In words: the service principal was declared able to act on reservation orders across the whole Azure AD tenant, including subscriptions the customer never onboarded; it can now act only on the single subscription that az deployment sub create targeted.

Is a tenant-level grant genuinely required? No.

Evidence, all in-repo:

  • iac/federation/azure-target/terraform/main.tf:77-83 — the production customer onboarding path assigns only at /subscriptions/${local.subscription_id} and works.
  • terraform/modules/iam/azure/cudly-reservation-role/main.tf:58-71 — documents the tenant-root scope as deliberately removed, with include_capacity_provider_scope defaulting to false.
  • known-issues.md records the origin: the tenant assignment was a workaround for the built-in Reservation Reader role not existing in every tenant. The custom role added by Azure ARM template grants role missing purchase/calculatePrice actions -- all reservation purchases 403 #731 removed that need; the assignment was left behind.
  • Azure authorises a reservation purchase against the subscription named in the request body's billingScopeId, not against the tenant-level Microsoft.Capacity path.

So this was drift, not a requirement. Rather than adding an opt-in flag to the template, a tenant-wide grant is documented in known-issues.md as a separate, manual, explicitly consented az role assignment create step — never a default.

Fail-closed

There is no scope parameter at all. Every scope derives from subscription().subscriptionId, the deployment target itself. There is no caller-supplied scope string to omit, widen, or inject, so there is no validation to bypass, which is strictly stronger than validating a scope parameter. The guard now enforces exactly this, rather than merely checking the string looks subscription-shaped (see Regression guard).

Regression guard

scripts/check-azure-role-parity.sh compared only the actions lists, which is exactly why this drift stayed green in CI: both files agreed on actions while disagreeing on scope. It now also asserts scope.

Review of the first version of that guard found eight reproducible ways to reintroduce the tenant-wide grant while keeping it green. All are fixed in the second commit. This is a CI drift guard, not a security boundary (anyone who can edit the template can edit the script), so it is tuned against accidental reintroduction:

# Bypass Fix
1 /providers/microsoft.capacity lowercased — Azure namespaces are case-insensitive, bash =~ is not match under shopt -s nocasematch
2 assignment hidden in a nested Microsoft.Resources/deployments, or nested in a parent resource, or type in different casing recursive .. walk, ascii_downcase on type, and refuse nested deployments outright
3 variables.tf absent — the default-false assertion silently skipped referenced flag whose default cannot be read is now an error
4 [concat('/subscriptions/', parameters('otherSubscriptionId'))] — passes a substring anchor, grants in another subscription exact-match allowlist
5 $schema repointed at the management-group template, widening every scope-less assignment without touching a scope string $schema pinned to subscriptionDeploymentTemplate

Correcting a claim in the first version of this PR. It said the guard was "allowlist-anchored (accepted only if it visibly contains /subscriptions/)". That is what the code did, and it is much weaker than it reads: case 4 above satisfies it, in a template named CrossSubscription. The allowlist is now genuinely exact-match — only the canonical expression bound to the deployment target, or a bare literal /subscriptions/<guid> for fixtures — and role assignments must carry no explicit scope at all. The token denylist is retained as defence in depth, and the in-code comment no longer overstates what is enforced.

Self-tests go from 2 cases to 9, one per bypass. Each was checked to fail for its own reason rather than incidentally:

PASS: matching lists exit 0                     PASS: other-subscription ARM exits 1
PASS: drifted ARM exits 1                       PASS: nested-deployment ARM exits 1
PASS: tenant-scope ARM exits 1                  PASS: management-group schema ARM exits 1
PASS: obfuscated tenant-scope ARM exits 1       PASS: TF flag without variables.tf exits 1
PASS: lowercase tenant-scope ARM exits 1
Results: 9 passed, 0 failed.

No workflow file was touched: the existing azure-role-parity CI job already runs both scripts.

Regression guard, round 2

A second, independent adversarial review defeated the guard above three more ways, despite its 9-case suite passing and the guard passing on the real files:

# Bypass Fix
F1 A bare literal /subscriptions/<guid> was accepted by GUID shape alone (never by value), so a template hard-coding a foreign subscription passed — including in the deployable shape (canonical expression retained, foreign literal appended to the same array). nocasematch was also live across that regex, so an uppercase GUID passed too. Literals are no longer accepted at all, of any case. Only the canonical ARM expression, or its [subscription().id] equivalent, is accepted.
F3 The actions extractor matched only the top-level .resources[] array with a case-sensitive ==, while the scope walk was recursive and case-insensitive. A second role definition typed microsoft.authorization/roleDefinitions (lowercase) granting actions: ["*"] was invisible to the actions axis — the guard printed OK: ARM and TF actions lists match and correctly counted the extra role on the scope axis, proving the two axes disagreed about how many role definitions existed. Actions extraction now uses the same recursive, case-insensitive selector as the scope walk, unioned across every matched role definition.
F4 Only permissions[0].actions was compared. ARM unions every permissions[] entry, so a second entry appended after the canonical one (e.g. {"actions": ["*"]}) passed. notActions, dataActions, and notDataActions were never compared at all — a first entry with dataActions: ["*"] also passed. permissions[].actions[] is now flattened across the whole array, and notActions/dataActions/notDataActions are compared the same way actions is.
F2 The scope comparison was byte-exact, so whitespace, quote style (" vs '), a redundant empty-string concat argument, and the [subscription().id] equivalent all failed despite being semantically identical to the canonical expression. A pure reformat could red CI. Both sides are normalized (whitespace stripped, quote style unified, redundant empty-string arg dropped) before comparing; the accepted spellings are printed in the error message.
F5 Microsoft.Resources/deploymentScripts and Microsoft.Resources/deploymentStacks were not refused, unlike plain deployments — despite a deployment script's runtime az/az-cli commands being able to issue role assignments this check never sees as JSON. Both resource types are now refused alongside deployments.
F6 A role assignment's roleDefinitionId was unconstrained as long as it carried no explicit scope: a built-in Owner grant correctly inheriting the (subscription-scoped) deployment passed, since only the scope axis was checked. roleDefinitionId is now allowlisted to the three roles this template ever assigns (the custom purchaser, Reader, Cost Management Reader).

Correcting a claim in the previous round of this PR. The code comment above LITERAL_SUBSCRIPTION_RE (and this PR's own round-1 description, item 4's fix column) said a bare literal /subscriptions/<guid> was accepted "for the test fixtures". That justification was false and was disproved directly: the fixtures were rewritten to use the canonical expression instead, the literal-acceptance branch was deleted, and the full self-test suite still passes. The literal was never structurally required by anything; accepting it just weakened the guard to GUID shape, not GUID value — the exact hole F1 exploits.

Self-tests go from 9 cases to 18, one per bypass across both rounds (nine round-2 cases: two for F1, one for F2, one for F3, two for F4, two for F5, one for F6). Each was reproduced against the pre-fix revision (fc1f5b782) first — confirmed exit 0 (or, for F2, a false-positive exit 1) — and confirmed to close (correct exit code) after the fix:

Results: 18 passed, 0 failed.

shellcheck on both scripts: exit 0 (no findings). bash -n on both scripts: exit 0. Verified against the real arm/CUDly-CrossSubscription/template.json and terraform/modules/iam/azure/cudly-reservation-role/main.tf: still exit 0.

One item deliberately left alone: several malformed-input self-test variants exit 5 (a jq error surfacing through set -e) rather than 1. Still fail-closed, not a defect, and out of scope for this round — normalizing exit codes for that path was explicitly not attempted here.

Regression guard, self-review before round 3

Before handing round 2 to another reviewer, self-checked the two highest-risk things it added — a normalizer (which exists to make different strings equal) and a newly-recursive JSON walk (which exists to see more of the template than before). Found one real issue in each, both fixed here with their own self-test case:

  • normalize_scope_expr() blanket-stripped all whitespace, including inside the /subscriptions/ string literal itself, so [concat('/sub scriptions/', subscription().subscriptionId)] (a typo, not an attack) normalized to the same text as the real canonical scope and was wrongly accepted. Not exploitable for escalation — the corrupted literal fails to deploy rather than pointing anywhere else — but a normalizer that can't tell "reformatted" from "corrupted" is the wrong shape of tool. Whitespace is now collapsed only where it is immediately adjacent to structural punctuation ([ ] ( ) ,), which by construction cannot reach inside a string literal's content. Verified against every legitimate spelling from round 2 (still accepted) plus a battery of adversarial inputs — an extra trailing parameters('evil') argument after the empty-string arg, a managementGroup().id suffix, a foreign literal — none of which normalize to canonical.
  • The actions extractor, the nested-deployment refusal, and the scope walk all used an unrooted .. over the whole document. A decorative object under variables or outputs that merely happened to carry a roleDefinitions-shaped "type" field — never actually deployed — was treated as a real grant, reproduced directly: a variables.decorativeDocOnly object granting actions: ["*"] at /providers/Microsoft.Capacity caused an otherwise-clean, fully-matching template to fail CI. That can only make the guard fail closed on a correct template, never accept a bad one, but a guard that reds valid input is exactly the pressure that gets a guard deleted or bypassed — which is how the original tenant-wide grant went unnoticed for as long as it did. All three walks are now rooted at .resources, which still finds a resource nested inside another resource's own resources array without ever leaving the tree of things ARM actually deploys.

Also confirmed on request: ALLOWED_ROLE_DEFINITION_IDS's Reader and Cost Management Reader entries are not fixture-driven — the real template.json genuinely assigns both (lines 91 and 103, roleDefinitionId: [variables('roles').reader] / [variables('roles').costManagementReader]) alongside the custom purchaser role, matching exactly the "three roles CUDly assigns" the allowlist claims. The comparison is confirmed exact (a probe battery of prefix/suffix/substring variants of the allowed values all correctly fail); it recognizes only the one raw-expression spelling this template actually uses, so a reformat to a bare GUID or a resourceId()/concat() expression would be rejected rather than silently accepted — a drift-guard tradeoff already called out in the code comment, not a gap.

Self-tests go from 18 to 20. Both reproduced against the prior revision (99ea68759) first — confirmed exit 0 (space-inside-literal, wrongly accepted) and exit 1 (decorative-variables, wrongly rejected) respectively — and confirmed fixed after:

Results: 20 passed, 0 failed.

Regression guard, round 4 (independent review)

Independent review of round 3 (76abcc853) found 8 hostile templates that exit 0. Two fixes close six of them cheaply; the remaining two are filed as follow-up rather than fixed here.

# Bypass Fix
Fix A (4 findings) jq property-key access is case-sensitive; ARM's resource-provider JSON deserializers are documented case-insensitive. .type (a value) was already downcased for exactly this reason, but that reasoning never reached the property keys read from the same JSON. Confirmed live: a roleAssignment with "Scope" (capital S) set to /providers/Microsoft.Capacity — issue #1545 byte-for-byte apart from one capital letter — passed, along with "RoleDefinitionId" on an Owner grant, "Properties" wrapping an Owner grant, and "AssignableScopes" on a second role definition. Each made the node invisible to the check that key feeds — silence, not refusal. Every object key in the ARM document is lowercased once into a scratch copy before any query runs; every jq field access naming a non-lowercase ARM property (roleDefinitionId, assignableScopes, notActions, dataActions, notDataActions) rewritten to match.
Fix B (2 findings) Microsoft.Authorization/roleEligibilityScheduleRequests and roleAssignmentScheduleRequests (Azure PIM) genuinely grant a role the same way a plain roleAssignment does — confirmed by constructing one binding built-in Owner — but under a property shape the roleAssignments-only type match never saw, bypassing this PR's own roleDefinitionId allowlist by changing the resource type. Also added: the legacy Microsoft.Storage/storageAccounts/providers/roleAssignments child-type spelling, invisible for the same reason. All three types refused outright, alongside the existing nested-deployment/deploymentScripts/deploymentStacks refusal.
Fix C The code comment claimed this check "errs towards refusing anything it cannot reason about" — false for any resource type outside the two lists it recognizes/refuses (which is the entirety of the finding set above). Rewritten to say what the guard actually does: a fixed recognize-list plus a fixed refuse-list, silent on everything else.

Caveat recorded honestly, per reviewer request: whether ARM itself accepts a miscased property key is Azure-side behaviour that was not independently verified against a live subscription — that would require deploying a miscased template to a real tenant, which was not done. The resource-provider JSON deserializers are documented as case-insensitive by default, which is the basis for fixing this fail-closed regardless: if ARM does accept it, this closes a real hole; if not, the guard is merely redundant with a deploy-time rejection, never wrong.

Filed rather than fixed, per reviewer guidance (the template diff in this PR is the real #1545 remediation; these are separate defense-in-depth guard gaps and shouldn't hold up the fix): #1681, covering (1) the guard constrains which role and where, but never who — a roleAssignment binding the allowed custom role at the correct scope to a hardcoded foreign principalId passes cleanly, money-spending permission handed to an arbitrary principal; and (2) the systemic fix — assert the template's exact expected set of grant tuples (4, today) rather than enumerating everything to refuse, which would close every finding above and future ones in one stroke. Also noted there as low severity: assignableScopes given as a bare string exits 5 via a raw jq error rather than a clean 1 (still fails closed).

Self-tests go from 20 to 27 (seven round-4 cases: four for Fix A, three for Fix B). Each reproduced against the prior revision (76abcc853) first — confirmed exit 0 — and confirmed fixed after:

Results: 27 passed, 0 failed.

shellcheck on both scripts: exit 0. bash -n: exit 0. Verified against the real template and TF module: still exit 0.

Live-Azure confirmation (round 4's Fix A premise): an independent reviewer ran az deployment sub what-if (validate/what-if only, nothing deployed) against a template carrying "Scope" (capital S) on a roleAssignment. ARM resolved it to /providers/Microsoft.Capacity — the miscased key was honoured, not ignored, confirming this is a live Azure behaviour and not a theoretical jq artifact. "Properties" and "AssignableScopes" capitalized were also confirmed to produce plan output byte-identical to the correctly-cased control, meaning nothing warns anyone at plan time either. Fix A closes all of these (see below for confirmation the has() trap — downcasing only the extraction expressions while leaving has("scope")/has("roleDefinitionId") case-sensitive, which would look fixed while staying open — was avoided: the whole document is downcased once via walk() before any query, including the two has() calls, so there was never a partial-fix window).

Regression guard, round 5 (explicit case-folding)

Two comparisons were already case-insensitive in practice, but only as a side effect of running inside the shopt -s nocasematch scope set for the (unrelated, already-documented) ESCAPE_TOKENS regex match: the canonical-scope comparison in normalize_scope_expr, and the ALLOWED_ROLE_DEFINITION_IDS allowlist comparison. Confirmed empirically that this dependency was real (a probe with shopt -u nocasematch correctly stopped matching). That's fragile — moving either comparison outside that scope, or narrowing what nocasematch covers, would silently drop the case-insensitivity with no visible change to the comparison line itself.

Both now fold case explicitly instead: normalize_scope_expr lowercases as an explicit final step, and the roleDefinitionId loop lowercases both sides before comparing — matching the same "ARM identifiers are case-insensitive" reasoning already applied to resource type values via ascii_downcase elsewhere in the script. Safe unconditionally: lowercasing is a 1:1 character transform that can't collapse two distinct values into one, unlike the whitespace-stripping this same function already had to be careful about (round 3).

Self-tests go from 27 to 29: an uppercase-spelled canonical scope expression (CONCAT/SUBSCRIPTION/SUBSCRIPTIONID) and an uppercase-spelled allowed roleDefinitionId ([Variables('Roles').Reader]) are both confirmed still accepted (exit 0) — re-verified the full adversarial battery from round 3 (foreign literals, evil concat args, management-group escapes, whitespace-in-literal) still correctly rejected after adding the lowercasing step.

Results: 29 passed, 0 failed.

Regression guard, round 6 (key-collision bypass)

Independent adversarial review found a live bypass of round 4's key-casing
fix
, pre-existing on 99ea68759 (confirmed independently — the same four
fixtures below also exit 0 against that revision, not just the current head)
and not introduced by any change on this branch.

Round 4's fix lowercases every object key once
(with_entries(.key |= ascii_downcase)) so a correctly-recognized resource
with one miscased property key is no longer invisible. But from_entries
(which with_entries is built on) keeps the last entry when two entries
fold to the same key. An object that already carries both case-variant
spellings of a property in the same object — not one miscased key, but two —
collapses to whichever is spelled last, and the discarded value is deleted
before any selector runs, not merely unmatched. A template carrying the
hostile value first and a correctly-spelled, canonical-looking value second
passed clean:

# Bypass
1 "AssignableScopes": ["/providers/Microsoft.Capacity"] then "assignableScopes": [<canonical>] — issue #1545 itself, reached via key collision instead of a single miscased key.
2 "RoleDefinitionId": <built-in Owner> then "roleDefinitionId": "[variables('roles').reader]" on a roleAssignment's properties object.
3 "Properties": {Owner} then "properties": {Reader} — same shape, one level up, as the two top-level keys of a roleAssignment resource.
4 "Type": "Microsoft.Resources/deployments" then "type": "...roleAssignments" — also evades REFUSED_TYPES: with the Type/deployments evidence deleted, the resource looks like an ordinary, fully-compliant roleAssignment.

Order-dependence confirms the mechanism and rules out a coincidental read of
the finding: the same collision with the two keys reversed
(benign-first, evil-last) already exits 1 today, because the surviving
(evil) value trips the pre-existing tenant-scope check. Only evil-first was
invisible.

Fix: refuse, don't pick a winner. Detects any same-object case-variant
key collision on the raw file, before normalization, and refuses
outright. Which of the two spellings ARM itself honors for a duplicate
property in one JSON object is not verified here (that would require
deploying a miscased template to a live tenant) and is not something this
script can determine from the file alone — unlike the single-miscased-key
case round 4 fixed, where ARM's documented case-insensitive deserialization
justifies treating the miscased spelling as equivalent to the correct one.
If ARM takes the first entry, a hostile value ahead of a benign one is a
live escalation this script was reporting as clean. If ARM takes the last
entry the same way jq does, this script's prior behavior was
coincidentally right rather than correctly reasoned about — and either way,
a template that is ambiguous about its own grants must not be the thing
that decides whether CI is green. The error message says so explicitly,
rather than merely reporting the refusal.

Independent re-verification of all three claims (not taken on trust):

  • The bare collision-detection expression returns 0 on the real
    arm/CUDly-CrossSubscription/template.json and on every one of the 28
    pre-existing role-parity fixtures — no false positives.
  • It returns 1 on all five new fixtures below.
  • All four evil-first fixtures also exit 0 against 99ea68759 (checked out
    and run directly), confirming the bypass predates this branch entirely.

Five new fixtures, one per bypass shape above plus the benign-first control,
each confirmed to fail for its own reason via its printed reason line
(the error names exactly which two keys collided, e.g.
AssignableScopes / assignableScopes vs. Type / type) — not incidentally
alongside an unrelated check.

Self-tests go from 29 to 34:

Results: 34 passed, 0 failed.

shellcheck 0.11.0 on both scripts: exit 0 (no findings). bash -n on both
scripts: exit 0. Verified against the real template: still exit 0, still
OK: all 4 ARM grant scopes/roleDefinitionIds are subscription-anchored.

Sibling paths audited

Checked every path that generates, copies or embeds this grant. Only template.json was affected, so Closes is safe here:

Path Scope Status
iac/federation/azure-target/bicep/azure-wif.bicep + .arm.json subscription clean
iac/federation/azure-target/terraform/main.tf subscription clean
terraform/modules/compute/azure/container-apps/main.tf subscription clean
terraform/modules/iam/azure/cudly-reservation-role flag defaults false clean (now guarded)
internal/iacfiles/templates/azure-* no role grants clean
arm/CUDly-CrossSubscription/setup.sh deploys template, no extra grants clean
go:embed sets template not embedded n/a

Verification (exit codes)

Check Exit
az deployment sub validate (post-fix) 0, provisioningState: Succeeded, error: null — all 4 validated resources under /subscriptions/<subId>/
az deployment sub validate (pre-fix control) 0, but produced a 5th resource at the doubled-providers path
jq empty template.json 0
shellcheck (both scripts) 0
check-azure-role-parity.sh on fixed template 0
check-azure-role-parity.sh on pre-fix template 1 (fails on the real bug)
test-azure-role-parity.sh 0 — 4 passed, 0 failed

az bicep decompile on the pre-fix template also emitted BCP036: The property "scope" expected a value of type "resource | tenant" for the tenant assignment; that error is gone post-fix. Remaining BCP034 decompiler warnings on dependsOn are pre-existing and present in both.

Not verified: whether the pre-fix assignment ever resolved to a genuine tenant-scope grant at apply time. Establishing that requires actually applying the template to a live tenant, which was not done.

Action required for existing deployments

Customers who already deployed the earlier template are not fixed by this merge. ARM deployments are incremental, so removing the resource from the template does not revoke anything already created. They must:

  1. List assignments at or under /providers/Microsoft.Capacity for the CUDly SP, projecting the assignment id.
  2. Delete any found by --ids, before redeploying. Not by --scope: the CLI rejects the malformed doubled-providers shape as an invalid scope, which would leave an operator with a row that is listed but undeletable.
  3. Then redeploy the corrected template to narrow assignableScopes.

Order matters: Azure refuses to drop an assignable scope from a role definition while assignments still exist at it. Commands and ordering are in known-issues.md.

Severity

I'd rate this HIGH rather than CRITICAL, agreeing with the original reviewer. The escalation to CRITICAL assumed a silent tenant-wide grant; ARM in fact rewrites the scope subscription-relative.

Review strengthened this: az deployment sub validate does not preflight authorization, but az deployment sub what-if does, and it returns a hard AuthorizationFailed on exactly that resource over the malformed scope. So the doubled-providers path is not a validator artifact: ARM genuinely computes that target and preflight fails on it. Separately, per this repo's own variables.tf:12, declaring an assignable scope above your own subscription 403s for a subscription-scoped principal, so for a normal customer the pre-fix template most likely failed on its first resource. The realistic failure mode is "onboarding breaks", not "silent tenant-wide grant".

It stays high-priority: assignableScopes was genuinely widened, the outcome is unconfirmed for tenant-root-privileged deployers, and it is drift from a deliberately narrowed decision. The fix is identical under either rating. Flagging the judgement rather than relabelling the issue.

Follow-up filed: LeanerCloud/cloud-commitments-platform#143 (unused roleAssignmentGuidPrefix parameter, pre-existing, deliberately kept out of this p0 diff).

Closes #1545

Summary by CodeRabbit

  • Bug Fixes

    • Removed unintended tenant-wide capacity permissions from Azure deployments.
    • Restricted custom role scopes and assignments to the target subscription.
    • Blocked broader-scope and unsafe deployment paths.
  • Tests

    • Expanded validation for permission parity, scope handling, nested resources, wildcard permissions, and casing variations.
    • Added coverage for tenant, cross-subscription, management-group, and scope-escape scenarios.
  • Documentation

    • Added remediation guidance for previously created tenant-wide grants.
    • Documented verification, cleanup, deployment limitations, and resolution steps.

@cristim cristim added effort/s Hours impact/all-users Affects every user priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens triaged Item has been triaged type/security Security finding urgency/now Drop other things labels Jul 28, 2026
@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The ARM template removes tenant-wide capacity access. Documentation records remediation for prior grants. The parity checker validates four permission lists, subscription-only scopes, deployment structure, role bindings, and adversarial ARM templates.

Changes

Subscription Scope Security and Parity Validation

Layer / File(s) Summary
Remove tenant-capacity assignment
arm/CUDly-CrossSubscription/template.json, known-issues.md
Restricts the custom role to the target subscription, removes the tenant-capacity assignment, and documents cleanup of existing grants.
Expand permission parity extraction
scripts/check-azure-role-parity.sh
Normalizes and compares actions, not_actions, data_actions, and not_data_actions between Terraform and ARM.
Enforce deployment scope invariants
scripts/check-azure-role-parity.sh
Validates canonical subscription scopes, allowed role bindings, deployment resources, collected grants, and the Terraform capacity-scope default.
Cover scope escape regressions
scripts/test-azure-role-parity.sh, scripts/testdata/role-parity/*
Adds cases for tenant and foreign scopes, wildcard permissions, case variants, duplicate keys, nested resources, PIM resources, and unsupported deployment escapes.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TerraformModule
  participant ParityCheck
  participant ARMTemplate
  participant jq
  TerraformModule->>ParityCheck: Provide permission lists and scope settings
  ARMTemplate->>jq: Provide role definitions and assignments
  jq->>ParityCheck: Return normalized permissions and scopes
  ParityCheck->>ParityCheck: Compare permissions and validate deployment scope
  ParityCheck-->>TerraformModule: Report validation result
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes scoping Azure purchase-role access to the onboarded subscription.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sec/1545-arm-role-scope

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@known-issues.md`:
- Around line 8-17: Update the historical grant wording in known-issues.md at
lines 8-17 and 139-145 to remain conditional: state that existing deployments
may retain, or the assignment could have granted, tenant-wide reservation
access. Preserve the existing verify-and-revoke remediation guidance at both
sites.
- Around line 56-71: Update the cleanup instructions around `az role assignment
list` to query `/providers/Microsoft.Capacity` directly with `--scope`, so
tenant-level assignments are included. Remove the case-sensitive
`contains(scope, 'Microsoft.Capacity')` filter or replace it with an appropriate
path match. Preserve projecting assignment IDs and deleting listed assignments
via `az role assignment delete --ids` before redeployment.

In `@scripts/test-azure-role-parity.sh`:
- Around line 90-99: Update Case 9 in the test fixture setup to append an actual
Terraform HCL reference to include_capacity_provider_scope, rather than only a
comment. Ensure the generated main.tf contains configuration that the checker
parses as a reference while variables.tf remains absent, preserving the expected
exit status of 1.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f5cc167c-d78b-4cda-92eb-e458b22f6bf3

📥 Commits

Reviewing files that changed from the base of the PR and between 887d51f and 99ea687.

📒 Files selected for processing (21)
  • arm/CUDly-CrossSubscription/template.json
  • known-issues.md
  • scripts/check-azure-role-parity.sh
  • scripts/test-azure-role-parity.sh
  • scripts/testdata/role-parity/canonical-scope-variants-arm.json
  • scripts/testdata/role-parity/dataactions-wildcard-arm.json
  • scripts/testdata/role-parity/deploymentscript-scope-escape-arm.json
  • scripts/testdata/role-parity/deploymentstack-scope-escape-arm.json
  • scripts/testdata/role-parity/drifted-arm.json
  • scripts/testdata/role-parity/foreign-subscription-literal-with-canonical-arm.json
  • scripts/testdata/role-parity/lowercase-tenant-scope-arm.json
  • scripts/testdata/role-parity/lowercase-type-wildcard-actions-arm.json
  • scripts/testdata/role-parity/matching-arm.json
  • scripts/testdata/role-parity/mgmt-group-schema-arm.json
  • scripts/testdata/role-parity/nested-deployment-arm.json
  • scripts/testdata/role-parity/obfuscated-tenant-scope-arm.json
  • scripts/testdata/role-parity/other-subscription-arm.json
  • scripts/testdata/role-parity/second-permissions-entry-arm.json
  • scripts/testdata/role-parity/tenant-scope-arm.json
  • scripts/testdata/role-parity/unallowed-roledefinitionid-arm.json
  • scripts/testdata/role-parity/uppercase-guid-literal-arm.json

Comment thread known-issues.md
Comment thread known-issues.md
Comment thread scripts/test-azure-role-parity.sh
@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 58 minutes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
scripts/test-azure-role-parity.sh (1)

349-352: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Case 34 no longer proves order-dependence.

The collision check in scripts/check-azure-role-parity.sh runs before scope validation and refuses both key orderings. Case 34 now exits 1 at the collision check, not at the tenant-scope check described in the comment at lines 314-318. The control therefore asserts the same code path as Case 30.

Assert the failure reason, for example by matching the checker's stderr, or restate the comment so it does not claim the tenant-scope check is what fires.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test-azure-role-parity.sh` around lines 349 - 352, Update Case 34 in
run_case so it verifies the intended failure reason by matching the checker’s
stderr for the tenant-scope validation message, or revise its comment to
accurately describe the collision-check failure path; ensure it no longer claims
to prove order-dependence without asserting which check rejects the input.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/check-azure-role-parity.sh`:
- Around line 96-108: Update the jq collision scan in the COLLISION_DETAIL
assignment to begin at the root document object rather than only traversing
.resources, while preserving recursive inspection of nested objects and the
existing collision reporting. Ensure root-level case-variant pairs such as
Resources/resources and $Schema/$schema are detected before normalization.

---

Nitpick comments:
In `@scripts/test-azure-role-parity.sh`:
- Around line 349-352: Update Case 34 in run_case so it verifies the intended
failure reason by matching the checker’s stderr for the tenant-scope validation
message, or revise its comment to accurately describe the collision-check
failure path; ensure it no longer claims to prove order-dependence without
asserting which check rejects the input.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 12e83a2e-ff12-444d-bf9b-6978faf401d7

📥 Commits

Reviewing files that changed from the base of the PR and between 99ea687 and fe46a70.

📒 Files selected for processing (19)
  • known-issues.md
  • scripts/check-azure-role-parity.sh
  • scripts/test-azure-role-parity.sh
  • scripts/testdata/role-parity/collision-assignablescopes-benign-first-arm.json
  • scripts/testdata/role-parity/collision-assignablescopes-evil-first-arm.json
  • scripts/testdata/role-parity/collision-properties-evil-first-arm.json
  • scripts/testdata/role-parity/collision-roledefinitionid-evil-first-arm.json
  • scripts/testdata/role-parity/collision-type-evil-first-arm.json
  • scripts/testdata/role-parity/decorative-variables-arm.json
  • scripts/testdata/role-parity/legacy-child-roleassignment-owner-arm.json
  • scripts/testdata/role-parity/miscased-assignablescopes-arm.json
  • scripts/testdata/role-parity/miscased-properties-arm.json
  • scripts/testdata/role-parity/miscased-roledefinitionid-arm.json
  • scripts/testdata/role-parity/miscased-scope-arm.json
  • scripts/testdata/role-parity/pim-roleassignment-schedule-owner-arm.json
  • scripts/testdata/role-parity/pim-roleeligibility-owner-arm.json
  • scripts/testdata/role-parity/space-inside-literal-arm.json
  • scripts/testdata/role-parity/uppercase-allowed-roledefinitionid-arm.json
  • scripts/testdata/role-parity/uppercase-canonical-scope-arm.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • known-issues.md

Comment thread scripts/check-azure-role-parity.sh
cristim added 9 commits August 3, 2026 15:21
The ARM cross-subscription onboarding template granted the CUDly service
principal more than the customer consented to. Alongside the intended
subscription-scope assignment it declared:

  * a second roleAssignment with "scope": "/providers/Microsoft.Capacity",
    an absolute path denoting tenant-wide reservation orders; and
  * "/providers/Microsoft.Capacity" in the custom role definition's
    assignableScopes, declaring the role eligible for assignment at tenant
    scope by anyone able to create role assignments there.

Customers deploy this template into their own Azure tenants, so a customer
onboarding one non-production subscription appeared to be granting access
across every subscription in the tenant, including ones never onboarded.

What the assignment actually produced is narrower than it reads. Running
`az deployment sub validate` on the pre-fix template resolves it to
/subscriptions/<subId>/providers/providers/Microsoft.Capacity/... , with a
doubled "providers" segment: in a subscription-scoped deployment ARM appends
the scope beneath the subscription instead of treating it as absolute. The
likely apply-time outcome is a malformed target rather than a live tenant
grant. That has not been confirmed either way without applying to a real
tenant, so the grant is treated as possibly live. The assignableScopes entry
is a real widening regardless of how the assignment resolved.

Both are removed. Every scope in the template now derives from
subscription().subscriptionId, the deployment target itself, so there is no
scope parameter a caller can supply or widen and no default that can fall
back to a broader scope. This matches the paths that were already correct:
iac/federation/azure-target/terraform assigns only at subscription scope, and
terraform/modules/iam/azure/cudly-reservation-role defaults
include_capacity_provider_scope to false, documenting the tenant-root scope as
deliberately removed. The ARM path never picked that decision up.

Purchases are unaffected. Azure authorises a reservation purchase against the
subscription named in the request body's billingScopeId, not against the
tenant-level Microsoft.Capacity path, which is why the Terraform onboarding
path has always worked on subscription scope alone. The tenant assignment
originated as a workaround for the built-in Reservation Reader role not
existing in every tenant; the custom role added by issue #731 removed that
need, but the assignment was left behind.

EXISTING DEPLOYMENTS REMAIN AFFECTED UNTIL THEY ACT. ARM deployments are
incremental, so deleting the resource from the template does not revoke
anything already created. Operators who deployed the earlier template must
list assignments scoped at or under /providers/Microsoft.Capacity, delete any
found, and only then redeploy. The order matters: Azure refuses to drop an
assignable scope from a role definition while assignments still exist at it.
known-issues.md carries the commands and the ordering.

Regression guard: check-azure-role-parity.sh previously compared only the
actions lists, which is why this drift stayed green in CI. It now also asserts
that every ARM grant scope is subscription-anchored, and that the Terraform
opt-in flag still defaults to false. The scope check is allowlist-anchored
(a value must visibly contain /subscriptions/) plus a denylist for tokens
above the subscription, so it rejects an expression that mentions
/subscriptions/ while still expanding to tenant scope, not just the blunt
literal. Two fixtures and two self-test cases cover both shapes; the check
fails on the pre-fix template and passes on the fixed one.

Verified: az deployment sub validate exit 0, provisioningState Succeeded,
all four validated resources under /subscriptions/<subId>/ (pre-fix produced
a fifth at the doubled-providers path); jq parse, shellcheck, parity check
and self-tests all exit 0.

Closes #1545
Review of the #1545 guard found eight reproducible ways to reintroduce the
tenant-wide grant while keeping the check green. This is a CI drift guard
rather than a security boundary, since anyone able to edit the template can
edit the script, so the yardstick is accidental reintroduction. Fixed in that
order of value.

Case-insensitive matching. Azure provider namespaces are case-insensitive but
bash =~ is not, so "/providers/microsoft.capacity" is a fully functional
tenant scope that sailed through, and lowercasing alone defeated the existing
fixtures. Matching now runs under shopt -s nocasematch.

Recursive, case-insensitive resource extraction. The jq walk covered only the
top-level resources array and compared types exactly, so an assignment nested
in a parent resource, or typed in different casing, was invisible. It now
walks recursively with ascii_downcase on the type. A nested deployment is
refused outright: its inner template cannot be reasoned about here, and
assigning at a different scope from a subscription deployment is exactly what
a future author with a legitimate cross-scope need would reach for.

Exact-match allowlist. Accepting any value containing "/subscriptions/" was
much weaker than the comment claimed: "[concat('/subscriptions/',
parameters('otherSubscriptionId'))]" satisfied it while granting in a
subscription the customer never targeted, in a template named
CrossSubscription, as would an escape assembled in a variables block the
script never reads. Only the canonical expression bound to the deployment
target, or a bare literal /subscriptions/<guid> for the fixtures, is accepted
now. Role assignments must carry no explicit scope at all, which is the shape
the template actually needs. The token denylist stays as defence in depth and
the comment no longer overstates what is enforced.

Fail closed on a missing variables.tf. The default-false assertion was gated
on the file existing, so copying main.tf alone made the whole assertion vanish
silently. A referenced flag whose default cannot be read is now an error.

$schema pinned. Every assignment inherits the deployment scope, so repointing
$schema at the management-group template would land all of them at
management-group scope, covering every child subscription, without changing
one scope string. The invariant is now explicit rather than incidental.

Remediation runbook. Step 1 projected no id while step 2 deleted by
--scope /providers/Microsoft.Capacity, which the CLI rejects as an invalid
scope for the malformed doubled-providers shape, leaving an operator with a
row that is listed but undeletable. Step 1 now projects id and step 2 deletes
by --ids.

Self-tests go from 2 cases to 9, one per bypass, each asserted to fail for its
own reason rather than incidentally. Existing fixtures gain the $schema key
the pin now requires.

The template itself is unchanged: server-side review confirmed the narrowing
holds, with all four validated resources under /subscriptions/<id>/.

Refs #1545
Refs #1666
An independent adversarial review defeated the ARM/TF role parity guard
(scripts/check-azure-role-parity.sh) three ways despite its 9-case suite
passing:

- The scope allowlist accepted any literal /subscriptions/<guid> by GUID
  shape alone, never checking the value, so a foreign subscription
  hard-coded into the template passed. The "needed for the test fixtures"
  justification for that literal branch was false: the fixtures moved to
  the canonical scope expression and the suite still passes. Literals are
  no longer accepted at all.
- The actions extractor walked only the top-level resources array with a
  case-sensitive type match and only permissions[0], so a second role
  definition (typed case-differently) or a second permissions[] entry
  could grant unreviewed actions while the guard reported success.
  dataActions/notActions/notDataActions were never compared either.
- Nested Microsoft.Resources/deploymentScripts and deploymentStacks
  resources were not refused, unlike plain nested deployments, despite
  being able to issue role assignments the guard never sees as JSON.
- A role assignment's roleDefinitionId was unconstrained as long as it
  carried no explicit scope, letting a built-in Owner grant inherit the
  (correctly) subscription-scoped deployment.

Also fixes the scope comparison's byte-exact brittleness (whitespace,
quote style, and a redundant empty-string concat argument now normalize
to the same canonical form) and adds nine self-test cases, one per
bypass, each reproduced against the prior revision and confirmed closed.
Self-review of the round-2 fix (99ea687) surfaced two more issues before
an external reviewer could:

- normalize_scope_expr() blanket-stripped all whitespace, including inside
  the '/subscriptions/' string literal itself, so a typo like
  '/sub scriptions/' normalized to the same text as the real canonical
  scope. Not exploitable for escalation (the corrupted literal fails to
  deploy rather than resolving elsewhere), but a normalizer that can't
  distinguish "reformatted" from "corrupted" is the wrong shape of tool.
  Whitespace is now collapsed only where it is immediately adjacent to
  structural punctuation ([ ] ( ) ,), which by construction cannot reach
  inside a string literal's content.

- The actions extractor, the nested-deployment refusal, and the scope walk
  all used an unrooted `..` over the whole document, so a decorative object
  under `variables` or `outputs` that merely happened to carry a
  roleDefinitions-shaped "type" field -- never actually deployed -- was
  treated as a real grant. That can only make the guard fail closed on a
  correct template, not accept a bad one, but a guard that reds valid input
  is exactly the pressure that gets a guard deleted or bypassed, which is
  how the original tenant-wide grant went unnoticed. All three walks are
  now rooted at `.resources`, which still finds a resource nested inside
  another resource's own `resources` array without ever leaving the tree of
  things ARM actually deploys.

Two more self-test cases (19-20), one per fix, each reproduced against the
prior revision and confirmed closed.
Independent review defeated round-3 (76abcc8) eight ways. Two fixes close
six of them:

- jq property-key access is case-sensitive; ARM's resource-provider JSON
  deserializers are documented case-insensitive. `.type` (a value) was
  already downcased for exactly this reason, but that reasoning never
  reached the property KEYS read from the same JSON: has("scope"),
  has("roleDefinitionId"), .properties, .assignableScopes were all
  case-sensitive, so a miscased key made the node invisible -- silence, not
  refusal. Confirmed live: "Scope" on a tenant-wide roleAssignment,
  "RoleDefinitionId" on an Owner grant, "Properties" wrapping an Owner
  grant, and "AssignableScopes" all passed. The first is issue #1545
  byte-for-byte apart from one capital letter. Every object key in the ARM
  document is now lowercased once into a scratch copy before any query
  runs; every jq field access naming a non-lowercase ARM property
  (roleDefinitionId, assignableScopes, notActions, dataActions,
  notDataActions) is rewritten to match.

- Two more resource types genuinely grant a role the way a plain
  roleAssignment does but were never matched by any type selector:
  Microsoft.Authorization/roleEligibilityScheduleRequests and
  roleAssignmentScheduleRequests (Azure PIM), plus the legacy
  storageAccounts/providers/roleAssignments child-type spelling. All three
  are now refused outright, alongside the existing nested-deployment refusal.

Also corrects a comment that claimed this check "errs towards refusing
anything it cannot reason about" -- false for any resource type outside the
two lists it recognizes/refuses. The general fix (assert the template's
exact expected set of grants, closing every enumerate-what-to-refuse gap in
one stroke) plus a separate, unrelated finding (principalId is never
checked) are filed as issue #1681 rather than attempted here, per review
guidance that the template diff in this PR is the real #1545 remediation
and shouldn't wait on defense-in-depth guard hardening.

Seven more self-test cases (21-27), one per bypass, each reproduced against
the prior revision and confirmed closed.
Both the canonical-scope comparison (normalize_scope_expr) and the
ALLOWED_ROLE_DEFINITION_IDS allowlist comparison were already
case-insensitive in practice, but only as a side effect of running inside
the ambient `shopt -s nocasematch` scope set for the ESCAPE_TOKENS regex
match. That's fragile: moving either comparison outside that scope, or
changing what nocasematch covers, would silently drop the case-insensitivity
with no visible change to the comparison line itself.

ARM function names, property accessors, and resource-path segments are
documented case-insensitive -- the same reasoning already applied to
resource `type` values via ascii_downcase. Both comparisons now fold case
explicitly: normalize_scope_expr lowercases as its final step, and the
roleDefinitionId loop lowercases both sides before comparing. Safe
unconditionally, since lowercasing is a 1:1 character transform that can't
collapse two distinct values into one (unlike the whitespace-stripping this
function already had to be careful about).

Two more self-test cases (28-29) confirming an uppercase-spelled canonical
scope expression and an uppercase-spelled allowed roleDefinitionId are both
still accepted.
Case 9 asserts check-azure-role-parity.sh fails CLOSED when the TF module
references include_capacity_provider_scope but variables.tf is absent. It
appended only a comment mentioning the flag name, so it passed solely
because the checker's trigger is a plain `grep -q` that also matches
comment text -- it proved nothing about a real Terraform reference and
would have stayed green if the trigger were ever tightened to skip
comments.

Append a `locals` block wiring var.include_capacity_provider_scope the way
the real module does (terraform/modules/iam/azure/cudly-reservation-role/
main.tf:68-71) instead. A `locals` block rather than a second
azurerm_role_definition: that resource requires a `permissions` block whose
actions extract_tf_list would pick up, making the case fail on the actions
axis rather than on the missing variables.tf.

Verified by mutation rather than by the case passing:
  - guard reverted to the pre-fix "skip the assertion when variables.tf is
    absent" form -> checker exits 0, case FAILS (expected 1, got 0)
  - trigger made comment-aware (`grep -qE '^[^#]*...'`) -> the old
    comment-only fixture exits 0 (vacuous), the new fixture exits 1

Suite: 29 passed, 0 failed. shellcheck and bash -n clean (exit 0) on both
scripts.

Refs #1545
…ant grant

Step 1 of the remediation runbook used a single `az role assignment list
--all` query filtered by JMESPath `contains(scope, 'Microsoft.Capacity')`.
Both halves of that could return an empty result while an over-broad grant
was still live, which on a p0 revocation runbook reads as an all-clear:

  - `--all` is documented by the CLI (2.88.0) as "show all assignments under
    the current subscription". /providers/Microsoft.Capacity sits outside
    any subscription, so a surviving tenant-wide assignment -- the exact
    thing this step exists to find -- never appears. It is not a parent
    scope of the subscription either, so --include-inherited does not
    surface it.
  - JMESPath contains() is case-sensitive; ARM provider namespaces are not.
    A row stored as /providers/microsoft.capacity is a fully functional
    grant that silently fails the filter.

Split into two queries, because neither shape is visible to the other:

  1a. --scope "/providers/Microsoft.Capacity" for the tenant-level path.
      Everything returned is over-broad by definition, so there is no
      filter to get wrong. Notes that reading this scope needs tenant-level
      rights and that an authorization error is not an all-clear.
  1b. --all for the subscription and below, where the malformed
      doubled-providers target would land, filtered with `grep -i` instead
      of a case-sensitive JMESPath match. Projected as a JMESPath list
      rather than a hash so the tsv column order is fixed by the query and
      step 2 can delete by the first column.

The two are kept as separate invocations rather than combined: --all and
--scope have historically been mutually exclusive in the CLI.

Refs #1545
check-azure-role-parity.sh's key-casing normalization
(with_entries(.key |= ascii_downcase)) closed the single-miscased-key
bypass by folding every object key to lowercase before any selector runs.
But jq's from_entries (which with_entries is built on) keeps the LAST
entry when two entries fold to the same key, so an object that already
carries BOTH case-variant spellings of a property in the same object --
not one miscased key, but two keys -- collapses to whichever is spelled
last. The discarded value is deleted before any selector runs, not merely
unmatched.

A template with the hostile value spelled first and a correctly-spelled,
canonical-looking value spelled second in the same object was therefore
invisible: the normalizer threw away the evidence before the scope check,
the roleDefinitionId check, and REFUSED_TYPES ever ran. Reproduced against
four shapes, each a live bypass of a distinct part of the guard:
AssignableScopes/assignableScopes (issue #1545 itself), RoleDefinitionId/
roleDefinitionId (built-in Owner grant), Properties/properties (same,
wrapped one level up), and Type/type (also evades REFUSED_TYPES, since
the resource ends up looking like an ordinary compliant roleAssignment).
Pre-existing, not introduced by any change on this branch -- the same four
fixtures exit 0 against 99ea687 too.

Order-dependence confirms the mechanism and rules out a coincidental fix:
the same collision with the two keys reversed (benign-first, evil-last)
already exits 1 today, because the surviving (evil) value trips the
pre-existing tenant-scope check.

Fix: detect any same-object case-variant key collision on the raw file,
before normalization, and refuse outright rather than pick a winner. Which
of the two spellings ARM itself honors for a duplicate property in one
object is not verified here and is not something this script can
determine from the file alone -- unlike the single-miscased-key case,
where ARM's documented case-insensitive deserialization justifies treating
the miscased spelling as equivalent to the correct one. If ARM takes the
first entry, this is a live escalation; if it takes the last, this
script's prior behavior was coincidentally right rather than correctly
reasoned about. A template ambiguous about its own grants must not be the
thing that decides whether CI is green.

Verified: the new collision check returns 0 on the real
arm/CUDly-CrossSubscription/template.json and every one of the 28
pre-existing role-parity fixtures (no false positives), and 1 on all five
new fixtures. Five new regression fixtures added, one per bypass shape
plus the benign-first control, each confirmed to fail for its own reason
via its printed reason line. Full suite: 34/34 pass (29 pre-existing + 5
new). bash -n and shellcheck 0.11.0 clean on both scripts.
@cristim
cristim force-pushed the sec/1545-arm-role-scope branch from fe46a70 to ed8e6a3 Compare August 3, 2026 13:22

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
scripts/check-azure-role-parity.sh (3)

569-581: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

A trailing comment on the default reds this check.

The gsub at line 573 strips the default = prefix and trailing whitespace only. For default = false # keep tenant scope off, CAPACITY_DEFAULT becomes false # keep tenant scope off, and the comparison at line 579 fails. The reported reason then points at the default value, not at the comment.

Strip an inline comment and any trailing punctuation before comparing.

♻️ Proposed change
       in_var && /^[[:space:]]*default[[:space:]]*=/ {
-        gsub(/^[[:space:]]*default[[:space:]]*=[[:space:]]*|[[:space:]]*$/, "")
+        sub(/^[[:space:]]*default[[:space:]]*=[[:space:]]*/, "")
+        sub(/[[:space:]]*(#|\/\/).*$/, "")
+        gsub(/^[[:space:]]+|[[:space:]]+$/, "")
         print; exit
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check-azure-role-parity.sh` around lines 569 - 581, Update the
CAPACITY_DEFAULT extraction in the awk block for include_capacity_provider_scope
to remove inline comments and trailing punctuation after parsing the default
value, so values such as false # keep tenant scope off normalize to false before
the comparison. Keep the existing error reporting and default validation
unchanged.

383-384: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Match the schema case-insensitively for consistency.

The rest of the script folds case before comparing ARM identifiers. Line 383 matches the $schema value case-sensitively. A template that spells the URL with different casing fails this gate with the wrong reason. The direction is fail-closed, so this is not a security gap; it is a consistency gap.

♻️ Proposed change
-if ! jq -e '.["$schema"] | test("subscriptionDeploymentTemplate")' "$ARM_FILE_NORM" >/dev/null 2>&1; then
+if ! jq -e '.["$schema"] | test("subscriptionDeploymentTemplate";"i")' "$ARM_FILE_NORM" >/dev/null 2>&1; then
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check-azure-role-parity.sh` around lines 383 - 384, Update the
`$schema` validation in the ARM template check around `ARM_FILE_NORM` to perform
a case-insensitive match for `subscriptionDeploymentTemplate`, consistent with
the script’s identifier comparisons, while preserving the existing fail-closed
behavior and `ACTUAL_SCHEMA` reporting.

162-175: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Handle valid inline non-empty permission lists.

extract_tf_list skips values from attr = ["a"] and can consume following permission lines, causing false parity drift. The referenced module currently uses supported multiline lists, so this is a future-format risk. Add inline-list handling or use an HCL-aware parser.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check-azure-role-parity.sh` around lines 162 - 175, Update
extract_tf_list to parse valid inline non-empty permission lists such as attr =
["a"], emitting each value without entering multiline mode. Preserve empty-list
handling and ensure subsequent permission attributes are not consumed as list
items; retain multiline-list parsing for the existing format.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/check-azure-role-parity.sh`:
- Around line 421-433: Update the REFUSED_TYPES filtering used by the jq
expression in REFUSED_TYPE_COUNT so it rejects every type whose normalized value
ends with "/providers/roleassignments", rather than only the listed
microsoft.storage entry. Preserve the existing exact refusals for the other
deployment and scheduling types, and ensure matching remains case-insensitive
through the existing lowercase normalization.

---

Nitpick comments:
In `@scripts/check-azure-role-parity.sh`:
- Around line 569-581: Update the CAPACITY_DEFAULT extraction in the awk block
for include_capacity_provider_scope to remove inline comments and trailing
punctuation after parsing the default value, so values such as false # keep
tenant scope off normalize to false before the comparison. Keep the existing
error reporting and default validation unchanged.
- Around line 383-384: Update the `$schema` validation in the ARM template check
around `ARM_FILE_NORM` to perform a case-insensitive match for
`subscriptionDeploymentTemplate`, consistent with the script’s identifier
comparisons, while preserving the existing fail-closed behavior and
`ACTUAL_SCHEMA` reporting.
- Around line 162-175: Update extract_tf_list to parse valid inline non-empty
permission lists such as attr = ["a"], emitting each value without entering
multiline mode. Preserve empty-list handling and ensure subsequent permission
attributes are not consumed as list items; retain multiline-list parsing for the
existing format.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3ee75540-522a-481e-aaca-b56b1410abf3

📥 Commits

Reviewing files that changed from the base of the PR and between fe46a70 and ed8e6a3.

📒 Files selected for processing (37)
  • arm/CUDly-CrossSubscription/template.json
  • known-issues.md
  • scripts/check-azure-role-parity.sh
  • scripts/test-azure-role-parity.sh
  • scripts/testdata/role-parity/canonical-scope-variants-arm.json
  • scripts/testdata/role-parity/collision-assignablescopes-benign-first-arm.json
  • scripts/testdata/role-parity/collision-assignablescopes-evil-first-arm.json
  • scripts/testdata/role-parity/collision-properties-evil-first-arm.json
  • scripts/testdata/role-parity/collision-roledefinitionid-evil-first-arm.json
  • scripts/testdata/role-parity/collision-type-evil-first-arm.json
  • scripts/testdata/role-parity/dataactions-wildcard-arm.json
  • scripts/testdata/role-parity/decorative-variables-arm.json
  • scripts/testdata/role-parity/deploymentscript-scope-escape-arm.json
  • scripts/testdata/role-parity/deploymentstack-scope-escape-arm.json
  • scripts/testdata/role-parity/drifted-arm.json
  • scripts/testdata/role-parity/foreign-subscription-literal-with-canonical-arm.json
  • scripts/testdata/role-parity/legacy-child-roleassignment-owner-arm.json
  • scripts/testdata/role-parity/lowercase-tenant-scope-arm.json
  • scripts/testdata/role-parity/lowercase-type-wildcard-actions-arm.json
  • scripts/testdata/role-parity/matching-arm.json
  • scripts/testdata/role-parity/mgmt-group-schema-arm.json
  • scripts/testdata/role-parity/miscased-assignablescopes-arm.json
  • scripts/testdata/role-parity/miscased-properties-arm.json
  • scripts/testdata/role-parity/miscased-roledefinitionid-arm.json
  • scripts/testdata/role-parity/miscased-scope-arm.json
  • scripts/testdata/role-parity/nested-deployment-arm.json
  • scripts/testdata/role-parity/obfuscated-tenant-scope-arm.json
  • scripts/testdata/role-parity/other-subscription-arm.json
  • scripts/testdata/role-parity/pim-roleassignment-schedule-owner-arm.json
  • scripts/testdata/role-parity/pim-roleeligibility-owner-arm.json
  • scripts/testdata/role-parity/second-permissions-entry-arm.json
  • scripts/testdata/role-parity/space-inside-literal-arm.json
  • scripts/testdata/role-parity/tenant-scope-arm.json
  • scripts/testdata/role-parity/unallowed-roledefinitionid-arm.json
  • scripts/testdata/role-parity/uppercase-allowed-roledefinitionid-arm.json
  • scripts/testdata/role-parity/uppercase-canonical-scope-arm.json
  • scripts/testdata/role-parity/uppercase-guid-literal-arm.json
🚧 Files skipped from review as they are similar to previous changes (33)
  • scripts/testdata/role-parity/collision-assignablescopes-benign-first-arm.json
  • scripts/testdata/role-parity/canonical-scope-variants-arm.json
  • scripts/testdata/role-parity/dataactions-wildcard-arm.json
  • scripts/testdata/role-parity/collision-properties-evil-first-arm.json
  • scripts/testdata/role-parity/collision-assignablescopes-evil-first-arm.json
  • scripts/testdata/role-parity/decorative-variables-arm.json
  • scripts/testdata/role-parity/deploymentstack-scope-escape-arm.json
  • scripts/testdata/role-parity/uppercase-guid-literal-arm.json
  • scripts/testdata/role-parity/legacy-child-roleassignment-owner-arm.json
  • scripts/testdata/role-parity/deploymentscript-scope-escape-arm.json
  • scripts/testdata/role-parity/drifted-arm.json
  • scripts/testdata/role-parity/mgmt-group-schema-arm.json
  • arm/CUDly-CrossSubscription/template.json
  • scripts/testdata/role-parity/nested-deployment-arm.json
  • scripts/test-azure-role-parity.sh
  • scripts/testdata/role-parity/lowercase-tenant-scope-arm.json
  • scripts/testdata/role-parity/miscased-assignablescopes-arm.json
  • scripts/testdata/role-parity/pim-roleassignment-schedule-owner-arm.json
  • scripts/testdata/role-parity/other-subscription-arm.json
  • scripts/testdata/role-parity/space-inside-literal-arm.json
  • scripts/testdata/role-parity/uppercase-allowed-roledefinitionid-arm.json
  • scripts/testdata/role-parity/matching-arm.json
  • scripts/testdata/role-parity/obfuscated-tenant-scope-arm.json
  • known-issues.md
  • scripts/testdata/role-parity/collision-type-evil-first-arm.json
  • scripts/testdata/role-parity/miscased-roledefinitionid-arm.json
  • scripts/testdata/role-parity/uppercase-canonical-scope-arm.json
  • scripts/testdata/role-parity/foreign-subscription-literal-with-canonical-arm.json
  • scripts/testdata/role-parity/collision-roledefinitionid-evil-first-arm.json
  • scripts/testdata/role-parity/second-permissions-entry-arm.json
  • scripts/testdata/role-parity/unallowed-roledefinitionid-arm.json
  • scripts/testdata/role-parity/tenant-scope-arm.json
  • scripts/testdata/role-parity/miscased-scope-arm.json

Comment thread scripts/check-azure-role-parity.sh
… roleAssignments

CodeRabbit found two gaps in the round-5 fixes to check-azure-role-parity.sh:

- The key-collision scan (line 96) was rooted at .resources, so it never
  treated the root document object itself as a candidate for collision.
  A template with both "Resources" (evil, first) and "resources" (benign,
  last) at the top level folds to the benign array during normalization
  (last-entry-wins), the same silent-discard bypass this script exists to
  refuse, just one level higher in the document tree. Fixed by including
  the root object (.) alongside the .resources walk.

- REFUSED_TYPES named only microsoft.storage/storageaccounts/providers/
  roleassignments for the legacy child-scoped role-assignment spelling, so
  any other parent (Microsoft.KeyVault/vaults, Microsoft.Resources/
  resourceGroups, etc.) granting the same way via the same
  */providers/roleAssignments shape passed unrefused. Fixed by matching the
  suffix instead of enumerating parents, the same pattern ESCAPE_TOKENS
  already uses for managementGroups.

Both fixes preserve the ordering invariant this script depends on:
collision detection still runs on the raw file before normalization.

Added two permanent regression fixtures under scripts/testdata/role-parity/
and their test cases (35, 36), following the existing per-finding pattern.
@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Addressed both open threads in scripts/check-azure-role-parity.sh, pushed as 34b4138b.

Thread 1 (line 108): collision scan missed root-level key collisions

Confirmed live. The collision scan was rooted at .resources, so it never treated the root document object itself as a candidate for collision. Since the normalization step just below (walk(...)) runs from the true root, a template with both "Resources" (evil, spelled first) and "resources" (benign, spelled last) at the top level folds to the benign array on normalization -- last-entry-wins, same as any other collision this script exists to catch, just one level higher in the document tree.

Fixed by widening the scanned objects from .resources // [] | .. to ., (.resources // [] | ..), so the root object's own keys are checked too. Collision detection still runs on the raw file before normalization; only what gets scanned changed, not when.

Thread 2 (line 433): legacy child-scoped roleAssignments refusal covered only microsoft.storage

Confirmed live. REFUSED_TYPES named only microsoft.storage/storageaccounts/providers/roleassignments for the legacy child-scoped role-assignment spelling. The file's own comment above it (lines 411-415) already describes this as a general pattern ("the legacy ARM spelling for a role assignment as a child resource... same grant, invisible to the same selectors for the same reason"), but the enumeration only caught one parent. A Microsoft.KeyVault/vaults/providers/roleAssignments (or any other parent) granting the same way passed unrefused.

Fixed by matching the suffix (endswith("/providers/roleassignments")) instead of enumerating parent types, the same pattern ESCAPE_TOKENS already uses for managementGroups a few lines up (matched independently of provider spelling). Removed the now-redundant storage-specific literal from the allowlist rather than keeping both -- one exact suffix match replaces an enumeration that was always going to be incomplete.

Both fixes are comparison changes only: no new modes, caps, or validation layers.

Fixture verification (pre/post)

Two fixtures, run against matching-tf.tf.fixture:

collision-root-resources-evil-first-arm.json -- "Resources" (evil, carries the issue #1545 tenant-wide grant) spelled first, "resources" (benign, canonical) spelled last at the root.

PRE-FIX : OK: ARM and TF actions/... lists match
          OK: all 1 ARM grant scopes/roleDefinitionIds are subscription-anchored.
          exit=0   <- false clean, the bug
POST-FIX: ERROR: ARM template contains an object with two case-variant spellings...
          resource type=<no type field> name=<no name field>: Resources / resources
          exit=1   <- refused

legacy-child-keyvault-roleassignment-owner-arm.json -- same legacy child-scoped shape as the existing storage-parent regression case, under Microsoft.KeyVault/vaults instead, binding built-in Owner.

PRE-FIX : OK: ARM and TF actions/... lists match
          OK: all 1 ARM grant scopes/roleDefinitionIds are subscription-anchored.
          exit=0   <- false clean, the bug
POST-FIX: ERROR: ARM template contains 1 resource(s) of a type this check cannot safely inspect...
          exit=1   <- refused

The real production template (arm/CUDly-CrossSubscription/template.json) still passes clean post-fix:

OK: ARM and TF actions/notActions/dataActions/notDataActions lists match (case-insensitive).
OK: all 4 ARM grant scopes/roleDefinitionIds are subscription-anchored.
exit=0

Both fixtures are now permanent regression fixtures under scripts/testdata/role-parity/, wired into test-azure-role-parity.sh as cases 35-36. Full suite: 36 passed, 0 failed. bash -n and shellcheck both exit 0 on both scripts.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

@cristim: Thanks for the detailed verification. I will review commit 34b4138b, including the two new regression cases and the production-template guard behavior.


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1658

Timestamp: 2026-08-03T16:33:59.685Z
Learning: In `scripts/check-azure-role-parity.sh`, case-variant JSON key collision detection must scan the raw ARM template root object as well as objects beneath `resources` before case-folding normalization. Root-level collisions such as `"Resources"` and `"resources"` can otherwise hide a malicious resource array through last-entry-wins normalization.

Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1658

Timestamp: 2026-08-03T16:33:59.685Z
Learning: In `scripts/check-azure-role-parity.sh`, legacy child-scoped ARM role assignments must be rejected generically by matching resource types that end with `/providers/roleassignments`. Do not enumerate parent resource providers, because any parent type, such as `Microsoft.KeyVault/vaults`, can use this legacy role-assignment spelling.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Correcting a clearance I gave in error: this PR has not been reviewed by CodeRabbit at its current head.

I previously treated it as reviewed because the CodeRabbit commit status read success :: Review completed at 16:39:18Z, later than the head commit 34b4138b8 at 16:32:50Z. That inference was wrong. Checking the review objects by SHA instead of by timestamp:

head                               = 34b4138b862c0d57f8bc5eda00a4bfa18e674b61
last verdict (body length 11481)   = commit ed8e6a3ffdae211278620a012df1f2a987931870

ed8e6a3ff is the previous commit on this branch, so the widening in 34b4138b8 — scanning the root object and every provider for legacy child-scoped roleAssignments — is exactly the part that has not been reviewed. The only newer CodeRabbit message is @cristim ... I will review commit 34b4138b, a statement of intent, and it follows a Fair Usage limit notice posted at 10:56:32Z.

What is independently verified and does stand: the collision-detection ordering invariant survived the widening. Confirmed on the committed blob, not the working tree — the collision scan at lines 106-119 reads the raw $ARM_FILE, ARM_FILE_NORM is not created until line 159, every downstream axis reads the normalised copy, and nothing normalises before line 108. The collision expression [., (.resources // [] | ..) | objects is a strict superset of the downstream [.resources // [] | .. | objects | select(has("type"))], so no axis can see less than another. Regression cases 35 and 36 cover both the same-object and root-level evil-first shapes.

That verification is human/agent review, not CodeRabbit review, and it does not substitute for one on a security-checker change. Not re-triggering right now given the Fair Usage state; a ping while throttled burns shared budget and tightens the adaptive limit.

@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

CodeRabbit's last verdict on this PR reviewed ed8e6a3ff, the commit before 34b4138b8 widened the scan to the root object and every provider. That widening is the part most worth reviewing, and it has not been.

Requesting a full review rather than the incremental form: after the repo-wide Fair Usage throttle earlier today, an incremental review silently skips the commits that were in flight during the throttle and reports clean on a diff it never read.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

@cristim I will run a full review of PR #1658 at the current head. I will include commit 34b4138b8, with focus on the root-level collision scan and generic rejection of legacy child-scoped role assignments.

🐇🔎

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 36 minutes.

@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

The ✅ Action performed / Full review finished reply above at 21:32:45Z did not produce a review. Recording that explicitly, because the wrapper reads like a completed pass.

Same comment, further down:

Your included review limit is currently reached under our Fair Usage Limits Policy... Your next included review will be available in 36 minutes.

And the review objects confirm it — the most recent CodeRabbit verdict with a non-empty body is still:

at=2026-08-03T13:30:47Z  bodylen=11481  commit=ed8e6a3ff

ed8e6a3ff is the commit before 34b4138b8 widened the collision scan to the root object and every provider. So the widening remains unreviewed by CodeRabbit, and the acknowledgement claiming otherwise should not be read as coverage.

Not re-triggering before the stated window elapses. The quota is per-developer per-organization and adaptive, so pinging while throttled consumes budget, tightens the limit, and returns nothing.

What does stand on this PR, independently of CodeRabbit: all checks green, zero unresolved threads, and a manual verification of the security invariant recorded earlier — collision detection at lines 106-119 reads the raw $ARM_FILE, ARM_FILE_NORM is not created until line 159, every downstream axis reads the normalised copy, and nothing normalises before line 108. The collision expression [., (.resources // [] | ..) | objects is a strict superset of the downstream [.resources // [] | .. | objects | select(has("type"))], so no axis sees less than another. Regression cases 35 and 36 cover the same-object and root-level evil-first shapes.

That is agent review, not CodeRabbit review, and on a security-checker change it is not a substitute for one.

@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Retrying after the stated cooldown. The previous full review request at 21:32:45Z replied ✅ Action performed / Full review finished while producing no review object, with a 36-minute Fair Usage cooldown noted in the same comment. That window has elapsed.

Still outstanding: the newest CodeRabbit verdict with a non-empty body is ed8e6a3ff, the commit before 34b4138b8 widened the collision scan to the root object and every provider. That widening is the part most worth reviewing on a security checker, and it remains unreviewed.

Using the full form deliberately: this branch head was pushed at 16:32:50Z, straight into a Fair Usage limit, so its automatic review never ran and is not retried retroactively. An incremental request would skip exactly those commits and report clean on a diff it never read.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

@cristim I will run a full review of PR #1658 at the current head. I will include 34b4138b8 and review the root-level collision scan and the generic legacy child-scoped role-assignment rejection.

🐇🔎

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 1 minute.

@cristim

cristim commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review, paced to one request per hour across this repo.

The CodeRabbit quota is per-developer per-organization and adaptive, tightening at the 95th percentile of recent review volume. Every open PR here draws on one budget, so bursting requests both consumes it faster and tightens the limit. Spacing them is what gets verdicts to land.

Full form rather than incremental: this head was pushed while the quota was exhausted, so its automatic review never ran and is not retried retroactively. An incremental request would skip exactly those commits and report clean on a diff it never read.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

@cristim I will run a full review at the current PR head. The review will include the root-level collision scan and the generic legacy child-scoped role-assignment rejection.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
known-issues.md (1)

8-17: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the historical grant status conditional.

Line 8 and Line 174 state that prior deployments definitely granted tenant-wide access. Lines 30 through 37 state that this outcome is unconfirmed. Use conditional wording at both sites.

  • known-issues.md#L8-L17: change the heading to state that existing deployments may retain a tenant-wide grant.
  • known-issues.md#L170-L176: state that the retained template assignment could have granted tenant-wide access.
Proposed fix
-### Existing Azure deployments carry a tenant-wide reservation grant (issue `#1545`)
+### Existing Azure deployments may retain a tenant-wide reservation grant (issue `#1545`)
@@
-  that need, but the tenant-wide assignment was left behind and silently
-  granted access across the whole tenant. It has since been removed; do not
+  that need, but the tenant-wide assignment remained in the template and
+  could have granted access across the whole tenant. It has since been removed; do not
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@known-issues.md` around lines 8 - 17, Use conditional wording for the
historical tenant-wide grant status: update the heading around the existing
Azure deployments section to say deployments may retain a tenant-wide grant, and
revise the corresponding statement around the retained template assignment to
say it could have granted tenant-wide access. Apply these changes at
known-issues.md lines 8-17 and 170-176.
🧹 Nitpick comments (1)
scripts/test-azure-role-parity.sh (1)

16-31: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the failure reason, not only the exit status.

run_case compares exit codes only. Every refusal in check-azure-role-parity.sh exits 1. Many cases document that they must fail for one specific reason, for example the miscased-key cases 21-24 and the collision cases 30-36. The harness cannot prove that today. A fixture that starts failing on the actions axis instead of the scope axis still reports PASS.

Add an optional expected-pattern argument and match it against captured stderr.

♻️ Proposed harness change
 run_case() {
   local label="$1"
   local expected_exit="$2"
-  shift 2
+  local expect_msg="${EXPECT_MSG:-}"
+  shift 2
 
   actual_exit=0
-  "$CHECK" "$@" >/dev/null 2>&1 || actual_exit=$?
+  local output
+  output="$("$CHECK" "$@" 2>&1)" || actual_exit=$?
 
-  if [[ "$actual_exit" -eq "$expected_exit" ]]; then
+  if [[ "$actual_exit" -ne "$expected_exit" ]]; then
+    echo "FAIL: $label (expected exit $expected_exit, got $actual_exit)"
+    (( fail++ )) || true
+  elif [[ -n "$expect_msg" && ! "$output" =~ $expect_msg ]]; then
+    echo "FAIL: $label (exit matched, but message did not match /$expect_msg/)"
+    (( fail++ )) || true
+  else
     echo "PASS: $label"
     (( pass++ )) || true
-  else
-    echo "FAIL: $label (expected exit $expected_exit, got $actual_exit)"
-    (( fail++ )) || true
   fi
 }

Then set the reason per case, for example:

EXPECT_MSG='names a scope above the subscription' \
run_case "miscased 'AssignableScopes' key exits 1" 1 \
  --tf-file  "${FIXTURES}/matching-tf.tf.fixture" \
  --arm-file "${FIXTURES}/miscased-assignablescopes-arm.json"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test-azure-role-parity.sh` around lines 16 - 31, Update run_case to
accept an optional expected-pattern argument, capture the checker’s stderr while
preserving its exit status, and require the pattern to match stderr when
provided. Update the relevant reason-specific cases, especially miscased-key
cases 21–24 and collision cases 30–36, to pass patterns describing their
expected refusal reason while retaining existing exit-code assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@known-issues.md`:
- Around line 8-17: Use conditional wording for the historical tenant-wide grant
status: update the heading around the existing Azure deployments section to say
deployments may retain a tenant-wide grant, and revise the corresponding
statement around the retained template assignment to say it could have granted
tenant-wide access. Apply these changes at known-issues.md lines 8-17 and
170-176.

---

Nitpick comments:
In `@scripts/test-azure-role-parity.sh`:
- Around line 16-31: Update run_case to accept an optional expected-pattern
argument, capture the checker’s stderr while preserving its exit status, and
require the pattern to match stderr when provided. Update the relevant
reason-specific cases, especially miscased-key cases 21–24 and collision cases
30–36, to pass patterns describing their expected refusal reason while retaining
existing exit-code assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2d546133-c026-4586-8166-b289022e7b3d

📥 Commits

Reviewing files that changed from the base of the PR and between a233ce0 and 34b4138.

📒 Files selected for processing (39)
  • arm/CUDly-CrossSubscription/template.json
  • known-issues.md
  • scripts/check-azure-role-parity.sh
  • scripts/test-azure-role-parity.sh
  • scripts/testdata/role-parity/canonical-scope-variants-arm.json
  • scripts/testdata/role-parity/collision-assignablescopes-benign-first-arm.json
  • scripts/testdata/role-parity/collision-assignablescopes-evil-first-arm.json
  • scripts/testdata/role-parity/collision-properties-evil-first-arm.json
  • scripts/testdata/role-parity/collision-roledefinitionid-evil-first-arm.json
  • scripts/testdata/role-parity/collision-root-resources-evil-first-arm.json
  • scripts/testdata/role-parity/collision-type-evil-first-arm.json
  • scripts/testdata/role-parity/dataactions-wildcard-arm.json
  • scripts/testdata/role-parity/decorative-variables-arm.json
  • scripts/testdata/role-parity/deploymentscript-scope-escape-arm.json
  • scripts/testdata/role-parity/deploymentstack-scope-escape-arm.json
  • scripts/testdata/role-parity/drifted-arm.json
  • scripts/testdata/role-parity/foreign-subscription-literal-with-canonical-arm.json
  • scripts/testdata/role-parity/legacy-child-keyvault-roleassignment-owner-arm.json
  • scripts/testdata/role-parity/legacy-child-roleassignment-owner-arm.json
  • scripts/testdata/role-parity/lowercase-tenant-scope-arm.json
  • scripts/testdata/role-parity/lowercase-type-wildcard-actions-arm.json
  • scripts/testdata/role-parity/matching-arm.json
  • scripts/testdata/role-parity/mgmt-group-schema-arm.json
  • scripts/testdata/role-parity/miscased-assignablescopes-arm.json
  • scripts/testdata/role-parity/miscased-properties-arm.json
  • scripts/testdata/role-parity/miscased-roledefinitionid-arm.json
  • scripts/testdata/role-parity/miscased-scope-arm.json
  • scripts/testdata/role-parity/nested-deployment-arm.json
  • scripts/testdata/role-parity/obfuscated-tenant-scope-arm.json
  • scripts/testdata/role-parity/other-subscription-arm.json
  • scripts/testdata/role-parity/pim-roleassignment-schedule-owner-arm.json
  • scripts/testdata/role-parity/pim-roleeligibility-owner-arm.json
  • scripts/testdata/role-parity/second-permissions-entry-arm.json
  • scripts/testdata/role-parity/space-inside-literal-arm.json
  • scripts/testdata/role-parity/tenant-scope-arm.json
  • scripts/testdata/role-parity/unallowed-roledefinitionid-arm.json
  • scripts/testdata/role-parity/uppercase-allowed-roledefinitionid-arm.json
  • scripts/testdata/role-parity/uppercase-canonical-scope-arm.json
  • scripts/testdata/role-parity/uppercase-guid-literal-arm.json

@cristim
cristim merged commit 409a466 into main Aug 4, 2026
20 checks passed
cristim added a commit that referenced this pull request Aug 19, 2026
…and assert its exact grant set

check-azure-role-parity.sh constrained which role a grant binds and where it
lands, never who receives it.

Measured on origin/main before changing anything: a fourth
Microsoft.Authorization/roleAssignments resource binding the ALLOWED custom
purchaser role, at the correctly inherited subscription scope, to a hardcoded
foreign principalId, exits 0 and prints "OK: all 5 ARM grant
scopes/roleDefinitionIds are subscription-anchored". That role carries
Microsoft.Capacity/reservationOrders/purchase/action, so the grant spends the
customer's money. The repository's own test data already asserted the bypass
passes: uppercase-allowed-roledefinitionid-arm.json is an exit-0 fixture whose
only role assignment binds an allowed role, at inherited scope, to the literal
GUID 00000000-0000-0000-0000-0000000000aa.

Four more shapes measured as passing on origin/main, all exit 0: a duplicate of
a legitimate grant, a grant deleted outright, a role assignment carrying no
principalId at all, and a Microsoft.ManagedIdentity/userAssignedIdentities
resource, a type no selector in the script has ever heard of. Each only changed
the number in the final "OK: all N ..." line, which asserted that a check had
run rather than that anything was true.

Two axes added.

PRINCIPAL: every principalId under .resources must be
parameters('servicePrincipalObjectId'). Collected by key presence on any object
in the recursive descent rather than from roleAssignments resources only,
because principalId names the recipient of a grant wherever it appears and the
list of shapes that carry one is not one this script can finish writing: the
PIM schedule requests and the legacy .../providers/roleAssignments child type
already on REFUSED_TYPES are two that were found the hard way. A roleAssignments
resource carrying no principalId emits <absent>, so deleting the field is not a
way to be exempt from the check on its value.

GRANT SET: the template must grant exactly the four expected tuples as a
multiset, each described by what decides its blast radius (role, principal,
scope), normalized so a reformat cannot red CI and a changed value always does.
Every deployed resource is one of those tuples or it is refused, so a resource
type nobody has thought of yet fails the assertion without anyone having thought
of it. This is what the previous axes could not do: they answered "is each thing
I happened to find acceptable?", which is only ever as complete as the list of
shapes someone remembered to refuse.

Adversarial review of the above found three more escalations of the same shape,
each verified passing before its fix was written, all fixed here: a check that
compares the text of an ARM expression asserts the NAME of a thing, and the
binding between that name and the thing sits elsewhere in the template,
unasserted.

- variables.roles.reader repointed at the built-in Owner definition: exit 0.
  Every string this script compared stayed byte-identical, the assignment still
  read "[variables('roles').reader]" and was still on the #1658 F6 allowlist,
  and the deployment granted Owner. roleDefinitionId is now resolved through the
  template's own variables table before being recorded, so EXPECTED_GRANTS names
  the definitions the grants resolve to. A form this resolver does not handle is
  recorded as unresolved:<text>, which is in no expected set.
- a defaultValue on parameters.servicePrincipalObjectId: exit 0. Every
  principalId still read the sanctioned parameter and passed the new principal
  axis, while az deployment sub create and the portal form both prefill the
  object ID named in the default. The parameter is now asserted to carry no
  defaultValue and no allowedValues, gated on having SEEN it referenced rather
  than on it existing, so deleting the declaration is not the way out.
- $schema set to the management-group template with
  "#subscriptionDeploymentTemplate" appended as a URL fragment: exit 0. ARM
  reads the path; the pin was an unanchored substring test reading the fragment.
  Anchored on the URL's file name now.

A second independent review found each of those three fixes still reachable by a
neighbouring form, which is the shape this guard keeps rediscovering: written
against the example rather than against the class. All fixed here.

- the $schema pin, anchored on the file name with an optional fragment after it,
  still accepted the management-group template with
  "#/subscriptionDeploymentTemplate.json" appended, because the sanctioned name
  was then the tail of the fragment. Matched on the URL path now, which is what
  ARM resolves.
- `condition` was not refused where `copy` was. condition:false is the copy
  loop's N=0 case: the declaration stays in the file for the grant set to count
  while the grant is not deployed, which defeats the "a removed grant is
  refused" invariant by leaving the resource where it is. Refused now.
- the variables resolver filtered with `strings`, whose empty stream dropped the
  ENTIRE resource from the inventory when a variable resolved to a non-string: a
  grant vanishing from the axis that exists to count grants. It records as
  unresolved:<text> now, as the comment always claimed.
- the key-collision scan concatenated a resource's `type` into its message
  before any string-type guard ran, so an ambiguous template with a numeric
  type aborted jq rather than being refused.
- allowedValues on the principal parameter was refused but untested; a flag
  given no value read "$2" unguarded and aborted on set -u rather than exiting
  the 2 this script documents for usage errors.

The same review measured a real regression this change introduced in the SUITE:
with the grant-set axis running, every pre-existing negative fixture case exited
1 whether or not the axis it was written for still worked, so REFUSED_TYPES, the
collision scan and the actions diff could each be deleted with the suite green.
All 31 of those cases now assert the checker's own diagnostic through
run_case_saying, and each of those three axes, plus the scope/principal loop and
the $schema pin, is noticed again when removed.

A third review found four more, three of them pre-existing and one introduced
above:

- exact-duplicate JSON keys were structurally invisible. jq's PARSER collapses
  `"principalId": <foreign>` followed by `"principalId":
  <parameters('servicePrincipalObjectId')>` to the second before any query runs,
  so the case-variant collision scan, whose whole purpose is refusing an
  ambiguous template, could not see the sharpest form of the ambiguity it exists
  for: every check read the second value while the file shows both. Detected
  with jq --stream, where a repeated leaf path is a duplicate key.
- the arm/ sweep used `find -type f`, which reports a symlink as neither a file
  nor anything to refuse, so a second template symlinked into arm/ was swept
  past. `find -L` now.
- the legacy child roleAssignments refusal matched "/providers/roleassignments"
  with a leading slash, and the relative spelling "providers/roleAssignments" is
  the idiomatic one inside a parent's own resources array, so the form an author
  would actually write was the one it missed while its comment claimed coverage
  of any parent type.
- the shape gate checked that the four permission lists are arrays but not what
  is in them, and extract_arm_list calls ascii_downcase per element, so a number
  in actions[] still aborted jq with exit 5.
- the resolved role bypassed the flattener, so a newline in a variable's value
  could still split one resource across two records.

Five assertions that no case covered are covered now, two of them predating this
change: the principal axis being keyed on the property name rather than the
resource type, include_capacity_provider_scope defaulting to false, the unknown
flag and missing-value exits, and the missing expected-grants file.

A fourth review found two defects introduced by the third round's fixes, and no
remaining way to exit 0 on a hostile template across 25 attempts and a 253-case
type fuzz:

- the duplicate-key detector compared --stream paths joined with a dot, which is
  not injective: {"a.b": 1, "a": {"b": 2}} joins to the same string twice and
  reds a template whose keys are all distinct. Dotted keys are ordinary in Azure
  tags. Compared as tojson now.
- the grant-set diff expanded a possibly-empty array unquoted-safe on bash 3.2,
  where set -u aborts on it rather than producing the diff the comment promises.

Also in the same file:

- arm/ is swept with find rather than the single template path being named, and
  any other .json there is refused. Every assertion here is specific to one
  template, so a second one would onboard customer subscriptions with nothing
  checking it, and naming the file already covered is how a guard fails to reach
  its sibling site. An empty discovery is a failure, not a pass.
- resources, properties, permissions, assignableScopes, each permissions[]
  element, each of actions/notActions/dataActions/notDataActions, each
  assignableScopes[] element, a non-object document root and a file that is not
  JSON are refused up front. Each previously aborted jq mid-pipeline and
  surfaced jq's own exit 5 through set -e, which is neither documented outcome
  of this script. This is issue #1681's own closing note, which guessed that one
  fix would normalize all of them.
- a copy loop on a resource is refused: one declaration is then N deployed
  grants whose properties can vary by copyIndex(), so the set asserted is not
  the set deployed.

test-azure-role-parity.sh grows run_case_saying, which asserts the checker's own
diagnostic as a fixed string alongside the exit code: a nonzero exit proves only
that something went wrong, and several of the new cases pass for the wrong
reason without it. 42 cases added and 31 pre-existing ones strengthened, 78 total, and the suite now also runs the
real sources with no flags, which no case did before, plus the arm/ sweep
against a scratch repo root rather than the repository's own tree.

One assertion written for this change was removed again: a RESOURCE_COUNT -eq 0
floor on the grant-set axis is unreachable behind the pre-existing "No
assignableScopes found" check, the suite could not exercise it, and mutating it
to false changed nothing. The axis cannot pass having read nothing regardless:
an empty expected set is refused, and what follows is equality against it rather
than an absence.

Verification, all against copies, never tracked files: the new suite run against
the origin/main checker fails 13 cases, every one of them a #1681 case; nine
template mutations each produce their own specific diagnostic rather than a bare
nonzero exit, as do the fixtures; every assertion in the file, new and
pre-existing, mutated one at a time, is noticed by the cases written for it,
with the exceptions recorded in the report. Ran on bash 3.2 (macOS) and
shellcheck clean; no awk was added, so the mawk/BWK dialect split is not
touched.

Closes #1681
cristim added a commit that referenced this pull request Aug 19, 2026
…1860)

Azure role-parity guard constrained which role and where, never who

check-azure-role-parity.sh enforced that a granted role is on the allowlist
and that the scope is the inherited subscription, but never looked at the
recipient. A fourth Microsoft.Authorization/roleAssignments resource binding
the allowed custom purchaser role, at the correct scope, to a hardcoded
foreign principalId passed cleanly. That role carries
Microsoft.Capacity/reservationOrders/purchase/action, which spends the
customer's money. Verified against origin/main before changing anything: the
bypass exits 0.

The repository's own test data asserted the bypass passes.
uppercase-allowed-roledefinitionid-arm.json was an exit-0 fixture whose only
role assignment bound an allowed role to the literal GUID
00000000-0000-0000-0000-0000000000aa, and every fixture carrying a
principalId used a hardcoded GUID rather than the parameter. A guard whose
fixtures agree with the bug cannot fail, and that reads as coverage.

The second finding is worse than filed. Measured on origin/main, all of these
exit 0: adding a resource of a type no selector knows, duplicating a grant,
deleting a grant, and deleting a role assignment's principalId outright.
Removing a grant passes, so the template can be silently weakened rather than
only extended, and the only trace is a count inside a line that reads as
success.

principalId is now collected and compared against the sanctioned
parameters('servicePrincipalObjectId') reference, with a roleAssignments
resource carrying no principalId emitting <absent> and failing rather than
being skipped. The template's expected set of grants is asserted, so the
guard is no longer a growing list of shapes it happens to refuse; an empty
expected set is itself refused, since every template satisfies granting
nothing beyond nothing.

Adversarial review of the first commit found three escalations the new axes
did not close, all one class: the check compares the text of an ARM
expression, so it asserts a name, while the binding between that name and its
target lives elsewhere in the template, unasserted. Repointing
variables.roles.reader at built-in Owner keeps every compared string
byte-identical and stays on the #1658 allowlist, which is a privilege
escalation to Owner past a guard built to constrain which role may be
granted. A defaultValue on the principal parameter leaves every principalId
reading the sanctioned parameter while the portal form prefills an attacker's
object ID. A $schema set to the management-group template with
#subscriptionDeploymentTemplate appended as a URL fragment passed an
unanchored test() that read the fragment rather than the path.

Fixed by resolving roleDefinitionId through the template's own variables
table before recording it, so the expected set names the built-in role GUIDs
the grants resolve to rather than the variables pointing at them, with an
unresolvable form recorded as unresolved:<text> which is in no expected set
and therefore fails closed; by asserting the principal parameter carries no
defaultValue and no allowedValues, gated on having seen the parameter
referenced rather than on the declaration existing, so deleting the
declaration is not the way out; and by anchoring the $schema test on the
URL's file name.

Two further defects were found while hardening. Duplicate-key detection
joined --stream paths with a dot, which is not injective, so a template with
a dotted key beside a nested path of the same spelling was reported as having
a duplicate; dotted keys are ordinary in Azure tags, and a guard that reds
valid input invites being deleted, which is how #1545 shipped. Paths are now
compared as tojson. And the grant diff expanded ACTUAL_GRANTS unguarded,
which is unbound under set -u on the bash 3.2 that ships with macOS when the
array is empty.

Verified: every new assertion is mutation-proven against a copy of the tree,
each mutation required to produce its specific FAIL line since a syntax error
also exits non-zero. The arm/ sweep asserts a non-empty discovery, so no
violations cannot mean nothing inspected. Eleven hostile fixtures were added,
one per escalation, so each is a permanent case rather than a one-time check.
CodeRabbit reviewed the prior head across all 35 files with 0 actionable
comments; the head was then amended, so the delta since that review was
verified by hand rather than by spending another review.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/all-users Affects every user priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens triaged Item has been triaged type/security Security finding urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(iac): ARM cross-subscription template assigns the purchase role at tenant Microsoft.Capacity scope

1 participant