From 2b8cde2bf4ec028246b9374653a6c7ad748578c1 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 19 Aug 2026 11:48:42 +0200 Subject: [PATCH] fix(scripts): constrain who the Azure onboarding template grants to, 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 , 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:, 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: 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": ` followed by `"principalId": ` 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 --- scripts/check-azure-role-parity.sh | 677 +++++++++++++++++- scripts/test-azure-role-parity.sh | 590 ++++++++++++++- .../role-parity/absent-principal-arm.json | 89 +++ .../actions-element-not-string-arm.json | 91 +++ .../role-parity/actions-not-array-arm.json | 78 ++ ...signablescopes-element-not-string-arm.json | 93 +++ .../assignablescopes-not-array-arm.json | 88 +++ .../collision-non-string-type-arm.json | 95 +++ .../role-parity/condition-false-arm.json | 91 +++ .../testdata/role-parity/copy-loop-arm.json | 94 +++ .../dotted-key-no-duplicate-arm.json | 96 +++ .../role-parity/duplicate-grant-arm.json | 100 +++ .../duplicate-principalid-key-arm.fixture | 91 +++ .../testdata/role-parity/empty-arm.fixture | 0 .../expected-set-baseline-arm.json | 90 +++ .../role-definition-and-reader.grants | 7 + .../expected/role-definition-only.grants | 9 + .../foreign-principal-literal-arm.json | 100 +++ .../foreign-principal-parameter-arm.json | 104 +++ .../legacy-child-relative-type-arm.json | 99 +++ .../role-parity/missing-grant-arm.json | 80 +++ .../testdata/role-parity/not-json-arm.fixture | 1 + .../permissions-element-not-object-arm.json | 91 +++ ...principal-parameter-allowedvalues-arm.json | 93 +++ .../principal-parameter-default-arm.json | 91 +++ .../principal-parameter-undeclared-arm.json | 86 +++ .../principalid-on-roledefinition-arm.json | 91 +++ .../role-parity/resources-not-array-arm.json | 24 + .../role-parity/root-not-object-arm.json | 92 +++ .../schema-fragment-mgmt-group-arm.json | 90 +++ .../role-parity/schema-fragment-tail-arm.json | 90 +++ .../unrecognized-resource-type-arm.json | 96 +++ ...ppercase-allowed-roledefinitionid-arm.json | 12 +- .../variable-customrole-repointed-arm.json | 90 +++ .../variable-repointed-to-owner-arm.json | 90 +++ .../variable-resolves-to-object-arm.json | 92 +++ .../role-parity/variable-value-empty-arm.json | 90 +++ .../variable-value-with-newline-arm.json | 90 +++ 38 files changed, 4009 insertions(+), 62 deletions(-) create mode 100644 scripts/testdata/role-parity/absent-principal-arm.json create mode 100644 scripts/testdata/role-parity/actions-element-not-string-arm.json create mode 100644 scripts/testdata/role-parity/actions-not-array-arm.json create mode 100644 scripts/testdata/role-parity/assignablescopes-element-not-string-arm.json create mode 100644 scripts/testdata/role-parity/assignablescopes-not-array-arm.json create mode 100644 scripts/testdata/role-parity/collision-non-string-type-arm.json create mode 100644 scripts/testdata/role-parity/condition-false-arm.json create mode 100644 scripts/testdata/role-parity/copy-loop-arm.json create mode 100644 scripts/testdata/role-parity/dotted-key-no-duplicate-arm.json create mode 100644 scripts/testdata/role-parity/duplicate-grant-arm.json create mode 100644 scripts/testdata/role-parity/duplicate-principalid-key-arm.fixture create mode 100644 scripts/testdata/role-parity/empty-arm.fixture create mode 100644 scripts/testdata/role-parity/expected-set-baseline-arm.json create mode 100644 scripts/testdata/role-parity/expected/role-definition-and-reader.grants create mode 100644 scripts/testdata/role-parity/expected/role-definition-only.grants create mode 100644 scripts/testdata/role-parity/foreign-principal-literal-arm.json create mode 100644 scripts/testdata/role-parity/foreign-principal-parameter-arm.json create mode 100644 scripts/testdata/role-parity/legacy-child-relative-type-arm.json create mode 100644 scripts/testdata/role-parity/missing-grant-arm.json create mode 100644 scripts/testdata/role-parity/not-json-arm.fixture create mode 100644 scripts/testdata/role-parity/permissions-element-not-object-arm.json create mode 100644 scripts/testdata/role-parity/principal-parameter-allowedvalues-arm.json create mode 100644 scripts/testdata/role-parity/principal-parameter-default-arm.json create mode 100644 scripts/testdata/role-parity/principal-parameter-undeclared-arm.json create mode 100644 scripts/testdata/role-parity/principalid-on-roledefinition-arm.json create mode 100644 scripts/testdata/role-parity/resources-not-array-arm.json create mode 100644 scripts/testdata/role-parity/root-not-object-arm.json create mode 100644 scripts/testdata/role-parity/schema-fragment-mgmt-group-arm.json create mode 100644 scripts/testdata/role-parity/schema-fragment-tail-arm.json create mode 100644 scripts/testdata/role-parity/unrecognized-resource-type-arm.json create mode 100644 scripts/testdata/role-parity/variable-customrole-repointed-arm.json create mode 100644 scripts/testdata/role-parity/variable-repointed-to-owner-arm.json create mode 100644 scripts/testdata/role-parity/variable-resolves-to-object-arm.json create mode 100644 scripts/testdata/role-parity/variable-value-empty-arm.json create mode 100644 scripts/testdata/role-parity/variable-value-with-newline-arm.json diff --git a/scripts/check-azure-role-parity.sh b/scripts/check-azure-role-parity.sh index 312e18dde..5b813d77a 100755 --- a/scripts/check-azure-role-parity.sh +++ b/scripts/check-azure-role-parity.sh @@ -2,11 +2,15 @@ # check-azure-role-parity.sh # # Asserts that the Azure custom role stays in parity across both sources of -# truth, on two axes: +# truth, on four axes: # -# 1. ACTIONS: the permission lists (actions, notActions, dataActions, -# notDataActions) are identical (case-insensitively). -# 2. SCOPE: no grant escapes the subscription being onboarded. +# 1. ACTIONS: the permission lists (actions, notActions, dataActions, +# notDataActions) are identical (case-insensitively). +# 2. SCOPE: no grant escapes the subscription being onboarded. +# 3. PRINCIPAL: every grant goes to the service principal the customer +# supplies at deploy time, and to nothing else. +# 4. GRANT SET: the template contains EXACTLY the grants it is supposed to, +# no more and no fewer. # # TF module : terraform/modules/iam/azure/cudly-reservation-role/main.tf # ARM template: arm/CUDly-CrossSubscription/template.json @@ -18,27 +22,63 @@ # onboarded), while the TF module deliberately granted subscription scope # only. Both files agreed on actions, so the parity gate stayed green. # -# Exit 0 = actions match and every scope is subscription-anchored. -# Exit 1 = drift; the offending values are printed to stderr. +# The principal and grant-set axes exist because axes 1 and 2 constrain WHICH +# role is granted and WHERE, but never WHO it is granted to, and never how +# many grants there are (issue #1681). A fourth roleAssignments resource +# binding the allowed custom purchaser role -- the one carrying +# Microsoft.Capacity/reservationOrders/purchase/action, which spends the +# customer's money -- at the correctly inherited subscription scope, to a +# hardcoded foreign principalId, passed this script cleanly: valid ARM, +# actions in parity, canonical scope, allowed role. So did a duplicate of an +# existing grant, a grant deleted outright, a role assignment carrying no +# principalId at all, and a resource of a type this script has never heard of. +# Each of those simply changed the number in the final "OK: all N ..." line, +# which asserted that a check had happened rather than that anything in +# particular was true. +# +# Exit 0 = actions match, every scope is subscription-anchored, every grant +# goes to the expected principal, and the grant set is exactly the +# expected one. +# Exit 1 = drift, or a template shape these checks cannot iterate; the +# offending values are printed to stderr. +# Exit 2 = the script could not run at all (missing jq, unknown flag). # # Usage: # scripts/check-azure-role-parity.sh [--tf-file ] [--arm-file ] +# [--expected-grants ] # -# The --tf-file / --arm-file flags let the test harness substitute fixture files -# without touching the real sources. +# The --tf-file / --arm-file / --expected-grants flags let the test harness +# substitute fixture files without touching the real sources. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ARM_DIR="${REPO_ROOT}/arm" TF_FILE="${REPO_ROOT}/terraform/modules/iam/azure/cudly-reservation-role/main.tf" -ARM_FILE="${REPO_ROOT}/arm/CUDly-CrossSubscription/template.json" +ARM_FILE="${ARM_DIR}/CUDly-CrossSubscription/template.json" + +# The expected grant set (axis 4) is a property of the real template, so it is +# spelled out below as EXPECTED_GRANTS rather than read from a file. The flag +# exists for the harness, whose fixtures are deliberately smaller templates. +EXPECTED_GRANTS_FILE="" +ARM_FILE_OVERRIDDEN=0 # Allow override via flags (used by the test harness). while [[ $# -gt 0 ]]; do case "$1" in - --tf-file) TF_FILE="$2"; shift 2 ;; - --arm-file) ARM_FILE="$2"; shift 2 ;; + --tf-file|--arm-file|--expected-grants) + if [[ $# -lt 2 ]]; then + echo "Flag $1 requires a value." >&2 + exit 2 + fi + case "$1" in + --tf-file) TF_FILE="$2" ;; + --arm-file) ARM_FILE="$2"; ARM_FILE_OVERRIDDEN=1 ;; + --expected-grants) EXPECTED_GRANTS_FILE="$2" ;; + esac + shift 2 + ;; *) echo "Unknown flag: $1" >&2; exit 2 ;; esac done @@ -61,6 +101,142 @@ if ! command -v jq &>/dev/null; then exit 2 fi +# --- refuse an ARM template nothing checks ----------------------------------- +# Every assertion below is specific to THIS template: the expected grant set, +# the allowed roleDefinitionIds and the canonical scope expression all name +# what arm/CUDly-CrossSubscription/template.json is supposed to contain. A +# SECOND template anywhere under arm/ would be onboarding customer +# subscriptions with nothing checking it at all, and naming the one file this +# guard already knows about is how a guard fails to reach its own sibling +# site. Discover the set and refuse anything outside it, rather than listing +# what is covered. +# +# `find -L`, because `-type f` alone reports a symlink as neither a file nor +# something to refuse, so a second template symlinked into arm/ is swept past +# rather than found. +# +# `find -print0` with `read -d ''` rather than a `**` glob: `globstar` is bash +# 4 and this runs on the bash 3.2 that ships with macOS. `sort -z` so the +# order is deterministic, since `find` order is not defined, and the loop runs +# in the current shell via process substitution, because a pipeline subshell +# would build the array and then discard it. Same idiom, same reasons, as +# build_swept_scripts in scripts/lib/code-scan-awk.sh. +# +# Every *.json is refused, with no attempt to tell a deployment template from +# a parameters file by its $schema: a shape test deciding which files are +# worth checking is a second thing to get wrong, and there is exactly one +# .json under arm/ today. Adding another is a deliberate act, and its author +# extends this guard first -- the rule the REFUSED_TYPES message below already +# states for a resource type this script does not recognize. +# +# An empty discovery is a failure, not a pass: a sweep that opened no file has +# no unchecked templates either, and would report clean while every template +# in the tree was unguarded. +# +# Skipped when --arm-file overrides the default, because the harness fixtures +# live outside arm/ and this assertion has nothing to say about them. The CI +# invocation passes no flags, so it always runs there. +if [[ "$ARM_FILE_OVERRIDDEN" -eq 0 ]]; then + ARM_TEMPLATES=() + while IFS= read -r -d '' candidate; do + ARM_TEMPLATES+=("$candidate") + done < <(find -L "$ARM_DIR" -type f -name '*.json' -print0 2>/dev/null | sort -z) + + UNCHECKED_TEMPLATES="" + FOUND_ARM_FILE=0 + for candidate in ${ARM_TEMPLATES[@]+"${ARM_TEMPLATES[@]}"}; do + if [[ "$candidate" == "$ARM_FILE" ]]; then + FOUND_ARM_FILE=1 + continue + fi + UNCHECKED_TEMPLATES+=" ${candidate}"$'\n' + done + + # The sweep must have found the file this script goes on to check. The + # `-f "$ARM_FILE"` test above already refuses the case where that file is + # missing, so what is left here is a sweep that could not read ${ARM_DIR} at + # all and reported nothing to refuse: an empty result and a clean result look + # identical to the loop above. + if [[ "$FOUND_ARM_FILE" -eq 0 ]]; then + echo "ERROR: the sweep of ${ARM_DIR} did not find ${ARM_FILE}," >&2 + echo " which exists. Nothing was read, so nothing was refused." >&2 + exit 1 + fi + + if [[ -n "$UNCHECKED_TEMPLATES" ]]; then + echo "ERROR: ${ARM_DIR} holds JSON this guard does not check:" >&2 + echo "" >&2 + printf '%s' "$UNCHECKED_TEMPLATES" >&2 + echo "" >&2 + echo " Everything below is specific to ${ARM_FILE}: its expected grant" >&2 + echo " set, its allowed roleDefinitionIds, its canonical scope. A second" >&2 + echo " template deploys into customer subscriptions with none of that" >&2 + echo " asserted about it (issue #1681). Extend this script to cover the" >&2 + echo " new file before adding it." >&2 + exit 1 + fi +fi + +# --- refuse a document jq cannot read at all --------------------------------- +# Both checks are ahead of every jq query below, including the collision scan, +# because those queries index the root: a document that is not JSON, or whose +# root is an array or a scalar, aborts the first of them and surfaces jq's own +# exit status through `set -e` instead of a verdict from this script. +if ! jq empty "$ARM_FILE" >/dev/null 2>&1; then + echo "ERROR: ARM template is not valid JSON: $ARM_FILE" >&2 + echo " jq says:" >&2 + jq empty "$ARM_FILE" 2>&1 | sed 's/^/ /' >&2 || true + exit 1 +fi + +if ! jq -e 'type == "object"' "$ARM_FILE" >/dev/null 2>&1; then + # An empty file parses without error and yields no value at all, so the type + # is reported as absent rather than as an empty string. + ROOT_TYPE="$(jq -r 'type' "$ARM_FILE" 2>/dev/null || true)" + echo "ERROR: ARM template root has type ${ROOT_TYPE:-}, expected an object." >&2 + echo " ARM source: $ARM_FILE" >&2 + echo " Every check below reads named keys off the root document." >&2 + exit 1 +fi + +# --- refuse exact-duplicate JSON keys, before jq resolves them --------------- +# The case-variant collision scan below reads `keys_unsorted`, which is what +# jq's PARSER produced: two entries spelled identically -- `"principalId": A` +# followed by `"principalId": B` in the same object -- have already been +# collapsed to one by then, keeping the last. The scan cannot see them, and +# every check in this script reads B while a reviewer skimming the file reads +# whichever comes first. +# +# That is the same ambiguity the scan below refuses, in its sharpest form: +# which of two identically-spelled keys ARM itself honors is not something this +# script can determine from the file, and a template ambiguous about its own +# grants must not be what decides whether CI is green. +# +# Detected with `--stream`, which reports every leaf as it is parsed rather +# than as a merged object, so a repeated path is a duplicate key. +# +# The path is compared as `tojson`, not joined with a separator: joining is not +# injective, so a key that itself contains the separator collides with a nested +# path -- `{"a.b": 1, "a": {"b": 2}}` joins to "a.b" twice and reds a template +# whose keys are all distinct. Dotted keys are ordinary in Azure tags. +DUPLICATE_KEY_PATHS=$( + jq -rc --stream 'select(length == 2) | .[0] | tojson' "$ARM_FILE" | sort | uniq -d +) + +if [[ -n "$DUPLICATE_KEY_PATHS" ]]; then + echo "ERROR: ARM template declares the same JSON key twice in one object." >&2 + echo "" >&2 + echo " ARM source: $ARM_FILE" >&2 + echo "" >&2 + printf '%s\n' "$DUPLICATE_KEY_PATHS" | sed 's/^/ /' >&2 + echo "" >&2 + echo "jq keeps the last of two identically-spelled keys, so every check in" >&2 + echo "this script reads that one while the file shows both. A foreign" >&2 + echo "principalId spelled ahead of the sanctioned one is invisible here for" >&2 + echo "exactly that reason (issue #1681). Keep one key per property." >&2 + exit 1 +fi + # --- refuse ambiguous same-object key collisions, before normalization ------- # The normalization step just below folds every object key to lowercase with # `with_entries(.key |= ascii_downcase)`. jq's `from_entries` (which @@ -110,8 +286,8 @@ COLLISION_DETAIL=$(jq -r ' | ($obj | keys_unsorted) as $keys | ($keys | group_by(ascii_downcase) | map(select(length > 1))) as $collisions | select($collisions | length > 0) - | { type: ($obj.type // $obj.Type // ""), - name: ($obj.name // $obj.Name // ""), + | { type: ($obj.type // $obj.Type // "" | tostring), + name: ($obj.name // $obj.Name // "" | tostring), colliding_key_groups: ($collisions | map(join(" / "))) } ] | .[] @@ -161,6 +337,108 @@ trap 'rm -f "$ARM_FILE_NORM"' EXIT jq 'walk(if type == "object" then with_entries(.key |= ascii_downcase) else . end)' \ "$ARM_FILE" > "$ARM_FILE_NORM" +# --- refuse a document shape the selectors below cannot iterate -------------- +# `resources`, `permissions` and `assignableScopes` are all iterated by jq +# below, and `properties` is indexed. When one of them is present but is not +# the type its selector assumes -- assignableScopes given as a bare JSON +# string is the shape that surfaced this -- jq aborts mid-pipeline and `set +# -e` propagates jq's own exit status (5) with jq's own message, which is +# neither of this script's documented outcomes. It still fails closed, so this +# is about diagnosis rather than about a hole: refuse the shape up front, with +# this script's own message and its own exit 1, so a malformed template is +# told what is wrong with it. +# +# Checked against the normalized copy, so a miscased key is caught here the +# same way it is everywhere else, and rooted at `.resources` like every other +# walk, so a decorative object under `variables` cannot trip it. +SHAPE_ERRORS=$(jq -r ' + def flat: tostring | gsub("[\\t\\r\\n]"; " "); + [ (if has("resources") and ((.resources | type) != "array") + then " .resources is a " + (.resources | type) + ", expected an array" + else empty end), + ( .resources // [] | .. | objects | select(has("type")) + | select((.type | type) == "string") + | select((.type | ascii_downcase) + | . == "microsoft.authorization/roledefinitions" + or . == "microsoft.authorization/roleassignments") + | . as $r + | (" " + ($r.type | flat) + " " + (($r.name // "") | flat) + ": ") as $where + | if (($r | has("properties")) and (($r.properties | type) != "object")) + then $where + "properties is a " + ($r.properties | type) + ", expected an object" + elif (($r.properties | type) == "object") + and ($r.properties | has("permissions")) + and (($r.properties.permissions | type) != "array") + then $where + "properties.permissions is a " + ($r.properties.permissions | type) + + ", expected an array" + elif (($r.properties | type) == "object") + and ($r.properties | has("assignablescopes")) + and (($r.properties.assignablescopes | type) != "array") + then $where + "properties.assignableScopes is a " + + ($r.properties.assignablescopes | type) + ", expected an array" + else + ( ( if (($r.properties | type) == "object") + and (($r.properties.permissions | type) == "array") + then ($r.properties.permissions | to_entries[] + | if (.value | type) != "object" + then $where + "properties.permissions[" + (.key | tostring) + "] is a " + + (.value | type) + ", expected an object" + else ( .key as $i | .value | to_entries[] + | select(.key | . == "actions" or . == "notactions" + or . == "dataactions" or . == "notdataactions") + | select((.value | type) != "array") + | $where + "properties.permissions[" + ($i | tostring) + "]." + + .key + " is a " + (.value | type) + ", expected an array" ) + end ) + else empty end ), + ( if (($r.properties | type) == "object") + and (($r.properties.permissions | type) == "array") + then ($r.properties.permissions | to_entries[] + | select((.value | type) == "object") + | .key as $i | .value | to_entries[] + | select(.key | . == "actions" or . == "notactions" + or . == "dataactions" or . == "notdataactions") + | select((.value | type) == "array") + | .key as $list | .value | to_entries[] + | select((.value | type) != "string") + | $where + "properties.permissions[" + ($i | tostring) + "]." + $list + + "[" + (.key | tostring) + "] is a " + (.value | type) + + ", expected a string") + else empty end ), + ( if (($r.properties | type) == "object") + and (($r.properties.assignablescopes | type) == "array") + then ($r.properties.assignablescopes | to_entries[] + | select((.value | type) != "string") + | $where + "properties.assignableScopes[" + (.key | tostring) + "] is a " + + (.value | type) + ", expected a string") + else empty end ), + # `copy` multiplies the resource it sits on into N deployed + # resources whose properties can vary by copyIndex(); `condition` + # is the same statement with N of 0 or 1, and a `condition` of + # false deletes a grant while leaving its declaration in the file + # for the set below to count. Either way one declaration is no + # longer one grant and the set asserted is not the set deployed. + # Refused rather than modelled: this template has neither, and + # adding one changes what a grant even means here. + ( if ($r | has("copy")) then $where + "carries a copy loop" else empty end ), + ( if ($r | has("condition")) + then $where + "carries a condition, so whether it deploys is not in the file" + else empty end ) ) + end ) + ] | .[] +' "$ARM_FILE_NORM") + +if [[ -n "$SHAPE_ERRORS" ]]; then + echo "ERROR: ARM template has a shape these checks cannot inspect." >&2 + echo "" >&2 + echo " ARM source: $ARM_FILE" >&2 + echo "" >&2 + echo "$SHAPE_ERRORS" >&2 + echo "" >&2 + echo "Every one of these is iterated or indexed below. A value of the wrong" >&2 + echo "type aborts the check partway through instead of producing a verdict." >&2 + exit 1 +fi + # --- extract permission lists from TF ----------------------------------------- # Pulls each of actions / not_actions / data_actions / not_data_actions out of # the `permissions { ... }` block of the azurerm_role_definition resource. @@ -313,6 +591,18 @@ echo "OK: ARM and TF actions/notActions/dataActions/notDataActions lists match ( CANONICAL_SCOPE_EXPR="[concat('/subscriptions/', subscription().subscriptionId)]" CANONICAL_SCOPE_EXPR_ALT="[subscription().id]" +# The only principal this template may ever grant to (issue #1681): the CUDly +# service principal whose object ID the customer supplies at deploy time. Any +# other value is a grant to somebody else, and a hardcoded GUID literal is one +# by construction -- the deploying customer cannot have written it, and this +# script cannot tell a foreign object ID from a legitimate one by its shape, +# the same reason a bare literal /subscriptions/ is refused above rather +# than shape-matched. Compared as ARM expression text, normalized the same way +# the scope expressions are. +EXPECTED_PRINCIPAL_PARAM="servicePrincipalObjectId" +EXPECTED_PRINCIPAL_EXPR="[parameters('${EXPECTED_PRINCIPAL_PARAM}')]" + + # Byte-exact comparison against CANONICAL_SCOPE_EXPR is brittle: whitespace # placement, quote style, and a redundant empty-string concat argument are all # spellings a well-meaning author could reach for that denote the identical @@ -358,6 +648,11 @@ normalize_scope_expr() { } CANONICAL_SCOPE_NORM="$(normalize_scope_expr "$CANONICAL_SCOPE_EXPR")" CANONICAL_SCOPE_ALT_NORM="$(normalize_scope_expr "$CANONICAL_SCOPE_EXPR_ALT")" +# normalize_scope_expr is named for its first caller but is a general ARM +# expression normalizer (trim, quote style, punctuation-adjacent whitespace, +# case); principalId is ARM expression text under the same case-insensitivity +# rules, so it is compared the same way. +EXPECTED_PRINCIPAL_NORM="$(normalize_scope_expr "$EXPECTED_PRINCIPAL_EXPR")" # Retained as defence in depth. The exact-match allowlist above already rejects # every one of these, but they name the specific scopes that motivated the @@ -391,7 +686,18 @@ ALLOWED_ROLE_DEFINITION_IDS=( # Repointing $schema at the management-group template would silently land all # of them at management-group scope, covering every child subscription, without # changing a single scope string. Pin it explicitly. -if ! jq -e '.["$schema"] | test("subscriptionDeploymentTemplate")' "$ARM_FILE_NORM" >/dev/null 2>&1; then +# +# Matched on the schema URL's PATH, which is the part ARM resolves, with the +# fragment and query excluded from the match rather than merely allowed after +# it. Two revisions of this check got that wrong in the same way: an unanchored +# `test("subscriptionDeploymentTemplate")` accepted the management-group schema +# with "#subscriptionDeploymentTemplate" appended, and anchoring on the name +# with `(#.*)?$` still accepted it with "#/subscriptionDeploymentTemplate.json" +# appended, because the sanctioned name was then the tail of the fragment. The +# path is everything before the first `#` or `?`, so that is what is matched. +if ! jq -e '.["$schema"] | strings + | test("^[^#?]*/subscriptiondeploymenttemplate\\.json([#?].*)?$"; "i")' \ + "$ARM_FILE_NORM" >/dev/null 2>&1; then ACTUAL_SCHEMA=$(jq -r '.["$schema"] // ""' "$ARM_FILE_NORM") echo "ERROR: ARM template is not a subscription-scoped deployment." >&2 echo " \$schema: ${ACTUAL_SCHEMA}" >&2 @@ -419,7 +725,7 @@ fi # match, so the roleDefinitionId allowlist above is bypassed simply by # changing the resource type. # -# */providers/roleAssignments -- the legacy ARM spelling for a role +# *providers/roleAssignments -- the legacy ARM spelling for a role # assignment as a child resource (a full `.../providers/...` type path) # rather than a separate top-level roleAssignments resource with a `scope` # property. Same grant, invisible to the same selectors for the same @@ -445,7 +751,7 @@ REFUSED_TYPE_COUNT=$( [.resources // [] | .. | objects | select(has("type")) | select((.type|type) == "string") | select((.type|ascii_downcase) as $t | ($types | index($t) != null) - or ($t | endswith("/providers/roleassignments")))] + or ($t | endswith("providers/roleassignments")))] | length ' "$ARM_FILE_NORM" ) @@ -462,9 +768,9 @@ if [[ "$REFUSED_TYPE_COUNT" != "0" ]]; then exit 1 fi -# Collect every scope value and roleDefinitionId the template grants, -# one per line, tagged with where it came from so the error message points at -# the right JSON node. +# Collect every scope value, roleDefinitionId and principalId the template +# grants, one per line, tagged with where it came from so the error message +# points at the right JSON node. # # The walk is rooted at `.resources` and recursive (`.resources // [] | ..`) # from there, rather than over the top-level `resources` array's direct @@ -473,6 +779,16 @@ fi # `variables` or `outputs` that is never actually deployed is not, so it # cannot red an otherwise-correct template. Type matching is case-insensitive # because ARM resource types are, while jq's `==` is not. +# +# principalId is collected by KEY PRESENCE on any object under `.resources`, +# not from roleAssignments resources only, and this asymmetry is deliberate. +# `principalId` names the recipient of a grant wherever it appears, and the +# shapes that carry one are not a list this script can finish writing: the PIM +# schedule requests and the legacy `.../providers/roleAssignments` child type +# refused above are two that were already found, and the refusal list is only +# ever as long as somebody's memory. The key itself is the invariant, so it is +# what is matched. It also means the value is read out of the object that +# actually holds it -- `properties` -- which carries no `type` of its own. SCOPES=$( jq -r ' [.resources // [] | .. | objects | select(has("type")) | select((.type|type) == "string")] as $all @@ -487,7 +803,13 @@ SCOPES=$( ( $all[] | select((.type|ascii_downcase) == "microsoft.authorization/roleassignments") | select(has("properties") and (.properties|has("roledefinitionid"))) - | "roleAssignment.roleDefinitionId\t" + (.properties.roledefinitionid|tostring) ) + | "roleAssignment.roleDefinitionId\t" + (.properties.roledefinitionid|tostring) ), + ( [.resources // [] | .. | objects | select(has("principalid"))][] + | "roleAssignment.principalId\t" + (.principalid|tostring) ), + ( $all[] + | select((.type|ascii_downcase) == "microsoft.authorization/roleassignments") + | select([.properties | objects | select(has("principalid"))] | length == 0) + | "roleAssignment.principalId\t" ) ' "$ARM_FILE_NORM" ) @@ -502,6 +824,7 @@ fi # "/providers/microsoft.capacity" is a fully functional tenant scope. Match # case-insensitively or lowercasing alone would defeat every check below. SCOPE_VIOLATIONS="" +PRINCIPAL_SITES=0 shopt -s nocasematch while IFS=$'\t' read -r origin value; do [[ -z "$origin" ]] && continue @@ -529,6 +852,14 @@ while IFS=$'\t' read -r origin value; do if [[ "$allowed_match" -eq 0 ]]; then reason="roleDefinitionId is not one of the three roles CUDly assigns (custom purchaser, Reader, Cost Management Reader)" fi + elif [[ "$origin" == "roleAssignment.principalId" ]]; then + # `` is emitted for a roleAssignments resource carrying no + # principalId at all, so omitting the field is not a way to be exempt + # from the check on its value. + PRINCIPAL_SITES=$((PRINCIPAL_SITES + 1)) + if [[ "$(normalize_scope_expr "$value")" != "$EXPECTED_PRINCIPAL_NORM" ]]; then + reason="principalId is not ${EXPECTED_PRINCIPAL_EXPR}, the only principal this template grants to" + fi elif [[ "$(normalize_scope_expr "$value")" == "$CANONICAL_SCOPE_NORM" ]]; then : elif [[ "$(normalize_scope_expr "$value")" == "$CANONICAL_SCOPE_ALT_NORM" ]]; then @@ -558,8 +889,12 @@ if [[ -n "$SCOPE_VIOLATIONS" ]]; then echo "A bare literal /subscriptions/ is never accepted, even for the" >&2 echo "onboarded subscription itself: accepting a literal by GUID shape alone" >&2 echo "cannot distinguish it from a foreign subscription hard-coded into the" >&2 - echo "template. Role assignments must carry no explicit scope, and" >&2 - echo "roleDefinitionId must be one of the three roles CUDly assigns. A grant" >&2 + echo "template. Role assignments must carry no explicit scope," >&2 + echo "roleDefinitionId must be one of the three roles CUDly assigns, and" >&2 + echo "principalId must be ${EXPECTED_PRINCIPAL_EXPR}: a" >&2 + echo "hardcoded object ID grants somebody other than the customer's own" >&2 + echo "CUDly service principal, and the custom purchaser role spends money" >&2 + echo "(issue #1681). A grant" >&2 echo "at a tenant, management-group or billing-account scope, or at another" >&2 echo "subscription, reaches subscriptions the customer never onboarded" >&2 echo "(issue #1545). If a wider grant is genuinely required it must be a" >&2 @@ -568,6 +903,56 @@ if [[ -n "$SCOPE_VIOLATIONS" ]]; then exit 1 fi +# --- the principal parameter must stay the customer's answer ----------------- +# Every principalId above reads parameters('servicePrincipalObjectId'), which +# is only worth anything while that parameter is one the customer is forced to +# answer. A defaultValue on it is the same escalation one hop out: the +# principalId fields are untouched and still pass, while `az deployment sub +# create` and the portal's own form both prefill the object ID the default +# names, so a template nobody edited past this line grants to it. Refused with +# allowedValues, which constrains the customer's answer from the other +# direction. +# +# Gated on having SEEN the parameter referenced, not on the parameter +# existing: PRINCIPAL_SITES counts the principalId sites the loop above read, +# and that loop has already refused any value other than this parameter, so a +# nonzero count means the template grants through it. A fixture with no role +# assignment at all references nothing and is asked for nothing. Reading the +# gate the other way round -- skipping when the parameter is missing -- would +# make deleting the declaration the way out. +if [[ "$PRINCIPAL_SITES" -gt 0 ]]; then + PARAM_ERRORS=$(jq -r --arg p "$(printf '%s' "$EXPECTED_PRINCIPAL_PARAM" | tr '[:upper:]' '[:lower:]')" ' + (.parameters // {}) as $params + | if ($params | type) != "object" then + " parameters is a " + ($params | type) + ", expected an object" + elif ($params | has($p) | not) then + " parameters." + $p + " is never declared, so nothing forces the customer to supply it" + elif (($params[$p] | type) != "object") then + " parameters." + $p + " is a " + ($params[$p] | type) + ", expected an object" + elif ($params[$p] | has("defaultvalue")) then + " parameters." + $p + " has a defaultValue: " + ($params[$p].defaultvalue | tostring) + elif ($params[$p] | has("allowedvalues")) then + " parameters." + $p + " has allowedValues: " + ($params[$p].allowedvalues | tostring) + else empty + end + ' "$ARM_FILE_NORM") + + if [[ -n "$PARAM_ERRORS" ]]; then + echo "ERROR: the principal parameter is not the customer's own answer." >&2 + echo "" >&2 + echo " ARM source: $ARM_FILE" >&2 + echo "" >&2 + echo "$PARAM_ERRORS" >&2 + echo "" >&2 + echo "${PRINCIPAL_SITES} grant(s) in this template are made to" >&2 + echo "${EXPECTED_PRINCIPAL_EXPR}, and the custom role one of them binds can" >&2 + echo "spend the customer's money. That is only a grant to the customer's own" >&2 + echo "CUDly service principal while the deploying customer is the one who" >&2 + echo "supplies the value (issue #1681)." >&2 + exit 1 + fi +fi + # The TF module offers include_capacity_provider_scope as an opt-in escape # hatch. It must stay default-false, otherwise the TF path silently reacquires # the tenant-wide grant this check removes from the ARM path. @@ -604,5 +989,251 @@ if grep -q 'include_capacity_provider_scope' "$TF_FILE"; then fi SCOPE_COUNT=$(echo "$SCOPES" | grep -c . || true) -echo "OK: all ${SCOPE_COUNT} ARM grant scopes/roleDefinitionIds are subscription-anchored." +echo "OK: all ${SCOPE_COUNT} ARM grant scopes/roleDefinitionIds/principalIds are subscription-anchored" +echo " and go to ${EXPECTED_PRINCIPAL_EXPR}." + +# --- the expected grant set (issue #1681) ------------------------------------ +# Everything above answers "is each thing I happened to find individually +# acceptable?", which is only ever as complete as the list of shapes somebody +# remembered to refuse. A resource type this script has never heard of is not +# recognized by any selector above and is not on the REFUSED_TYPES list, so it +# contributes nothing and passes in silence; so does a second copy of a grant +# that is legitimate once; so does deleting one. +# +# This axis asserts the other direction: the template grants EXACTLY the set +# below, as a multiset, and every resource it deploys is one of them. Whatever +# the next unforeseen shape turns out to be, it is either one of these tuples +# or it is not, and nobody has to have thought of it first. +# +# Each grant is described by the fields that decide what it can do -- what +# role, to whom, at what scope -- normalized the same way the checks above +# normalize theirs, so a pure reformat of the template cannot red CI while a +# changed value always does. +CANONICAL_SCOPE_TOKEN="" +EXPECTED_PRINCIPAL_TOKEN="" + +# The four grants arm/CUDly-CrossSubscription/template.json is supposed to +# make: the custom purchaser role definition, assignable in the subscription +# being onboarded, and the three assignments of it and of the two built-in +# read-only roles to the customer's CUDly service principal. +# +# The roles are named by what `variables` resolves them TO, not by the +# variable that points at them, so repointing a variable is a change to this +# list rather than a change nothing reads. The two GUIDs are Azure's own +# built-in Reader and Cost Management Reader definitions, which is the whole +# assertion: any other definition, built-in or custom, is a different role. +# +# Written in the template's own spelling and normalized here, rather than +# pre-normalized by hand, so this list stays diffable against the template it +# describes. +EXPECTED_GRANTS=( + "roleDefinition assignableScopes=${CANONICAL_SCOPE_TOKEN}" + "roleAssignment roleDefinitionId=$(normalize_scope_expr "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]") principalId=${EXPECTED_PRINCIPAL_TOKEN} scope=" + "roleAssignment roleDefinitionId=$(normalize_scope_expr "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7") principalId=${EXPECTED_PRINCIPAL_TOKEN} scope=" + "roleAssignment roleDefinitionId=$(normalize_scope_expr "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3") principalId=${EXPECTED_PRINCIPAL_TOKEN} scope=" +) + +# The harness substitutes its own set for the smaller fixture templates. +# Blank lines and `#` comments are ignored; anything else is an expected grant +# descriptor, in the exact form the loop below builds. +if [[ -n "$EXPECTED_GRANTS_FILE" ]]; then + if [[ ! -f "$EXPECTED_GRANTS_FILE" ]]; then + echo "ERROR: expected-grants file not found: $EXPECTED_GRANTS_FILE" >&2 + exit 1 + fi + EXPECTED_GRANTS=() + while IFS= read -r line || [[ -n "$line" ]]; do + [[ -z "${line// /}" || "$line" == \#* ]] && continue + EXPECTED_GRANTS+=("$line") + done < "$EXPECTED_GRANTS_FILE" +fi + +if [[ ${#EXPECTED_GRANTS[@]} -eq 0 ]]; then + echo "ERROR: the expected grant set is empty, so this axis would assert nothing." >&2 + echo " Every template satisfies 'grants nothing beyond an empty set'." >&2 + exit 1 +fi + +# Walk the resources ARM actually deploys: the document's `resources` array +# and, recursively, each resource's own nested `resources` array. This is a +# structural walk rather than the `..` descent the checks above use, because +# this axis asks what the DEPLOYED set is, and `..` also reaches objects that +# merely sit inside a resource -- `properties` carries its own `"type": +# "CustomRole"`, which is not a resource of type CustomRole, and reporting it +# as an unrecognized resource type would red the real template. +# +# What that costs is precise: an object sitting at a position ARM does not +# deploy from is not in this inventory. The checks above are the ones that +# read those, and they run first and exit on their own -- every `principalId` +# anywhere under `.resources` by key, every roleAssignments and +# roleDefinitions resource anywhere under it by type, and REFUSED_TYPES by +# type and by `/providers/roleAssignments` suffix. Between them they cover +# the shapes that carry a grant; this axis covers the set that is deployed. +# +# Emitted as four tab-separated fields per resource, with tabs and newlines in +# any value flattened to spaces so one resource is always exactly one line. +# +# roleDefinitionId is resolved through the template's own `variables` table +# before being recorded, because the expression text alone names a pointer +# rather than a role: `[variables('roles').reader]` is on the allowlist, and +# repointing `variables.roles.reader` at the built-in Owner definition changes +# what every grant of it confers while every string this script compares stays +# byte-identical. Only the two forms this template uses are resolved, +# `[variables('x')]` and `[variables('x').y]`; anything else is recorded as +# `unresolved:`, which is in no expected set and is therefore refused +# rather than quietly compared as text. +GRANT_RECORDS=$( + jq -r ' + # `` rather than "": IFS=$'"'"'\t'"'"' treats tab as IFS whitespace, so a + # run of tabs collapses and an empty field shifts every field after it, + # which makes the diagnostic describe a grant the template does not have. + def flat: tostring | gsub("[\\t\\r\\n]"; " ") | if . == "" then "" else . end; + def resolve_variable($vars): + ascii_downcase as $e + | ($e | capture("^\\[variables\\('"'"'(?[^'"'"']+)'"'"'\\)(\\.(?[a-z0-9_]+))?\\]$")) as $m + | if $m == null then null + elif ($vars | type) != "object" then null + elif ($m.f == null) + then ($vars[$m.n] | if type == "string" then . else null end) + elif (($vars[$m.n] | type) == "object") + then ($vars[$m.n][$m.f] | if type == "string" then . else null end) + else null + end; + def deployed: (if type == "object" and (.resources | type) == "array" + then .resources else [] end)[] + | ., deployed; + (.variables // {}) as $vars + | [deployed][] + | . as $r + | ((if ($r | type) == "object" and ($r.properties | type) == "object" + then $r.properties else {} end)) as $p + | if (($r | type) != "object") then + "unrecognized\t\t\t" + elif (($r.type | type) != "string") then + "unrecognized\t\t\t" + elif ($r.type | ascii_downcase) == "microsoft.authorization/roledefinitions" then + "roleDefinition\t" + + ( if ($p | has("assignablescopes")) + then ($p.assignablescopes | map(flat) | join("\u0001")) + else "" end ) + + "\t\t" + elif ($r.type | ascii_downcase) == "microsoft.authorization/roleassignments" then + "roleAssignment\t" + + ( if ($p | has("roledefinitionid") | not) then "" + else ($p.roledefinitionid | flat) as $raw + | ($raw | resolve_variable($vars)) as $resolved + | (if $resolved == null then "unresolved:" + $raw else ($resolved | flat) end) + end ) + + "\t" + + (if ($p | has("principalid")) then ($p.principalid | flat) else "" end) + + "\t" + + (if ($r | has("scope")) then ($r.scope | flat) else "" end) + else + "unrecognized\t" + ($r.type | ascii_downcase | flat) + "\t\t" + end + ' "$ARM_FILE_NORM" +) + +# A scope or principal that means what it is supposed to mean is reported as a +# token, so the expected set names the invariant rather than one of the +# several spellings that satisfy it. Anything else passes through normalized, +# so it shows up in the diff as the value it actually is. +canonical_scope_token() { + local n + n="$(normalize_scope_expr "$1")" + if [[ "$n" == "$CANONICAL_SCOPE_NORM" || "$n" == "$CANONICAL_SCOPE_ALT_NORM" ]]; then + printf '%s' "$CANONICAL_SCOPE_TOKEN" + else + printf '%s' "$n" + fi +} + +expected_principal_token() { + local n + n="$(normalize_scope_expr "$1")" + if [[ "$n" == "$EXPECTED_PRINCIPAL_NORM" ]]; then + printf '%s' "$EXPECTED_PRINCIPAL_TOKEN" + else + printf '%s' "$n" + fi +} + +ACTUAL_GRANTS=() +RESOURCE_COUNT=0 +while IFS=$'\t' read -r kind field_a field_b field_c; do + [[ -z "$kind" ]] && continue + RESOURCE_COUNT=$((RESOURCE_COUNT + 1)) + case "$kind" in + roleDefinition) + # Several spellings of the canonical scope in one assignableScopes array + # are one grant, not several, so the tokens are deduplicated. They are + # sorted for the same reason the grant list is: array order is not a + # security property. + scopes="" + if [[ "$field_a" == "" ]]; then + scopes="" + else + mapped="" + # `|| [[ -n "$one_scope" ]]`: the split below emits no trailing + # newline, and a bare `read` discards an unterminated final line -- + # which for a single-element assignableScopes array is the only line + # there is. + while IFS= read -r one_scope || [[ -n "$one_scope" ]]; do + [[ -z "$one_scope" ]] && continue + mapped+="$(canonical_scope_token "$one_scope")"$'\n' + done < <(printf '%s' "$field_a" | tr '\001' '\n') + scopes="$(printf '%s' "$mapped" | sort -u | paste -sd, -)" + [[ -z "$scopes" ]] && scopes="" + fi + ACTUAL_GRANTS+=("roleDefinition assignableScopes=${scopes}") + ;; + roleAssignment) + role="$(normalize_scope_expr "$field_a")" + principal="$(expected_principal_token "$field_b")" + # The scope field is recorded as emitted rather than mapped through + # canonical_scope_token: a roleAssignment carrying an explicit `scope` at + # all is refused by the scope axis above, which runs first and exits, so + # the only value that reaches here is . Recording the raw text + # keeps that from being an assumption -- if the axis above ever stops + # refusing explicit scopes, the value lands in this diff instead of being + # normalized into looking expected. + ACTUAL_GRANTS+=("roleAssignment roleDefinitionId=${role} principalId=${principal} scope=${field_c}") + ;; + *) + ACTUAL_GRANTS+=("unrecognized resource type=${field_a}") + ;; + esac +done <<< "$GRANT_RECORDS" + +# This axis cannot pass having read nothing: the expected set is refused when +# empty (above), and what follows is equality against it rather than an +# absence, so an empty inventory is reported as every expected grant missing. +# That is the floor; a separate "did I read anything" assertion here would be +# a branch no input reaches. +GRANT_DIFF=$( + diff <(printf '%s\n' "${EXPECTED_GRANTS[@]}" | sort) \ + <(printf '%s\n' ${ACTUAL_GRANTS[@]+"${ACTUAL_GRANTS[@]}"} | sort) || true +) + +if [[ -n "$GRANT_DIFF" ]]; then + echo "ERROR: ARM template does not grant exactly the expected set." >&2 + echo "" >&2 + echo " ARM source: $ARM_FILE" >&2 + echo "" >&2 + echo "Diff (< expected > found):" >&2 + echo "$GRANT_DIFF" >&2 + echo "" >&2 + echo "This template deploys into a customer's subscription and the custom role" >&2 + echo "it defines can spend their money, so what it grants is enumerated rather" >&2 + echo "than filtered: a grant that is not on the list is refused whether or not" >&2 + echo "anyone has thought about that shape before (issue #1681). A '> found'" >&2 + echo "line reading 'unrecognized resource type=...' is a resource no check here" >&2 + echo "inspects at all." >&2 + echo "" >&2 + echo "If the template is meant to change, change EXPECTED_GRANTS in this script" >&2 + echo "in the same commit, so the new grant is reviewed as a grant." >&2 + exit 1 +fi + +echo "OK: ARM template grants exactly the ${#EXPECTED_GRANTS[@]} expected tuples across ${RESOURCE_COUNT} deployed resource(s)." exit 0 diff --git a/scripts/test-azure-role-parity.sh b/scripts/test-azure-role-parity.sh index 1dbb5a244..8b91914ec 100755 --- a/scripts/test-azure-role-parity.sh +++ b/scripts/test-azure-role-parity.sh @@ -1,15 +1,25 @@ #!/usr/bin/env bash # test-azure-role-parity.sh # -# Exercises check-azure-role-parity.sh against testdata fixtures. +# Exercises check-azure-role-parity.sh against testdata fixtures, and against +# the repository's own template and TF module with no flags at all, the way CI +# invokes it. The fixture cases prove the checker behaves correctly on inputs +# built to break it; only the flagless ones prove anything about the sources it +# actually guards. # Exits 0 when all cases pass; exits 1 on any failure. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" CHECK="${SCRIPT_DIR}/check-azure-role-parity.sh" FIXTURES="${SCRIPT_DIR}/testdata/role-parity" +# The checker both runners invoke. It is the repo's own copy for every case +# but the arm/ sweep at the end of this file, which needs a checker whose +# REPO_ROOT is a scratch tree rather than the repository. +CHECK_UNDER_TEST="$CHECK" + pass=0 fail=0 @@ -19,7 +29,7 @@ run_case() { shift 2 actual_exit=0 - "$CHECK" "$@" >/dev/null 2>&1 || actual_exit=$? + "$CHECK_UNDER_TEST" "$@" >/dev/null 2>&1 || actual_exit=$? if [[ "$actual_exit" -eq "$expected_exit" ]]; then echo "PASS: $label" @@ -30,13 +40,59 @@ run_case() { fi } +# run_case_saying LABEL EXPECTED_EXIT DIAGNOSTIC ARGS... +# +# run_case with the checker's own diagnostic asserted as well. A nonzero exit +# proves only that something went wrong: a typo in the script, jq aborting on +# an unexpected shape and `set -e` surfacing its status, or an unrelated check +# firing first all exit nonzero too, so a case that asserts the code alone can +# go on passing after the assertion it was written for stops running. The +# cases below that pin a specific finding use this instead. +# +# DIAGNOSTIC is matched as a fixed string (`grep -F`), not a pattern: a regex +# here would be one more thing that can match more than it reads as. +run_case_saying() { + local label="$1" + local expected_exit="$2" + local diagnostic="$3" + shift 3 + + local output actual_exit=0 + output="$("$CHECK_UNDER_TEST" "$@" 2>&1)" || actual_exit=$? + + if [[ "$actual_exit" -ne "$expected_exit" ]]; then + echo "FAIL: $label (expected exit $expected_exit, got $actual_exit)" + (( fail++ )) || true + return + fi + if ! printf '%s\n' "$output" | grep -qF -- "$diagnostic"; then + echo "FAIL: $label (exit $actual_exit as expected, but never said:)" + echo " ${diagnostic}" + echo " what it said was:" + printf '%s\n' "$output" | sed 's/^/ /' + (( fail++ )) || true + return + fi + echo "PASS: $label" + (( pass++ )) || true +} + +# The fixtures below are deliberately smaller than the real onboarding +# template, so the grant-set axis (issue #1681) is given the set each fixture +# is supposed to contain. The cases that assert the REAL expected set pass no +# --expected-grants at all and are grouped at the end of this file. +GRANTS_ROLE_DEF_ONLY=(--expected-grants "${FIXTURES}/expected/role-definition-only.grants") +GRANTS_ROLE_DEF_AND_READER=(--expected-grants "${FIXTURES}/expected/role-definition-and-reader.grants") + # Case 1: matching fixtures -> should exit 0 run_case "matching lists exit 0" 0 \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ - --arm-file "${FIXTURES}/matching-arm.json" + --arm-file "${FIXTURES}/matching-arm.json" \ + "${GRANTS_ROLE_DEF_ONLY[@]}" # Case 2: drifted ARM (missing purchase/action) -> should exit 1 -run_case "drifted ARM exits 1" 1 \ +run_case_saying "drifted ARM exits 1" 1 \ + "TF module actions lists differ" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/drifted-arm.json" @@ -44,7 +100,8 @@ run_case "drifted ARM exits 1" 1 \ # assigned at) the tenant-wide /providers/Microsoft.Capacity scope. This is # the exact shape that shipped in arm/CUDly-CrossSubscription/template.json: # the actions check passes, so only the scope check can catch it. -run_case "tenant-scope ARM exits 1" 1 \ +run_case_saying "tenant-scope ARM exits 1" 1 \ + "names a scope above the subscription" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/tenant-scope-arm.json" @@ -53,14 +110,16 @@ run_case "tenant-scope ARM exits 1" 1 \ # the scope to look subscription-anchored would admit this while rejecting the # blunt literal in case 3, accepting the fail-OPEN form and blocking only the # cosmetically-bad one. -run_case "obfuscated tenant-scope ARM exits 1" 1 \ +run_case_saying "obfuscated tenant-scope ARM exits 1" 1 \ + "names a scope above the subscription" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/obfuscated-tenant-scope-arm.json" # Case 5: the same tenant scope in lowercase. Azure provider namespaces are # case-insensitive, so /providers/microsoft.capacity is a fully functional # tenant scope; a case-sensitive check would pass it while failing case 3. -run_case "lowercase tenant-scope ARM exits 1" 1 \ +run_case_saying "lowercase tenant-scope ARM exits 1" 1 \ + "names a scope above the subscription" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/lowercase-tenant-scope-arm.json" @@ -68,7 +127,8 @@ run_case "lowercase tenant-scope ARM exits 1" 1 \ # contains /subscriptions/ and names no escape provider, so only an exact-match # allowlist rejects it. This is the cross-subscription grant the template's own # name invites. -run_case "other-subscription ARM exits 1" 1 \ +run_case_saying "other-subscription ARM exits 1" 1 \ + "not the canonical subscription scope" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/other-subscription-arm.json" @@ -76,14 +136,16 @@ run_case "other-subscription ARM exits 1" 1 \ # the idiomatic ARM way to assign at a scope other than the deployment's own, # so it is what an author with a legitimate cross-scope need would reach for. # A check that walked only the top-level resources array would not see it. -run_case "nested-deployment ARM exits 1" 1 \ +run_case_saying "nested-deployment ARM exits 1" 1 \ + "resource(s) of a type this" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/nested-deployment-arm.json" # Case 8: repointing $schema at the management-group template. Every role # assignment here inherits the deployment scope, so this widens all of them to # cover every child subscription without altering one scope string. -run_case "management-group schema ARM exits 1" 1 \ +run_case_saying "management-group schema ARM exits 1" 1 \ + "not a subscription-scoped deployment" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/mgmt-group-schema-arm.json" @@ -113,7 +175,8 @@ locals { ]) } HCL -run_case "TF flag without variables.tf exits 1" 1 \ +run_case_saying "TF flag without variables.tf exits 1" 1 \ + "does not exist, so its default cannot be checked" \ --tf-file "${TMP_TF_DIR}/main.tf" \ --arm-file "${FIXTURES}/matching-arm.json" @@ -127,14 +190,16 @@ run_case "TF flag without variables.tf exits 1" 1 \ # working) with a foreign-subscription literal appended to the same array. # A GUID-shape-only allowlist accepted this; only rejecting literals outright # catches it. -run_case "foreign-subscription literal alongside canonical exits 1 (F1)" 1 \ +run_case_saying "foreign-subscription literal alongside canonical exits 1 (F1)" 1 \ + "not the canonical subscription scope" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/foreign-subscription-literal-with-canonical-arm.json" # Case 11 (F1): an uppercase GUID literal. `nocasematch` was live across the # old LITERAL_SUBSCRIPTION_RE match, so `[0-9a-f]` also matched uppercase; # rejecting literals outright makes case sufficiency moot. -run_case "uppercase GUID literal exits 1 (F1)" 1 \ +run_case_saying "uppercase GUID literal exits 1 (F1)" 1 \ + "not the canonical subscription scope" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/uppercase-guid-literal-arm.json" @@ -144,7 +209,8 @@ run_case "uppercase GUID literal exits 1 (F1)" 1 \ # fixture carries all four in one assignableScopes array and must pass. run_case "canonical scope spelling variants exit 0 (F2)" 0 \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ - --arm-file "${FIXTURES}/canonical-scope-variants-arm.json" + --arm-file "${FIXTURES}/canonical-scope-variants-arm.json" \ + "${GRANTS_ROLE_DEF_ONLY[@]}" # Case 13 (F3): a second role definition typed # "microsoft.authorization/roleDefinitions" (lowercase) granting actions:["*"]. @@ -153,20 +219,23 @@ run_case "canonical scope spelling variants exit 0 (F2)" 0 \ # though the case-insensitive scope walk counted it (the old "all 2 ARM grant # scopes are subscription-anchored" message proved the scope axis saw what the # actions axis did not). -run_case "lowercase-typed second role def with wildcard actions exits 1 (F3)" 1 \ +run_case_saying "lowercase-typed second role def with wildcard actions exits 1 (F3)" 1 \ + "actions lists differ" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/lowercase-type-wildcard-actions-arm.json" # Case 14 (F4): a second permissions[] entry appended after the canonical one, # granting actions:["*"]. ARM unions permissions across the whole array; only # comparing permissions[0] missed the second entry entirely. -run_case "second permissions entry with wildcard actions exits 1 (F4)" 1 \ +run_case_saying "second permissions entry with wildcard actions exits 1 (F4)" 1 \ + "actions lists differ" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/second-permissions-entry-arm.json" # Case 15 (F4): dataActions:["*"] on the (otherwise matching) first permissions # entry. dataActions/notActions/notDataActions were never compared at all. -run_case "dataActions wildcard exits 1 (F4)" 1 \ +run_case_saying "dataActions wildcard exits 1 (F4)" 1 \ + "dataActions lists differ" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/dataactions-wildcard-arm.json" @@ -174,12 +243,14 @@ run_case "dataActions wildcard exits 1 (F4)" 1 \ # (matching, canonical-scope) role definition. Only "deployments" was refused; # a deployment script's runtime az-cli commands can issue role assignments this # check never sees as JSON at all. -run_case "deploymentScripts resource exits 1 (F5)" 1 \ +run_case_saying "deploymentScripts resource exits 1 (F5)" 1 \ + "resource(s) of a type this" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/deploymentscript-scope-escape-arm.json" # Case 17 (F5): the same refusal, for Microsoft.Resources/deploymentStacks. -run_case "deploymentStacks resource exits 1 (F5)" 1 \ +run_case_saying "deploymentStacks resource exits 1 (F5)" 1 \ + "resource(s) of a type this" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/deploymentstack-scope-escape-arm.json" @@ -187,7 +258,8 @@ run_case "deploymentStacks resource exits 1 (F5)" 1 \ # explicit `scope` so it correctly inherits the subscription-scope deployment # (the scope axis finds nothing wrong). Only an explicit `scope` was ever # checked; roleDefinitionId itself was unconstrained. -run_case "unallowed roleDefinitionId (Owner) exits 1 (F6)" 1 \ +run_case_saying "unallowed roleDefinitionId (Owner) exits 1 (F6)" 1 \ + "roleDefinitionId is not one of the three roles CUDly assigns" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/unallowed-roledefinitionid-arm.json" @@ -203,7 +275,8 @@ run_case "unallowed roleDefinitionId (Owner) exits 1 (F6)" 1 \ # doesn't grant anything wider -- the corrupted path just fails to deploy -- # but a normalizer that can't tell "reformatted" from "corrupted" is the # textbook shape of the next bypass, so this is refused rather than tolerated. -run_case "whitespace inside scope string literal exits 1" 1 \ +run_case_saying "whitespace inside scope string literal exits 1" 1 \ + "not the canonical subscription scope" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/space-inside-literal-arm.json" @@ -215,7 +288,8 @@ run_case "whitespace inside scope string literal exits 1" 1 \ # invites being deleted, which is how issue #1545 shipped in the first place. run_case "decorative variables object is invisible, exits 0" 0 \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ - --arm-file "${FIXTURES}/decorative-variables-arm.json" + --arm-file "${FIXTURES}/decorative-variables-arm.json" \ + "${GRANTS_ROLE_DEF_ONLY[@]}" # --- round-4: independent review, key-casing and unmatched-type bypasses ---- # ARM's resource-provider JSON deserializers are documented case-insensitive @@ -232,21 +306,24 @@ run_case "decorative variables object is invisible, exits 0" 0 \ # Microsoft.Capacity path. has("scope") never matched "Scope", so the # assignment's explicit-scope violation -- the exact shape issue #1545 # shipped as -- was invisible. -run_case "miscased 'Scope' key on tenant-wide assignment exits 1" 1 \ +run_case_saying "miscased 'Scope' key on tenant-wide assignment exits 1" 1 \ + "role assignments must inherit the deployment scope" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/miscased-scope-arm.json" # Case 22: a roleAssignment with "RoleDefinitionId" (capitalized) binding # built-in Owner, no explicit scope. has("roleDefinitionId") never matched # "RoleDefinitionId", so the roleDefinitionId allowlist (F6) was invisible. -run_case "miscased 'RoleDefinitionId' key on Owner grant exits 1" 1 \ +run_case_saying "miscased 'RoleDefinitionId' key on Owner grant exits 1" 1 \ + "roleDefinitionId is not one of the three roles CUDly assigns" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/miscased-roledefinitionid-arm.json" # Case 23: a roleAssignment with "Properties" (capitalized) wrapping an Owner # roleDefinitionId. has("properties") never matched "Properties", so nothing # inside it -- roleDefinitionId included -- was ever reached. -run_case "miscased 'Properties' key on Owner grant exits 1" 1 \ +run_case_saying "miscased 'Properties' key on Owner grant exits 1" 1 \ + "roleDefinitionId is not one of the three roles CUDly assigns" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/miscased-properties-arm.json" @@ -255,7 +332,8 @@ run_case "miscased 'Properties' key on Owner grant exits 1" 1 \ # first role definition kept SCOPES non-empty, so the miscased entry's # absence didn't even trip the "no assignableScopes found" fallback -- it # just silently contributed nothing, and the template passed. -run_case "miscased 'AssignableScopes' key exits 1" 1 \ +run_case_saying "miscased 'AssignableScopes' key exits 1" 1 \ + "names a scope above the subscription" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/miscased-assignablescopes-arm.json" @@ -263,13 +341,15 @@ run_case "miscased 'AssignableScopes' key exits 1" 1 \ # (Azure PIM) binding built-in Owner. Grants a role the same way a plain # roleAssignment does, under a property shape this check's # roleAssignments-only type match never saw. -run_case "PIM roleEligibilityScheduleRequests (Owner) exits 1" 1 \ +run_case_saying "PIM roleEligibilityScheduleRequests (Owner) exits 1" 1 \ + "resource(s) of a type this" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/pim-roleeligibility-owner-arm.json" # Case 26 (Fix B): Microsoft.Authorization/roleAssignmentScheduleRequests # (Azure PIM), same reasoning. -run_case "PIM roleAssignmentScheduleRequests (Owner) exits 1" 1 \ +run_case_saying "PIM roleAssignmentScheduleRequests (Owner) exits 1" 1 \ + "resource(s) of a type this" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/pim-roleassignment-schedule-owner-arm.json" @@ -277,7 +357,8 @@ run_case "PIM roleAssignmentScheduleRequests (Owner) exits 1" 1 \ # resource type path (Microsoft.Storage/storageAccounts/providers/ # roleAssignments) rather than a top-level roleAssignments resource with a # scope property. Same grant, invisible to the same type-string match. -run_case "legacy child-type roleAssignments (Owner) exits 1" 1 \ +run_case_saying "legacy child-type roleAssignments (Owner) exits 1" 1 \ + "resource(s) of a type this" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/legacy-child-roleassignment-owner-arm.json" @@ -288,7 +369,8 @@ run_case "legacy child-type roleAssignments (Owner) exits 1" 1 \ # scope, so this must still be accepted as canonical. run_case "uppercase canonical scope expression exits 0" 0 \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ - --arm-file "${FIXTURES}/uppercase-canonical-scope-arm.json" + --arm-file "${FIXTURES}/uppercase-canonical-scope-arm.json" \ + "${GRANTS_ROLE_DEF_ONLY[@]}" # Case 29: an allowed roleDefinitionId ("[Variables('Roles').Reader]") written # with different capitalization than ALLOWED_ROLE_DEFINITION_IDS's own @@ -296,7 +378,8 @@ run_case "uppercase canonical scope expression exits 0" 0 \ # implicit in the roleDefinitionId comparison loop. run_case "case-varied allowed roleDefinitionId exits 0" 0 \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ - --arm-file "${FIXTURES}/uppercase-allowed-roledefinitionid-arm.json" + --arm-file "${FIXTURES}/uppercase-allowed-roledefinitionid-arm.json" \ + "${GRANTS_ROLE_DEF_AND_READER[@]}" # --- round-5: adversarial-review key-collision bypass ----------------------- # Round 4's key-casing normalization (`with_entries(.key |= ascii_downcase)`) @@ -320,19 +403,22 @@ run_case "case-varied allowed roleDefinitionId exits 0" 0 \ # Case 30: AssignableScopes (evil, tenant-wide) then assignableScopes (benign, # canonical) on the same roleDefinitions.properties object -- issue #1545 # itself, reached via key collision instead of a single miscased key. -run_case "key-collision AssignableScopes/assignableScopes exits 1" 1 \ +run_case_saying "key-collision AssignableScopes/assignableScopes exits 1" 1 \ + "two case-variant spellings" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/collision-assignablescopes-evil-first-arm.json" # Case 31: RoleDefinitionId (evil, built-in Owner) then roleDefinitionId # (benign, allowed Reader) on the same roleAssignment.properties object. -run_case "key-collision RoleDefinitionId/roleDefinitionId exits 1" 1 \ +run_case_saying "key-collision RoleDefinitionId/roleDefinitionId exits 1" 1 \ + "two case-variant spellings" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/collision-roledefinitionid-evil-first-arm.json" # Case 32: Properties (evil, wraps Owner) then properties (benign, wraps # allowed Reader) as two TOP-LEVEL keys on the same roleAssignment resource. -run_case "key-collision Properties/properties exits 1" 1 \ +run_case_saying "key-collision Properties/properties exits 1" 1 \ + "two case-variant spellings" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/collision-properties-evil-first-arm.json" @@ -342,12 +428,14 @@ run_case "key-collision Properties/properties exits 1" 1 \ # ordinary, fully-compliant roleAssignments grant -- invisible not just to # REFUSED_TYPES but to every other check too, which is why this fixture # carries an otherwise-allowed roleDefinitionId and no scope override. -run_case "key-collision Type/type (evades REFUSED_TYPES too) exits 1" 1 \ +run_case_saying "key-collision Type/type (evades REFUSED_TYPES too) exits 1" 1 \ + "two case-variant spellings" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/collision-type-evil-first-arm.json" # Case 34: benign-first control -- same collision as case 30, keys reversed. -run_case "key-collision benign-first control still exits 1" 1 \ +run_case_saying "key-collision benign-first control still exits 1" 1 \ + "two case-variant spellings" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/collision-assignablescopes-benign-first-arm.json" @@ -363,7 +451,8 @@ run_case "key-collision benign-first control still exits 1" 1 \ # the objects being inspected; normalization then folds the two root keys the # same last-entry-wins way as any other collision, silently keeping only the # benign array and discarding the evil one before any downstream check runs. -run_case "root-level key-collision Resources/resources exits 1" 1 \ +run_case_saying "root-level key-collision Resources/resources exits 1" 1 \ + "two case-variant spellings" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/collision-root-resources-evil-first-arm.json" @@ -375,10 +464,435 @@ run_case "root-level key-collision Resources/resources exits 1" 1 \ # the same roleAssignments-only type match -- passed silently. Matched by # suffix now, the same way ESCAPE_TOKENS matches managementGroups independently # of its provider spelling. -run_case "legacy child-type roleAssignments under non-storage parent exits 1" 1 \ +run_case_saying "legacy child-type roleAssignments under non-storage parent exits 1" 1 \ + "resource(s) of a type this" \ --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ --arm-file "${FIXTURES}/legacy-child-keyvault-roleassignment-owner-arm.json" +# --- issue #1681: who the grant goes to, and how many grants there are ------- +# Every case above hands the checker a fixture smaller than the real template, +# which is what let both of these gaps live: a two-resource fixture exercises +# one grant, and the checker reported "OK: all 2 ..." while the template it +# guards has four. The fixtures below are the REAL template's grant set -- +# generated from arm/CUDly-CrossSubscription/template.json with its prose-only +# metadata and outputs removed -- so they are checked against the checker's own +# built-in EXPECTED_GRANTS, with no --expected-grants override, exactly as CI +# checks the template itself. +# +# Case 37 is the baseline the rest are one edit away from. Without it the +# hostile cases below prove only that the checker refuses things, not that it +# accepts the template it is supposed to accept, and a checker that refuses +# everything passes every negative case ever written. +run_case "the real template's grant set exits 0" 0 \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/expected-set-baseline-arm.json" + +# Case 38 (#1681 finding 1): the bypass itself. A fourth roleAssignments +# resource binding the ALLOWED custom purchaser role -- the one carrying +# Microsoft.Capacity/reservationOrders/purchase/action -- with no explicit +# scope, so it correctly inherits the subscription-scope deployment, to a +# hardcoded foreign principalId. Valid ARM, actions in parity, canonical +# scope, allowed role: every axis that existed before this case was written +# passes it, and it grants a stranger the ability to spend the customer's +# money. +run_case_saying "hardcoded foreign principalId on an allowed role exits 1 (#1681)" 1 \ + "principalId is not [parameters('servicePrincipalObjectId')]" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/foreign-principal-literal-arm.json" + +# Case 39 (#1681 finding 1): the same grant with the object ID moved into a +# second parameter's default. It is no longer a literal anywhere in a +# principalId field, so a check that refused GUID-shaped literals rather than +# requiring the expected expression would pass it. +run_case_saying "principalId from another parameter exits 1 (#1681)" 1 \ + "principalId is not [parameters('servicePrincipalObjectId')]" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/foreign-principal-parameter-arm.json" + +# Case 40 (#1681 finding 1): a roleAssignments resource with no principalId at +# all. Deleting the field must not be a way to be exempt from the check on its +# value -- "every principalId I found is correct" is satisfied by finding none. +run_case_saying "role assignment with no principalId exits 1 (#1681)" 1 \ + "roleAssignment.principalId: " \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/absent-principal-arm.json" + +# Case 41 (#1681 finding 2): a second copy of the Reader grant. Every field is +# one the checker allows, because it is a duplicate of a grant that is +# legitimate once, so no per-value check can object to it. Only asserting the +# expected set as a multiset catches a grant being made twice. +run_case_saying "a duplicated grant exits 1 (#1681)" 1 \ + "ARM template does not grant exactly the expected set" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/duplicate-grant-arm.json" + +# Case 42 (#1681 finding 2): the Cost Management Reader grant deleted. Nothing +# about the remaining template is wrong, which is the point: before the +# expected set was asserted, removing a grant changed the count in the final +# "OK: all N ..." line and nothing else. The customer's cost data then stops +# being readable at the next deployment, with CI green. +run_case_saying "a removed grant exits 1 (#1681)" 1 \ + "ARM template does not grant exactly the expected set" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/missing-grant-arm.json" + +# Case 43 (#1681 finding 2): a resource of a type no selector in the checker +# has ever heard of, and which is not on REFUSED_TYPES either. This is the +# whole reason the set is asserted rather than filtered: the refusal list is +# only ever as long as somebody's memory, and a type nobody has thought of is +# refused here without anyone having to think of it. +run_case_saying "an unrecognized resource type exits 1 (#1681)" 1 \ + "unrecognized resource type=microsoft.managedidentity/userassignedidentities" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/unrecognized-resource-type-arm.json" + +# Cases 44-45: shapes the jq selectors cannot iterate. Both already failed +# closed, by aborting jq mid-pipeline and letting `set -e` surface jq's exit +# status (5) with jq's own message; neither is one of this script's documented +# outcomes, and neither tells the author what is wrong with the file. +run_case_saying "assignableScopes as a bare string exits 1, not 5" 1 \ + "properties.assignableScopes is a string, expected an array" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/assignablescopes-not-array-arm.json" + +run_case_saying "resources as a bare string exits 1, not 5" 1 \ + ".resources is a string, expected an array" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/resources-not-array-arm.json" + +# Case 46: an expected grant set with no entries in it. Every template on +# earth grants exactly the empty set of things beyond an empty set, so a +# checker that accepted this would report success on the strength of an +# assertion that says nothing. +EMPTY_GRANTS="$(mktemp)" +trap 'rm -rf "$TMP_TF_DIR"; rm -f "$EMPTY_GRANTS"' EXIT +printf '# no grants listed\n\n' > "$EMPTY_GRANTS" +run_case_saying "an empty expected grant set is refused" 1 \ + "the expected grant set is empty" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/expected-set-baseline-arm.json" \ + --expected-grants "$EMPTY_GRANTS" + +# Case 47: the real sources, invoked with no flags at all, the way CI does. +# Every other case here hands the checker a fixture, so this is the only one +# that asserts the template and the TF module are in parity with each other +# right now, on every axis, rather than that the checker behaves correctly on +# something else. +run_case "the real sources pass with no flags" 0 + +# --- what an expression NAMES versus what it RESOLVES TO --------------------- +# Found by an independent adversarial review of the first three axes, each +# verified passing before the case was written. All three are the same defect: +# a check that compares the text of an ARM expression is asserting the name of +# a thing, and the binding between that name and the thing is somewhere else +# in the template, unasserted. + +# Case 50: variables.roles.reader repointed at the built-in Owner definition. +# Every string this script compares is byte-identical afterwards -- the +# assignment still reads "[variables('roles').reader]", which is on the +# roleDefinitionId allowlist (#1658 F6) -- and the deployment grants Owner. +run_case_saying "a variable repointed at Owner exits 1 (#1681)" 1 \ + "ARM template does not grant exactly the expected set" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/variable-repointed-to-owner-arm.json" + +# Case 51: the same, on customRoleDefinitionId, which is the grant that +# carries Microsoft.Capacity/reservationOrders/purchase/action. +run_case_saying "the custom role's variable repointed at Owner exits 1 (#1681)" 1 \ + "ARM template does not grant exactly the expected set" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/variable-customrole-repointed-arm.json" + +# Case 52: a defaultValue on the principal parameter. Every principalId in the +# template still reads parameters('servicePrincipalObjectId') and passes the +# principal axis; the deployment prefills the object ID in the default. +run_case_saying "a defaultValue on the principal parameter exits 1 (#1681)" 1 \ + "has a defaultValue" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/principal-parameter-default-arm.json" + +# Case 53: the parameter deleted outright. Deleting the declaration must not +# be the way out of the check on it. +run_case_saying "an undeclared principal parameter exits 1 (#1681)" 1 \ + "is never declared" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/principal-parameter-undeclared-arm.json" + +# Case 54: the management-group schema with "#subscriptionDeploymentTemplate" +# appended as a URL fragment. ARM reads the path, which is the +# management-group template, widening every inherited grant to cover every +# child subscription; the pin was an unanchored substring test that read the +# fragment. +run_case_saying "the mgmt-group schema with a subscription fragment exits 1" 1 \ + "not a subscription-scoped deployment" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/schema-fragment-mgmt-group-arm.json" + +# Case 55: a copy loop on a role assignment. One declaration is then N +# deployed grants whose properties can vary by copyIndex(), so the set +# asserted is not the set deployed. +run_case_saying "a copy loop on a role assignment exits 1" 1 \ + "carries a copy loop" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/copy-loop-arm.json" + +# --- second review round: the same three defects, one step further out ------ +# A second independent review of the fixes above found each of them still +# reachable by a neighbouring form, which is the shape this whole file keeps +# rediscovering: a guard written against the example is not a guard against the +# class. + +# Case 56: the management-group schema again, with the sanctioned name as the +# tail of the URL FRAGMENT rather than appended to it. Anchoring on the file +# name with an optional fragment after it still read the fragment; ARM reads +# the path, so the path is what is matched now. +run_case_saying "the mgmt-group schema with the name in the fragment tail exits 1" 1 \ + "not a subscription-scoped deployment" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/schema-fragment-tail-arm.json" + +# Case 57: condition:false on a grant. This 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 is case 42's own invariant (a removed grant is refused) +# defeated by leaving the resource where it is. +run_case_saying "a condition on a role assignment exits 1" 1 \ + "carries a condition" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/condition-false-arm.json" + +# Case 58: a case-variant key collision on a resource whose `type` is a +# number. The collision scan runs before every string-type guard in this +# script and concatenated that value into its message, so the scan that exists +# to refuse an ambiguous template aborted jq instead. +run_case_saying "a key collision on a non-string type exits 1, not 5" 1 \ + "two case-variant spellings" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/collision-non-string-type-arm.json" + +# Case 59: a variable that resolves to an object rather than a string. The +# resolver filtered with `strings`, whose empty stream dropped the ENTIRE +# resource from the inventory -- a grant vanishing from the set that exists to +# count grants, in the one axis that would otherwise have caught it. +run_case_saying "a variable resolving to a non-string is recorded, not dropped" 1 \ + "unresolved:[variables('roles').reader]" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/variable-resolves-to-object-arm.json" + +# Case 60: allowedValues on the principal parameter, the other half of case +# 52. A defaultValue answers for the customer; allowedValues constrains what +# the customer is allowed to answer. +run_case_saying "allowedValues on the principal parameter exits 1 (#1681)" 1 \ + "has allowedValues" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/principal-parameter-allowedvalues-arm.json" + +# Case 61: a flag with no value is a usage error, which this script documents +# as exit 2. Reading "$2" unguarded made it a `set -u` abort instead. +run_case "a flag with no value exits 2" 2 --arm-file + +# --- the rest of the uniterable shapes --------------------------------------- +# Cases 44-45 covered `resources` and `assignableScopes`; these are the +# siblings on the same selectors, each of which aborted jq mid-pipeline and +# surfaced exit 5. Issue #1681's own closing note asks for all of them. +run_case_saying "permissions[].actions as a bare string exits 1, not 5" 1 \ + "properties.permissions[0].actions is a string, expected an array" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/actions-not-array-arm.json" + +run_case_saying "a non-object permissions[] element exits 1, not 5" 1 \ + "properties.permissions[1] is a string, expected an object" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/permissions-element-not-object-arm.json" + +run_case_saying "a non-string assignableScopes[] element exits 1, not 5" 1 \ + "properties.assignableScopes[1] is a object, expected a string" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/assignablescopes-element-not-string-arm.json" + +run_case_saying "a document whose root is an array exits 1, not 5" 1 \ + "root has type array, expected an object" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/root-not-object-arm.json" + +# Neither of these is named *.json: one is deliberately not JSON and the other +# is empty, and the repository's check-json pre-commit hook reds every .json +# that fails to parse. +run_case_saying "a file that is not JSON exits 1, not 5" 1 \ + "is not valid JSON" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/not-json-arm.fixture" + +# An empty file parses clean and yields no JSON value at all, which is a +# different shape from a root of the wrong type and reads as one in the +# message. +run_case_saying "an empty file exits 1, naming what it found" 1 \ + "root has type " \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/empty-arm.fixture" + +# --- third review round ------------------------------------------------------ + +# Case 62: the same principalId key spelled twice in one object, the foreign +# value first. jq's parser collapses the two 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; a reviewer skimming the file reads the +# first. +# Not named *.json: the repository's check-json pre-commit hook refuses a +# duplicate key outright, which is a second, independent guard on this exact +# shape for any .json in the tree and the reason this fixture has to sit +# outside its reach to be usable as one. +run_case_saying "a duplicate principalId key exits 1 (#1681)" 1 \ + "declares the same JSON key twice" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/duplicate-principalid-key-arm.fixture" + +# Case 63: the legacy child-scoped role assignment in its RELATIVE spelling, +# "providers/roleAssignments" with no leading slash, which is the idiomatic +# form inside a parent resource's own resources array. The refusal matched the +# absolute suffix only, so the form an author would actually write was the one +# it missed, while its comment claimed coverage of any parent type. +run_case_saying "legacy child roleAssignments in relative spelling exits 1" 1 \ + "resource(s) of a type this" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/legacy-child-relative-type-arm.json" + +# Case 64: a non-string element inside permissions[].actions. The shape gate +# checked that the four permission lists are arrays but not what is in them, +# and extract_arm_list calls ascii_downcase on each element, which aborts jq +# on a number and surfaces exit 5. +run_case_saying "a non-string actions[] element exits 1, not 5" 1 \ + "properties.permissions[0].actions[11] is a number, expected a string" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/actions-element-not-string-arm.json" + +# Case 65: a principalId on the role DEFINITION's properties rather than on a +# role assignment. Nothing deploys a grant from there, and that is the point: +# the principal axis is keyed on the property name wherever it appears, not on +# the resource type, because the set of types that carry one is not a list this +# script can finish writing. Narrowing the collection to roleAssignments +# resources leaves every other case in this file green. +run_case_saying "a principalId outside a roleAssignment is still read" 1 \ + "principalId is not [parameters('servicePrincipalObjectId')]" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/principalid-on-roledefinition-arm.json" + +# Case 66: include_capacity_provider_scope defaulting to true. The TF path then +# silently reacquires the tenant-wide /providers/Microsoft.Capacity grant this +# whole guard exists to keep out (issue #1545). The assertion predates this +# change and had no case: only its fail-closed branch, the missing +# variables.tf, was covered. +TRUE_DEFAULT_TF_DIR="$(mktemp -d)" +cp "${FIXTURES}/matching-tf.tf.fixture" "${TRUE_DEFAULT_TF_DIR}/main.tf" +cat >> "${TRUE_DEFAULT_TF_DIR}/main.tf" <<'HCL' + +locals { + capacity_assignable_scopes = compact([ + "/subscriptions/00000000-0000-0000-0000-000000000001", + var.include_capacity_provider_scope ? "/providers/Microsoft.Capacity" : "", + ]) +} +HCL +cat > "${TRUE_DEFAULT_TF_DIR}/variables.tf" <<'HCL' +variable "include_capacity_provider_scope" { + type = bool + default = true +} +HCL +run_case_saying "include_capacity_provider_scope defaulting to true exits 1" 1 \ + "must default to false" \ + --tf-file "${TRUE_DEFAULT_TF_DIR}/main.tf" \ + --arm-file "${FIXTURES}/matching-arm.json" \ + "${GRANTS_ROLE_DEF_ONLY[@]}" + +# Case 67: an unknown flag is a usage error, exit 2. +run_case "an unknown flag exits 2" 2 --bogus-flag + +# Case 68: --expected-grants naming a file that does not exist. Letting `set +# -e` catch the failed read would exit with the shell's message rather than +# one that says which file and why it mattered. +run_case_saying "a missing expected-grants file exits 1" 1 \ + "expected-grants file not found" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/matching-arm.json" \ + --expected-grants "${FIXTURES}/expected/no-such-file.grants" + +# --- fourth review round ----------------------------------------------------- + +# Case 72: a template with a dotted key beside a nested path of the same +# spelling, and no duplicate key anywhere. The duplicate-key detector compared +# `--stream` paths joined with a dot, which is not injective, so it reported a +# duplicate in a legal template. A guard that reds valid input invites being +# deleted, which is how issue #1545 shipped in the first place. +run_case "a dotted key beside a nested path of the same spelling exits 0" 0 \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/dotted-key-no-duplicate-arm.json" + +# Case 73: a variable whose value carries a newline and a tab. It reaches the +# grant inventory through the roleDefinitionId allowlist untouched, because +# that allowlist compares the expression text and the expression is allowed; +# only the resolved value carries the newline. Unflattened it split one +# resource across two records, so the diagnostic described grants the template +# does not have. +run_case_saying "a newline in a resolved variable stays one record" 1 \ + "roledefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7 junk more principalId=" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/variable-value-with-newline-arm.json" + +# Case 74: a variable resolving to the empty string, which reaches the +# inventory the same way. IFS=$'"'"'\t'"'"' treats tab as IFS whitespace, so an empty +# field would collapse and shift every field after it. +run_case_saying "an empty resolved variable is named, not collapsed" 1 \ + "roleDefinitionId= principalId=" \ + --tf-file "${FIXTURES}/matching-tf.tf.fixture" \ + --arm-file "${FIXTURES}/variable-value-empty-arm.json" + +# --- the arm/ sweep ---------------------------------------------------------- +# The sweep refuses any .json under arm/ that this checker does not check, +# because every assertion in it is specific to the one template it knows +# about. It runs only when no --arm-file is given, so exercising it means +# invoking the checker with no flags -- against a REPO ROOT that is a copy, +# since a test that writes a second template into the repo's own arm/ to see +# what happens is a test that can leave one there. +# +# The checker resolves its defaults from BASH_SOURCE, so a copy of it under +# ${SWEEP_ROOT}/scripts finds ${SWEEP_ROOT}/arm and ${SWEEP_ROOT}/terraform. +SWEEP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_TF_DIR" "$SWEEP_ROOT" "$TRUE_DEFAULT_TF_DIR"; rm -f "$EMPTY_GRANTS"' EXIT +mkdir -p "${SWEEP_ROOT}/scripts" +cp "$CHECK" "${SWEEP_ROOT}/scripts/" +cp -R "${REPO_ROOT}/arm" "${SWEEP_ROOT}/arm" +mkdir -p "${SWEEP_ROOT}/terraform/modules/iam/azure" +cp -R "${REPO_ROOT}/terraform/modules/iam/azure/cudly-reservation-role" \ + "${SWEEP_ROOT}/terraform/modules/iam/azure/cudly-reservation-role" + +# Case 48: the control. The copy is the repo's own tree, so it must pass for +# the same reasons case 47 does -- otherwise case 49 below would be refusing +# the copy rather than the file added to it. +CHECK_UNDER_TEST="${SWEEP_ROOT}/scripts/check-azure-role-parity.sh" +run_case "an unmodified copy of the tree passes the sweep" 0 + +# Case 49 (#1681): a second .json under arm/. Nothing in this checker looks at +# it, so a template deploying into customer subscriptions would be onboarding +# them with no assertion made about what it grants. Naming the one file the +# checker already knows about is how a guard fails to reach its own sibling +# site, so the set is discovered and anything outside it is refused. +cp "${SWEEP_ROOT}/arm/CUDly-CrossSubscription/template.json" \ + "${SWEEP_ROOT}/arm/second-template.json" +run_case_saying "a second, unchecked template under arm/ exits 1 (#1681)" 1 \ + "holds JSON this guard does not check" +rm -f "${SWEEP_ROOT}/arm/second-template.json" + +# Case 71: the same file as a SYMLINK. `find -type f` reports a symlink as +# neither a file nor anything to refuse, so the second template was swept past +# rather than found; `find -L` resolves it first. +ln -s "${SWEEP_ROOT}/arm/CUDly-CrossSubscription/template.json" \ + "${SWEEP_ROOT}/arm/symlinked-template.json" +run_case_saying "a symlinked second template under arm/ exits 1" 1 \ + "holds JSON this guard does not check" +rm -f "${SWEEP_ROOT}/arm/symlinked-template.json" +CHECK_UNDER_TEST="$CHECK" + echo "" echo "Results: ${pass} passed, ${fail} failed." [[ "$fail" -eq 0 ]] diff --git a/scripts/testdata/role-parity/absent-principal-arm.json b/scripts/testdata/role-parity/absent-principal-arm.json new file mode 100644 index 000000000..9e0039dc7 --- /dev/null +++ b/scripts/testdata/role-parity/absent-principal-arm.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/actions-element-not-string-arm.json b/scripts/testdata/role-parity/actions-element-not-string-arm.json new file mode 100644 index 000000000..6d4a6cab4 --- /dev/null +++ b/scripts/testdata/role-parity/actions-element-not-string-arm.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action", + 123 + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/actions-not-array-arm.json b/scripts/testdata/role-parity/actions-not-array-arm.json new file mode 100644 index 000000000..2d0a157af --- /dev/null +++ b/scripts/testdata/role-parity/actions-not-array-arm.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": "Microsoft.Capacity/register/action", + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/assignablescopes-element-not-string-arm.json b/scripts/testdata/role-parity/assignablescopes-element-not-string-arm.json new file mode 100644 index 000000000..9fa9988fc --- /dev/null +++ b/scripts/testdata/role-parity/assignablescopes-element-not-string-arm.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]", + { + "scope": "x" + } + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/assignablescopes-not-array-arm.json b/scripts/testdata/role-parity/assignablescopes-not-array-arm.json new file mode 100644 index 000000000..2ad3eff67 --- /dev/null +++ b/scripts/testdata/role-parity/assignablescopes-not-array-arm.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": "[concat('/subscriptions/', subscription().subscriptionId)]" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/collision-non-string-type-arm.json b/scripts/testdata/role-parity/collision-non-string-type-arm.json new file mode 100644 index 000000000..5eb5c9cad --- /dev/null +++ b/scripts/testdata/role-parity/collision-non-string-type-arm.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": 42, + "Foo": 1, + "foo": 2 + } + ] +} diff --git a/scripts/testdata/role-parity/condition-false-arm.json b/scripts/testdata/role-parity/condition-false-arm.json new file mode 100644 index 000000000..86960ec0d --- /dev/null +++ b/scripts/testdata/role-parity/condition-false-arm.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + }, + "condition": false + } + ] +} diff --git a/scripts/testdata/role-parity/copy-loop-arm.json b/scripts/testdata/role-parity/copy-loop-arm.json new file mode 100644 index 000000000..396027de5 --- /dev/null +++ b/scripts/testdata/role-parity/copy-loop-arm.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + }, + "copy": { + "name": "extra", + "count": 5 + } + } + ] +} diff --git a/scripts/testdata/role-parity/dotted-key-no-duplicate-arm.json b/scripts/testdata/role-parity/dotted-key-no-duplicate-arm.json new file mode 100644 index 000000000..3cc019a08 --- /dev/null +++ b/scripts/testdata/role-parity/dotted-key-no-duplicate-arm.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + }, + "tags": { + "a.b": "1", + "a": { + "b": "2" + } + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/duplicate-grant-arm.json b/scripts/testdata/role-parity/duplicate-grant-arm.json new file mode 100644 index 000000000..4c6f4f1c6 --- /dev/null +++ b/scripts/testdata/role-parity/duplicate-grant-arm.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/duplicate-principalid-key-arm.fixture b/scripts/testdata/role-parity/duplicate-principalid-key-arm.fixture new file mode 100644 index 000000000..267ca66f1 --- /dev/null +++ b/scripts/testdata/role-parity/duplicate-principalid-key-arm.fixture @@ -0,0 +1,91 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "00000000-0000-0000-0000-0000000000aa", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/empty-arm.fixture b/scripts/testdata/role-parity/empty-arm.fixture new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/testdata/role-parity/expected-set-baseline-arm.json b/scripts/testdata/role-parity/expected-set-baseline-arm.json new file mode 100644 index 000000000..af1b439e9 --- /dev/null +++ b/scripts/testdata/role-parity/expected-set-baseline-arm.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/expected/role-definition-and-reader.grants b/scripts/testdata/role-parity/expected/role-definition-and-reader.grants new file mode 100644 index 000000000..8d69c116d --- /dev/null +++ b/scripts/testdata/role-parity/expected/role-definition-and-reader.grants @@ -0,0 +1,7 @@ +# Expected grant set for uppercase-allowed-roledefinitionid-arm.json: a role +# definition plus one Reader assignment, whose roleDefinitionId is deliberately +# spelled "[Variables('Roles').Reader]" to prove case folding. The fixture +# declares the variables table that spelling reads, so the grant is recorded by +# the definition it resolves to rather than by the name pointing at it. +roleDefinition assignableScopes= +roleAssignment roleDefinitionId=/providers/microsoft.authorization/roledefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7 principalId= scope= diff --git a/scripts/testdata/role-parity/expected/role-definition-only.grants b/scripts/testdata/role-parity/expected/role-definition-only.grants new file mode 100644 index 000000000..f0983c806 --- /dev/null +++ b/scripts/testdata/role-parity/expected/role-definition-only.grants @@ -0,0 +1,9 @@ +# Expected grant set for the fixtures that carry a role definition and nothing +# else. Passed to check-azure-role-parity.sh with --expected-grants, because +# the script's built-in EXPECTED_GRANTS describes the real onboarding template +# and these fixtures are deliberately smaller. +# +# Several spellings of the canonical subscription scope in one assignableScopes +# array are one grant, so canonical-scope-variants-arm.json (four spellings) +# reduces to the same single line as matching-arm.json (one). +roleDefinition assignableScopes= diff --git a/scripts/testdata/role-parity/foreign-principal-literal-arm.json b/scripts/testdata/role-parity/foreign-principal-literal-arm.json new file mode 100644 index 000000000..c2f22e5b5 --- /dev/null +++ b/scripts/testdata/role-parity/foreign-principal-literal-arm.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid('unrelated', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "11111111-2222-3333-4444-555555555555", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/foreign-principal-parameter-arm.json b/scripts/testdata/role-parity/foreign-principal-parameter-arm.json new file mode 100644 index 000000000..15d740ed6 --- /dev/null +++ b/scripts/testdata/role-parity/foreign-principal-parameter-arm.json @@ -0,0 +1,104 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + }, + "extraPrincipalId": { + "type": "string", + "defaultValue": "11111111-2222-3333-4444-555555555555" + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid('unrelated', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('extraPrincipalId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/legacy-child-relative-type-arm.json b/scripts/testdata/role-parity/legacy-child-relative-type-arm.json new file mode 100644 index 000000000..df4fda4ec --- /dev/null +++ b/scripts/testdata/role-parity/legacy-child-relative-type-arm.json @@ -0,0 +1,99 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "providers/roleAssignments", + "apiVersion": "2022-04-01", + "name": "Microsoft.Authorization/child-owner-grant", + "properties": { + "roleDefinitionId": "/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635", + "principalId": "[parameters('servicePrincipalObjectId')]" + } + } + ] +} diff --git a/scripts/testdata/role-parity/missing-grant-arm.json b/scripts/testdata/role-parity/missing-grant-arm.json new file mode 100644 index 000000000..8fe0ff470 --- /dev/null +++ b/scripts/testdata/role-parity/missing-grant-arm.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/not-json-arm.fixture b/scripts/testdata/role-parity/not-json-arm.fixture new file mode 100644 index 000000000..7f8c66c66 --- /dev/null +++ b/scripts/testdata/role-parity/not-json-arm.fixture @@ -0,0 +1 @@ +this is not JSON diff --git a/scripts/testdata/role-parity/permissions-element-not-object-arm.json b/scripts/testdata/role-parity/permissions-element-not-object-arm.json new file mode 100644 index 000000000..fc9a4c77a --- /dev/null +++ b/scripts/testdata/role-parity/permissions-element-not-object-arm.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + }, + "oops" + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/principal-parameter-allowedvalues-arm.json b/scripts/testdata/role-parity/principal-parameter-allowedvalues-arm.json new file mode 100644 index 000000000..5a597c3a2 --- /dev/null +++ b/scripts/testdata/role-parity/principal-parameter-allowedvalues-arm.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {}, + "allowedValues": [ + "11111111-2222-3333-4444-555555555555" + ] + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/principal-parameter-default-arm.json b/scripts/testdata/role-parity/principal-parameter-default-arm.json new file mode 100644 index 000000000..cf47c94bc --- /dev/null +++ b/scripts/testdata/role-parity/principal-parameter-default-arm.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {}, + "defaultValue": "11111111-2222-3333-4444-555555555555" + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/principal-parameter-undeclared-arm.json b/scripts/testdata/role-parity/principal-parameter-undeclared-arm.json new file mode 100644 index 000000000..19fbf0d27 --- /dev/null +++ b/scripts/testdata/role-parity/principal-parameter-undeclared-arm.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/principalid-on-roledefinition-arm.json b/scripts/testdata/role-parity/principalid-on-roledefinition-arm.json new file mode 100644 index 000000000..8522d9695 --- /dev/null +++ b/scripts/testdata/role-parity/principalid-on-roledefinition-arm.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ], + "principalId": "00000000-0000-0000-0000-0000000000aa" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/resources-not-array-arm.json b/scripts/testdata/role-parity/resources-not-array-arm.json new file mode 100644 index 000000000..f39317b06 --- /dev/null +++ b/scripts/testdata/role-parity/resources-not-array-arm.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": "see the other template" +} diff --git a/scripts/testdata/role-parity/root-not-object-arm.json b/scripts/testdata/role-parity/root-not-object-arm.json new file mode 100644 index 000000000..80be25d2d --- /dev/null +++ b/scripts/testdata/role-parity/root-not-object-arm.json @@ -0,0 +1,92 @@ +[ + { + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] + } +] diff --git a/scripts/testdata/role-parity/schema-fragment-mgmt-group-arm.json b/scripts/testdata/role-parity/schema-fragment-mgmt-group-arm.json new file mode 100644 index 000000000..47d13c859 --- /dev/null +++ b/scripts/testdata/role-parity/schema-fragment-mgmt-group-arm.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-08-01/managementGroupDeploymentTemplate.json#subscriptionDeploymentTemplate", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/schema-fragment-tail-arm.json b/scripts/testdata/role-parity/schema-fragment-tail-arm.json new file mode 100644 index 000000000..d7d43870a --- /dev/null +++ b/scripts/testdata/role-parity/schema-fragment-tail-arm.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-08-01/managementGroupDeploymentTemplate.json#/subscriptionDeploymentTemplate.json", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/unrecognized-resource-type-arm.json b/scripts/testdata/role-parity/unrecognized-resource-type-arm.json new file mode 100644 index 000000000..a33902c53 --- /dev/null +++ b/scripts/testdata/role-parity/unrecognized-resource-type-arm.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.ManagedIdentity/userAssignedIdentities", + "apiVersion": "2023-01-31", + "name": "unreviewed-identity", + "location": "westeurope" + } + ] +} diff --git a/scripts/testdata/role-parity/uppercase-allowed-roledefinitionid-arm.json b/scripts/testdata/role-parity/uppercase-allowed-roledefinitionid-arm.json index 072b4eba1..7c98986d6 100644 --- a/scripts/testdata/role-parity/uppercase-allowed-roledefinitionid-arm.json +++ b/scripts/testdata/role-parity/uppercase-allowed-roledefinitionid-arm.json @@ -1,5 +1,15 @@ { "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "parameters": { + "servicePrincipalObjectId": { + "type": "string" + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7" + } + }, "resources": [ { "type": "Microsoft.Authorization/roleDefinitions", @@ -37,7 +47,7 @@ "name": "reader-grant-uppercase-expr", "properties": { "roleDefinitionId": "[Variables('Roles').Reader]", - "principalId": "00000000-0000-0000-0000-0000000000aa", + "principalId": "[parameters('servicePrincipalObjectId')]", "principalType": "ServicePrincipal" } } diff --git a/scripts/testdata/role-parity/variable-customrole-repointed-arm.json b/scripts/testdata/role-parity/variable-customrole-repointed-arm.json new file mode 100644 index 000000000..5e413a714 --- /dev/null +++ b/scripts/testdata/role-parity/variable-customrole-repointed-arm.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/variable-repointed-to-owner-arm.json b/scripts/testdata/role-parity/variable-repointed-to-owner-arm.json new file mode 100644 index 000000000..baae2920f --- /dev/null +++ b/scripts/testdata/role-parity/variable-repointed-to-owner-arm.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/variable-resolves-to-object-arm.json b/scripts/testdata/role-parity/variable-resolves-to-object-arm.json new file mode 100644 index 000000000..ae33e4377 --- /dev/null +++ b/scripts/testdata/role-parity/variable-resolves-to-object-arm.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": { + "id": "/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635" + }, + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/variable-value-empty-arm.json b/scripts/testdata/role-parity/variable-value-empty-arm.json new file mode 100644 index 000000000..63d341ed7 --- /dev/null +++ b/scripts/testdata/role-parity/variable-value-empty-arm.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +} diff --git a/scripts/testdata/role-parity/variable-value-with-newline-arm.json b/scripts/testdata/role-parity/variable-value-with-newline-arm.json new file mode 100644 index 000000000..c0e9add78 --- /dev/null +++ b/scripts/testdata/role-parity/variable-value-with-newline-arm.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "servicePrincipalObjectId": { + "type": "string", + "metadata": {} + }, + "roleAssignmentGuidPrefix": { + "type": "string", + "defaultValue": "[newGuid()]", + "metadata": {} + } + }, + "variables": { + "roles": { + "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7\nJUNK\tMORE", + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "apiVersion": "2022-04-01", + "name": "[variables('customRoleName')]", + "properties": { + "roleName": "CUDly Reservation Purchaser (custom)", + "type": "CustomRole", + "permissions": [ + { + "actions": [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/register/action", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [] + } + ], + "assignableScopes": [ + "[concat('/subscriptions/', subscription().subscriptionId)]" + ] + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').reader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "properties": { + "roleDefinitionId": "[variables('roles').costManagementReader]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal" + } + } + ] +}