Repository navigation
sec(iac): scope Azure purchase role to onboarded subscription - #1658
Conversation
📝 WalkthroughWalkthroughThe 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. ChangesSubscription Scope Security and Parity Validation
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
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
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
📒 Files selected for processing (21)
arm/CUDly-CrossSubscription/template.jsonknown-issues.mdscripts/check-azure-role-parity.shscripts/test-azure-role-parity.shscripts/testdata/role-parity/canonical-scope-variants-arm.jsonscripts/testdata/role-parity/dataactions-wildcard-arm.jsonscripts/testdata/role-parity/deploymentscript-scope-escape-arm.jsonscripts/testdata/role-parity/deploymentstack-scope-escape-arm.jsonscripts/testdata/role-parity/drifted-arm.jsonscripts/testdata/role-parity/foreign-subscription-literal-with-canonical-arm.jsonscripts/testdata/role-parity/lowercase-tenant-scope-arm.jsonscripts/testdata/role-parity/lowercase-type-wildcard-actions-arm.jsonscripts/testdata/role-parity/matching-arm.jsonscripts/testdata/role-parity/mgmt-group-schema-arm.jsonscripts/testdata/role-parity/nested-deployment-arm.jsonscripts/testdata/role-parity/obfuscated-tenant-scope-arm.jsonscripts/testdata/role-parity/other-subscription-arm.jsonscripts/testdata/role-parity/second-permissions-entry-arm.jsonscripts/testdata/role-parity/tenant-scope-arm.jsonscripts/testdata/role-parity/unallowed-roledefinitionid-arm.jsonscripts/testdata/role-parity/uppercase-guid-literal-arm.json
|
@coderabbitai full review |
✅ Action performedFull 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. |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
scripts/test-azure-role-parity.sh (1)
349-352: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueCase 34 no longer proves order-dependence.
The collision check in
scripts/check-azure-role-parity.shruns 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
📒 Files selected for processing (19)
known-issues.mdscripts/check-azure-role-parity.shscripts/test-azure-role-parity.shscripts/testdata/role-parity/collision-assignablescopes-benign-first-arm.jsonscripts/testdata/role-parity/collision-assignablescopes-evil-first-arm.jsonscripts/testdata/role-parity/collision-properties-evil-first-arm.jsonscripts/testdata/role-parity/collision-roledefinitionid-evil-first-arm.jsonscripts/testdata/role-parity/collision-type-evil-first-arm.jsonscripts/testdata/role-parity/decorative-variables-arm.jsonscripts/testdata/role-parity/legacy-child-roleassignment-owner-arm.jsonscripts/testdata/role-parity/miscased-assignablescopes-arm.jsonscripts/testdata/role-parity/miscased-properties-arm.jsonscripts/testdata/role-parity/miscased-roledefinitionid-arm.jsonscripts/testdata/role-parity/miscased-scope-arm.jsonscripts/testdata/role-parity/pim-roleassignment-schedule-owner-arm.jsonscripts/testdata/role-parity/pim-roleeligibility-owner-arm.jsonscripts/testdata/role-parity/space-inside-literal-arm.jsonscripts/testdata/role-parity/uppercase-allowed-roledefinitionid-arm.jsonscripts/testdata/role-parity/uppercase-canonical-scope-arm.json
🚧 Files skipped from review as they are similar to previous changes (1)
- known-issues.md
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.
fe46a70 to
ed8e6a3
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (3)
scripts/check-azure-role-parity.sh (3)
569-581: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winA trailing comment on the default reds this check.
The
gsubat line 573 strips thedefault =prefix and trailing whitespace only. Fordefault = false # keep tenant scope off,CAPACITY_DEFAULTbecomesfalse # 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 valueMatch the schema case-insensitively for consistency.
The rest of the script folds case before comparing ARM identifiers. Line 383 matches the
$schemavalue 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 winHandle valid inline non-empty permission lists.
extract_tf_listskips values fromattr = ["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
📒 Files selected for processing (37)
arm/CUDly-CrossSubscription/template.jsonknown-issues.mdscripts/check-azure-role-parity.shscripts/test-azure-role-parity.shscripts/testdata/role-parity/canonical-scope-variants-arm.jsonscripts/testdata/role-parity/collision-assignablescopes-benign-first-arm.jsonscripts/testdata/role-parity/collision-assignablescopes-evil-first-arm.jsonscripts/testdata/role-parity/collision-properties-evil-first-arm.jsonscripts/testdata/role-parity/collision-roledefinitionid-evil-first-arm.jsonscripts/testdata/role-parity/collision-type-evil-first-arm.jsonscripts/testdata/role-parity/dataactions-wildcard-arm.jsonscripts/testdata/role-parity/decorative-variables-arm.jsonscripts/testdata/role-parity/deploymentscript-scope-escape-arm.jsonscripts/testdata/role-parity/deploymentstack-scope-escape-arm.jsonscripts/testdata/role-parity/drifted-arm.jsonscripts/testdata/role-parity/foreign-subscription-literal-with-canonical-arm.jsonscripts/testdata/role-parity/legacy-child-roleassignment-owner-arm.jsonscripts/testdata/role-parity/lowercase-tenant-scope-arm.jsonscripts/testdata/role-parity/lowercase-type-wildcard-actions-arm.jsonscripts/testdata/role-parity/matching-arm.jsonscripts/testdata/role-parity/mgmt-group-schema-arm.jsonscripts/testdata/role-parity/miscased-assignablescopes-arm.jsonscripts/testdata/role-parity/miscased-properties-arm.jsonscripts/testdata/role-parity/miscased-roledefinitionid-arm.jsonscripts/testdata/role-parity/miscased-scope-arm.jsonscripts/testdata/role-parity/nested-deployment-arm.jsonscripts/testdata/role-parity/obfuscated-tenant-scope-arm.jsonscripts/testdata/role-parity/other-subscription-arm.jsonscripts/testdata/role-parity/pim-roleassignment-schedule-owner-arm.jsonscripts/testdata/role-parity/pim-roleeligibility-owner-arm.jsonscripts/testdata/role-parity/second-permissions-entry-arm.jsonscripts/testdata/role-parity/space-inside-literal-arm.jsonscripts/testdata/role-parity/tenant-scope-arm.jsonscripts/testdata/role-parity/unallowed-roledefinitionid-arm.jsonscripts/testdata/role-parity/uppercase-allowed-roledefinitionid-arm.jsonscripts/testdata/role-parity/uppercase-canonical-scope-arm.jsonscripts/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
… 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.
|
Addressed both open threads in Thread 1 (line 108): collision scan missed root-level key collisionsConfirmed live. The collision scan was rooted at Fixed by widening the scanned objects from Thread 2 (line 433): legacy child-scoped roleAssignments refusal covered only microsoft.storageConfirmed live. Fixed by matching the suffix ( Both fixes are comparison changes only: no new modes, caps, or validation layers. Fixture verification (pre/post)Two fixtures, run against
The real production template ( Both fixtures are now permanent regression fixtures under @coderabbitai review |
|
✏️ Learnings added
✅ Action performedReview finished.
|
|
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
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 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. |
|
CodeRabbit's last verdict on this PR reviewed 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 |
|
🐇🔎 ✅ Action performedFull 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. |
|
The Same comment, further down:
And the review objects confirm it — the most recent CodeRabbit verdict with a non-empty body is still:
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 That is agent review, not CodeRabbit review, and on a security-checker change it is not a substitute for one. |
|
Retrying after the stated cooldown. The previous Still outstanding: the newest CodeRabbit verdict with a non-empty body is Using the full form deliberately: this branch head was pushed at @coderabbitai full review |
|
🐇🔎 ✅ Action performedFull 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. |
|
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 |
|
✅ Action performedFull review finished. |
There was a problem hiding this comment.
♻️ Duplicate comments (1)
known-issues.md (1)
8-17: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winKeep 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 winAssert the failure reason, not only the exit status.
run_casecompares exit codes only. Every refusal incheck-azure-role-parity.shexits 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
📒 Files selected for processing (39)
arm/CUDly-CrossSubscription/template.jsonknown-issues.mdscripts/check-azure-role-parity.shscripts/test-azure-role-parity.shscripts/testdata/role-parity/canonical-scope-variants-arm.jsonscripts/testdata/role-parity/collision-assignablescopes-benign-first-arm.jsonscripts/testdata/role-parity/collision-assignablescopes-evil-first-arm.jsonscripts/testdata/role-parity/collision-properties-evil-first-arm.jsonscripts/testdata/role-parity/collision-roledefinitionid-evil-first-arm.jsonscripts/testdata/role-parity/collision-root-resources-evil-first-arm.jsonscripts/testdata/role-parity/collision-type-evil-first-arm.jsonscripts/testdata/role-parity/dataactions-wildcard-arm.jsonscripts/testdata/role-parity/decorative-variables-arm.jsonscripts/testdata/role-parity/deploymentscript-scope-escape-arm.jsonscripts/testdata/role-parity/deploymentstack-scope-escape-arm.jsonscripts/testdata/role-parity/drifted-arm.jsonscripts/testdata/role-parity/foreign-subscription-literal-with-canonical-arm.jsonscripts/testdata/role-parity/legacy-child-keyvault-roleassignment-owner-arm.jsonscripts/testdata/role-parity/legacy-child-roleassignment-owner-arm.jsonscripts/testdata/role-parity/lowercase-tenant-scope-arm.jsonscripts/testdata/role-parity/lowercase-type-wildcard-actions-arm.jsonscripts/testdata/role-parity/matching-arm.jsonscripts/testdata/role-parity/mgmt-group-schema-arm.jsonscripts/testdata/role-parity/miscased-assignablescopes-arm.jsonscripts/testdata/role-parity/miscased-properties-arm.jsonscripts/testdata/role-parity/miscased-roledefinitionid-arm.jsonscripts/testdata/role-parity/miscased-scope-arm.jsonscripts/testdata/role-parity/nested-deployment-arm.jsonscripts/testdata/role-parity/obfuscated-tenant-scope-arm.jsonscripts/testdata/role-parity/other-subscription-arm.jsonscripts/testdata/role-parity/pim-roleassignment-schedule-owner-arm.jsonscripts/testdata/role-parity/pim-roleeligibility-owner-arm.jsonscripts/testdata/role-parity/second-permissions-entry-arm.jsonscripts/testdata/role-parity/space-inside-literal-arm.jsonscripts/testdata/role-parity/tenant-scope-arm.jsonscripts/testdata/role-parity/unallowed-roledefinitionid-arm.jsonscripts/testdata/role-parity/uppercase-allowed-roledefinitionid-arm.jsonscripts/testdata/role-parity/uppercase-canonical-scope-arm.jsonscripts/testdata/role-parity/uppercase-guid-literal-arm.json
…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
…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.
Problem
arm/CUDly-CrossSubscription/template.jsonis 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:roleAssignmentwith"scope": "/providers/Microsoft.Capacity"— as an absolute path that denotes tenant-wide reservation orders./providers/Microsoft.Capacityin the custom role definition'sassignableScopes— declaring the role eligible for assignment at tenant scope by anyone who can create role assignments there.Verified live on current
origin/main(887d51fd6), attemplate.json:64-67and:86-100.What the grant actually did
Worth recording, because it is narrower than the template reads. Running
az deployment sub validateon the pre-fix template resolves the tenant assignment to:Note the doubled
providers/providers. In a subscription-scoped deployment ARM appends thescopevalue 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
assignableScopes/subscriptions/{id}+/providers/Microsoft.Capacity/subscriptions/{id}only/providers/Microsoft.CapacityIn 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 createtargeted.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, withinclude_capacity_provider_scopedefaulting tofalse.known-issues.mdrecords the origin: the tenant assignment was a workaround for the built-inReservation Readerrole 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.billingScopeId, not against the tenant-levelMicrosoft.Capacitypath.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.mdas a separate, manual, explicitly consentedaz role assignment createstep — 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.shcompared 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:
/providers/microsoft.capacitylowercased — Azure namespaces are case-insensitive, bash=~is notshopt -s nocasematchMicrosoft.Resources/deployments, or nested in a parent resource, or type in different casing..walk,ascii_downcaseon type, and refuse nested deployments outrightvariables.tfabsent — the default-false assertion silently skipped[concat('/subscriptions/', parameters('otherSubscriptionId'))]— passes a substring anchor, grants in another subscription$schemarepointed at the management-group template, widening every scope-less assignment without touching a scope string$schemapinned tosubscriptionDeploymentTemplateCorrecting 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 explicitscopeat 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:
No workflow file was touched: the existing
azure-role-parityCI 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:
/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).nocasematchwas also live across that regex, so an uppercase GUID passed too.[subscription().id]equivalent, is accepted..resources[]array with a case-sensitive==, while the scope walk was recursive and case-insensitive. A second role definition typedmicrosoft.authorization/roleDefinitions(lowercase) grantingactions: ["*"]was invisible to the actions axis — the guard printedOK: ARM and TF actions lists matchand correctly counted the extra role on the scope axis, proving the two axes disagreed about how many role definitions existed.permissions[0].actionswas compared. ARM unions everypermissions[]entry, so a second entry appended after the canonical one (e.g.{"actions": ["*"]}) passed.notActions,dataActions, andnotDataActionswere never compared at all — a first entry withdataActions: ["*"]also passed.permissions[].actions[]is now flattened across the whole array, andnotActions/dataActions/notDataActionsare compared the same wayactionsis."vs'), a redundant empty-stringconcatargument, and the[subscription().id]equivalent all failed despite being semantically identical to the canonical expression. A pure reformat could red CI.Microsoft.Resources/deploymentScriptsandMicrosoft.Resources/deploymentStackswere not refused, unlike plaindeployments— despite a deployment script's runtimeaz/az-cli commands being able to issue role assignments this check never sees as JSON.deployments.roleDefinitionIdwas unconstrained as long as it carried no explicitscope: a built-in Owner grant correctly inheriting the (subscription-scoped) deployment passed, since only the scope axis was checked.roleDefinitionIdis 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:shellcheckon both scripts: exit 0 (no findings).bash -non both scripts: exit 0. Verified against the realarm/CUDly-CrossSubscription/template.jsonandterraform/modules/iam/azure/cudly-reservation-role/main.tf: still exit 0.One item deliberately left alone: several malformed-input self-test variants exit
5(ajqerror surfacing throughset -e) rather than1. 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 trailingparameters('evil')argument after the empty-string arg, amanagementGroup().idsuffix, a foreign literal — none of which normalize to canonical...over the whole document. A decorative object undervariablesoroutputsthat merely happened to carry a roleDefinitions-shaped"type"field — never actually deployed — was treated as a real grant, reproduced directly: avariables.decorativeDocOnlyobject grantingactions: ["*"]at/providers/Microsoft.Capacitycaused 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 ownresourcesarray 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 realtemplate.jsongenuinely 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 aresourceId()/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: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..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.roleDefinitionId,assignableScopes,notActions,dataActions,notDataActions) rewritten to match.Microsoft.Authorization/roleEligibilityScheduleRequestsandroleAssignmentScheduleRequests(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 theroleAssignments-only type match never saw, bypassing this PR's ownroleDefinitionIdallowlist by changing the resource type. Also added: the legacyMicrosoft.Storage/storageAccounts/providers/roleAssignmentschild-type spelling, invisible for the same reason.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
principalIdpasses 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:assignableScopesgiven as a bare string exits5via a raw jq error rather than a clean1(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:shellcheckon 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 thehas()trap — downcasing only the extraction expressions while leavinghas("scope")/has("roleDefinitionId")case-sensitive, which would look fixed while staying open — was avoided: the whole document is downcased once viawalk()before any query, including the twohas()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 nocasematchscope set for the (unrelated, already-documented)ESCAPE_TOKENSregex match: the canonical-scope comparison innormalize_scope_expr, and theALLOWED_ROLE_DEFINITION_IDSallowlist comparison. Confirmed empirically that this dependency was real (a probe withshopt -u nocasematchcorrectly 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_exprlowercases as an explicit final step, and theroleDefinitionIdloop lowercases both sides before comparing — matching the same "ARM identifiers are case-insensitive" reasoning already applied to resourcetypevalues viaascii_downcaseelsewhere 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 allowedroleDefinitionId([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.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 fourfixtures 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 resourcewith one miscased property key is no longer invisible. But
from_entries(which
with_entriesis built on) keeps the last entry when two entriesfold 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:
"AssignableScopes": ["/providers/Microsoft.Capacity"]then"assignableScopes": [<canonical>]— issue #1545 itself, reached via key collision instead of a single miscased key."RoleDefinitionId": <built-in Owner>then"roleDefinitionId": "[variables('roles').reader]"on a roleAssignment'spropertiesobject."Properties": {Owner}then"properties": {Reader}— same shape, one level up, as the two top-level keys of a roleAssignment resource."Type": "Microsoft.Resources/deployments"then"type": "...roleAssignments"— also evadesREFUSED_TYPES: with theType/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
jqdoes, this script's prior behavior wascoincidentally 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):
arm/CUDly-CrossSubscription/template.jsonand on every one of the 28pre-existing role-parity fixtures — no false positives.
99ea68759(checked outand 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 / assignableScopesvs.Type / type) — not incidentallyalongside an unrelated check.
Self-tests go from 29 to 34:
shellcheck0.11.0 on both scripts: exit 0 (no findings).bash -non bothscripts: 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.jsonwas affected, soClosesis safe here:iac/federation/azure-target/bicep/azure-wif.bicep+.arm.jsoniac/federation/azure-target/terraform/main.tfterraform/modules/compute/azure/container-apps/main.tfterraform/modules/iam/azure/cudly-reservation-roleinternal/iacfiles/templates/azure-*arm/CUDly-CrossSubscription/setup.shgo:embedsetsVerification (exit codes)
az deployment sub validate(post-fix)provisioningState: Succeeded,error: null— all 4 validated resources under/subscriptions/<subId>/az deployment sub validate(pre-fix control)providerspathjq empty template.jsonshellcheck(both scripts)check-azure-role-parity.shon fixed templatecheck-azure-role-parity.shon pre-fix templatetest-azure-role-parity.shaz bicep decompileon the pre-fix template also emittedBCP036: The property "scope" expected a value of type "resource | tenant"for the tenant assignment; that error is gone post-fix. RemainingBCP034decompiler warnings ondependsOnare 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:
/providers/Microsoft.Capacityfor the CUDly SP, projecting the assignmentid.--ids, before redeploying. Not by--scope: the CLI rejects the malformed doubled-providersshape as an invalid scope, which would leave an operator with a row that is listed but undeletable.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 validatedoes not preflight authorization, butaz deployment sub what-ifdoes, and it returns a hardAuthorizationFailedon exactly that resource over the malformed scope. So the doubled-providerspath is not a validator artifact: ARM genuinely computes that target and preflight fails on it. Separately, per this repo's ownvariables.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:
assignableScopeswas 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
roleAssignmentGuidPrefixparameter, pre-existing, deliberately kept out of this p0 diff).Closes #1545
Summary by CodeRabbit
Bug Fixes
Tests
Documentation