Manage Access Packages¶
The engineer's guide to managing privileged personas. Use this runbook to:
- Create new privileged personas and publish their Entitlement Management access packages.
- Register new directory roles, Tier 0 RBAC scopes, or adjust user caps in the governance allow-list.
- Expand the operator's ABAC conditions to permit new Azure RBAC assignments.
For architectural context and why execution is split between humans and pipelines, see Access Packages 101.
What is a persona?
A persona is a bundle of admin permissions for one job, like managing Azure Policy. The permissions belong to a group, not to people. You request access to the group, and once approved, you switch the permissions on yourself for a limited time using PIM when you need them.
The projects¶
The following projects are used to manage this system.
| Project | Lane | Holds |
|---|---|---|
solution-privileged-access-allowlist |
— | Central governance data (directory roles, Tier 0 RBAC role+scope pairs, eligibility caps, and the operator persona's ABAC condition). Contains no state or infrastructure; published as a versioned SemVer tag. |
azure-privileged-access-grants |
A | Persona groups (pag-*.tf), their assigned directory roles, and Azure RBAC grants; requestor and approver security groups (sg-t*-*.tf). Consumes the versioned governance allow-list pinned in allow-list.tf. |
azure-privileged-access-packages |
B | Entitlement Management access packages and PIM for Groups activation policies (pkg-*.tf) for each persona. Configures request workflows, activation rules, and review schedules. |
Why we separate these projects:
The solution-privileged-access-allowlist project acts as the pure data layer. It contains no Terraform state and applies no infrastructure—it simply defines the allow-list (directory roles, Tier 0 scopes, eligibility caps) and publishes an immutable SemVer release tag on merge. This ensures governance changes are auditable and version-pinned before anyone deploys them.
Assigning directory roles requires RoleManagement.ReadWrite.Directory in Microsoft Graph. Because that permission effectively gives someone keys to the entire tenant (including creating Global Admins), it should never live in automation—only a human should hold it. So role assignment is managed in code in azure-privileged-access-grants and as it's Lane A the engineer needs to apply the change using a PIM elevated role.
Conversely, managing PIM for Groups and access packages requires Graph API permissions that trigger AADSTS65002 errors when run through the interactive Azure CLI. Because human CLI sessions can't acquire these scopes, automation pipelines have to handle them instead. So, the project azure-privileged-access-packages must be Lane B.
Before writing code, establish these parameters:¶
Persona Name: Format as t<n>-<verb>-<scope> (e.g., t1-manage-policy-defender). Standardizes your group (pag-<persona>), code files/modules (pag-* and pkg-*), and access package title ((tier <n>) <Title>).
Tier: Select t0, t1, or t2. Determines the requestor/approver groups (sg-t<n>-*) and review lifecycle policies. See the guide on how to determine the tier.
Roles & Scopes: Map every required directory role and Azure RBAC grant. Ensure every entry passes prerequisite checks B (allow-list.tf) and C (operator ABAC condition).
Manage RBAC/Entra Access grants¶
📁
azure-privileged-access-grants: 📃pag-<persona>.tf
This project defines the identity and privilege boundary for each persona. Here, you declare the Privileged Access Group (pag-*), attach its Entra directory roles, and assign Azure RBAC scopes directly to the group object. Because assigning directory roles requires high-privilege Microsoft Graph permissions (RoleManagement.ReadWrite.Directory), changes defined here belong to Lane A and are applied locally via ./apply.ps1 under a temporary PIM elevation rather than by an automated CI pipeline.
# =============================================================================
# Persona: Landing Zones Owner
#
# Bundles:
# 1. Security Administrator (Entra ID Directory Role)
# 2. Owner at mg-landingzones (Azure RBAC)
#
# Tier 1 RBAC scope: Owner below mg-intermediate-root isn't on the
# solution-priv-tier-0 allow-list, so the role is set here (as the
# reader-mg-* personas do). Security Administrator is Tier 0 and comes from
# the allow-list.
# =============================================================================
module "pag-t0-owner-mg-landing-zones" {
source = "git::https://gitlab.com/grinntec-cloud/terraform-modules/terraform-solutions/solution-manage-pag-persona.git?ref=v1.5.0"
# The solution prefixes this persona name with "pag-" to form the Entra ID group name.
persona_name = "t0-owner-mg-landing-zones"
description = "Owner at mg-landingzones and Entra Security Administrator."
# Cross-reference to allowed Entra directory roles for this persona.
directory_role_template_ids = {
for k in ["security-administrator"] :
k => module.allow_list.allowed_entra_directory_roles[k].role_template_id
}
# RBAC grants for this persona.
rbac_grants = {
owner-mg-landingzones = {
scope = data.azurerm_management_group.landing_zones.id
role_definition_name = "Owner"
}
}
# Owners of the Entra ID group for this persona.
group_owners = local.tier_0_group_owner_ids
}
output "pag-t0-owner-mg-landing-zones-object-id" {
description = "Object ID of the pag-t0-owner-mg-landing-zones PAG group."
value = module.pag-t0-owner-mg-landing-zones.group_object_id
}
Open an MR, check the plan creates the group, its role assignments and nothing else, merge, then apply.
The group now exists, holds its roles, and has no members.
Why the packages pipeline briefly owns the group
Entra only lets an app add a role-assignable group to an access
package catalog if the app owns it (CallerNotResourceOwner
otherwise). catalog_onboarding = true makes the packages pipeline's
identity a second owner just long enough to onboard the group. It must
never stay on: an owner can add members directly, bypassing PIM. A
non-role-assignable persona skips this and A3.
Manage the access package¶
📁
azure-privileged-access-packages: 📃pkg-<persona>.tf
This project configures the request and activation lifecycle for each persona. Here, you connect the persona's Privileged Access Group (pag.*) to an Entitlement Management catalog, publish its access package, and define the PIM for Groups activation rules—such as maximum duration, MFA enforcement, and peer approval workflows. Because managing PIM for Groups and access package policies requires Microsoft Graph application permissions that fail under interactive CLI sessions (AADSTS65002), changes defined here belong to Lane B and are deployed automatically by the CI pipeline upon merging to main.
# =============================================================================
# Access Package: Tier 0 — Landing Zones Owner
#
# Persona Group: pag-t0-owner-mg-landing-zones
# Bundles:
# 1. Security Administrator (Entra ID Directory Role)
# 2. Owner at mg-landingzones (Azure RBAC)
# =============================================================================
data "azuread_group" "pag-t0-owner-mg-landing-zones" {
display_name = "pag-t0-owner-mg-landing-zones"
security_enabled = true
}
module "pkg-t0-owner-mg-landing-zones" {
source = "git::https://gitlab.com/grinntec-cloud/terraform-modules/terraform-azure-modules/terraform-azuread-access-package.git?ref=v2.5.0"
catalog_id = azuread_access_package_catalog.priv_tier_0.id
display_name = "(tier 0) Owner at Landing Zones"
description = "Owner at mg-landingzones and Entra Security Administrator. Activation: 2h."
resource_origin_id = data.azuread_group.pag-t0-owner-mg-landing-zones.object_id
catalog_writer_object_id = local.catalog_writer_object_id
# To require peer approval on request:
# approval_required = true
# approver_group_object_id = local.t0_approvers_id
approval_required = false
requestor_group_object_id = local.t0_requestors_id
duration_in_days = 90
assignment_review = local.t0_assignment_review
questions = local.requestor_questions
}
resource "azuread_group_role_management_policy" "pkg-t0-owner-mg-landing-zones" {
group_id = data.azuread_group.pag-t0-owner-mg-landing-zones.object_id
role_id = "member"
activation_rules {
maximum_duration = "PT2H"
require_approval = false
require_justification = true
require_multifactor_authentication = true
# To re-enable two-person peer approval on activation:
# require_approval = true
# approval_stage {
# primary_approver {
# object_id = local.t0_approvers_id
# type = "groupMembers"
# }
# }
}
}
For a package with no approval, set approval_required = false and drop
approver_group_object_id. Use the tier's own t<n>_* locals.
Open an MR. CI posts the plan: check it creates one PIM policy and one access package (package, catalog association, Eligible Member binding, assignment policy) and nothing else. Merge — CI applies.
A new persona's first apply takes a couple of minutes
The package can only bind Eligible Member once PIM for Groups has
onboarded the group, which the PIM policy in the same apply triggers. The
module retries for about 2.5 minutes. Don't add depends_on to the
module call to force the order — it fails the plan with "Invalid count
argument".
Manage the allow-list¶
📁
solution-privileged-access-allowlist: 📃entra-directory-roles.tfor 📃rbac-grants.tf
This project acts as the centralized policy contract and data layer for your privileged access model. Rather than managing state or deploying infrastructure directly, it maintains the authoritative allow-list of approved Entra directory roles, sensitive Tier 0 Azure RBAC scopes, and maximum user eligibility caps. Merging changes here automatically stamps an immutable SemVer release tag, providing a version-controlled governance baseline that downstream deployment projects pin to before applying role grants or provisioning access packages.
Use this project when you need to:
- Add a Directory role
- Add a Tier 0 RBAC role+scope pair (e.g., at
tenant rootormg-intermediate-root) - Manage the
max_eligible_usersthreshold
Info
If an assignment references a role or scope that is not defined here, the azure-privileged-access-grants
pipeline will fail during terraform plan with:
Info
Only Tier 0 grants need to be defined in the access list. Tier 1/2 do not and can be made in code directly.
Tier 0 grant - refers to the access list for the role_definition_name
rbac_grants = {
management-group-contributor-mg-tenant-root = {
scope = data.azurerm_management_group.tenant_root.id
role_definition_name = module.allow_list.allowed_rbac_grants["management-group-contributor-mg-tenant-root"].role
}
}
Tier 1/2 grant - uses just the role_definition_name value directly
-
📁
solution-privileged-access-allowlist: 📃entra-directory-roles.tf# Can perform common billing related tasks like updating payment information. "billing-administrator" = { role_template_id = "b0f54661-2d74-4c50-afa3-1ec803f12efe" max_eligible_users = 5 }- Take the Directory Role Name and Template IDs from Microsoft's Entra built-in roles reference
- Use
max_eligibile_userswith a sensible number to limit how many people can use the access package.
-
📁
solution-privileged-access-allowlist: 📃rbac-grants.tf"management-group-contributor-mg-tenant-root" = { scope_management_group = "17452008-ed27-47bc-b363-3bcb5faa883d" # tenant root role = "Management Group Contributor" max_eligible_users = 3 }- Define a name for the block that will be referenced in the
azure-privileged-access-packagesproject. Something like<role>-<scope>. - Set the scope to as small as possible
- Take the Directory Role Name and Template IDs from Microsoft's Azure built-in roles reference
- Use
max_eligibile_userswith a sensible number to limit how many people can use the access package.
- Define a name for the block that will be referenced in the
-
Commit as
feat:(e.g.feat: allow billing-administrator). The release job only tagsfeat:/fix:commits; anything else merges with no new version. Merge, then confirm the new tag exists under Code → Tags. - In the
azure-privileged-access-grantsproject — bumprefinallow-list.tfto the new tag.
Manage what roles can be assigned¶
📁
solution-privileged-access-allowlist: 📃locals.tf
This project defines the delegation boundaries for the human operator persona via Azure Attribute-Based Access Control (ABAC). Here, operator_group_roles in locals.tf specifies the exact list of Azure RBAC roles that an elevated operator is permitted to assign or delete on security groups. Because an operator cannot use their conditioned rights to expand their own permissions, widening this condition requires updating the allow-list contract first and executing a coordinated, two-stage apply using an emergency elevation persona before the new persona grants can be applied.
Follow this procedure when you want to give a persona an Azure RBAC role (not an Entra directory role — that's B) that the operator isn't allowed to assign yet.
When pag-t0-manage-privileged-access-grants is activated in PIM, Azure only
lets it assign and remove the roles listed in operator_group_roles in
locals.tf, and only to groups. Think of it as ABAC in IaC: without this
control, the operator could assign any role to anyone.
If the role isn't in the list, the apply fails with a 403 saying your ABAC
condition is not fulfilled for Microsoft.Authorization/roleAssignments/write
(or /delete when removing one).
The role list is part of the operator's own role assignment, so changing it means changing the operator's own permissions — and the operator isn't allowed to do that. So this takes two applies:
Apply 1 — update the operator's list
In the grants repo, bump the allowlist ref to the new version. Don't add
the new grants yet. Merge, then apply with two groups active in PIM:
(tier 0) Manage Privileged Access Grants and (tier 0) Owner at Tenant
Root. The plan should change only the operator's RBAC Administrator
assignment. Deactivate Owner at Tenant Root as soon as it finishes.
Apply 2 — add the grants (normal). Add the persona's new role assignments, merge, and apply with only (tier 0) Manage Privileged Access Grants active. If this works, the new list is doing its job.