Skip to content

[ChangeSafety] Phase 1: AcquirePolicyToken Support#449

Open
notyashhh wants to merge 2 commits intomainfrom
feat/change-safety
Open

[ChangeSafety] Phase 1: AcquirePolicyToken Support#449
notyashhh wants to merge 2 commits intomainfrom
feat/change-safety

Conversation

@notyashhh
Copy link
Copy Markdown
Member

@notyashhh notyashhh commented Oct 17, 2025

Add Change Safety support: AcquirePolicyToken and ChangeReference parameters

Summary

Adds Change Safety protocol support to Azure PowerShell, matching Azure CLI parity. Two new dynamic parameters (-AcquirePolicyToken and -ChangeReference) are automatically available on all write cmdlets but excluded from read-only cmdlets (Get/List/Show).

Main Repo PR: Azure/azure-powershell#29222

What this does

When a user passes -AcquirePolicyToken or -ChangeReference, the handler:

  1. Intercepts outgoing PUT/POST/DELETE/PATCH requests
  2. Calls POST /subscriptions/{id}/providers/Microsoft.Authorization/acquirePolicyToken with operation details and change reference
  3. Attaches the returned token as x-ms-policy-external-evaluations header on the original request

When neither parameter is passed, there is zero impact on existing behavior.

Files changed

File Change
AzurePSCmdlet.cs Added GetDynamicParameters() virtual method, ShouldAcquirePolicyToken, CurrentChangeReference
AcquirePolicyTokenHandler.cs New DelegatingHandler for token acquisition (api-version 2025-03-01)
AzureRMCmdlet.cs Changed GetDynamicParameters() to override to chain base parameters
ConfigKeysForCommon.cs Removed EnablePolicyToken feature flag (always-on, matching Azure CLI)
GeneralUtilities.cs Added x-ms-policy-external-evaluations to sanitized header list
AzureRMCmdletUnitTests.cs Updated assertions for new dynamic parameters
Common.Test/ New — 61 unit tests for handler, payload format, regression safety

Testing

  • 61 new unit tests in Common.Test
  • 41 updated tests in ResourceManager.Test
  • All 140+ tests pass across all test suites
  • End-to-end validated with connected repos (Storage, IotHub modules)

Copilot AI review requested due to automatic review settings October 17, 2025 03:01
@notyashhh
Copy link
Copy Markdown
Member Author

Related to this change: Azure/azure-powershell#28711

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This pull request implements Phase 1 of the ChangeSafety feature, adding support for acquiring Azure Policy tokens to validate resource operations. The implementation introduces a new -AcquirePolicyToken dynamic parameter and handler that intercepts write operations to automatically obtain policy tokens from the Azure Authorization API.

Key changes:

  • Introduces AcquirePolicyTokenHandler to intercept HTTP requests and acquire policy tokens for write operations
  • Adds -AcquirePolicyToken dynamic parameter to Azure cmdlets (excludes read-only operations like Get-/List-/Show-*)
  • Implements feature flag support via environment variable AZ_ENABLE_POLICY_TOKEN and config key EnablePolicyToken

Reviewed Changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/Common/AcquirePolicyTokenHandler.cs New handler that acquires policy tokens from Azure Authorization API and attaches them to outgoing requests
src/Common/AzurePSCmdlet.cs Adds dynamic parameter support, feature flag logic, and handler registration for policy token acquisition
src/ResourceManager/Version2016_09_01/AzureRMCmdlet.cs Updates GetDynamicParameters to call base implementation to support inherited dynamic parameters
src/Common/Utilities/GeneralUtilities.cs Adds policy token header to authorization header sanitization list
src/Authentication.Abstractions/Models/ConfigKeysForCommon.cs Defines new config key for EnablePolicyToken feature flag

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

@notyashhh notyashhh requested review from Copilot and isra-fel March 24, 2026 08:21
@notyashhh notyashhh marked this pull request as ready for review March 24, 2026 08:21
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


object contentObj = null;
if (originalRequest.Content != null)
{
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

Reading originalRequest.Content via ReadAsStringAsync() can consume non-buffered content (e.g., StreamContent) and leave the original request body empty when it later goes through base.SendAsync, breaking write operations. Consider buffering the content (LoadIntoBufferAsync) before reading, or cloning the content for the token payload without mutating/consuming the original request.

Suggested change
{
{
await originalRequest.Content.LoadIntoBufferAsync().ConfigureAwait(false);

Copilot uses AI. Check for mistakes.
Comment on lines +168 to +172
using (var http = new HttpClient())
{
EnqueueDebug($"POST acquirePolicyToken {tokenUri}");
var response = await http.SendAsync(tokenRequest, cancellationToken).ConfigureAwait(false);
EnqueueDebug($"Response {(int)response.StatusCode} {response.StatusCode}");
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

Creating a new HttpClient per request inside the handler can lead to socket exhaustion and bypasses any proxy/timeout/retry configuration applied via AzureSession.Instance.ClientFactory. Prefer reusing a shared HttpClient (or using the existing ClientFactory-created client/handler stack) and ensure responses are disposed promptly (e.g., dispose HttpResponseMessage) to release connections.

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +31
/// Delegating handler to acquire an Azure Policy token for change safety feature and attach to outgoing request.
/// Activated when user specifies -AcquirePolicyToken. (ChangeReference deferred to Phase 2.)
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

The XML doc says ChangeReference is deferred to Phase 2, but the handler already includes changeReference in the payload and ShouldAcquirePolicyToken triggers when ChangeReference is provided. Please update the comment to reflect current behavior (or adjust the implementation if ChangeReference truly must be deferred).

Suggested change
/// Delegating handler to acquire an Azure Policy token for change safety feature and attach to outgoing request.
/// Activated when user specifies -AcquirePolicyToken. (ChangeReference deferred to Phase 2.)
/// Delegating handler to acquire an Azure Policy token for the change safety feature and attach it to the outgoing request.
/// Activated when the user specifies -AcquirePolicyToken or when a ChangeReference is provided.

Copilot uses AI. Check for mistakes.
Comment on lines +426 to +456
try
{
var acquireParam = new RuntimeDefinedParameter(
"AcquirePolicyToken",
typeof(SwitchParameter),
new Collection<Attribute>
{
new ParameterAttribute
{
HelpMessage = "Acquire an Azure Policy token automatically for this resource operation.",
ParameterSetName = ParameterAttribute.AllParameterSets
}
});
dict.Add("AcquirePolicyToken", acquireParam);

var changeRefParam = new RuntimeDefinedParameter(
"ChangeReference",
typeof(string),
new Collection<Attribute>
{
new ParameterAttribute
{
HelpMessage = "The related change reference ID for this resource operation.",
ParameterSetName = ParameterAttribute.AllParameterSets
}
});
dict.Add("ChangeReference", changeRefParam);
}
catch
{
// Ignore dynamic parameter creation issues.
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

The catch-all here silently swallows any failures while adding dynamic parameters (including common cases like duplicate keys). This can make cmdlet parameter binding failures very hard to diagnose. Prefer avoiding the exception (e.g., check dict.ContainsKey before Add) and/or emit a debug message when parameter creation fails instead of ignoring it entirely.

Suggested change
try
{
var acquireParam = new RuntimeDefinedParameter(
"AcquirePolicyToken",
typeof(SwitchParameter),
new Collection<Attribute>
{
new ParameterAttribute
{
HelpMessage = "Acquire an Azure Policy token automatically for this resource operation.",
ParameterSetName = ParameterAttribute.AllParameterSets
}
});
dict.Add("AcquirePolicyToken", acquireParam);
var changeRefParam = new RuntimeDefinedParameter(
"ChangeReference",
typeof(string),
new Collection<Attribute>
{
new ParameterAttribute
{
HelpMessage = "The related change reference ID for this resource operation.",
ParameterSetName = ParameterAttribute.AllParameterSets
}
});
dict.Add("ChangeReference", changeRefParam);
}
catch
{
// Ignore dynamic parameter creation issues.
var acquireParam = new RuntimeDefinedParameter(
"AcquirePolicyToken",
typeof(SwitchParameter),
new Collection<Attribute>
{
new ParameterAttribute
{
HelpMessage = "Acquire an Azure Policy token automatically for this resource operation.",
ParameterSetName = ParameterAttribute.AllParameterSets
}
});
if (!dict.ContainsKey("AcquirePolicyToken"))
{
dict.Add("AcquirePolicyToken", acquireParam);
}
else
{
WriteDebug("Dynamic parameter 'AcquirePolicyToken' was not added because a parameter with the same name already exists.");
}
var changeRefParam = new RuntimeDefinedParameter(
"ChangeReference",
typeof(string),
new Collection<Attribute>
{
new ParameterAttribute
{
HelpMessage = "The related change reference ID for this resource operation.",
ParameterSetName = ParameterAttribute.AllParameterSets
}
});
if (!dict.ContainsKey("ChangeReference"))
{
dict.Add("ChangeReference", changeRefParam);
}
else
{
WriteDebug("Dynamic parameter 'ChangeReference' was not added because a parameter with the same name already exists.");

Copilot uses AI. Check for mistakes.
private static Assembly assembly = Assembly.GetExecutingAssembly();

private static List<string> AuthorizationHeaderNames = new List<string>() { "Authorization" };
private static List<string> AuthorizationHeaderNames = new List<string>() { "Authorization", "x-ms-policy-external-evaluations" };
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

Indentation of this field is inconsistent with the surrounding block (it’s currently less-indented than adjacent members). Please align it with the file’s standard indentation to keep formatting consistent.

Suggested change
private static List<string> AuthorizationHeaderNames = new List<string>() { "Authorization", "x-ms-policy-external-evaluations" };
private static List<string> AuthorizationHeaderNames = new List<string>() { "Authorization", "x-ms-policy-external-evaluations" };

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +58
public async Task SendAsync_GET_NeverAcquiresToken()
{
var innerHandler = new MockInnerHandler((req, ct) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));

Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

The current test suite only validates the pass-through paths (e.g., GET or write methods with null cmdlet) and does not cover the core feature: when the user requests acquisition, the handler should call acquirePolicyToken and attach the x-ms-policy-external-evaluations header. Consider adding a test for the positive path (likely requiring the handler to accept an injectable HttpMessageHandler/HttpClient factory so the token endpoint can be mocked).

Copilot uses AI. Check for mistakes.
public void PolicyTokenHeader_NameMatchesSpec()
{
// Header name must be exactly this — ARM checks for it
Assert.Equal("x-ms-policy-external-evaluations", "x-ms-policy-external-evaluations");
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

This test is tautological (it only compares a string literal to itself), so it won’t catch regressions. It would be more valuable to assert behavior that depends on production code (e.g., that the handler sets exactly this header name on outgoing requests when enabled).

Suggested change
Assert.Equal("x-ms-policy-external-evaluations", "x-ms-policy-external-evaluations");
var expectedHeaderName = "x-ms-policy-external-evaluations";
// Simulate HTTP header behavior (case-insensitive keys) and ensure the
// canonical header name is recognized even if the actual header casing differs.
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "X-Ms-Policy-External-Evaluations", "true" }
};
Assert.True(headers.ContainsKey(expectedHeaderName));

Copilot uses AI. Check for mistakes.
Comment on lines +552 to +553
var authHeaders = new List<string> { "Authorization", "x-ms-policy-external-evaluations" };
Assert.Contains("x-ms-policy-external-evaluations", authHeaders);
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

This test currently validates a hard-coded local list rather than the production GeneralUtilities.AuthorizationHeaderNames, so it won’t fail if the real sanitizer list is accidentally changed. Consider asserting against the production behavior (e.g., via reflection/internal access, or by exercising the logging/sanitization path) so the test truly protects against leaking the policy token header.

Suggested change
var authHeaders = new List<string> { "Authorization", "x-ms-policy-external-evaluations" };
Assert.Contains("x-ms-policy-external-evaluations", authHeaders);
Assert.Contains("x-ms-policy-external-evaluations", GeneralUtilities.AuthorizationHeaderNames);

Copilot uses AI. Check for mistakes.
@notyashhh
Copy link
Copy Markdown
Member Author

/azp run azure-powershell-common - ci

@notyashhh notyashhh self-assigned this Mar 24, 2026
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.

2 participants