Skip to content

Supporting the "Invoke" / External Evaluation Policy Flow in the Azure SDKs #10081

Description

@weidongxu-microsoft

Background: what is "Invoke"?

Azure Policy is adding External Evaluation (colloquially "Invoke"): an enforcement policy that factors in the result of an external call when deciding to allow or deny an ARM operation. Today the only endpoint is an Azure Resource Graph (ARG) query; more are planned.

A normal policy can only inspect the request body. An External Evaluation policy can also query other state in the subscription via ARG, so it can enforce rules a plain policy cannot. Rule semantics: "Deny unless the request carries a token proving the external check ran and returned an acceptable result."

Typical use cases — what ARG actually checks

The ARG query runs at request time, scoped to the single subscription of the operation, and must return 0 or 1 rows projecting one claim the rule compares against. Patterns:

  • Reference / over-sharing checks — canonical example: deny attaching an NSG to a subnet if that NSG is already attached to a different subnet. ARG counts the other subnets referencing the NSG; the rule denies if count > 0.
  • Existence checks — deny (or require) based on whether a related resource exists, e.g. deny creating a resource unless a prerequisite resource is already present.
  • Reference-by-others checks — deny deleting/changing a resource that other resources still depend on.
  • Cross-resource invariants — e.g. deny a tag change on an NSG if it would violate an immutable-tag rule, decided by querying the resource's current state.

Not a fit: cross-subscription / cross-tenant / management-group checks (query is pinned to one subscription), or anything needing more than a single projected value.

How enforcement works (why ARM rejects the request)

  1. An External Evaluation policy is assigned to a scope. Its definition holds an ARG query + the claim it projects, plus missingTokenAction (typically deny).
  2. A user issues a normal ARM op (e.g. PUT a subnet referencing an NSG).
  3. The op carries no policy token, so ARM rejects it with 403 RequestDisallowedByPolicy — meaning "this op is guarded; prove the external check passes first." The 403 body carries machine-readable instructions.
  4. The client calls the subscription-level acquirePolicyToken API, which runs the ARG queries of the applicable policies and signs the results into one token.
  5. The client retries the original op with the token in a header.
  6. ARM validates the token and proceeds (or the policy denies if the result is rejected).

The flow is reactive (triggered by the 403) and needs client-side orchestration, not just API exposure.

The Ask to the SDKs

Stage 1 (exposing acquirePolicyToken in the SDKs) is done. The ask is Stage 2: the end-to-end orchestrationdetect the 403 → acquire token → retry — transparently, ideally hidden from the user, matching clients that already do this:

  • Azure Portal and ARM Template / Deployments — auto intercept, acquire, retry.
  • Azure CLI — global --acquire-policy-token flag since v2.85.0.
  • PowerShell — similar flag-based support.

CLI/PS add the behavior behind an optional switch initially, with a plan to remove the switch and make it default once the flow has been in use for a while.

Deliverables: a cross-language standardized design, plus samples and docs, aligned with each language owner / the architecture board.

REST contract (key wire details)

API versions

Operation API version
Policy definition / assignment CRUD 2025-03-01
acquirePolicyToken 2025-11-01

1. Initial 403403 RequestDisallowedByPolicy; actionable payload under error.additionalInfo[*].info.evaluationDetails.missingPolicyTokenDetails (shouldDeny, endpointKind, endpointDetails, endpointResultLifespan, isChangeReferenceRequired). Clients consume this directly to drive acquire + retry.

2. acquirePolicyToken request — subscription-level call describing the operation (URI, method, request body). The token is bound to the body via a contentHash.

3. acquirePolicyToken response

  • resultSucceeded (per-policy outcomes under results[*]) vs. Failed (acquisition itself failed — don't retry).
  • token — present when minted; always "PoP "-prefixed (pass verbatim).
  • results[*].policyAction — verdict: all Allow/Audit → token issued, retry; any Denyno token, don't retry; Error → see policyEvaluationDetails.reason.

4. Retry

  • Header x-ms-policy-external-evaluations (lowercase, dashes; a typo = no header).
  • Value = token verbatim, incl. PoP prefix.
  • Body must match the acquire request body byte-for-byte (contentHash binding).

Current gap in the SDKs

  • Stage 1 — API exposed.
  • Stage 2 — E2E orchestration not implemented or documented; no clear owner.

Key challenges:

  1. Cross-library dependencyacquirePolicyToken lives in the policy/authorization library, but guarded operations live across many resource libraries; the orchestration must work without coupling each service library.
  2. Byte-for-byte payload binding — the body sent to acquirePolicyToken (operation.content) and the retried body must match contentHash exactly. This is handled naturally by the pipeline policy, which already has the final serialized bytes (re-serializing the model would risk drift).

Current direction & considerations <-- This is one proposal

  • Where: an HTTP pipeline policy that sees the request body + response, performs acquire + retry, shared across libraries. (Agreed as the primary direction.)
  • Detection: only signal is the 403 RequestDisallowedByPolicy + missingPolicyTokenDetails; no proactive detection.
  • Errors: surface which policy blocked and what's required, not a raw 403; on Failed/Deny, don't retry — fail clearly.
  • Enablement: default-enabled in the SDK with an optional override (opt-in has no long-term value — it just lets policies break apps). CLI/PS start behind a switch and plan to make it default.

Open questions & current proposals

Each item lists the question and the current proposed direction (✅), or flags it as an open gap (❓) where no concrete answer exists yet.

  • Enablement model — auto-enable vs. opt-in/opt-out. ✅ Default-enabled in the SDK, with an optional override. An opt-in approach has no long-term value — not opting in effectively asks for policies to break your app. (CLI/PS add the behavior behind a switch initially and plan to remove the switch once the flow has been in use for a while; SDKs should aim for default-on.)
    One consideration for opt-in (instead of enable by default) is the Diagnosable guideline, and it is easier for customer to "add" a pipeline policy, than to "remove" one -- and this may depend on how many subscription enables this External Evaluation Policy.
  • Error surfacing — original 403 vs. enriched error. ❓ In the case that acquirePolicyToken itself fails (4xx), Keep the original 403 and surface its enriched policy details rather than replacing it with a new error type. In the case that acquirePolicyToken completes (200), but the Evalutation didn't pass, the 403 error need to wrap this information. We need more discussion on error/exception structure, as they are vital for customer to figure out that went wrong on 403 and no token, and the details of implementation can be language dependent.
  • Retry semantics — how much custom retry logic is needed. ✅ Drive acquire + retry from the pipeline policy and reuse standard ARM retry behavior; avoid a bespoke retry model. In ambiguous internal-failure cases it may be best to just return the first response. ❓ 200 + result=Failed + retryAfter and any multi-step retry chains beyond 403 → acquire → retry are not yet worked out.
  • Cross-library integrationacquirePolicyToken ships in one library while the guarded resource ops live in others. ✅ Solve it in a shared HTTP pipeline policy so the logic isn't coupled to individual service libraries. ❓ The concrete packaging (shared dependency vs. interface contract) is still open.
  • Payload access / rebinding — the token request needs the full JSON payload, and the retry must resend it byte-for-byte to match contentHash. ✅ Largely solved by the pipeline policy: it sits at the bottom of the pipeline and already holds the final serialized request body, so it can feed those exact bytes to acquirePolicyToken as operation.content and replay the identical bytes on retry — no re-serialization, no drift. (The user-facing models never need to expose the raw JSON.)
  • Endpoint / version construction — which endpoint and API-version the pipeline uses for acquirePolicyToken. ✅ Reuse the client's own endpoint (the same ARM host as the guarded PUT), so Public/Gov/other clouds are handled automatically. Use a default API-version with the ability for the user to override it.
  • RBAC — what the caller needs to acquire a token. ✅ Subscription-level read on the same subscription as the original request, plus permission to perform the guarded operation; no additional restriction. Surface permission failures clearly.
  • Cross-language alignment — architecture-board sign-off so behavior is consistent across languages. ❓ Pending.

Status

Item Status
Stage 1 — API exposure ✅ Done
Stage 2 — E2E SDK support ❌ Not started / no owner
Design 🟡 Active discussion, open questions
Cross-language alignment 🔄 Needed (architecture board)

Next steps

  1. Define a reference design for the pipeline policy (detect 403 → acquire → retry with token + original body).
  2. Settle payload rebinding and cross-library integration.
  3. Align with language architects / architecture board.
  4. Produce E2E scenarios, samples, and docs.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageWorkflow: This is a new issue that needs to be triaged to the appropriate team.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions