Automating Operator Access with Entra ID and Terraform Stacks
Managing operator access for cloud workloads can be challenging — especially when the goal is to enforce consistent identity, ownership, and access control across services in a scalable and secure way.
In this article, I’ll walk through a technique for managing workload identity using Entra ID automation and Terraform. The focus is on encapsulating access control into an isolated Terraform Stacks component that defines service team groups, assigns members and owners, and outputs group IDs for downstream use in role assignments, Azure Monitor alerting, and other use cases.
This technique enables service teams to decouple access configuration from core workload deployments while maintaining traceable, code-defined access control that neatly fuses with their operational architecture.
Why Manage Operator Access via Terraform?
Modern cloud services typically involve multiple personas with varying access needs — read-only support, on-call engineers, senior developers, and more. Managing these roles manually through the Entra ID portal is error-prone and doesn’t scale.
With Terraform and the AzureAD provider, we can define:
- Group creation: Each workload gets its own structured set of Entra ID groups.
- Ownership: A consistent set of service owners is assigned across groups.
- Membership: Access levels (L1, L2, L3) are clearly defined and composable.
- Outputs: Group object IDs are exposed for use in other Terraform modules — e.g., role assignments, alerts, policies.
This approach allows each workload to have its own identity boundary, managed entirely as code.
Permissions Challenge: Entra ID Limitations
Before we dive into the implementation, it’s important to understand a major challenge in automating group management: Entra ID’s authorization model.
While it’s possible to grant an application the Group.Create permission, this only allows group creation—not full lifecycle management. To add members or change group properties, you must also grant:
- Group.ReadWrite.All
- GroupMember.ReadWrite.All

To lookup users, you must also grant:

These broad permissions are often seen as overly permissive by administrators, and for good reason — they allow changes to any group in the tenant. What’s missing is scoped permissions like:
- Group.ReadWrite.Mine
- GroupMember.ReadWrite.Mine
Such permissions would allow an app to manage only the groups it creates or owns — ideal for secure Infrastructure-as-Code workflows. Without them, we’re forced to over-provision access, which runs counter to the principle of least privilege and a complete non-starter with the “powers that be” at an organization who are managing the identity platform!
Dealing with Permission Errors
If your application identity does not have the required Entra ID permissions, Terraform will fail with errors like:

Notice how the data sources for looking up the users are still spinning.

You will run into many errors that look like this:
Error: unexpected status 403 (403 Forbidden) with error: Authorization_RequestDenied: Insufficient privileges to complete the operation.
on src/terraform/identity/groups.tf line 2 , in ‘data “azuread_users” “group_owners”’ :

You may also notice that data sources (e.g., user lookups) spin indefinitely or fail silently. These are signs that your identity lacks User.Read.All, Group.ReadWrite.All, or GroupMember.ReadWrite.All—permissions that must be granted explicitly in the App Registration.
While not ideal, granting these permissions at the tenant level may be necessary until more granular options are supported by Microsoft.
The Terraform Stacks Component
To work around this, I’ve built a standalone Terraform module — used as a Stack component — that encapsulates group creation, ownership, and membership for a workload.
The component:
- Defines a consistent set of group owners (typically service leads or system identities).
- Creates structured L1, L2, and L3 groups for tiered access.
- Assigns users to each group based on their level.
- Outputs group object IDs for use in downstream role assignments or alerts.
Defining Group Owners
To maintain consistency, all groups for a given workload share the same ownership. This simplifies management and reflects the reality that there’s usually one team responsible for a service.
data "azuread_users" "group_owners" {
user_principal_names = ["bill@revoptix.com"]
}
locals {
owners = distinct(concat(
[data.azuread_client_config.current.object_id],
[for u in data.azuread_users.group_owners.users : u.object_id]
))
}
The current client identity (often a service principal) is also included as an owner, ensuring that Terraform can manage the groups after creation.
Creating Groups and Assigning Members
Each access tier — L1, L2, L3 — has its own Entra ID group. These levels correspond to roles like:
- L1: Read-only users (e.g., helpdesk or monitoring-only staff)
- L2: On-call engineers or service developers
- L3: Senior or principal engineers with elevated permissions
Here’s how the Level 1 group is defined:
data "azuread_users" "level1" {
user_principal_names = ["bill@revoptix.com", "bob@revoptix.com"]
}
locals {
level1_member_ids = toset([for u in data.azuread_users.level1.users : u.object_id])
}
resource "azuread_group" "level1" {
display_name = "${var.application_name}-${var.environment_name}-L1"
security_enabled = true
owners = local.owners
}
resource "azuread_group_member" "level1" {
for_each = local.level1_member_ids
group_object_id = azuread_group.level1.object_id
member_object_id = each.key
}
The same pattern is applied for L2 and L3, each with their own user sets and group declarations.
Outputs for Integration
Once the groups are created, the component outputs their object IDs. In the Identity component we can simply output the following object.
output "groups" {
value = {
level1 = azuread_group.level1.object_id
level2 = azuread_group.level2.object_id
level3 = azuread_group.level3.object_id
}
}
In each downstream component we can define an input like this:
variable "groups" {
type = object({
level1 = string
level2 = string
level3 = string
})
}
Then we simply need to pipe the output from the identity component into the input to downstream components.
component "apps" {
source = "./src/terraform/apps"
inputs = {
location = var.location
application_name = var.application_name
environment_name = var.environment_name
resource_suffix = component.shared.resource_suffix
tags = var.tags
log_analytics = component.shared.log_analytics
application_insights = component.shared.application_insights
container_registry = component.shared.container_registry
cosmosdb_account = component.shared.cosmosdb_account
keyvault = component.shared.keyvault
groups = component.identity.groups
}
providers = {
azurerm = provider.azurerm.this
azapi = provider.azapi.this
random = provider.random.this
}
}
Once this is done, these IDs can then be used in the downstream components to:
- Grant Azure role assignments (e.g., Reader, Contributor) scoped to specific resources.
- Configure Azure Monitor alerting rules, so that the appropriate tier of operators is notified.
- Drive conditional access policies, RBAC logic, or access reviews.
This separation of identity definition from consumption improves modularity and reusability across environments.
Conclusion
This Terraform Stacks component demonstrates a scalable, code-driven approach to managing operator access for cloud workloads via Entra ID. By structuring group ownership and access tiers in a single reusable module, infrastructure teams can enforce consistent identity management across services and environments.
Although Entra ID’s current permission model forces broader access than desired, encapsulating this functionality in an isolated component limits blast radius and provides a clear path to more secure, automated identity management in the future.
The need for scoped permissions like Group.ReadWrite.Mine is clear—and until they arrive, we’ll continue to build responsibly within the constraints of the platform.
