Skip to content

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.tf or 📃 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 root or mg-intermediate-root)
  • Manage the max_eligible_users threshold

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:

Error: Invalid index on module.policy

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

  rbac_grants = {
  reader-mg-landing-zones = {
    scope                = data.azurerm_management_group.landing_zones.id
    role_definition_name = "Reader"
  }
}
  1. 📁 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_users with a sensible number to limit how many people can use the access package.
  2. 📁 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-packages project. 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_users with a sensible number to limit how many people can use the access package.
  3. Commit as feat: (e.g. feat: allow billing-administrator). The release job only tags feat: / fix: commits; anything else merges with no new version. Merge, then confirm the new tag exists under Code → Tags.

  4. In the azure-privileged-access-grants project — bump ref in allow-list.tf to 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.