Most access-request systems I inherit are a ticket queue bolted onto manual group adds. Someone fills a form, an admin eyeballs it, drops the user into a security group, and the entitlement lives forever because nobody remembers to remove it. Entra Entitlement Management (EM) replaces that with a declarative model: bundle the groups, apps, and SharePoint sites a role needs into an access package, attach policies that say who can request, who approves, and how long it lasts, and let the platform handle the grant, the recertification, and the timed removal. This article is how I architect EM for a real enterprise: delegated catalogs, multi-stage approvals, separation of duties between incompatible packages, and the Graph automation that makes it operable at scale. It assumes Entra ID Governance licensing (the standalone SKU or the Suite); EM is not in plain P2.
Licensing reality check: EM requires an Entra ID Governance license for every user who requests, approves, or is assigned an access package, and for every reviewer in an access-package access review. This is its own SKU on top of P1/P2. Budget for it before you design the rollout.
1. The governance model: catalogs, resources, access packages
Four object types, and getting the boundaries right up front saves you a re-platforming later:
| Object | What it is | Owned by |
|---|---|---|
| Catalog | A container that holds resources and the access packages built from them | Catalog owners (delegated) |
| Resource | A group, an enterprise app (with its app roles), or a SharePoint Online site added into a catalog | Added by catalog owners |
| Access package | A bundle of resource roles from one catalog, presented as a single requestable unit | Access package managers |
| Assignment policy | Rules attached to a package: who can request, approval flow, lifecycle | Defined per package |
The catalog is your delegation boundary. A resource (say, the app-finance-erp enterprise app) can only be put into a package if it has first been added to that catalog. So the catalog is what you hand to a business unit: “here are your resources, build the packages your teams need, and you cannot touch anyone else’s.” Create a catalog per domain of ownership, not per app.
Connect-MgGraph -Scopes "EntitlementManagement.ReadWrite.All"
# A catalog owned by the Finance platform team
$catalog = New-MgEntitlementManagementCatalog -BodyParameter @{
displayName = "Finance Applications"
description = "Finance-owned apps, groups, and sites"
isExternallyVisible = $true # required if you will let external guests request from it
state = "published"
}
$catalog.Id
Delegate ownership to the Finance identity leads so my central team is out of the day-to-day request path:
# Catalog roles: "Catalog owner" and "Catalog reader" are app role assignments
# on the catalog's resource. Assign via the role-assignments endpoint.
$ownerRole = (Get-MgEntitlementManagementCatalog -AccessPackageCatalogId $catalog.Id `
-ExpandProperty "*").Id
New-MgRoleManagementEntitlementManagementRoleAssignment -BodyParameter @{
roleDefinitionId = "ae79f266-94d4-4dab-b730-feca7e132178" # Catalog owner (built-in EM role)
principalId = "<finance-lead-group-objectId>"
appScopeId = "/AccessPackageCatalog/$($catalog.Id)"
}
Use the EM-specific RBAC roles (Catalog owner, Access package manager, Access package assignment manager) scoped to the catalog. Do not hand out the tenant-wide Identity Governance Administrator role just to let a BU manage its own packages. Scoped delegation is the entire point of catalogs.
2. Build a package that grants groups, apps, and SharePoint together
The power of EM is that one request lights up everything a role needs. A new financial analyst should get the ERP app, the Power BI workspace group, and the finance SharePoint site in one approval. First, add each resource to the catalog, then create a package that references their roles.
Add resources (each call is asynchronous; EM ingests the resource and its roles):
# Add a security group as a resource in the catalog
New-MgEntitlementManagementResourceRequest -BodyParameter @{
requestType = "adminAdd"
resource = @{
originId = "<group-objectId>"
originSystem = "AadGroup"
}
catalog = @{ id = $catalog.Id }
}
# Add an enterprise app (brings its app roles in as assignable roles)
New-MgEntitlementManagementResourceRequest -BodyParameter @{
requestType = "adminAdd"
resource = @{ originId = "<servicePrincipal-objectId>"; originSystem = "AadApplication" }
catalog = @{ id = $catalog.Id }
}
# Add a SharePoint Online site (originId is the site URL)
New-MgEntitlementManagementResourceRequest -BodyParameter @{
requestType = "adminAdd"
resource = @{ originId = "https://contoso.sharepoint.com/sites/finance"; originSystem = "SharePointOnline" }
catalog = @{ id = $catalog.Id }
}
Now create the access package and attach the specific resource roles. Each resource exposes roles: a group has Member (and Owner); an app exposes its declared app roles plus a default User role; a SharePoint site exposes its permission levels (e.g. Member, Visitor).
$pkg = New-MgEntitlementManagementAccessPackage -BodyParameter @{
displayName = "Financial Analyst - Standard"
description = "ERP access, BI workspace, and finance SharePoint for analysts"
catalog = @{ id = $catalog.Id }
}
Binding roles to a package is done through accessPackageResourceRoleScopes. The cleanest way is the Graph beta endpoint, which lets you reference the resource role and scope in one payload:
# Grant the "Member" role of the security group through the access package
az rest --method POST \
--uri "https://graph.microsoft.com/beta/identityGovernance/entitlementManagement/accessPackages/$PKG_ID/resourceRoleScopes" \
--headers "Content-Type=application/json" \
--body '{
"role": {
"displayName": "Member",
"originSystem": "AadGroup",
"originId": "Member_<group-objectId>",
"resource": { "id": "<catalog-resource-id>", "originId": "<group-objectId>", "originSystem": "AadGroup" }
},
"scope": { "displayName": "Root", "originId": "<group-objectId>", "originSystem": "AadGroup", "isRootScope": true }
}'
Repeat for the app role (originSystem: AadApplication) and the SharePoint permission level (originSystem: SharePointOnline). The result: one package, three downstream provisioning targets, granted and revoked atomically.
3. Assignment policies: internal users, connected orgs, and any external guest
A package with no policy is unrequestable. Policies are where audience and lifecycle live, and a single package can carry several — one for employees, one for partners, one for open external request. The discriminator is requestorSettings.acceptRequests plus the allowedRequestors / scope settings.
Internal employees, scoped to a group (only members of that group see the package):
New-MgEntitlementManagementAssignmentPolicy -BodyParameter @{
displayName = "Internal - Finance staff"
accessPackage = @{ id = $pkg.Id }
allowedTargetScope = "specificDirectoryUsers"
specificAllowedTargets = @(@{
"@odata.type" = "#microsoft.graph.groupMembers"
groupId = "<finance-staff-group-id>"
})
requestorSettings = @{ enableTargetsToSelfAddAccess = $true }
}
Connected organizations (named partner tenants you have onboarded as a connectedOrganization) — guests from those specific orgs may request:
New-MgEntitlementManagementAssignmentPolicy -BodyParameter @{
displayName = "Partners - connected orgs only"
accessPackage = @{ id = $pkg.Id }
allowedTargetScope = "allConfiguredConnectedOrganizationUsers"
requestorSettings = @{ enableTargetsToSelfAddAccess = $true }
}
Any external guest — for an open partner-facing scenario where you do not pre-onboard tenants. EM auto-invites the guest as a B2B user on approval:
New-MgEntitlementManagementAssignmentPolicy -BodyParameter @{
displayName = "Open - any external user"
accessPackage = @{ id = $pkg.Id }
allowedTargetScope = "allExternalUsers"
requestorSettings = @{ enableTargetsToSelfAddAccess = $true }
}
allExternalUsersis a real attack surface if the package is also externally visible. Pair it with mandatory approval (next section) and never with auto-approval. The catalog must haveisExternallyVisible = trueor external users will get a “you don’t have access” page even with the policy in place.
4. Multi-stage approvals, sponsors, and requestor justification
Approval is configured per policy under requestApprovalSettings. EM supports up to three serial stages, each with its own approvers, escalation, and timeout. The pattern I use for external access: stage 1 to the partner’s internal sponsor, stage 2 to the resource owner.
Sponsors are a first-class concept: a connected organization carries internalSponsors and externalSponsors, and you reference them in approval as sponsorStageOfRecipient / requestorManager rather than hardcoding people. That keeps the flow correct as sponsorship changes.
$policyBody = @{
displayName = "External - two-stage with sponsor"
accessPackage = @{ id = $pkg.Id }
allowedTargetScope = "allConfiguredConnectedOrganizationUsers"
requestApprovalSettings = @{
isApprovalRequiredForAdd = $true
isRequestorJustificationRequired = $true
approvalMode = "Serial"
approvalStages = @(
@{
approvalStageTimeOutInDays = 3
isApproverJustificationRequired = $true
isEscalationEnabled = $true
escalationTimeInMinutes = 1440 # escalate after 1 day
primaryApprovers = @(@{
"@odata.type" = "#microsoft.graph.internalSponsors"
})
escalationApprovers = @(@{
"@odata.type" = "#microsoft.graph.singleUser"
userId = "<governance-fallback-userId>"
})
},
@{
approvalStageTimeOutInDays = 5
isApproverJustificationRequired = $true
primaryApprovers = @(@{
"@odata.type" = "#microsoft.graph.singleUser"
userId = "<resource-owner-userId>"
})
}
)
}
}
New-MgEntitlementManagementAssignmentPolicy -BodyParameter $policyBody
Key design rules I enforce:
- Both stages require justification.
isRequestorJustificationRequiredforces the requestor to state why;isApproverJustificationRequiredper stage forces the approver to leave a defensible audit note. Auditors live on these fields. - Always set escalation. A stage with no escalation and a vacationing approver silently stalls requests. The
escalationApproversplusescalationTimeInMinutesis your safety valve. - Use
internalSponsors/requestorManager, not named users, wherever the org model supports it. Hardcoded approver lists are the thing that rots.
5. Separation of duties: incompatible packages and groups
This is the feature that turns EM from a request portal into a real control. You can declare that holding package A makes a user ineligible to request package B — the request is blocked at submission, not flagged after the fact. Classic use: “Initiate Payment” and “Approve Payment” must never be held by the same person.
Mark Vendor Payment - Approver as incompatible with Vendor Payment - Initiator:
# Add an incompatible access package (the "approver" pkg cannot coexist with the "initiator" pkg)
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/accessPackages/$APPROVER_PKG_ID/incompatibleAccessPackages/\$ref" \
--headers "Content-Type=application/json" \
--body '{ "@odata.id": "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/accessPackages/'$INITIATOR_PKG_ID'" }'
You can also block on existing group membership — useful when one side of the SoD is a legacy group not yet behind a package:
# Anyone already in this group is blocked from requesting the package
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/accessPackages/$APPROVER_PKG_ID/incompatibleGroups/\$ref" \
--headers "Content-Type=application/json" \
--body '{ "@odata.id": "https://graph.microsoft.com/v1.0/groups/'$LEGACY_INITIATOR_GROUP_ID'" }'
Incompatibility is bidirectional in effect but you declare it on one side — declaring it on the approver package blocks initiators from getting approver, and EM also surfaces the conflict from the other direction. Before you turn it on for a live package, pull additionalAccess to find who already holds both and would be grandfathered into a violation:
# Who currently has access that would now be incompatible
az rest --method GET \
--uri "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/accessPackages/$APPROVER_PKG_ID/getApplicablePolicyRequirements"
Remediate existing dual-holders by revoking one side before enforcement, or your first access review will surface a pile of violations.
6. Time-bound assignments, expiration, and access reviews
Standing access is the liability EM exists to kill. Set lifecycle on the policy via expiration, and layer an access review so even time-bound grants get re-justified before renewal.
# Expire after 180 days; force re-request rather than silent renewal
$lifecycle = @{
expiration = @{
type = "afterDuration"
duration = "P180D" # ISO 8601 duration
}
accessReviewSettings = @{
isEnabled = $true
recurrenceType = "quarterly"
reviewerType = "Manager" # or "Reviewers" with an explicit list
durationInDays = 14
isAccessRecommendationEnabled = $true # show "last sign-in" based recommendation
isApprovalJustificationRequired = $true
accessReviewTimeoutBehavior = "removeAccess" # deny-by-default on no response
}
}
Update-MgEntitlementManagementAssignmentPolicy -AccessPackageAssignmentPolicyId $policyId -BodyParameter $lifecycle
The two settings that make this non-negotiable in a regulated shop:
accessReviewTimeoutBehavior = "removeAccess"— if a reviewer ignores the review, access is removed, not kept. Deny-by-default is the whole posture.isAccessRecommendationEnabled = $true— surfaces inactivity (e.g. “no sign-in in 30 days”) so reviewers rubber-stamp less and revoke stale access more.
These reviews are scoped to the access package, which is exactly the granularity auditors want: “show me everyone with Financial Analyst - Standard and who recertified them last quarter.”
7. Custom extensions: Logic App callouts for ticketing and provisioning
EM fires custom extensions at lifecycle stages — request created, request approved, assignment granted, assignment removed — and calls an Azure Logic App you own. This is how you bridge to systems EM does not natively provision: open a ServiceNow ticket, create a mailbox, poke an on-prem entitlement, and (with a callback) even pause the workflow until your external system confirms.
Register the Logic App as a custom extension on the catalog:
New-MgEntitlementManagementAccessPackageCatalogCustomAccessPackageWorkflowExtension `
-AccessPackageCatalogId $catalog.Id -BodyParameter @{
displayName = "ServiceNow ticket on grant"
callbackConfiguration = @{
"@odata.type" = "#microsoft.graph.customExtensionCallbackConfiguration"
durationBeforeTimeout = "PT1H" # wait up to 1h for the Logic App callback
}
authenticationConfiguration = @{
"@odata.type" = "#microsoft.graph.logicAppTriggerEndpointConfiguration"
subscriptionId = "<sub-id>"
resourceGroupName = "rg-identity-governance"
logicAppWorkflowName = "la-em-servicenow"
}
}
Then bind it to a policy stage so it actually fires. On the policy, customExtensionStageSettings maps a stage to the extension:
{
"customExtensionStageSettings": [
{
"stage": "assignmentRequestGranted",
"customExtension": { "id": "<customExtension-id>" }
},
{
"stage": "assignmentRequestRemoved",
"customExtension": { "id": "<customExtension-id-deprovision>" }
}
]
}
The Logic App authenticates EM’s call via its managed identity and the SAS trigger; for callback-style stages, your workflow must POST back to the callbackUri EM passes in, with EntitlementManagement.ReadWrite.All on the calling identity. Use callbacks sparingly — only where you genuinely need EM to wait for an external commit. For fire-and-forget side effects (raise a ticket, send a Teams message), skip the callback and let EM proceed.
8. Reporting, auditing, and bulk ops via the Graph API
EM’s portal is fine for humans and useless for an estate of hundreds of packages. Everything is in Graph; here is the operational query set I actually run.
List every assignment for a package, with the requestor and policy, filtered to currently-delivered access:
az rest --method GET \
--uri "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/assignments?\$filter=accessPackage/id eq '$PKG_ID' and state eq 'delivered'&\$expand=target,assignmentPolicy"
Bulk-create assignments (onboarding a batch of users directly, bypassing the request flow) via assignmentRequests with requestType: adminAdd:
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/assignmentRequests" \
--headers "Content-Type=application/json" \
--body '{
"requestType": "adminAdd",
"assignment": {
"targetId": "<user-objectId>",
"assignmentPolicyId": "'$POLICY_ID'",
"accessPackageId": "'$PKG_ID'"
}
}'
For audit, the request history lives in assignmentRequests and the sign-off trail in the directory audit logs. To prove who approved what, query the request and expand the approval stages:
az rest --method GET \
--uri "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/assignmentRequests/$REQUEST_ID?\$expand=requestor,accessPackage"
And the unified audit log in Log Analytics, if you stream Entra audit logs, gives you the approval decisions with reasoning text:
AuditLogs
| where Category == "EntitlementManagement"
| where ActivityDisplayName in ("Approve access package assignment request",
"Deny access package assignment request")
| extend Pkg = tostring(TargetResources[0].displayName)
| project TimeGenerated, ActivityDisplayName, Pkg,
Approver = InitiatedBy.user.userPrincipalName, Result, ResultReason
| order by TimeGenerated desc
Verify
Confirm the design is actually enforcing, not just configured.
# 1. Package exists and is bound to the expected catalog
Get-MgEntitlementManagementAccessPackage -AccessPackageId $pkg.Id `
-ExpandProperty "catalog,resourceRoleScopes" |
Select-Object DisplayName, @{n='Catalog';e={$_.Catalog.DisplayName}}
# 2. Policies and their target scope (who can request)
Get-MgEntitlementManagementAssignmentPolicy `
-Filter "accessPackage/id eq '$($pkg.Id)'" |
Select-Object DisplayName, AllowedTargetScope
# 3. SoD is live: a user holding the initiator package must be BLOCKED from approver.
# Submit a test request as that user (or have them try in My Access) and confirm
# the request is rejected with an incompatibility error, NOT merely flagged.
az rest --method GET \
--uri "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/accessPackages/$APPROVER_PKG_ID/incompatibleAccessPackages"
# 4. Access reviews scheduled on the policy
az rest --method GET \
--uri "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/assignmentPolicies/$POLICY_ID?\$select=displayName,expiration,reviewSettings"
Functional checks that the API will not tell you:
- Request the package end-to-end in My Access (
myaccess.microsoft.com) as a real internal user and a real external guest; confirm both approval stages fire to the right approvers and that justification is mandatory. - Let an assignment hit its expiration in a test policy (short duration) and confirm group, app, and SharePoint access are all removed, and your deprovision custom extension fired.
- Attempt the incompatible request and confirm it is hard-blocked.
Enterprise scenario
A bank’s platform team ran payment operations out of two legacy security groups: PAY-Initiators and PAY-Approvers. An internal audit found eleven people in both groups — they could raise and approve their own wire transfers. The remediation mandate from risk was: enforce separation of duties technically (not via a quarterly spreadsheet), keep an immutable approval trail, and onboard an external audit firm as time-bound guests with no standing access.
They modeled both sides as access packages in a single Payment Operations catalog, then declared incompatibility on the approver package. Critically, they discovered that turning on incompatibility does not retroactively strip existing dual-holders — those eleven are grandfathered until the next review. So before enforcement they ran additionalAccess to enumerate the overlap, revoked the initiator side for everyone who was primarily an approver, and only then wired the incompatibility plus a quarterly, deny-by-default access review.
# Find users who hold BOTH the initiator and approver packages before enforcing SoD
az rest --method GET \
--uri "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/accessPackages/$APPROVER_PKG_ID/getApplicablePolicyRequirements"
# After remediation, enforce: initiator package is incompatible with approver
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/accessPackages/$APPROVER_PKG_ID/incompatibleAccessPackages/\$ref" \
--headers "Content-Type=application/json" \
--body '{ "@odata.id": "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/accessPackages/'$INITIATOR_PKG_ID'" }'
The external auditors went through an allConfiguredConnectedOrganizationUsers policy with a two-stage approval (engagement sponsor, then control owner) and a 90-day afterDuration expiration — so their access self-destructs at engagement end with zero manual cleanup. A custom extension opened a ServiceNow record on every grant and removal, giving the bank an external, tamper-evident log to hand examiners alongside the EM audit trail. The control that had been a recurring audit finding became a configuration that cannot be violated through the request path.