feat(entra): support AdministrativeUnit membership via entitlements#288
Conversation
…287) Extends the Entra ID provider to manage Administrative Unit (AU) memberships using the existing entitlement contract with Kind = 'AdministrativeUnit'. No Step or Core changes required. Changes: - New-IdleEntraIDAdapter: add GetAdministrativeUnitById, ListUserAdministrativeUnits, AddAdministrativeUnitMember, RemoveAdministrativeUnitMember. Also tighten ListUserGroups to the microsoft.graph.group type-cast endpoint so groups and AUs are never mixed in the same response. - New-IdleEntraIDIdentityProvider: add ResolveAdministrativeUnit helper; extend ListEntitlements to return both Group and AdministrativeUnit entitlements; extend GrantEntitlement, RevokeEntitlement and ResolveEntitlement to dispatch on Kind. - Docs: document Kind = 'AdministrativeUnit', required Graph permissions, supported operations table, workflow examples (Present and Absent), constraints (GUID-only, no bulk support), and Graph endpoints table. - Tests: update both mock adapters with AU methods; add 8 new tests covering grant/revoke/list/idempotency for AdministrativeUnit Kind; rename all Fake → Mock throughout the test file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code Coverage Report
|
…oints All AU operations go through /directory/administrativeUnits/ — not the top-level /administrativeUnits/ path — per Microsoft Graph v1.0 docs. Also corrects the endpoint table in the provider documentation. Affected adapter methods: GetAdministrativeUnitById, AddAdministrativeUnitMember, RemoveAdministrativeUnitMember. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- .DESCRIPTION: mention AU entitlement management alongside groups - .NOTES: add AdministrativeUnit.Read.All and AdministrativeUnit.ReadWrite.All to the required Graph permissions list - .PARAMETER Adapter: replace 'fake' with 'mock' for consistency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…plates Joiner template: - AddToBaselineAdministrativeUnits: assigns user to their department AU on create - Mover_AdjustAdministrativeUnitMemberships: reassigns to new department AU on move Leaver template: - RevokeAdministrativeUnitMemberships_Optional: prunes all AU memberships on offboarding (conditional, opt-in via Request.Intent.RevokeAdministrativeUnitMemberships) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ntt-matthias-fleschuetz
left a comment
There was a problem hiding this comment.
A resolver as for groups is missing to resolve Administrative Unit's Display Names to Ids if not given as Ids. Implement this for consistency to Kind = Group and document properly.
Additionally the test with Prune failed:
OperationStopped: C:\Users\adm-mafl-server\Downloads\idle\IdentityLifecycleEngine\src\IdLE.Core\Private\ConvertTo-IdleWorkflowSteps.ps1:114:13
Line |
114 | throw [System.InvalidOperationException]::new($errorMessa …
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| MissingStepTypeMetadata: Workflow step 'AUPrune' references step type 'IdLE.Step.PruneEntitlement' which has no metadata entry. To resolve this: (1) Import/load the step pack module (IdLE.Steps.*)
| that provides metadata for 'IdLE.Step.PruneEntitlement' via Get-IdleStepMetadataCatalog, OR (2) For host-defined/custom step types only, provide Providers.StepMetadata['IdLE.Step.PruneEntitlement'] =
| @{ RequiredCapabilities = @(...) }.
- Add GetAdministrativeUnitByDisplayName to adapter (matches GetGroupByDisplayName pattern) - Update resolveAdministrativeUnit to fall back to display-name lookup when input is not a GUID; throws on ambiguity - Add GetAdministrativeUnitByDisplayName to all mock adapters in tests - New Describe block: 5 AU resolution tests (GUID, displayName, not-found, ambiguous) - Two new ResolveEntitlement tests for AdministrativeUnit by GUID and displayName - Rewrite entraid-joiner.psd1: replace Desired=@(...) with individual EnsureEntitlement steps (Entitlement+State); use Request.IdentityKeys.UserPrincipalName - Rewrite entraid-leaver.psd1: fix IdentityKey to Request.IdentityKeys.*; replace broken EnsureEntitlement/Id='*' with PruneEntitlements+Kind=Group+Keep=@(); fix stale comments - Update provider-entraID.md: document AU display-name resolution, uniqueness caveat, new Graph endpoint, updated troubleshooting Agent-Logs-Url: https://github.com/blindzero/IdentityLifecycleEngine/sessions/75eab0e8-b564-442a-98e3-522194322681 Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com>
Done in c81f4c7. AU display-name resolver: Added Prune step type: The error you saw ( Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
ntt-matthias-fleschuetz
left a comment
There was a problem hiding this comment.
@copilot PruneEntitlements seems not to support Administrative Units yet:
Error : Exception calling "BulkRevokeEntitlements" with "3" argument(s): "BulkRevokeEntitlements only supports entitlements with Kind 'Group'. Received Kind 'AdministrativeUnit'."
Additionally I realized that Keep = @() syntax is a little awkward.
The Kind = is outside of Keep = already defined. Hence Within Keep = @() only a list of Ids is required, not a list of hashtables with each Kind and Id.
Instead of
Keep = @(
@{ Kind = <Type>; Id = '{{Request.Intent.AdministrativeUnitId}}' }
)or
Keep = @(
@{ Id = '{{Request.Intent.AdministrativeUnitId}}' }
)Keep = @(
'{{Request.Intent.AdministrativeUnitId1}}'
'{{Request.Intent.AdministrativeUnitId2}}'
)should be sufficient?
This applies to Kind = Group as well.
Also make sure that KeepPattern works with Administrative Units as well.
…e DisplayName from Entitlement objects - BulkRevokeEntitlements: route Kind=AdministrativeUnit items through per-item RemoveAdministrativeUnitMember calls (no Graph batch API for AUs); Group items still use Graph $batch; mixed-kind batches accepted; unknown Kind throws. Rename $auResults → $administrativeUnitResults for clarity. - 3 new tests: AU removal via BulkRevokeEntitlements, idempotency, mixed Group+AU batch. - entraid-joiner.psd1: remove DisplayName from all Entitlement objects (EnsureEntitlement step only reads Kind and Id; DisplayName is silently ignored). - provider-entraID.md: update bulk-operation constraints to reflect BulkRevokeEntitlements now supporting AdministrativeUnit kind. Agent-Logs-Url: https://github.com/blindzero/IdentityLifecycleEngine/sessions/057140af-d1e5-4607-abde-8e3375e4bd50 Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com>
Fixed in bdd8460.
|
… PruneEntitlementsEnsureKeep for AUs Agent-Logs-Url: https://github.com/blindzero/IdentityLifecycleEngine/sessions/082cfd9f-7861-41f7-928b-7c7e49c7b989 Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com>
|
Just as a heads up, I was blocked by some firewall rules while working on your feedback. Expand below for details. Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
ntt-matthias-fleschuetz
left a comment
There was a problem hiding this comment.
@copilot Bulk doesnt seem to work anymore, I get on bulk with a case to add again:
Data : {[Index, 0], [StepType, IdLE.Step.PruneEntitlements], [Error, Exception calling "ResolveEntitlement" with "3" argument(s): "Exception calling "ResolveAdministrativeUnit" with "2"
argument(s): "Exception calling "GetAdministrativeUnitById" with "2" argument(s): "Exception calling "InvokeGraphRequest" with "4" argument(s): "Unable to index into an object of type
"System.Net.Http.Headers.HttpResponseHeaders".""""]}
on a full prune I get:
Data : {[Index, 0], [StepType, IdLE.Step.PruneEntitlements], [Error, Exception calling "ListEntitlements" with "2" argument(s): "Exception calling "ResolveIdentity" with "2" argument(s): "Exception
calling "GetUserByUpn" with "2" argument(s): "Exception calling "InvokeGraphRequest" with "4" argument(s): "Unable to index into an object of type
"System.Net.Http.Headers.HttpResponseHeaders".""""]}
Both were executed as with no administrative units assigned at all but doesn't change if AUs are assigned.
…uest catch block Agent-Logs-Url: https://github.com/blindzero/IdentityLifecycleEngine/sessions/f79ed8cc-5a4f-4848-a1dd-1a4ea64da430 Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com>
Fixed in 7a01991. The root cause was in |
ntt-matthias-fleschuetz
left a comment
There was a problem hiding this comment.
@copilot Bulk Remove works again, but not with Keep to add a non added:
- had 2 AUs assigned; Prune with no keep --> all removed
- had AU1 assigned; Prune with keep AU2 Id --> AU1 removed, non added
- had AU2 assigned; Prune with keep AU2 Id --> AU2 kept
- had AU1 assigned; Prune with keep AU2 Name --> AU1 removed, non added
- had AU2 assigned; Prune with keep AU2 Name --> AU2 kept
- no AU assigned; Prune with keep AU2 Name --> non added; changed = false
…plate Agent-Logs-Url: https://github.com/blindzero/IdentityLifecycleEngine/sessions/814c3a60-e6be-4e92-847a-da21c5b227fd Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com>
The behavior you're seeing is by design for To get the "remove all other AUs AND guarantee this AU is present (grant if missing)" behavior, use @{
Name = 'PruneAdministrativeUnitMemberships'
Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' # ← EnsureKeep, not PruneEntitlements
With = @{
Provider = 'Entra'
AuthSessionName = 'Entra'
IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}'
Kind = 'AdministrativeUnit'
Keep = @(
@{ Kind = 'AdministrativeUnit'; Id = '{{Request.Intent.AdministrativeUnitName}}' }
)
}
}Added a |
Co-authored-by: Matthias Fleschütz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR extends the Entra ID provider so Administrative Unit memberships can be represented through the existing entitlement model alongside group memberships.
Changes:
- Adds AU adapter/provider methods and dispatch logic for list/grant/revoke/resolve entitlement flows.
- Updates Entra workflow templates to include Administrative Unit assignment/pruning examples.
- Expands provider documentation and tests for AU entitlement behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 |
Adds AU resolution and entitlement handling in provider methods. |
src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 |
Adds Microsoft Graph AU lookup/list/add/remove adapter calls and tightens group member listing. |
tests/Providers/EntraIDIdentityProvider.Tests.ps1 |
Renames test doubles and adds AU entitlement tests. |
examples/workflows/templates/entraid-joiner.psd1 |
Adds AU assignment examples to joiner/mover template. |
examples/workflows/templates/entraid-leaver.psd1 |
Adds AU pruning examples to leaver template. |
docs/reference/providers/provider-entraID.md |
Documents AU entitlements and related permissions/troubleshooting. |
Comments suppressed due to low confidence (5)
src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1:843
- ListEntitlements now always calls ListUserAdministrativeUnits, so any existing group-only EnsureEntitlement/PruneEntitlements workflow will start requiring AdministrativeUnit.Read.All even when it only manages groups. Since the common steps call ListEntitlements before filtering by Kind, this is a backward-incompatible permission regression unless the AU lookup is optional/lazy or gracefully skipped when AU permissions are unavailable.
$aus = $this.Adapter.ListUserAdministrativeUnits($user.id, $accessToken)
src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1:483
- This idempotency check will not work with the real adapter because InvokeGraphRequest wraps Graph failures in a sanitized message that only includes the HTTP status/request id, not the Graph body text containing “already exists” or “already a member”. A duplicate AU grant will therefore throw instead of returning Changed=$false.
catch {
if ($_.Exception.Message -match 'already exists|already a member') {
return $false
src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1:1000
- This changes BulkRevokeEntitlements/BulkGrantEntitlements to accept AdministrativeUnit entries, but the PR description says the bulk APIs remain Group-only and that AU bulk support was not requested. Please either align the implementation/tests with that stated contract or update the PR/docs to make AU bulk support an intentional public behavior.
# Split entitlements by kind: Groups use the Graph $batch path; AUs are processed per-item
# (no Graph batch API exists for Administrative Unit membership changes).
src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1:1023
- The AU revoke is executed while the method is still iterating and resolving the rest of the input. If a later entitlement has an unsupported Kind or fails resolution, the method throws after already changing earlier AU memberships, leaving callers with partial side effects and no complete per-item result; validate/resolve all entries before performing any AU mutations.
elseif ($normalized.Kind -eq 'AdministrativeUnit') {
$auObjectId = $this.ResolveAdministrativeUnit($normalized.Id, $AuthSession)
$normalized.Id = $auObjectId
try {
$changed = [bool]$this.Adapter.RemoveAdministrativeUnitMember($auObjectId, $user.id, $accessToken)
src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1:1121
- The AU grant is executed during input processing before the rest of the entitlements have been validated/resolved. A later invalid entitlement can make the bulk call throw after this membership was already added, causing partial writes without a complete result set; resolve and validate the full batch before applying AU mutations.
elseif ($normalized.Kind -eq 'AdministrativeUnit') {
$auObjectId = $this.ResolveAdministrativeUnit($normalized.Id, $AuthSession)
$normalized.Id = $auObjectId
try {
$changed = [bool]$this.Adapter.AddAdministrativeUnitMember($auObjectId, $user.id, $accessToken)
…nd param Agent-Logs-Url: https://github.com/blindzero/IdentityLifecycleEngine/sessions/e84281ea-0790-4670-8f0a-504b1f4be93f Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com>
Summary
Closes #287.
Extends the Entra ID provider (
IdLE.Provider.EntraID) to manage Administrative Unit (AU) memberships via the existing entitlement contract, usingKind = 'AdministrativeUnit'. No Step or Core changes are required — the implementation is fully contained within the provider.What changed
New-IdleEntraIDAdapter.ps1ListUserGroupsnow uses the type-cast endpoint/memberOf/microsoft.graph.group(was/memberOf) to ensure groups and AUs are never mixed in the same response.GetAdministrativeUnitById,ListUserAdministrativeUnits,AddAdministrativeUnitMember,RemoveAdministrativeUnitMember.New-IdleEntraIDIdentityProvider.ps1ResolveAdministrativeUnithelper: validates AU exists by GUID and throws a clear, actionable error if not found. GUID-only (display-name lookup not supported for AUs).ListEntitlementsnow returns bothKind = 'Group'andKind = 'AdministrativeUnit'entitlements.GrantEntitlementandRevokeEntitlementdispatch onKind— bothGroupandAdministrativeUnitare supported; any other Kind throws immediately with a descriptive error.ResolveEntitlementextended to canonicaliseKind = 'AdministrativeUnit'entitlements.PruneEntitlementsworks automatically (step-level composition of List + Revoke; no provider changes needed).docs/reference/providers/provider-entraID.mdIdLE.Entitlement.Listcapability table extended withKind = 'AdministrativeUnit'shape.AdministrativeUnit.Read.All,AdministrativeUnit.ReadWrite.All).tests/Providers/EntraIDIdentityProvider.Tests.ps1GrantEntitlement/RevokeEntitlementresult shape, idempotency,ListEntitlementsreflects AU grant/revoke, combined Group+AU listing, unsupported Kind throws.Fake/fakeidentifiers toMock/mockthroughout the file (variable names, type names, function names).Reviewer notes
BulkGrantEntitlements/BulkRevokeEntitlementsremain Group-only — the Graph batch API uses group-specific URLs and AU bulk operations were not requested in the issue. This is documented explicitly.ListUserGroupstype-cast tightening is a correctness fix: the old/memberOfendpoint returned all directory object types (including AUs, directory roles), which were all incorrectly labelledKind = 'Group'.Test results
864 tests passed, 0 failed, 0 skipped.