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¶
- 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), 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.