$ cd ../writing

2026-09-18 · 13 min

Security groups at scale

Assigned versus dynamic membership, nesting that keeps the group count sane, groups managed from Terraform by convention, and the line between creating groups and managing who is in them.

Part 1 put one move at the centre of rung two: stop assigning roles to people and assign them to security groups. This is that move in detail. What a group buys you, the two ways to fill one, how nesting keeps the number of groups from growing with the number of people, where the groups live in a shared tenant, and how to manage them from Terraform without also trying to manage the people in them.

What a group buys you

A role assignment to a person is a fact somebody has to remember. A role assignment to a group is a fact the membership answers for you.

  • Access follows the org, not the ticket queue. Someone joins a team, they are in the group, they have what the team has. Someone leaves, they are out, everywhere that group grants access, without a checklist.
  • The assignment stops changing. The group holds the role at the scope. People move in and out of the group. The RBAC layer is touched only when a new scope or a new role appears, which is rare and worth a review.
  • Auditors get one object to look at. Group, members, owners, assignments. That is a review. Eighty-three direct assignments on a resource blade is not.

The cost is indirection. Access is no longer a fact about a person; it is a fact about a group they happen to be in, and now you have to design the groups.

Two ways to fill a group

An Entra security group is either assigned or dynamic. Not both, ever. In the Terraform provider the two are literally exclusive: members conflicts with dynamic_membership. That single constraint shapes the whole design.

Assigned. A person or code maintains the members. It changes when somebody acts. Right for teams with exceptions and for anything that nests. It fails when nobody removes anyone.

Dynamic. The directory maintains the members from a rule, within minutes of an attribute changing. Right for anything the directory already knows: department, company, account type. It fails when the attribute is wrong, or when a person fits no rule.

A dynamic rule is a query over user attributes:

(user.companyName -eq "Contoso")
  and (user.department -eq "Platform")
  and (user.accountEnabled -eq true)

The group carries the rule as configuration, and types has to say so or the provider recreates the group:

resource "azuread_group" "platform_dept" {
  display_name     = "acme-az-platform-dept"
  security_enabled = true
  mail_enabled     = false
  types            = ["DynamicMembership"]

  dynamic_membership {
    enabled = true
    rule    = "user.department -eq \"Platform\""
  }
}

The directory evaluates it continuously. Nobody adds or removes members. And the gain is bigger than saved clicks: the membership reconciles itself. When HR corrects a department, every rule that reads it is re-evaluated, and people land where they belong with nobody acting.

Two things a rule cannot do

Dynamic membership removes the maintenance, not the judgement. Two limits follow from where the attributes come from.

The rule is exactly as accurate as the attribute. In most organizations department flows from the HR system into the directory, and the HR system is authoritative. Correcting it in Entra is pointless; the next sync overwrites it. A wrong membership is fixed with an HR ticket, not a portal click. That is the right place to fix it, and it is slower than people expect.

Some people will never match a rule. A service owner who sits in a platform department but owns a product team’s service. A contractor whose department is blank. A person mid-move whose HR record lags two weeks. Every estate has them, and because a dynamic group cannot also hold hand-added members, they need somewhere else to go.

The pattern that follows from both is a pair of groups per team: a dynamic group the directory keeps current, nested into an assigned group that also carries the exceptions. The assigned group is the thing you grant access to.

Nesting: build the tree once

Groups can contain groups, and Azure RBAC resolves the chain. That is what keeps the group count tied to the shape of the estate instead of the size of the workforce. A worked example, read as containment:

acme-az-sub-a-contributor
│   Contributor + Key Vault Secrets Officer
└── acme-az-platform-team      assigned
    ├── acme-az-platform-dept  dynamic
    │     department -eq "Platform"
    └── John Doe               exception
          moved to Platform, HR not updated

Three levels, each with one job:

  • Access group (acme-az-sub-a-contributor): what may be done, and where. It holds the role assignments on subscription A, in this case Contributor and Key Vault Secrets Officer, and nothing else.
  • Team group (acme-az-platform-team): who belongs to the platform team, including the people the rule misses. This is the nestable unit, the one thing you ever add to an access group. It has to be assigned, because a dynamic group cannot take members.
  • Department group (acme-az-platform-dept): who a person is, decided by the directory from user.department.
  • Exceptions (John Doe): people the rule does not reach yet. John moved from Development to Platform last week and his HR record still says Development. He is added to the team group by hand, listed in the configuration, and the day HR catches up the rule picks him up and the exception is removed.

Nesting is one resource per edge, so a plan shows each membership change as one reviewable line:

resource "azuread_group_member" "platform_in_sub_a" {
  group_object_id  = azuread_group.access["sub-a/contributor"].object_id
  member_object_id = azuread_group.team["platform"].object_id
}

What this buys: a new subscription means a new set of access groups and one nesting per team that needs it. A person changing teams means one directory attribute changing. The access groups themselves are never edited for a people reason.

Two things nesting will not tell you

Transitive for Azure RBAC. Not for every consumer. RBAC walks the tree, so a person in the department group holds what the access group grants. A point-to-site VPN gateway in the same tenant reads direct membership only, and so do some applications that assign app roles by group. Same groups, same tenant, different answer. Establish transitivity per consumer before relying on it.

Do not nest anyone into the owner tier. Owner carries User Access Administrator, which means whoever holds it can grant standing access to anyone. An owner-level access group should exist as the escalation path and stay empty, reachable through PIM eligibility with approval rather than through a nesting. A team group nested there turns eligibility into standing access, quietly.

Administrative units

This one is an enterprise control. A small tenant does not need it. A tenant shared by several companies or streams does, because “who may manage these groups” needs a boundary as much as “who may read this subscription” does.

An administrative unit is that boundary: a container for users, groups and devices, and a scope at which directory roles can be assigned. A role held at the unit applies inside it and nowhere else. Every group the configuration creates lives in one unit, and the pipeline’s service principal holds Groups Administrator scoped to that unit. It can create, edit and delete groups there. It cannot touch a group outside, and it cannot touch users anywhere.

That is the attack surface argument: a leaked pipeline credential, or a bad plan that got applied, can damage one unit. Not the tenant.

locals {
  unit_id = "00000000-0000-0000-0000-0000000000a0"
  ci_id   = "00000000-0000-0000-0000-0000000000c1"
}

resource "azuread_directory_role" "groups_admin" {
  display_name = "Groups Administrator"
}

resource "azuread_administrative_unit_role_member" "ci" {
  role_object_id                = azuread_directory_role.groups_admin.object_id
  administrative_unit_object_id = local.unit_id
  member_object_id              = local.ci_id
}

Before the request goes to the role approver

  • The role writes, it does not read. Groups Administrator carries write actions and no general directory read, so a principal holding only that role fails on the first Graph query with Authorization_RequestDenied. Pair it with a read permission.
  • Unit membership for groups is explicit. Dynamic membership rules for a unit cover users and devices, not groups. Every group the configuration creates also sets administrative_unit_ids, or the scoped role stops covering it.
  • This is a directory-role request, not an RBAC one. Contributor on a subscription is granted by a subscription owner. A scoped directory role is granted by a Privileged Role Administrator, which in most organizations is a different team and a slower queue. Budget for it.

Groups as code, by convention

Once the layout is a tree with a handful of dimensions, the groups stop being individually interesting and become a projection of a small set of inputs. That is the point at which Terraform earns its place, and the same convention-over-configuration bet applies as anywhere else: the naming pattern is the registration.

Two group shapes cover most of the need:

<prefix>-<scope>-<tier>
<prefix>-<scope>-<workload>-<env>-<tier>

One per scope and role tier; one per workload and environment. Derive the names from the inputs. Never type one. With two subscriptions and three tiers as input:

locals {
  prefix = "acme-az"
  tiers  = ["reader", "contributor", "owner"]

  scopes = {
    "sub-a" = "00000000-0000-0000-0000-00000000000a"
    "sub-b" = "00000000-0000-0000-0000-00000000000b"
  }

  pairs = setproduct(keys(local.scopes), local.tiers)

  access_groups = {
    for p in local.pairs : join("/", p) => {
      name  = "${local.prefix}-${p[0]}-${p[1]}"
      scope = "/subscriptions/${local.scopes[p[0]]}"
      tier  = p[1]
    }
  }
}

resource "azuread_group" "access" {
  for_each = local.access_groups

  display_name            = each.value.name
  security_enabled        = true
  mail_enabled            = false
  administrative_unit_ids = [local.unit_id]
  owners                  = [local.ci_id]

  lifecycle {
    ignore_changes  = [members]
    prevent_destroy = true
  }
}

resource "azurerm_role_assignment" "access" {
  for_each = local.access_groups

  scope                = each.value.scope
  role_definition_name = title(each.value.tier)
  principal_id         = azuread_group.access[each.key].object_id
  principal_type       = "Group"
}

The plan creates six groups and six role assignments:

acme-az-sub-a-reader        Reader       on sub-a
acme-az-sub-a-contributor   Contributor  on sub-a
acme-az-sub-a-owner         Owner        on sub-a
acme-az-sub-b-reader        Reader       on sub-b
acme-az-sub-b-contributor   Contributor  on sub-b
acme-az-sub-b-owner         Owner        on sub-b

Subscription C costs three more, and nothing else changes. One role per tier keeps the example short; in practice a contributor tier is a list, because Contributor does not include the data-plane roles for Key Vault, Storage or Service Bus, and a contributor who cannot read the secrets the app needs is not one. That list is where the assignment count grows, so do the arithmetic for your own dimensions before the first apply rather than after.

Three rules make this hold up as the estate grows.

Validate the inputs, because the names cannot be validated

A derived name is an output; nothing can check it. What can be checked is that a scope name matches the expected shape, that an environment token is one of the allowed set, that a workload is declared before a resource group is listed against it. Put those in validation blocks on the variables.

Enumerate, never construct

The temptation is to compute the workload list: every workload in every environment. Real estates are not regular. One workload skips an environment, one scope follows an older naming rule, one team calls the same tier by a different name. A computed for_each over irregular data does not fail. It creates correctly named groups that match nothing and grants them nothing, and the plan reads as a success. List the inputs from the tenant and defend the list with plan-time checks. Here the workloads are declared per subscription, the resource groups are listed per workload, and the check catches the one that was declared but never given a resource group:

locals {
  workloads = {
    "sub-a" = ["billing", "ledger"]
  }

  resource_groups = {
    "sub-a/billing" = ["rg-billing-prod"]
  }

  unscoped = flatten([
    for sub, names in local.workloads : [
      for w in names : "${sub}/${w}"
      if !contains(keys(local.resource_groups), "${sub}/${w}")
    ]
  ])
}

check "every_group_has_a_scope" {
  assert {
    condition = length(local.unscoped) == 0
    error_message = join(" ", [
      "Groups with no resource group:",
      join(", ", local.unscoped),
      "Created, and granted nothing.",
    ])
  }
}

With that input, local.unscoped evaluates to ["sub-a/ledger"] and the check fails with the message above. Then the part that surprises people: a failed check is only a warning to Terraform. The plan still succeeds and an apply still proceeds. So the pipeline has to read the plan JSON and stop on any check whose status is not pass. Otherwise the gate is decorative.

Assert owners, not members

owners is authoritative in the code above: anyone not listed is removed on apply. That is deliberate, because a group owner can add members outside any approval, so on a privileged group ownership is itself a privilege. Members are the opposite, and that is the next section.

Manage groups, not members

The configuration above owns group lifecycle: names, owners, nesting, unit membership, role assignments. It does not own who is in the assigned groups, and ignore_changes = [members] is what says so.

The reason is what happens on the first apply against an estate that already exists. The groups you import already hold people, granted one at a time over years. The configuration lists none of them, because the configuration is about structure, not staffing. Without ignore_changes, Terraform’s default is to make the group look like the file, and the file is empty. With it, the first apply adds the nestings and the owners and removes nobody.

So membership is owned elsewhere, and by different mechanisms at different rungs:

  • Department groups: the dynamic rule, and the HR system behind it.
  • Team groups: the nested department group, plus a short exception list in the configuration.
  • Privileged tiers: PIM eligibility. Nobody is a member; people activate.
  • Access groups you inherited: nobody, yet. A periodic drift report lists the members the configuration did not add.
  • Everything, at rung four: access packages and access reviews, so every membership has an owner and an expiry date.

The honest cost of the split: a member added by hand to an assigned group is invisible to the configuration forever. The drift report is a report, not a gate. Somebody has to read it, and at rung two that somebody is you.

One more consequence follows from a group being an identity rather than a pointer. Deleting a group takes its object ID, its memberships, its PIM eligibility and every role assignment that referenced it, and recreating it produces a new object ID that none of those come back to. prevent_destroy is the local backstop. The pipeline should have the first one: refuse to apply any plan that destroys a group, with no override flag. Removing a role assignment or a nesting is a different class of change, reversible by reapplying, and can be gated more lightly.

Restrictions

  • Dynamic or assigned, never both. Exceptions need a second, assigned group.
  • mailNickname is immutable and invisible. The portal does not show it; changing it destroys and recreates the group. Groups created by hand or by older scripts carry whatever nickname they were given, so importing them means carrying that value in the configuration rather than deriving it.
  • Role-assignable groups are a different kind of group. Azure has two role systems. Azure RBAC roles (Reader, Contributor, Owner) apply to resources, and any security group can hold them. Entra directory roles (Groups Administrator, User Administrator) apply to the directory itself, and a group can hold one only if it was created with assignable_to_role = true. That flag cannot be added later, the group cannot be dynamic, the tenant allows only a few hundred of them, and only privileged roles may manage them. If a group will ever hold a directory role, decide at creation.
  • Nesting is not transitive everywhere. VPN gateways and some app-role assignments read direct membership only.
  • Group owners bypass everything. An owner adds members with no approval and no expiry. Assert owners from code and treat owner lists on privileged groups as access grants.

What you get, what stays manual

  • Access changes are a plan diff in a pull request, reviewed before they happen.
  • A new subscription is a few lines of input and a predictable number of new groups.
  • Joiners and movers are handled by the directory for anyone a rule matches. Leavers are handled everywhere a dynamic group reaches.
  • The group count tracks the estate, not the headcount.

What stays manual is exactly the membership the configuration refuses to own: the exceptions, and the people already inside the assigned groups you inherited. Standing privileged access is the next problem, and it is not solved by a better group layout. It is solved by nobody being a member at all, which is what PIM is for.


Part 2 of three. Part 1 is Azure identity, from startup to enterprise.

azureidentityrbacterraform

read next