Entra ID Automation #4 Conditional Access: Managing Policy as Code

4 min read

If accounts and groups define “who belongs to what,” Conditional Access is the layer that decides “should this sign-in be allowed, here and now?” It is a P1 feature, and in practice it is where an organization’s zero-trust policy actually gets implemented. This part covers the concepts, and then how to manage policies as code instead of portal clicks.

The structure: a policy is an if-then statement #

One Conditional Access policy has two halves.

  • Assignments (if): who (users, groups), to what (apps), under which conditions (location, device state, platform, sign-in risk)
  • Controls (then): block, or allow with requirements (require MFA, require a compliant device, session restrictions)

Every sign-in evaluates all applicable policies, and all requirements of every applying policy must be satisfied. If policy A demands MFA and policy B demands a compliant device, both must hold. This all-AND property makes interactions more complex as policies multiply — which is why the testing procedure below matters.

A typical baseline set: require MFA for all users (with legacy authentication blocked), require phishing-resistant auth for admins, restrict downloads on unregistered devices. Note also that Microsoft now deploys Microsoft-managed policies into tenants in report-only state, enabling them after a window (at least 45 days) — knowing which managed policies have arrived in your tenant is part of the job.

Working with policies through the Graph API #

Policies live at /identity/conditionalAccess/policies. Query, create, update, and delete all work through the API.

Policy API request
GET https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies

POST https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies
Content-Type: application/json

{
  "displayName": "CA002: Require MFA for all users",
  "state": "enabledForReportingButNotEnforced",
  "conditions": {
    "users": {
      "includeUsers": ["All"],
      "excludeUsers": ["<break-glass account objectId>"]
    },
    "applications": { "includeApplications": ["All"] }
  },
  "grantControls": {
    "operator": "OR",
    "builtInControls": ["mfa"]
  }
}

The key detail is that state has three values: enabled, disabled, and enabledForReportingButNotEnforced (report-only). The safety mechanism of policy operations lives in that third state.

Policy-as-code: reviews and deployments instead of clicks #

If policies are API-manageable, they are code-manageable. The working setup:

  1. Policy definitions live as JSON files in a repository. One policy per file, with a naming convention (number + purpose) applied to both filename and displayName.
  2. Changes go through PRs. A change like “add a guest MFA exception” shows up as a diff and passes security review — an auditability portal clicks never had.
  3. CI deploys. On merge, the pipeline applies to the live tenant via the Graph API. The same flow can be built with Terraform’s Entra provider.
  4. Watch for drift. Periodically compare repository definitions against policies someone edited directly in the portal, and alert on differences. For code to be the truth, drift detection must come along.

The value is audit and rollback: “when and why did this policy change” lives in git history, and a bad change reverts with one commit.

Report-only: the one procedure that prevents policy incidents #

Conditional Access incidents have a wide blast radius — a mis-scoped condition can lock the whole company out. So new policies and major changes follow a fixed procedure.

  1. Deploy in report-only state. The policy is evaluated but not enforced, and the sign-in log records “what would have happened had this been on.”
  2. Verify the impact. In the sign-in log’s report-only tab and the insights workbook, confirm the intended audience gets the intended result and nobody is blocked unintentionally. The What If tool (and its evaluation API) simulates outcomes for specific users and scenarios.
  3. Observe over a period, then flip to enabled. Watching long enough to include days with different traffic patterns is the safe habit.

Combined with policy-as-code, you can enforce rules like “new-policy PRs merge only with state report-only; flipping to enabled is a separate PR” right in the pipeline.

Break-glass accounts: the exception to every policy #

A break-glass account is the last-resort admin account that can still get in when a Conditional Access accident locks everyone out. The principles are clear.

  • Create at least two cloud-only accounts with strong credentials (phishing-resistant where possible) and store them physically securely.
  • Put them in every policy’s excludeUsers. With policy-as-code, a CI test can verify the exclusion exists in every policy file — another payoff of managing this in code.
  • Alert immediately on any sign-in by these accounts. An account that should never be used makes its every sign-in an event.

One more mistake-proofing fact: Conditional Access policies are soft-deleted and restorable for 30 days. Even if an automation script deletes the wrong policy, there is a window to undo it.

Summary #

  • Conditional Access is a set of if-then policies evaluated at every sign-in; access requires satisfying all applying policies.
  • Policies are fully CRUD-able at /identity/conditionalAccess/policies, enabling policy-as-code: JSON + PR review + CI deploy + drift detection.
  • Every new policy ships in report-only, gets verified against the logs, then flips to enabled — and the pipeline can enforce that procedure.
  • Break-glass accounts are excluded from every policy, verified by CI, and alarmed on sign-in.
  • Deleted policies restore within 30 days. Next, the finale: eliminating standing admin privileges with PIM, and audit automation.
X