Skip to content

fix(azure_policy): collect nested wildcards below a count-bound prefix - #871

Merged
Anand Krishnamoorthi (anakrish) merged 1 commit into
microsoft:mainfrom
snehabandla:split/azure-policy-count-field-fn-nested-wildcard
Oct 27, 2026
Merged

Anand Krishnamoorthi (anakrish) merged 1 commit into
microsoft:mainfrom
snehabandla:split/azure-policy-count-field-fn-nested-wildcard

Conversation

@snehabandla

Copy link
Copy Markdown
Contributor

Summary

Inside a count where, a path that extends the counted [*] prefix with further [*] levels was navigated only up to the next wildcard, and field() then wrapped that value in a one-member array.

Resource {"properties":{"items":[{"subs":[{"v":"x"},{"v":"y"}]}]}}:

{"if":{"count":{"field":"properties.items[*]",
  "where":{"value":"[field('properties.items[*].subs[*].v')]","equals":["x","y"]}},
  "equals":1},"then":{"effect":"deny"}}
  • Before: field() returned [[{"v":"x"},{"v":"y"}]] → no effect.
  • After: ["x","y"] → deny.

Azure docs ("The field function inside where conditions"): "Array aliases are resolved into a collection of values selected from all array members." The same page shows field('...objectArray[*].nestedArray[*]') ⇒ [1, 2, 3, 4].

Fix

When the suffix after the bound prefix still contains [*], compile_suffix_from_binding reuses the existing wildcard collector (compile_field_wildcard_collect, now taking an optional base register) relative to the current element. field() wraps into a one-member array only when the path selects a single value.

Paths that go through the same code:

  • current('a[*].b[*].c') inside a count over a[*] now collects too (the built-in CMK policy in e2e_cmk_disk_encryption.yaml depends on this). Previously it returned the inner array itself; a missing inner array now gives [].
  • A dynamic if(cond, 'a[*].b[*].c', ...) field inside where now collects.
  • A "field": "a[*].b[*]" condition inside where keeps its implicit allOf behaviour; a test pins it.

Testing

tests/azure_policy/cases/count.yaml: 9 scenarios. Every counterexample fails before the fix and every _no_match twin passes both before and after. Validated with fmt, clippy (both configs), lib and integration tests on Rust 1.95.0. Deep-reviewed (round 5: no defects; review-requested tests added).

Dependencies: none (based on main).

Inside a count `where`, a path that extends the counted `[*]` prefix with
further `[*]` levels was navigated only up to the next wildcard
(`compile_suffix_from_binding` dropped everything after it), and `field()`
then wrapped that single value in a one-member array.

Before (no alias catalog), resource
{"properties":{"items":[{"subs":[{"v":"x"},{"v":"y"}]}]}}:
  {"if":{"count":{"field":"properties.items[*]",
    "where":{"value":"[field('properties.items[*].subs[*].v')]","equals":["x","y"]}},
    "equals":1},"then":{"effect":"deny"}}
  -> field() returned [[{"v":"x"},{"v":"y"}]] (suffix after the next [*]
     dropped, then wrapped) -> Undefined.
After: field() returns ["x","y"] -> deny.

Azure docs (author-policies-for-arrays, "The field function inside where
conditions"): "Array aliases are resolved into a collection of values
selected from all array members." and "Referencing the counted array alias
inside the `where` condition returns a collection with a single value
selected from the array member that is evaluated in the current
iteration." The same page shows
`field('...objectArray[*].nestedArray[*]')` => `[1, 2, 3, 4]` (flattened).

Fix: when the suffix after the bound prefix still contains `[*]`,
`compile_suffix_from_binding` now reuses the existing unbound wildcard
collector (`compile_field_wildcard_collect`, now taking an optional base
register) relative to the current element; suffixes without `[*]` keep
single-value navigation. `field()` only wraps into a one-member array when
the path selects a single value from the bound element.

Siblings sharing compile_suffix_from_binding / compile_from_binding:
- current('a[*].b[*].c') inside count over a[*] now also collects (the
  built-in CMK policy in e2e_cmk_disk_encryption.yaml relies on
  `length(current('...targetRegions[*].encryption.dataDiskImages[*].diskEncryptionSetId'))`);
  previously it returned the inner array itself.
- dynamic `if(cond, 'a[*].b[*].c[*]', ...)` field inside where now collects.
- field condition `"field": "a[*].b[*]"` inside where is unchanged (already
  implicit allOf via has_inner_unbound_wildcard_field); pinned by a test.

Tests (tests/azure_policy/cases/count.yaml); (*) = fails pre-fix,
every _no_match twin passes both before and after:
- field_fn_nested_wildcard_under_bound_prefix_collects (*) / _no_match
- field_fn_two_level_nested_wildcard_under_bound_prefix_flattens (*) / _no_match
- field_fn_property_of_bound_member_is_single_member_array
- field_condition_nested_wildcard_under_bound_prefix_is_allof
- current_nested_wildcard_under_bound_prefix_collects (*) / _no_match
- current_property_of_bound_member_is_single_value / _no_match
- current_nested_wildcard_over_missing_inner_array_is_empty (*) / _no_match
- dynamic_if_field_nested_wildcard_under_bound_prefix_collects (*) / _no_match
- nested_count_field_fn_wildcard_under_inner_bound_prefix_collects (*) / _no_match

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

The focused change reuses existing collection logic, has targeted regression coverage, and has no identified blocking defects.

No open issues 🎉

What changed in this PR

Fixes nested wildcard resolution in Azure Policy count conditions so paths extending a count-bound prefix collect values relative to the current member.

Changes:

  • Reuses the wildcard collector with an optional base register for bound paths.
  • Preserves single-value wrapping for field() while avoiding extra wrapping of collected arrays.
  • Adds regression coverage for nested wildcards, current(), dynamic fields, missing arrays, and nested counts.
File Description
tests/​azure_policy/​cases/​count.yaml Adds matching and non-matching regression cases for bound-path behavior.
src/​languages/​azure_policy/​compiler/​fields.rs Allows wildcard collection relative to a supplied base register.
src/​languages/​azure_policy/​compiler/​expressions.rs Wraps bound field() results only when they select a single value.
src/​languages/​azure_policy/​compiler/​count.rs Collects wildcard suffixes and identifies single-value bound paths.

Review effort: Balanced

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants