Resolve policy definition versions to the version the assignment actually runs - #248
Resolve policy definition versions to the version the assignment actually runs#248Yingjie Chen (Y1ngJ1eChen) wants to merge 3 commits into
Conversation
…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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
|
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:
|
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
|
Thanks Matt, you're right. I had conflated the exact, read-only The exact lookup is still required internally because Azure can report an effective version such as I have updated the implementation to preserve that distinction:
I have also updated the code comments and added regression tests for both the supported wildcard formats and the rejected patch-pinned formats. Thanks for catching this distinction. |
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:
alzlibre-resolves an assignment'sdefinitionVersionrange 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 missingpolicy_role_assignmentswhen a built-in initiative is bumped upstream. Reported trigger:Deploy-MCSB2-Monitoringpins1.*.*-preview, Microsoft published a non-preview1.4.0, the tenant stayed on1.3.0-preview, but the client jumped to1.4.0.Root cause: Azure Policy encodes state as a semver prerelease suffix,
-previewor-deprecated. Masterminds expands1.*.*-previewinto 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-previewand-deprecatedalike, so a-previewconstraint would otherwise select a higher-deprecatedversion. The stable tier preserves the intentional graduated-out-of-preview fallback, so assignments pinned to-previewstill resolve once only non-preview versions remain, instead of failing withno 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 at1.2.0-preview, resolution returns1.3.0-preview.Fix: Azure reports the version an assignment actually runs as the read-only
effectiveDefinitionVersionproperty.PolicyAssignmentnow carries it, andReferencedPolicyDefinitionResourceIDAndVersion()prefers it over the writabledefinitionVersionconstraint. This single accessor feeds both the definition fetch indeployment/hierarchy.goand the role-assignment computation indeployment/managementgroup.go, so both follow the same version. The example above now resolves to1.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 writabledefinitionVersionto be a wildcard. Values such as1.*.*-previewand1.3.*-previeware accepted, while1.3.0and1.3.0-previeware rejected.Exact effective-version lookup:
GetVersion()resolves an exact version before treating the input as a constraint. This is required because Azure reportseffectiveDefinitionVersionas an exact value such as1.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
effectiveDefinitionVersionandlatestDefinitionVersion, 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-onlyeffectiveDefinitionVersion. This does not permit an assignment to pin its writabledefinitionVersionto a patch version.ValidatePolicyAssignment()continues to require a wildcard patch (#.*.*or#.#.*) so patch updates are automatically ingested as required by Azure Policy.Properties.EffectiveDefinitionVersionandProperties.LatestDefinitionVersionare 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
alzlibnever does; it only reads built-in policy definitions and policy set definitions. Consumers such asterraform-provider-alzsupply the value throughSetEffectiveDefinitionVersion(), 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.