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.
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¶
- MyAccess (
myaccess.microsoft.com) → the package is listed for members ofsg-priv-tier-0-requestors. - Request it; a member of
sg-priv-tier-0-approversapproves (if required). - 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.