From a57203e1176a46ec6681151c2245b07aabeba623 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 16 May 2026 22:54:25 +0200 Subject: [PATCH 01/11] feat(entra): support AdministrativeUnit membership via entitlements (#287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/reference/providers/provider-entraID.md | 95 ++++- .../Private/New-IdleEntraIDAdapter.ps1 | 107 ++++- .../New-IdleEntraIDIdentityProvider.ps1 | 109 +++-- .../EntraIDIdentityProvider.Tests.ps1 | 389 +++++++++++++----- 4 files changed, 548 insertions(+), 152 deletions(-) diff --git a/docs/reference/providers/provider-entraID.md b/docs/reference/providers/provider-entraID.md index d27c71d0..7d77a6f2 100644 --- a/docs/reference/providers/provider-entraID.md +++ b/docs/reference/providers/provider-entraID.md @@ -11,16 +11,16 @@ import EntraLeaver from '@site/../examples/workflows/templates/entraid-leaver.ps ## Summary - **Module:** `IdLE.Provider.EntraID` -- **What it’s for:** Entra ID user lifecycle + group entitlements (Microsoft Graph API) +- **What it’s for:** Entra ID user lifecycle + group and Administrative Unit entitlements (Microsoft Graph API) - **Targets:** Microsoft Entra ID (formerly Azure AD) via Microsoft Graph (v1.0) ## When to use Use this provider when your workflow needs to manage **Entra ID user accounts**, for example: -- **Joiner:** create or update a user, set baseline attributes, assign baseline groups +- **Joiner:** create or update a user, set baseline attributes, assign baseline groups and Administrative Units - **Mover:** update org attributes and managed groups (covered as *optional patterns* inside the Joiner template) -- **Leaver:** disable account, revoke sessions, optional cleanup (groups, delete) +- **Leaver:** disable account, revoke sessions, optional cleanup (groups, Administrative Units, delete) Non-goals: @@ -32,7 +32,7 @@ Non-goals: ### Requirements - Your runtime must be able to supply a **Microsoft Graph auth session** (token/session object) to IdLE -- Graph permissions must allow the actions you intend to run (users + groups) +- Graph permissions must allow the actions you intend to run (users, groups, Administrative Units) ### Install (PowerShell Gallery) @@ -127,14 +127,24 @@ Writes to scoped path: `Request.Context.Providers.. Date: Sat, 16 May 2026 23:06:46 +0200 Subject: [PATCH 02/11] fix(entra): correct Graph API base paths for Administrative Unit endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/reference/providers/provider-entraID.md | 6 +++--- .../Private/New-IdleEntraIDAdapter.ps1 | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/reference/providers/provider-entraID.md b/docs/reference/providers/provider-entraID.md index 7d77a6f2..1a5666ef 100644 --- a/docs/reference/providers/provider-entraID.md +++ b/docs/reference/providers/provider-entraID.md @@ -206,9 +206,9 @@ Administrative Units (AUs) are modelled as `Kind = 'AdministrativeUnit'` entitle | Operation | Endpoint | | --- | --- | | List | `GET /users/{id}/memberOf/microsoft.graph.administrativeUnit` | -| Grant | `POST /administrativeUnits/{id}/members/$ref` | -| Revoke | `DELETE /administrativeUnits/{id}/members/{userId}/$ref` | -| Validate AU exists | `GET /administrativeUnits/{id}` | +| Grant | `POST /directory/administrativeUnits/{id}/members/$ref` | +| Revoke | `DELETE /directory/administrativeUnits/{id}/members/{userId}/$ref` | +| Validate AU exists | `GET /directory/administrativeUnits/{id}` | ## Configuration diff --git a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 index ae229188..2d23d7e7 100644 --- a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 +++ b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 @@ -390,7 +390,7 @@ function New-IdleEntraIDAdapter { [string] $AccessToken ) - $uri = "$($this.BaseUri)/administrativeUnits/$AuId" + $uri = "$($this.BaseUri)/directory/administrativeUnits/$AuId" $uri += '?$select=id,displayName' try { @@ -438,7 +438,7 @@ function New-IdleEntraIDAdapter { [string] $AccessToken ) - $uri = "$($this.BaseUri)/administrativeUnits/$AuObjectId/members/`$ref" + $uri = "$($this.BaseUri)/directory/administrativeUnits/$AuObjectId/members/`$ref" $body = @{ '@odata.id' = "$($this.BaseUri)/users/$UserObjectId" } @@ -470,7 +470,7 @@ function New-IdleEntraIDAdapter { [string] $AccessToken ) - $uri = "$($this.BaseUri)/administrativeUnits/$AuObjectId/members/$UserObjectId/`$ref" + $uri = "$($this.BaseUri)/directory/administrativeUnits/$AuObjectId/members/$UserObjectId/`$ref" try { $null = $this.InvokeGraphRequest('DELETE', $uri, $AccessToken, $null) From 1cb79660f4386622c130016fed10815d225cf589 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 16 May 2026 23:18:44 +0200 Subject: [PATCH 03/11] docs(entra): update comment-based help for AdministrativeUnit support - .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 --- .../Public/New-IdleEntraIDIdentityProvider.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 index 68cd9404..810340c1 100644 --- a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 +++ b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 @@ -9,7 +9,7 @@ function New-IdleEntraIDIdentityProvider { via the host-provided AuthSessionBroker pattern. The provider supports common identity operations (Create, Read, Disable, Enable, Delete) - and group entitlement management (List, Grant, Revoke). + and entitlement management for groups and Administrative Units (List, Grant, Revoke). Identity addressing supports: - objectId (GUID string) - most deterministic @@ -37,7 +37,7 @@ function New-IdleEntraIDIdentityProvider { .PARAMETER Adapter Internal parameter for dependency injection during testing. Allows unit tests to inject - a fake Graph adapter without requiring a real Entra ID environment. + a mock Graph adapter without requiring a real Entra ID environment. .EXAMPLE # Basic usage with delegated auth @@ -82,6 +82,8 @@ function New-IdleEntraIDIdentityProvider { Requires Microsoft Graph API permissions (delegated or app-only): - User.Read.All, User.ReadWrite.All - Group.Read.All, GroupMember.ReadWrite.All + - AdministrativeUnit.Read.All (for ListEntitlements with Kind=AdministrativeUnit) + - AdministrativeUnit.ReadWrite.All (for Grant/Revoke with Kind=AdministrativeUnit) - For delete: User.ReadWrite.All See docs/reference/providers/provider-entraID.md for detailed permission requirements. From d0ed97e3a471e4dcb0e0dae3a7a698085489600d Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 16 May 2026 23:24:28 +0200 Subject: [PATCH 04/11] feat(examples): add AdministrativeUnit steps to Entra ID workflow templates 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 --- .../workflows/templates/entraid-joiner.psd1 | 49 +++++++++++++++++++ .../workflows/templates/entraid-leaver.psd1 | 25 ++++++++++ 2 files changed, 74 insertions(+) diff --git a/examples/workflows/templates/entraid-joiner.psd1 b/examples/workflows/templates/entraid-joiner.psd1 index 3755ce94..9e37bf8a 100644 --- a/examples/workflows/templates/entraid-joiner.psd1 +++ b/examples/workflows/templates/entraid-joiner.psd1 @@ -63,6 +63,26 @@ } } + @{ + Name = 'AddToBaselineAdministrativeUnits' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + + IdentityKey = '{{Request.Intent.UserPrincipalName}}' + + # Baseline Administrative Units control which scoped admins can manage this user. + # Reference AUs by their Entra object ID (GUID). AUs must exist in Entra before use. + Desired = @( + @{ + Kind = 'AdministrativeUnit' + Id = '{{Request.Intent.DepartmentAdministrativeUnitId}}' + } + ) + } + } + @{ Name = 'EnableAccount' Type = 'IdLE.Step.EnableIdentity' @@ -140,6 +160,35 @@ } } + @{ + Name = 'Mover_AdjustAdministrativeUnitMemberships' + Type = 'IdLE.Step.EnsureEntitlement' + Condition = @{ + All = @( + @{ + Equals = @{ + Path = 'Request.Intent.IsMover' + Value = $true + } + } + ) + } + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Intent.UserPrincipalName}}' + + # Reassign to the new department's Administrative Unit on a move. + # AUs are referenced by Entra object ID (GUID) and must exist before use. + Desired = @( + @{ + Kind = 'AdministrativeUnit' + Id = '{{Request.Intent.NewDepartmentAdministrativeUnitId}}' + } + ) + } + } + @{ Name = 'EmitCompletionEvent' Type = 'IdLE.Step.EmitEvent' diff --git a/examples/workflows/templates/entraid-leaver.psd1 b/examples/workflows/templates/entraid-leaver.psd1 index c60da599..561e2f06 100644 --- a/examples/workflows/templates/entraid-leaver.psd1 +++ b/examples/workflows/templates/entraid-leaver.psd1 @@ -99,6 +99,31 @@ } } + # Optional: remove the user from all Administrative Units. + # Use when scoped admin visibility must be revoked as part of offboarding. + @{ + Name = 'RevokeAdministrativeUnitMemberships_Optional' + Type = 'IdLE.Step.PruneEntitlements' + Condition = @{ + All = @( + @{ + Equals = @{ + Path = 'Request.Intent.RevokeAdministrativeUnitMemberships' + Value = $true + } + } + ) + } + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Intent.UserPrincipalName}}' + Kind = 'AdministrativeUnit' + # Keep = @() means remove all AU memberships. + Keep = @() + } + } + # Optional delete (requires provider to be created with -AllowDelete) @{ Name = 'DeleteAccount_Optional' From c81f4c7721a8672eaf0f6c01039ca0e083f44e8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 22:17:37 +0000 Subject: [PATCH 05/11] feat(entra): AU display-name resolver, fix example template syntax - 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> --- docs/reference/providers/provider-entraID.md | 10 +- .../workflows/templates/entraid-joiner.psd1 | 145 ++++++++++-------- .../workflows/templates/entraid-leaver.psd1 | 37 ++--- .../Private/New-IdleEntraIDAdapter.ps1 | 28 ++++ .../New-IdleEntraIDIdentityProvider.ps1 | 9 +- .../EntraIDIdentityProvider.Tests.ps1 | 108 ++++++++++++- 6 files changed, 247 insertions(+), 90 deletions(-) diff --git a/docs/reference/providers/provider-entraID.md b/docs/reference/providers/provider-entraID.md index 1a5666ef..066e6275 100644 --- a/docs/reference/providers/provider-entraID.md +++ b/docs/reference/providers/provider-entraID.md @@ -197,8 +197,8 @@ Administrative Units (AUs) are modelled as `Kind = 'AdministrativeUnit'` entitle ### Constraints -- Administrative Units must be **pre-created in Entra** before being referenced in a workflow. The provider validates AU existence and throws a clear, actionable error if the AU ID is not found. -- AUs are referenced **only by their object ID (GUID)** — display-name lookup is not supported, as AU names are not guaranteed to be unique within a tenant. +- Administrative Units must be **pre-created in Entra** before being referenced in a workflow. The provider validates AU existence and throws a clear, actionable error if the AU is not found. +- AUs can be referenced by **object ID (GUID)** or by **displayName**. Display-name lookup is supported for convenience, but AU display names are not guaranteed to be unique within a tenant — if multiple AUs share the same name, the provider throws an error and requires the object ID to be used instead. - Bulk operations (`BulkGrantEntitlements` / `BulkRevokeEntitlements`) are Group-only and do not support `Kind = 'AdministrativeUnit'`. Use individual `GrantEntitlement` / `RevokeEntitlement` calls for AU membership changes. ### Graph endpoints used @@ -208,7 +208,8 @@ Administrative Units (AUs) are modelled as `Kind = 'AdministrativeUnit'` entitle | List | `GET /users/{id}/memberOf/microsoft.graph.administrativeUnit` | | Grant | `POST /directory/administrativeUnits/{id}/members/$ref` | | Revoke | `DELETE /directory/administrativeUnits/{id}/members/{userId}/$ref` | -| Validate AU exists | `GET /directory/administrativeUnits/{id}` | +| Validate AU exists by ID | `GET /directory/administrativeUnits/{id}` | +| Resolve AU by displayName | `GET /directory/administrativeUnits?$filter=displayName eq '...'` | ## Configuration @@ -256,4 +257,5 @@ Mover scenarios are integrated as **optional patterns** in the Joiner template. - **Auth session not found**: check `AuthSessionName` matches your runtime/broker configuration. - **Delete doesn’t work**: deletion is opt-in. Create the provider with `-AllowDelete` and only use delete with a privileged auth role. - **Group cleanup is disruptive**: only enable revoke/remove operations when you fully understand the impact (prefer managed allow-lists). -- **Administrative Unit not found**: the AU object ID must exist in Entra before the workflow runs. The provider throws a descriptive error if the AU is not found — check the GUID and ensure `AdministrativeUnit.Read.All` permission is granted. +- **Administrative Unit not found**: the AU must exist in Entra before the workflow runs. When referencing by objectId, confirm the GUID is correct. When referencing by displayName, confirm the name matches exactly and `AdministrativeUnit.Read.All` permission is granted. +- **Multiple AUs match displayName**: AU display names are not unique in Entra. If multiple AUs share the same name, use the objectId (GUID) instead to ensure deterministic lookup. diff --git a/examples/workflows/templates/entraid-joiner.psd1 b/examples/workflows/templates/entraid-joiner.psd1 index 9e37bf8a..6cd265f6 100644 --- a/examples/workflows/templates/entraid-joiner.psd1 +++ b/examples/workflows/templates/entraid-joiner.psd1 @@ -1,7 +1,7 @@ @{ Name = 'EntraID Joiner - Complete Onboarding' LifecycleEvent = 'Joiner' - Description = 'Creates or updates an Entra ID user with baseline attributes and group memberships. Includes optional mover patterns.' + Description = 'Creates or updates an Entra ID user with baseline attributes, group memberships, and Administrative Unit assignments. Includes optional mover patterns.' Steps = @( @{ @@ -10,9 +10,8 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - - # Using UPN keeps it human-friendly in templates. - IdentityKey = '{{Request.Intent.UserPrincipalName}}' + + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' Attributes = @{ UserPrincipalName = '{{Request.Intent.UserPrincipalName}}' @@ -37,49 +36,53 @@ } } + # Baseline groups: add one EnsureEntitlement step per group. @{ - Name = 'AddToBaselineGroups' + Name = 'AddToAllEmployeesGroup' Type = 'IdLE.Step.EnsureEntitlement' With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - - # Using UPN keeps it human-friendly in templates. - IdentityKey = '{{Request.Intent.UserPrincipalName}}' - - # Baseline groups should be explicit and driven by request input (no hardcoding). - Desired = @( - @{ - Kind = 'Group' - Id = '{{Request.Intent.AllEmployeesGroupId}}' - DisplayName = '{{Request.Intent.AllEmployeesGroupName}}' - } - @{ - Kind = 'Group' - Id = '{{Request.Intent.DepartmentGroupId}}' - DisplayName = '{{Request.Intent.DepartmentGroupName}}' - } - ) + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' + Entitlement = @{ + Kind = 'Group' + Id = '{{Request.Intent.AllEmployeesGroupId}}' + DisplayName = '{{Request.Intent.AllEmployeesGroupName}}' + } + State = 'Present' } } @{ - Name = 'AddToBaselineAdministrativeUnits' + Name = 'AddToDepartmentGroup' Type = 'IdLE.Step.EnsureEntitlement' With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' + Entitlement = @{ + Kind = 'Group' + Id = '{{Request.Intent.DepartmentGroupId}}' + DisplayName = '{{Request.Intent.DepartmentGroupName}}' + } + State = 'Present' + } + } - IdentityKey = '{{Request.Intent.UserPrincipalName}}' - - # Baseline Administrative Units control which scoped admins can manage this user. - # Reference AUs by their Entra object ID (GUID). AUs must exist in Entra before use. - Desired = @( - @{ - Kind = 'AdministrativeUnit' - Id = '{{Request.Intent.DepartmentAdministrativeUnitId}}' - } - ) + # Baseline Administrative Unit: controls which scoped admins can manage this user. + # AUs can be referenced by their GUID objectId or by displayName (tenant-unique names only). + @{ + Name = 'AddToDepartmentAdministrativeUnit' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' + Entitlement = @{ + Kind = 'AdministrativeUnit' + Id = '{{Request.Intent.DepartmentAdministrativeUnitId}}' + } + State = 'Present' } } @@ -89,7 +92,7 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Intent.UserPrincipalName}}' + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' } } @@ -115,19 +118,20 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Intent.UserPrincipalName}}' + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' Attributes = @{ Department = '{{Request.Intent.NewDepartment}}' - JobTitle = '{{Request.Intent.NewJobTitle}}' - OfficeLocation = '{{Request.Intent.NewOfficeLocation}}' - Manager = '{{Request.Intent.NewManagerObjectId}}' + JobTitle = '{{Request.Intent.NewJobTitle}}' + OfficeLocation = '{{Request.Intent.NewOfficeLocation}}' + Manager = '{{Request.Intent.NewManagerObjectId}}' } } } + # Add one EnsureEntitlement step per group change required on a mover. @{ - Name = 'Mover_AdjustManagedGroups' + Name = 'Mover_AddToDepartmentGroup' Type = 'IdLE.Step.EnsureEntitlement' Condition = @{ All = @( @@ -142,26 +146,18 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Intent.UserPrincipalName}}' - - # Optional: add department/project groups as part of a move. - Desired = @( - @{ - Kind = 'Group' - Id = '{{Request.Intent.DepartmentGroupId}}' - DisplayName = '{{Request.Intent.DepartmentGroupName}}' - } - @{ - Kind = 'Group' - Id = '{{Request.Intent.ProjectGroupId}}' - DisplayName = '{{Request.Intent.ProjectGroupName}}' - } - ) + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' + Entitlement = @{ + Kind = 'Group' + Id = '{{Request.Intent.DepartmentGroupId}}' + DisplayName = '{{Request.Intent.DepartmentGroupName}}' + } + State = 'Present' } } @{ - Name = 'Mover_AdjustAdministrativeUnitMemberships' + Name = 'Mover_AddToProjectGroup' Type = 'IdLE.Step.EnsureEntitlement' Condition = @{ All = @( @@ -176,25 +172,48 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Intent.UserPrincipalName}}' + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' + Entitlement = @{ + Kind = 'Group' + Id = '{{Request.Intent.ProjectGroupId}}' + DisplayName = '{{Request.Intent.ProjectGroupName}}' + } + State = 'Present' + } + } - # Reassign to the new department's Administrative Unit on a move. - # AUs are referenced by Entra object ID (GUID) and must exist before use. - Desired = @( + # Reassign to the new department's Administrative Unit on a move. + @{ + Name = 'Mover_AddToNewDepartmentAdministrativeUnit' + Type = 'IdLE.Step.EnsureEntitlement' + Condition = @{ + All = @( @{ - Kind = 'AdministrativeUnit' - Id = '{{Request.Intent.NewDepartmentAdministrativeUnitId}}' + Equals = @{ + Path = 'Request.Intent.IsMover' + Value = $true + } } ) } + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' + Entitlement = @{ + Kind = 'AdministrativeUnit' + Id = '{{Request.Intent.NewDepartmentAdministrativeUnitId}}' + } + State = 'Present' + } } @{ Name = 'EmitCompletionEvent' Type = 'IdLE.Step.EmitEvent' With = @{ - Message = 'EntraID user {{Request.Intent.UserPrincipalName}} created/updated successfully.' + Message = 'EntraID user {{Request.IdentityKeys.UserPrincipalName}} created/updated successfully.' } } ) -} \ No newline at end of file +} diff --git a/examples/workflows/templates/entraid-leaver.psd1 b/examples/workflows/templates/entraid-leaver.psd1 index 561e2f06..f7784638 100644 --- a/examples/workflows/templates/entraid-leaver.psd1 +++ b/examples/workflows/templates/entraid-leaver.psd1 @@ -10,9 +10,7 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - - # Prefer ObjectId for leaver (stable), but you may also use UPN if your provider supports it. - IdentityKey = '{{Request.Intent.UserPrincipalName}}' + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' } } @@ -22,7 +20,7 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Intent.UserPrincipalName}}' + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' } } @@ -32,7 +30,7 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Intent.UserPrincipalName}}' + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' Attributes = @{ DisplayName = '{{Request.Intent.DisplayName}} (LEAVER)' Manager = $null @@ -40,11 +38,11 @@ } } - # Optional & potentially disruptive: - # Setting Desired = @() will remove *all* group memberships the provider manages. + # Optional: remove ALL group memberships — use when no specific groups need to be retained. + # PruneEntitlements with an empty Keep list removes every group the provider sees. @{ Name = 'RevokeAllGroupMemberships_Optional' - Type = 'IdLE.Step.EnsureEntitlement' + Type = 'IdLE.Step.PruneEntitlements' Condition = @{ All = @( @{ @@ -58,18 +56,15 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Intent.UserPrincipalName}}' - Entitlement = @{ - Kind = 'Group'; - Id = '*' - } - State = 'Absent' + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' + Kind = 'Group' + Keep = @() } } - # Optional & potentially disruptive: + # Optional: remove all groups EXCEPT a retain set AND ensure retain set is present. # PruneEntitlementsEnsureKeep removes all groups except the keep set AND ensures - # explicit Keep items are present. Use PruneEntitlements if you only need removal. + # explicit Keep items are present. Use PruneEntitlements (above) if you only need removal. @{ Name = 'PruneGroupMemberships_Optional' Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' @@ -86,7 +81,7 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Intent.UserPrincipalName}}' + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' Kind = 'Group' # Retain this specific leaver group and ensure it is present. @@ -117,9 +112,8 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Intent.UserPrincipalName}}' + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' Kind = 'AdministrativeUnit' - # Keep = @() means remove all AU memberships. Keep = @() } } @@ -141,7 +135,7 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Tier0' } - IdentityKey = '{{Request.Intent.UserPrincipalName}}' + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' } } @@ -149,8 +143,9 @@ Name = 'EmitCompletionEvent' Type = 'IdLE.Step.EmitEvent' With = @{ - Message = 'EntraID user {{Request.Intent.UserPrincipalName}} offboarding completed.' + Message = 'EntraID user {{Request.IdentityKeys.UserPrincipalName}} offboarding completed.' } } ) } + diff --git a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 index 2d23d7e7..66f0127e 100644 --- a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 +++ b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 @@ -405,6 +405,34 @@ function New-IdleEntraIDAdapter { } } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name GetAdministrativeUnitByDisplayName -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $DisplayName, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccessToken + ) + + $encodedName = [System.Net.WebUtility]::UrlEncode($DisplayName) + $uri = "$($this.BaseUri)/directory/administrativeUnits?`$filter=displayName eq '$encodedName'" + $uri += '&$select=id,displayName' + + $aus = $this.InvokeGraphRequest('GET', $uri, $AccessToken, $null) + + if (-not $aus.value -or $aus.value.Count -eq 0) { + return $null + } + + if ($aus.value.Count -gt 1) { + throw "Multiple Administrative Units found with displayName '$DisplayName'. Use objectId for deterministic lookup." + } + + return $aus.value[0] + } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name ListUserAdministrativeUnits -Value { param( [Parameter(Mandatory)] diff --git a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 index 810340c1..be9917ca 100644 --- a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 +++ b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 @@ -299,6 +299,7 @@ function New-IdleEntraIDIdentityProvider { $accessToken = $this.ExtractAccessToken($AuthSession) + # Try as objectId first $guid = [System.Guid]::Empty if ([System.Guid]::TryParse($AuId, [ref]$guid)) { $au = $this.Adapter.GetAdministrativeUnitById($AuId, $accessToken) @@ -308,7 +309,13 @@ function New-IdleEntraIDIdentityProvider { throw "Administrative Unit with objectId '$AuId' not found." } - throw "Administrative Unit '$AuId' is not a valid objectId (GUID). Administrative Units must be referenced by their Entra object ID." + # Try as displayName + $au = $this.Adapter.GetAdministrativeUnitByDisplayName($AuId, $accessToken) + if ($null -ne $au) { + return $au.id + } + + throw "Administrative Unit '$AuId' not found." } $provider = [pscustomobject]@{ diff --git a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 index 1cbca76e..70b18648 100644 --- a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 +++ b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 @@ -173,6 +173,11 @@ Describe 'EntraID identity provider - Contract tests' { return @{ id = $AuId; displayName = "AU $AuId" } } + $mockAdapter | Add-Member -MemberType ScriptMethod -Name GetAdministrativeUnitByDisplayName -Value { + param($DisplayName, $AccessToken) + return @{ id = "resolved-au-$DisplayName"; displayName = $DisplayName } + } + $mockAdapter | Add-Member -MemberType ScriptMethod -Name ListUserAdministrativeUnits -Value { param($ObjectId, $AccessToken) $key = "aus:$ObjectId" @@ -712,6 +717,79 @@ Describe 'EntraID identity provider - Group resolution' { } } +Describe 'EntraID identity provider - Administrative Unit resolution' { + BeforeEach { + $mockAdapter = [pscustomobject]@{ + PSTypeName = 'IdLE.EntraIDAdapter.Mock' + } + + $mockAdapter | Add-Member -MemberType ScriptMethod -Name GetAdministrativeUnitById -Value { + param($AuId, $AccessToken) + $guid = [System.Guid]::Empty + if ([System.Guid]::TryParse($AuId, [ref]$guid)) { + return @{ id = $AuId; displayName = "AU $AuId" } + } + return $null + } + + $mockAdapter | Add-Member -MemberType ScriptMethod -Name GetAdministrativeUnitByDisplayName -Value { + param($DisplayName, $AccessToken) + if ($DisplayName -eq 'AmbiguousAU') { + throw "Multiple Administrative Units found with displayName '$DisplayName'. Use objectId for deterministic lookup." + } + if ($DisplayName -eq 'MissingAU') { + return $null + } + return @{ id = "resolved-au-$DisplayName"; displayName = $DisplayName } + } + + $script:AuTestAdapter = $mockAdapter + } + + Context 'Lookups' { + It 'Resolves Administrative Unit by objectId' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:AuTestAdapter + + $auGuid = [guid]::NewGuid().ToString() + $resolvedId = $provider.ResolveAdministrativeUnit($auGuid, 'mock-token') + + $resolvedId | Should -Be $auGuid + } + + It 'Resolves Administrative Unit by displayName' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:AuTestAdapter + + $resolvedId = $provider.ResolveAdministrativeUnit('EU Region Admins', 'mock-token') + $resolvedId | Should -Be 'resolved-au-EU Region Admins' + } + + It 'Throws when GUID objectId is not found' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:AuTestAdapter + + $missingGuid = [guid]::NewGuid().ToString() + # Override GetAdministrativeUnitById to return null for this GUID + $script:AuTestAdapter | Add-Member -MemberType ScriptMethod -Name GetAdministrativeUnitById -Value { + param($AuId, $AccessToken) + return $null + } -Force + + { $provider.ResolveAdministrativeUnit($missingGuid, 'mock-token') } | Should -Throw '*not found*' + } + + It 'Throws when displayName is not found' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:AuTestAdapter + + { $provider.ResolveAdministrativeUnit('MissingAU', 'mock-token') } | Should -Throw '*not found*' + } + + It 'Throws when multiple Administrative Units match displayName' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:AuTestAdapter + + { $provider.ResolveAdministrativeUnit('AmbiguousAU', 'mock-token') } | Should -Throw '*Multiple Administrative Units found*' + } + } +} + Describe 'EntraID identity provider - Entitlement operations' { BeforeAll { function New-MockEntraIDAdapterForEntitlements { @@ -759,6 +837,11 @@ Describe 'EntraID identity provider - Entitlement operations' { return @{ id = $AuId; displayName = "AU $AuId" } } + $adapter | Add-Member -MemberType ScriptMethod -Name GetAdministrativeUnitByDisplayName -Value { + param($DisplayName, $AccessToken) + return @{ id = "resolved-au-$DisplayName"; displayName = $DisplayName } + } + $adapter | Add-Member -MemberType ScriptMethod -Name ListUserAdministrativeUnits -Value { param($ObjectId, $AccessToken) $key = "aus:$ObjectId" @@ -1423,7 +1506,7 @@ Describe 'EntraID identity provider - ResolveEntitlement' { Context 'ResolveEntitlement behavior' { BeforeAll { - # Mock adapter that returns canonical objectId for groups (mimics real Graph lookup) + # Mock adapter that returns canonical objectId for groups and AUs (mimics real Graph lookup) $mockAdapter = [pscustomobject]@{ PSTypeName = 'IdLE.EntraIDAdapter.Mock'; Store = @{} } $mockAdapter | Add-Member -MemberType ScriptMethod -Name GetGroupById -Value { param($GroupId, $AccessToken) @@ -1433,6 +1516,14 @@ Describe 'EntraID identity provider - ResolveEntitlement' { param($DisplayName, $AccessToken) return @{ id = "resolved-$DisplayName"; displayName = $DisplayName } } + $mockAdapter | Add-Member -MemberType ScriptMethod -Name GetAdministrativeUnitById -Value { + param($AuId, $AccessToken) + return @{ id = $AuId; displayName = "AU $AuId" } + } + $mockAdapter | Add-Member -MemberType ScriptMethod -Name GetAdministrativeUnitByDisplayName -Value { + param($DisplayName, $AccessToken) + return @{ id = "resolved-au-$DisplayName"; displayName = $DisplayName } + } $script:NormProvider = New-IdleEntraIDIdentityProvider -Adapter $mockAdapter } @@ -1451,6 +1542,21 @@ Describe 'EntraID identity provider - ResolveEntitlement' { $result.Id | Should -Be 'resolved-HR Team' } + It 'Normalizes an AdministrativeUnit entitlement with a GUID Id to canonical objectId' { + $auGuid = [guid]::NewGuid().ToString() + $ent = @{ Kind = 'AdministrativeUnit'; Id = $auGuid } + $result = $script:NormProvider.ResolveEntitlement('AdministrativeUnit', $ent, 'mock-token') + $result.Kind | Should -Be 'AdministrativeUnit' + $result.Id | Should -Be $auGuid + } + + It 'Normalizes an AdministrativeUnit entitlement with a displayName to canonical objectId' { + $ent = @{ Kind = 'AdministrativeUnit'; Id = 'EU Region Admins' } + $result = $script:NormProvider.ResolveEntitlement('AdministrativeUnit', $ent, 'mock-token') + $result.Kind | Should -Be 'AdministrativeUnit' + $result.Id | Should -Be 'resolved-au-EU Region Admins' + } + It 'Returns entitlement unchanged when Kind is not Group' { $ent = [pscustomobject]@{ Kind = 'License'; Id = 'Some-License-Id' } $result = $script:NormProvider.ResolveEntitlement('License', $ent, 'mock-token') From bdd846057571b1743dd04bec3166e5e748cb69ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 22:40:52 +0000 Subject: [PATCH 06/11] fix(entra): BulkRevokeEntitlements supports AdministrativeUnit; remove DisplayName from Entitlement objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- docs/reference/providers/provider-entraID.md | 3 +- .../workflows/templates/entraid-joiner.psd1 | 20 ++-- .../New-IdleEntraIDIdentityProvider.ps1 | 96 +++++++++++++------ .../EntraIDIdentityProvider.Tests.ps1 | 52 ++++++++++ 4 files changed, 127 insertions(+), 44 deletions(-) diff --git a/docs/reference/providers/provider-entraID.md b/docs/reference/providers/provider-entraID.md index 066e6275..63c2b81b 100644 --- a/docs/reference/providers/provider-entraID.md +++ b/docs/reference/providers/provider-entraID.md @@ -199,7 +199,8 @@ Administrative Units (AUs) are modelled as `Kind = 'AdministrativeUnit'` entitle - Administrative Units must be **pre-created in Entra** before being referenced in a workflow. The provider validates AU existence and throws a clear, actionable error if the AU is not found. - AUs can be referenced by **object ID (GUID)** or by **displayName**. Display-name lookup is supported for convenience, but AU display names are not guaranteed to be unique within a tenant — if multiple AUs share the same name, the provider throws an error and requires the object ID to be used instead. -- Bulk operations (`BulkGrantEntitlements` / `BulkRevokeEntitlements`) are Group-only and do not support `Kind = 'AdministrativeUnit'`. Use individual `GrantEntitlement` / `RevokeEntitlement` calls for AU membership changes. +- `BulkGrantEntitlements` is Group-only and does not support `Kind = 'AdministrativeUnit'` (the Graph batch API uses group-specific URLs). Use individual `GrantEntitlement` calls for AU membership changes. +- `BulkRevokeEntitlements` supports both `Kind = 'Group'` (batch path) and `Kind = 'AdministrativeUnit'` (per-item path, no batch API exists for AUs). Mixed-kind batches are accepted. ### Graph endpoints used diff --git a/examples/workflows/templates/entraid-joiner.psd1 b/examples/workflows/templates/entraid-joiner.psd1 index 6cd265f6..1cfc1bca 100644 --- a/examples/workflows/templates/entraid-joiner.psd1 +++ b/examples/workflows/templates/entraid-joiner.psd1 @@ -45,9 +45,8 @@ AuthSessionOptions = @{ Role = 'Admin' } IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' Entitlement = @{ - Kind = 'Group' - Id = '{{Request.Intent.AllEmployeesGroupId}}' - DisplayName = '{{Request.Intent.AllEmployeesGroupName}}' + Kind = 'Group' + Id = '{{Request.Intent.AllEmployeesGroupId}}' } State = 'Present' } @@ -61,9 +60,8 @@ AuthSessionOptions = @{ Role = 'Admin' } IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' Entitlement = @{ - Kind = 'Group' - Id = '{{Request.Intent.DepartmentGroupId}}' - DisplayName = '{{Request.Intent.DepartmentGroupName}}' + Kind = 'Group' + Id = '{{Request.Intent.DepartmentGroupId}}' } State = 'Present' } @@ -148,9 +146,8 @@ AuthSessionOptions = @{ Role = 'Admin' } IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' Entitlement = @{ - Kind = 'Group' - Id = '{{Request.Intent.DepartmentGroupId}}' - DisplayName = '{{Request.Intent.DepartmentGroupName}}' + Kind = 'Group' + Id = '{{Request.Intent.DepartmentGroupId}}' } State = 'Present' } @@ -174,9 +171,8 @@ AuthSessionOptions = @{ Role = 'Admin' } IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' Entitlement = @{ - Kind = 'Group' - Id = '{{Request.Intent.ProjectGroupId}}' - DisplayName = '{{Request.Intent.ProjectGroupName}}' + Kind = 'Group' + Id = '{{Request.Intent.ProjectGroupId}}' } State = 'Present' } diff --git a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 index be9917ca..28d6c9b7 100644 --- a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 +++ b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 @@ -996,49 +996,83 @@ function New-IdleEntraIDIdentityProvider { $accessToken = $this.ExtractAccessToken($AuthSession) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) - $operations = @() + # 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). + $groupOperations = @() + $administrativeUnitResults = @() + foreach ($ent in $Entitlements) { $normalized = $this.ConvertToEntitlement($ent) - if ($null -ne $normalized.Kind -and $normalized.Kind -ne 'Group') { + if (-not $normalized.Kind) { $normalized.Kind = 'Group' } + + if ($normalized.Kind -eq 'Group') { + $groupObjectId = $this.ResolveGroup($normalized.Id, $AuthSession) + $normalized.Id = $groupObjectId + $groupOperations += @{ + RequestId = [guid]::NewGuid().ToString('N').Substring(0, 8) + GroupObjectId = $groupObjectId + UserObjectId = $user.id + Action = 'remove' + Entitlement = $normalized + } + } + elseif ($normalized.Kind -eq 'AdministrativeUnit') { + $auObjectId = $this.ResolveAdministrativeUnit($normalized.Id, $AuthSession) + $normalized.Id = $auObjectId + try { + $changed = [bool]$this.Adapter.RemoveAdministrativeUnitMember($auObjectId, $user.id, $accessToken) + $administrativeUnitResults += [pscustomobject]@{ + PSTypeName = 'IdLE.BulkProviderResult' + Operation = 'RevokeEntitlement' + IdentityKey = $IdentityKey + Changed = $changed + Error = $null + Entitlement = $normalized + } + } + catch { + $administrativeUnitResults += [pscustomobject]@{ + PSTypeName = 'IdLE.BulkProviderResult' + Operation = 'RevokeEntitlement' + IdentityKey = $IdentityKey + Changed = $false + Error = $_.Exception.Message + Entitlement = $normalized + } + } + } + else { throw [System.ArgumentException]::new( - "BulkRevokeEntitlements only supports entitlements with Kind 'Group'. Received Kind '$($normalized.Kind)'." + "BulkRevokeEntitlements only supports entitlements with Kind 'Group' or 'AdministrativeUnit'. Received Kind '$($normalized.Kind)'." ) } - $groupObjectId = $this.ResolveGroup($normalized.Id, $AuthSession) - $normalized.Id = $groupObjectId - $operations += @{ - RequestId = [guid]::NewGuid().ToString('N').Substring(0, 8) - GroupObjectId = $groupObjectId - UserObjectId = $user.id - Action = 'remove' - Entitlement = $normalized - } } - if ($operations.Count -eq 0) { - return @() - } + $results = @() - $batchResults = $this.Adapter.BatchMembershipChanges($operations, $accessToken) + if ($groupOperations.Count -gt 0) { + $batchResults = $this.Adapter.BatchMembershipChanges($groupOperations, $accessToken) - # Build lookup table for operations by RequestId to avoid O(n²) Where-Object scans - $operationByRequestId = @{} - foreach ($op in $operations) { - $operationByRequestId[$op.RequestId] = $op - } + # Build lookup table by RequestId to avoid O(n²) Where-Object scans + $operationByRequestId = @{} + foreach ($op in $groupOperations) { + $operationByRequestId[$op.RequestId] = $op + } - $results = @() - foreach ($br in $batchResults) { - $op = $operationByRequestId[$br.RequestId] - $results += [pscustomobject]@{ - PSTypeName = 'IdLE.BulkProviderResult' - Operation = 'RevokeEntitlement' - IdentityKey = $IdentityKey - Changed = $br.Changed - Error = $br.Error - Entitlement = $op.Entitlement + foreach ($br in $batchResults) { + $op = $operationByRequestId[$br.RequestId] + $results += [pscustomobject]@{ + PSTypeName = 'IdLE.BulkProviderResult' + Operation = 'RevokeEntitlement' + IdentityKey = $IdentityKey + Changed = $br.Changed + Error = $br.Error + Entitlement = $op.Entitlement + } } } + + $results += $administrativeUnitResults return $results } -Force diff --git a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 index 70b18648..b119f982 100644 --- a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 +++ b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 @@ -1094,6 +1094,58 @@ Describe 'EntraID identity provider - Entitlement operations' { $results[0].Error | Should -BeNullOrEmpty } + It 'BulkRevokeEntitlements removes an AdministrativeUnit membership and returns Changed=$true' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + $auId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GrantEntitlement($userId, @{ Kind = 'AdministrativeUnit'; Id = $auId }) + + $results = $script:EntProvider.BulkRevokeEntitlements($userId, @( + @{ Kind = 'AdministrativeUnit'; Id = $auId } + )) + + @($results).Count | Should -Be 1 + $results[0].Changed | Should -Be $true + $results[0].Error | Should -BeNullOrEmpty + $results[0].Entitlement.Kind | Should -Be 'AdministrativeUnit' + + @($script:EntProvider.ListEntitlements($userId) | Where-Object { $_.Kind -eq 'AdministrativeUnit' -and $_.Id -eq $auId }).Count | Should -Be 0 + } + + It 'BulkRevokeEntitlements is idempotent for AdministrativeUnit (not a member → Changed=$false)' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + $auId = [guid]::NewGuid().ToString() + + $results = $script:EntProvider.BulkRevokeEntitlements($userId, @( + @{ Kind = 'AdministrativeUnit'; Id = $auId } + )) + + @($results).Count | Should -Be 1 + $results[0].Changed | Should -Be $false + $results[0].Error | Should -BeNullOrEmpty + } + + It 'BulkRevokeEntitlements handles mixed Group and AdministrativeUnit in a single call' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + $groupId = [guid]::NewGuid().ToString() + $auId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GrantEntitlement($userId, @{ Kind = 'Group'; Id = $groupId }) + [void]$script:EntProvider.GrantEntitlement($userId, @{ Kind = 'AdministrativeUnit'; Id = $auId }) + + $results = $script:EntProvider.BulkRevokeEntitlements($userId, @( + @{ Kind = 'Group'; Id = $groupId }, + @{ Kind = 'AdministrativeUnit'; Id = $auId } + )) + + @($results).Count | Should -Be 2 + ($results | Where-Object { $_.Changed -eq $true }).Count | Should -Be 2 + + @($script:EntProvider.ListEntitlements($userId) | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq $groupId }).Count | Should -Be 0 + @($script:EntProvider.ListEntitlements($userId) | Where-Object { $_.Kind -eq 'AdministrativeUnit' -and $_.Id -eq $auId }).Count | Should -Be 0 + } + It 'GrantEntitlement returns stable result shape with Kind=AdministrativeUnit' { $userId = [guid]::NewGuid().ToString() [void]$script:EntProvider.GetIdentity($userId) From fe6cf25dd3fffb7709333d24a47b836b208619b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 23:10:22 +0000 Subject: [PATCH 07/11] feat(entra): BulkGrantEntitlements supports AdministrativeUnit; fixes 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> --- docs/reference/providers/provider-entraID.md | 3 +- .../New-IdleEntraIDIdentityProvider.ps1 | 92 +++++++++++++------ .../EntraIDIdentityProvider.Tests.ps1 | 50 ++++++++++ 3 files changed, 117 insertions(+), 28 deletions(-) diff --git a/docs/reference/providers/provider-entraID.md b/docs/reference/providers/provider-entraID.md index 63c2b81b..0d186808 100644 --- a/docs/reference/providers/provider-entraID.md +++ b/docs/reference/providers/provider-entraID.md @@ -199,8 +199,7 @@ Administrative Units (AUs) are modelled as `Kind = 'AdministrativeUnit'` entitle - Administrative Units must be **pre-created in Entra** before being referenced in a workflow. The provider validates AU existence and throws a clear, actionable error if the AU is not found. - AUs can be referenced by **object ID (GUID)** or by **displayName**. Display-name lookup is supported for convenience, but AU display names are not guaranteed to be unique within a tenant — if multiple AUs share the same name, the provider throws an error and requires the object ID to be used instead. -- `BulkGrantEntitlements` is Group-only and does not support `Kind = 'AdministrativeUnit'` (the Graph batch API uses group-specific URLs). Use individual `GrantEntitlement` calls for AU membership changes. -- `BulkRevokeEntitlements` supports both `Kind = 'Group'` (batch path) and `Kind = 'AdministrativeUnit'` (per-item path, no batch API exists for AUs). Mixed-kind batches are accepted. +- `BulkGrantEntitlements` and `BulkRevokeEntitlements` both support `Kind = 'Group'` (Graph batch path) and `Kind = 'AdministrativeUnit'` (per-item path — no Graph batch API exists for AU membership changes). Mixed-kind batches are accepted in both methods. This ensures `PruneEntitlementsEnsureKeep` works correctly for AUs (it calls both bulk methods internally). ### Graph endpoints used diff --git a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 index 28d6c9b7..0ed14d98 100644 --- a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 +++ b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 @@ -1094,43 +1094,83 @@ function New-IdleEntraIDIdentityProvider { $accessToken = $this.ExtractAccessToken($AuthSession) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) - $operations = @() + # 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). + $groupOperations = @() + $administrativeUnitResults = @() + foreach ($ent in $Entitlements) { $normalized = $this.ConvertToEntitlement($ent) - if ($null -ne $normalized.Kind -and $normalized.Kind -ne 'Group') { + if (-not $normalized.Kind) { $normalized.Kind = 'Group' } + + if ($normalized.Kind -eq 'Group') { + $groupObjectId = $this.ResolveGroup($normalized.Id, $AuthSession) + $normalized.Id = $groupObjectId + $groupOperations += @{ + RequestId = [guid]::NewGuid().ToString('N').Substring(0, 8) + GroupObjectId = $groupObjectId + UserObjectId = $user.id + Action = 'add' + Entitlement = $normalized + } + } + elseif ($normalized.Kind -eq 'AdministrativeUnit') { + $auObjectId = $this.ResolveAdministrativeUnit($normalized.Id, $AuthSession) + $normalized.Id = $auObjectId + try { + $changed = [bool]$this.Adapter.AddAdministrativeUnitMember($auObjectId, $user.id, $accessToken) + $administrativeUnitResults += [pscustomobject]@{ + PSTypeName = 'IdLE.BulkProviderResult' + Operation = 'GrantEntitlement' + IdentityKey = $IdentityKey + Changed = $changed + Error = $null + Entitlement = $normalized + } + } + catch { + $administrativeUnitResults += [pscustomobject]@{ + PSTypeName = 'IdLE.BulkProviderResult' + Operation = 'GrantEntitlement' + IdentityKey = $IdentityKey + Changed = $false + Error = $_.Exception.Message + Entitlement = $normalized + } + } + } + else { throw [System.ArgumentException]::new( - "BulkGrantEntitlements only supports entitlements with Kind 'Group'. Received Kind '$($normalized.Kind)'." + "BulkGrantEntitlements only supports entitlements with Kind 'Group' or 'AdministrativeUnit'. Received Kind '$($normalized.Kind)'." ) } - $groupObjectId = $this.ResolveGroup($normalized.Id, $AuthSession) - $normalized.Id = $groupObjectId - $operations += @{ - RequestId = [guid]::NewGuid().ToString('N').Substring(0, 8) - GroupObjectId = $groupObjectId - UserObjectId = $user.id - Action = 'add' - Entitlement = $normalized - } } - if ($operations.Count -eq 0) { - return @() - } + $results = @() - $batchResults = $this.Adapter.BatchMembershipChanges($operations, $accessToken) + if ($groupOperations.Count -gt 0) { + $batchResults = $this.Adapter.BatchMembershipChanges($groupOperations, $accessToken) - $results = @() - foreach ($br in $batchResults) { - $op = $operations | Where-Object { $_.RequestId -eq $br.RequestId } - $results += [pscustomobject]@{ - PSTypeName = 'IdLE.BulkProviderResult' - Operation = 'GrantEntitlement' - IdentityKey = $IdentityKey - Changed = $br.Changed - Error = $br.Error - Entitlement = $op.Entitlement + # Build lookup table by RequestId to avoid O(n²) Where-Object scans + $operationByRequestId = @{} + foreach ($op in $groupOperations) { + $operationByRequestId[$op.RequestId] = $op + } + + foreach ($br in $batchResults) { + $op = $operationByRequestId[$br.RequestId] + $results += [pscustomobject]@{ + PSTypeName = 'IdLE.BulkProviderResult' + Operation = 'GrantEntitlement' + IdentityKey = $IdentityKey + Changed = $br.Changed + Error = $br.Error + Entitlement = $op.Entitlement + } } } + + $results += $administrativeUnitResults return $results } -Force diff --git a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 index b119f982..c0f4fe34 100644 --- a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 +++ b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 @@ -1094,6 +1094,56 @@ Describe 'EntraID identity provider - Entitlement operations' { $results[0].Error | Should -BeNullOrEmpty } + It 'BulkGrantEntitlements adds an AdministrativeUnit membership and returns Changed=$true' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + $auId = [guid]::NewGuid().ToString() + + $results = $script:EntProvider.BulkGrantEntitlements($userId, @( + @{ Kind = 'AdministrativeUnit'; Id = $auId } + )) + + @($results).Count | Should -Be 1 + $results[0].Changed | Should -Be $true + $results[0].Error | Should -BeNullOrEmpty + $results[0].Entitlement.Kind | Should -Be 'AdministrativeUnit' + + @($script:EntProvider.ListEntitlements($userId) | Where-Object { $_.Kind -eq 'AdministrativeUnit' -and $_.Id -eq $auId }).Count | Should -Be 1 + } + + It 'BulkGrantEntitlements is idempotent for AdministrativeUnit (already a member → Changed=$false)' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + $auId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GrantEntitlement($userId, @{ Kind = 'AdministrativeUnit'; Id = $auId }) + + $results = $script:EntProvider.BulkGrantEntitlements($userId, @( + @{ Kind = 'AdministrativeUnit'; Id = $auId } + )) + + @($results).Count | Should -Be 1 + $results[0].Changed | Should -Be $false + $results[0].Error | Should -BeNullOrEmpty + } + + It 'BulkGrantEntitlements handles mixed Group and AdministrativeUnit in a single call' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + $groupId = [guid]::NewGuid().ToString() + $auId = [guid]::NewGuid().ToString() + + $results = $script:EntProvider.BulkGrantEntitlements($userId, @( + @{ Kind = 'Group'; Id = $groupId }, + @{ Kind = 'AdministrativeUnit'; Id = $auId } + )) + + @($results).Count | Should -Be 2 + ($results | Where-Object { $_.Changed -eq $true }).Count | Should -Be 2 + + @($script:EntProvider.ListEntitlements($userId) | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq $groupId }).Count | Should -Be 1 + @($script:EntProvider.ListEntitlements($userId) | Where-Object { $_.Kind -eq 'AdministrativeUnit' -and $_.Id -eq $auId }).Count | Should -Be 1 + } + It 'BulkRevokeEntitlements removes an AdministrativeUnit membership and returns Changed=$true' { $userId = [guid]::NewGuid().ToString() [void]$script:EntProvider.GetIdentity($userId) From 7a01991e3001d2a34126dd28016e8f557219d983 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 09:52:40 +0000 Subject: [PATCH 08/11] fix(entra): use GetValues() for HttpResponseHeaders in InvokeGraphRequest 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> --- .../Private/New-IdleEntraIDAdapter.ps1 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 index 66f0127e..386aecc3 100644 --- a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 +++ b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 @@ -75,8 +75,11 @@ function New-IdleEntraIDAdapter { if ($_.Exception.Response) { $statusCode = [int]$_.Exception.Response.StatusCode if ($_.Exception.Response.Headers) { - $requestId = $_.Exception.Response.Headers['request-id'] - $retryAfter = $_.Exception.Response.Headers['Retry-After'] + # HttpResponseHeaders (PowerShell 7 / HttpClient) does not support + # indexer-style access. Use GetValues() with a safety catch for the + # case where the header is absent (GetValues throws in that case). + try { $requestId = $_.Exception.Response.Headers.GetValues('request-id') | Select-Object -First 1 } catch { } + try { $retryAfter = $_.Exception.Response.Headers.GetValues('Retry-After') | Select-Object -First 1 } catch { } } } From b9155cd37aeac3ee09bcc80664c3d844be5fff97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 10:35:33 +0000 Subject: [PATCH 09/11] docs(examples): add PruneEntitlementsEnsureKeep AU step to leaver template 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> --- .../workflows/templates/entraid-leaver.psd1 | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/examples/workflows/templates/entraid-leaver.psd1 b/examples/workflows/templates/entraid-leaver.psd1 index f7784638..48f968a1 100644 --- a/examples/workflows/templates/entraid-leaver.psd1 +++ b/examples/workflows/templates/entraid-leaver.psd1 @@ -96,6 +96,10 @@ # Optional: remove the user from all Administrative Units. # Use when scoped admin visibility must be revoked as part of offboarding. + # This is remove-only: no AU is added. Existing memberships NOT in Keep are removed; + # Keep entries are protected but NOT granted if missing. + # Use PruneAdministrativeUnitMemberships_Optional (below) when you also need to guarantee + # a specific AU is present after the prune. @{ Name = 'RevokeAdministrativeUnitMemberships_Optional' Type = 'IdLE.Step.PruneEntitlements' @@ -118,6 +122,37 @@ } } + # Optional: remove all AU memberships EXCEPT a retain set AND ensure retain set is present. + # PruneEntitlementsEnsureKeep removes all AU memberships except the keep set AND grants + # any Keep AU that is not currently assigned. + # Use PruneEntitlements (above) if you only need removal with no guaranteed grant. + @{ + Name = 'PruneAdministrativeUnitMemberships_Optional' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + Condition = @{ + All = @( + @{ + Equals = @{ + Path = 'Request.Intent.PruneAdministrativeUnitMemberships' + Value = $true + } + } + ) + } + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}' + Kind = 'AdministrativeUnit' + + # This AU is retained AND guaranteed to be present after the step. + # Reference by objectId (GUID) or by displayName (must be tenant-unique). + Keep = @( + @{ Kind = 'AdministrativeUnit'; Id = '{{Request.Intent.RetainAdministrativeUnitId}}' } + ) + } + } + # Optional delete (requires provider to be created with -AllowDelete) @{ Name = 'DeleteAccount_Optional' From 12e5e651da3965e61eab1c1e21150a3fbcdd3a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Flesch=C3=BCtz?= <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> Date: Sun, 17 May 2026 13:01:41 +0200 Subject: [PATCH 10/11] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Matthias Fleschütz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> --- docs/reference/providers/provider-entraID.md | 61 -------------------- 1 file changed, 61 deletions(-) diff --git a/docs/reference/providers/provider-entraID.md b/docs/reference/providers/provider-entraID.md index 0d186808..07286ef9 100644 --- a/docs/reference/providers/provider-entraID.md +++ b/docs/reference/providers/provider-entraID.md @@ -133,7 +133,6 @@ Each element represents one entitlement (group membership or Administrative Unit | Property | Type | Notes | | --- | --- | --- | -| `PSTypeName` | `string` | Always `IdLE.Entitlement`. | | `Kind` | `string` | Always `Group`. | | `Id` | `string` | Entra group object ID (GUID). | | `Mail` | `string` or `$null` | Group `mail` (if returned by Graph). | @@ -142,7 +141,6 @@ Each element represents one entitlement (group membership or Administrative Unit | Property | Type | Notes | | --- | --- | --- | -| `PSTypeName` | `string` | Always `IdLE.Entitlement`. | | `Kind` | `string` | Always `AdministrativeUnit`. | | `Id` | `string` | Entra Administrative Unit object ID (GUID). | @@ -152,65 +150,6 @@ Notes: - Use the global View (`Request.Context.Views.Identity.Entitlements`) in **Conditions** when you don't need to filter by provider. Use the scoped path when you need results from a specific provider only. - See [Context Resolvers](../../use/workflows/context-resolver.md) for the full path reference. -## Administrative Unit entitlements - -Administrative Units (AUs) are modelled as `Kind = 'AdministrativeUnit'` entitlements. They control which scoped admins can manage which users — assigning a user to an AU makes them visible to that AU's scoped admin roles. - -### Supported operations - -| Operation | Behaviour | -| --- | --- | -| `ListEntitlements` | Returns all current AU memberships alongside group memberships. | -| `GrantEntitlement` | Adds the user to the specified AU. Idempotent — no error if already a member. | -| `RevokeEntitlement` | Removes the user from the specified AU. Idempotent — no error if not a member. | -| `PruneEntitlements` | Covered automatically: `ListEntitlements` returns both groups and AUs, so the Prune step removes unlisted AUs in the same pass as groups. | - -### Workflow usage - -```powershell -# Ensure a user is assigned to an Administrative Unit (Joiner / Mover) -@{ - Name = 'Assign to HR Administrative Unit' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ - IdentityKey = '{{Request.IdentityKeys.Id}}' - Provider = 'Entra' - AuthSessionName = 'MicrosoftGraph' - Entitlement = @{ Kind = 'AdministrativeUnit'; Id = '' } - State = 'Present' - } -} - -# Remove a user from an Administrative Unit (Leaver / Mover) -@{ - Name = 'Remove from HR Administrative Unit' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ - IdentityKey = '{{Request.IdentityKeys.Id}}' - Provider = 'Entra' - AuthSessionName = 'MicrosoftGraph' - Entitlement = @{ Kind = 'AdministrativeUnit'; Id = '' } - State = 'Absent' - } -} -``` - -### Constraints - -- Administrative Units must be **pre-created in Entra** before being referenced in a workflow. The provider validates AU existence and throws a clear, actionable error if the AU is not found. -- AUs can be referenced by **object ID (GUID)** or by **displayName**. Display-name lookup is supported for convenience, but AU display names are not guaranteed to be unique within a tenant — if multiple AUs share the same name, the provider throws an error and requires the object ID to be used instead. -- `BulkGrantEntitlements` and `BulkRevokeEntitlements` both support `Kind = 'Group'` (Graph batch path) and `Kind = 'AdministrativeUnit'` (per-item path — no Graph batch API exists for AU membership changes). Mixed-kind batches are accepted in both methods. This ensures `PruneEntitlementsEnsureKeep` works correctly for AUs (it calls both bulk methods internally). - -### Graph endpoints used - -| Operation | Endpoint | -| --- | --- | -| List | `GET /users/{id}/memberOf/microsoft.graph.administrativeUnit` | -| Grant | `POST /directory/administrativeUnits/{id}/members/$ref` | -| Revoke | `DELETE /directory/administrativeUnits/{id}/members/{userId}/$ref` | -| Validate AU exists by ID | `GET /directory/administrativeUnits/{id}` | -| Resolve AU by displayName | `GET /directory/administrativeUnits?$filter=displayName eq '...'` | - ## Configuration ### Provider constructor / factory From 8f139e9a840aedec0c5652a51c4be96d64445a8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 11:21:53 +0000 Subject: [PATCH 11/11] fix(entra): OData apostrophe escaping + conditional AU listing via Kind 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> --- docs/reference/providers/provider-entraID.md | 6 +- .../Private/New-IdleEntraIDAdapter.ps1 | 8 ++- .../New-IdleEntraIDIdentityProvider.ps1 | 69 +++++++++++-------- .../Invoke-IdleStepEnsureEntitlement.ps1 | 13 +++- .../Invoke-IdleStepPruneEntitlements.ps1 | 9 ++- .../EntraIDIdentityProvider.Tests.ps1 | 32 ++++++++- 6 files changed, 100 insertions(+), 37 deletions(-) diff --git a/docs/reference/providers/provider-entraID.md b/docs/reference/providers/provider-entraID.md index 07286ef9..edfba868 100644 --- a/docs/reference/providers/provider-entraID.md +++ b/docs/reference/providers/provider-entraID.md @@ -168,7 +168,7 @@ This provider has **no provider-specific option bag**. Configuration is done thr At minimum, you typically need: - **Users:** read/write (create/update/disable/delete if enabled) - **Groups:** read/write memberships (if you use group entitlement steps) -- **Administrative Units:** read/write memberships (if you use Administrative Unit entitlement steps) +- **Administrative Units:** read/write memberships — only required when using `Kind = 'AdministrativeUnit'` in entitlement steps. Group-only workflows do not need these permissions because `ListEntitlements` skips AU Graph calls when `Kind = 'Group'` is specified. Exact permission names depend on your auth model (delegated vs application) and what operations you enable. @@ -178,8 +178,8 @@ Exact permission names depend on your auth model (delegated vs application) and | Create/update/disable users | `User.ReadWrite.All` | | List group memberships | `Group.Read.All` | | Grant/revoke group memberships | `GroupMember.ReadWrite.All` | -| List AU memberships | `AdministrativeUnit.Read.All` | -| Grant/revoke AU memberships | `AdministrativeUnit.ReadWrite.All` | +| List AU memberships (`Kind = 'AdministrativeUnit'`) | `AdministrativeUnit.Read.All` | +| Grant/revoke AU memberships (`Kind = 'AdministrativeUnit'`) | `AdministrativeUnit.ReadWrite.All` | ## Examples (canonical templates) diff --git a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 index 386aecc3..683fc47a 100644 --- a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 +++ b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 @@ -347,7 +347,9 @@ function New-IdleEntraIDAdapter { [string] $AccessToken ) - $encodedName = [System.Net.WebUtility]::UrlEncode($DisplayName) + # Escape single quotes for OData string literals (doubling), then URL-encode for the URI + $odataEscapedName = $DisplayName.Replace("'", "''") + $encodedName = [System.Net.WebUtility]::UrlEncode($odataEscapedName) $uri = "$($this.BaseUri)/groups?`$filter=displayName eq '$encodedName'" $uri += '&$select=id,displayName,mail,mailNickname' @@ -419,7 +421,9 @@ function New-IdleEntraIDAdapter { [string] $AccessToken ) - $encodedName = [System.Net.WebUtility]::UrlEncode($DisplayName) + # Escape single quotes for OData string literals (doubling), then URL-encode for the URI + $odataEscapedName = $DisplayName.Replace("'", "''") + $encodedName = [System.Net.WebUtility]::UrlEncode($odataEscapedName) $uri = "$($this.BaseUri)/directory/administrativeUnits?`$filter=displayName eq '$encodedName'" $uri += '&$select=id,displayName' diff --git a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 index 0ed14d98..f0e12bc3 100644 --- a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 +++ b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 @@ -82,7 +82,7 @@ function New-IdleEntraIDIdentityProvider { Requires Microsoft Graph API permissions (delegated or app-only): - User.Read.All, User.ReadWrite.All - Group.Read.All, GroupMember.ReadWrite.All - - AdministrativeUnit.Read.All (for ListEntitlements with Kind=AdministrativeUnit) + - AdministrativeUnit.Read.All (for ListEntitlements/GrantEntitlement/RevokeEntitlement with Kind=AdministrativeUnit) - AdministrativeUnit.ReadWrite.All (for Grant/Revoke with Kind=AdministrativeUnit) - For delete: User.ReadWrite.All @@ -833,45 +833,60 @@ function New-IdleEntraIDIdentityProvider { [Parameter()] [AllowNull()] - [object] $AuthSession + [object] $AuthSession, + + [Parameter()] + [AllowNull()] + [string] $Kind = $null ) $accessToken = $this.ExtractAccessToken($AuthSession) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) - $groups = $this.Adapter.ListUserGroups($user.id, $accessToken) - $aus = $this.Adapter.ListUserAdministrativeUnits($user.id, $accessToken) + # When Kind is supplied, only fetch the relevant directory objects. + # When Kind is null/empty, fetch both (backward-compatible, e.g. host-level reports). + $includeGroups = [string]::IsNullOrEmpty($Kind) -or + [string]::Equals($Kind, 'Group', [System.StringComparison]::OrdinalIgnoreCase) + $includeAUs = [string]::IsNullOrEmpty($Kind) -or + [string]::Equals($Kind, 'AdministrativeUnit', [System.StringComparison]::OrdinalIgnoreCase) $result = @() - foreach ($group in $groups) { - # Handle both hashtables and PSCustomObjects - $groupId = if ($group -is [System.Collections.IDictionary]) { - $group['id'] - } else { - $group.id - } - $mail = if ($group -is [System.Collections.IDictionary]) { - if ($group.ContainsKey('mail')) { $group['mail'] } else { $null } - } else { - if ($group.PSObject.Properties.Name -contains 'mail') { $group.mail } else { $null } - } + if ($includeGroups) { + $groups = $this.Adapter.ListUserGroups($user.id, $accessToken) + foreach ($group in $groups) { + # Handle both hashtables and PSCustomObjects + $groupId = if ($group -is [System.Collections.IDictionary]) { + $group['id'] + } else { + $group.id + } - $result += [pscustomobject]@{ - PSTypeName = 'IdLE.Entitlement' - Kind = 'Group' - Id = $groupId - Mail = $mail + $mail = if ($group -is [System.Collections.IDictionary]) { + if ($group.ContainsKey('mail')) { $group['mail'] } else { $null } + } else { + if ($group.PSObject.Properties.Name -contains 'mail') { $group.mail } else { $null } + } + + $result += [pscustomobject]@{ + PSTypeName = 'IdLE.Entitlement' + Kind = 'Group' + Id = $groupId + Mail = $mail + } } } - foreach ($au in $aus) { - $auId = if ($au -is [System.Collections.IDictionary]) { $au['id'] } else { $au.id } + if ($includeAUs) { + $aus = $this.Adapter.ListUserAdministrativeUnits($user.id, $accessToken) + foreach ($au in $aus) { + $auId = if ($au -is [System.Collections.IDictionary]) { $au['id'] } else { $au.id } - $result += [pscustomobject]@{ - PSTypeName = 'IdLE.Entitlement' - Kind = 'AdministrativeUnit' - Id = $auId + $result += [pscustomobject]@{ + PSTypeName = 'IdLE.Entitlement' + Kind = 'AdministrativeUnit' + Id = $auId + } } } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 index 545ae8f8..3f027731 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 @@ -178,13 +178,20 @@ function Invoke-IdleStepEnsureEntitlement { # Check AuthSession support for each method $listSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['ListEntitlements'] -ParameterName 'AuthSession' + $listSupportsKind = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['ListEntitlements'] -ParameterName 'Kind' $grantSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['GrantEntitlement'] -ParameterName 'AuthSession' $revokeSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['RevokeEntitlement'] -ParameterName 'AuthSession' - if ($listSupportsAuthSession -and $null -ne $authSession) { + # Pass Kind to the provider when supported, so it can skip fetching unrelated objects + # (e.g. avoid AU Graph calls for a Group entitlement check). + $entitlementKind = [string]$entitlement.Kind + if ($listSupportsKind -and $listSupportsAuthSession -and $null -ne $authSession) { + $current = @($provider.ListEntitlements($identityKey, $authSession, $entitlementKind)) + } elseif ($listSupportsKind) { + $current = @($provider.ListEntitlements($identityKey, $null, $entitlementKind)) + } elseif ($listSupportsAuthSession -and $null -ne $authSession) { $current = @($provider.ListEntitlements($identityKey, $authSession)) - } - else { + } else { $current = @($provider.ListEntitlements($identityKey)) } $matches = @($current | Where-Object { Test-IdleStepEntitlementEquals -A $_ -B $entitlement }) diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 index 8bd6931e..2514a369 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -222,6 +222,7 @@ function Invoke-IdleStepPruneEntitlements { } $listSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['ListEntitlements'] -ParameterName 'AuthSession' + $listSupportsKind = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['ListEntitlements'] -ParameterName 'Kind' $revokeSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['RevokeEntitlement'] -ParameterName 'AuthSession' $grantSupportsAuthSession = if ($ensureKeep) { Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['GrantEntitlement'] -ParameterName 'AuthSession' @@ -238,7 +239,13 @@ function Invoke-IdleStepPruneEntitlements { } else { $false } # 1. List current entitlements, filter by Kind - $allCurrent = if ($listSupportsAuthSession -and $null -ne $authSession) { + # When the provider supports the Kind parameter, pass it so the provider can skip + # fetching unrelated directory objects (e.g. avoid AU Graph calls for Group-only prunes). + $allCurrent = if ($listSupportsKind -and $listSupportsAuthSession -and $null -ne $authSession) { + @($provider.ListEntitlements($identityKey, $authSession, $kind)) + } elseif ($listSupportsKind) { + @($provider.ListEntitlements($identityKey, $null, $kind)) + } elseif ($listSupportsAuthSession -and $null -ne $authSession) { @($provider.ListEntitlements($identityKey, $authSession)) } else { @($provider.ListEntitlements($identityKey)) diff --git a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 index c0f4fe34..ed729d81 100644 --- a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 +++ b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 @@ -1252,7 +1252,37 @@ Describe 'EntraID identity provider - Entitlement operations' { @($afterRevoke | Where-Object { $_.Kind -eq 'AdministrativeUnit' -and $_.Id -eq $auId }).Count | Should -Be 0 } - It 'ListEntitlements returns both Group and AdministrativeUnit entitlements' { + It 'ListEntitlements with Kind=Group only returns Group entitlements and does not call ListUserAdministrativeUnits' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + $groupId = [guid]::NewGuid().ToString() + $auId = [guid]::NewGuid().ToString() + + [void]$script:EntProvider.GrantEntitlement($userId, @{ Kind = 'Group'; Id = $groupId }) + [void]$script:EntProvider.GrantEntitlement($userId, @{ Kind = 'AdministrativeUnit'; Id = $auId }) + + $entitlements = @($script:EntProvider.ListEntitlements($userId, $null, 'Group')) + + @($entitlements | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq $groupId }).Count | Should -Be 1 + @($entitlements | Where-Object { $_.Kind -eq 'AdministrativeUnit' }).Count | Should -Be 0 + } + + It 'ListEntitlements with Kind=AdministrativeUnit only returns AU entitlements and does not call ListUserGroups' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + $groupId = [guid]::NewGuid().ToString() + $auId = [guid]::NewGuid().ToString() + + [void]$script:EntProvider.GrantEntitlement($userId, @{ Kind = 'Group'; Id = $groupId }) + [void]$script:EntProvider.GrantEntitlement($userId, @{ Kind = 'AdministrativeUnit'; Id = $auId }) + + $entitlements = @($script:EntProvider.ListEntitlements($userId, $null, 'AdministrativeUnit')) + + @($entitlements | Where-Object { $_.Kind -eq 'AdministrativeUnit' -and $_.Id -eq $auId }).Count | Should -Be 1 + @($entitlements | Where-Object { $_.Kind -eq 'Group' }).Count | Should -Be 0 + } + + It 'ListEntitlements with no Kind returns both Group and AdministrativeUnit entitlements' { $userId = [guid]::NewGuid().ToString() [void]$script:EntProvider.GetIdentity($userId) $groupId = [guid]::NewGuid().ToString()