Skip to content

Resolve policy definition versions to the version the assignment actually runs - #248

Open
Yingjie Chen (Y1ngJ1eChen) wants to merge 3 commits into
mainfrom
user/yingjie/fix-issue-4190-extra
Open

Resolve policy definition versions to the version the assignment actually runs#248
Yingjie Chen (Y1ngJ1eChen) wants to merge 3 commits into
mainfrom
user/yingjie/fix-issue-4190-extra

Conversation

@Y1ngJ1eChen

@Y1ngJ1eChen Yingjie Chen (Y1ngJ1eChen) commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

1. Fix the issue: Azure/Azure-Landing-Zones#4190

#246 stopped the crash by skipping empty scopes, but deliberately left the version-resolution behavior untouched. This PR fixes that remaining part.

  • Problem: alzlib re-resolves an assignment's definitionVersion range client-side and selects the highest match, rather than the version the assignment actually runs. Role assignments are then computed against the wrong member set, producing spurious or missing policy_role_assignments when a built-in initiative is bumped upstream. Reported trigger: Deploy-MCSB2-Monitoring pins 1.*.*-preview, Microsoft published a non-preview 1.4.0, the tenant stayed on 1.3.0-preview, but the client jumped to 1.4.0.

  • Root cause: Azure Policy encodes state as a semver prerelease suffix, -preview or -deprecated. Masterminds expands 1.*.*-preview into a range whose upper bound is a clean version, so the suffix leaks and non-preview releases also match. GetVersion() then returned the highest match. The leak occurs only with a wildcard minor plus a suffix; pinning the minor (1.3.*-preview) scopes it correctly, which is why the workaround reported in the issue works.

  • Fix: Replaced the highest-match selection in GetVersion() with identifier-aware, three-tier resolution: highest match carrying the same suffix, then the highest stable release, then any remaining match. A boolean "is a prerelease" test is not sufficient because semver treats -preview and -deprecated alike, so a -preview constraint would otherwise select a higher -deprecated version. The stable tier preserves the intentional graduated-out-of-preview fallback, so assignments pinned to -preview still resolve once only non-preview versions remain, instead of failing with no version found. The last tier keeps deprecated-only sets resolvable. The constraint parser and validator are unchanged; only selection changed.

2. Resolve against the deployed effective version

  • Problem: The fix above still resolves to the highest matching preview. If the tenant runs an older preview than the highest available one, the client and the Policy RP still disagree. For example, with {1.1.0-preview, 1.2.0-preview, 1.3.0-preview, 1.4.0} deployed at 1.2.0-preview, resolution returns 1.3.0-preview.

  • Fix: Azure reports the version an assignment actually runs as the read-only effectiveDefinitionVersion property. PolicyAssignment now carries it, and ReferencedPolicyDefinitionResourceIDAndVersion() prefers it over the writable definitionVersion constraint. This single accessor feeds both the definition fetch in deployment/hierarchy.go and the role-assignment computation in deployment/managementgroup.go, so both follow the same version. The example above now resolves to 1.2.0-preview. When no effective version is supplied, resolution falls back to the constraint, so behavior is unchanged for existing callers and greenfield deployments.

  • Writable constraint validation: Azure does not permit assignments to pin a specific patch version. ValidatePolicyAssignment() therefore continues to require the patch component of the writable definitionVersion to be a wildcard. Values such as 1.*.*-preview and 1.3.*-preview are accepted, while 1.3.0 and 1.3.0-preview are rejected.

  • Exact effective-version lookup: GetVersion() resolves an exact version before treating the input as a constraint. This is required because Azure reports effectiveDefinitionVersion as an exact value such as 1.2.0-preview. This exact value is only an internal lookup key and does not change the supported writable assignment formats.

  • Why the effective version is not stored in the embedded SDK type: The SDK marshaller emits effectiveDefinitionVersion and latestDefinitionVersion, and assignments are written out as ARM JSON where Azure rejects these read-only properties. The effective version is therefore held in an unexported field on the wrapper. Both read-only SDK fields are cleared during construction or unmarshal, while the internal value survives the deep copies made by AlzLib.

3. Behavior changes to be aware of

The change is not purely additive. Two existing semantics changed, both in a safe direction:

  • GetVersion() can resolve an exact semantic version when it is used as Azure's read-only effectiveDefinitionVersion. This does not permit an assignment to pin its writable definitionVersion to a patch version. ValidatePolicyAssignment() continues to require a wildcard patch (#.*.* or #.#.*) so patch updates are automatically ingested as required by Azure Policy.
  • Properties.EffectiveDefinitionVersion and Properties.LatestDefinitionVersion are cleared on construction and unmarshal. Practical impact is nil, because library JSON never carries these read-only fields and nothing in the repository reads them, but this is what prevents the ARM serialization leak described in section 2.

4. Out of scope

Populating the effective version requires reading deployed policy assignments from Azure, which alzlib never does; it only reads built-in policy definitions and policy set definitions. Consumers such as terraform-provider-alz supply the value through SetEffectiveDefinitionVersion(), so closing the drift case end to end needs a provider-side change. Section 1 on its own already resolves the preview to non-preview jump reported in the issue, and requires only a dependency bump downstream.

…on versions

Azure Policy encodes preview and deprecated state as a semver prerelease
suffix ("-preview" / "-deprecated"). Masterminds semver expands a wildcard
constraint such as "1.*.*-preview" to a range whose upper bound is a clean
version, so the suffix leaks and the range also matches non-preview releases.
GetVersion then selected the highest match, resolving "1.*.*-preview" to
1.4.0 instead of the 1.3.0-preview the assignment is actually pinned to.

Role assignments were therefore computed against a different version than the
one the Policy RP enforces, producing spurious or missing role assignments on
upstream version bumps.

Replace the highest-match selection in GetVersion with an identifier-aware
three-tier resolution:

  1. highest match carrying the SAME prerelease suffix
  2. highest stable release, so a definition that graduated out of preview
     still resolves instead of failing with "no version found"
  3. highest remaining match, so a set containing only a different suffix
     (e.g. deprecated-only) still resolves

Matching by identifier rather than by "is a prerelease" is required because
semver treats "-preview" and "-deprecated" alike, so a "-preview" constraint
would otherwise select a higher "-deprecated" version.

Rename policyVersionConstraintHasPrerelease to
policyVersionConstraintPrerelease and return the identifier instead of a bool.

Verified: "1.*.*-preview" over {1.2.0-preview, 1.3.0-preview, 1.4.0-deprecated,
1.5.0} now resolves to 1.3.0-preview.

Refs Azure/Azure-Landing-Zones#4190
Option A made a "-preview" constraint resolve to a matching preview rather than
to a newer stable release, but it still re-resolves the constraint to the
highest match. When a tenant runs an older preview than the highest available
one, the client and the Policy RP still disagree, and role assignments are
computed against the wrong member set.

Azure reports the version an assignment actually runs as the read-only
effectiveDefinitionVersion property. Carry it on PolicyAssignment and prefer it
over the definitionVersion constraint when resolving the referenced definition,
so role assignments follow what the Policy RP enforces instead of what the
constraint happens to match today.

The value is held in an unexported field rather than in the embedded SDK type.
The SDK marshaller emits effectiveDefinitionVersion and latestDefinitionVersion,
and assignments are written out as ARM JSON, where Azure rejects these read-only
properties. Keeping the value off the embedded type makes leaking it structurally
impossible, and a deep copy preserves it, so it survives the copies made when an
assignment is stored in and retrieved from AlzLib. Both read-only fields are
cleared when an assignment is constructed or unmarshalled.

GetVersion now resolves an exact version by direct lookup before treating the
string as a constraint. This is required because an effective version such as
1.2.0-preview carries no wildcard and the constraint validator rejects it. It
also makes pinning a concrete definitionVersion work, which previously failed
validation despite being valid for Azure Policy.

Resolution falls back to the constraint when no effective version is known, so
behaviour is unchanged for callers that do not supply one, including greenfield
deployments where the assignment does not exist yet.

Note that populating the effective version requires reading deployed assignments
from Azure, which alzlib does not do; consumers such as terraform-provider-alz
supply it via SetEffectiveDefinitionVersion.

Refs Azure/Azure-Landing-Zones#4190
@codecov-commenter

Codecov Comments Bot (codecov-commenter) commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.61017% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 48.72%. Comparing base (ff9170d) to head (7c1ff3e).

Files with missing lines Patch % Lines
assets/policyAssignment.go 93.33% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #248      +/-   ##
==========================================
+ Coverage   47.93%   48.72%   +0.78%     
==========================================
  Files          54       54              
  Lines        4915     4965      +50     
==========================================
+ Hits         2356     2419      +63     
+ Misses       2272     2256      -16     
- Partials      287      290       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@matt-FFFFFF

Copy link
Copy Markdown
Contributor

Hi! nice work on this fix, you have captured the desired behaviour really well :)

One thing to bear in mind is that Azure does not permit pinning to a patch version:

https://learn.microsoft.com/azure/governance/policy/concepts/assignment-structure#policy-definition-id-and-version

  • To autoingest any minor changes of the definition, the version number would be #... The Wildcard represents autoingesting updates.
  • To pin to a minor version path, the version format would be #.#.*.
  • All patch changes must be autoinjested for security purposes. Patch changes are limited to text changes and break glass scenarios.

Azure Policy does not permit assignments to pin a definition to a specific
patch version. Writable definitionVersion values must keep the patch component
as a wildcard so patch updates are always ingested.

Distinguish the writable definitionVersion constraint from Azure's read-only
effectiveDefinitionVersion. Validate assignment definitionVersion values with
the existing policy constraint validator, while retaining exact-version lookup
for effective versions reported by Azure.

Accept supported wildcard forms such as 1.*.*-preview and 1.3.*-preview, and
reject exact patch values such as 1.3.0 and 1.3.0-preview.

Clarify the GetVersion documentation and add regression coverage for the
supported and unsupported assignment version formats.

Refs Azure/Azure-Landing-Zones#4190
@Y1ngJ1eChen

Copy link
Copy Markdown
Contributor Author

Thanks Matt, you're right. I had conflated the exact, read-only effectiveDefinitionVersion returned by Azure with the writable definitionVersion constraint on an assignment.

The exact lookup is still required internally because Azure can report an effective version such as 1.2.0-preview, but it must not imply that users can pin an assignment to a specific patch version.

I have updated the implementation to preserve that distinction:

  • ValidatePolicyAssignment() accepts writable constraints with a wildcard patch, such as 1.*.*-preview and 1.3.*-preview.
  • It rejects patch-pinned writable values such as 1.3.0 and 1.3.0-preview.
  • Exact-version lookup is retained for resolving Azure's read-only effectiveDefinitionVersion.

I have also updated the code comments and added regression tests for both the supported wildcard formats and the rejected patch-pinned formats.
I have also updated the PR description to remove the incorrect statement that Azure permits pinning definitionVersion to a concrete patch version.

Thanks for catching this distinction.

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