Skip to content

Manage Access Packages

A privileged persona — a bundle of Entra directory roles and Azure RBAC grants that someone requests as one unit — lives in two projects:

Project Lane Holds Applied by
azure-privileged-access-grants A The persona's group, its directory roles and Azure RBAC grants; the approver and requestor groups You, locally, with PIM-activated roles
azure-privileged-access-packages B The persona group's PIM activation policy and its access package CI, on merge to main

Why two: assigning a directory role needs Graph RoleManagement.ReadWrite.Directory (enough to make anyone Global Administrator), so only a human may hold it. PIM for Groups policies and access packages need Graph scopes the Azure CLI can never get (AADSTS65002), so only a pipeline identity can manage them.

Adding a persona is therefore two MRs, in order — grants, then packages — plus, for a persona with directory roles, a third: grants again to switch off the temporary onboarding owner (catalog_onboarding).

Before you start: decide the persona

  • Name — kebab-case; the group becomes pag-<name>.
  • Directory roles — e.g. Billing Administrator.
  • Azure RBAC grants — role and scope.
  • Access package — display name, description, how long an approved assignment lasts (days), and whether requests need approval.
  • PIM activation — how long one activation lasts (e.g. PT4H), MFA and justification.

Only Tier 0 access belongs here: directory roles, and Owner/Reader at the tenant root or mg-intermediate-root. Standing or lower-tier access (e.g. Contributor on a landing zone) belongs in a Tier 1/2 project instead.

Step 1 — allow the grant (only if it's new)

Every directory role and Tier 0 RBAC scope must be on the allow-list in solution-priv-tier-0. If yours isn't, add it there first — its own MR and semver tag — then bump module.policy's ref in the grants project's policy.tf.

# solution-priv-tier-0/main.tf — allowed_entra_directory_roles
"billing-administrator" = {
  role_template_id   = "b0f54661-2d74-4c50-afa3-1ec803f12efe"
  max_eligible_users = 5
}

Verify role template IDs against Microsoft's built-in roles reference, never a copy elsewhere in the tenant.

Step 2 — the grants (Lane A)

In azure-privileged-access-grants, add one file per persona that calls solution-manage-pag-persona. Leave pim_activation_rules and access_package out — those are Lane B's.

📁 pag-billing-administrator.tf

# =============================================================================
# Persona: Billing Administrator
#
# Bundles:
# 1. Billing Administrator (Entra ID Directory Role)
# =============================================================================

module "billing_administrator" {
  source = "git::https://gitlab.com/grinntec-cloud/terraform-modules/terraform-solutions/solution-manage-pag-persona.git?ref=v1.4.0"

  persona_name = "billing-administrator"
  description  = "Privileged Access Group for Billing Administrator."

  directory_role_template_ids = {
    for k in ["billing-administrator"] :
    k => module.policy.allowed_entra_directory_roles[k].role_template_id
  }

  # Azure RBAC, if any — keys are allow-list keys:
  # rbac_grants = {
  #   reader-mg-tenant-root = {
  #     scope                = data.azurerm_management_group.tenant_root.id
  #     role_definition_name = module.policy.allowed_rbac_grants["reader-mg-tenant-root"].role
  #   }
  # }

  group_owners = [data.azuread_user.tier_0_group_owner.object_id]

  # TEMPORARY: lets the packages pipeline add this group to the catalog.
  # Only for personas with directory roles. Step 4 removes it.
  catalog_onboarding = true
}

output "pag_billing_administrator_object_id" {
  description = "Object ID of the pag-billing-administrator PAG group."
  value       = module.billing_administrator.group_object_id
}

Why the packages pipeline briefly owns the group

A persona with directory roles gets a role-assignable group, and Microsoft only lets an app add one 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; Step 4 turns it off. It must never stay on: an owner can add members directly, bypassing PIM.

An RBAC-only persona (no directory roles) gets a non-role-assignable group — leave catalog_onboarding out and skip Step 4.

Open an MR. There's no CI apply — after merge, wait for the main pipeline (it saves the plan), then apply it locally with ./apply.ps1.

Activate one group: Manage Azure Privileged Access Grants (tier 0)

pag-t0-manage-azure-privileged-access-grants is this project's Tier 0 project group (formerly Core Infrastructure & IaC Engineer) and carries everything an apply here needs:

  • Privileged Role Administrator — create role-assignable groups and assign directory roles.
  • Reader at the tenant root — the plan reads management groups and role assignments.
  • Role Based Access Control Administrator at the tenant root, conditioned to Owner, Reader, Storage Blob Data Contributor, Storage Blob Data Reader and Management Group Contributor, on groups only — create and remove the persona's RBAC grants.
  • Storage Blob Data Contributor on the project's state container.

Granting any other Azure role (outside that condition) still needs Emergency Tenant Recovery.

Sign in again after activating (az account clear, then az login) — a cached token doesn't pick up new activations.

cd azure-privileged-access-grants
git checkout main; git pull
./apply.ps1

Why ./apply.ps1, not terraform apply

Lane B changes are reviewed on the MR and applied from exactly what was merged. apply.ps1 gives Lane A the same guarantee: it applies the plan CI saved on main for the merged commit — never a plan made from local files. It refuses unless you're on an up-to-date, clean main whose commit came from a merged MR, and the latest main pipeline saved a plan for exactly that commit; it shows the plan and asks you to type apply. It also checks the group is active and your sign-in is newer than the activation, and fetches the exact Terraform version if yours differs. If the plan is stale (state changed since), re-run the main pipeline. It lives in the project — nothing to install.

The group now exists, holds its roles, and has no members.

Step 3 — the access package (Lane B)

In azure-privileged-access-packages, three additions.

Look the group up — 📁 catalog-resources.tf:

data "azuread_group" "billing_administrator" {
  display_name     = "pag-billing-administrator"
  security_enabled = true
}

Its PIM activation policy — 📁 pim-activation-policies.tf:

resource "azuread_group_role_management_policy" "billing_administrator" {
  group_id = data.azuread_group.billing_administrator.object_id
  role_id  = "member"

  activation_rules {
    maximum_duration                   = "PT4H"
    require_approval                   = false
    require_justification              = true
    require_multifactor_authentication = true
  }
}

Its access package — 📁 pkg-billing-administrator.tf:

# =============================================================================
# Access Package: Billing Administrator
#
# Persona Group: pag-billing-administrator
# Bundles:
# 1. Billing Administrator (Entra ID Directory Role)
# =============================================================================

module "billing_administrator" {
  source = "git::https://gitlab.com/grinntec-cloud/terraform-modules/terraform-azure-modules/terraform-azuread-access-package.git?ref=v2.2.1"

  catalog_id   = azuread_access_package_catalog.priv_tier_0.id
  display_name = "Billing Administrator"
  description  = "Request eligibility for Billing Administrator. Approval required."

  # Bound as Eligible Member (the module's default): approval grants
  # eligibility, every use is a time-boxed PIM activation.
  resource_origin_id = data.azuread_group.billing_administrator.object_id

  approver_group_object_id  = local.priv_tier_0_approvers_id
  requestor_group_object_id = local.priv_tier_0_requestors_id
  duration_in_days          = 90
}

For a package with no approval, set approval_required = false and drop approver_group_object_id.

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.

Plan fails: \"is role-assignable, and Entra only lets this pipeline add it to the catalog if it owns the group\"

Step 2's catalog_onboarding = true hasn't been applied yet (or was left out). The module (v2.2.1+) checks at plan time, so this stops the MR rather than failing the apply on main. Set the flag in grants, apply-merged, then re-run this MR's pipeline.

A new persona's first apply takes a couple of minutes

The access package can only bind Eligible Member after the group is onboarded to PIM for Groups, which the PIM policy in the same apply does — and that can take a minute or more. The module waits for it: it looks for the Eligible Member role, and if it isn't there yet, waits 30 seconds and looks again, up to 6 times (~2.5 min). Only if it's still missing after that does the apply fail with "still has no 'Eligible Member' role in the catalog" — check the PIM policy was created.

Don't add depends_on to the access package module call to force the order: on a module it defers every lookup inside to apply time, and the plan fails with "Invalid count argument".

Step 4 — switch off the onboarding owner (Lane A)

Only for a persona with directory roles. Once Step 3 has applied, take the packages pipeline's identity off the group: in pag-billing-administrator.tf, delete the catalog_onboarding = true line (and its comment), MR, merge, and apply-merged. The plan shows one in-place change (owners). The package keeps working — Entra's own service manages membership from here.

Until you do, every packages plan warns "is in the catalog but this pipeline still owns it" — that's the reminder.

Step 5 — check it

  1. MyAccess (myaccess.microsoft.com) → the package is listed for members of sg-priv-tier-0-requestors.
  2. Request it; a member of sg-priv-tier-0-approvers approves (if required).
  3. PIM → My roles → Groups → the persona group shows as eligible. Activate it; the roles apply for the activation window only.

Changing or removing a persona

Change Where
Roles or RBAC grants Grants (pag-<name>.tf), then apply-merged
Activation length, MFA, justification Packages (pim-activation-policies.tf)
Package name, duration, approval Packages (pkg-<name>.tf)
Who may request or approve Grants (sg-priv-tier-0-requestors.tf / sg-priv-tier-0-approvers.tf), then apply-merged
Remove a persona Packages first (package + policy + lookup), then grants. No onboarding owner needed

Who can request and approve is deliberately a Lane A change: those groups are role-assignable, so the packages pipeline's identity can't alter them.