The problem: identity built by hand
- An Azure tenant spanning production and non-production subscriptions had dozens of applications, integrations and platform tools, each needing service principals, security groups and role assignments.
- Done by hand, that produces:
- credentials created in the portal, with no record of why
- role grants nobody can explain six months later
- no consistent permission model across subscriptions
- standing admin rights: people hold powerful roles all day, every day
- secrets in pipeline variables: the most common way cloud credentials leak
The approach
- Everything identity-related is code: reviewed, versioned, reproducible.
- Admin rights exist only while in use: just-in-time activation instead of standing assignment.
- Pipelines prove who they are without a secret: federation instead of client secrets.
CI run → signed OIDC token (subject = org / workspace / run phase)
→ Entra ID federated credential (trusts that exact issuer + subject)
→ short-lived Azure access token → deploy
No client secret exists anywhere in the chain.
What I built
1. The tenant's identity surface, entirely in Terraform
- A management group hierarchy (a root with production and non-production groups) that sets the scope boundary for every role.
- Custom RBAC roles at management-group scope for distinct functions (security administration, SOC, IAM administration, log reading, networking, database, billing), replacing broad built-in roles like Contributor.
- Security groups with subscription-level role assignments.
- 30+ application registrations and service principals, one per integration, each with exactly the Microsoft Graph permissions it needs, typed correctly as Application (no signed-in user) or Delegated (acts as the signed-in user).
- A central mapping that gives each new subscription a default role set, with per-subscription overrides, so onboarding a subscription is a reviewed config change.
- Decommissioned groups tracked explicitly, so Terraform never recreates something that was deliberately removed.
Illustrative: a custom read-only role scoped to a management group.
resource "azurerm_role_definition" "soc_reader" {
name = "soc-reader"
scope = azurerm_management_group.root.id
description = "Read-only visibility for the security operations team"
permissions {
actions = ["*/read"]
not_actions = []
}
assignable_scopes = [azurerm_management_group.root.id]
}
2. Just-in-time privileged access with Entra ID PIM
Standing administrative role assignments are replaced with eligible assignments. Admins activate a role when they need it, for a limited time, and access is reviewed on a schedule.
Typical activation controls in PIM are a maximum activation window, MFA at activation, a written justification and, for the most sensitive roles, approval by a second person. Access reviews then confirm that each eligible assignment is still needed.
3. No stored credentials in pipelines
Every Azure Terraform workspace authenticates with OIDC workload identity federation. I also built Azure DevOps service connections that support workload identity federation.
Illustrative: the Entra side of the trust, and the workspace settings that use it.
# Trust tokens from HCP Terraform, but only for one workspace's apply phase.
resource "azuread_application_federated_identity_credential" "apply" {
application_id = azuread_application.terraform.id
display_name = "tfc-network-apply"
audiences = ["api://AzureADTokenExchange"]
issuer = "https://app.terraform.io"
subject = "organization:example-org:project:infra:workspace:azure-network:run_phase:apply"
}
# Workspace environment variables. No client secret anywhere.
TFC_AZURE_PROVIDER_AUTH = true
TFC_AZURE_RUN_CLIENT_ID = <application (client) ID>
ARM_TENANT_ID = <tenant ID>
ARM_SUBSCRIPTION_ID = <subscription ID>
How OIDC workload identity federation works
- For each run, the CI platform issues a short-lived signed token (a JWT). Its subject claim identifies the organization, project, workspace and run phase (plan or apply).
- An Entra ID app registration holds a federated credential that trusts that issuer and that exact subject.
- Entra ID exchanges the token for an Azure access token. There's no client secret to leak, store or rotate.
- Because the run phase is in the subject, plan and apply can get different identities:
read-only for plan, write for apply (
TFC_AZURE_PLAN_CLIENT_ID/TFC_AZURE_APPLY_CLIENT_ID).
Things to watch
- The subject is the whole security boundary. A federated credential trusts whatever matches its subject. Keep subjects exact, down to the workspace and run phase, or other workspaces can borrow the identity.
- Application vs Delegated Graph permissions are not interchangeable. Application permissions act tenant-wide without a user, and are the ones that need the closest scrutiny and admin consent.
- Custom roles drift behind the platform. When Azure adds new actions, wildcard-based roles may grant more than intended, and explicit lists may grant less. Review them.
- Decommissioning is part of the lifecycle. Without an explicit record of removed groups, a stale branch or an old module can quietly recreate access that was deliberately removed.
What it solves
- Every service principal, group, role definition and role assignment is version-controlled and peer-reviewed. No portal-created credentials, no unexplained grants.
- Admin rights exist only while they're being used.
- The most common cloud credential leak, long-lived secrets in CI variables, is removed for Azure infrastructure pipelines.
- New subscriptions and integrations follow one repeatable pattern.
What it doesn't solve
- Service principals that still use secrets. Integrations that can't federate still need secret or certificate rotation and expiry alerting. Where a service connection still uses a client secret, the secret is managed outside Terraform: the configuration ignores changes to that credential, so the secret never lands in Terraform state and rotating it doesn't register as drift.
- Over-permissioned Graph grants. Code makes grants visible, not minimal. They still need review.
- Compromised pipelines. Federation removes the stored secret, but a compromised pipeline definition can still use the identity it's given. Protect who can edit pipelines and which branches can run apply.
Checklist
- [ ] Management groups, custom roles, groups and assignments all in code
- [ ] One service principal per integration, with least-privilege Graph permissions
- [ ] Application vs Delegated permissions reviewed for each service principal
- [ ] Standing admin assignments replaced with PIM eligible assignments
- [ ] Pipelines use OIDC federation; no client secrets in CI variables
- [ ] Federated credential subjects scoped to an exact workspace and run phase
- [ ] Decommissioned identities tracked so they can't be recreated
- [ ] Rotation and expiry alerting for any remaining secrets
Further reading
- Microsoft Entra workload identity federation: https://learn.microsoft.com/entra/workload-id/workload-identity-federation
- Privileged Identity Management overview: https://learn.microsoft.com/entra/id-governance/privileged-identity-management/pim-configure
- Azure custom roles: https://learn.microsoft.com/azure/role-based-access-control/custom-roles
- HCP Terraform dynamic credentials with Azure: https://developer.hashicorp.com/terraform/cloud-docs/workspaces/dynamic-provider-credentials/azure-configuration