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-accessrepository.
- Break-Glass Accounts: Standing emergency access accounts excluded from every policy.
- Entra ID Diagnostic Settings: Stream sign-in logs to Log Analytics for Conditional Access Insights.
- Exceptions Register (EX-006): Governance record permitting the CI pipeline to hold
Policy.ReadWrite.ConditionalAccess. - Microsoft Learn: What is Conditional Access?
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:
- 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.
- "Block" Always Wins:
If three matching policies evaluate to
Grant with MFAbut a fourth policy evaluates toBlock(for example, sign-in from an unapproved country or an unsupported OS), the user is blocked. There is no override mechanism. - 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.
- 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
- Identity Baseline (CA001, CA002):
- CA001 establishes tenant-wide MFA across all cloud apps.
- 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.
- Privileged Hardening (CA003, CA004, CA009, CA010):
- CA003 & CA004 protect all 14 privileged directory roles and the Azure Management plane (Portal, CLI, PowerShell, ARM SDK).
- 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.
- CA010 enforces a 12-hour maximum sign-in frequency and disables persistent browser cookies for administrators, neutralizing stolen session tokens.
- External & Lifecycle Boundaries (CA005, CA006):
- CA005 ensures external B2B guests cannot access tenant resources without MFA.
- CA006 requires step-up MFA before joining or registering a new device into Entra ID.
- Dynamic Threat & Risk Detection (CA007, CA008):
- Powered by Microsoft Entra ID Protection (P2 license).
- CA007 challenges sign-ins flagged with medium or high real-time risk (impossible travel, anonymous IP/Tor, password spray).
- 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.
- Device Trust & Perimeter Control (CA011 to CA016):
- 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.
- 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.
- 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¶
- Open the specific policy file (e.g.
ca004-mfa-azure-management.tf). - Make the required HCL change.
- Submit a Merge Request.
- Examine the GitLab CI plan output:
- Verify the plan shows
~ update in-placeon the targeted policy key (e.g.,module.ca["ca004"]). - Ensure
0 to destroy. A plan that attempts to destroy a policy indicates a key drift or breaking change. - Confirm
data.azuread_group.emergency_accessis strictly read, never modified. - 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"] }
}
} : {}
}
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)¶
- Authentication Strength ID Must Be a Resource Path, Not a GUID:
The
azureadprovider requiresauthentication_strength_policy_idto be formatted as/policies/authenticationStrengthPolicies/<guid>. Providing a bare GUID causes silent API rejection. - App-Specific Conditions Require
Application.Read.Allon the CI Identity: Targetingincluded_applications = ["All"]requires no directory permissions. However, targeting a specific enterprise app (such as the Azure Management GUID797f4846-ba00-4fd7-ba43-dac1f8f63013in CA004) requires the deployment service principal to hold GraphApplication.Read.All, even when deploying in report-only mode. - Named Location References Must Use Bare GUIDs:
The
azuread_named_locationresource exports an ID formatted as/identity/conditionalAccess/namedLocations/<guid>. Passing this full path into a policy'sincluded_locationstriggersError 1040: NamedLocation does not exist(which mimics replication lag). You must pass only the bare GUID usingbasename(...). - 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). - The break-glass group must exist before the first plan:
emergency-access-ref.tflooks the group up by display name. Ifgrp-identity-emergency-accessis missing or renamed,terraform planfails, even withdeploy_policies = false. - Map Keys Must Be Slugs (
ca001), Never Display Names: Thefor_eachinside_policies.tfindexes on map slugs (ca001,ca002). This ensures that tuning a policy's human-readabledisplay_nameresults in a clean, zero-downtime in-place update rather than an accidental destroy-and-recreate. - 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-accessexists and containsea-adm-breakglass-01andea-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_accessresolving and only+ createactions (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
- Soak & Log Analysis (Minimum 7–14 days):
- Keep
policy_state = "enabledForReportingButNotEnforced". - In Entra ID, navigate to Sign-in logs → select the Conditional Access tab → filter by Report-only: Success / Failure.
- 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. - Verify Emergency Access:
- Launch a clean private browsing session.
- Successfully sign in with both break-glass accounts (
ea-adm-breakglass-01andea-adm-breakglass-02) using their FIDO2 keys. - In the sign-in log entry for each, open the Report-only tab and confirm every policy shows Not applied (excluded).
- Keep one active Global Admin session open in an isolated window as a safety net.
- Disable Security Defaults:
- In the Entra admin center: Overview → Properties → Manage security defaults → set to Disabled.
- Select reason: "Moving to Conditional Access".
- Proceed immediately to step 4.
- Enforce via Code:
- In
locals.tf, setpolicy_state = "enabled". - Submit Merge Request and review the plan comment:
- Expect
~ update in-placefor every deployed policy (12 with the current flags). - Verify
0 to destroy.
- Expect
- Merge and execute the manual deployment job.
- Post-Cutover Validation:
- Normal user in standard browser: Challenged for MFA.
- Legacy auth client (e.g.
curl -u ...or basic SMTP): Blocked. - 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.)
- Safety-net browser session: Confirmed functional.
Lockout Recovery¶
If an erroneous policy deployment locks all administrators out of the tenant, execute this recovery procedure:
- Access Emergency Session:
- Open a fresh private browsing session.
- Sign in to
https://entra.microsoft.comusingea-adm-breakglass-01@grinntecgmail.onmicrosoft.comwith its FIDO2 key (credentials and key stored in the safe). If that fails, useea-adm-breakglass-02. - Expect a critical sign-in alert to fire. Tell whoever receives it that this is a real incident.
- Halt the Incident (Fastest option):
- 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).
- Option B (Pipeline Kill Switch): Edit
locals.tfto setpolicy_state = "enabledForReportingButNotEnforced"ordeploy_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. - Option C (Disaster Fallback): If policies cannot be fixed, set
deploy_policies = falseto purge all policies, then re-enable Security Defaults in the portal. - Reconcile and Fix Forward:
- Diagnose the root cause in code.
- Commit the fix, apply via GitLab CI, and ensure Terraform state is reconciled with any emergency manual portal edits.
- Security Post-Mortem:
- 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.
- Audit
AuditLogsandAzureActivityduring 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.