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" + } + } + ] +}