Microsoft Entra ID · Identity Governance

Privileged Identity
Management

pim.summary
formerly: Azure AD PIM
model: just-in-time, time-bound, audited
surfaces: entra roles · azure resource roles · groups
license: entra id p2 or entra id governance
apis: microsoft graph · azure resource manager

A practical field guide for identity admins and engineers who have to understand PIM, justify it, roll it out, and drive it from scripts and pipelines on Windows and macOS. Each claim was checked against Microsoft Learn, and links go to the source.

0
Standing admins (goal)
1–24h
Activation window
3
PIM surfaces
2+
Break-glass accounts
01 · Definition

What PIM is in Entra

Privileged Identity Management is the Microsoft Entra service that lets you manage, control, and monitor privileged access. It replaces permanent "always-on" admin rights with access that has to be requested, is limited in time, and is logged.

Eligible, active, and activated

Eligible

The principal can use the role but must activate it first. Activation can require MFA, a justification, a ticket number, and/or approval. Eligibility itself grants nothing.

Active (assigned)

The role is usable right now without any action. It can be permanent (standing access) or time-bound (start and end dates).

Activated

An eligible principal completed activation and is temporarily active for a duration you configure per role (1–24 hours in role settings). When the window ends, PIM removes the active assignment.

Microsoft's own wording is worth repeating: there is no difference in the access an eligible-then-activated user has compared with a permanent assignee. PIM narrows when privilege exists, not what it can do. Least privilege still decides what the role can do.

The three PIM surfaces

Microsoft Entra roles

Directory roles such as Global Administrator, User Administrator, Exchange Administrator. Built-in and custom roles. Managed through Microsoft Graph.

Azure resource roles

Azure RBAC roles (built-in or custom) at management group, subscription, resource group, or resource scope. Managed through Azure Resource Manager (ARM).

PIM for Groups

Just-in-time membership or ownership of a security group or Microsoft 365 group. Formerly "Privileged Access Groups" (renamed January 2023). Managed through Microsoft Graph.

Licensing

PIM requires Microsoft Entra ID P2 or Microsoft Entra ID Governance licences. Microsoft Entra Suite includes the ID Governance capabilities. Microsoft E5-class bundles that include Entra ID P2 qualify. Per Microsoft Learn, you need enough licences to cover:

  • Users with eligible and/or time-bound assignments to Entra roles or Azure roles managed with PIM.
  • Users with eligible and/or time-bound member or owner assignments in PIM for Groups.
  • Users who can approve or reject activation requests.
  • Users assigned to an access review, and users who perform access reviews.

Licences do not have to be assigned to each person individually, but the count must cover everyone in scope. In Microsoft's worked examples, administrators who only configure PIM and are not themselves eligible are not counted.

Licence expiry is a security event

If the P2 / ID Governance licence or trial lapses, eligible assignments are removed and active time-bound assignments become active permanent. Your carefully time-boxed access can turn into standing access. Track renewal dates.

What PIM is not

  • Not least privilege. An eligible Global Administrator is still a Global Administrator once activated. Choose narrower roles first.
  • Not Conditional Access. PIM can call Conditional Access through an authentication context at activation time, but it does not replace device, location, or session controls.
  • Not account separation. Eligibility on your daily email-and-browser account still leaves privilege one click from a phishing session. Use dedicated admin identities.
  • Not a session recorder. PIM audits activation and assignment events. What the admin then does is in the Entra audit logs, Azure Activity Log, and workload logs.
02 · Architecture

Its role in the admin model

PIM does not define permissions. It is a lifecycle layer that sits on top of the existing authorisation systems and controls when their assignments exist.

Identity GovernanceThe umbrella: entitlement management, access reviews, lifecycle workflows, and PIM.
PIMTurns role assignments into eligible / time-bound ones; enforces activation rules (MFA, justification, ticket, approval, max duration); notifies; alerts.
Access reviewsPeriodic recertification of who is eligible or active. Works on Entra roles, Azure roles, and PIM groups.
Entra RBACDirectory role definitions and assignments (tenant-wide, administrative unit, or app scope).
Azure RBACResource role definitions and assignments at management group / subscription / resource group / resource.
GroupsRole-assignable groups for Entra roles; any security group for Azure roles and app access. PIM for Groups time-boxes membership/ownership.
Audit logsEntra audit logs record PIM activity; export them to Log Analytics / Microsoft Sentinel for retention and detection.

Standing vs eligible vs active

Three states of privileged access
StatePrivilege existsTypical useRisk
Standing (permanent active)24/7Break-glass accounts onlyHighest: any session compromise is full compromise
EligibleNever, until activatedEvery human adminLow at rest; depends on activation controls
Active (activated or time-bound)For the window onlyThe hours someone is actually doing admin work; time-bound active for workloadsSame as standing, but only for hours

Role settings (Microsoft Graph calls them role management policies) are defined per role. In Entra, every assignment of the same role follows the same settings. In Azure, settings are per role per scope.

03 · Justification

Why use it

Most tenant compromises don't need a zero-day. They need one admin session that was already privileged. PIM keeps privilege off by default, so stealing a session gets an attacker much less.

Shrink standing privilege

Admins hold no live privilege between tasks. A stolen token for an idle admin is worth far less.

Time-box activation

Activation expires automatically. Nobody has to remember to remove access.

Gate activation

Require MFA (or a Conditional Access authentication context), a justification, a ticket number, and/or named approvers.

Notify

Email notifications on assignment, activation, and approval, configurable per role.

Review

Access reviews recertify eligible and active assignments on a schedule.

Alert

Built-in alerts such as Roles are being activated too frequently, Roles are being assigned outside of PIM, and There are too many Global Administrators.

Audit trail

Who activated which role, at what scope, when, for how long, with what justification and ticket, and who approved it.

Lock-out guard

PIM prevents removing the last active Global Administrator and Privileged Role Administrator assignments.

Failure modes PIM prevents (or shortens)

  • Stolen daily-driver session with permanent Global Admin. An infostealer lifts browser cookies from the account that also reads email. With eligibility only, the attacker gets a non-admin session and still has to get through activation (MFA / approval), which is visible and alertable.
  • Forgotten Owner on a subscription. A contractor got Owner "for the migration" two years ago. Time-bound eligibility plus access reviews make that assignment expire or get recertified.
  • Silent role sprawl. The Roles are being assigned outside of PIM alert catches assignments made directly in Entra RBAC that bypass your activation rules.
  • Unaccountable change. "Who turned off that Conditional Access policy?" With justification plus ticket on activation, the audit log answers why as well as who.
  • Lateral escalation through helpdesk roles. Approval on high-impact roles means a compromised low-tier admin cannot simply activate their way up.
04 · Day to day

How to use it (human path)

Activate an eligible role

  1. Sign in with your admin identity (not your daily account) in the Microsoft Entra admin center.
  2. Go to ID Governance → Privileged Identity Management → My roles. (Some docs and older UI say "Identity Governance".) Choose Microsoft Entra roles, Azure resources, or Groups.
  3. On the Eligible assignments tab, select Activate on the role you need.
  4. If the role requires MFA, complete Verify your identity. If it uses a Conditional Access authentication context, you will be redirected to satisfy that policy.
  5. Optionally narrow the scope (for example a single resource group instead of the subscription), choose a duration up to the role's maximum, and enter a justification and ticket if required.
  6. Select Activate. If approval is required, the request shows as pending under My requests until an approver acts.
  7. Use the role. Some apps cache role state; signing out and back in can help when a just-activated role doesn't show up yet.
  8. When the window ends, PIM removes the active assignment. You can also Deactivate early (not within five minutes of activation).

Azure resources from the Azure portal: you can also activate from Subscriptions → View eligible subscriptions, or from a resource's Access control (IAM) → View my access, at management group, subscription, or resource group scope. PIM is also available in the Azure mobile app.

My roles vs assign vs approve vs audit

PIM blades and who uses them
BladeWhoPurpose
My roles / My requestsAny eligible principalActivate, deactivate, extend or renew own assignments, track pending requests.
Roles → Add assignmentsPrivileged Role Administrator (Entra roles); Owner, User Access Administrator or equivalent (Azure resources)Create eligible or active, permanent or time-bound assignments; edit role settings.
Approve requestsDesignated approversApprove or deny activation, extension, and renewal requests, with a justification.
Resource audit / My auditAdmins, auditors, and the individualActivation and assignment history; export for evidence.
Alerts / Access reviewsPIM admins, reviewersRespond to PIM security alerts; recertify assignments.

Extend and renew

Extend and renew apply to time-bound assignments (eligible or active), not to a running activation:

  • Extend: when an assignment expires within 14 days, the assignee can request an extension from My roles.
  • Renew: after an assignment has expired, the assignee can request renewal from the Expired roles tab (expired assignments stay visible for up to 30 days).
  • For Entra roles, both require approval from a Global Administrator or Privileged Role Administrator. For Azure resources, the resource Owner or User Access Administrator approves.
  • A role assigned to a group can't be extended by an individual member who inherits it.

To get more time inside an activation, activate again after it ends. You can't stretch a running activation past the role's maximum duration.

05 · Rollout

How to implement it

Prerequisites

  • A Microsoft Entra tenant with enough Entra ID P2 or Entra ID Governance licences for the people in scope (see section 01).
  • Privileged Role Administrator (or Global Administrator) to manage Entra role assignments and Entra role settings in PIM. For Azure resource roles, Owner or User Access Administrator on the scope.
  • Two or more emergency-access ("break-glass") accounts, set up before you start converting assignments:
    • Cloud-only, on the *.onmicrosoft.com domain, not federated or synchronised from on-premises.
    • Permanent active Global Administrator. This is the one place Microsoft tells you to use permanent active rather than eligible.
    • Phishing-resistant, passwordless authentication: passkey (FIDO2) recommended, or certificate-based authentication. Use different methods from your normal admin accounts.
    • Excluded from Conditional Access policies that block or restrict sign-in, not tied to any individual, and not used as anyone's PIM onboarding or daily account.
    • Credentials stored securely and available to several admins; sign-ins monitored and alerted on; accounts validated on a schedule.

The rollout sequence

  1. Discover. Inventory current Entra role assignments and Azure RBAC assignments at management group and subscription level. Note who holds Global Administrator, Privileged Role Administrator, and Owner / User Access Administrator.
  2. Right-size. For each assignment, ask whether a narrower role would do (see the role map). Remove what nobody needs.
  3. Configure role settings first. Per role: activation maximum duration, MFA or Conditional Access authentication context, justification, ticket, approval and approvers, eligible/active assignment expiry, and notifications.
  4. Convert permanent to eligible. Change existing permanent active assignments to eligible, starting with the highest-impact roles and keeping the break-glass accounts permanent.
  5. Set up access reviews for privileged Entra roles, Azure roles at sensitive scopes, and PIM groups.
  6. Turn on and triage alerts. Review PIM alert settings and wire notifications to a monitored mailbox or SOC.
  7. Export logs. Send Entra audit logs to Log Analytics / Microsoft Sentinel (see best practice).
  8. Tell people. Admins need to know what changes, how to activate, how long approval takes, and who approves. Most failed PIM rollouts are workflow surprises, not technical ones.
Lock-out trap

Microsoft warns that you can lock yourself out if all Privileged Role Administrators / Global Administrators are eligible with none active, approval is required, and no approvers are configured. Keep break-glass accounts permanent active and name explicit approvers (at least two).

PIM for Groups vs direct role eligibility

Direct role eligibility

Each admin is made eligible for each role individually.

Use when: a small number of admins, one-off roles, or you want per-person role settings visibility.

PIM for Groups

A role-assignable group is permanently assigned a role (or a bundle of Entra and Azure roles); admins are made eligible members of the group and activate membership.

Use when: a team needs the same bundle of roles, you want one activation to grant a job function, or you want access reviews on group membership.

Microsoft's guidance for groups used to elevate into Entra roles: require approval for eligible member activation (otherwise a lower-tier admin who can reset a member's credentials could activate on their behalf), and make sure such groups are created as role-assignable. Changing credentials of members and owners of role-assignable groups needs at least Privileged Authentication Administrator. Microsoft does not recommend nesting groups inside a PIM-managed group. Group owners control membership, so treat eligible ownership as at least as sensitive as membership.

Azure resource scope

Azure eligibility can be granted at management group, subscription, resource group, or resource scope (the Access control (IAM) blade creates eligible assignments at the first three; PIM and the APIs support resource scope too). Role settings apply per role per scope, and activation can be narrowed to a child scope. Prefer granting at the lowest scope that matches the job. Eligible Owner on the tenant root management group is effectively eligible Owner of everything.

Infrastructure as code

Schemas below were taken from the current provider and template references. Schedule requests are request objects, not declarative desired-state assignments: re-running a deployment with the same request name, or removing it from code, may not do what you expect. Test in a non-production subscription and check the linked docs for drift behaviour.

Terraform: azurerm (Azure resource role eligibility)

hcl · azurerm_pim_eligible_role_assignment
# Eligible Contributor on one resource group, expiring after 180 days.
# principal_id must be a user or group object id (service principals cannot be eligible).
data "azurerm_subscription" "current" {}

data "azurerm_resource_group" "app" {
  name = "rg-app-prod"
}

data "azurerm_role_definition" "contributor" {
  name = "Contributor"
}

resource "time_static" "start" {}

resource "azurerm_pim_eligible_role_assignment" "app_team" {
  scope              = data.azurerm_resource_group.app.id
  # Subscription-prefixed form, as in the provider's own example.
  role_definition_id = "${data.azurerm_subscription.current.id}${data.azurerm_role_definition.contributor.id}"
  principal_id       = var.app_admins_group_object_id # placeholder variable
  justification      = "Eligible Contributor for the app operations team"

  schedule {
    start_date_time = time_static.start.rfc3339
    expiration {
      duration_days = 180
    }
  }

  ticket {
    number = "CHG-0000"
    system = "ITSM"
  }
}

There is a matching azurerm_pim_active_role_assignment for time-bound active assignments. For management-group scope, the registry example uses the role definition id without the subscription prefix.

Terraform: azuread (Entra role eligibility)

hcl · azuread_directory_role_eligibility_schedule_request
# Caller needs RoleEligibilitySchedule.ReadWrite.Directory (or RoleManagement.ReadWrite.Directory)
# and the Privileged Role Administrator (or Global Administrator) role.
resource "azuread_directory_role" "groups_admin" {
  display_name = "Groups Administrator"
}

resource "azuread_directory_role_eligibility_schedule_request" "helpdesk_lead" {
  role_definition_id = azuread_directory_role.groups_admin.template_id # built-in roles: use template_id
  principal_id       = var.admin_user_object_id                          # placeholder variable
  directory_scope_id = "/"
  justification      = "Eligible Groups Administrator for identity operations"
}

Bicep: Microsoft.Authorization/roleEligibilityScheduleRequests

bicep · resource group scope
targetScope = 'resourceGroup'

@description('Object id of a USER or GROUP. Service principals cannot hold eligible assignments.')
param principalId string

@description('Built-in Contributor role definition GUID.')
param roleDefinitionGuid string = 'b24988ac-6180-42a0-ab88-20f7382dd24c'

param startDateTime string = utcNow()

resource eligibleContributor 'Microsoft.Authorization/roleEligibilityScheduleRequests@2020-10-01' = {
  name: guid(resourceGroup().id, principalId, roleDefinitionGuid)
  properties: {
    principalId: principalId
    requestType: 'AdminAssign'
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionGuid)
    justification: 'Eligible Contributor for the app operations team'
    scheduleInfo: {
      startDateTime: startDateTime
      expiration: {
        type: 'AfterDuration'
        duration: 'P180D'
      }
    }
  }
}

Newer preview API versions exist; 2020-10-01 is the GA version used by the Azure REST reference above. See the template reference.

06 · Hardening

Best practice

No permanent Global Admin

Only the break-glass accounts hold permanent active Global Administrator. Every human is eligible, and Global Administrator activation requires approval.

Least-privilege roles

Prefer the narrowest role that does the job. Microsoft publishes least privileged roles by task. Scope Entra roles to administrative units where supported.

Short activations

Hours, not days. A one- to four-hour maximum suits most Entra roles; the setting allows 1–24 hours.

MFA + justification + ticket

Require all three on privileged Entra roles. Use a Conditional Access authentication context with an authentication strength to force phishing-resistant MFA at activation.

Approval on high-impact roles

Global Administrator, Privileged Role Administrator, Privileged Authentication Administrator, Conditional Access Administrator, and in Azure Owner and User Access Administrator / Role Based Access Control Administrator at sensitive scopes. Name at least two approvers.

Separate admin identities

Cloud-only admin accounts with no mailbox use, no day-to-day browsing, and ideally a privileged access workstation. Phishing-resistant MFA (passkeys / FIDO2, Windows Hello for Business, or certificate-based auth) for every admin.

Scheduled access reviews

Quarterly for privileged Entra roles and sensitive Azure scopes is a common baseline; monthly for Tier-0 roles. Auto-remove on non-response where risk allows.

Protect the protector

Privileged Role Administrator can grant any Entra role, including Global Administrator. Put it under PIM with approval, keep the holder count tiny, and alert on activation.

CA admins are privileged

Anyone who can edit the Conditional Access policy behind an authentication context can weaken PIM activation. Microsoft explicitly says to treat those principals as highly privileged.

Detect: activations outside business hours and repeat activations

PIM's built-in alert covers Roles are being activated too frequently. There is no built-in "outside business hours" alert, so build it in Microsoft Sentinel or Azure Monitor from the Entra AuditLogs table. Send logs to a workspace in a subscription the activated admin cannot quietly change: separate RBAC, resource locks, and retention, ideally owned by security operations.

kql · starting point, verify operation names in your tenant
// Activations outside 07:00-19:00 (adjust for your time zone) or on weekends.
// Confirm the exact OperationName values your tenant emits before alerting on this.
AuditLogs
| where LoggedByService == "PIM"
| where OperationName has "activation"
| extend Local = datetime_utc_to_local(TimeGenerated, "Australia/Brisbane")
| where hourofday(Local) !between (7 .. 18) or dayofweek(Local) in (0d, 6d)
| project TimeGenerated, OperationName, Result,
          Actor = tostring(InitiatedBy.user.userPrincipalName),
          Target = tostring(TargetResources[0].displayName)

Automation secrets

  • Never put service-principal client secrets in repositories or plaintext pipeline variables.
  • Prefer workload identity federation (OIDC) so there is no long-lived secret at all. Use managed identity when the runner is in Azure. If a credential must exist, use a certificate held in a key vault or HSM rather than a shared secret.
  • See section 08 for why PIM eligibility does not apply to workload identities, and what to do instead.
07 · Mapping

Common PIM role mapping

The roles people actually put under PIM, with a plain-language summary of what each one can do, a suggested PIM posture, and a narrower alternative where one exists. Capability text is paraphrased from Microsoft's role references and is a summary, not the full permission set. For the exact actions, use the Entra permissions reference and Azure built-in roles.

MS privileged marks Entra roles that Microsoft labels privileged. Any Azure RBAC role, built-in or custom, can be made PIM-eligible, not just the ones listed.

Roles commonly managed with PIM
RolePlaneWhat it can doTypical PIM postureNarrower alternative
Global AdministratorEntraMS privilegedManage all aspects of Microsoft Entra ID and Microsoft services that use Entra identities.Eligible + approval + phishing-resistant MFA. Permanent active only for break-glass.The specific workload admin role; Global Reader for read-only.
Privileged Role AdministratorEntraMS privilegedManage role assignments in Entra ID and all aspects of PIM, including granting Global Administrator.Eligible + approval. Keep holders to a handful.None for its core job; minimise holders instead.
Privileged Authentication AdministratorEntraMS privilegedView, set, and reset authentication method information for any user, including admins.Eligible + approval.Authentication Administrator (non-admin users only).
Security AdministratorEntraMS privilegedRead security information and reports, and manage security configuration in Entra ID and Office 365.Eligible + MFA + justification; approval in high-assurance tenants.Security Operator or Security Reader.
Security ReaderEntraMS privilegedRead security information and reports in Entra ID and Office 365.Eligible + MFA, or active for SOC analysts with periodic review.Already read-only.
Conditional Access AdministratorEntraMS privilegedManage Conditional Access capabilities. Can weaken or disable tenant-wide sign-in controls, including PIM's authentication context.Eligible + approval.Global Reader or Security Reader to review policies.
Application AdministratorEntraMS privilegedCreate and manage all aspects of app registrations and enterprise apps, including Application Proxy. Can add credentials to apps and so act with those apps' permissions.Eligible + approval.Cloud Application Administrator; owner of specific apps.
Cloud Application AdministratorEntraMS privilegedAs Application Administrator, except Application Proxy.Eligible + approval.Owner of specific app registrations; Application Developer for creation only.
Hybrid Identity AdministratorEntraMS privilegedManage Entra Connect, cloud provisioning, pass-through authentication, password hash sync, seamless SSO, and federation settings.Eligible + approval. Federation changes can redirect authentication.None for federation; separate sync operators from federation owners.
User AdministratorEntraMS privilegedManage all aspects of users and groups, including resetting passwords for limited admins.Eligible + MFA + justification.Helpdesk or Password Administrator for resets; scope to an administrative unit.
Authentication AdministratorEntraMS privilegedView, set, and reset authentication method information for non-admin users.Eligible + MFA + justification.Helpdesk Administrator for password-only work; administrative-unit scope.
Authentication Policy AdministratorEntraCreate and manage the authentication methods policy, tenant-wide MFA settings, password protection policy, and verifiable credentials.Eligible + MFA + justification.None; keep it separate from Conditional Access Administrator.
Helpdesk AdministratorEntraMS privilegedReset passwords for non-administrators and Helpdesk Administrators.Eligible + MFA; or scope to an administrative unit.Password Administrator; administrative-unit scope.
Password AdministratorEntraMS privilegedReset passwords for non-administrators and Password Administrators.Eligible + MFA.Administrative-unit scope.
Groups AdministratorEntraCreate and manage groups and group settings (such as naming and expiration policies), and view group activity and audit reports.Eligible + MFA.Group ownership of specific groups; administrative-unit scope.
Exchange AdministratorEntraManage all aspects of the Exchange product.Eligible + MFA + justification.Exchange Recipient Administrator; Exchange RBAC role groups.
SharePoint AdministratorEntraManage all aspects of the SharePoint service.Eligible + MFA + justification.Site-level permissions for site owners.
Teams AdministratorEntraManage the Microsoft Teams service.Eligible + MFA.Narrower Teams roles (for example Teams Communications Administrator).
Compliance AdministratorEntraRead and manage compliance configuration and reports in Entra ID and Microsoft 365.Eligible + MFA + justification.Microsoft Purview role groups for specific tasks.
Intune AdministratorEntraMS privilegedManage all aspects of the Intune product.Eligible + MFA + justification.Intune's own RBAC roles with scope tags.
Cloud Device AdministratorEntraMS privilegedLimited access to manage devices in Entra ID. See the permissions reference for exact device actions.Eligible + MFA.Intune RBAC for device management tasks.
Global ReaderEntraMS privilegedRead everything a Global Administrator can, but not update anything.Eligible + MFA, or active for auditors with access reviews.Security Reader or service-specific reader roles.
Billing AdministratorEntraPerform common billing tasks such as updating payment information.Eligible + MFA.None common.
Directory WritersEntraMS privilegedRead and write basic directory information. Microsoft notes it is for granting access to applications and not intended for users.Not for humans. For an app, prefer specific Graph app permissions; if unavoidable, time-bound active only.Specific Microsoft Graph application permissions.
Application DeveloperEntraMS privilegedCreate app registrations regardless of the "Users can register applications" setting.Eligible + MFA.Allow registration tenant-wide only if policy permits.
OwnerAzure RBACFull access to manage all resources, including assigning roles in Azure RBAC.Eligible + approval at management group and subscription; never standing for humans; never on a CI identity.Contributor plus Role Based Access Control Administrator with conditions.
ContributorAzure RBACFull access to manage all resources, but cannot assign roles in Azure RBAC, manage Blueprints assignments, or share image galleries.Eligible + MFA + justification. For CI, time-bound active on one resource group.Service-specific contributor roles; a custom role.
User Access AdministratorAzure RBACManage user access to Azure resources (assign roles).Eligible + approval.Role Based Access Control Administrator with conditions restricting which roles can be assigned.
Role Based Access Control AdministratorAzure RBACManage access by assigning roles using Azure RBAC; does not allow managing access by other means such as Azure Policy.Eligible + approval; add assignment conditions.Same role with conditions limiting assignable roles and principal types.
Key Vault AdministratorAzure RBACAll data-plane operations on a key vault and its certificates, keys, and secrets. Cannot manage vault resources or role assignments. RBAC permission model only.Eligible + approval on production vaults.Key Vault Secrets Officer, Secrets User, or Crypto/Certificate-specific roles.
Key Vault Secrets OfficerAzure RBACAny action on the secrets of a key vault, except managing permissions. RBAC permission model only.Eligible + MFA + justification.Key Vault Secrets User (read secret contents).
Storage Blob Data OwnerAzure RBACFull access to blob containers and data, including assigning POSIX access control.Eligible + MFA; add an ABAC condition (supported for Storage Blob Data roles in PIM).Storage Blob Data Contributor or Reader, with conditions.
Website ContributorAzure RBACManage websites, but not web plans. Cannot assign roles in Azure RBAC.Eligible + MFA; scope to a single app.Scope to one site; a custom role.
Any built-in or custom Azure roleAzure RBACPIM supports eligible and time-bound assignment of any Azure RBAC role definition at any supported scope.Match posture to the role's impact.Custom roles containing only required actions.
PIM for Groups: MemberGroupActivating membership grants whatever the group grants: Entra roles (role-assignable groups), Azure roles, and app access.Eligible + approval when the group grants Entra roles (Microsoft recommendation).Smaller, job-specific groups.
PIM for Groups: OwnerGroupActivating ownership lets the owner manage the group, including its membership, and so its access.Eligible + approval; fewer owners than members.Centrally managed membership with no human owners.
08 · Automation

Service principals and workload identities

Verified limit: workloads cannot be eligible

Microsoft Learn states: "You can't assign service principals as eligible to Microsoft Entra roles, Azure roles, and PIM for Groups but you can grant a time-limited active assignment to all three." The Azure RBAC docs add that you can't create eligible assignments for applications, service principals, or managed identities because they "can't perform the activation steps."

So the "make the pipeline identity eligible and have it self-activate" pattern is not supported. Don't design around it.

Why the human mental model doesn't transfer

  • A service principal can't complete an interactive MFA prompt or a Conditional Access authentication-context challenge.
  • Graph self-activation (selfActivate) is documented as requiring a session in which the caller was challenged for MFA. That is a user concept.
  • Approval workflows apply to activation of eligible assignments, which workloads don't have.

Supported patterns, in order of preference

  1. No privileged role at all. Give the workload a narrow Azure RBAC role on the smallest scope (a custom role, or Contributor on one resource group), or specific Graph application permissions instead of a directory role. Combine with deployment-environment approvals in the CI system and a federated credential locked to that environment or branch. This removes the need for JIT in most pipelines.
  2. Human-approved, time-bound active assignment (JIT for workloads). When a privileged run is approved, a human approver (who has activated their own PIM role) creates a time-bound active assignment for the workload identity, for example PT1H. The pipeline authenticates with OIDC, runs a preflight that confirms the window exists, does the privileged step, and lets the assignment expire. Nothing has to be removed afterwards. The approval is the human creating the assignment, and it lands in the PIM audit log.
  3. Automated broker identity (last resort). A separate identity holding RoleAssignmentSchedule.ReadWrite.Directory (Entra) or Owner / User Access Administrator (Azure) creates time-bound assignments for other workloads. That broker can grant any role. Treat it as Tier 0: its own isolated pipeline, protected environment with required reviewers, federated credential only, and alerting on every grant.

Permissions you actually need

Verified permissions for PIM API calls
OperationAPILeast-privileged permission / role
Create role assignment schedule request (activate, assign, extend)Graph POST /roleManagement/directory/roleAssignmentScheduleRequestsRoleAssignmentSchedule.ReadWrite.Directory (delegated or application). Higher: RoleManagement.ReadWrite.Directory. Delegated write callers need Privileged Role Administrator for assigning others; self-activation needs only your own eligibility.
Create role eligibility schedule requestGraph POST /roleManagement/directory/roleEligibilityScheduleRequestsRoleEligibilitySchedule.ReadWrite.Directory, or RoleManagement.ReadWrite.Directory.
List my eligible Entra rolesGraph roleEligibilityScheduleInstances/filterByCurrentUser(on='principal')RoleEligibilitySchedule.Read.Directory.
Read active role assignment instances (preflight)Graph GET /roleManagement/directory/roleAssignmentScheduleInstancesRoleAssignmentSchedule.Read.Directory.
PIM for Groups assignment requestsGraph POST /identityGovernance/privilegedAccess/group/assignmentScheduleRequestsPrivilegedAssignmentSchedule.ReadWrite.AzureADGroup.
Azure resource roles (all operations)ARM Microsoft.Authorization/roleAssignmentScheduleRequests, roleEligibilityScheduleRequestsNo Graph permission needed. Managing other principals' assignments needs at least Owner or User Access Administrator on the scope. Self-activation needs an eligible assignment on the scope.
Danger

A time-bound active Global Administrator on a CI principal is still Global Administrator for that window, with no MFA in the loop and a credential that lives in a pipeline. Scope the workload's role to the minimum (a custom role, Contributor on one resource group, a narrow Entra role, or a time-boxed group membership) and keep the duration to the minimum the job needs. Any app holding RoleAssignmentSchedule.ReadWrite.Directory or RoleManagement.ReadWrite.Directory as an application permission can grant roles, so treat it as equivalent to Privileged Role Administrator.

Identity choices for the workload, in preference order

  1. Workload identity federation (OIDC): GitHub Actions, GitLab CI, and Azure DevOps service connections. No secret is stored. Pin the federated credential subject to a protected branch or environment.
  2. Managed identity when the runner is in Azure (self-hosted agents on VMs, Container Apps jobs, Functions).
  3. Certificate credential in Key Vault or an HSM if a credential must exist. Avoid client secrets; never store them in repos or plaintext CI variables.

Pipeline flow (pattern 2)

  1. Change approved. An approver activates their own PIM role and grants the workload a time-bound active assignment (for example Contributor on rg-app-prod for PT1H). See the bash and C# examples.
  2. Pipeline job starts in a protected environment and authenticates with OIDC. No secret.
  3. Preflight queries the schedule instances and fails fast if there's no window, or if it finds a standing (non-expiring) assignment that shouldn't exist. See the TypeScript and Go examples.
  4. Privileged step runs.
  5. The assignment expires on schedule. No cleanup step for anyone to forget.
09 · Automation

Programmatic examples: Windows and macOS

Everything below runs on both Windows and macOS unless labelled. PowerShell 7 (pwsh) is the default shell on both. All identifiers are fake placeholders (00000000-0000-0000-0000-000000000000 and friends). Replace them with values from your own tenant.

Request body fields that matter (Graph, Entra roles): action (selfActivate, adminAssign, selfDeactivate, …), principalId (object id), roleDefinitionId (role template id for built-ins), directoryScopeId (/ for tenant-wide), justification, ticketInfo, and scheduleInfo.expiration with type afterDuration and an ISO-8601 duration such as PT2H.

ARM (Azure roles): properties.requestType (SelfActivate, AdminAssign, …), principalId, full roleDefinitionId path, linkedRoleEligibilityScheduleId when activating, and the same scheduleInfo. The request name in the URL is a new GUID you generate.

Role definition ids are GUIDs. Resolve them by display name from roleDefinitions rather than trusting a pasted value. One verified example: Global Administrator's template id is 62e90394-69f5-4237-9190-012177145e10.

Install notes

Tooling install, Windows and macOS
ToolWindows (winget)macOS (Homebrew)
PowerShell 7winget install --id Microsoft.PowerShell --source wingetbrew install --cask powershell
Graph PowerShellInstall-Module Microsoft.Graph.Authentication, Microsoft.Graph.Identity.Governance -Scope CurrentUser
Az PowerShellInstall-Module Az.Accounts, Az.Resources -Scope CurrentUser
Azure CLIwinget install --exact --id Microsoft.AzureCLIbrew install azure-cli
Python 3winget install Python.Python.3.12brew install python
Node.js LTSwinget install OpenJS.NodeJS.LTSbrew install node
.NET SDKwinget install Microsoft.DotNet.SDK.8brew install --cask dotnet-sdk
Gowinget install GoLang.Gobrew install go

Official install pages: PowerShell, Microsoft Graph PowerShell, Az PowerShell, Azure CLI.

a. PowerShell 7 + Microsoft.Graph: activate your eligible Entra role

WindowsmacOSpwsh 7delegated / human
pwsh · Activate-EntraRole.ps1
# Runs unchanged in PowerShell 7 on Windows and macOS.
# Self-activation must happen in a session where you completed MFA;
# Conditional Access may prompt you again at sign-in.
Import-Module Microsoft.Graph.Identity.Governance

Connect-MgGraph -NoWelcome -Scopes 'User.Read',
  'RoleEligibilitySchedule.Read.Directory',
  'RoleAssignmentSchedule.ReadWrite.Directory'

$me = (Invoke-MgGraphRequest -Method GET -Uri '/v1.0/me?$select=id').id

# 1. What am I eligible for?
$eligible = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/roleManagement/directory/roleEligibilityScheduleInstances/filterByCurrentUser(on='principal')?`$expand=roleDefinition"
$eligible.value | ForEach-Object { '{0}  (scope {1})' -f $_.roleDefinition.displayName, $_.directoryScopeId }

# 2. Resolve the role definition id by display name (don't hard-code unverified GUIDs).
$role = Get-MgRoleManagementDirectoryRoleDefinition -Filter "displayName eq 'User Administrator'"

# 3. Activate for two hours with justification and ticket.
$params = @{
  action           = 'selfActivate'
  principalId      = $me
  roleDefinitionId = $role.Id
  directoryScopeId = '/'
  justification    = 'CHG-0000: bulk attribute correction'
  scheduleInfo     = @{
    startDateTime = (Get-Date).ToUniversalTime().ToString('o')
    expiration    = @{ type = 'afterDuration'; duration = 'PT2H' }
  }
  ticketInfo       = @{ ticketNumber = 'CHG-0000'; ticketSystem = 'ITSM' }
}
$req = New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $params

# 4. Check status. Provisioned/Granted = active; PendingApproval = waiting on an approver.
(Get-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -UnifiedRoleAssignmentScheduleRequestId $req.Id).Status

Azure resource roles from PowerShell (Az.Resources)

Graph PowerShell covers Entra roles and PIM for Groups. For Azure resource roles, use the Az.Resources PIM cmdlets (or Invoke-AzRestMethod against the ARM endpoints in example b).

pwsh · Activate-AzureRole.ps1
Connect-AzAccount   # interactive; MFA per your Conditional Access policies

# Placeholder scope. If your eligibility is at subscription scope, use the subscription id here.
$scope = '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-app-prod'
$contributorGuid = 'b24988ac-6180-42a0-ab88-20f7382dd24c'   # built-in Contributor

$elig = Get-AzRoleEligibilitySchedule -Scope $scope -Filter 'asTarget()' |
  Where-Object { $_.RoleDefinitionId -like "*/$contributorGuid" } |
  Select-Object -First 1
if (-not $elig) { throw "No eligible Contributor assignment at $scope" }

New-AzRoleAssignmentScheduleRequest -Name (New-Guid).Guid -Scope $scope `
  -PrincipalId $elig.PrincipalId -RoleDefinitionId $elig.RoleDefinitionId `
  -RequestType SelfActivate -LinkedRoleEligibilityScheduleId $elig.Name `
  -ExpirationType AfterDuration -ExpirationDuration PT2H `
  -ScheduleInfoStartDateTime (Get-Date -Format o) `
  -Justification 'CHG-0000: hotfix deployment'

b. Azure CLI az rest: activate an eligible Azure role

macOS / Linux bashWindows pwshdelegated / human

Azure CLI is the common tool on both platforms. The body is written to a file so the same JSON works in every shell. For Entra roles, prefer example a or c: Microsoft doesn't document Azure CLI's built-in Graph token as carrying the PIM RoleAssignmentSchedule.* scopes.

bash · macOS Terminal, Linux, WSL
# Placeholder scope; Contributor role GUID is the built-in id.
SCOPE="/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-app-prod"
ROLE_GUID="b24988ac-6180-42a0-ab88-20f7382dd24c"
API="api-version=2020-10-01"
ARM="https://management.azure.com"

az login                                    # interactive; MFA per Conditional Access
ME=$(az ad signed-in-user show --query id -o tsv)

# 1. Find my eligibility schedule for that role at this scope.
read -r ROLE_ID ELIG_NAME <<< "$(az rest --method get \
  --url "${ARM}${SCOPE}/providers/Microsoft.Authorization/roleEligibilitySchedules?${API}&\$filter=asTarget()" \
  --query "value[?ends_with(properties.roleDefinitionId, '${ROLE_GUID}')] | [0].[properties.roleDefinitionId, name]" \
  -o tsv)"
[ -n "$ELIG_NAME" ] || { echo "No eligible assignment found" >&2; exit 1; }

# 2. Build the activation request.
REQ_ID=$(uuidgen 2>/dev/null || python3 -c 'import uuid; print(uuid.uuid4())')
cat > activate.json <<EOF
{
  "properties": {
    "principalId": "${ME}",
    "roleDefinitionId": "${ROLE_ID}",
    "requestType": "SelfActivate",
    "linkedRoleEligibilityScheduleId": "${ELIG_NAME}",
    "justification": "CHG-0000: hotfix deployment",
    "scheduleInfo": {
      "startDateTime": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
      "expiration": { "type": "AfterDuration", "duration": "PT2H" }
    }
  }
}
EOF

# 3. Submit, then print the status (Provisioned, PendingApproval, ...).
az rest --method put \
  --url "${ARM}${SCOPE}/providers/Microsoft.Authorization/roleAssignmentScheduleRequests/${REQ_ID}?${API}" \
  --body @activate.json --query properties.status -o tsv
rm -f activate.json
pwsh · Windows (also works on macOS)
$Scope    = '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-app-prod'
$RoleGuid = 'b24988ac-6180-42a0-ab88-20f7382dd24c'
$Arm      = 'https://management.azure.com'
$Api      = 'api-version=2020-10-01'

az login
$me = az ad signed-in-user show --query id -o tsv

# Query parameters go in --uri-parameters: on Windows, az is a .cmd shim and cmd.exe
# would treat a literal '&' inside the URL as a command separator.
$elig = az rest --method get `
  --url "$Arm$Scope/providers/Microsoft.Authorization/roleEligibilitySchedules" `
  --uri-parameters $Api '$filter=asTarget()' |
  ConvertFrom-Json
$match = $elig.value | Where-Object { $_.properties.roleDefinitionId -like "*/$RoleGuid" } | Select-Object -First 1
if (-not $match) { throw 'No eligible assignment found' }

$body = @{
  properties = @{
    principalId                     = $me
    roleDefinitionId                = $match.properties.roleDefinitionId
    requestType                     = 'SelfActivate'
    linkedRoleEligibilityScheduleId = $match.name
    justification                   = 'CHG-0000: hotfix deployment'
    scheduleInfo = @{
      startDateTime = (Get-Date).ToUniversalTime().ToString('o')
      expiration    = @{ type = 'AfterDuration'; duration = 'PT2H' }
    }
  }
} | ConvertTo-Json -Depth 6
$tmp = New-TemporaryFile
Set-Content -Path $tmp -Value $body -Encoding utf8NoBOM

az rest --method put `
  --url "$Arm$Scope/providers/Microsoft.Authorization/roleAssignmentScheduleRequests/$((New-Guid).Guid)?$Api" `
  --body "@$tmp" --query properties.status -o tsv
Remove-Item $tmp

c. Python 3: activate an Entra role through Microsoft Graph

WindowsmacOSazure-identity + requestsdelegated / human

Uses a public client app registration you control (redirect URI http://localhost under "Mobile and desktop applications"). No client secret exists. MSAL Python's PublicClientApplication works the same way if you prefer it.

python · activate_entra_role.py
# pip install azure-identity requests
# Environment (placeholders shown; set real values in your shell, never in code):
#   AZURE_TENANT_ID=00000000-0000-0000-0000-000000000000
#   PIM_CLIENT_ID=11111111-1111-1111-1111-111111111111   (public client app, no secret)
#   PIM_ROLE="User Administrator"
import datetime
import os
import sys
import time

import requests
from azure.identity import InteractiveBrowserCredential

GRAPH = "https://graph.microsoft.com/v1.0"
SCOPES = [
    "https://graph.microsoft.com/User.Read",
    "https://graph.microsoft.com/RoleAssignmentSchedule.ReadWrite.Directory",
]

tenant_id = os.environ["AZURE_TENANT_ID"]
client_id = os.environ["PIM_CLIENT_ID"]
role_name = os.environ.get("PIM_ROLE", "User Administrator")

cred = InteractiveBrowserCredential(tenant_id=tenant_id, client_id=client_id)
session = requests.Session()
session.headers["Authorization"] = f"Bearer {cred.get_token(*SCOPES).token}"


def get(path, **params):
    resp = session.get(f"{GRAPH}{path}", params=params, timeout=30)
    resp.raise_for_status()
    return resp.json()


me = get("/me", **{"$select": "id"})["id"]
safe_name = role_name.replace("'", "''")  # OData string escaping
roles = get("/roleManagement/directory/roleDefinitions",
            **{"$filter": f"displayName eq '{safe_name}'"})["value"]
if not roles:
    sys.exit(f"Role not found: {role_name}")

body = {
    "action": "selfActivate",
    "principalId": me,
    "roleDefinitionId": roles[0]["id"],
    "directoryScopeId": "/",
    "justification": "CHG-0000: bulk attribute correction",
    "ticketInfo": {"ticketNumber": "CHG-0000", "ticketSystem": "ITSM"},
    "scheduleInfo": {
        "startDateTime": datetime.datetime.now(datetime.timezone.utc).isoformat(),
        "expiration": {"type": "afterDuration", "duration": "PT2H"},
    },
}
resp = session.post(f"{GRAPH}/roleManagement/directory/roleAssignmentScheduleRequests",
                    json=body, timeout=30)
if not resp.ok:
    sys.exit(f"Activation failed: {resp.status_code} {resp.text}")
req_id = resp.json()["id"]

for _ in range(12):  # poll for up to ~2 minutes
    status = get(f"/roleManagement/directory/roleAssignmentScheduleRequests/{req_id}")["status"]
    print(status)
    if status in ("Provisioned", "Granted", "PendingApproval", "Denied", "Failed"):
        break
    time.sleep(10)

d. Bash + curl: approver grants a time-bound window to a workload identity

macOS TerminalLinuxWindows: Git Bash or WSLhuman approver

Run by a human who has activated Owner, User Access Administrator, or Role Based Access Control Administrator on the scope. This is pattern 2 from section 08: a time-bound active assignment, because workloads can't be eligible. On Windows, the pwsh az rest style in example b ports directly if you'd rather not use Git Bash.

bash · grant-workload-window.sh
#!/usr/bin/env bash
set -euo pipefail

SCOPE="/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-app-prod"  # placeholder
SP_OBJECT_ID="22222222-2222-2222-2222-222222222222"   # placeholder: enterprise app (service principal) object id
SUB="${SCOPE%%/resourceGroups/*}"
ROLE_ID="${SUB}/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c"  # Contributor
ARM="https://management.azure.com"
API="api-version=2020-10-01"

# Token from your own interactive az login; never echo it.
TOKEN=$(az account get-access-token --resource "$ARM" --query accessToken -o tsv)
REQ_ID=$(uuidgen 2>/dev/null || python3 -c 'import uuid; print(uuid.uuid4())')

curl -sS --fail-with-body -X PUT \
  "${ARM}${SCOPE}/providers/Microsoft.Authorization/roleAssignmentScheduleRequests/${REQ_ID}?${API}" \
  -H "Authorization: Bearer ${TOKEN}" -H "Content-Type: application/json" \
  --data @- <<EOF
{
  "properties": {
    "principalId": "${SP_OBJECT_ID}",
    "roleDefinitionId": "${ROLE_ID}",
    "requestType": "AdminAssign",
    "justification": "CHG-0000: approved production deployment window",
    "scheduleInfo": {
      "startDateTime": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
      "expiration": { "type": "AfterDuration", "duration": "PT1H" }
    }
  }
}
EOF
echo
# The assignment disappears by itself after one hour. There is nothing to clean up.

e. TypeScript / Node.js: pipeline preflight for an Azure window

WindowsmacOSLinux runnersworkload / app-only
typescript · pim-preflight.ts
// npm i @azure/identity      (Node 18+ ships fetch)
// Run with:  npx tsx pim-preflight.ts
// DefaultAzureCredential picks up the OIDC login done by azure/login or az login --federated-token.
import { DefaultAzureCredential } from "@azure/identity";

type Instance = {
  properties: { principalId: string; roleDefinitionId: string; endDateTime: string | null };
};

function fail(msg: string): never {
  console.error(`PIM preflight failed: ${msg}`);
  process.exit(1);
}

async function main(): Promise<void> {
  const scope = process.env.PIM_SCOPE ?? fail("PIM_SCOPE not set");
  const principalId = process.env.AZURE_PRINCIPAL_OBJECT_ID ?? fail("AZURE_PRINCIPAL_OBJECT_ID not set");
  const minMinutes = Number(process.env.PIM_MIN_MINUTES ?? "15");

  const cred = new DefaultAzureCredential();
  const { token } = await cred.getToken("https://management.azure.com/.default");

  const url = `https://management.azure.com${scope}/providers/Microsoft.Authorization/` +
    `roleAssignmentScheduleInstances?api-version=2020-10-01&$filter=atScope()`;
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (res.status === 401 || res.status === 403) fail("identity has no access at this scope - no active window");
  if (!res.ok) fail(`ARM returned HTTP ${res.status}`);

  const { value } = (await res.json()) as { value: Instance[] };
  const mine = value.filter((i) => i.properties.principalId === principalId);
  if (mine.length === 0) fail("no assignment for this identity at this scope");
  if (mine.some((i) => i.properties.endDateTime === null)) {
    fail("standing (non-expiring) assignment found - remove it; this job expects time-bound access");
  }

  const latestEnd = Math.max(...mine.map((i) => Date.parse(i.properties.endDateTime as string)));
  const minutesLeft = (latestEnd - Date.now()) / 60000;
  if (minutesLeft < minMinutes) fail(`window ends in ${minutesLeft.toFixed(0)} min (need ${minMinutes})`);
  console.log(`PIM window OK: ${minutesLeft.toFixed(0)} minutes remaining`);
}

main().catch((err) => fail(err instanceof Error ? err.message : String(err)));

f. C# + Azure.Identity: approver grants a time-bound Entra role to a workload

WindowsmacOS.NET 8human approver

The approver (an activated Privileged Role Administrator) signs in interactively through a public client app. The role's PIM settings for active assignments, such as maximum duration and required justification, still apply.

csharp · Program.cs
// dotnet new console -n PimGrant && cd PimGrant && dotnet add package Azure.Identity
// Placeholders come from environment variables; nothing secret is stored.
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Azure.Core;
using Azure.Identity;

string Env(string name) => Environment.GetEnvironmentVariable(name)
    ?? throw new InvalidOperationException($"{name} is not set");

var tenantId   = Env("AZURE_TENANT_ID");          // 00000000-0000-0000-0000-000000000000
var clientId   = Env("PIM_CLIENT_ID");            // public client app registration, no secret
var spObjectId = Env("WORKLOAD_SP_OBJECT_ID");    // 22222222-2222-2222-2222-222222222222
var roleId     = Env("PIM_ROLE_DEFINITION_ID");   // resolve from roleDefinitions; pick the narrowest role

var credential = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions
{
    TenantId = tenantId,
    ClientId = clientId,
});
var token = await credential.GetTokenAsync(new TokenRequestContext(
    new[] { "https://graph.microsoft.com/RoleAssignmentSchedule.ReadWrite.Directory" }));

using var http = new HttpClient { BaseAddress = new Uri("https://graph.microsoft.com/v1.0/") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);

var body = new
{
    action = "adminAssign",
    principalId = spObjectId,
    roleDefinitionId = roleId,
    directoryScopeId = "/",
    justification = "CHG-0000: approved automation window",
    scheduleInfo = new
    {
        startDateTime = DateTimeOffset.UtcNow.ToString("o"),
        expiration = new { type = "afterDuration", duration = "PT1H" },
    },
};

var response = await http.PostAsJsonAsync("roleManagement/directory/roleAssignmentScheduleRequests", body);
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
if (!response.IsSuccessStatusCode)
{
    Console.Error.WriteLine($"Grant failed: {(int)response.StatusCode} {json}");
    return 1;
}
Console.WriteLine($"Request {json.GetProperty("id")} status {json.GetProperty("status")}");
return 0;

g. Go + azidentity: pipeline preflight for an Entra window

WindowsmacOSLinux runnersworkload / app-only

The workload needs the Graph application permission RoleAssignmentSchedule.Read.Directory (read-only) to run this check.

go · main.go
// go mod init pimcheck
// go get github.com/Azure/azure-sdk-for-go/sdk/azidentity
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"time"

	"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
)

type instances struct {
	Value []struct {
		RoleDefinitionID string     `json:"roleDefinitionId"`
		EndDateTime      *time.Time `json:"endDateTime"`
	} `json:"value"`
}

func fail(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "PIM preflight failed: "+format+"\n", args...)
	os.Exit(1)
}

func main() {
	spID := os.Getenv("AZURE_PRINCIPAL_OBJECT_ID")   // 22222222-2222-2222-2222-222222222222
	roleID := os.Getenv("PIM_ROLE_DEFINITION_ID")    // role template id resolved earlier
	if spID == "" || roleID == "" {
		fail("AZURE_PRINCIPAL_OBJECT_ID and PIM_ROLE_DEFINITION_ID must be set")
	}

	cred, err := azidentity.NewDefaultAzureCredential(nil)
	if err != nil {
		fail("credential: %v", err)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	tok, err := cred.GetToken(ctx, policy.TokenRequestOptions{
		Scopes: []string{"https://graph.microsoft.com/.default"},
	})
	if err != nil {
		fail("token: %v", err)
	}

	q := url.Values{}
	q.Set("$filter", fmt.Sprintf("principalId eq '%s'", spID))
	endpoint := "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleInstances?" + q.Encode()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		fail("request: %v", err)
	}
	req.Header.Set("Authorization", "Bearer "+tok.Token)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		fail("graph call: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		fail("graph returned HTTP %d", resp.StatusCode)
	}

	var out instances
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		fail("decode: %v", err)
	}
	for _, inst := range out.Value {
		if inst.RoleDefinitionID != roleID {
			continue
		}
		if inst.EndDateTime == nil {
			fail("standing (non-expiring) assignment found; expected time-bound")
		}
		left := time.Until(*inst.EndDateTime)
		if left < 10*time.Minute {
			fail("window ends in %s", left.Round(time.Minute))
		}
		fmt.Printf("PIM window OK: %s remaining\n", left.Round(time.Minute))
		return
	}
	fail("no active assignment of the expected role for this identity")
}

h. CI sketches: OIDC, then PIM preflight, then the privileged job

Both sketches assume pattern 2: an approver has already granted a time-bound active assignment. Neither pipeline holds a secret. Client and tenant ids are configuration, not credentials. Lock the federated credential's subject to the protected environment or branch.

GitHub Actions

yaml · .github/workflows/privileged-deploy.yml
name: privileged-deploy
on:
  workflow_dispatch:

permissions:
  contents: read            # default for every job: read-only, no token minting

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production # required reviewers; federated credential subject pinned to this environment
    permissions:
      contents: read
      id-token: write       # this job only: request an OIDC token for Entra
    env:
      PIM_SCOPE: /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-app-prod
      AZURE_PRINCIPAL_OBJECT_ID: 22222222-2222-2222-2222-222222222222  # placeholder, not a secret
    steps:
      - uses: actions/checkout@v4

      - name: Azure login (OIDC, no client secret)
        uses: azure/login@v3
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          allow-no-subscriptions: true   # let preflight report a missing window clearly

      - name: PIM preflight (require a time-bound window)
        run: |
          set -euo pipefail
          URL="https://management.azure.com${PIM_SCOPE}/providers/Microsoft.Authorization/roleAssignmentScheduleInstances?api-version=2020-10-01&\$filter=atScope()"
          MINE="properties.principalId=='${AZURE_PRINCIPAL_OBJECT_ID}'"
          COUNT=$(az rest --method get --url "$URL" --query "length(value[?${MINE}])" -o tsv) \
            || { echo "::error::No access at ${PIM_SCOPE}. Ask an approver to grant a time-bound PIM window."; exit 1; }
          STANDING=$(az rest --method get --url "$URL" --query "length(value[?${MINE} && !properties.endDateTime])" -o tsv)
          [ "$COUNT" -gt 0 ] || { echo "::error::No PIM assignment for this identity."; exit 1; }
          [ "$STANDING" -eq 0 ] || { echo "::error::Standing assignment found; remove it."; exit 1; }
          echo "PIM window present."

      - name: Privileged step
        run: az deployment group create --resource-group rg-app-prod --template-file infra/main.bicep

      # No cleanup step: the time-bound assignment expires on its own.

GitLab CI

yaml · .gitlab-ci.yml
stages: [deploy]

variables:
  PIM_SCOPE: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-app-prod"
  AZURE_PRINCIPAL_OBJECT_ID: "22222222-2222-2222-2222-222222222222"  # placeholder, not a secret
  # AZURE_CLIENT_ID and AZURE_TENANT_ID: project CI/CD variables (identifiers, not secrets)

privileged-deploy:
  stage: deploy
  image: mcr.microsoft.com/azure-cli:latest   # pin by digest in production
  environment: production                      # protected environment with required approvals
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
  id_tokens:
    GITLAB_OIDC_TOKEN:
      aud: https://gitlab.com                  # must match the federated credential audience
  script:
    - az login --service-principal -u "$AZURE_CLIENT_ID" -t "$AZURE_TENANT_ID"
        --federated-token "$GITLAB_OIDC_TOKEN" --allow-no-subscriptions
    - |
      set -eu
      URL="https://management.azure.com${PIM_SCOPE}/providers/Microsoft.Authorization/roleAssignmentScheduleInstances?api-version=2020-10-01&\$filter=atScope()"
      MINE="properties.principalId=='${AZURE_PRINCIPAL_OBJECT_ID}'"
      COUNT=$(az rest --method get --url "$URL" --query "length(value[?${MINE}])" -o tsv) \
        || { echo "No access at ${PIM_SCOPE}: request a time-bound PIM window"; exit 1; }
      STANDING=$(az rest --method get --url "$URL" --query "length(value[?${MINE} && !properties.endDateTime])" -o tsv)
      [ "$COUNT" -gt 0 ] || { echo "No PIM assignment for this identity"; exit 1; }
      [ "$STANDING" -eq 0 ] || { echo "Standing assignment found; remove it"; exit 1; }
    - az deployment group create --resource-group rg-app-prod --template-file infra/main.bicep
  # No cleanup job: the assignment expires on schedule.

GitLab's federated-credential subject typically looks like project_path:<group>/<project>:ref_type:branch:ref:main. See GitLab's Azure OIDC guide. For GitHub, see Configuring OpenID Connect in Azure. Azure DevOps uses workload identity federation service connections.

10 · Reading

Resources

Every link below was opened and returned successfully when this page was built. Microsoft Learn first.