Security Azure

Entra ID Governance at Scale: Entitlement Management, Access Reviews, and Lifecycle Workflows

Most tenants treat identity governance as a portal you visit during an audit. That is how you end up with a contractor who still holds Owner on three production subscriptions, a security group whose membership nobody can explain, and 400 guest accounts that last signed in 18 months ago. Access creep is not a one-time cleanup problem — it is a rate problem. Grants happen continuously through tickets and group adds; removal happens almost never. Governance at scale means building the machinery that revokes at the same rate access is granted: entitlement management (access packages) for time-bound self-service, lifecycle workflows for joiner-mover-leaver automation, and access reviews for periodic re-attestation. This guide wires all three together with Microsoft Graph, ties them back to Privileged Identity Management (PIM) and audit evidence, and closes with a worked joiner-mover-leaver design you can adapt.

The three features are complementary, not interchangeable, and confusing them is the most common design error. Entitlement management governs requested access — the analyst who needs the BI workspace fills a form, a manager approves, and the grant self-destructs in 180 days. Lifecycle workflows govern event-driven access — the joiner gets default groups on day one, the leaver loses everything the moment employeeLeaveDateTime passes, no ticket required. Access reviews govern residual access — the mover who changed teams but kept an old group, or the manual grant made outside any package, gets re-attested on a schedule with a deny-by-default outcome. Miss any one of the three and a whole class of access decay slips through.

Everything here requires Microsoft Entra ID Governance (or the broader Entra Suite) licensing for the principals being governed — entitlement management, lifecycle workflows, and recurring reviews are not in the base P2 SKU. Get licensing settled before you design, because the feature you scope to 9,000 employees is the feature you must license for 9,000 employees. By the end you will be able to design catalogs and access packages with real policies, stand up JML workflows with the documented built-in tasks, schedule enforcing access reviews of every object type, model separation of duties as a preventive gate, integrate PIM so privilege is time-boxed twice over, and prove all of it ran from AuditLogs rather than assuming it from config state.

What problem this solves

The pain is quantifiable and it always shows up in the same three places: the audit finding, the incident, and the offboarding gap.

The audit finding is a SOX or ISO 27001 auditor asking “who has access to the payment system, who approved it, when was it last reviewed, and prove access is removed when someone leaves.” If your answer is a spreadsheet a human maintains, you fail — a manually maintained list is a detective control at best and stale the moment it is exported. Auditors increasingly demand preventive controls: not “we caught the toxic combination in a quarterly review” but “the toxic combination was structurally impossible to grant.” Entitlement management’s separation-of-duties gate is exactly that preventive control, and access reviews are the recurring re-attestation on top of it.

The incident is a compromised or malicious account with standing privilege. A contractor account with permanent Owner, phished on a Tuesday, is a breach on Tuesday. The mitigation is not detection — it is not having standing privilege to steal in the first place. Governance delivers access as PIM-eligible rather than active, so a compromised account that has not activated holds nothing, and even an activated session is time-boxed to hours.

The offboarding gap is the leaver who departs Friday and still has VPN, email, and three SaaS seats on Monday. Manual offboarding via a ticket queue is slow, error-prone, and invisible to audit. Lifecycle workflows fire on the HR-driven leave date and de-provision in real time — disable the account, strip groups and licenses, revoke refresh tokens — with a run history you can point an auditor at.

Who hits this: every regulated organization (finance, healthcare, government), every company past a few hundred employees where central IT cannot personally know who should have what, and every tenant with a large guest population from partner collaboration. The larger the tenant, the more the rate mismatch between granting and revoking dominates, and the more the machinery matters.

To frame the whole field, here is the failure-mode map — every governance gap, what it looks like in production, and the primary control that closes it:

Gap What it looks like in production Primary control Reinforced by
Access creep Permissions accumulate through tickets/group adds, never shrink Expiring access packages (expiration) Recurring access reviews
Orphaned access Leaver/mover keeps old entitlements after departure or transfer Lifecycle workflows on JML triggers Mover reviews; SoD
Standing privilege Permanent admin on high-value scopes PIM eligible assignments Reviews over eligible assignments
Toxic combination One person can both create and approve a payment Separation-of-duties (incompatible packages) SoD review over the sensitive package
Guest decay Partner guests never offboarded, invisible to HR Externally visible catalog auto-removal Inactive-guest reviews; connected orgs
Audit blindness Cannot prove who approved what, when, or that it was reviewed Catalog policies + review decision logs AuditLogs / decision exports
Ticket bottleneck Every access request routes through central IT Delegated catalogs + self-service packages Resource-owner approval

The mental shift: stop asking “who has access?” Start asking “who has access, granted by whom, justified how, expiring when, and last reviewed on what date?” If you cannot answer all five from a query, you do not have governance — you have a list.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already understand core Entra ID: users, groups (security and Microsoft 365), enterprise applications and app roles, directory roles, and the difference between P1 and P2. You should be comfortable with Microsoft Graph PowerShell (Connect-MgGraph, the Microsoft.Graph.Identity.Governance module) and reading JSON/@odata.type payloads, because the portal cannot express everything governance can and IaC-style repeatability lives in Graph. Familiarity with PIM for roles and groups is assumed at the level of “eligible vs active, activation with justification” — this article uses PIM but does not re-teach it.

This sits at the top of the identity governance stack. It assumes the primitives from Privileged Identity Management and PAM Architecture: Just-in-Time Access at Scale (this article delivers governed access into PIM), and it is the operational sibling of Zero Trust on Microsoft Entra: Conditional Access + PIM, Step by Step — governance decides what a principal may hold; Conditional Access decides under what conditions they may use it. For external identities it builds directly on Entra External ID B2B and Cross-Tenant Access: Guest Collaboration, Direct Connect, and Trust Settings Done Right. Governance is one pillar of the broader Zero Trust Architecture Blueprint: Identity, Network, and Data Pillars.

A quick map of who owns and confirms each control, so you route work to the right team:

Layer What lives here Who usually owns it Governance control it drives
HR / HCM source employeeHireDate, employeeLeaveDateTime, department, manager HR + Entra provisioning Lifecycle workflow triggers and scope
Catalog / resource owner Groups, apps, sites in a business domain The business domain team Access package design, delegated approval
Central IAM team Governance config, licensing, connected orgs Identity/security team Catalogs, SoD, review schedules, PIM
Resource / app owner An individual app or privileged group The service owner Approver of requests; reviewer of reviews
Compliance / audit Evidence, retention, control attestation GRC team Decision-log exports, AuditLogs queries
Security operations Detection of what governance failed to prevent SecOps Backstop KQL over de-provisioning events

Before any config, install the module and connect with the least-privilege scopes governance actually needs — four scopes cover almost everything below:

Install-Module Microsoft.Graph.Identity.Governance -Scope CurrentUser
Connect-MgGraph -Scopes @(
  "EntitlementManagement.ReadWrite.All",   # catalogs, packages, policies, assignments
  "AccessReview.ReadWrite.All",            # access review definitions and decisions
  "LifecycleWorkflows.ReadWrite.All",      # JML workflows, tasks, runs
  "RoleManagement.ReadWrite.Directory"     # PIM eligible role/group assignments
)

Core concepts

Six mental models make every later section obvious. Read these once; the rest is application.

Entitlement management is a hierarchy: catalog → resource → resource role → access package → policy. A catalog is a container of governed resources plus a delegation boundary — a resource owner manages their catalog without tenant-wide admin. A resource (a group, an app, a SharePoint site) is added to the catalog. Each resource exposes resource roles (group membership, an app role like “Reader”, a site permission level). An access package bundles specific resource roles into one requestable unit. A policy on the package defines who can request it, who approves, and how long the grant lasts. The policy is where governance lives; the package without a policy is inert.

Access packages come in two flavours: request policies and auto-assignment policies. A request policy is self-service — a user (or a request on their behalf) asks, an approver decides, and the grant is time-bound. An auto-assignment policy has no request at all — it is a membership rule (department -eq 'Finance') and every matching user is automatically granted the package’s roles, losing them when they no longer match. Request policies fit discretionary access; auto-assignment fits birthright access that should track an attribute.

Access reviews are a scope, reviewers, and settings. An accessReviewScheduleDefinition reviews a scope (a group’s members, an app’s assigned users, a role’s members, an access package’s assignments), assigns reviewers (owners, managers, the users themselves, or named people), and applies settings (recurrence, duration, decision helpers, and — critically — what happens on non-response). The two settings that turn a review from a survey into a control are autoApplyDecisionsEnabled and defaultDecision; without them, “remove” decisions rot in a report nobody actions.

Lifecycle workflows are a trigger, an execution scope, and ordered tasks. A workflow has a trigger (a time offset relative to an attribute like employeeHireDate or employeeLeaveDateTime, or an on-demand run), an execution scope (a rule selecting which users it applies to), and an ordered list of tasks referenced by stable taskDefinitionId GUIDs. Categories are joiner, mover, and leaver; the same task (e.g. “Add user to groups”) can appear in multiple categories.

PIM is orthogonal and stacks on top. Reviews and packages govern which roles a person can hold; PIM governs when they hold them. The clean pattern is that governance makes someone a member of a PIM-enabled group, and that group is eligible (not active) for a privileged role. Governance controls group membership; PIM controls activation. Two expirations then stack — eligibility (governed, months) and activation (PIM, hours).

Connected organizations extend all of this to partners. A connected organization is a registered external tenant (or domain) whose users may be invited into your catalogs. It lets an access package’s requestor scope include “users from partner X’s tenant” without opening self-service to the entire internet, and it makes the catalog the lifecycle boundary for those guests.

The vocabulary in one table

Pin down every moving part before the deep sections. The glossary repeats these for lookup; this is the mental model side by side:

Concept One-line definition Where it lives Why it matters
Catalog Container of governed resources + delegation boundary Entitlement management Splits ownership; kills the central-IT bottleneck
Resource A group/app/site added to a catalog In a catalog The thing access is granted to
Resource role A specific role on a resource (member, app role, permission level) On a catalog resource The granularity of a grant
Access package A bundle of resource roles that is requestable In a catalog The unit a user requests or is auto-assigned
Assignment policy Who can request/get it, approval, expiry On an access package Where governance actually lives
Request policy Policy with self-service request + approval On an access package Discretionary, time-bound access
Auto-assignment policy Attribute-rule policy, no request On an access package Birthright access that tracks an attribute
Separation of duties Incompatible package/group at request time On an access package Preventive toxic-combination gate
Access review Scheduled re-attestation of a scope Identity Governance Removes residual/creeping access
Decision helper Sign-in-based approve/deny recommendation Review setting Scales reviewer judgement
Lifecycle workflow Trigger + scope + tasks for JML Identity Governance Event-driven provision/deprovision
Task definition A built-in workflow action (GUID) Workflow task The verb: disable, remove groups, etc.
Custom task extension A workflow task that calls a Logic App Workflow task Reach systems Graph cannot
Connected organization Registered external tenant/domain Entitlement management Governed B2B onboarding
PIM-enabled group Group whose membership can be eligible/active PIM for Groups The bridge from governance to JIT privilege

Which control for which decay — the routing table

The single most useful decision you make is which of the three features owns a given access problem. Get this wrong and you build the machine but point it at the wrong door:

Access problem Owning control Why not the others
“Analyst needs BI workspace for a project” Access package (request policy) Discretionary, needs approval + expiry — not birthright, not event-driven
“All Finance staff get the Finance apps” Access package (auto-assignment) Tracks an attribute; a request form would be pure friction
“New hire needs default groups on day one” Lifecycle workflow (joiner) Fires on employeeHireDate; no human request should gate day-one access
“Leaver must lose everything on departure” Lifecycle workflow (leaver) Fires on employeeLeaveDateTime; reviews are too slow, packages don’t cover manual grants
“Mover changed teams, has stale access” Lifecycle workflow (mover) + review Workflow strips known groups; review catches everything else
“Prove admin group still needs its members” Access review of the group Periodic re-attestation is exactly a review’s job
“One person can create and approve payments” Separation of duties on the package Preventive gate; a review only catches it after the fact
“Guest hasn’t signed in for 90 days” Inactive-guest access review No employeeLeaveDateTime exists, so no workflow fires
“Owner role held permanently” PIM eligibility + review of eligibility Governance delivers eligibility; PIM time-boxes activation

Design catalogs, access packages, and policies

Entitlement management’s hierarchy exists for one reason: to let a business domain own its own access without central IT in the loop for every request. The biggest design error is one giant catalog owned by central IT — that recreates the ticket bottleneck you are trying to kill. Build catalogs per business domain (Finance, Engineering, Data Platform) and delegate ownership to those teams.

Catalogs and delegation

A catalog carries a delegation boundary. Users granted the Catalog owner role manage resources and packages inside it; Catalog reader can view. Critically, adding a directory role as a resource, or governing privileged resources, may require the actor to hold a matching directory role — you cannot delegate away more than you hold.

# Catalog scoped to a business domain, delegable to its owners
$catalog = New-MgEntitlementManagementCatalog -BodyParameter @{
  displayName         = "Data Platform"
  description         = "Governed access for the data platform domain"
  isExternallyVisible = $false   # internal only; flip to $true for B2B sharing via connected orgs
}

# Add a Microsoft Entra group as a governed resource in the catalog
New-MgEntitlementManagementResourceRequest -BodyParameter @{
  requestType = "AdminAdd"
  catalog     = @{ id = $catalog.Id }
  resource    = @{
    originId     = "f3f9a4d2-0000-0000-0000-000000000001"   # group objectId
    originSystem = "AadGroup"
  }
}

The catalog design choices and their consequences:

Catalog setting Values Default When to change Trade-off / gotcha
isExternallyVisible true / false false Enable when guests from connected orgs must request packages External visibility exposes the catalog to configured external users only, not the world
Catalog scope One per business domain (your design) Split when a team should self-manage Too few = bottleneck; too many = sprawl and duplicated resources
Delegated roles Catalog owner / reader None (central) Delegate to domain owners early Owner can add resources they themselves can administer — not a privilege-escalation bypass
Resource types Group, app, SharePoint site (added per resource) Add the resources the domain grants Directory roles as resources need matching privilege to add
Resource origin AadGroup, AadApplication, SharePointOnline Match the resource kind A resource lives in exactly one catalog at a time

Resources and resource roles

Once a resource is in the catalog, you attach one or more of its resource roles to an access package. For a group the role is membership; for an app it is a specific app role (e.g. “Reports.Read”); for a SharePoint site it is a permission level. This is the granularity control — a package can grant “member of group X and the Reader app role on app Y and Contribute on site Z” as one unit.

Resource type originSystem Resource roles available Typical use
Security / M365 group AadGroup Member (and owner, via role) Group-based access, Teams, licensing
Enterprise application AadApplication The app’s defined app roles SaaS/line-of-business app entitlements
SharePoint Online site SharePointOnline Site permission levels Document/site access

Access packages and assignment policies

Create the package, then bind a policy. The policy is where expiration, requestApprovalSettings, and requestorSettings live — these are not optional decorations, they are the control.

$ap = New-MgEntitlementManagementAccessPackage -BodyParameter @{
  displayName = "Data Platform - Analyst (read)"
  description = "Read access to the data lake and BI workspace"
  catalog     = @{ id = $catalog.Id }
}

New-MgEntitlementManagementAssignmentPolicy -BodyParameter @{
  displayName   = "Analyst - 180 day, manager approval"
  accessPackage = @{ id = $ap.Id }
  # Who may request: all members (scope can be narrowed to specific groups or connected orgs)
  requestorSettings = @{
    enableTargetsToSelfAddAccess    = $true
    enableTargetsToSelfRemoveAccess = $true
    allowCustomAssignmentSchedule   = $true
    onBehalfRequestorsAllowed       = $false
  }
  # Approval: single-stage, the requestor's manager, justification required, auto-deny after 7 days
  requestApprovalSettings = @{
    isApprovalRequiredForAdd = $true
    stages = @(
      @{
        durationBeforeAutomaticDenial   = "P7D"
        isApproverJustificationRequired = $true
        primaryApprovers = @(
          @{ "@odata.type" = "#microsoft.graph.requestorManager"; managerLevel = 1 }
        )
      }
    )
  }
  # Time-bound: access expires, forcing re-request
  expiration = @{ type = "afterDuration"; duration = "P180D" }
}

The expiration block is the antidote to access creep. With afterDuration set to P180D, every grant self-destructs in 180 days; if the analyst still needs it, they re-request and a manager re-approves. No standing analyst access exists by default. The expiration options:

expiration.type Meaning Extra fields Use when
afterDuration Grant lasts a fixed span, then expires duration (ISO 8601, e.g. P180D) Most discretionary access — the default choice
afterDateTime Grant expires on a fixed date endDateTime Project-bound access with a known end
noExpiration Grant never expires by itself none Birthright access reviewed by another control; avoid for privilege

The requestor-settings knobs that shape self-service:

Setting Values Effect Gotcha
enableTargetsToSelfAddAccess true/false Whether users may request for themselves If false, only admins/sponsors add — not self-service
enableTargetsToSelfRemoveAccess true/false Users can drop their own access early Good hygiene; lets users self-reduce
allowCustomAssignmentSchedule true/false Requestor picks start/end within limits Bounded by the policy’s expiration
onBehalfRequestorsAllowed true/false Managers/sponsors request for others Needed for delegated onboarding
Scope (allowedRequestors) All members / specific groups / connected orgs / all external Who is in the requestor audience Narrow this for sensitive packages

Approvals, separation of duties, and expiration

Two policy capabilities turn a request form into a control: multi-stage approval and separation of duties.

Multi-stage approval

A single stages object is one approval hop. Add a second object to the array to chain independent approvers — use it when the resource owner and the requestor’s manager must both sign off, or when a security review follows a business approval. Each stage has its own timeout, its own approvers, and optional escalation.

# Two-stage approval: manager first, then the resource owner, with escalation on stage 2
requestApprovalSettings = @{
  isApprovalRequiredForAdd = $true
  stages = @(
    @{  # Stage 1 — requestor's manager
      durationBeforeAutomaticDenial   = "P3D"
      isApproverJustificationRequired = $true
      primaryApprovers = @(
        @{ "@odata.type" = "#microsoft.graph.requestorManager"; managerLevel = 1 }
      )
    }
    @{  # Stage 2 — named resource owner, escalate to a backup if idle
      durationBeforeAutomaticDenial   = "P4D"
      isApproverJustificationRequired = $true
      isEscalationEnabled             = $true
      escalationTimeInMinutes         = 1440
      primaryApprovers = @(
        @{ "@odata.type" = "#microsoft.graph.singleUser"; userId = "1111...owner" }
      )
      escalationApprovers = @(
        @{ "@odata.type" = "#microsoft.graph.singleUser"; userId = "2222...backup" }
      )
    }
  )
}

The approver types and when each fits:

Approver @odata.type Who approves Extra field Best for
requestorManager The requestor’s manager managerLevel (1 = direct) Default first stage; business accountability
singleUser A named person userId A specific resource owner
groupMembers Any member of a group groupId An approval rota / on-call queue
connectedOrganizationMembers Sponsors of a connected org connectedOrganizationId Approving external requestors
internalSponsors / externalSponsors Designated sponsors Sponsor-based B2B approval

Approval-stage settings that matter beyond who approves:

Setting Effect Typical value Note
durationBeforeAutomaticDenial Timeout → auto-deny if no decision P3DP7D Keeps requests from stalling forever
isApproverJustificationRequired Approver must type a reason true for privilege The reason becomes audit evidence
isEscalationEnabled + escalationTimeInMinutes Route to backup after idle time true, 1440 (1 day) Prevents a single absent approver blocking
isRequestorJustificationRequired (requestor side) Requestor must justify true Pairs with approver justification

Separation of duties (SoD)

Separation of duties blocks toxic combinations at request time, before the grant exists. If holding both “Vendor Onboarding” and “Payment Approval” enables fraud, mark them incompatible: a user assigned to one cannot even request the other. This is far stronger than a detective control in a SIEM — the conflicting access never gets granted, so there is no window of exposure to detect. The difference between a guardrail and an alarm.

# Mark another access package as incompatible with this one (SoD)
New-MgEntitlementManagementAccessPackageIncompatibleAccessPackageByRef `
  -AccessPackageId $paymentApprovalPkgId `
  -BodyParameter @{
    "@odata.id" = "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/accessPackages/$vendorOnboardingPkgId"
  }

# You can also make membership of a security group incompatible with a package
New-MgEntitlementManagementAccessPackageIncompatibleGroupByRef `
  -AccessPackageId $paymentApprovalPkgId `
  -BodyParameter @{
    "@odata.id" = "https://graph.microsoft.com/v1.0/groups/$contractorsGroupId"
  }

Two subtleties decide whether SoD actually holds:

SoD mechanism Blocks Set on Note
Incompatible access package Requesting pkg B while holding pkg A Each package (both directions) The core toxic-pair control
Incompatible group Requesting the package while in group G The package Useful when the conflict is a group, e.g. “Contractors”
Override (grace) Temporarily allow a blocked request Request-level For exceptional, documented cases only

Auto-assignment: access that follows an attribute

Some access is birthright — every Finance employee should have the Finance apps, full stop, with no request form. That is an auto-assignment policy: instead of requestorSettings, it carries a specificAllowedTargets membership rule, and every user matching the rule is granted the package’s roles automatically, losing them when they stop matching. There is no approval because there is no request — the rule is the approval.

New-MgEntitlementManagementAssignmentPolicy -BodyParameter @{
  displayName   = "Finance staff - birthright apps (auto)"
  accessPackage = @{ id = $financeAppsPkgId }
  # Auto-assignment: a filter over user attributes, no request/approval
  specificAllowedTargets = @(
    @{
      "@odata.type" = "#microsoft.graph.attributeRuleMembers"
      membershipRule = "(user.department -eq `"Finance`") and (user.employeeType -eq `"Employee`")"
    }
  )
  automaticRequestSettings = @{
    requestAccessForAllowedTargets = $true
    removeAccessWhenTargetLeavesAllowedTargets = $true   # de-provision when the rule stops matching
    gracePeriodBeforeAccessRemoval = "P7D"               # keep access N days after they fall out
  }
  expiration = @{ type = "noExpiration" }   # governed by the rule, not a timer
}

Request policy vs auto-assignment, decided by the nature of the access:

Dimension Request policy Auto-assignment policy
Trigger User (or on-behalf) requests Matches an attribute rule
Approval One or more stages None — the rule is the gate
Expiration Timer (afterDuration/afterDateTime) noExpiration; removed when rule stops matching
Best for Discretionary, project, elevated access Birthright/role-based access that tracks HR data
Removal On expiry or self-remove Automatic on rule mismatch (+ grace period)
Audit story “Requested, approved by X, expires Y” “Held because attribute matched on date Z”
Data dependency Low High — only as good as the attribute source

The removeAccessWhenTargetLeavesAllowedTargets flag is what makes auto-assignment a governance control rather than a one-way grant: when the mover’s department changes away from Finance, the package (and its roles) are removed after the grace period — an automatic, attribute-driven de-provision that complements the mover lifecycle workflow.

Automate joiner-mover-leaver with lifecycle workflows

Access packages handle requested and attribute-driven access. Lifecycle workflows handle event-driven access tied to employment milestones — the joiner who needs default groups on day one, and far more importantly, the leaver who must lose everything the moment they depart.

A workflow has a trigger (a time offset relative to an attribute like employeeHireDate or employeeLeaveDateTime), an execution scope (a rule selecting which users it applies to), and an ordered list of tasks referenced by stable taskDefinitionId GUIDs. The attribute values must actually be populated — usually by HR-driven inbound provisioning (Workday, SuccessFactors) or Entra Connect — or the workflow has nothing to fire on.

The leaver workflow — real-time offboard

The highest-value workflow. It fires on the employee leave date and de-provisions cleanly: disable, strip groups and licenses, revoke tokens. These GUIDs are the documented built-in task definitions.

New-MgIdentityGovernanceLifecycleWorkflow -BodyParameter @{
  displayName  = "Leaver - real-time offboard"
  description  = "Disable, strip groups/licenses, revoke tokens on departure"
  category     = "leaver"
  isEnabled    = $true
  executionConditions = @{
    "@odata.type" = "#microsoft.graph.identityGovernance.triggerAndScopeBasedConditions"
    scope = @{
      "@odata.type" = "#microsoft.graph.identityGovernance.ruleBasedSubjectSet"
      rule = "(department -eq 'Engineering')"
    }
    trigger = @{
      "@odata.type" = "#microsoft.graph.identityGovernance.timeBasedAttributeTrigger"
      timeBasedAttribute = "employeeLeaveDateTime"
      offsetInDays       = 0                 # fire exactly on the leave date
    }
  }
  tasks = @(
    @{ category = "leaver"; displayName = "Disable user account"; isEnabled = $true
       taskDefinitionId = "1dfdfcc7-52fa-4c2e-bf3a-e3919cc12950"; arguments = @() }
    @{ category = "leaver"; displayName = "Remove user from all groups"; isEnabled = $true
       taskDefinitionId = "b3a31406-2a15-4c9a-b25b-a658fa5f07fc"; arguments = @() }
    @{ category = "leaver"; displayName = "Remove all licenses for user"; isEnabled = $true
       taskDefinitionId = "8fa97d28-3e52-4985-b3a9-a1126f9b8b4e"; arguments = @() }
    @{ category = "leaver"; displayName = "Revoke all refresh tokens for user"; isEnabled = $true
       taskDefinitionId = "509589a4-0466-4471-829e-49c5e502bdee"; arguments = @() }
  )
}

Order matters: revoke tokens after disabling and stripping access, so an in-flight session cannot re-acquire anything before the tokens are killed. The built-in task catalogue you will actually use:

Task taskDefinitionId Category Notes
Send welcome email to new hire 70b29d51-b59a-4773-9280-8841dfd3f2ea joiner Customizable HTML; fires pre-hire
Add user to groups 22085229-5809-45e8-97fd-270d28d66910 joiner, mover Argument = target group IDs
Add user to teams e440ffb9-... (per docs) joiner, mover Teams membership
Enable user account 6fc52c9d-398b-4305-9763-15f42c1676fc joiner, leaver Reverse of disable
Run a custom task extension 4262b724-8dba-4fad-afc3-43fcbb497a0e / d79d1fcc-16be-490c-a865-f4533b1639ee all Calls an Azure Logic App (verify the current GUID in docs)
Disable user account 1dfdfcc7-52fa-4c2e-bf3a-e3919cc12950 leaver, mover Blocks sign-in
Remove user from selected groups 1953a66c-751c-45e5-8bfe-01462c70da3c mover, leaver Argument = specific groups
Remove user from all groups b3a31406-2a15-4c9a-b25b-a658fa5f07fc leaver Wholesale group strip
Remove all licenses for user 8fa97d28-3e52-4985-b3a9-a1126f9b8b4e leaver Frees license, may cascade to mailbox
Remove user from all teams 2891e2b0-... (per docs) leaver Teams cleanup
Revoke all refresh tokens 509589a4-0466-4471-829e-49c5e502bdee leaver Kills live sessions
Delete user account 8d18588d-9ad3-4c0d-8e5f-... leaver Terminal; usually gated far after departure

Treat the GUIDs above as the reference set, but confirm the exact taskDefinitionId in the current Microsoft docs before pasting into production — a handful are versioned and a wrong GUID makes the task silently invalid. The three leaver GUIDs and the two joiner GUIDs shown in the code blocks are the stable, commonly used ones.

The joiner workflow — ready before day one

The joiner side triggers ahead of the hire date so accounts are ready on day one, not provisioned at 9 a.m. while the new hire waits. Set offsetInDays = -7 against employeeHireDate.

executionConditions = @{
  "@odata.type" = "#microsoft.graph.identityGovernance.triggerAndScopeBasedConditions"
  scope = @{
    "@odata.type" = "#microsoft.graph.identityGovernance.ruleBasedSubjectSet"
    rule = "(employeeType -eq 'Employee')"
  }
  trigger = @{
    "@odata.type" = "#microsoft.graph.identityGovernance.timeBasedAttributeTrigger"
    timeBasedAttribute = "employeeHireDate"
    offsetInDays       = -7                  # provision a week before start
  }
}
tasks = @(
  @{ category = "joiner"; displayName = "Add to default groups"; isEnabled = $true
     taskDefinitionId = "22085229-5809-45e8-97fd-270d28d66910"
     arguments = @( @{ name = "groupID"; value = "aaaa-...all-staff" } ) }
  @{ category = "joiner"; displayName = "Send welcome email"; isEnabled = $true
     taskDefinitionId = "70b29d51-b59a-4773-9280-8841dfd3f2ea"; arguments = @() }
)

The mover workflow — the subtle one

The mover is the workflow people forget, and the one reviews were invented to backstop. A mover changed department, title, or manager; some access should follow them (new team’s groups), some should be stripped (old team’s groups), and much is invisible to any rule. Mover workflows trigger on a change to an attribute rather than a fixed date, so the trigger model differs — you scope by the attribute change and run on-demand or scheduled reconciliation, then let an access review catch the residue the workflow’s known-group list cannot.

Triggers and their attributes, side by side:

Trigger type @odata.type suffix Attribute / basis Offset JML fit
Time-based attribute timeBasedAttributeTrigger employeeHireDate, employeeLeaveDateTime offsetInDays (±) Joiner (−7), Leaver (0)
Attribute-change attributeChangeTrigger e.g. department, jobTitle n/a (on change) Mover
On-demand run action, no trigger manual selection of users n/a Ad-hoc reprocessing/testing

Execution-scope rule attributes (a subset — the rule uses the same filter syntax as dynamic groups):

Attribute Example rule fragment Use
department (department -eq 'Finance') Scope to a business unit
employeeType (employeeType -eq 'Contractor') Different flows for staff vs contractors
jobTitle (jobTitle -startsWith 'Senior') Seniority-based provisioning
accountEnabled (accountEnabled -eq true) Exclude already-disabled accounts
companyName (companyName -eq 'Acme EU') Multi-entity tenants

Custom task extensions for systems Graph cannot reach

Built-in tasks cover Entra-native objects. For anything external — de-provisioning a Salesforce seat, releasing a phone number, archiving a mailbox via a third party, notifying a ticketing system — use the Run a Custom Task Extension task, which calls an Azure Logic App. Two execution modes matter:

Aspect Launch and continue Launch and wait
Workflow behaviour Proceeds immediately Pauses until callback or timeout
Downstream gating No Yes — later tasks depend on success
Failure handling Fire-and-forget Callback reports success/failure
Use for Notifications, best-effort side effects De-provision that must complete first
Limit Up to 100 custom task extensions per tenant Same; treat the Logic App as the integration boundary

Keep the workflow declarative and push all integration logic into the Logic App — that keeps the JML model readable and the vendor-specific glue in one place.

Recurring access reviews for groups, apps, roles, and packages

Lifecycle workflows catch the clean leaver. They do not catch the mover who changed teams but kept their old group, or access granted manually outside any package. That residue is what access reviews re-attest on a schedule.

A review is an accessReviewScheduleDefinition: a scope (what is reviewed), reviewers (who decides), and settings (recurrence, duration, decision helpers, and what happens to non-responses). The two settings that make a review a control rather than a survey are autoApplyDecisionsEnabled and defaultDecision — without auto-apply, “remove” decisions sit in a report nobody actions.

Review of a group’s membership

$params = @{
  displayName             = "Quarterly - Data Platform Admins group"
  descriptionForAdmins    = "Re-attest membership of the data platform admin group"
  descriptionForReviewers = "Confirm each member still needs admin on the data platform."
  scope = @{
    "@odata.type" = "#microsoft.graph.accessReviewQueryScope"
    query     = "/groups/02f3bafb-448c-487c-88c2-5fd65ce49a41/transitiveMembers"
    queryType = "MicrosoftGraph"
  }
  reviewers = @(
    @{ query = "/groups/02f3bafb-448c-487c-88c2-5fd65ce49a41/owners"; queryType = "MicrosoftGraph" }
  )
  settings = @{
    mailNotificationsEnabled        = $true
    reminderNotificationsEnabled    = $true
    justificationRequiredOnApproval = $true
    recommendationsEnabled          = $true    # sign-in-based approve/deny hints (decision helper)
    instanceDurationInDays          = 14
    autoApplyDecisionsEnabled       = $true     # ENFORCE the outcome
    defaultDecisionEnabled          = $true
    defaultDecision                 = "Deny"    # no response = revoke (deny-by-default)
    recurrence = @{
      pattern = @{ type = "absoluteMonthly"; dayOfMonth = 1; interval = 3 }   # quarterly
      range   = @{ type = "noEnd"; startDate = "2026-07-01" }
    }
  }
}
New-MgIdentityGovernanceAccessReviewDefinition -BodyParameter $params

defaultDecision = "Deny" is the load-bearing choice. A reviewer who ignores the review removes the access by inaction. That is the only model that scales: it makes keeping access the deliberate act, not losing it. For lower-stakes reviews you may prefer "None" (no change on silence), but for privileged groups, deny-by-default is correct.

What you can review, and the scope for each

Each object type has a distinct scope query. Getting the query right is most of the work:

Review target Scope query pattern @odata.type Reviewer choices
Group members /groups/{id}/transitiveMembers accessReviewQueryScope Group owners, managers, self, named
Application (assigned users) /servicePrincipals/{id}/appRoleAssignedTo accessReviewQueryScope App owners, managers, named
Directory role members (active) PIM role scope query principalResourceMembershipsScope Privileged-role admins, named
PIM-eligible role assignments role eligibility scope principalResourceMembershipsScope Same; reviews eligibility, not active
PIM group (eligible/active) group + member/owner accessId group scope Group owners
Access package assignments accessPackageAssignments filter accessReviewQueryScope Package’s policy reviewers, managers, self
Inactive users (guests) members filtered by inactivity accessReviewInactiveUsersQueryScope Owners
All Microsoft 365 groups (bulk) groups filter + per-group instances scope + instanceEnumerationScope Owners per group

Reviewer, recurrence, and decision-helper reference

Who reviews:

Reviewer type Query / config Best for Risk
Resource/group owners /groups/{id}/owners or ./owners Owner knows who should have access Owner may rubber-stamp; pair with helpers
Managers managerLevel / manager query Membership tied to reporting line Manager may not know app specifics
Self-review self scope Low-stakes attestation, “do you still need this?” Users almost never remove their own access
Named reviewers singleUser list Security team owns a sensitive review Bottleneck if the list is short
Fallback reviewers fallbackReviewers When primary reviewer is absent/unset Configure so a review never stalls

Recurrence patterns:

Recurrence pattern.type Key fields Typical cadence
One-time (no recurrence block) Ad-hoc cleanup, existing-holder resolution
Weekly weekly interval, daysOfWeek High-churn sensitive access
Monthly absoluteMonthly dayOfMonth, interval Guests, standard privileged groups
Quarterly absoluteMonthly + interval: 3 as above SOX-style re-attestation
Semi-annual / annual absoluteMonthly + interval: 6/12 as above Low-risk, broad populations
Range end range.type = noEnd / endDate / numbered startDate, endDate, numberOfOccurrences noEnd for standing programs

Decision helpers and non-response behaviour — the settings that make a review scale:

Setting Values Effect Recommendation
recommendationsEnabled true/false Show sign-in-based approve/deny hints true — surfaces stale access to reviewers
recommendationLookBackDuration ISO duration (e.g. P30D) Window used to judge “inactive” Match your inactivity policy
autoApplyDecisionsEnabled true/false Apply decisions automatically at end true — the difference between control and report
defaultDecisionEnabled + defaultDecision Deny/Approve/None Outcome for non-responders Deny for privilege; None for low-stakes
justificationRequiredOnApproval true/false Approver must justify to keep access true — makes “approve” a deliberate act
instanceDurationInDays integer How long each cycle stays open 7–14 days typically
mailNotificationsEnabled / reminderNotificationsEnabled true/false Notify/remind reviewers Both true

For application access reviews, scope to the app’s assigned users; for privileged role reviews, prefer reviewing the PIM-eligible assignments (see the PIM section) over active role members — otherwise you review only who is currently active, missing everyone who is merely eligible.

Govern guests and external access

Guests are the highest-decay population in any tenant — project-based, rarely offboarded by the inviting employee, and invisible to HR-driven lifecycle workflows because they have no employeeLeaveDateTime. Three controls contain them: connected organizations for governed onboarding, inactive-guest reviews for decay, and externally visible catalogs for automatic lifecycle boundaries.

Connected organizations — governed B2B onboarding

A connected organization registers a partner tenant (or domain) so its users can request packages from your externally visible catalogs, with the partner’s sponsors as approvers. This is what lets you offer self-service to known partners without opening the door to any guest from any tenant.

# Register a partner tenant as a connected organization
New-MgEntitlementManagementConnectedOrganization -BodyParameter @{
  displayName = "Contoso Partners"
  description = "External auditors and co-dev partner"
  state       = "configured"
  identitySources = @(
    @{
      "@odata.type" = "#microsoft.graph.azureActiveDirectoryTenant"
      tenantId    = "contoso.onmicrosoft.com-tenant-guid"
      displayName = "Contoso"
    }
  )
}

You then scope an access package’s requestors to that connected organization, and set its sponsors as the approvers, so external requests are governed exactly like internal ones.

Inactive-guest reviews

Scope a review to guests who have not signed in for a defined window using the inactive-users scope, and let the team owners decide. Auto-apply with deny-by-default so silence removes the stale guest.

$guestReview = @{
  displayName          = "Inactive guests on M365 groups (90d)"
  descriptionForAdmins = "Remove guest access to groups with no recent sign-in."
  instanceEnumerationScope = @{
    "@odata.type" = "#microsoft.graph.accessReviewQueryScope"
    query     = "/groups?`$filter=(groupTypes/any(c:c+eq+'Unified'))"
    queryType = "MicrosoftGraph"
  }
  scope = @{
    "@odata.type" = "#microsoft.graph.accessReviewInactiveUsersQueryScope"
    query            = "./members/microsoft.graph.user/?`$filter=(userType eq 'Guest')"
    queryType        = "MicrosoftGraph"
    inactiveDuration = "P90D"
  }
  reviewers = @( @{ query = "./owners"; queryType = "MicrosoftGraph" } )
  settings  = @{
    mailNotificationsEnabled  = $true
    instanceDurationInDays    = 7
    autoApplyDecisionsEnabled = $true
    defaultDecisionEnabled    = $true
    defaultDecision           = "Deny"
    recurrence = @{ pattern = @{ type = "absoluteMonthly"; dayOfMonth = 1; interval = 1 }
                    range = @{ type = "noEnd"; startDate = "2026-07-01" } }
  }
}
New-MgIdentityGovernanceAccessReviewDefinition -BodyParameter $guestReview

Catalog as the lifecycle boundary

Combine an externally visible catalog with entitlement management’s external-user settings so guests provisioned through the catalog are auto-removed when their last access package assignment expires — closing the orphaned-guest gap by construction. The catalog, not a manual cleanup, becomes the lifecycle boundary for external identities.

Guest lifecycle stage Control Effect
Onboard Connected org + externally visible catalog Governed request, sponsor approval
Steady state Package expiration Access self-expires unless re-requested
Decay Inactive-guest review Stale guests removed on non-response
Offboard Catalog external-user setting: remove on last assignment expiry Guest object cleaned up automatically
Boundary Cross-tenant access settings Which partner tenants may be invited in/out

The external-user removal behaviours a catalog offers:

Behaviour What it does When to use
Block external user from signing in Disables sign-in when last assignment ends Retain object for audit, deny access
Remove external user Deletes the guest object when last assignment ends Full cleanup; the strong default for project guests
Days before removal Grace window after last assignment expires Buffer against premature deletion

For the full cross-tenant access model — inbound/outbound trust, MFA and compliant-device trust, and B2B direct connect — see Entra External ID B2B and Cross-Tenant Access: Guest Collaboration, Direct Connect, and Trust Settings Done Right.

Integrate with PIM for time-bound privilege

Reviews and packages govern which roles a person can hold. PIM governs when they hold them. For any high-value role, the assignment delivered by governance should be eligible, not active — the person activates just-in-time, with justification and a time box.

The clean pattern: an access package or lifecycle task makes someone a member of a PIM-enabled group, and that group is itself eligible (not active) for the privileged role. Governance controls group membership; PIM controls activation. Create the eligible group-to-member relationship via the privileged access group eligibility schedule request:

New-MgIdentityGovernancePrivilegedAccessGroupEligibilityScheduleRequest -BodyParameter @{
  accessId      = "member"                               # eligible as a member of the group
  principalId   = "aaaaaaaa-0000-0000-0000-000000000001" # the user
  groupId       = "bbbbbbbb-0000-0000-0000-000000000002" # PIM-enabled privileged group
  action        = "adminAssign"
  scheduleInfo  = @{
    startDateTime = (Get-Date).ToString("o")
    expiration    = @{ type = "afterDuration"; duration = "P90D" }   # eligibility itself expires
  }
  justification = "Quarterly eligible assignment via governance"
}

Two expirations now stack: eligibility expires in 90 days (governed, re-attested by a review), and each activation expires in hours (PIM policy). A compromised account that has not activated holds no standing privilege, and even an active session is time-boxed. Then run an access review over the eligible assignments themselves so eligibility does not become the new standing privilege — PIM exposes reviews for eligible role and group assignments precisely for this.

How governance and PIM divide responsibility:

Question Answered by Mechanism
Which role can this person hold? Governance Access package / lifecycle task → group membership
For how long may they be eligible? Governance Eligibility expiration (e.g. P90D), reviewed
When do they actually hold it? PIM Activation with justification/MFA
For how long per activation? PIM Activation max duration (hours)
Is the eligibility still justified? Governance Access review over eligible assignments
Was activation appropriate? PIM + SecOps PIM audit / activation alerts

The two-expiration stack, concretely:

Layer Expires after Governed by If account is compromised
Group membership (via package) Package expiry (e.g. 180d) Entitlement management + review Loses eligibility at expiry
Eligibility (group → role) Eligibility expiry (e.g. 90d) PIM assignment + review Holds nothing until activation
Activation (role active) Hours (PIM policy) PIM activation Even active session is time-boxed

For the full PIM design — role settings, activation policies, approval on activation, and PAM patterns — see Privileged Identity Management and PAM Architecture: Just-in-Time Access at Scale.

Licensing: what you must own before you design

Governance features are licensed per governed principal, and the license gates the feature, not just the admin. Design to what you can license.

Feature Base P1 Base P2 Entra ID Governance / Suite Note
Basic access reviews of groups/apps Included Included P2 covers foundational reviews
PIM (roles) Included Included P2 core capability
Entitlement management (access packages) Required Not in P1/P2 alone
Lifecycle workflows (JML) Required Governance/Suite feature
Access reviews of access packages / inactive users / PIM eligibility Partial Required for full scope Advanced review scopes need Governance
Auto-assignment policies Required Attribute-driven assignment
Connected organizations / governed B2B Required External governance
Custom task extensions (Logic Apps) Required Plus Logic App consumption cost

The licensing rules that catch teams out:

Rule Consequence
License every principal governed, not just admins A leaver workflow over 9,000 staff needs 9,000 Governance licenses
Guests may consume governance too External identities in governed catalogs count toward external licensing (MAU-based for CIAM-style, per Microsoft’s model)
Entra Suite bundles Governance + more If you also want Internet Access / Private Access / Verified ID, the Suite may be cheaper than parts
Downgrading strands config Removing Governance licenses disables the features but may leave orphaned definitions — plan exits

Confirm before you build:

# What Governance-related service plans are enabled on the tenant's SKUs?
Get-MgSubscribedSku | Select-Object SkuPartNumber, `
  @{n='Enabled';e={$_.PrepaidUnits.Enabled}}, @{n='Consumed';e={$_.ConsumedUnits}}

Architecture at a glance

Picture the governance machine as three intake lanes feeding one directory, with PIM as a gate on the privileged lane and audit as a tap on the output. There is no single diagram because the value is in how the lanes interlock, not in a box chart — so walk it as a flow.

Lane one is event-driven (lifecycle workflows). The HR/HCM system is the source of truth for employment: it sets employeeHireDate, employeeLeaveDateTime, department, and manager via inbound provisioning. Those attributes are the triggers. Seven days before a hire date, the joiner workflow adds default groups and sends a welcome email; on the leave date, the leaver workflow disables the account, strips groups and licenses, and revokes tokens; on an attribute change, the mover workflow reconciles known groups. Anything external (SaaS seat, phone number) is a custom task extension calling a Logic App. This lane needs no human in the loop — the employment event is the authorization.

Lane two is request-driven (entitlement management). A user opens the My Access portal, requests an access package from a delegated catalog, and one or more approval stages decide. Separation-of-duties gates block toxic combinations at this exact point — the request for the incompatible package never completes. The grant is time-bound; it self-destructs on expiration, forcing a re-request. Partners flow through the same lane via connected organizations and externally visible catalogs, with sponsors as approvers.

Lane three is attribute-driven (auto-assignment). Birthright access — the Finance apps for Finance staff — is granted by a membership rule with no request, and removed when the rule stops matching. This lane overlaps the mover workflow deliberately: when someone leaves Finance, both the auto-assignment removal and the mover workflow act, and the belt-and-suspenders is intentional.

The privileged gate (PIM). Where a lane would grant a privileged role, it instead grants membership of a PIM-enabled group that is merely eligible for the role. The user must activate through PIM — justification, MFA, a time box — so no standing privilege exists. Eligibility itself expires and is re-attested.

The re-attestation loop and the audit tap (access reviews + AuditLogs). Over all three lanes, recurring access reviews re-attest what the lanes left behind — the mover’s residual groups, the manual grant outside any package, the eligible assignment that outlived its need — with deny-by-default and auto-apply so silence removes access. Every action any lane takes lands in AuditLogs (workflow runs, package grants, review decisions), which is the evidence tap: you prove the machine ran from the log, never from the config. Read left to right — HR event / user request / attribute match on the left, a governed and time-boxed directory in the middle, PIM gating privilege, and an audit trail on the right — and the whole system is one picture.

Real-world scenario

A 9,000-employee insurer ran SOX and faced a finding that hits every regulated firm eventually. Claims adjusters could request the “Claims Write” access package and the “Payment Release” package, and 14 people held both. That combination let one person file and pay a fraudulent claim end to end. The external auditor wanted not just the toxic pairs removed, but a preventive control proving the combination could never recur — a quarterly cleanup spreadsheet was explicitly rejected as a detective control.

The platform team’s first instinct was a Sentinel rule to alert when both assignments coexisted. That failed the auditor for the right reason: it detects the violation after the access exists, leaving an exploitable window. The fix moved the control to request time. They marked the two packages mutually incompatible in entitlement management, so the second request is blocked — no toxic state is reachable. They then layered a quarterly review (deny-by-default) over the Payment Release package as recurring re-attestation on top of the preventive gate.

# Preventive SoD: Payment Release becomes unrequestable for anyone holding Claims Write
New-MgEntitlementManagementAccessPackageIncompatibleAccessPackageByRef `
  -AccessPackageId $paymentReleaseId `
  -BodyParameter @{
    "@odata.id" = "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/accessPackages/$claimsWriteId"
  }

The reciprocal reference was added on the Claims Write package so the block holds in both directions. The 14 existing dual-holders were resolved through a one-time targeted review rather than bulk removal, preserving the access each legitimately needed. But the finding also exposed two adjacent gaps the auditor then probed. First, offboarding: three of the 14 had left the company months earlier and their accounts were still enabled — manual offboarding via a ticket queue had missed them. The team stood up a leaver workflow on employeeLeaveDateTime (disable, strip groups/licenses, revoke tokens) so departure de-provisioning stopped depending on a human remembering. Second, the “Payment Release” package granted an app role that was effectively privileged; the team re-plumbed it so the grant delivered membership of a PIM-enabled group that was eligible for the role, with activation requiring approval and MFA — turning standing capability into just-in-time.

The numbers told the story at the next audit. Standing dual-holders: zero, structurally impossible. Mean time to de-provision a leaver: from “up to weeks, ticket-dependent” to under an hour, on the leave date, with a run-history record per user. Reviewer effort on the Payment Release review dropped because the decision helper flagged the two members who had not signed in in 60 days, and deny-by-default removed a third who ignored the review entirely. The auditor signed off on the incompatibility configuration, the leaver workflow run history, and the review schedule as a combined preventive-and-detective control. The lesson the team carried forward: SoD belongs at the request gate, not in the SIEM, and offboarding belongs on the HR trigger, not in a ticket queue. A guardrail that prevents the state beats an alarm that reports it.

Advantages and disadvantages

The honest trade-off before you commit a program:

Advantages Disadvantages
Revocation keeps pace with granting (expiry, JML, reviews) Requires Entra ID Governance/Suite licensing per governed principal
Preventive SoD makes toxic combinations structurally impossible Attribute-driven pieces are only as good as the HR data feeding them
Delegated catalogs kill the central-IT ticket bottleneck Poor catalog design (one giant catalog) recreates the bottleneck
Deny-by-default reviews scale reviewer effort down Rubber-stamping reviewers can undermine the control if helpers unused
Real-time leaver de-provisioning with audit-grade run history Workflow depends on populated employeeLeaveDateTime
PIM interplay removes standing privilege entirely Added activation friction for admins; more moving parts
Governed B2B via connected orgs closes the guest-decay gap External identity licensing/consumption adds cost
Everything is Graph-configurable and repeatable as code Steep initial design; not a checkbox you flip on

Where each advantage matters most: preventive SoD is decisive in regulated finance/healthcare where auditors demand it; real-time leaver workflows matter most where high staff churn or contractor populations make manual offboarding untenable; PIM interplay is essential wherever a compromised admin account is the top risk; delegated catalogs pay off hardest in large tenants where central IT cannot know every access need. Conversely, a 200-person startup with clean HR data and few privileged roles may get 80% of the value from access packages with expiry plus a leaver workflow, and defer the rest.

Hands-on lab

A safe, end-to-end walk-through in a non-production tenant: a delegated catalog, an expiring access package with manager approval, a leaver lifecycle workflow (created disabled), an enforcing access review, then verification and teardown. Requires Entra ID Governance licensing on the tenant and the scopes from Prerequisites. Use throwaway test users.

Step 0 — Connect and create test objects.

Connect-MgGraph -Scopes @(
  "EntitlementManagement.ReadWrite.All","AccessReview.ReadWrite.All",
  "LifecycleWorkflows.ReadWrite.All","Group.ReadWrite.All","User.ReadWrite.All")

# A test group to govern, and a test user
$grp = New-MgGroup -DisplayName "LAB-DataPlatform-Analysts" -MailEnabled:$false `
  -MailNickname "lab-dp-analysts" -SecurityEnabled:$true
"Group: $($grp.Id)"

Expected: a group object id prints. Note it as $grp.Id.

Step 1 — Create a delegated catalog and add the group as a resource.

$cat = New-MgEntitlementManagementCatalog -BodyParameter @{
  displayName = "LAB - Data Platform"; description = "Lab catalog"; isExternallyVisible = $false }
New-MgEntitlementManagementResourceRequest -BodyParameter @{
  requestType = "AdminAdd"; catalog = @{ id = $cat.Id }
  resource = @{ originId = $grp.Id; originSystem = "AadGroup" } }
"Catalog: $($cat.Id)"

Expected: catalog id prints; the resource-add request completes. Validate: Get-MgEntitlementManagementCatalogResource -AccessPackageCatalogId $cat.Id lists the group.

Step 2 — Create an access package with a 30-day, manager-approval policy.

$pkg = New-MgEntitlementManagementAccessPackage -BodyParameter @{
  displayName = "LAB - Analyst read"; description = "Lab package"; catalog = @{ id = $cat.Id } }

New-MgEntitlementManagementAssignmentPolicy -BodyParameter @{
  displayName = "LAB - 30 day manager approval"; accessPackage = @{ id = $pkg.Id }
  requestorSettings = @{ enableTargetsToSelfAddAccess = $true; onBehalfRequestorsAllowed = $false }
  requestApprovalSettings = @{ isApprovalRequiredForAdd = $true
    stages = @( @{ durationBeforeAutomaticDenial = "P7D"; isApproverJustificationRequired = $true
      primaryApprovers = @( @{ "@odata.type"="#microsoft.graph.requestorManager"; managerLevel=1 } ) } ) }
  expiration = @{ type = "afterDuration"; duration = "P30D" } }
"Package: $($pkg.Id)"

Expected: package id prints. Validate: the package appears in the My Access portal for an in-scope user, showing “requires approval” and a 30-day duration.

Step 3 — Create a leaver workflow, disabled, for safe inspection.

New-MgIdentityGovernanceLifecycleWorkflow -BodyParameter @{
  displayName = "LAB - Leaver (disabled)"; description = "Lab only"; category = "leaver"
  isEnabled = $false                                        # created OFF so it cannot fire
  executionConditions = @{
    "@odata.type" = "#microsoft.graph.identityGovernance.triggerAndScopeBasedConditions"
    scope = @{ "@odata.type" = "#microsoft.graph.identityGovernance.ruleBasedSubjectSet"
               rule = "(department -eq 'LAB-NONEXISTENT')" }   # matches nobody
    trigger = @{ "@odata.type" = "#microsoft.graph.identityGovernance.timeBasedAttributeTrigger"
                 timeBasedAttribute = "employeeLeaveDateTime"; offsetInDays = 0 } }
  tasks = @(
    @{ category="leaver"; displayName="Disable user account"; isEnabled=$true
       taskDefinitionId="1dfdfcc7-52fa-4c2e-bf3a-e3919cc12950"; arguments=@() }
    @{ category="leaver"; displayName="Revoke all refresh tokens"; isEnabled=$true
       taskDefinitionId="509589a4-0466-4471-829e-49c5e502bdee"; arguments=@() } ) }

Expected: the workflow is created with isEnabled=$false and a scope matching nobody — doubly safe. Validate: Get-MgIdentityGovernanceLifecycleWorkflow -Filter "displayName eq 'LAB - Leaver (disabled)'" shows it disabled.

Step 4 — Create an enforcing access review of the group.

New-MgIdentityGovernanceAccessReviewDefinition -BodyParameter @{
  displayName = "LAB - Analyst group review"
  descriptionForAdmins = "Lab"; descriptionForReviewers = "Confirm membership"
  scope = @{ "@odata.type"="#microsoft.graph.accessReviewQueryScope"
    query = "/groups/$($grp.Id)/transitiveMembers"; queryType = "MicrosoftGraph" }
  reviewers = @( @{ query = "/groups/$($grp.Id)/owners"; queryType = "MicrosoftGraph" } )
  settings = @{ mailNotificationsEnabled=$true; recommendationsEnabled=$true
    instanceDurationInDays=3; autoApplyDecisionsEnabled=$true
    defaultDecisionEnabled=$true; defaultDecision="Deny"
    recurrence = @{ pattern=@{ type="absoluteMonthly"; dayOfMonth=1; interval=3 }
                    range=@{ type="numbered"; startDate="2026-07-01"; numberOfOccurrences=1 } } } }

Expected: a review definition is created (one occurrence, so it self-terminates). Validate: Get-MgIdentityGovernanceAccessReviewDefinition -Filter "displayName eq 'LAB - Analyst group review'" returns it with autoApplyDecisionsEnabled = true and defaultDecision = Deny.

Step 5 — Verify the machinery (read-only).

# Packages and policies exist and are time-bound
Get-MgEntitlementManagementAccessPackage -Filter "displayName eq 'LAB - Analyst read'" |
  Select-Object DisplayName, Id
# Workflow is present and safely disabled
(Get-MgIdentityGovernanceLifecycleWorkflow -Filter "displayName eq 'LAB - Leaver (disabled)'").IsEnabled
# Review enforces (auto-apply + deny default)
$def = Get-MgIdentityGovernanceAccessReviewDefinition -Filter "displayName eq 'LAB - Analyst group review'"
$def.Settings | Select-Object AutoApplyDecisionsEnabled, DefaultDecision

Expected: package prints; workflow IsEnabled is False; review shows True / Deny.

Step 6 — Teardown. Remove everything you created, in reverse order.

# Delete the review, workflow, package, catalog, and test group
Remove-MgIdentityGovernanceAccessReviewDefinition -AccessReviewScheduleDefinitionId $def.Id
$wf = Get-MgIdentityGovernanceLifecycleWorkflow -Filter "displayName eq 'LAB - Leaver (disabled)'"
Remove-MgIdentityGovernanceLifecycleWorkflow -LifecycleWorkflowId $wf.Id
Remove-MgEntitlementManagementAccessPackage -AccessPackageId $pkg.Id
Remove-MgEntitlementManagementCatalog -AccessPackageCatalogId $cat.Id
Remove-MgGroup -GroupId $grp.Id
"Torn down."

Expected: each removal returns without error and “Torn down.” prints. Validate: re-running any Get-...-Filter returns nothing.

Common mistakes & troubleshooting

The failure modes that actually bite in production — symptom, root cause, how to confirm, and the fix.

# Symptom Root cause Confirm (exact command / portal path) Fix
1 Feature simply not available / “requires a license” Entra ID Governance/Suite not assigned to the governed principals Get-MgSubscribedSku shows no Governance plan; portal banner Assign Governance/Suite to every governed user (incl. guests)
2 Leaver workflow never fires employeeLeaveDateTime not populated by HR provisioning Get-MgUser -UserId <id> -Property employeeLeaveDateTime is null Fix inbound provisioning mapping; backfill the attribute
3 Workflow fires but task fails for some users Task target missing (group deleted) or insufficient privilege Get-MgIdentityGovernanceLifecycleWorkflowRunTaskProcessingResult: FailureReason Correct task arguments; re-run for failed users
4 SoD block only works one way Incompatible reference set on one package, not the mirror Portal → package → Separation of duties (each package) Add the reciprocal IncompatibleAccessPackageByRef
5 Users still hold a toxic pair after SoD SoD is preventive; existing holders not auto-removed Query assignments for both packages One-time targeted review to resolve existing holders
6 Review “completed” but nobody lost access autoApplyDecisionsEnabled = false (report-only) $def.Settings.AutoApplyDecisionsEnabled = false Set it true; decisions then enforce automatically
7 Non-responders kept access defaultDecision = None (no change on silence) $def.Settings.DefaultDecision = None Set Deny for privileged scopes
8 Whole app/group emptied by a review Deny-by-default + reviewers ignored the review Decision export shows mass Deny by system Re-grant needed access; add reminders/escalation; reconsider default for low-stakes
9 Auto-assignment grants nobody Membership rule syntax wrong or attribute unpopulated Portal shows 0 in policy; test rule as a dynamic group Fix membershipRule; ensure attributes are set
10 Auto-assignment never removes access removeAccessWhenTargetLeavesAllowedTargets = false Inspect the auto-assignment policy Enable removal (+ grace period)
11 Guest never offboarded despite expiry Catalog external-user removal not configured Catalog → Settings → external user lifecycle Set “remove/block on last assignment expiry”
12 Approver never sees the request Approver type resolves to nobody (no manager set) Request stuck; manager unset on requestor Set manager, or use singleUser/groupMembers; enable escalation
13 Privileged access still “standing” after governance Package grants active role, not eligibility Role shows active assignment, not eligible Deliver via PIM-enabled group + eligibility; activate via PIM
14 Review of a role misses most holders Reviewed active members only, not PIM-eligible Review scope = active membership Scope the review to eligible assignments
15 Workflow ran but external system unchanged Built-in tasks don’t touch external systems Salesforce/HR seat still active Add a custom task extension (Logic App) for the external system
16 Delegated owner can’t add a resource Owner lacks the directory privilege the resource needs Add fails for a directory-role resource Grant matching privilege, or have central IT add it

Three expanded root-causes for the ones that waste the most time:

Leaver workflow never fires (#2). The most common governance disappointment. The workflow is enabled, scoped correctly, and still does nothing — because the trigger attribute is empty. Time-based triggers fire off employeeLeaveDateTime/employeeHireDate, and if HR provisioning never writes those attributes, there is no event. Confirm with Get-MgUser -UserId <id> -Property employeeLeaveDateTime,employeeHireDate; if null, the fix is in the provisioning mapping (Workday/SuccessFactors attribute flow) or Entra Connect, not in the workflow.

Review completed but nobody lost access (#6/#7). A review can finish and change nothing in two ways: auto-apply is off (decisions are recorded but never enforced — the classic “report we email to nobody”), or the default decision is None so silence keeps access. Both feel like the review “ran.” Confirm both settings on the definition; for any privileged scope, autoApplyDecisionsEnabled = true and defaultDecision = Deny together are what make a review a control.

Privileged access still standing after governance (#13). Teams often grant a privileged app role or directory role directly through a package, achieving time-bound-but-still-standing access. The intended pattern delivers membership of a PIM-enabled group that is merely eligible; the user then activates through PIM. Confirm by checking whether the role assignment is active or eligible; the fix re-plumbs the package/task to grant group membership and moves the role relationship to PIM eligibility.

Best practices

Security notes

Cost & sizing

A rough monthly picture for the 9,000-employee insurer: Governance/Suite licensing for the governed workforce is the headline figure (negotiated per-user, so tenant-specific), plus a few thousand INR for Logic Apps behind external de-provisioning, plus normal Log Analytics ingestion for the AuditLogs you export as evidence. The controls themselves add no compute bill — the discipline is the investment.

Cost driver What you pay for Rough scale (INR/month) What it buys Watch-out
Governance/Suite licensing Per governed principal Headline; per-user × headcount The features at all License every governed user, incl. guests
External-user licensing Guests in governed catalogs MAU-based Governed B2B Large partner base scales cost
Logic Apps (custom tasks) Per-execution + connectors Low thousands External-system de-provision Use Consumption tier; keep logic external
Log Analytics ingestion AuditLogs evidence retention ~₹1,000–3,000 Audit-grade proof Set retention to your compliance window
Design / operations Engineer time (front-loaded) One-off + ongoing A working program Not a checkbox; budget the build

Interview & exam questions

1. What is the difference between entitlement management, access reviews, and lifecycle workflows, and when do you use each? Entitlement management governs requested/attribute-driven access via access packages with policies (approval, expiry, SoD). Access reviews re-attest existing access on a schedule to remove creep and residue. Lifecycle workflows automate event-driven joiner-mover-leaver access on HR triggers. Use packages for discretionary and birthright access, workflows for employment-event provisioning/de-provisioning, and reviews for periodic re-certification of whatever the other two leave behind.

2. Walk the entitlement-management hierarchy. Catalog (container + delegation boundary) → resource (group/app/site added to the catalog) → resource role (membership, app role, permission level) → access package (a bundle of resource roles that is requestable) → assignment policy (who can request/get it, approval stages, and expiration). The policy is where governance lives; a package without a policy grants nothing.

3. How does separation of duties in entitlement management differ from a SIEM alert on toxic combinations? SoD is preventive and enforced at request time — a user holding an incompatible package cannot even request the conflicting one, so the toxic state is never reachable. A SIEM alert is detective — it fires after the access exists, leaving an exploitable window. SoD is a guardrail; the alert is an alarm. Auditors increasingly require the guardrail. Remember to configure both directions.

4. What two access-review settings turn a review from a report into a control? autoApplyDecisionsEnabled = true (decisions are enforced automatically at cycle end, not left in a report) and defaultDecision = Deny with defaultDecisionEnabled = true (non-response removes access). Together they make keeping access the deliberate act and losing it the default — the only model that scales for privileged scopes.

5. Design a leaver workflow. What triggers it, what tasks, in what order, and what’s the prerequisite? Trigger: time-based on employeeLeaveDateTime, offsetInDays = 0. Tasks in order: disable account → remove from all groups → remove all licenses → revoke all refresh tokens (revoke last so an in-flight session can’t re-acquire access). Prerequisite: employeeLeaveDateTime must be populated by HR-driven inbound provisioning, or nothing fires.

6. What is an auto-assignment policy and how does it differ from a request policy? An auto-assignment policy grants an access package’s roles to every user matching an attribute rule (e.g. department -eq 'Finance'), with no request and no approval — the rule is the gate — and removes access when the user stops matching (with an optional grace period). A request policy is self-service with approval stages and a timer-based expiry. Use auto-assignment for birthright access that tracks HR data; use request policies for discretionary access.

7. How do governance and PIM divide responsibility for a privileged role? Governance controls which role a person can hold and for how long they’re eligible — typically by making them a member of a PIM-enabled group that is eligible (not active) for the role, with the eligibility itself expiring and re-attested by a review. PIM controls when they actually hold it — activation with justification/MFA and an hours-long time box. Two expirations stack; no standing privilege exists.

8. A leaver workflow is enabled and correctly scoped but never runs. What’s the most likely cause and how do you confirm? The trigger attribute is empty. Time-based triggers fire off employeeLeaveDateTime; if HR provisioning never writes it, there is no event. Confirm with Get-MgUser -UserId <id> -Property employeeLeaveDateTime; if null, fix the provisioning attribute mapping or backfill.

9. How do you govern the lifecycle of B2B guests that have no employeeLeaveDateTime? Onboard them through connected organizations into an externally visible catalog (sponsor approval), give the package an expiration so access self-expires, run recurring inactive-guest reviews (deny-by-default) to catch decay, and configure the catalog’s external-user setting to remove/block the guest when their last assignment expires — making the catalog the lifecycle boundary. Constrain who can be invited with cross-tenant access settings.

10. Why review PIM-eligible assignments rather than only active role members? Because active membership shows only who is currently activated; the standing risk is everyone who is eligible and could activate. Reviewing only active members misses the population you most need to re-certify. PIM exposes reviews for eligible role and group assignments precisely so eligibility does not silently become the new standing privilege.

11. What is a custom task extension and when do you need one? A lifecycle-workflow task that calls an Azure Logic App, used to reach systems Graph cannot touch — de-provision a Salesforce seat, release a phone number, notify a ticketing system. It runs in “launch and continue” (fire-and-forget) or “launch and wait” (block on callback, up to timeout) mode; use “wait” when a later task depends on the external action completing. Up to 100 per tenant.

12. Which licensing is required for entitlement management and lifecycle workflows, and what’s the common trap? Microsoft Entra ID Governance (or the Entra Suite) — not base P1 or P2. The trap: the license is required per governed principal, not just the admin, so a workflow over 9,000 employees needs 9,000 Governance licenses, and guests in governed catalogs count too. Confirm licensing before designing scope.

These map to SC-300 (Identity and Access Administrator)implement and manage identity governance (entitlement management, access reviews, PIM, entitlement lifecycle) — and touch SC-100 (Cybersecurity Architect) for the Zero Trust and least-privilege framing. Cert-mapping for revision:

Question theme Primary cert Objective area
Entitlement management hierarchy, policies, SoD SC-300 Plan & implement entitlement management
Access reviews (scopes, auto-apply, defaults) SC-300 Plan & implement access reviews
Lifecycle workflows (JML, triggers, tasks) SC-300 Implement identity lifecycle
PIM interplay, eligibility, JIT SC-300 Implement PIM
Governed B2B, connected orgs SC-300 Manage external identities governance
Zero Trust / least-privilege framing SC-100 Design identity security strategy

Quick check

  1. You need every Finance employee to automatically get the Finance apps, with no request form, and to lose them if they leave Finance. Which policy type, and which one flag makes the “lose them” part work?
  2. A quarterly access review of a privileged group “completed” but no one lost access, even people who never responded. Name the two settings you check and their correct values for a privileged scope.
  3. Two access packages, “Vendor Onboarding” and “Payment Approval”, must never be held together. What control do you use, and what is the one configuration step people forget?
  4. Your leaver workflow is enabled and scoped to Engineering, but nobody is ever de-provisioned. What is the single most likely cause?
  5. Governance made a user a member of a group that grants an app role, and the app role is privileged. What did you get wrong from a Zero-Trust standpoint, and what’s the fix?

Answers

  1. An auto-assignment policy (attribute rule department -eq 'Finance', no request/approval). The flag that removes access when they no longer match is removeAccessWhenTargetLeavesAllowedTargets = true (optionally with a grace period).
  2. autoApplyDecisionsEnabled must be true (otherwise decisions are recorded but never enforced — report-only) and defaultDecision must be Deny with defaultDecisionEnabled = true (otherwise non-response defaults to None and keeps access). For a privileged group, both together are required.
  3. Separation of duties via incompatible access packages — mark each package incompatible with the other. The forgotten step is reciprocity: set the incompatible reference on both packages, or the block only works in one direction.
  4. employeeLeaveDateTime is not populated on those users (the time-based trigger has no event to fire on). Fix the HR-driven inbound provisioning mapping that should write it. Confirm with Get-MgUser -Property employeeLeaveDateTime.
  5. You delivered a privileged role as standing (active) access rather than eligibility. The fix: make the group a PIM-enabled group that is eligible (not active) for the role, so the user must activate through PIM (justification, MFA, time box) — and review the eligibility.

Glossary

Next steps

You can now stand up the full governance machine and prove it runs. Build outward:

Entra-ID-Governanceentitlement-managementaccess-reviewslifecycle-workflowsleast-privilegejoiner-mover-leaverPIMMicrosoft-Graph
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading