Identity Azure

Entra ID Governance: Designing Entitlement Management Access Packages with Multi-Stage Approvals and Separation of Duties

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

allExternalUsers is 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 have isExternallyVisible = true or 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:

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:

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:

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.

Checklist

Entra IDIdentity GovernanceEntitlement ManagementAccess PackagesLifecycle

Comments

Keep Reading