A practical implementation guide covering identity baseline, privileged access, conditional access, and monitoring – fully managed as infrastructure as code.
Entra ID is the identity backbone of most Microsoft 365 and Azure environments, yet it’s one of the last places organisations apply Infrastructure as Code discipline. Firewall rules get reviewed in PRs. Storage Account settings are version-controlled. Entra ID? Someone clicks through the Portal, maybe writes it down, and six months later nobody is sure what the current state actually is.
This post documents a Terraform configuration I built to change that. It covers the full stack: identity defaults, privileged role protection, conditional access policies, a hardened break-glass account, and a monitoring setup that alerts within five minutes of that account being used.
If you want the reference material the configuration is based on, Sean Metcalf’s “Improve Entra ID Security More Quickly” blog post is a well-structured starting point.
Contents
Why Not Bicep?
→ versions.tf
The Microsoft Graph Bicep Extension went GA in July 2025. After investigation, the current v1.0 release covers seven resource types – applications, service principals, groups, federated identity credentials, app role assignments, OAuth2 permission grants, and users (read-only). That’s useful for application lifecycle management, not security hardening.
Authorization Policy, Conditional Access Policies, PIM assignments, and Authentication Strength Policies are all missing. The Terraform azuread provider covers all of these, so that became the tool. Three providers in total are needed:
azuread = "~> 3.8.0" # 3.8.0+ required for device code flow CA condition
azurerm = "~> 4.0" # for Log Analytics Workspace + alerting
null = "~> 3.2.0" # for the Authorization Policy workaround
Authentication – Service Principal Required
→ providers.tf
The configuration must run as a Service Principal with application permissions. Several resources – Authentication Strength Policies, Conditional Access Policies, and the Authorization Policy endpoint – require scopes that Microsoft does not pre-authorize for the Azure CLI’s own first-party app ID. Running az login and deploying will get you 403s on roughly half the security-relevant resources.
The SP needs these Microsoft Graph application permissions:
| Permission | Required for |
|---|---|
Policy.ReadWrite.Authorization | Authorization Policy endpoint |
Policy.ReadWrite.ConditionalAccess | CA policies + Auth Strength |
Policy.Read.All | Reading existing policies |
RoleManagement.ReadWrite.Directory | PIM assignments |
Directory.ReadWrite.All | Groups + authorization policy |
Group.ReadWrite.All | Role Assignable Groups |
Application.ReadWrite.All | App registrations |
AppRoleAssignment.ReadWrite.All | App role grants |
Plus two Azure RBAC assignments: Contributor on the subscription for the monitoring resources, and Contributor at /providers/Microsoft.aadiam for the Diagnostic Setting. The second one is separate from subscription scope and requires User Access Administrator at root to assign:
az role assignment create \
--assignee-principal-type ServicePrincipal \
--assignee-object-id "<sp-object-id>" \
--scope "/providers/Microsoft.aadiam" \
--role "Contributor"
Section 1: Authorization Policy
→ authorization_policy.tf
The most impactful tenant-wide settings live at a single Graph API endpoint: PATCH /v1.0/policies/authorizationPolicy. This controls whether users can register applications, create tenants, invite guests, and consent to app permissions – six distinct security settings in one call.
There is no azuread_authorization_policy resource. The workaround is a null_resource with a local-exec provisioner that calls the Graph API directly. Two things I learned the hard way making this work:
az rest uses the wrong token scope. After SP login, az rest fetches a token for management.azure.com by default, not graph.microsoft.com. The fix is to request a Graph-scoped token explicitly and use curl:
TOKEN=$(az account get-access-token \
--resource https://graph.microsoft.com \
--query accessToken \
--output tsv) && \
curl -sf -X PATCH \
"https://graph.microsoft.com/v1.0/policies/authorizationPolicy" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '${jsonencode(local.authorization_policy_body)}'
--allow-no-subscriptions is also needed on az login because the SP has no Azure subscription access – only Entra ID permissions.
The policy body is defined as a local so the hash-based trigger and the API call always stay in sync:
triggers = {
policy_hash = sha256(jsonencode(local.authorization_policy_body))
}
This means the local-exec re-runs automatically whenever any policy value changes – no manual version bumping needed.
Section 2: Role Assignable Groups
→ role_assignable_groups.tf
Role Assignable Groups (RAGs) close a specific lateral movement path: when a normal group is assigned to an Entra ID role, any Group Owner can modify its membership and effectively assume the role. The Microsoft docs on what RAGs actually change are worth reading carefully:
By default, Privileged Role Administrators can manage the membership of a role-assignable group, but you can delegate the management of role-assignable groups by adding group owners.
RAG owners can manage membership. The security benefit is that you control explicitly who those owners are. For a regular group, any user set as owner can change membership. For a RAG, you make a deliberate choice.
The configuration creates five RAGs for the Tier 0 roles: Global Administrator, Privileged Role Administrator, Privileged Authentication Administrator, Hybrid Identity Administrator, and Partner Tier2 Support. The deploying Service Principal is set as sole owner for all five:
resource "azuread_group" "rag_global_admin" {
display_name = "RAG-Tier0-GlobalAdministrators"
security_enabled = true
mail_enabled = false
assignable_to_role = true # immutable after creation
members = var.tier0_admin_object_ids
owners = [data.azuread_client_config.current.object_id]
}
Ownership is fully Terraform-managed and version-controlled. Any change has to go through the IaC process. The SP never acts interactively, which is exactly what you want from an owner of a Tier 0 group.
One provider limitation: owners = [] causes a validation error – at least one owner is required. This forces an explicit ownership decision, which in retrospect is the right forcing function.
Section 3: PIM Eligible Assignments
→ pim_assignments.tf
azuread_directory_role_eligibility_schedule_request works cleanly with for_each over the list of admin Object IDs:
resource "azuread_directory_role_eligibility_schedule_request" "global_admin" {
for_each = toset(var.tier0_admin_object_ids)
role_definition_id = local.role_global_administrator
principal_id = each.value
directory_scope_id = "/"
justification = "Tier 0 PIM eligible assignment – managed via Terraform IaC"
}
Requires Entra ID P2 licensing.
Section 4: Application Permission Control
→ applications.tf
Certain Microsoft Graph application permissions grant near-unlimited control over Entra ID. Any application holding them is effectively Tier 0:
Directory.ReadWrite.All– equivalent to Global AdministratorAppRoleAssignment.ReadWrite.All– can grant itself or others any permissionRoleManagement.ReadWrite.Directory– can assign any directory roleApplication.ReadWrite.All– can impersonate any application
Terraform can enforce the correct pattern for new app registrations. Auditing existing grants across a tenant requires PowerShell:
$tier0 = @(
"19dbc75e-c2e2-444c-a770-ec69d8559fc7",
"06b708a9-e830-4db3-a914-8e69da51d44f",
"9e3f62cf-ca93-4e58-908e-1e76e63a0b39",
"1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9"
)
Get-MgServicePrincipal -All | ForEach-Object {
$sp = $_
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id |
Where-Object { $_.AppRoleId -in $tier0 } |
Select-Object @{N="App";E={$sp.DisplayName}}, AppRoleId
}
Section 5: Authentication Strength Policies
→ authentication_strength.tf
Authentication Strength policies define which authentication methods satisfy an MFA requirement. Two policies are deployed.
For Tier 0 admins, only phishing-resistant methods are accepted:
allowed_combinations = [
"fido2",
"windowsHelloForBusiness",
]
Microsoft Authenticator push is intentionally excluded. It’s vulnerable to MFA fatigue attacks and real-time phishing proxies – exactly the threat vectors targeting privileged accounts.
For all other users, a broader standard MFA policy allows Authenticator push and FIDO2. SMS and voice are excluded from both policies.
Section 6: Conditional Access Policies
→ conditional_access_policies.tf
Six policies are deployed, all controlled by a single variable:
variable "ca_policy_state" {
default = "enabledForReportingButNotEnforced"
}
Deploy in report-only mode, review Sign-in logs for two to four weeks, then switch to "enabled". The state applies to all six policies at once.
All policies automatically exclude a combined list via a single local:
locals {
all_ca_excluded_ids = distinct(concat(
[azuread_user.emergency_access.object_id],
var.ca_excluded_user_ids,
))
}
Add your own Object ID to ca_excluded_user_ids during initial testing and remove it before enforcing.
The six policies:
| Policy | Target | Control |
|---|---|---|
| CA-001 | Tier 0 role holders | Phishing-resistant MFA (FIDO2/WHfB only) |
| CA-002 | All 16 privileged roles | Standard MFA |
| CA-003 | All users | Block legacy auth (SMTP, POP3, IMAP) |
| CA-004 | All users | Block device code flow |
| CA-005 | Guest users | Standard MFA |
| CA-006 | All users | Standard MFA on device Entra join |
Two provider issues worth documenting:
Hybrid Identity Administrator cannot appear in included_roles. The Graph API returns a 400 with “non-built-in role ids” despite it appearing in Microsoft’s own privileged role list. It’s excluded with a comment explaining why.
The device code flow condition (authentication_flow_transfer_methods) requires provider >= 3.8.0. Earlier versions reject it regardless of syntax – hence the specific version pin.
Section 7: Emergency Access Account
→ emergency_access.tf
Three things matter here beyond just creating the user:
Use the .onmicrosoft.com domain. If your primary domain is federated and federation breaks, only accounts on the .onmicrosoft.com domain can still authenticate directly.
Permanent active Global Administrator – not PIM eligible. The break-glass account exists for scenarios where PIM itself is unavailable. A PIM eligible assignment is useless in exactly the emergency you built this account for:
resource "azuread_directory_role_assignment" "emergency_access_ga" {
role_id = local.role_global_administrator
principal_object_id = azuread_user.emergency_access.object_id
}
This is the only account in the configuration with a permanent active role assignment.
Register a FIDO2 key after deployment. This is the one step that cannot be automated. Entra Portal → Users → Emergency Access – Break Glass → Authentication methods → Add → Passkey (FIDO2). Store the key physically secure and separate from the password.
Section 8: Monitoring and Alerting
→ diagnostic_settings.tf
The configuration deploys a complete monitoring stack: a Resource Group, Log Analytics Workspace with 90-day retention, a Diagnostic Setting streaming all Entra ID log categories, an Action Group for email delivery, and a Scheduled Query Rule that fires within five minutes of any successful break-glass sign-in.
SigninLogs
| where UserPrincipalName =~ "emergency@yourdomain.onmicrosoft.com"
| where ResultType == 0
| project TimeGenerated, UserPrincipalName, IPAddress, Location, AppDisplayName, DeviceDetail
If this alert fires under normal operations, something is wrong. The projected fields are your starting point for investigation.
One prerequisite that’s easy to miss: azurerm_monitor_aad_diagnostic_setting requires Contributor at /providers/Microsoft.aadiam – separate from subscription Contributor. See the Authentication section above.
After deployment, expect 15–30 minutes before logs appear in the workspace. The Entra Portal shows logs from a different internal data path, which is why they appear there immediately.
Deployment Order
The CA policies reference the emergency access account Object ID as an exclusion. That account must exist before the CA policies can be created.
# 1. Init
terraform init
# 2. Emergency access account first
terraform apply \
-target=azuread_user.emergency_access \
-target=azuread_directory_role_assignment.emergency_access_ga
# 3. Configure tfvars with the output Object ID, then full deploy
terraform apply
# 4. After validating CA policies in report-only mode:
# update ca_policy_state = "enabled" and remove your test exclusion
terraform apply
Full deployment guide including SP setup and RBAC assignments is in the repository README.
Full Code
→ github.com/simon-vedder/terraform-azure/initial-entra-securesetup
PRs are welcome. The azuread provider is moving quickly and some workarounds documented here – particularly the Authorization Policy null_resource – may become unnecessary as new resource types are added.