Skip to content

Conditional Access

Conditional Access is the Zero Trust policy engine for Microsoft Entra ID. It evaluates real-time signals—who is signing in, from where, on what device, and at what risk level—and enforces access controls on every sign-in in the tenant. Because one bad policy can lock every administrator and employee out at once, Grinntec manages its 16-policy set as reviewed code in the azure-conditional-access repository.


Why Conditional Access? (The Engineer's Mental Model)

To manage Conditional Access effectively, engineers must understand the architectural shift from perimeter security to identity-driven security, why basic authentication defaults fail at enterprise scale, and how Entra's evaluation engine actually works.

1. The Death of the Network Perimeter

Traditional enterprise IT relied on a castle-and-moat perimeter: firewalls, VPNs, and corporate office LANs. If a connection originated from inside the corporate network, it was trusted.

In a modern cloud-native organisation, this model is obsolete: - Workloads and data live in Azure and SaaS (Microsoft 365, GitLab, third-party APIs). - Employees, contractors, and automated pipelines connect from home, airports, client sites, and diverse cloud providers. - Passwords are regularly leaked, credential-stuffed, or captured via adversary-in-the-middle (AiTM) phishing proxies.

Under Zero Trust, the network perimeter is replaced by the Identity perimeter. The core tenets are: 1. Verify explicitly: Always authenticate and authorise based on all available data points (identity, location, device health, service context, and anomaly detection). 2. Use least privileged access: Limit access with Just-In-Time (JIT) and Just-Enough-Access (JEA), adaptive policies, and session constraints. 3. Assume breach: Minimize blast radius, segment access, verify end-to-end encryption, and use analytics to detect threats in real time.

Conditional Access is the enforcement mechanism of Zero Trust in Azure.

2. Why Not Just Use "Security Defaults"?

Microsoft Entra ID provides a feature called Security Defaults. It offers basic security out of the box (requiring MFA for all users, blocking legacy authentication, and protecting admin portals).

However, Security Defaults is a blunt, binary switch designed for small businesses. Enterprise platform engineering quickly outgrows it:

Capability Security Defaults Conditional Access (This Repo)
Exclusions ❌ None. Cannot exclude emergency break-glass accounts or automated service principals. ✅ Granular exclusions by user, role, group (grp-identity-emergency-access), or trusted IP.
Phishing-Resistant MFA ❌ Basic MFA only (Authenticator, SMS, Voice). Vulnerable to push fatigue and AiTM proxies. ✅ Enforces FIDO2 hardware keys and Windows Hello for Business for admins (CA009).
Device Trust (Intune) ❌ None. Any personal or compromised device can access corporate resources if MFA passes. ✅ Requires Intune compliance or Entra hybrid join (CA012, CA013).
Real-time Risk Step-Up ❌ Static triggers only. ✅ Integrated with Entra ID Protection P2 machine-learning risk engines (CA007, CA008).
Geofencing & Locations ❌ None. Sign-ins permitted globally. ✅ Enforces country blocklists and operating allowlists (CA014, CA015).
Session Lifetime Controls ❌ Static default session lifetimes. ✅ Limits admin sign-in frequency to 12h and blocks persistent browser cookies (CA010, CA011).
Infrastructure as Code ❌ Portal toggle only; cannot be versioned or automated via CI/CD. ✅ 100% managed in Terraform with automated plans and code review.

3. The Policy Engine: Signal → Decision → Enforcement

Conditional Access behaves as an If [Signal] Then [Control] engine evaluated at the token issuance layer:

flowchart LR
    subgraph Signals["1. Real-Time Signals"]
        A["Who<br/>(User / Admin Role / Guest)"]
        B["What<br/>(All Apps / Azure Mgmt / Actions)"]
        C["Where<br/>(Trusted IPs / Sanctioned Geo)"]
        D["Device<br/>(Windows / macOS / Intune Compliant)"]
        E["Risk<br/>(Sign-in Risk / User Risk via P2)"]
    end

    subgraph Engine["2. Entra CA Engine"]
        F["Parallel Policy Evaluation<br/>(Evaluated on EVERY sign-in)"]
    end

    subgraph Controls["3. Enforcement Decision"]
        G["BLOCK ACCESS<br/>(Trumps all grants)"]
        H["GRANT with Controls:<br/>- Require MFA<br/>- Phishing-resistant FIDO2<br/>- Compliant device<br/>- Password change"]
        I["SESSION Limits:<br/>- 12h sign-in frequency<br/>- No persistent browser"]
    end

    Signals --> Engine
    Engine --> G
    Engine --> H
    Engine --> I

4. Critical Evaluation Mechanics (Engine Quirks)

Engineers writing or modifying policies must know how Entra ID processes multiple rules:

  1. Parallel Evaluation (Not Sequential): Unlike firewall rules or routing tables, Conditional Access policies do not stop on first match. Every policy whose conditions match the sign-in is evaluated simultaneously.
  2. "Block" Always Wins: If three matching policies evaluate to Grant with MFA but a fourth policy evaluates to Block (for example, sign-in from an unapproved country or an unsupported OS), the user is blocked. There is no override mechanism.
  3. Union of Grant Controls: If multiple policies apply grant controls to a sign-in, the user must satisfy the requirements of all matching policies. If Policy A requires MFA and Policy B requires a compliant device, the user must satisfy both MFA and device compliance to receive an access token.
  4. Intersection of Session Controls: If multiple policies configure session controls (such as sign-in frequency), the most restrictive policy takes precedence (e.g. 12 hours wins over 24 hours).

Why Handwritten in Code vs. Microsoft-Managed Policies

Microsoft provides two built-in mechanisms to adopt Conditional Access: - Conditional Access Templates: Pre-configured policy blueprints (Secure Foundation, Zero Trust, Protect Administrators). When you deploy a template, you own the policy object. - Microsoft-Managed Policies: Policies created and maintained automatically by Microsoft inside your tenant.

We reject Microsoft-Managed policies and author our own definitions in Terraform HCL, mirroring Microsoft's official templates:

Aspect Microsoft-Managed Policies Grinntec Code Baseline (This Repo)
Source of Truth Microsoft backend, modified out-of-band Git repository (deployments/azure-access-management/azure-conditional-access)
Configuration Drift Permanent. Terraform cannot reconcile changes pushed by Microsoft. Zero drift. Any manual portal change is caught and corrected by terraform plan.
Enforcement Schedule Microsoft automatically flips policies from report-only to enforced on its own schedule. Controlled cutover. Policies remain in report-only until explicitly enabled via MR.
Break-Glass Safety Exclusions must be manually patched in the portal and risk reset during policy refreshes. Mandatory local.break_glass_exclusion baked into every HCL block.
Deletion / Teardown Cannot be deleted; only disabled. Leaves permanent clutter. Full lifecycle control. deploy_policies = false cleanly destroys all objects.
Scope & Breadth Limited to ~3 generic policies (all-user MFA, admin MFA, legacy auth). 16 modular policies spanning risk, device compliance, geofencing, and session limits.

The Principle: We adopt Microsoft's security baselines and Zero Trust templates one-for-one, but we implement them as declarative code. This ensures peer review, auditability, canary testing, and predictable rollbacks.

If Microsoft-managed policies appear in the tenant

Microsoft can create managed policies in a tenant once Conditional Access is in use. They are not in Terraform, so they don't get local.break_glass_exclusion. Check Conditional Access → Policies after the first deployment. For each Microsoft-managed policy, either set it to Off (our policies cover the same ground) or add grp-identity-emergency-access to its exclusions. Do this before cutover.


Grinntec Policy Architecture (16-Policy Set)

Grinntec separates concerns into 16 distinct policies rather than relying on monolithic configurations. This makes log auditing straightforward: when an access request is challenged or blocked, the sign-in log names the exact policy responsible. With the current feature flags, 12 are deployed (see the Default column below).

Display Naming Convention

Entra ID has no description or metadata field for Conditional Access policies. Therefore, every policy display name strictly follows this pattern:

$$\text{CANNN} \;\mid\; \langle\text{who}\rangle \;\mid\; \langle\text{what / where}\rangle \;\mid\; \langle\text{control}\rangle$$

Example: CA009 | Admin roles | All apps | Require phishing-resistant MFA

The 5 Policy Tiers

flowchart TD
    subgraph Tier1["Tier 1: Identity Baseline"]
        CA001["CA001: All Users MFA"]
        CA002["CA002: Block Legacy Auth"]
    end

    subgraph Tier2["Tier 2: Privileged Hardening (Tier 0)"]
        CA003["CA003: Admin Roles MFA"]
        CA004["CA004: Azure Management MFA"]
        CA009["CA009: Phishing-Resistant MFA (FIDO2)"]
        CA010["CA010: Admin 12h Session Frequency"]
    end

    subgraph Tier3["Tier 3: External & Lifecycle Boundaries"]
        CA005["CA005: Guest & External User MFA"]
        CA006["CA006: Device Registration MFA"]
    end

    subgraph Tier4["Tier 4: Dynamic Threat & Risk (P2)"]
        CA007["CA007: Medium/High Sign-in Risk MFA"]
        CA008["CA008: High User Risk Password Reset"]
    end

    subgraph Tier5["Tier 5: Device Trust & Geofencing"]
        CA011["CA011: Block Persistent Browser"]
        CA012["CA012: Compliant Device for Admins"]
        CA013["CA013: Compliant Device for All Users"]
        CA014["CA014: Block Sanctioned Countries"]
        CA015["CA015: Allow Operating Countries Only"]
        CA016["CA016: Block Unsupported Platforms"]
    end

    Tier1 --> Tier2 --> Tier3 --> Tier4 --> Tier5
  1. Identity Baseline (CA001, CA002):
  2. CA001 establishes tenant-wide MFA across all cloud apps.
  3. CA002 blocks legacy authentication protocols (IMAP, POP3, SMTP AUTH, older Office clients). Legacy protocols cannot prompt for MFA and account for over 90% of brute-force and password-spray attacks.
  4. Privileged Hardening (CA003, CA004, CA009, CA010):
  5. CA003 & CA004 protect all 14 privileged directory roles and the Azure Management plane (Portal, CLI, PowerShell, ARM SDK).
  6. CA009 elevates admin authentication from standard MFA (SMS/Authenticator push) to phishing-resistant MFA (FIDO2 security keys, Windows Hello for Business, Certificate-Based Authentication), defeating AiTM reverse-proxy attacks.
  7. CA010 enforces a 12-hour maximum sign-in frequency and disables persistent browser cookies for administrators, neutralizing stolen session tokens.
  8. External & Lifecycle Boundaries (CA005, CA006):
  9. CA005 ensures external B2B guests cannot access tenant resources without MFA.
  10. CA006 requires step-up MFA before joining or registering a new device into Entra ID.
  11. Dynamic Threat & Risk Detection (CA007, CA008):
  12. Powered by Microsoft Entra ID Protection (P2 license).
  13. CA007 challenges sign-ins flagged with medium or high real-time risk (impossible travel, anonymous IP/Tor, password spray).
  14. CA008 intercepts users whose credentials have been detected on the dark web or leaked publicly (high user risk), forcing an immediate self-service password reset combined with MFA.
  15. Device Trust & Perimeter Control (CA011 to CA016):
  16. CA012 & CA013 require devices to be marked compliant by Intune or hybrid-joined to Entra ID. These carry the highest lockout risk and remain disabled until Intune device management is fully deployed.
  17. CA014 vs CA015 (Blocklist vs Allowlist):
    • CA014 blocks sign-ins originating from sanctioned jurisdictions (RU, BY, IR, KP, SY, CU).
    • CA015 inverts the logic: it blocks all sign-ins originating from outside verified operating countries (GB, IE, US, DK), excluding trusted corporate networks (AllTrusted).
    • Strategy: Both policies run concurrently in report-only mode. Analyzing report-only logs allows the team to compare false-positive rates before committing to either a blocklist or an allowlist approach.
  18. CA016 blocks connections from unknown or spoofed device platforms.

Complete Policy Catalogue

Policy Display Name Control / Action Prerequisite Default Lockout / Breakage Risk
CA001 CA001 \| All users \| All apps \| Require MFA Require MFA — On High: Users without registered MFA methods cannot sign in.
CA002 CA002 \| All users \| Legacy auth clients \| Block Block access — On Medium: Older email clients, scripts using basic auth fail.
CA003 CA003 \| Admin roles \| All apps \| Require MFA Require MFA — On High: Defense-in-depth step-up for 14 privileged directory roles.
CA004 CA004 \| All users \| Azure management \| Require MFA Require MFA SP Application.Read.All On Medium: Re-prompts Azure Portal, Azure CLI, and ARM sessions.
CA005 CA005 \| Guests & external users \| All apps \| Require MFA Require MFA — On Medium: External B2B partners must satisfy MFA in this tenant.
CA006 CA006 \| All users \| Device registration \| Require MFA Require MFA — On Low: Device enrollment / Entra join requires verified MFA.
CA007 CA007 \| All users \| Sign-in risk medium/high \| Require MFA Require MFA Entra ID P2 On Low: Suspicious / anomalous sessions challenged dynamically.
CA008 CA008 \| All users \| User risk high \| Require MFA + password change Require MFA + Password Change Entra ID P2 + SSPR On Medium: Compromised accounts blocked until password reset succeeds.
CA009 CA009 \| Admin roles \| All apps \| Require phishing-resistant MFA Require Auth Strength (FIDO2/CBA) Hardware tokens / WHfB On Extreme: Admins without registered FIDO2 keys are blocked.
CA010 CA010 \| Admin roles \| Session \| 12h sign-in frequency, no persistent browser 12h session frequency, disable persistent cookies — On Low: Admins must re-authenticate daily; minor friction.
CA011 CA011 \| All users \| Browser session \| Block persistent browser Disable persistent browser cookies — Off Low: "Keep me signed in" disabled tenant-wide.
CA012 CA012 \| Admin roles \| All apps \| Require compliant or hybrid-joined device Require Compliant Device Microsoft Intune Off Extreme: Admins on unmanaged hardware instantly blocked.
CA013 CA013 \| All users \| All apps \| Require compliant or hybrid-joined device Require Compliant Device Microsoft Intune Off Extreme: Any non-compliant personal or corporate laptop blocked.
CA014 CA014 \| All users \| Sign-in from sanctioned countries \| Block Block access Sanctioned named location On Low: Blocks OFAC jurisdictions; IP geo-spoofing caveat.
CA015 CA015 \| All users \| Sign-in from non-operating countries \| Block Block access (Allowlist) Operating named location On High: Travelling employees blocked unless trusted IP or updated.
CA016 CA016 \| All users \| Unsupported device platforms \| Block Block access — Off Medium: False positives on Linux or custom user agents.

Managing Conditional Access in Code (Terraform Guide)

The entire policy set resides in deployments/azure-access-management/azure-conditional-access.

Repository Structure & Architecture

azure-conditional-access/
├── locals.tf                   # The 3 control switches, role GUIDs, country lists
├── versions.tf                 # azurerm backend + azuread provider configuration
├── emergency-access-ref.tf     # Data lookup for break-glass group (read-only)
├── named-locations.tf          # Named locations for CA014 & CA015
├── _policies.tf                # merge() map aggregator + module.ca for_each
├── ca001-mfa-all-users.tf      # Individual policy definitions (1 file per policy)
├── ca002-block-legacy-auth.tf
├── ...
├── ca016-block-unsupported-platforms.tf
└── outputs.tf                  # Policy count, slugs, and emergency group IDs
flowchart TD
    subgraph Repo["azure-conditional-access"]
        L["locals.tf<br/>(Master switches, flags, reference GUIDs)"]
        D["emergency-access-ref.tf<br/>(data.azuread_group.emergency_access)"]
        P1["ca001-mfa-all-users.tf<br/>local.policy_ca001"]
        P2["ca002-block-legacy-auth.tf<br/>local.policy_ca002"]
        PN["ca016-*.tf<br/>local.policy_ca016"]

        M["_policies.tf<br/>merge(policy_ca001, policy_ca002, ...)"]
        MOD["module.ca<br/>(terraform-azuread-conditional-access-policy)<br/>for_each = local.all_policies"]
    end

    subgraph EntraID["Microsoft Entra ID Tenant"]
        E["Conditional Access policies<br/>(12 of 16 with current flags)"]
    end

    L --> P1
    L --> P2
    L --> PN
    D --> P1
    D --> P2
    D --> PN
    P1 --> M
    P2 --> M
    PN --> M
    M --> MOD
    MOD --> E

The Three Control Switches (locals.tf)

All global orchestrations are controlled by three switches in locals.tf:

# 1. Master kill switch. false = drops all policies from the tenant.
deploy_policies = true

# 2. Enforcement state applied across all policies.
#    "enabledForReportingButNotEnforced" (Audit / Report-only mode)
#    "enabled"                          (Enforced - requires Security Defaults OFF)
#    "disabled"                         (Created but inactive)
policy_state = "enabledForReportingButNotEnforced"

# 3. Family feature flags: Gated on environmental prerequisites
enable_risk_policies               = true   # CA007, CA008 (Requires Entra ID P2)
enable_phishing_resistant          = true   # CA009        (Built-in auth strength)
enable_sanctioned_country_block    = true   # CA014        (Reviewed sanctioned list)
enable_operating_country_allowlist = true   # CA015        (Reviewed operating list)
enable_persistent_browser_block    = false  # CA011        (Friction opt-in)
enable_device_compliance           = false  # CA012, CA013 (Requires Intune enrollment)
enable_platform_block              = false  # CA016        (False-positive opt-in)

Safe Operational Workflows

Modifying an Existing Policy

  1. Open the specific policy file (e.g. ca004-mfa-azure-management.tf).
  2. Make the required HCL change.
  3. Submit a Merge Request.
  4. Examine the GitLab CI plan output:
  5. Verify the plan shows ~ update in-place on the targeted policy key (e.g., module.ca["ca004"]).
  6. Ensure 0 to destroy. A plan that attempts to destroy a policy indicates a key drift or breaking change.
  7. Confirm data.azuread_group.emergency_access is strictly read, never modified.
  8. Merge and trigger the manual pipeline deployment gate.

Adding a New Policy

To introduce CA017: 1. Create ca017-<description>.tf using the standard pattern:

locals {
  policy_ca017 = local.deploy_policies && local.enable_new_feature ? {
    ca017 = {
      display_name = "CA017 | All users | Specific Scenario | Control"
      conditions = {
        client_app_types = ["all"]
        applications     = { included_applications = ["All"] }
        users = {
          included_users  = ["All"]
          # MANDATORY: Every policy must exclude the break-glass group
          excluded_groups = local.break_glass_exclusion
        }
      }
      grant_controls = { operator = "OR", built_in_controls = ["mfa"] }
    }
  } : {}
}
2. Add local.policy_ca017 into the merge(...) list inside _policies.tf. 3. If the policy depends on an external service (e.g., Defender for Cloud Apps), declare a feature flag in locals.tf defaulting to false. 4. Deploy the policy in policy_state = "enabledForReportingButNotEnforced" to soak and analyze logs before considering enforcement.

Gotchas Paid for in Blood (Real-World IaC Traps)

  1. Authentication Strength ID Must Be a Resource Path, Not a GUID: The azuread provider requires authentication_strength_policy_id to be formatted as /policies/authenticationStrengthPolicies/<guid>. Providing a bare GUID causes silent API rejection.
  2. App-Specific Conditions Require Application.Read.All on the CI Identity: Targeting included_applications = ["All"] requires no directory permissions. However, targeting a specific enterprise app (such as the Azure Management GUID 797f4846-ba00-4fd7-ba43-dac1f8f63013 in CA004) requires the deployment service principal to hold Graph Application.Read.All, even when deploying in report-only mode.
  3. Named Location References Must Use Bare GUIDs: The azuread_named_location resource exports an ID formatted as /identity/conditionalAccess/namedLocations/<guid>. Passing this full path into a policy's included_locations triggers Error 1040: NamedLocation does not exist (which mimics replication lag). You must pass only the bare GUID using basename(...).
  4. Security Defaults API Mutual Exclusion: Entra ID's Graph API rejects any attempt to set policy_state = "enabled" while Security Defaults is on. Report-only policies can be created alongside Security Defaults, which is why the first deployment is safe. The reverse doesn't hold: Microsoft says existing Conditional Access policies prevent Security Defaults from being turned on, with no exception for report-only. To go back to Security Defaults, remove the policies first (deploy_policies = false).
  5. The break-glass group must exist before the first plan: emergency-access-ref.tf looks the group up by display name. If grp-identity-emergency-access is missing or renamed, terraform plan fails, even with deploy_policies = false.
  6. Map Keys Must Be Slugs (ca001), Never Display Names: The for_each inside _policies.tf indexes on map slugs (ca001, ca002). This ensures that tuning a policy's human-readable display_name results in a clean, zero-downtime in-place update rather than an accidental destroy-and-recreate.
  7. Session-Only Policies Require No Grant Controls: Policies configuring session controls (like CA010 and CA011) do not need grant_controls. Passing empty blocks or misconfigured grant controls will trigger API schema validation errors.

Safety Guardrails & Blast Radius Control

Because an enforced Conditional Access policy affects every authentication event in the tenant, an error can lock out every employee and administrator simultaneously with zero warning.

We enforce four layers of defense:

Guardrail Mechanism Operational Function
Break-Glass Exclusion local.break_glass_exclusion Excludes grp-identity-emergency-access from every policy. These accounts keep access even if every policy is wrong.
Audit-First Default local.policy_state Defaults to enabledForReportingButNotEnforced. Real traffic is analyzed in sign-in logs before flipping to live enforcement.
Master Kill Switch local.deploy_policies Emergency toggle. Setting to false removes every managed policy in a single pipeline run.
Prerequisite Feature Flags enable_* booleans Prevents high-risk policies (device compliance, geofencing) from deploying before Intune or country registers are verified.

The Break-Glass Architecture

The break-glass group contains two cloud-only emergency administrator accounts. Full detail is on Break-Glass Accounts.

Item Value
Accounts ea-adm-breakglass-01@grinntecgmail.onmicrosoft.com, ea-adm-breakglass-02@grinntecgmail.onmicrosoft.com
Group grp-identity-emergency-access: a role-assignable security group
Role Global Administrator, assigned to the group, permanently active (not PIM-eligible)
Sign-in FIDO2 security key (phishing-resistant). The password is a sealed fallback.
  • How it was created: The accounts, group, membership and role assignment were created once, by hand, with an Azure CLI / Microsoft Graph script run by a Global Administrator. They are not in Terraform. That keeps credentials out of state and keeps write access to the most powerful identities away from every pipeline. The trade-off is that there's no drift detection, so the quarterly break-glass test also checks the group membership and role assignment.
  • Read-only from this repo: The Conditional Access pipeline identity (sp-pla-tf-conditional-access-rw) only reads the group by name (emergency-access-ref.tf). It has no directory write or role assignment permissions, so a compromised CI job can't change who is in the group.
  • Mandatory policy rule: Every policy must set conditions.users.excluded_groups = local.break_glass_exclusion. Reviewers reject any merge request that leaves it out. There's no automated CI check for this yet.
  • Excluded from Conditional Access, not from MFA: Microsoft requires MFA for every sign-in to the Azure portal, the Entra admin center and Azure CLI/PowerShell, whatever the Conditional Access policies say. Break-glass sign-ins still need the FIDO2 key. That's why the keys are registered and tested, not optional.

First Deployment (Report-Only)

Report-only can't lock anyone out, but the first apply is the point where the break-glass exclusion first gets wired in. Check these before merging:

  • grp-identity-emergency-access exists and contains ea-adm-breakglass-01 and ea-adm-breakglass-02.
  • The group holds an active, permanent Global Administrator assignment (Entra admin center → Roles & admins → Global Administrator → Active assignments).
  • Each account has a FIDO2 key registered and has signed in successfully from a private browser.
  • Entra sign-in logs are flowing to Log Analytics (Entra ID Diagnostic Settings), so report-only results can be queried.
  • The merge request plan shows data.azuread_group.emergency_access resolving and only + create actions (12 policies + 2 named locations).

After the apply, in the Entra admin center under Conditional Access → Policies:

  • All 12 policies show Report-only.
  • Open any one policy → Users → Exclude and confirm it shows grp-identity-emergency-access.
  • Deal with any Microsoft-managed policies (see the tip under Why Handwritten in Code).

Cutover to Enforcement (Runbook)

Do not deviate from the sequence below. Between steps 3 and 4, the tenant briefly operates without an MFA baseline.

sequenceDiagram
    autonumber
    actor Admin as Platform Engineer
    participant Entra as Entra ID Portal
    participant Git as GitLab Repo
    participant Pipe as CI/CD Pipeline

    Admin->>Entra: 1. Soak for 7+ days and review Report-only logs
    Admin->>Entra: 2. Open private browser and verify break-glass accounts
    Admin->>Entra: 3. Disable Security Defaults in Tenant Properties
    Admin->>Git: 4. Set policy_state to enabled in locals.tf and open MR
    Git->>Pipe: 5. Run plan to verify in-place updates and zero destroys
    Admin->>Pipe: 6. Merge and trigger manual deployment gate
    Pipe->>Entra: 7. Apply enabled state across all policies
    Admin->>Entra: 8. Validate MFA challenge and break-glass bypass
  1. Soak & Log Analysis (Minimum 7–14 days):
  2. Keep policy_state = "enabledForReportingButNotEnforced".
  3. In Entra ID, navigate to Sign-in logs → select the Conditional Access tab → filter by Report-only: Success / Failure.
  4. Investigate any event marked Report-only: Failure. Identify whether service accounts, developer tooling, or automated pipelines would have been blocked. Tune exclusions in code until report-only runs clean.
  5. Verify Emergency Access:
  6. Launch a clean private browsing session.
  7. Successfully sign in with both break-glass accounts (ea-adm-breakglass-01 and ea-adm-breakglass-02) using their FIDO2 keys.
  8. In the sign-in log entry for each, open the Report-only tab and confirm every policy shows Not applied (excluded).
  9. Keep one active Global Admin session open in an isolated window as a safety net.
  10. Disable Security Defaults:
  11. In the Entra admin center: Overview → Properties → Manage security defaults → set to Disabled.
  12. Select reason: "Moving to Conditional Access".
  13. Proceed immediately to step 4.
  14. Enforce via Code:
  15. In locals.tf, set policy_state = "enabled".
  16. Submit Merge Request and review the plan comment:
    • Expect ~ update in-place for every deployed policy (12 with the current flags).
    • Verify 0 to destroy.
  17. Merge and execute the manual deployment job.
  18. Post-Cutover Validation:
  19. Normal user in standard browser: Challenged for MFA.
  20. Legacy auth client (e.g. curl -u ... or basic SMTP): Blocked.
  21. Break-glass account in clean browser: signs in with its FIDO2 key, and the sign-in log shows every Conditional Access policy as Not applied. (It is still asked for MFA by Microsoft's mandatory admin-portal MFA. That's expected.)
  22. Safety-net browser session: Confirmed functional.

Lockout Recovery

If an erroneous policy deployment locks all administrators out of the tenant, execute this recovery procedure:

  1. Access Emergency Session:
  2. Open a fresh private browsing session.
  3. Sign in to https://entra.microsoft.com using ea-adm-breakglass-01@grinntecgmail.onmicrosoft.com with its FIDO2 key (credentials and key stored in the safe). If that fails, use ea-adm-breakglass-02.
  4. Expect a critical sign-in alert to fire. Tell whoever receives it that this is a real incident.
  5. Halt the Incident (Fastest option):
  6. Option A (Fastest - Portal Override): Navigate to Conditional Access → Policies → locate the misconfigured policy → flip to Report-only or Off. (Terraform will detect drift on the next run; reconcile after recovery).
  7. Option B (Pipeline Kill Switch): Edit locals.tf to set policy_state = "enabledForReportingButNotEnforced" or deploy_policies = false, merge, and apply. The pipeline signs in as a service principal, which these user-targeted policies don't apply to, so it keeps working while humans are locked out. You still need to be able to merge in GitLab.
  8. Option C (Disaster Fallback): If policies cannot be fixed, set deploy_policies = false to purge all policies, then re-enable Security Defaults in the portal.
  9. Reconcile and Fix Forward:
  10. Diagnose the root cause in code.
  11. Commit the fix, apply via GitLab CI, and ensure Terraform state is reconciled with any emergency manual portal edits.
  12. Security Post-Mortem:
  13. Follow Break-Glass Accounts → After use: reset the password of every break-glass account used, check its FIDO2 key, and return both to the safe. Microsoft's guidance is to renew credentials after any use.
  14. Audit AuditLogs and AzureActivity during the break-glass session window.

Pipeline Identity & Governance

Automated CI/CD deployments are executed by the dedicated service principal sp-pla-tf-conditional-access-rw.

Least Privilege Permission Boundary

To ensure the pipeline cannot escalate privilege, it is granted application permissions scoped strictly to Conditional Access management and identity reading:

Graph Application Permission Type Justification
Policy.ReadWrite.ConditionalAccess Write Create, modify, and delete Conditional Access policies and named locations.
Policy.Read.All Read Read existing policy definitions and authorization configurations.
Group.Read.All Read Resolve the break-glass group grp-identity-emergency-access object ID.
User.Read.All Read Validate user-targeted conditions and exclusions.
Application.Read.All Read Resolve targeted application IDs (required for CA004 Azure Management targeting).

Why This Pipeline Cannot Escalate Privilege

The service principal holds no directory write permissions (Directory.ReadWrite.All), no group modification permissions (Group.ReadWrite.All), and no role assignment permissions (RoleManagement.ReadWrite.Directory). It cannot add itself or any other identity to Global Administrator or modify group memberships.

Governance Tier & Exceptions Register (EX-006)

While sp-pla-tf-conditional-access-rw cannot escalate directory privileges, an identity capable of modifying Conditional Access policies has the power to weaken or disable sign-in security across the entire tenant.

Because this control-plane authority is exercised via automated CI/CD from GitLab, it is formally registered as an accepted risk: - Exception ID: EX-006 in the Exceptions Register - Compensating Controls: - Mandatory peer review on all merge requests touching deployments/azure-access-management/azure-conditional-access. - Manual deployment gate required before Terraform apply. - Break-glass exclusion enforced in code. - OIDC workload identity federation pinning GitLab CI job tokens to the repository path.