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: 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 and ties them back to PIM and audit evidence.

Everything here requires Microsoft Entra ID Governance (or the Entra Suite) licensing for the principals being governed - entitlement management, lifecycle workflows, and recurring reviews are not in the base P2 SKU. Check licensing before you design.

1. The three governance gaps you are closing

Before any config, name the failure modes you are designing against. Every control below maps to one.

Gap What it looks like Control
Access creep Permissions accumulate, never shrink Expiring access packages, recurring reviews
Orphaned access Leaver/mover keeps old entitlements Lifecycle workflows on the JML trigger
Standing privilege Permanent admin on high-value scopes PIM eligible assignments + reviews
Audit blindness Cannot prove who approved what, when Catalog policies + review decision logs

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.

Connect with the Identity Governance module and the least-privilege scopes you actually need:

Install-Module Microsoft.Graph.Identity.Governance -Scope CurrentUser
Connect-MgGraph -Scopes @(
  "EntitlementManagement.ReadWrite.All",
  "AccessReview.ReadWrite.All",
  "LifecycleWorkflows.ReadWrite.All",
  "RoleManagement.ReadWrite.Directory"
)

2. Design catalogs, access packages, and policies

Entitlement management has a deliberate hierarchy. A catalog is a container of governed resources (groups, apps, SharePoint sites) plus a delegation boundary - resource owners manage their catalog without tenant-wide admin. An access package bundles specific roles on those resources into one requestable unit. A policy on the package defines who can request it, who approves, and how long the grant lasts.

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.

# 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
}

# 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"
  }
}

Now create the package and bind a request policy. Note the policy is where governance lives - expiration, requestApprovalSettings, and requestorSettings are not optional decorations.

$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)
  requestorSettings = @{
    enableTargetsToSelfAddAccess    = $true
    enableTargetsToSelfRemoveAccess = $true
    allowCustomAssignmentSchedule   = $true
    onBehalfRequestorsAllowed       = $false
  }
  # Approval: single-stage, the requestor's manager
  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.

3. Approvals, separation of duties, and expiration

Two policy capabilities turn a request form into a control.

Multi-stage approval chains independent approvers - use it when the resource owner and the requestor’s manager must both sign off. Add a second object to the stages array with its own primaryApprovers.

Separation of duties (SoD) 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.

# 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
New-MgEntitlementManagementAccessPackageIncompatibleGroupByRef `
  -AccessPackageId $paymentApprovalPkgId `
  -BodyParameter @{
    "@odata.id" = "https://graph.microsoft.com/v1.0/groups/$contractorsGroupId"
  }

SoD enforced here is far stronger than a detective control in Sentinel: the conflicting access never gets granted, so there is no window of exposure to detect - the difference between a guardrail and an alarm.

4. Automate joiner-mover-leaver with lifecycle workflows

Access packages handle requested access. Lifecycle workflows handle automatic access tied to employment events - 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. The tasks are referenced by stable taskDefinitionId GUIDs.

Here is a leaver workflow that fires on the employee leave date and de-provisions cleanly. 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
    }
  }
  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 = @() }
  )
}

The joiner side uses different task IDs - Send welcome email to new hire (70b29d51-b59a-4773-9280-8841dfd3f2ea) and Add user to groups (22085229-5809-45e8-97fd-270d28d66910), triggered at offsetInDays = -7 against employeeHireDate so accounts are ready before day one. Disable (1dfdfcc7-...) and Enable user account (6fc52c9d-398b-4305-9763-15f42c1676fc) cover suspend/return.

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 - use the Run a Custom Task Extension task (d79d1fcc-16be-490c-a865-f4533b1639ee), which calls an Azure Logic App. Two execution modes matter:

You can register up to 100 custom task extensions per tenant. Treat the Logic App as the integration boundary and keep the workflow declarative.

5. Recurring access reviews for groups, apps, and roles

Lifecycle workflows catch the clean leaver. They do not catch the mover who changed teams but kept their old group, or the 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, 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.

$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   # surface sign-in-based "approve/deny" hints
    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.

For application access reviews, scope to the app’s assigned users; for privileged role reviews, prefer reviewing the PIM-eligible assignments (next section) over active role members.

6. 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. Two controls contain them.

First, an inactive-guest review. Scope it to guests who have not signed in for a defined window using the inactive-users scope, and let the team owners decide:

$guestReview = @{
  displayName          = "Inactive guests on Teams (30d)"
  descriptionForAdmins = "Remove guest access to teams 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 = "P30D"
  }
  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

Second, cross-tenant access settings. Decide centrally which partner tenants your users can be invited into (outbound) and which external tenants can be invited in (inbound), rather than letting any guest from any tenant land in your directory. Combine that with entitlement management’s external-user settings so guests provisioned through an externally visible catalog are auto-removed when their last access package assignment expires - closing the orphaned-guest gap by construction.

This makes the catalog, not a manual cleanup, the lifecycle boundary for external identities.

7. 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-role relationship via the privileged access group 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.

Verify

Prove the machinery works before you trust it. Do not assume a workflow ran because it is enabled.

# 1. Access packages and their policies exist and are time-bound
Get-MgEntitlementManagementAccessPackage -All |
  Select-Object DisplayName, Id

# 2. Lifecycle workflow run history - confirm tasks actually executed
$wf = Get-MgIdentityGovernanceLifecycleWorkflow -Filter "displayName eq 'Leaver - real-time offboard'"
Get-MgIdentityGovernanceLifecycleWorkflowRun -LifecycleWorkflowId $wf.Id |
  Select-Object Id, LastUpdatedDateTime, ProcessingStatus, FailedTasksCount, SuccessfulUsersCount

# 3. Per-user task results for a failed run (root-cause individual failures)
Get-MgIdentityGovernanceLifecycleWorkflowRunTaskProcessingResult `
  -LifecycleWorkflowId $wf.Id -RunId $runId |
  Select-Object @{n='Task';e={$_.Subject.DisplayName}}, ProcessingResult, FailureReason

# 4. Access review decisions and whether they were applied
$def = Get-MgIdentityGovernanceAccessReviewDefinition -Filter "displayName eq 'Quarterly - Data Platform Admins group'"
$inst = (Get-MgIdentityGovernanceAccessReviewDefinitionInstance -AccessReviewScheduleDefinitionId $def.Id)[0]
Get-MgIdentityGovernanceAccessReviewDefinitionInstanceDecision `
  -AccessReviewScheduleDefinitionId $def.Id -AccessReviewInstanceId $inst.Id |
  Group-Object Decision | Select-Object Name, Count

A KQL backstop in the AuditLogs table confirms de-provisioning actually happened in the directory, independent of what the workflow reports:

AuditLogs
| where TimeGenerated > ago(7d)
| where LoggedByService == "Lifecycle Workflows"
| where ActivityDisplayName in ("Disable user account", "Remove user from all groups",
                                "Remove all licenses for user", "Revoke all refresh tokens for user")
| extend Target = tostring(TargetResources[0].userPrincipalName)
| project TimeGenerated, ActivityDisplayName, Target, Result
| order by TimeGenerated desc

For least-privilege posture reporting, age the entitlement-management assignment list: each assignment carries creation and (for expiring policies) expiry timestamps, so Get-MgEntitlementManagementAssignment grouped by access package and expiry answers “how much standing access exists and when does it drain.” Reviews emit a per-decision record (reviewedBy, decision, justification, timestamp) - export it per cycle for a defensible answer to “prove access was reviewed.”

Enterprise scenario

A 9,000-employee insurer ran SOX and faced a finding that hit 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 the 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. The auditor signed off on the incompatibility configuration plus 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. A guardrail that prevents the state beats an alarm that reports it.

Governance rollout checklist

Entra-ID-Governanceentitlement-managementaccess-reviewslifecycle-workflowsleast-privilegejoiner-mover-leaver

Comments

Keep Reading