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 first, then packages.

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" {
  # checkov:skip=CKV_TF_1:semver tag is controlled and auditable for internal modules
  source = "git::https://gitlab.com/grinntec-cloud/terraform-modules/terraform-solutions/solution-manage-pag-persona.git?ref=v1.1.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]
}

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

Open an MR. There's no CI apply — after merge, apply locally.

Roles you need to activate for a Lane A apply

  • Core Infrastructure & IaC Engineer — Privileged Role Administrator (create role-assignable groups, assign directory roles) and state access.
  • Cloud Estate Security Auditor — Reader at the tenant root, so the plan can read management groups and role assignments.
  • If the persona adds or changes an RBAC grant at the tenant root or mg-intermediate-root, you also need Owner there (Emergency Tenant Recovery or Cloud Platform & Landing Zone Admin).

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
terraform init
terraform plan "-out=tfplan"
terraform apply tfplan

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" {
  # checkov:skip=CKV_TF_1:semver tag is controlled and auditable for internal modules
  source = "git::https://gitlab.com/grinntec-cloud/terraform-modules/terraform-azure-modules/terraform-azuread-access-package.git?ref=v2.0.0"

  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.

First apply of a new persona can fail once

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. If the catalog is read first, the plan fails with "has no 'Eligible Member' role in the catalog". Re-run the main pipeline — the policy exists by then.

Step 4 — 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), apply locally
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), apply locally
Remove a persona Packages first (package + policy + lookup), then grants

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.