From f045a4fcbfb19766424f8534aa0ebe324c95b2d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:56:23 +0000 Subject: [PATCH 01/27] Initial plan From a676ad030b151fa0c2238336295ae513e6101a04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:10:13 +0000 Subject: [PATCH 02/27] Add IdLE.Step.PruneEntitlements with tests, capability, provider updates and docs" Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/capabilities.md | 3 + docs/reference/steps.md | 1 + .../steps/step-prune-entitlements.md | 133 +++++++ examples/workflows/templates/ad-leaver.psd1 | 26 +- .../workflows/templates/entraid-leaver.psd1 | 35 ++ .../Private/Get-IdleStepRegistry.ps1 | 7 + .../Public/New-IdleADIdentityProvider.ps1 | 1 + .../New-IdleEntraIDIdentityProvider.ps1 | 1 + .../Public/New-IdleMockIdentityProvider.ps1 | 13 +- src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 | 3 +- src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 | 3 +- .../Public/Get-IdleStepMetadataCatalog.ps1 | 5 + .../Invoke-IdleStepPruneEntitlements.ps1 | 365 +++++++++++++++++ ...Invoke-IdleStepPruneEntitlements.Tests.ps1 | 370 ++++++++++++++++++ 14 files changed, 960 insertions(+), 6 deletions(-) create mode 100644 docs/reference/steps/step-prune-entitlements.md create mode 100644 src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 create mode 100644 tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index ce79aee0..35fd4a45 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -79,6 +79,9 @@ Grant/assign an entitlement to an identity (e.g., add group membership, assign l #### `IdLE.Entitlement.Revoke` Revoke/remove an entitlement from an identity. Must be idempotent. +#### `IdLE.Entitlement.Prune` +Explicit opt-in for bulk entitlement convergence ("remove all except"). Providers that advertise this capability support the `IdLE.Step.PruneEntitlements` step, which removes all entitlements of a given kind except an explicit keep-set and/or pattern-matched entitlements. This is a separate capability from `Revoke` because the operation is bulk and destructive by design. Providers must also implement `ListEntitlements` and `RevokeEntitlement` (and optionally `GrantEntitlement`) to support this step. + ### Mailbox #### `IdLE.Mailbox.Info.Read` diff --git a/docs/reference/steps.md b/docs/reference/steps.md index 688c34b5..d865febc 100644 --- a/docs/reference/steps.md +++ b/docs/reference/steps.md @@ -17,5 +17,6 @@ | [IdLE.Step.Mailbox.EnsureType](steps/step-mailbox-ensure-type.md) | ``IdLE.Steps.Mailbox`` | Ensures that a mailbox is of the desired type (User, Shared, Room, Equipment). | | [IdLE.Step.Mailbox.GetInfo](steps/step-mailbox-get-info.md) | ``IdLE.Steps.Mailbox`` | Retrieves mailbox details and returns a structured report. | | [IdLE.Step.MoveIdentity](steps/step-move-identity.md) | ``IdLE.Steps.Common`` | Moves an identity to a different container/OU in the target system. | +| [IdLE.Step.PruneEntitlements](steps/step-prune-entitlements.md) | ``IdLE.Steps.Common`` | Converges an identity's entitlements by removing all non-kept entitlements of a given kind. | | [IdLE.Step.RevokeIdentitySessions](steps/step-revoke-identity-sessions.md) | ``IdLE.Steps.Common`` | Revokes all active sign-in sessions for an identity in the target system. | | [IdLE.Step.TriggerDirectorySync](steps/step-trigger-directory-sync.md) | ``IdLE.Steps.DirectorySync`` | Triggers a directory sync cycle and optionally waits for completion. | diff --git a/docs/reference/steps/step-prune-entitlements.md b/docs/reference/steps/step-prune-entitlements.md new file mode 100644 index 00000000..9bff1c3a --- /dev/null +++ b/docs/reference/steps/step-prune-entitlements.md @@ -0,0 +1,133 @@ +# IdLE.Step.PruneEntitlements + +> Generated file. Do not edit by hand. +> Source: tools/Generate-IdleStepReference.ps1 + +## Summary + +- **Step Type**: `IdLE.Step.PruneEntitlements` +- **Module**: `IdLE.Steps.Common` +- **Implementation**: `Invoke-IdleStepPruneEntitlements` +- **Idempotent**: `Yes` + +## Synopsis + +Converges an identity's entitlements by removing all non-kept entitlements of a given kind. + +## Description + +This provider-agnostic step implements "remove all except" semantics for entitlements. +It is intended for leaver and mover workflows where all entitlements of a given kind +(e.g. group memberships) must be removed, except for an explicit keep-set and/or +entitlements matching a wildcard keep pattern. + +The host must supply a provider that: + +- Advertises the `IdLE.Entitlement.Prune` capability (explicit opt-in) +- Implements `ListEntitlements(identityKey)` +- Implements `RevokeEntitlement(identityKey, entitlement)` +- Implements `GrantEntitlement(identityKey, entitlement)` — required only when `With.EnsureKeepEntitlements` is `$true` + +Provider/system non-removable entitlements (e.g., AD primary group / Domain Users) are +handled safely: if a revoke operation fails, the step emits a structured warning event, +skips the entitlement, and continues. The workflow is not failed for these items. + +Authentication: + +- If `With.AuthSessionName` is present, the step acquires an auth session via + `Context.AcquireAuthSession(Name, Options)` and passes it to provider methods + if the provider supports an `AuthSession` parameter. +- `With.AuthSessionOptions` (optional, hashtable) is passed to the broker for + session selection (e.g., `@{ Role = 'Tier0' }`). +- ScriptBlocks in `AuthSessionOptions` are rejected (security boundary). + +## Inputs (With.*) + +The following keys are supported in the step's `With` configuration: + +| Key | Required | Description | +| --- | --- | --- | +| `IdentityKey` | Yes | Unique identifier for the identity whose entitlements to prune | +| `Kind` | Yes | Entitlement kind to prune (e.g. `Group`, `Role`, `License`) — provider-defined | +| `Keep` | No* | Array of entitlement references to keep. Each entry must have an `Id` and optionally a `Kind` and `DisplayName`. At least one of `Keep` or `KeepPattern` is required. | +| `KeepPattern` | No* | Array of wildcard strings (PowerShell `-like` semantics). Current entitlements whose `Id` or `DisplayName` matches any pattern are kept. At least one of `Keep` or `KeepPattern` is required. | +| `EnsureKeepEntitlements` | No | If `$true`, entitlements listed in `Keep` that are not currently present will be granted. Does not apply to pattern-matched entitlements. | +| `Provider` | No | Alias for the provider in `Context.Providers`. Defaults to `'Identity'`. | +| `AuthSessionName` | No | Name used to acquire an auth session via `Context.AcquireAuthSession(...)`. | +| `AuthSessionOptions` | No | Hashtable of options passed to the auth session broker (e.g., `@{ Role = 'Tier0' }`). ScriptBlocks are rejected. | + +\* At least one of `Keep` or `KeepPattern` **must** be provided. Specifying neither is rejected as a safety guardrail. + +## Capability Requirement + +This step requires the provider to advertise the `IdLE.Entitlement.Prune` capability (explicit opt-in). +This is in addition to the standard `IdLE.Entitlement.List`, `IdLE.Entitlement.Revoke`, and +`IdLE.Entitlement.Grant` capabilities. + +See [Capabilities Reference](../capabilities.md) for details. + +## Behavior + +The step executes the following convergence logic: + +1. Lists all current entitlements of the specified `Kind` for the identity. +2. Builds a **keep-set** from: + - Explicit `Keep` entries (matched by case-insensitive `Id` comparison) + - Current entitlements whose `Id` or `DisplayName` matches any `KeepPattern` wildcard +3. Computes **remove-set** = current − keep-set. +4. Revokes each entitlement in the remove-set. If a revoke fails (e.g. non-removable entitlement), the error is recorded as a skip with a warning event; the workflow continues. +5. If `EnsureKeepEntitlements` is `$true`: grants any explicit `Keep` entitlements that were not in the current set. + +## Result + +Returns an `IdLE.StepResult` object. In addition to the standard `Status`, `Changed`, and `Error` properties, +a `Skipped` array is included. Each entry in `Skipped` contains: + +| Property | Description | +| --- | --- | +| `EntitlementId` | The `Id` of the entitlement that could not be removed | +| `Reason` | The error message from the provider | + +## Examples + +### Basic: prune all groups except one explicit group + +```powershell +@{ + Name = 'Prune group memberships (leaver)' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ + IdentityKey = '{{Request.Intent.SamAccountName}}' + Kind = 'Group' + Keep = @( + @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' } + ) + } +} +``` + +### With wildcard pattern: keep all LEAVER-* groups and ensure the retain group is present + +```powershell +@{ + Name = 'Prune group memberships (leaver with pattern)' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ + IdentityKey = '{{Request.Intent.SamAccountName}}' + Provider = 'Identity' + Kind = 'Group' + Keep = @( + @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com'; DisplayName = 'Leaver Retain' } + ) + KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') + EnsureKeepEntitlements = $true + AuthSessionName = 'Directory' + } +} +``` + +## See Also + +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities including `IdLE.Entitlement.Prune` +- [Providers](../providers.md) - Available provider implementations +- [IdLE.Step.EnsureEntitlement](./step-ensure-entitlement.md) - Atomic single-entitlement convergence diff --git a/examples/workflows/templates/ad-leaver.psd1 b/examples/workflows/templates/ad-leaver.psd1 index 3924d76d..ece470c9 100644 --- a/examples/workflows/templates/ad-leaver.psd1 +++ b/examples/workflows/templates/ad-leaver.psd1 @@ -28,7 +28,31 @@ # Optional, use with caution: # Removing groups can break business processes unexpectedly. - # Prefer an explicit allow-list or a "remove only managed groups" approach. + # PruneEntitlements offers a safer "remove all except" approach for leavers. + @{ + Type = 'IdLE.Step.PruneEntitlements' + Name = 'Prune all group memberships except leaver retain group' + With = @{ + Condition = @{ Equals = @{ Path = 'Request.Intent.PruneGroups'; Value = $true } } + AuthSessionName = 'Directory' + IdentityKey = '{{Request.Intent.SamAccountName}}' + Kind = 'Group' + + # Explicitly retain this group and ensure it is present after pruning. + Keep = @( + @{ Kind = 'Group'; Id = '{{Request.Intent.LeaverRetainGroupDn}}'; DisplayName = 'Leaver Retain' } + ) + + # Also retain any group whose DN starts with CN=LEAVER- (e.g. LEAVER-*) + KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') + + # Ensure the explicit keep group is present even if the user was not a member. + EnsureKeepEntitlements = $true + } + } + + # Alternatively, remove individual managed group memberships one by one: + # Prefer PruneEntitlements above for bulk removal scenarios. @{ Type = 'IdLE.Step.EnsureEntitlement' Name = 'Remove managed group memberships (optional, item 1)' diff --git a/examples/workflows/templates/entraid-leaver.psd1 b/examples/workflows/templates/entraid-leaver.psd1 index 4c8cfdd8..5f02533f 100644 --- a/examples/workflows/templates/entraid-leaver.psd1 +++ b/examples/workflows/templates/entraid-leaver.psd1 @@ -67,6 +67,41 @@ } } + # Optional & potentially disruptive: + # PruneEntitlements offers a safe "remove all except" approach for leavers. + # Use this instead of removing each group individually. + @{ + Name = 'PruneGroupMemberships_Optional' + Type = 'IdLE.Step.PruneEntitlements' + Condition = @{ + All = @( + @{ + Equals = @{ + Path = 'Request.Intent.PruneGroupMemberships' + Value = $true + } + } + ) + } + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Intent.UserPrincipalName}}' + Kind = 'Group' + + # Retain this specific leaver group and ensure it is present. + Keep = @( + @{ Kind = 'Group'; Id = '{{Request.Intent.LeaverRetainGroupId}}'; DisplayName = 'Leaver Retain' } + ) + + # Also retain any group whose displayName starts with LEAVER-. + KeepPattern = @('LEAVER-*') + + # Ensure the explicit keep group is present even if the user was not a member. + EnsureKeepEntitlements = $true + } + } + # Optional delete (requires provider to be created with -AllowDelete) @{ Name = 'DeleteAccount_Optional' diff --git a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 index 74d7ed73..52db61bf 100644 --- a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -170,6 +170,13 @@ function Get-IdleStepRegistry { } } + if (-not $registry.ContainsKey('IdLE.Step.PruneEntitlements')) { + $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepPruneEntitlements' -ModuleName 'IdLE.Steps.Common' + if (-not [string]::IsNullOrWhiteSpace($handler)) { + $registry['IdLE.Step.PruneEntitlements'] = $handler + } + } + if (-not $registry.ContainsKey('IdLE.Step.TriggerDirectorySync')) { $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepTriggerDirectorySync' -ModuleName 'IdLE.Steps.DirectorySync' if (-not [string]::IsNullOrWhiteSpace($handler)) { diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index 53a21cf2..5c4e8454 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -341,6 +341,7 @@ function New-IdleADIdentityProvider { 'IdLE.Entitlement.List' 'IdLE.Entitlement.Grant' 'IdLE.Entitlement.Revoke' + 'IdLE.Entitlement.Prune' ) if ($this.AllowDelete) { diff --git a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 index 9fa90933..f7f17948 100644 --- a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 +++ b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 @@ -309,6 +309,7 @@ function New-IdleEntraIDIdentityProvider { 'IdLE.Entitlement.List' 'IdLE.Entitlement.Grant' 'IdLE.Entitlement.Revoke' + 'IdLE.Entitlement.Prune' ) if ($this.AllowDelete) { diff --git a/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 b/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 index 498a6f73..cfac762a 100644 --- a/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 +++ b/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 @@ -115,9 +115,10 @@ function New-IdleMockIdentityProvider { } $provider = [pscustomobject]@{ - PSTypeName = 'IdLE.Provider.MockIdentityProvider' - Name = 'MockIdentityProvider' - Store = $store + PSTypeName = 'IdLE.Provider.MockIdentityProvider' + Name = 'MockIdentityProvider' + Store = $store + ProtectedEntitlementIds = @() } $provider | Add-Member -MemberType ScriptMethod -Name ConvertToEntitlement -Value $convertToEntitlement -Force @@ -143,6 +144,7 @@ function New-IdleMockIdentityProvider { 'IdLE.Entitlement.List' 'IdLE.Entitlement.Grant' 'IdLE.Entitlement.Revoke' + 'IdLE.Entitlement.Prune' ) } -Force @@ -356,6 +358,11 @@ function New-IdleMockIdentityProvider { $normalized = $this.ConvertToEntitlement($Entitlement) + # Simulate non-removable entitlements (e.g. AD primary group) + if ($this.ProtectedEntitlementIds -contains $normalized.Id) { + throw "Entitlement '$($normalized.Id)' is protected and cannot be removed." + } + $identity = $this.Store[$IdentityKey] if ($null -eq $identity.Entitlements) { $identity.Entitlements = @() diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 index 498d1c7b..ec342289 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 @@ -20,7 +20,8 @@ 'Invoke-IdleStepEnableIdentity', 'Invoke-IdleStepMoveIdentity', 'Invoke-IdleStepDeleteIdentity', - 'Invoke-IdleStepRevokeIdentitySessions' + 'Invoke-IdleStepRevokeIdentitySessions', + 'Invoke-IdleStepPruneEntitlements' ) PrivateData = @{ diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 index 5437357c..ced47acb 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 @@ -54,5 +54,6 @@ Export-ModuleMember -Function @( 'Invoke-IdleStepEnableIdentity', 'Invoke-IdleStepMoveIdentity', 'Invoke-IdleStepDeleteIdentity', - 'Invoke-IdleStepRevokeIdentitySessions' + 'Invoke-IdleStepRevokeIdentitySessions', + 'Invoke-IdleStepPruneEntitlements' ) diff --git a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 index 56d90c7b..da18b58d 100644 --- a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 @@ -68,5 +68,10 @@ function Get-IdleStepMetadataCatalog { RequiredCapabilities = @('IdLE.Identity.RevokeSessions') } + # IdLE.Step.PruneEntitlements - requires explicit prune opt-in capability plus list/revoke/grant + $catalog['IdLE.Step.PruneEntitlements'] = @{ + RequiredCapabilities = @('IdLE.Entitlement.Prune', 'IdLE.Entitlement.List', 'IdLE.Entitlement.Revoke', 'IdLE.Entitlement.Grant') + } + return $catalog } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 new file mode 100644 index 00000000..2d90333d --- /dev/null +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -0,0 +1,365 @@ +function Invoke-IdleStepPruneEntitlements { + <# + .SYNOPSIS + Converges an identity's entitlements by removing all non-kept entitlements of a given kind. + + .DESCRIPTION + This provider-agnostic step implements "remove all except" semantics for entitlements. + It is intended for leaver and mover workflows where all entitlements of a given kind + (e.g. group memberships) must be removed, except for an explicit keep-set and/or + entitlements matching a wildcard keep pattern. + + The host must supply a provider that: + + - Advertises the IdLE.Entitlement.Prune capability (explicit opt-in) + - Implements ListEntitlements(identityKey) + - Implements RevokeEntitlement(identityKey, entitlement) + - Implements GrantEntitlement(identityKey, entitlement) [required only when With.EnsureKeepEntitlements is $true] + + Provider/system non-removable entitlements (e.g., AD primary group / Domain Users) are + handled safely: if a revoke operation fails, the step emits a structured warning event, + skips the entitlement, and continues. The workflow is not failed for these items. + + Authentication: + + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to provider methods + if the provider supports an AuthSession parameter. + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). + - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + + .PARAMETER Context + Execution context created by IdLE.Core. + + .PARAMETER Step + Normalized step object from the plan. Must contain a 'With' hashtable. + + .EXAMPLE + Invoke-IdleStepPruneEntitlements -Context $context -Step [pscustomobject]@{ + Name = 'Prune group memberships (leaver)' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ + IdentityKey = 'jsmith' + Provider = 'Identity' + Kind = 'Group' + Keep = @( + @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' } + ) + KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') + EnsureKeepEntitlements = $true + } + } + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + function ConvertTo-IdleStepPruneEntitlement { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Value, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $DefaultKind + ) + + $kind = $null + $id = $null + $displayName = $null + + if ($Value -is [System.Collections.IDictionary]) { + if ($Value.Contains('Kind')) { $kind = $Value['Kind'] } + if ($Value.Contains('Id')) { $id = $Value['Id'] } + if ($Value.Contains('DisplayName')) { $displayName = $Value['DisplayName'] } + } + else { + $props = $Value.PSObject.Properties + if ($props.Name -contains 'Kind') { $kind = $Value.Kind } + if ($props.Name -contains 'Id') { $id = $Value.Id } + if ($props.Name -contains 'DisplayName') { $displayName = $Value.DisplayName } + } + + if ([string]::IsNullOrWhiteSpace([string]$kind)) { + $kind = $DefaultKind + } + + if ([string]::IsNullOrWhiteSpace([string]$id)) { + throw "PruneEntitlements: each Keep entry requires an Id." + } + + $normalized = [ordered]@{ + Kind = [string]$kind + Id = [string]$id + } + + if ($null -ne $displayName -and -not [string]::IsNullOrWhiteSpace([string]$displayName)) { + $normalized['DisplayName'] = [string]$displayName + } + + return [pscustomobject]$normalized + } + + function Test-IdleStepPruneEntitlementMatch { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Current, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $KeepItem + ) + + return [string]::Equals([string]$Current.Id, [string]$KeepItem.Id, [System.StringComparison]::OrdinalIgnoreCase) + } + + $with = $Step.With + if ($null -eq $with -or -not ($with -is [hashtable])) { + throw "PruneEntitlements requires 'With' to be a hashtable." + } + + foreach ($key in @('IdentityKey', 'Kind')) { + if (-not $with.ContainsKey($key)) { + throw "PruneEntitlements requires With.$key." + } + } + + $identityKey = [string]$with.IdentityKey + $kind = [string]$with.Kind + + if ([string]::IsNullOrWhiteSpace($identityKey)) { + throw "PruneEntitlements requires With.IdentityKey to be non-empty." + } + if ([string]::IsNullOrWhiteSpace($kind)) { + throw "PruneEntitlements requires With.Kind to be non-empty." + } + + $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'Identity' } + + # Parse explicit Keep items + $keepItems = @() + if ($with.ContainsKey('Keep') -and $null -ne $with.Keep) { + foreach ($item in @($with.Keep)) { + if ($item -is [scriptblock]) { + throw "PruneEntitlements: Keep entries must not contain ScriptBlocks." + } + $keepItems += ConvertTo-IdleStepPruneEntitlement -Value $item -DefaultKind $kind + } + } + + # Parse KeepPattern items (wildcard strings only) + $keepPatterns = @() + if ($with.ContainsKey('KeepPattern') -and $null -ne $with.KeepPattern) { + foreach ($p in @($with.KeepPattern)) { + if ($p -is [scriptblock]) { + throw "PruneEntitlements: KeepPattern entries must be strings, not ScriptBlocks." + } + $pStr = [string]$p + if ([string]::IsNullOrWhiteSpace($pStr)) { + throw "PruneEntitlements: KeepPattern entries must not be empty." + } + $keepPatterns += $pStr + } + } + + # At least one keep rule required (safety guardrail) + if ($keepItems.Count -eq 0 -and $keepPatterns.Count -eq 0) { + throw "PruneEntitlements requires at least one of: With.Keep or With.KeepPattern. At least one keep rule must be specified to prevent accidental removal of all entitlements." + } + + $ensureKeep = $false + if ($with.ContainsKey('EnsureKeepEntitlements') -and $null -ne $with.EnsureKeepEntitlements) { + $ensureKeep = [bool]$with.EnsureKeepEntitlements + } + + # Validate Context and Providers + if (-not ($Context.PSObject.Properties.Name -contains 'Providers')) { + throw "Context does not contain a Providers hashtable." + } + if ($null -eq $Context.Providers -or -not ($Context.Providers -is [hashtable])) { + throw "Context.Providers must be a hashtable." + } + if (-not $Context.Providers.ContainsKey($providerAlias)) { + throw "Provider '$providerAlias' was not supplied by the host." + } + + # Auth session acquisition (optional, data-only) + $authSession = $null + if ($with.ContainsKey('AuthSessionName')) { + $sessionName = [string]$with.AuthSessionName + $sessionOptions = if ($with.ContainsKey('AuthSessionOptions')) { $with.AuthSessionOptions } else { $null } + + if ($null -ne $sessionOptions -and -not ($sessionOptions -is [hashtable])) { + throw "With.AuthSessionOptions must be a hashtable or null." + } + + $authSession = $Context.AcquireAuthSession($sessionName, $sessionOptions) + } + + $provider = $Context.Providers[$providerAlias] + + $requiredMethods = @('ListEntitlements', 'RevokeEntitlement') + if ($ensureKeep) { + $requiredMethods += 'GrantEntitlement' + } + foreach ($m in $requiredMethods) { + if (-not ($provider.PSObject.Methods.Name -contains $m)) { + throw "Provider '$providerAlias' must implement method '$m' for PruneEntitlements." + } + } + + $listSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['ListEntitlements'] -ParameterName 'AuthSession' + $revokeSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['RevokeEntitlement'] -ParameterName 'AuthSession' + $grantSupportsAuthSession = if ($ensureKeep) { + Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['GrantEntitlement'] -ParameterName 'AuthSession' + } else { $false } + + # 1. List current entitlements, filter by Kind + $allCurrent = if ($listSupportsAuthSession -and $null -ne $authSession) { + @($provider.ListEntitlements($identityKey, $authSession)) + } else { + @($provider.ListEntitlements($identityKey)) + } + + $current = @($allCurrent | Where-Object { + $null -ne $_ -and + ($_.PSObject.Properties.Name -contains 'Kind') -and + [string]::Equals([string]$_.Kind, $kind, [System.StringComparison]::OrdinalIgnoreCase) + }) + + # 2. Compute keep-set and remove-set + $toKeep = @() + $toRemove = @() + + foreach ($ent in $current) { + $shouldKeep = $false + + # Check explicit Keep items (case-insensitive Id match) + foreach ($k in $keepItems) { + if (Test-IdleStepPruneEntitlementMatch -Current $ent -KeepItem $k) { + $shouldKeep = $true + break + } + } + + # Check KeepPattern (wildcard -like against Id and DisplayName) + if (-not $shouldKeep) { + foreach ($pattern in $keepPatterns) { + if ([string]$ent.Id -like $pattern) { + $shouldKeep = $true + break + } + if ($ent.PSObject.Properties.Name -contains 'DisplayName' -and + $null -ne $ent.DisplayName -and + [string]$ent.DisplayName -like $pattern) { + $shouldKeep = $true + break + } + } + } + + if ($shouldKeep) { + $toKeep += $ent + } else { + $toRemove += $ent + } + } + + # Emit plan intent event + if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and + $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { + $Context.EventSink.WriteEvent('Information', "PruneEntitlements: plan - keep=$(@($toKeep).Count), remove=$(@($toRemove).Count)", $Step.Name, @{ + Kind = $kind + KeepCount = @($toKeep).Count + PruneCount = @($toRemove).Count + }) + } + + $changed = $false + $skippedItems = @() + + # 3. Revoke each entitlement in remove-set + foreach ($ent in $toRemove) { + try { + if ($revokeSupportsAuthSession -and $null -ne $authSession) { + $null = $provider.RevokeEntitlement($identityKey, $ent, $authSession) + } else { + $null = $provider.RevokeEntitlement($identityKey, $ent) + } + $changed = $true + + if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and + $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { + $Context.EventSink.WriteEvent('Information', "PruneEntitlements: revoked entitlement '$($ent.Id)'", $Step.Name, @{ + Kind = $kind + EntitlementId = [string]$ent.Id + }) + } + } + catch { + # Non-removable or permission-denied entitlement: skip with warning + $reason = $_.Exception.Message + $skippedItems += [pscustomobject]@{ + EntitlementId = [string]$ent.Id + Reason = $reason + } + + if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and + $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { + $Context.EventSink.WriteEvent('Warning', "PruneEntitlements: skipped non-removable entitlement '$($ent.Id)': $reason", $Step.Name, @{ + Kind = $kind + EntitlementId = [string]$ent.Id + Reason = $reason + }) + } + } + } + + # 4. If EnsureKeepEntitlements: grant any explicit Keep items that are missing + if ($ensureKeep -and $keepItems.Count -gt 0) { + foreach ($k in $keepItems) { + $alreadyPresent = @($current | Where-Object { Test-IdleStepPruneEntitlementMatch -Current $_ -KeepItem $k }) + if (@($alreadyPresent).Count -eq 0) { + if ($grantSupportsAuthSession -and $null -ne $authSession) { + $null = $provider.GrantEntitlement($identityKey, $k, $authSession) + } else { + $null = $provider.GrantEntitlement($identityKey, $k) + } + $changed = $true + + if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and + $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { + $Context.EventSink.WriteEvent('Information', "PruneEntitlements: granted keep entitlement '$($k.Id)'", $Step.Name, @{ + Kind = $kind + EntitlementId = [string]$k.Id + }) + } + } + } + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Changed = $changed + Error = $null + Skipped = $skippedItems + } +} diff --git a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 new file mode 100644 index 00000000..0f368e7d --- /dev/null +++ b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 @@ -0,0 +1,370 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { + BeforeEach { + $script:Provider = New-IdleMockIdentityProvider + $script:Context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Plan = $null + Providers = @{ Identity = $script:Provider } + EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + } + + # Seed the identity with some entitlements + $null = $script:Provider.EnsureAttribute('user1', 'Seed', 'Value') + $null = $script:Provider.GrantEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=G-All,DC=contoso,DC=com' }) + $null = $script:Provider.GrantEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=G-HR,DC=contoso,DC=com'; DisplayName = 'HR Group' }) + $null = $script:Provider.GrantEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,DC=contoso,DC=com'; DisplayName = 'Leaver Retain' }) + $null = $script:Provider.GrantEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=LEAVER-EXTRA,DC=contoso,DC=com'; DisplayName = 'Leaver Extra' }) + + $script:StepTemplate = [pscustomobject]@{ + Name = 'Prune group memberships' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ + IdentityKey = 'user1' + Provider = 'Identity' + Kind = 'Group' + Keep = @( + @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,DC=contoso,DC=com' } + ) + } + } + } + + Context 'Behavior: Keep only' { + It 'removes entitlements not in the keep set' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + + $remaining = $script:Provider.ListEntitlements('user1') + @($remaining).Count | Should -Be 1 + $remaining[0].Id | Should -Be 'CN=LEAVER-RETAIN,DC=contoso,DC=com' + } + + It 'keeps explicitly kept entitlement regardless of case' { + $step = $script:StepTemplate + $step.With.Keep = @( + @{ Kind = 'Group'; Id = 'cn=leaver-retain,dc=contoso,dc=com' } + ) + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + + $remaining = $script:Provider.ListEntitlements('user1') + @($remaining).Count | Should -Be 1 + } + + It 'is idempotent when all non-keep entitlements are already absent' { + # Remove non-keep entitlements manually first + $null = $script:Provider.RevokeEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=G-All,DC=contoso,DC=com' }) + $null = $script:Provider.RevokeEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=G-HR,DC=contoso,DC=com' }) + $null = $script:Provider.RevokeEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=LEAVER-EXTRA,DC=contoso,DC=com' }) + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeFalse + } + } + + Context 'Behavior: Keep + KeepPattern union' { + It 'keeps entitlements matching wildcard KeepPattern' { + $step = $script:StepTemplate + $step.With.KeepPattern = @('CN=LEAVER-*,DC=contoso,DC=com') + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + + $remaining = $script:Provider.ListEntitlements('user1') + # Keep: explicit LEAVER-RETAIN + pattern LEAVER-* (LEAVER-RETAIN + LEAVER-EXTRA) + @($remaining).Count | Should -Be 2 + $remainingIds = $remaining | Select-Object -ExpandProperty Id + $remainingIds | Should -Contain 'CN=LEAVER-RETAIN,DC=contoso,DC=com' + $remainingIds | Should -Contain 'CN=LEAVER-EXTRA,DC=contoso,DC=com' + } + + It 'unions Keep and KeepPattern (keep-set is the union)' { + $step = $script:StepTemplate + # Keep CN=G-All explicitly, KeepPattern matches LEAVER-* + $step.With.Keep = @( + @{ Kind = 'Group'; Id = 'CN=G-All,DC=contoso,DC=com' } + ) + $step.With.KeepPattern = @('CN=LEAVER-*,DC=contoso,DC=com') + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + + $remaining = $script:Provider.ListEntitlements('user1') + $remainingIds = $remaining | Select-Object -ExpandProperty Id + # G-All (explicit) + LEAVER-RETAIN + LEAVER-EXTRA (pattern) + @($remaining).Count | Should -Be 3 + $remainingIds | Should -Contain 'CN=G-All,DC=contoso,DC=com' + $remainingIds | Should -Contain 'CN=LEAVER-RETAIN,DC=contoso,DC=com' + $remainingIds | Should -Contain 'CN=LEAVER-EXTRA,DC=contoso,DC=com' + } + + It 'only keeps entitlements matching KeepPattern when Keep is absent' { + $step = [pscustomobject]@{ + Name = 'Prune with pattern only' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ + IdentityKey = 'user1' + Provider = 'Identity' + Kind = 'Group' + KeepPattern = @('CN=G-All,*') + } + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + + $remaining = $script:Provider.ListEntitlements('user1') + @($remaining).Count | Should -Be 1 + $remaining[0].Id | Should -Be 'CN=G-All,DC=contoso,DC=com' + } + } + + Context 'Behavior: EnsureKeepEntitlements' { + It 'grants missing explicit Keep entitlements when EnsureKeepEntitlements is $true' { + $step = $script:StepTemplate + # Add a Keep item that does NOT exist in current entitlements + $step.With.Keep = @( + @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,DC=contoso,DC=com' } + @{ Kind = 'Group'; Id = 'CN=LEAVER-NEW,DC=contoso,DC=com'; DisplayName = 'Leaver New' } + ) + $step.With.EnsureKeepEntitlements = $true + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + + $remaining = $script:Provider.ListEntitlements('user1') + $remainingIds = $remaining | Select-Object -ExpandProperty Id + $remainingIds | Should -Contain 'CN=LEAVER-RETAIN,DC=contoso,DC=com' + $remainingIds | Should -Contain 'CN=LEAVER-NEW,DC=contoso,DC=com' + } + + It 'does not re-grant already present Keep entitlements' { + $step = $script:StepTemplate + $step.With.EnsureKeepEntitlements = $true + + $grantCount = 0 + $originalGrant = $script:Provider.PSObject.Methods['GrantEntitlement'] + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + + $remaining = $script:Provider.ListEntitlements('user1') + $remainingIds = $remaining | Select-Object -ExpandProperty Id + $remainingIds | Should -Contain 'CN=LEAVER-RETAIN,DC=contoso,DC=com' + } + + It 'does not grant pattern-matched entitlements (only explicit Keep items)' { + $step = $script:StepTemplate + $step.With.KeepPattern = @('CN=LEAVER-*,DC=contoso,DC=com') + $step.With.EnsureKeepEntitlements = $true + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + + $remaining = $script:Provider.ListEntitlements('user1') + $remainingIds = $remaining | Select-Object -ExpandProperty Id + # Only explicit Keep items from Keep array are granted if missing; patterns are not granted + $remainingIds | Should -Contain 'CN=LEAVER-RETAIN,DC=contoso,DC=com' + $remainingIds | Should -Contain 'CN=LEAVER-EXTRA,DC=contoso,DC=com' + } + } + + Context 'Behavior: Non-removable entitlement handling' { + It 'skips non-removable entitlements and continues without failing' { + # Mark CN=G-All as protected (non-removable, like AD primary group) + $script:Provider.ProtectedEntitlementIds = @('CN=G-All,DC=contoso,DC=com') + + $warningEvents = @() + $script:Context.EventSink = [pscustomobject]@{ + WriteEvent = { + param($Type, $Message, $StepName, $Data) + if ($Type -eq 'Warning') { + $script:warningEvents += $Message + } + }.GetNewClosure() + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Skipped | Should -Not -BeNullOrEmpty + $result.Skipped[0].EntitlementId | Should -Be 'CN=G-All,DC=contoso,DC=com' + + # Non-protected entitlements should still be removed + $remaining = $script:Provider.ListEntitlements('user1') + $remainingIds = $remaining | Select-Object -ExpandProperty Id + $remainingIds | Should -Contain 'CN=G-All,DC=contoso,DC=com' + $remainingIds | Should -Contain 'CN=LEAVER-RETAIN,DC=contoso,DC=com' + $remainingIds | Should -Not -Contain 'CN=G-HR,DC=contoso,DC=com' + } + + It 'includes the skip reason from the provider error message' { + $script:Provider.ProtectedEntitlementIds = @('CN=G-All,DC=contoso,DC=com') + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Skipped | Should -Not -BeNullOrEmpty + $result.Skipped[0].Reason | Should -Not -BeNullOrEmpty + } + } + + Context 'Behavior: Kind filtering' { + It 'only prunes entitlements matching the specified Kind' { + # Grant an entitlement of a different kind + $null = $script:Provider.GrantEntitlement('user1', @{ Kind = 'Role'; Id = 'admin-role' }) + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + + # The Role entitlement must survive (different kind) + $remaining = $script:Provider.ListEntitlements('user1') + $roleEntitlements = $remaining | Where-Object { $_.Kind -eq 'Role' } + @($roleEntitlements).Count | Should -Be 1 + $roleEntitlements[0].Id | Should -Be 'admin-role' + } + } + + Context 'Validation' { + It 'throws when With is not a hashtable' { + $step = [pscustomobject]@{ + Name = 'bad' + Type = 'IdLE.Step.PruneEntitlements' + With = 'invalid' + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + { & $handler -Context $script:Context -Step $step } | Should -Throw + } + + It 'throws when With.IdentityKey is missing' { + $step = [pscustomobject]@{ + Name = 'bad' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ Kind = 'Group'; Keep = @(@{ Kind = 'Group'; Id = 'x' }) } + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + { & $handler -Context $script:Context -Step $step } | Should -Throw + } + + It 'throws when With.Kind is missing' { + $step = [pscustomobject]@{ + Name = 'bad' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ IdentityKey = 'user1'; Keep = @(@{ Kind = 'Group'; Id = 'x' }) } + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + { & $handler -Context $script:Context -Step $step } | Should -Throw + } + + It 'throws when neither Keep nor KeepPattern is provided' { + $step = [pscustomobject]@{ + Name = 'bad' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ IdentityKey = 'user1'; Kind = 'Group'; Provider = 'Identity' } + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + { & $handler -Context $script:Context -Step $step } | Should -Throw -ExpectedMessage '*at least one*' + } + + It 'throws when Keep is empty array and KeepPattern is absent' { + $step = [pscustomobject]@{ + Name = 'bad' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ IdentityKey = 'user1'; Kind = 'Group'; Provider = 'Identity'; Keep = @() } + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + { & $handler -Context $script:Context -Step $step } | Should -Throw + } + + It 'throws when Keep item is missing an Id' { + $step = [pscustomobject]@{ + Name = 'bad' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ + IdentityKey = 'user1' + Kind = 'Group' + Provider = 'Identity' + Keep = @(@{ Kind = 'Group' }) + } + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + { & $handler -Context $script:Context -Step $step } | Should -Throw + } + + It 'throws when KeepPattern contains a ScriptBlock' { + $step = [pscustomobject]@{ + Name = 'bad' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ + IdentityKey = 'user1' + Kind = 'Group' + Provider = 'Identity' + KeepPattern = @({ 'CN=*' }) + } + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + { & $handler -Context $script:Context -Step $step } | Should -Throw + } + + It 'throws when the provider is missing' { + $script:Context.Providers.Clear() + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw + } + + It 'throws when AuthSessionOptions is not a hashtable' { + $step = $script:StepTemplate + $step.With.AuthSessionName = 'Directory' + $step.With.AuthSessionOptions = 'invalid' + + $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) return $null + } -Force + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + { & $handler -Context $script:Context -Step $step } | Should -Throw + } + } +} From 1bbc4a032c597d16cdb3598e9eb5be0c69289306 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:11:27 +0000 Subject: [PATCH 03/27] Fix Condition placement and step name in AD leaver template Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- examples/workflows/templates/ad-leaver.psd1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/workflows/templates/ad-leaver.psd1 b/examples/workflows/templates/ad-leaver.psd1 index ece470c9..cdc529dd 100644 --- a/examples/workflows/templates/ad-leaver.psd1 +++ b/examples/workflows/templates/ad-leaver.psd1 @@ -30,10 +30,10 @@ # Removing groups can break business processes unexpectedly. # PruneEntitlements offers a safer "remove all except" approach for leavers. @{ - Type = 'IdLE.Step.PruneEntitlements' - Name = 'Prune all group memberships except leaver retain group' - With = @{ - Condition = @{ Equals = @{ Path = 'Request.Intent.PruneGroups'; Value = $true } } + Type = 'IdLE.Step.PruneEntitlements' + Name = 'Prune group memberships (leaver)' + Condition = @{ Equals = @{ Path = 'Request.Intent.PruneGroups'; Value = $true } } + With = @{ AuthSessionName = 'Directory' IdentityKey = '{{Request.Intent.SamAccountName}}' Kind = 'Group' From cff2fd711240a287afea4b6cc50e6de2fcb1e305 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:56:07 +0000 Subject: [PATCH 04/27] Add dedicated adapter PruneGroupMemberships + provider PruneEntitlements with ID normalization; simplify step loop Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/New-IdleADAdapter.ps1 | 61 ++++++ .../Public/New-IdleADIdentityProvider.ps1 | 80 ++++++++ .../Private/New-IdleEntraIDAdapter.ps1 | 67 +++++++ .../New-IdleEntraIDIdentityProvider.ps1 | 80 ++++++++ .../Invoke-IdleStepPruneEntitlements.ps1 | 102 ++++++---- tests/Providers/ADIdentityProvider.Tests.ps1 | 156 +++++++++++++++ .../EntraIDIdentityProvider.Tests.ps1 | 184 ++++++++++++++++++ ...Invoke-IdleStepPruneEntitlements.Tests.ps1 | 50 +++++ 8 files changed, 741 insertions(+), 39 deletions(-) diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index 28b77786..9ba066f5 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -738,6 +738,67 @@ function New-IdleADAdapter { } } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name PruneGroupMemberships -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $UserDN, + + [Parameter()] + [AllowNull()] + [string[]] $KeepGroupDNs, + + [Parameter()] + [AllowNull()] + [string[]] $KeepPatterns + ) + + $groups = $this.GetUserGroups($UserDN) + $removed = @() + $skipped = @() + + foreach ($group in @($groups)) { + $dn = [string]$group.DistinguishedName + $name = [string]$group.Name + + $shouldKeep = $false + + foreach ($keepDN in @($KeepGroupDNs)) { + if ([string]::Equals($dn, $keepDN, [System.StringComparison]::OrdinalIgnoreCase)) { + $shouldKeep = $true + break + } + } + + if (-not $shouldKeep) { + foreach ($pattern in @($KeepPatterns)) { + if ($dn -like $pattern -or $name -like $pattern) { + $shouldKeep = $true + break + } + } + } + + if (-not $shouldKeep) { + try { + $this.RemoveGroupMember($dn, $UserDN) + $removed += $dn + } + catch { + $skipped += [pscustomobject]@{ + GroupDN = $dn + Reason = $_.Exception.Message + } + } + } + } + + return [pscustomobject]@{ + Removed = $removed + Skipped = $skipped + } + } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name ListUsers -Value { param( [Parameter()] diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index 5c4e8454..22665a23 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -852,5 +852,85 @@ function New-IdleADIdentityProvider { } } -Force + $provider | Add-Member -MemberType ScriptMethod -Name PruneEntitlements -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Kind, + + [Parameter()] + [AllowNull()] + [object[]] $KeepItems, + + [Parameter()] + [AllowNull()] + [string[]] $KeepPatterns, + + [Parameter()] + [bool] $EnsureKeepEntitlements, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + # AD provider only supports Group entitlements for prune + if (-not [string]::Equals($Kind, 'Group', [System.StringComparison]::OrdinalIgnoreCase)) { + throw "PruneEntitlements: AD provider only supports Kind 'Group'. Got: '$Kind'." + } + + $adapter = $this.GetEffectiveAdapter($AuthSession) + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + + # Normalize keep item IDs to canonical DNs (provider-specific ID-type-detection via NormalizeGroupId) + $keepGroupDNs = @() + if ($null -ne $KeepItems) { + foreach ($item in @($KeepItems)) { + $normalized = $this.ConvertToEntitlement($item) + $canonicalDN = $this.NormalizeGroupId($normalized.Id, $AuthSession) + $keepGroupDNs += $canonicalDN + } + } + + # Delegate bulk removal to adapter (adapter handles list + delta + remove loop) + $pruneResult = $adapter.PruneGroupMemberships($user.DistinguishedName, $keepGroupDNs, $KeepPatterns) + $changed = $pruneResult.Removed.Count -gt 0 + + # Map adapter skipped entries to standard IdLE format + $skippedItems = @() + foreach ($s in @($pruneResult.Skipped)) { + $skippedItems += [pscustomobject]@{ + EntitlementId = [string]$s.GroupDN + Reason = [string]$s.Reason + } + } + + # EnsureKeepEntitlements: grant any explicit Keep items not yet present + if ($EnsureKeepEntitlements -and $null -ne $KeepItems -and @($KeepItems).Count -gt 0) { + $currentGroups = $this.ListEntitlements($IdentityKey, $AuthSession) + foreach ($item in @($KeepItems)) { + $normalized = $this.ConvertToEntitlement($item) + $existing = @($currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) }) + if (@($existing).Count -eq 0) { + $canonicalDN = $this.NormalizeGroupId($normalized.Id, $AuthSession) + $adapter.AddGroupMember($canonicalDN, $user.DistinguishedName) + $changed = $true + } + } + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'PruneEntitlements' + IdentityKey = $IdentityKey + Changed = $changed + Skipped = $skippedItems + } + } -Force + return $provider } diff --git a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 index 61b615ad..b4e2b3d3 100644 --- a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 +++ b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 @@ -429,6 +429,73 @@ function New-IdleEntraIDAdapter { } } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name PruneGroupMemberships -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $UserObjectId, + + [Parameter()] + [AllowNull()] + [string[]] $KeepGroupObjectIds, + + [Parameter()] + [AllowNull()] + [string[]] $KeepPatterns, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccessToken + ) + + $groups = $this.ListUserGroups($UserObjectId, $AccessToken) + $removed = @() + $skipped = @() + + foreach ($group in @($groups)) { + $groupId = if ($group -is [System.Collections.IDictionary]) { [string]$group['id'] } else { [string]$group.id } + $displayName = if ($group -is [System.Collections.IDictionary]) { + if ($group.ContainsKey('displayName')) { [string]$group['displayName'] } else { $null } + } else { + if ($group.PSObject.Properties.Name -contains 'displayName') { [string]$group.displayName } else { $null } + } + + $shouldKeep = $false + + foreach ($keepId in @($KeepGroupObjectIds)) { + if ([string]::Equals($groupId, $keepId, [System.StringComparison]::OrdinalIgnoreCase)) { + $shouldKeep = $true + break + } + } + + if (-not $shouldKeep) { + foreach ($pattern in @($KeepPatterns)) { + if ($groupId -like $pattern) { $shouldKeep = $true; break } + if ($null -ne $displayName -and $displayName -like $pattern) { $shouldKeep = $true; break } + } + } + + if (-not $shouldKeep) { + try { + $this.RemoveGroupMember($groupId, $UserObjectId, $AccessToken) + $removed += $groupId + } + catch { + $skipped += [pscustomobject]@{ + GroupObjectId = $groupId + Reason = $_.Exception.Message + } + } + } + } + + return [pscustomobject]@{ + Removed = $removed + Skipped = $skipped + } + } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name RevokeSignInSessions -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 f7f17948..b4d6f5aa 100644 --- a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 +++ b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 @@ -948,5 +948,85 @@ function New-IdleEntraIDIdentityProvider { } } -Force + $provider | Add-Member -MemberType ScriptMethod -Name PruneEntitlements -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Kind, + + [Parameter()] + [AllowNull()] + [object[]] $KeepItems, + + [Parameter()] + [AllowNull()] + [string[]] $KeepPatterns, + + [Parameter()] + [bool] $EnsureKeepEntitlements, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + # Entra ID provider only supports Group entitlements for prune + if (-not [string]::Equals($Kind, 'Group', [System.StringComparison]::OrdinalIgnoreCase)) { + throw "PruneEntitlements: Entra ID provider only supports Kind 'Group'. Got: '$Kind'." + } + + $accessToken = $this.ExtractAccessToken($AuthSession) + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + + # Normalize keep item IDs to canonical objectIds (provider-specific ID-type-detection via NormalizeGroupId) + $keepGroupObjectIds = @() + if ($null -ne $KeepItems) { + foreach ($item in @($KeepItems)) { + $normalized = $this.ConvertToEntitlement($item) + $canonicalId = $this.NormalizeGroupId($normalized.Id, $AuthSession) + $keepGroupObjectIds += $canonicalId + } + } + + # Delegate bulk removal to adapter (adapter handles list + delta + remove loop) + $pruneResult = $this.Adapter.PruneGroupMemberships($user.id, $keepGroupObjectIds, $KeepPatterns, $accessToken) + $changed = $pruneResult.Removed.Count -gt 0 + + # Map adapter skipped entries to standard IdLE format + $skippedItems = @() + foreach ($s in @($pruneResult.Skipped)) { + $skippedItems += [pscustomobject]@{ + EntitlementId = [string]$s.GroupObjectId + Reason = [string]$s.Reason + } + } + + # EnsureKeepEntitlements: grant any explicit Keep items not yet present + if ($EnsureKeepEntitlements -and $null -ne $KeepItems -and @($KeepItems).Count -gt 0) { + $currentGroups = $this.ListEntitlements($IdentityKey, $AuthSession) + foreach ($item in @($KeepItems)) { + $normalized = $this.ConvertToEntitlement($item) + $existing = @($currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) }) + if (@($existing).Count -eq 0) { + $canonicalId = $this.NormalizeGroupId($normalized.Id, $AuthSession) + $this.Adapter.AddGroupMember($canonicalId, $user.id, $accessToken) + $changed = $true + } + } + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'PruneEntitlements' + IdentityKey = $IdentityKey + Changed = $changed + Skipped = $skippedItems + } + } -Force + return $provider } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 index 2d90333d..3523e149 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -113,19 +113,40 @@ function Invoke-IdleStepPruneEntitlements { return [pscustomobject]$normalized } - function Test-IdleStepPruneEntitlementMatch { + function Test-IdleStepPruneEntitlementShouldKeep { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Current, + [object] $Ent, [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $KeepItem + [AllowNull()] + [AllowEmptyCollection()] + [object[]] $KeepItems, + + [Parameter(Mandatory)] + [AllowNull()] + [AllowEmptyCollection()] + [string[]] $KeepPatterns ) - return [string]::Equals([string]$Current.Id, [string]$KeepItem.Id, [System.StringComparison]::OrdinalIgnoreCase) + # Check explicit Keep items (case-insensitive Id match) + foreach ($k in $KeepItems) { + if ([string]::Equals([string]$Ent.Id, [string]$k.Id, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + + # Check KeepPattern (wildcard -like against Id and DisplayName) + foreach ($pattern in $KeepPatterns) { + if ([string]$Ent.Id -like $pattern) { return $true } + if ($Ent.PSObject.Properties.Name -contains 'DisplayName' -and + $null -ne $Ent.DisplayName -and + [string]$Ent.DisplayName -like $pattern) { return $true } + } + + return $false } $with = $Step.With @@ -213,13 +234,40 @@ function Invoke-IdleStepPruneEntitlements { $provider = $Context.Providers[$providerAlias] - $requiredMethods = @('ListEntitlements', 'RevokeEntitlement') - if ($ensureKeep) { - $requiredMethods += 'GrantEntitlement' + # Use provider's native PruneEntitlements method when available (handles ID normalization internally) + $providerHasPruneMethod = $provider.PSObject.Methods.Name -contains 'PruneEntitlements' + + if (-not $providerHasPruneMethod) { + # Validate fallback individual methods + $requiredMethods = @('ListEntitlements', 'RevokeEntitlement') + if ($ensureKeep) { + $requiredMethods += 'GrantEntitlement' + } + foreach ($m in $requiredMethods) { + if (-not ($provider.PSObject.Methods.Name -contains $m)) { + throw "Provider '$providerAlias' must implement method '$m' for PruneEntitlements." + } + } } - foreach ($m in $requiredMethods) { - if (-not ($provider.PSObject.Methods.Name -contains $m)) { - throw "Provider '$providerAlias' must implement method '$m' for PruneEntitlements." + + if ($providerHasPruneMethod) { + # Provider handles ID normalization, user resolution, and bulk removal natively + $pruneSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['PruneEntitlements'] -ParameterName 'AuthSession' + + $pruneResult = if ($pruneSupportsAuthSession) { + $provider.PruneEntitlements($identityKey, $kind, $keepItems, $keepPatterns, $ensureKeep, $authSession) + } else { + $provider.PruneEntitlements($identityKey, $kind, $keepItems, $keepPatterns, $ensureKeep) + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Changed = [bool]$pruneResult.Changed + Error = $null + Skipped = if ($null -ne $pruneResult.Skipped) { @($pruneResult.Skipped) } else { @() } } } @@ -247,33 +295,7 @@ function Invoke-IdleStepPruneEntitlements { $toRemove = @() foreach ($ent in $current) { - $shouldKeep = $false - - # Check explicit Keep items (case-insensitive Id match) - foreach ($k in $keepItems) { - if (Test-IdleStepPruneEntitlementMatch -Current $ent -KeepItem $k) { - $shouldKeep = $true - break - } - } - - # Check KeepPattern (wildcard -like against Id and DisplayName) - if (-not $shouldKeep) { - foreach ($pattern in $keepPatterns) { - if ([string]$ent.Id -like $pattern) { - $shouldKeep = $true - break - } - if ($ent.PSObject.Properties.Name -contains 'DisplayName' -and - $null -ne $ent.DisplayName -and - [string]$ent.DisplayName -like $pattern) { - $shouldKeep = $true - break - } - } - } - - if ($shouldKeep) { + if (Test-IdleStepPruneEntitlementShouldKeep -Ent $ent -KeepItems $keepItems -KeepPatterns $keepPatterns) { $toKeep += $ent } else { $toRemove += $ent @@ -333,7 +355,9 @@ function Invoke-IdleStepPruneEntitlements { # 4. If EnsureKeepEntitlements: grant any explicit Keep items that are missing if ($ensureKeep -and $keepItems.Count -gt 0) { foreach ($k in $keepItems) { - $alreadyPresent = @($current | Where-Object { Test-IdleStepPruneEntitlementMatch -Current $_ -KeepItem $k }) + $alreadyPresent = @($current | Where-Object { + [string]::Equals([string]$_.Id, [string]$k.Id, [System.StringComparison]::OrdinalIgnoreCase) + }) if (@($alreadyPresent).Count -eq 0) { if ($grantSupportsAuthSession -and $null -ne $authSession) { $null = $provider.GrantEntitlement($identityKey, $k, $authSession) diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index 81842bfa..ff12153a 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -551,6 +551,44 @@ Describe 'AD identity provider' { return $groups } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name PruneGroupMemberships -Value { + param([string]$UserDN, [string[]]$KeepGroupDNs, [string[]]$KeepPatterns) + + $groups = $this.GetUserGroups($UserDN) + $removed = @() + $skipped = @() + + foreach ($group in @($groups)) { + $dn = [string]$group.DistinguishedName + $name = [string]$group.Name + + $shouldKeep = $false + foreach ($keepDN in @($KeepGroupDNs)) { + if ([string]::Equals($dn, $keepDN, [System.StringComparison]::OrdinalIgnoreCase)) { + $shouldKeep = $true; break + } + } + if (-not $shouldKeep) { + foreach ($pattern in @($KeepPatterns)) { + if ($dn -like $pattern -or $name -like $pattern) { + $shouldKeep = $true; break + } + } + } + + if (-not $shouldKeep) { + try { + $this.RemoveGroupMember($dn, $UserDN) + $removed += $dn + } + catch { + $skipped += [pscustomobject]@{ GroupDN = $dn; Reason = $_.Exception.Message } + } + } + } + return [pscustomobject]@{ Removed = $removed; Skipped = $skipped } + } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name ListUsers -Value { param([hashtable]$Filter) @@ -2027,4 +2065,122 @@ Describe 'AD identity provider' { $secure | Should -BeOfType [securestring] } } + + Context 'PruneEntitlements' { + BeforeEach { + $adapter = New-FakeADAdapter + $provider = New-IdleADIdentityProvider -Adapter $adapter + + # Create a test user with group memberships + $testUser = $adapter.NewUser('PruneUser', @{ SamAccountName = 'pruneuser' }, $true) + $script:UserId = $testUser.ObjectGuid.ToString() + + # Seed with groups using GrantEntitlement + $provider.GrantEntitlement($script:UserId, @{ Kind = 'Group'; Id = 'CN=G-All,OU=Groups,DC=domain,DC=local' }) | Out-Null + $provider.GrantEntitlement($script:UserId, @{ Kind = 'Group'; Id = 'CN=G-HR,OU=Groups,DC=domain,DC=local' }) | Out-Null + $provider.GrantEntitlement($script:UserId, @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=domain,DC=local' }) | Out-Null + $provider.GrantEntitlement($script:UserId, @{ Kind = 'Group'; Id = 'CN=LEAVER-EXTRA,OU=Groups,DC=domain,DC=local' }) | Out-Null + + $script:Provider = $provider + $script:PruneAdapter = $adapter + } + + It 'Exposes PruneEntitlements as a ScriptMethod' { + $p = New-IdleADIdentityProvider -Adapter (New-FakeADAdapter) + $p.PSObject.Methods.Name | Should -Contain 'PruneEntitlements' + } + + It 'Removes entitlements not in the keep set' { + $result = $script:Provider.PruneEntitlements( + $script:UserId, 'Group', + @(@{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=domain,DC=local' }), + $null, $false, $null + ) + + $result.Changed | Should -BeTrue + $remaining = @($script:Provider.ListEntitlements($script:UserId) | Where-Object Kind -eq 'Group') + @($remaining).Count | Should -Be 1 + } + + It 'Keeps entitlements matching KeepPattern (wildcard against DN and Name)' { + $result = $script:Provider.PruneEntitlements( + $script:UserId, 'Group', + $null, + @('CN=LEAVER-*,OU=Groups,DC=domain,DC=local'), + $false, $null + ) + + $result.Changed | Should -BeTrue + $remaining = @($script:Provider.ListEntitlements($script:UserId) | Where-Object Kind -eq 'Group') + @($remaining).Count | Should -Be 2 + ($remaining | Select-Object -ExpandProperty Id) | Should -Contain 'CN=LEAVER-RETAIN,OU=Groups,DC=domain,DC=local' + ($remaining | Select-Object -ExpandProperty Id) | Should -Contain 'CN=LEAVER-EXTRA,OU=Groups,DC=domain,DC=local' + } + + It 'Grants missing explicit Keep items when EnsureKeepEntitlements is true' { + $newGroupDN = 'CN=LEAVER-NEW,OU=Groups,DC=domain,DC=local' + $result = $script:Provider.PruneEntitlements( + $script:UserId, 'Group', + @( + @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=domain,DC=local' }, + @{ Kind = 'Group'; Id = $newGroupDN } + ), + $null, $true, $null + ) + + $result.Changed | Should -BeTrue + $remaining = @($script:Provider.ListEntitlements($script:UserId) | Where-Object Kind -eq 'Group') + ($remaining | Select-Object -ExpandProperty Id) | Should -Contain $newGroupDN + } + + It 'Returns structured Skipped entries for non-removable groups' { + # Override RemoveGroupMember to throw for one group + $script:PruneAdapter | Add-Member -MemberType NoteProperty -Name ProtectedGroupDN -Value 'CN=G-HR,OU=Groups,DC=domain,DC=local' -Force + $script:PruneAdapter | Add-Member -MemberType ScriptMethod -Name RemoveGroupMember -Value { + param([string]$GroupIdentity, [string]$MemberIdentity) + if ($GroupIdentity -eq $this.ProtectedGroupDN) { + throw "Cannot remove protected group '$GroupIdentity'." + } + $user = $null + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].DistinguishedName -eq $MemberIdentity) { $user = $this.Store[$key]; break } + } + if ($null -ne $user -and $null -ne $user.Groups) { + $user.Groups = @($user.Groups | Where-Object { $_.Id -ne $GroupIdentity }) + } + } -Force + + $result = $script:Provider.PruneEntitlements( + $script:UserId, 'Group', + @(@{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=domain,DC=local' }), + $null, $false, $null + ) + + $result.Changed | Should -BeTrue + $result.Skipped | Should -Not -BeNullOrEmpty + $result.Skipped[0].EntitlementId | Should -Be 'CN=G-HR,OU=Groups,DC=domain,DC=local' + } + + It 'Throws when Kind is not Group' { + { + $script:Provider.PruneEntitlements($script:UserId, 'Role', $null, @('LEAVER-*'), $false, $null) + } | Should -Throw -ExpectedMessage "*Kind 'Group'*" + } + + It 'Is idempotent when all non-keep groups are already removed' { + $script:Provider.PruneEntitlements( + $script:UserId, 'Group', + @(@{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=domain,DC=local' }), + $null, $false, $null + ) | Out-Null + + $result = $script:Provider.PruneEntitlements( + $script:UserId, 'Group', + @(@{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=domain,DC=local' }), + $null, $false, $null + ) + + $result.Changed | Should -BeFalse + } + } } diff --git a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 index 276443cf..14566055 100644 --- a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 +++ b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 @@ -237,6 +237,7 @@ Describe 'EntraID identity provider - Capabilities' { $caps | Should -Contain 'IdLE.Entitlement.List' $caps | Should -Contain 'IdLE.Entitlement.Grant' $caps | Should -Contain 'IdLE.Entitlement.Revoke' + $caps | Should -Contain 'IdLE.Entitlement.Prune' $caps | Should -Not -Contain 'IdLE.Identity.Delete' } @@ -725,6 +726,43 @@ Describe 'EntraID identity provider - Entitlement operations' { } } + $adapter | Add-Member -MemberType ScriptMethod -Name PruneGroupMemberships -Value { + param($UserObjectId, $KeepGroupObjectIds, $KeepPatterns, $AccessToken) + + $groups = $this.ListUserGroups($UserObjectId, $AccessToken) + $removed = @() + $skipped = @() + + foreach ($group in @($groups)) { + $groupId = [string]$group.id + $displayName = if ($group.PSObject.Properties.Name -contains 'displayName') { [string]$group.displayName } else { $null } + + $shouldKeep = $false + foreach ($keepId in @($KeepGroupObjectIds)) { + if ([string]::Equals($groupId, $keepId, [System.StringComparison]::OrdinalIgnoreCase)) { + $shouldKeep = $true; break + } + } + if (-not $shouldKeep) { + foreach ($pattern in @($KeepPatterns)) { + if ($groupId -like $pattern) { $shouldKeep = $true; break } + if ($null -ne $displayName -and $displayName -like $pattern) { $shouldKeep = $true; break } + } + } + + if (-not $shouldKeep) { + try { + $this.RemoveGroupMember($groupId, $UserObjectId, $AccessToken) + $removed += $groupId + } + catch { + $skipped += [pscustomobject]@{ GroupObjectId = $groupId; Reason = $_.Exception.Message } + } + } + } + return [pscustomobject]@{ Removed = $removed; Skipped = $skipped } + } + return $adapter } @@ -1121,3 +1159,149 @@ Describe 'EntraID identity provider - Password generation' { } } } + +Describe 'EntraID identity provider - PruneEntitlements' { + BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule + + # Local re-definition of the fake adapter (same structure as in Entitlement operations) + function New-FakeEntraIDAdapterForEntitlements { + $store = @{} + $adapter = [pscustomobject]@{ PSTypeName = 'IdLE.EntraIDAdapter.Fake'; Store = $store } + + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserById -Value { + param($ObjectId, $AccessToken) + $key = "id:$ObjectId" + if (-not $this.Store.ContainsKey($key)) { + $this.Store[$key] = @{ id = $ObjectId; userPrincipalName = "$ObjectId@test.local"; displayName = "User $ObjectId"; accountEnabled = $true } + } + return $this.Store[$key] + } + $adapter | Add-Member -MemberType ScriptMethod -Name GetGroupById -Value { + param($GroupId, $AccessToken) + return @{ id = $GroupId; displayName = "Group $GroupId" } + } + $adapter | Add-Member -MemberType ScriptMethod -Name ListUserGroups -Value { + param($ObjectId, $AccessToken) + $key = "groups:$ObjectId" + if (-not $this.Store.ContainsKey($key)) { $this.Store[$key] = @() } + return $this.Store[$key] + } + $adapter | Add-Member -MemberType ScriptMethod -Name AddGroupMember -Value { + param($GroupObjectId, $UserObjectId, $AccessToken) + $key = "groups:$UserObjectId" + if (-not $this.Store.ContainsKey($key)) { $this.Store[$key] = @() } + $alreadyMember = $false + foreach ($g in $this.Store[$key]) { if ($g.id -eq $GroupObjectId) { $alreadyMember = $true; break } } + if (-not $alreadyMember) { $this.Store[$key] += @{ id = $GroupObjectId; displayName = "Group $GroupObjectId" } } + } + $adapter | Add-Member -MemberType ScriptMethod -Name RemoveGroupMember -Value { + param($GroupObjectId, $UserObjectId, $AccessToken) + $key = "groups:$UserObjectId" + if ($this.Store.ContainsKey($key)) { $this.Store[$key] = @($this.Store[$key] | Where-Object { $_.id -ne $GroupObjectId }) } + } + $adapter | Add-Member -MemberType ScriptMethod -Name PruneGroupMemberships -Value { + param($UserObjectId, $KeepGroupObjectIds, $KeepPatterns, $AccessToken) + $groups = $this.ListUserGroups($UserObjectId, $AccessToken) + $removed = @(); $skipped = @() + foreach ($group in @($groups)) { + $gId = [string]$group.id; $dn = if ($group.PSObject.Properties.Name -contains 'displayName') { [string]$group.displayName } else { $null } + $keep = $false + foreach ($k in @($KeepGroupObjectIds)) { if ([string]::Equals($gId, $k, [System.StringComparison]::OrdinalIgnoreCase)) { $keep = $true; break } } + if (-not $keep) { foreach ($p in @($KeepPatterns)) { if ($gId -like $p) { $keep = $true; break } if ($null -ne $dn -and $dn -like $p) { $keep = $true; break } } } + if (-not $keep) { + try { $this.RemoveGroupMember($gId, $UserObjectId, $AccessToken); $removed += $gId } + catch { $skipped += [pscustomobject]@{ GroupObjectId = $gId; Reason = $_.Exception.Message } } + } + } + return [pscustomobject]@{ Removed = $removed; Skipped = $skipped } + } + return $adapter + } + } + + Context 'Provider exposes PruneEntitlements method' { + It 'Exposes PruneEntitlements as a ScriptMethod' { + $provider = New-IdleEntraIDIdentityProvider -Adapter ([pscustomobject]@{}) + $provider.PSObject.Methods.Name | Should -Contain 'PruneEntitlements' + } + } + + Context 'PruneEntitlements behavior' { + BeforeEach { + $adapter = New-FakeEntraIDAdapterForEntitlements + $provider = New-IdleEntraIDIdentityProvider -Adapter $adapter + + $userId = [guid]::NewGuid().ToString() + # Seed identity + [void]$provider.GetIdentity($userId) + + $groupAll = [guid]::NewGuid().ToString() + $groupHR = [guid]::NewGuid().ToString() + $groupKeep = [guid]::NewGuid().ToString() + + $provider.GrantEntitlement($userId, @{ Kind = 'Group'; Id = $groupAll }) | Out-Null + $provider.GrantEntitlement($userId, @{ Kind = 'Group'; Id = $groupHR }) | Out-Null + $provider.GrantEntitlement($userId, @{ Kind = 'Group'; Id = $groupKeep }) | Out-Null + + $script:Provider = $provider + $script:Adapter = $adapter + $script:UserId = $userId + $script:GroupAll = $groupAll + $script:GroupHR = $groupHR + $script:GroupKeep = $groupKeep + } + + It 'Removes entitlements not in the keep set' { + $result = $script:Provider.PruneEntitlements( + $script:UserId, 'Group', + @(@{ Kind = 'Group'; Id = $script:GroupKeep }), + $null, $false, 'fake-token' + ) + + $result.Changed | Should -BeTrue + $remaining = @($script:Provider.ListEntitlements($script:UserId) | Where-Object Kind -eq 'Group') + @($remaining).Count | Should -Be 1 + $remaining[0].Id | Should -Be $script:GroupKeep + } + + It 'Grants missing Keep items when EnsureKeepEntitlements is true' { + $newGroupId = [guid]::NewGuid().ToString() + $result = $script:Provider.PruneEntitlements( + $script:UserId, 'Group', + @( + @{ Kind = 'Group'; Id = $script:GroupKeep }, + @{ Kind = 'Group'; Id = $newGroupId } + ), + $null, $true, 'fake-token' + ) + + $result.Changed | Should -BeTrue + $remaining = @($script:Provider.ListEntitlements($script:UserId) | Where-Object Kind -eq 'Group') + ($remaining | Select-Object -ExpandProperty Id) | Should -Contain $newGroupId + } + + It 'Throws when Kind is not Group' { + { + $script:Provider.PruneEntitlements($script:UserId, 'Role', $null, @('LEAVER-*'), $false, 'fake-token') + } | Should -Throw -ExpectedMessage "*Kind 'Group'*" + } + + It 'Is idempotent when all non-keep groups are already removed' { + $script:Provider.PruneEntitlements( + $script:UserId, 'Group', + @(@{ Kind = 'Group'; Id = $script:GroupKeep }), + $null, $false, 'fake-token' + ) | Out-Null + + $result = $script:Provider.PruneEntitlements( + $script:UserId, 'Group', + @(@{ Kind = 'Group'; Id = $script:GroupKeep }), + $null, $false, 'fake-token' + ) + + $result.Changed | Should -BeFalse + } + } +} diff --git a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 index 0f368e7d..664990be 100644 --- a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 @@ -367,4 +367,54 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { { & $handler -Context $script:Context -Step $step } | Should -Throw } } + + Context 'Behavior: step delegates to provider.PruneEntitlements when available' { + It 'calls provider PruneEntitlements and uses its result' { + $pruneCallArgs = @{} + $mockProvider = [pscustomobject]@{ + PSTypeName = 'MockPruneProvider' + } + $mockProvider | Add-Member -MemberType ScriptMethod -Name PruneEntitlements -Value { + param($IdentityKey, $Kind, $KeepItems, $KeepPatterns, $EnsureKeep, $AuthSession) + $script:PruneCallArgs = @{ + IdentityKey = $IdentityKey + Kind = $Kind + KeepItems = $KeepItems + KeepPatterns = $KeepPatterns + EnsureKeep = $EnsureKeep + } + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Changed = $true + Skipped = @([pscustomobject]@{ EntitlementId = 'CN=X'; Reason = 'protected' }) + } + } -Force + + $script:Context.Providers['Identity'] = $mockProvider + + $step = [pscustomobject]@{ + Name = 'Prune via provider' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ + IdentityKey = 'user1' + Kind = 'Group' + Provider = 'Identity' + Keep = @(@{ Kind = 'Group'; Id = 'CN=RETAIN,DC=x' }) + KeepPattern = @('CN=LEAVER-*') + } + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + $result.Skipped | Should -Not -BeNullOrEmpty + $result.Skipped[0].EntitlementId | Should -Be 'CN=X' + + $script:PruneCallArgs.IdentityKey | Should -Be 'user1' + $script:PruneCallArgs.Kind | Should -Be 'Group' + $script:PruneCallArgs.KeepPatterns | Should -Contain 'CN=LEAVER-*' + } + } } From 2ebe95c557f0b27c5cda42c1717f1a81159afc05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:03:54 +0000 Subject: [PATCH 05/27] Refactor: step is single delta-computation source; add NormalizeEntitlementId to AD/Entra providers; enhance AD NormalizeGroupId with GUID/DN/sAMAccountName fallbacks; remove PruneGroupMemberships from adapters Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/New-IdleADAdapter.ps1 | 61 ------ .../Public/New-IdleADIdentityProvider.ps1 | 98 +++------ .../Private/New-IdleEntraIDAdapter.ps1 | 67 ------ .../New-IdleEntraIDIdentityProvider.ps1 | 77 ++----- .../Invoke-IdleStepPruneEntitlements.ps1 | 53 ++--- tests/Providers/ADIdentityProvider.Tests.ps1 | 161 ++------------ .../EntraIDIdentityProvider.Tests.ps1 | 199 +++--------------- ...Invoke-IdleStepPruneEntitlements.Tests.ps1 | 75 ++++--- 8 files changed, 161 insertions(+), 630 deletions(-) diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index 9ba066f5..28b77786 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -738,67 +738,6 @@ function New-IdleADAdapter { } } -Force - $adapter | Add-Member -MemberType ScriptMethod -Name PruneGroupMemberships -Value { - param( - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $UserDN, - - [Parameter()] - [AllowNull()] - [string[]] $KeepGroupDNs, - - [Parameter()] - [AllowNull()] - [string[]] $KeepPatterns - ) - - $groups = $this.GetUserGroups($UserDN) - $removed = @() - $skipped = @() - - foreach ($group in @($groups)) { - $dn = [string]$group.DistinguishedName - $name = [string]$group.Name - - $shouldKeep = $false - - foreach ($keepDN in @($KeepGroupDNs)) { - if ([string]::Equals($dn, $keepDN, [System.StringComparison]::OrdinalIgnoreCase)) { - $shouldKeep = $true - break - } - } - - if (-not $shouldKeep) { - foreach ($pattern in @($KeepPatterns)) { - if ($dn -like $pattern -or $name -like $pattern) { - $shouldKeep = $true - break - } - } - } - - if (-not $shouldKeep) { - try { - $this.RemoveGroupMember($dn, $UserDN) - $removed += $dn - } - catch { - $skipped += [pscustomobject]@{ - GroupDN = $dn - Reason = $_.Exception.Message - } - } - } - } - - return [pscustomobject]@{ - Removed = $removed - Skipped = $skipped - } - } -Force - $adapter | Add-Member -MemberType ScriptMethod -Name ListUsers -Value { param( [Parameter()] diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index 22665a23..4e46cfab 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -243,12 +243,25 @@ function New-IdleADIdentityProvider { $adapter = $this.GetEffectiveAdapter($AuthSession) - $group = $adapter.GetGroupById($GroupId) - if ($null -eq $group) { - throw "Group '$GroupId' not found." + # Try as GUID first (most deterministic) + $guid = [System.Guid]::Empty + if ([System.Guid]::TryParse($GroupId, [ref]$guid)) { + $group = $adapter.GetGroupById($guid.ToString()) + if ($null -ne $group) { return $group.DistinguishedName } + throw "Group with GUID '$GroupId' not found." } - return $group.DistinguishedName + # Looks like a DN (contains = and ,) — use direct lookup + if ($GroupId -match '=' -and $GroupId -match ',') { + $group = $adapter.GetGroupById($GroupId) + if ($null -ne $group) { return $group.DistinguishedName } + throw "Group with DN '$GroupId' not found." + } + + # Fallback: treat as sAMAccountName (Get-ADGroup -Identity accepts sAMAccountName) + $group = $adapter.GetGroupById($GroupId) + if ($null -ne $group) { return $group.DistinguishedName } + throw "Group with sAMAccountName '$GroupId' not found." } $provider = [pscustomobject]@{ @@ -852,84 +865,35 @@ function New-IdleADIdentityProvider { } } -Force - $provider | Add-Member -MemberType ScriptMethod -Name PruneEntitlements -Value { + $provider | Add-Member -MemberType ScriptMethod -Name NormalizeEntitlementId -Value { param( - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $IdentityKey, - [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Kind, - [Parameter()] - [AllowNull()] - [object[]] $KeepItems, - - [Parameter()] - [AllowNull()] - [string[]] $KeepPatterns, - - [Parameter()] - [bool] $EnsureKeepEntitlements, + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Entitlement, [Parameter()] [AllowNull()] [object] $AuthSession ) - # AD provider only supports Group entitlements for prune - if (-not [string]::Equals($Kind, 'Group', [System.StringComparison]::OrdinalIgnoreCase)) { - throw "PruneEntitlements: AD provider only supports Kind 'Group'. Got: '$Kind'." - } - - $adapter = $this.GetEffectiveAdapter($AuthSession) - $user = $this.ResolveIdentity($IdentityKey, $AuthSession) - - # Normalize keep item IDs to canonical DNs (provider-specific ID-type-detection via NormalizeGroupId) - $keepGroupDNs = @() - if ($null -ne $KeepItems) { - foreach ($item in @($KeepItems)) { - $normalized = $this.ConvertToEntitlement($item) - $canonicalDN = $this.NormalizeGroupId($normalized.Id, $AuthSession) - $keepGroupDNs += $canonicalDN - } - } - - # Delegate bulk removal to adapter (adapter handles list + delta + remove loop) - $pruneResult = $adapter.PruneGroupMemberships($user.DistinguishedName, $keepGroupDNs, $KeepPatterns) - $changed = $pruneResult.Removed.Count -gt 0 + $converted = $this.ConvertToEntitlement($Entitlement) - # Map adapter skipped entries to standard IdLE format - $skippedItems = @() - foreach ($s in @($pruneResult.Skipped)) { - $skippedItems += [pscustomobject]@{ - EntitlementId = [string]$s.GroupDN - Reason = [string]$s.Reason - } - } - - # EnsureKeepEntitlements: grant any explicit Keep items not yet present - if ($EnsureKeepEntitlements -and $null -ne $KeepItems -and @($KeepItems).Count -gt 0) { - $currentGroups = $this.ListEntitlements($IdentityKey, $AuthSession) - foreach ($item in @($KeepItems)) { - $normalized = $this.ConvertToEntitlement($item) - $existing = @($currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) }) - if (@($existing).Count -eq 0) { - $canonicalDN = $this.NormalizeGroupId($normalized.Id, $AuthSession) - $adapter.AddGroupMember($canonicalDN, $user.DistinguishedName) - $changed = $true - } + # AD only supports Group entitlements; normalize to canonical DN + if ([string]::Equals($converted.Kind, 'Group', [System.StringComparison]::OrdinalIgnoreCase)) { + $canonicalId = $this.NormalizeGroupId($converted.Id, $AuthSession) + return [pscustomobject]@{ + PSTypeName = 'IdLE.Entitlement' + Kind = $converted.Kind + Id = $canonicalId + DisplayName = $converted.PSObject.Properties.Name -contains 'DisplayName' ? $converted.DisplayName : $null } } - return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Operation = 'PruneEntitlements' - IdentityKey = $IdentityKey - Changed = $changed - Skipped = $skippedItems - } + return $converted } -Force return $provider diff --git a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 index b4e2b3d3..61b615ad 100644 --- a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 +++ b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 @@ -429,73 +429,6 @@ function New-IdleEntraIDAdapter { } } -Force - $adapter | Add-Member -MemberType ScriptMethod -Name PruneGroupMemberships -Value { - param( - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $UserObjectId, - - [Parameter()] - [AllowNull()] - [string[]] $KeepGroupObjectIds, - - [Parameter()] - [AllowNull()] - [string[]] $KeepPatterns, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $AccessToken - ) - - $groups = $this.ListUserGroups($UserObjectId, $AccessToken) - $removed = @() - $skipped = @() - - foreach ($group in @($groups)) { - $groupId = if ($group -is [System.Collections.IDictionary]) { [string]$group['id'] } else { [string]$group.id } - $displayName = if ($group -is [System.Collections.IDictionary]) { - if ($group.ContainsKey('displayName')) { [string]$group['displayName'] } else { $null } - } else { - if ($group.PSObject.Properties.Name -contains 'displayName') { [string]$group.displayName } else { $null } - } - - $shouldKeep = $false - - foreach ($keepId in @($KeepGroupObjectIds)) { - if ([string]::Equals($groupId, $keepId, [System.StringComparison]::OrdinalIgnoreCase)) { - $shouldKeep = $true - break - } - } - - if (-not $shouldKeep) { - foreach ($pattern in @($KeepPatterns)) { - if ($groupId -like $pattern) { $shouldKeep = $true; break } - if ($null -ne $displayName -and $displayName -like $pattern) { $shouldKeep = $true; break } - } - } - - if (-not $shouldKeep) { - try { - $this.RemoveGroupMember($groupId, $UserObjectId, $AccessToken) - $removed += $groupId - } - catch { - $skipped += [pscustomobject]@{ - GroupObjectId = $groupId - Reason = $_.Exception.Message - } - } - } - } - - return [pscustomobject]@{ - Removed = $removed - Skipped = $skipped - } - } -Force - $adapter | Add-Member -MemberType ScriptMethod -Name RevokeSignInSessions -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 b4d6f5aa..50acd4d7 100644 --- a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 +++ b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 @@ -948,84 +948,35 @@ function New-IdleEntraIDIdentityProvider { } } -Force - $provider | Add-Member -MemberType ScriptMethod -Name PruneEntitlements -Value { + $provider | Add-Member -MemberType ScriptMethod -Name NormalizeEntitlementId -Value { param( - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $IdentityKey, - [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Kind, - [Parameter()] - [AllowNull()] - [object[]] $KeepItems, - - [Parameter()] - [AllowNull()] - [string[]] $KeepPatterns, - - [Parameter()] - [bool] $EnsureKeepEntitlements, + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Entitlement, [Parameter()] [AllowNull()] [object] $AuthSession ) - # Entra ID provider only supports Group entitlements for prune - if (-not [string]::Equals($Kind, 'Group', [System.StringComparison]::OrdinalIgnoreCase)) { - throw "PruneEntitlements: Entra ID provider only supports Kind 'Group'. Got: '$Kind'." - } + $converted = $this.ConvertToEntitlement($Entitlement) - $accessToken = $this.ExtractAccessToken($AuthSession) - $user = $this.ResolveIdentity($IdentityKey, $AuthSession) - - # Normalize keep item IDs to canonical objectIds (provider-specific ID-type-detection via NormalizeGroupId) - $keepGroupObjectIds = @() - if ($null -ne $KeepItems) { - foreach ($item in @($KeepItems)) { - $normalized = $this.ConvertToEntitlement($item) - $canonicalId = $this.NormalizeGroupId($normalized.Id, $AuthSession) - $keepGroupObjectIds += $canonicalId - } - } - - # Delegate bulk removal to adapter (adapter handles list + delta + remove loop) - $pruneResult = $this.Adapter.PruneGroupMemberships($user.id, $keepGroupObjectIds, $KeepPatterns, $accessToken) - $changed = $pruneResult.Removed.Count -gt 0 - - # Map adapter skipped entries to standard IdLE format - $skippedItems = @() - foreach ($s in @($pruneResult.Skipped)) { - $skippedItems += [pscustomobject]@{ - EntitlementId = [string]$s.GroupObjectId - Reason = [string]$s.Reason - } - } - - # EnsureKeepEntitlements: grant any explicit Keep items not yet present - if ($EnsureKeepEntitlements -and $null -ne $KeepItems -and @($KeepItems).Count -gt 0) { - $currentGroups = $this.ListEntitlements($IdentityKey, $AuthSession) - foreach ($item in @($KeepItems)) { - $normalized = $this.ConvertToEntitlement($item) - $existing = @($currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) }) - if (@($existing).Count -eq 0) { - $canonicalId = $this.NormalizeGroupId($normalized.Id, $AuthSession) - $this.Adapter.AddGroupMember($canonicalId, $user.id, $accessToken) - $changed = $true - } + # Entra ID only supports Group entitlements; normalize to canonical objectId + if ([string]::Equals($converted.Kind, 'Group', [System.StringComparison]::OrdinalIgnoreCase)) { + $canonicalId = $this.NormalizeGroupId($converted.Id, $AuthSession) + return [pscustomobject]@{ + PSTypeName = 'IdLE.Entitlement' + Kind = $converted.Kind + Id = $canonicalId + DisplayName = $converted.PSObject.Properties.Name -contains 'DisplayName' ? $converted.DisplayName : $null } } - return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Operation = 'PruneEntitlements' - IdentityKey = $IdentityKey - Changed = $changed - Skipped = $skippedItems - } + return $converted } -Force return $provider diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 index 3523e149..ef072777 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -234,41 +234,30 @@ function Invoke-IdleStepPruneEntitlements { $provider = $Context.Providers[$providerAlias] - # Use provider's native PruneEntitlements method when available (handles ID normalization internally) - $providerHasPruneMethod = $provider.PSObject.Methods.Name -contains 'PruneEntitlements' - - if (-not $providerHasPruneMethod) { - # Validate fallback individual methods - $requiredMethods = @('ListEntitlements', 'RevokeEntitlement') - if ($ensureKeep) { - $requiredMethods += 'GrantEntitlement' - } - foreach ($m in $requiredMethods) { - if (-not ($provider.PSObject.Methods.Name -contains $m)) { - throw "Provider '$providerAlias' must implement method '$m' for PruneEntitlements." - } - } + # Validate required provider methods (step is the single source of delta computation) + $requiredMethods = @('ListEntitlements', 'RevokeEntitlement') + if ($ensureKeep) { + $requiredMethods += 'GrantEntitlement' } - - if ($providerHasPruneMethod) { - # Provider handles ID normalization, user resolution, and bulk removal natively - $pruneSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['PruneEntitlements'] -ParameterName 'AuthSession' - - $pruneResult = if ($pruneSupportsAuthSession) { - $provider.PruneEntitlements($identityKey, $kind, $keepItems, $keepPatterns, $ensureKeep, $authSession) - } else { - $provider.PruneEntitlements($identityKey, $kind, $keepItems, $keepPatterns, $ensureKeep) + foreach ($m in $requiredMethods) { + if (-not ($provider.PSObject.Methods.Name -contains $m)) { + throw "Provider '$providerAlias' must implement method '$m' for PruneEntitlements." } + } - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Changed = [bool]$pruneResult.Changed - Error = $null - Skipped = if ($null -ne $pruneResult.Skipped) { @($pruneResult.Skipped) } else { @() } - } + # Normalize Keep IDs to canonical form via provider.NormalizeEntitlementId (when available). + # This ensures correct comparison with the canonical IDs returned by ListEntitlements. + # Each provider handles its own ID-type detection (e.g., GUID/DN/sAMAccountName for AD; + # objectId/displayName for Entra ID). + if ($keepItems.Count -gt 0 -and $provider.PSObject.Methods.Name -contains 'NormalizeEntitlementId') { + $normalizeSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['NormalizeEntitlementId'] -ParameterName 'AuthSession' + $keepItems = @($keepItems | ForEach-Object { + if ($normalizeSupportsAuthSession -and $null -ne $authSession) { + $provider.NormalizeEntitlementId($kind, $_, $authSession) + } else { + $provider.NormalizeEntitlementId($kind, $_) + } + }) } $listSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['ListEntitlements'] -ParameterName 'AuthSession' diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index ff12153a..99a43bff 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -551,44 +551,6 @@ Describe 'AD identity provider' { return $groups } -Force - $adapter | Add-Member -MemberType ScriptMethod -Name PruneGroupMemberships -Value { - param([string]$UserDN, [string[]]$KeepGroupDNs, [string[]]$KeepPatterns) - - $groups = $this.GetUserGroups($UserDN) - $removed = @() - $skipped = @() - - foreach ($group in @($groups)) { - $dn = [string]$group.DistinguishedName - $name = [string]$group.Name - - $shouldKeep = $false - foreach ($keepDN in @($KeepGroupDNs)) { - if ([string]::Equals($dn, $keepDN, [System.StringComparison]::OrdinalIgnoreCase)) { - $shouldKeep = $true; break - } - } - if (-not $shouldKeep) { - foreach ($pattern in @($KeepPatterns)) { - if ($dn -like $pattern -or $name -like $pattern) { - $shouldKeep = $true; break - } - } - } - - if (-not $shouldKeep) { - try { - $this.RemoveGroupMember($dn, $UserDN) - $removed += $dn - } - catch { - $skipped += [pscustomobject]@{ GroupDN = $dn; Reason = $_.Exception.Message } - } - } - } - return [pscustomobject]@{ Removed = $removed; Skipped = $skipped } - } -Force - $adapter | Add-Member -MemberType ScriptMethod -Name ListUsers -Value { param([hashtable]$Filter) @@ -2066,121 +2028,28 @@ Describe 'AD identity provider' { } } - Context 'PruneEntitlements' { - BeforeEach { + Context 'NormalizeEntitlementId' { + BeforeAll { $adapter = New-FakeADAdapter - $provider = New-IdleADIdentityProvider -Adapter $adapter - - # Create a test user with group memberships - $testUser = $adapter.NewUser('PruneUser', @{ SamAccountName = 'pruneuser' }, $true) - $script:UserId = $testUser.ObjectGuid.ToString() - - # Seed with groups using GrantEntitlement - $provider.GrantEntitlement($script:UserId, @{ Kind = 'Group'; Id = 'CN=G-All,OU=Groups,DC=domain,DC=local' }) | Out-Null - $provider.GrantEntitlement($script:UserId, @{ Kind = 'Group'; Id = 'CN=G-HR,OU=Groups,DC=domain,DC=local' }) | Out-Null - $provider.GrantEntitlement($script:UserId, @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=domain,DC=local' }) | Out-Null - $provider.GrantEntitlement($script:UserId, @{ Kind = 'Group'; Id = 'CN=LEAVER-EXTRA,OU=Groups,DC=domain,DC=local' }) | Out-Null - - $script:Provider = $provider - $script:PruneAdapter = $adapter - } - - It 'Exposes PruneEntitlements as a ScriptMethod' { - $p = New-IdleADIdentityProvider -Adapter (New-FakeADAdapter) - $p.PSObject.Methods.Name | Should -Contain 'PruneEntitlements' - } - - It 'Removes entitlements not in the keep set' { - $result = $script:Provider.PruneEntitlements( - $script:UserId, 'Group', - @(@{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=domain,DC=local' }), - $null, $false, $null - ) - - $result.Changed | Should -BeTrue - $remaining = @($script:Provider.ListEntitlements($script:UserId) | Where-Object Kind -eq 'Group') - @($remaining).Count | Should -Be 1 + $script:NormProvider = New-IdleADIdentityProvider -Adapter $adapter } - It 'Keeps entitlements matching KeepPattern (wildcard against DN and Name)' { - $result = $script:Provider.PruneEntitlements( - $script:UserId, 'Group', - $null, - @('CN=LEAVER-*,OU=Groups,DC=domain,DC=local'), - $false, $null - ) - - $result.Changed | Should -BeTrue - $remaining = @($script:Provider.ListEntitlements($script:UserId) | Where-Object Kind -eq 'Group') - @($remaining).Count | Should -Be 2 - ($remaining | Select-Object -ExpandProperty Id) | Should -Contain 'CN=LEAVER-RETAIN,OU=Groups,DC=domain,DC=local' - ($remaining | Select-Object -ExpandProperty Id) | Should -Contain 'CN=LEAVER-EXTRA,OU=Groups,DC=domain,DC=local' - } - - It 'Grants missing explicit Keep items when EnsureKeepEntitlements is true' { - $newGroupDN = 'CN=LEAVER-NEW,OU=Groups,DC=domain,DC=local' - $result = $script:Provider.PruneEntitlements( - $script:UserId, 'Group', - @( - @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=domain,DC=local' }, - @{ Kind = 'Group'; Id = $newGroupDN } - ), - $null, $true, $null - ) - - $result.Changed | Should -BeTrue - $remaining = @($script:Provider.ListEntitlements($script:UserId) | Where-Object Kind -eq 'Group') - ($remaining | Select-Object -ExpandProperty Id) | Should -Contain $newGroupDN + It 'Exposes NormalizeEntitlementId as a ScriptMethod' { + $script:NormProvider.PSObject.Methods.Name | Should -Contain 'NormalizeEntitlementId' } - It 'Returns structured Skipped entries for non-removable groups' { - # Override RemoveGroupMember to throw for one group - $script:PruneAdapter | Add-Member -MemberType NoteProperty -Name ProtectedGroupDN -Value 'CN=G-HR,OU=Groups,DC=domain,DC=local' -Force - $script:PruneAdapter | Add-Member -MemberType ScriptMethod -Name RemoveGroupMember -Value { - param([string]$GroupIdentity, [string]$MemberIdentity) - if ($GroupIdentity -eq $this.ProtectedGroupDN) { - throw "Cannot remove protected group '$GroupIdentity'." - } - $user = $null - foreach ($key in $this.Store.Keys) { - if ($this.Store[$key].DistinguishedName -eq $MemberIdentity) { $user = $this.Store[$key]; break } - } - if ($null -ne $user -and $null -ne $user.Groups) { - $user.Groups = @($user.Groups | Where-Object { $_.Id -ne $GroupIdentity }) - } - } -Force - - $result = $script:Provider.PruneEntitlements( - $script:UserId, 'Group', - @(@{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=domain,DC=local' }), - $null, $false, $null - ) - - $result.Changed | Should -BeTrue - $result.Skipped | Should -Not -BeNullOrEmpty - $result.Skipped[0].EntitlementId | Should -Be 'CN=G-HR,OU=Groups,DC=domain,DC=local' + It 'Normalizes a Group entitlement with a DN Id to canonical DN' { + $ent = @{ Kind = 'Group'; Id = 'CN=TestGroup,OU=Groups,DC=domain,DC=local' } + $result = $script:NormProvider.NormalizeEntitlementId('Group', $ent, $null) + $result.Kind | Should -Be 'Group' + $result.Id | Should -Be 'CN=TestGroup,OU=Groups,DC=domain,DC=local' } - It 'Throws when Kind is not Group' { - { - $script:Provider.PruneEntitlements($script:UserId, 'Role', $null, @('LEAVER-*'), $false, $null) - } | Should -Throw -ExpectedMessage "*Kind 'Group'*" - } - - It 'Is idempotent when all non-keep groups are already removed' { - $script:Provider.PruneEntitlements( - $script:UserId, 'Group', - @(@{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=domain,DC=local' }), - $null, $false, $null - ) | Out-Null - - $result = $script:Provider.PruneEntitlements( - $script:UserId, 'Group', - @(@{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=domain,DC=local' }), - $null, $false, $null - ) - - $result.Changed | Should -BeFalse + It 'Returns entitlement unchanged when Kind is not Group' { + $ent = [pscustomobject]@{ Kind = 'License'; Id = 'Some-License-Id' } + $result = $script:NormProvider.NormalizeEntitlementId('License', $ent, $null) + $result.Kind | Should -Be 'License' + $result.Id | Should -Be 'Some-License-Id' } } } diff --git a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 index 14566055..82a6176e 100644 --- a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 +++ b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 @@ -726,43 +726,6 @@ Describe 'EntraID identity provider - Entitlement operations' { } } - $adapter | Add-Member -MemberType ScriptMethod -Name PruneGroupMemberships -Value { - param($UserObjectId, $KeepGroupObjectIds, $KeepPatterns, $AccessToken) - - $groups = $this.ListUserGroups($UserObjectId, $AccessToken) - $removed = @() - $skipped = @() - - foreach ($group in @($groups)) { - $groupId = [string]$group.id - $displayName = if ($group.PSObject.Properties.Name -contains 'displayName') { [string]$group.displayName } else { $null } - - $shouldKeep = $false - foreach ($keepId in @($KeepGroupObjectIds)) { - if ([string]::Equals($groupId, $keepId, [System.StringComparison]::OrdinalIgnoreCase)) { - $shouldKeep = $true; break - } - } - if (-not $shouldKeep) { - foreach ($pattern in @($KeepPatterns)) { - if ($groupId -like $pattern) { $shouldKeep = $true; break } - if ($null -ne $displayName -and $displayName -like $pattern) { $shouldKeep = $true; break } - } - } - - if (-not $shouldKeep) { - try { - $this.RemoveGroupMember($groupId, $UserObjectId, $AccessToken) - $removed += $groupId - } - catch { - $skipped += [pscustomobject]@{ GroupObjectId = $groupId; Reason = $_.Exception.Message } - } - } - } - return [pscustomobject]@{ Removed = $removed; Skipped = $skipped } - } - return $adapter } @@ -1160,148 +1123,54 @@ Describe 'EntraID identity provider - Password generation' { } } -Describe 'EntraID identity provider - PruneEntitlements' { +Describe 'EntraID identity provider - NormalizeEntitlementId' { BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule + } - # Local re-definition of the fake adapter (same structure as in Entitlement operations) - function New-FakeEntraIDAdapterForEntitlements { - $store = @{} - $adapter = [pscustomobject]@{ PSTypeName = 'IdLE.EntraIDAdapter.Fake'; Store = $store } + Context 'Exposes NormalizeEntitlementId' { + It 'Provider exposes NormalizeEntitlementId as a ScriptMethod' { + $provider = New-IdleEntraIDIdentityProvider -Adapter ([pscustomobject]@{}) + $provider.PSObject.Methods.Name | Should -Contain 'NormalizeEntitlementId' + } + } - $adapter | Add-Member -MemberType ScriptMethod -Name GetUserById -Value { - param($ObjectId, $AccessToken) - $key = "id:$ObjectId" - if (-not $this.Store.ContainsKey($key)) { - $this.Store[$key] = @{ id = $ObjectId; userPrincipalName = "$ObjectId@test.local"; displayName = "User $ObjectId"; accountEnabled = $true } - } - return $this.Store[$key] - } - $adapter | Add-Member -MemberType ScriptMethod -Name GetGroupById -Value { + Context 'NormalizeEntitlementId behavior' { + BeforeAll { + # Fake adapter that returns canonical objectId for groups (mimics real Graph lookup) + $fakeAdapter = [pscustomobject]@{ PSTypeName = 'IdLE.EntraIDAdapter.Fake'; Store = @{} } + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetGroupById -Value { param($GroupId, $AccessToken) return @{ id = $GroupId; displayName = "Group $GroupId" } } - $adapter | Add-Member -MemberType ScriptMethod -Name ListUserGroups -Value { - param($ObjectId, $AccessToken) - $key = "groups:$ObjectId" - if (-not $this.Store.ContainsKey($key)) { $this.Store[$key] = @() } - return $this.Store[$key] - } - $adapter | Add-Member -MemberType ScriptMethod -Name AddGroupMember -Value { - param($GroupObjectId, $UserObjectId, $AccessToken) - $key = "groups:$UserObjectId" - if (-not $this.Store.ContainsKey($key)) { $this.Store[$key] = @() } - $alreadyMember = $false - foreach ($g in $this.Store[$key]) { if ($g.id -eq $GroupObjectId) { $alreadyMember = $true; break } } - if (-not $alreadyMember) { $this.Store[$key] += @{ id = $GroupObjectId; displayName = "Group $GroupObjectId" } } - } - $adapter | Add-Member -MemberType ScriptMethod -Name RemoveGroupMember -Value { - param($GroupObjectId, $UserObjectId, $AccessToken) - $key = "groups:$UserObjectId" - if ($this.Store.ContainsKey($key)) { $this.Store[$key] = @($this.Store[$key] | Where-Object { $_.id -ne $GroupObjectId }) } - } - $adapter | Add-Member -MemberType ScriptMethod -Name PruneGroupMemberships -Value { - param($UserObjectId, $KeepGroupObjectIds, $KeepPatterns, $AccessToken) - $groups = $this.ListUserGroups($UserObjectId, $AccessToken) - $removed = @(); $skipped = @() - foreach ($group in @($groups)) { - $gId = [string]$group.id; $dn = if ($group.PSObject.Properties.Name -contains 'displayName') { [string]$group.displayName } else { $null } - $keep = $false - foreach ($k in @($KeepGroupObjectIds)) { if ([string]::Equals($gId, $k, [System.StringComparison]::OrdinalIgnoreCase)) { $keep = $true; break } } - if (-not $keep) { foreach ($p in @($KeepPatterns)) { if ($gId -like $p) { $keep = $true; break } if ($null -ne $dn -and $dn -like $p) { $keep = $true; break } } } - if (-not $keep) { - try { $this.RemoveGroupMember($gId, $UserObjectId, $AccessToken); $removed += $gId } - catch { $skipped += [pscustomobject]@{ GroupObjectId = $gId; Reason = $_.Exception.Message } } - } - } - return [pscustomobject]@{ Removed = $removed; Skipped = $skipped } + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetGroupByDisplayName -Value { + param($DisplayName, $AccessToken) + return @{ id = "resolved-$DisplayName"; displayName = $DisplayName } } - return $adapter + $script:NormProvider = New-IdleEntraIDIdentityProvider -Adapter $fakeAdapter } - } - Context 'Provider exposes PruneEntitlements method' { - It 'Exposes PruneEntitlements as a ScriptMethod' { - $provider = New-IdleEntraIDIdentityProvider -Adapter ([pscustomobject]@{}) - $provider.PSObject.Methods.Name | Should -Contain 'PruneEntitlements' + It 'Normalizes a Group entitlement with a GUID Id to canonical objectId' { + $groupGuid = [guid]::NewGuid().ToString() + $ent = @{ Kind = 'Group'; Id = $groupGuid } + $result = $script:NormProvider.NormalizeEntitlementId('Group', $ent, 'fake-token') + $result.Kind | Should -Be 'Group' + $result.Id | Should -Be $groupGuid } - } - - Context 'PruneEntitlements behavior' { - BeforeEach { - $adapter = New-FakeEntraIDAdapterForEntitlements - $provider = New-IdleEntraIDIdentityProvider -Adapter $adapter - $userId = [guid]::NewGuid().ToString() - # Seed identity - [void]$provider.GetIdentity($userId) - - $groupAll = [guid]::NewGuid().ToString() - $groupHR = [guid]::NewGuid().ToString() - $groupKeep = [guid]::NewGuid().ToString() - - $provider.GrantEntitlement($userId, @{ Kind = 'Group'; Id = $groupAll }) | Out-Null - $provider.GrantEntitlement($userId, @{ Kind = 'Group'; Id = $groupHR }) | Out-Null - $provider.GrantEntitlement($userId, @{ Kind = 'Group'; Id = $groupKeep }) | Out-Null - - $script:Provider = $provider - $script:Adapter = $adapter - $script:UserId = $userId - $script:GroupAll = $groupAll - $script:GroupHR = $groupHR - $script:GroupKeep = $groupKeep - } - - It 'Removes entitlements not in the keep set' { - $result = $script:Provider.PruneEntitlements( - $script:UserId, 'Group', - @(@{ Kind = 'Group'; Id = $script:GroupKeep }), - $null, $false, 'fake-token' - ) - - $result.Changed | Should -BeTrue - $remaining = @($script:Provider.ListEntitlements($script:UserId) | Where-Object Kind -eq 'Group') - @($remaining).Count | Should -Be 1 - $remaining[0].Id | Should -Be $script:GroupKeep - } - - It 'Grants missing Keep items when EnsureKeepEntitlements is true' { - $newGroupId = [guid]::NewGuid().ToString() - $result = $script:Provider.PruneEntitlements( - $script:UserId, 'Group', - @( - @{ Kind = 'Group'; Id = $script:GroupKeep }, - @{ Kind = 'Group'; Id = $newGroupId } - ), - $null, $true, 'fake-token' - ) - - $result.Changed | Should -BeTrue - $remaining = @($script:Provider.ListEntitlements($script:UserId) | Where-Object Kind -eq 'Group') - ($remaining | Select-Object -ExpandProperty Id) | Should -Contain $newGroupId - } - - It 'Throws when Kind is not Group' { - { - $script:Provider.PruneEntitlements($script:UserId, 'Role', $null, @('LEAVER-*'), $false, 'fake-token') - } | Should -Throw -ExpectedMessage "*Kind 'Group'*" - } - - It 'Is idempotent when all non-keep groups are already removed' { - $script:Provider.PruneEntitlements( - $script:UserId, 'Group', - @(@{ Kind = 'Group'; Id = $script:GroupKeep }), - $null, $false, 'fake-token' - ) | Out-Null - - $result = $script:Provider.PruneEntitlements( - $script:UserId, 'Group', - @(@{ Kind = 'Group'; Id = $script:GroupKeep }), - $null, $false, 'fake-token' - ) + It 'Normalizes a Group entitlement with a displayName to canonical objectId' { + $ent = @{ Kind = 'Group'; Id = 'HR Team' } + $result = $script:NormProvider.NormalizeEntitlementId('Group', $ent, 'fake-token') + $result.Kind | Should -Be 'Group' + $result.Id | Should -Be 'resolved-HR Team' + } - $result.Changed | Should -BeFalse + It 'Returns entitlement unchanged when Kind is not Group' { + $ent = [pscustomobject]@{ Kind = 'License'; Id = 'Some-License-Id' } + $result = $script:NormProvider.NormalizeEntitlementId('License', $ent, 'fake-token') + $result.Kind | Should -Be 'License' + $result.Id | Should -Be 'Some-License-Id' } } } diff --git a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 index 664990be..66c5fcd8 100644 --- a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 @@ -368,39 +368,58 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } } - Context 'Behavior: step delegates to provider.PruneEntitlements when available' { - It 'calls provider PruneEntitlements and uses its result' { - $pruneCallArgs = @{} + Context 'Behavior: step normalizes Keep IDs via provider.NormalizeEntitlementId when available' { + It 'normalizes Keep item IDs before comparison so non-canonical IDs are matched correctly' { + # Build a provider that: returns canonical IDs from ListEntitlements, + # normalizes 'short-name' Keep IDs → canonical 'CN=...' form via NormalizeEntitlementId, + # and tracks which IDs were passed to RevokeEntitlement. + $revokedIds = @() $mockProvider = [pscustomobject]@{ - PSTypeName = 'MockPruneProvider' + PSTypeName = 'MockNormProvider' + RevokedIds = $revokedIds } - $mockProvider | Add-Member -MemberType ScriptMethod -Name PruneEntitlements -Value { - param($IdentityKey, $Kind, $KeepItems, $KeepPatterns, $EnsureKeep, $AuthSession) - $script:PruneCallArgs = @{ - IdentityKey = $IdentityKey - Kind = $Kind - KeepItems = $KeepItems - KeepPatterns = $KeepPatterns - EnsureKeep = $EnsureKeep - } - return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Changed = $true - Skipped = @([pscustomobject]@{ EntitlementId = 'CN=X'; Reason = 'protected' }) + + $mockProvider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { + param($IdentityKey) + return @( + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=G-All,OU=Groups,DC=contoso,DC=com' }, + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=G-Keep,OU=Groups,DC=contoso,DC=com' }, + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=G-Remove,OU=Groups,DC=contoso,DC=com' } + ) + } -Force + + $mockProvider | Add-Member -MemberType ScriptMethod -Name NormalizeEntitlementId -Value { + param($Kind, $Entitlement, $AuthSession) + # Map short sAMAccountName-style IDs to canonical DNs + $idMap = @{ + 'G-Keep' = 'CN=G-Keep,OU=Groups,DC=contoso,DC=com' + 'G-All' = 'CN=G-All,OU=Groups,DC=contoso,DC=com' } + $rawId = if ($Entitlement -is [hashtable]) { $Entitlement['Id'] } else { $Entitlement.Id } + $canonicalId = if ($idMap.ContainsKey($rawId)) { $idMap[$rawId] } else { $rawId } + $kind = if ($Entitlement -is [hashtable]) { $Entitlement['Kind'] } else { $Entitlement.Kind } + return [pscustomobject]@{ Kind = $kind; Id = $canonicalId } + } -Force + + $mockProvider | Add-Member -MemberType ScriptMethod -Name RevokeEntitlement -Value { + param($IdentityKey, $Entitlement) + $id = if ($Entitlement -is [hashtable]) { $Entitlement['Id'] } else { $Entitlement.Id } + $this.RevokedIds += $id } -Force $script:Context.Providers['Identity'] = $mockProvider $step = [pscustomobject]@{ - Name = 'Prune via provider' + Name = 'Prune via NormalizeEntitlementId' Type = 'IdLE.Step.PruneEntitlements' With = @{ - IdentityKey = 'user1' - Kind = 'Group' - Provider = 'Identity' - Keep = @(@{ Kind = 'Group'; Id = 'CN=RETAIN,DC=x' }) - KeepPattern = @('CN=LEAVER-*') + IdentityKey = 'user1' + Kind = 'Group' + Provider = 'Identity' + Keep = @( + @{ Kind = 'Group'; Id = 'G-Keep' }, # short/non-canonical ID + @{ Kind = 'Group'; Id = 'G-All' } # short/non-canonical ID + ) } } @@ -409,12 +428,10 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue - $result.Skipped | Should -Not -BeNullOrEmpty - $result.Skipped[0].EntitlementId | Should -Be 'CN=X' - - $script:PruneCallArgs.IdentityKey | Should -Be 'user1' - $script:PruneCallArgs.Kind | Should -Be 'Group' - $script:PruneCallArgs.KeepPatterns | Should -Contain 'CN=LEAVER-*' + # G-Remove should have been revoked (not in keep set), G-Keep and G-All preserved + $mockProvider.RevokedIds | Should -Contain 'CN=G-Remove,OU=Groups,DC=contoso,DC=com' + $mockProvider.RevokedIds | Should -Not -Contain 'CN=G-Keep,OU=Groups,DC=contoso,DC=com' + $mockProvider.RevokedIds | Should -Not -Contain 'CN=G-All,OU=Groups,DC=contoso,DC=com' } } } From 7842b41e1586dcc9b1b9f62e55321c0411310275 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:00:50 +0000 Subject: [PATCH 06/27] Split PruneEntitlements into two step types: PruneEntitlements (remove-only) and PruneEntitlementsEnsureKeep (remove + ensure keep); fix capability requirements; update registry, manifest, docs, templates and tests Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/steps.md | 1 + .../step-prune-entitlements-ensure-keep.md | 136 +++++++++++++++ .../steps/step-prune-entitlements.md | 38 ++--- examples/workflows/templates/ad-leaver.psd1 | 8 +- .../workflows/templates/entraid-leaver.psd1 | 9 +- .../Private/Get-IdleStepRegistry.ps1 | 7 + src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 | 3 +- src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 | 3 +- .../Public/Get-IdleStepMetadataCatalog.ps1 | 7 +- .../Invoke-IdleStepPruneEntitlements.ps1 | 15 +- ...ke-IdleStepPruneEntitlementsEnsureKeep.ps1 | 89 ++++++++++ ...Invoke-IdleStepPruneEntitlements.Tests.ps1 | 159 ++++++++++++++++++ 12 files changed, 435 insertions(+), 40 deletions(-) create mode 100644 docs/reference/steps/step-prune-entitlements-ensure-keep.md create mode 100644 src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 diff --git a/docs/reference/steps.md b/docs/reference/steps.md index d865febc..a675f86b 100644 --- a/docs/reference/steps.md +++ b/docs/reference/steps.md @@ -18,5 +18,6 @@ | [IdLE.Step.Mailbox.GetInfo](steps/step-mailbox-get-info.md) | ``IdLE.Steps.Mailbox`` | Retrieves mailbox details and returns a structured report. | | [IdLE.Step.MoveIdentity](steps/step-move-identity.md) | ``IdLE.Steps.Common`` | Moves an identity to a different container/OU in the target system. | | [IdLE.Step.PruneEntitlements](steps/step-prune-entitlements.md) | ``IdLE.Steps.Common`` | Converges an identity's entitlements by removing all non-kept entitlements of a given kind. | +| [IdLE.Step.PruneEntitlementsEnsureKeep](steps/step-prune-entitlements-ensure-keep.md) | ``IdLE.Steps.Common`` | Converges an identity's entitlements by removing all non-kept entitlements and ensuring kept ones are present. | | [IdLE.Step.RevokeIdentitySessions](steps/step-revoke-identity-sessions.md) | ``IdLE.Steps.Common`` | Revokes all active sign-in sessions for an identity in the target system. | | [IdLE.Step.TriggerDirectorySync](steps/step-trigger-directory-sync.md) | ``IdLE.Steps.DirectorySync`` | Triggers a directory sync cycle and optionally waits for completion. | diff --git a/docs/reference/steps/step-prune-entitlements-ensure-keep.md b/docs/reference/steps/step-prune-entitlements-ensure-keep.md new file mode 100644 index 00000000..6111678b --- /dev/null +++ b/docs/reference/steps/step-prune-entitlements-ensure-keep.md @@ -0,0 +1,136 @@ +# IdLE.Step.PruneEntitlementsEnsureKeep + +> Generated file. Do not edit by hand. +> Source: tools/Generate-IdleStepReference.ps1 + +## Summary + +- **Step Type**: `IdLE.Step.PruneEntitlementsEnsureKeep` +- **Module**: `IdLE.Steps.Common` +- **Implementation**: `Invoke-IdleStepPruneEntitlementsEnsureKeep` +- **Idempotent**: `Yes` + +## Synopsis + +Converges an identity's entitlements by removing all non-kept entitlements and ensuring kept ones are present. + +## Description + +This provider-agnostic step combines "remove all except" semantics with "ensure kept entitlements are present". +It is intended for leaver and mover workflows where all entitlements of a given kind must be removed except +for an explicit keep-set, and where those kept entitlements must also be guaranteed to be present. + +Use [`IdLE.Step.PruneEntitlements`](./step-prune-entitlements.md) when you only need removal without the +ensure-grant phase. Use this step type when you need both prune and grant semantics in a single operation. + +The host must supply a provider that: + +- Advertises the `IdLE.Entitlement.Prune` capability (explicit opt-in) +- Implements `ListEntitlements(identityKey)` +- Implements `RevokeEntitlement(identityKey, entitlement)` +- Implements `GrantEntitlement(identityKey, entitlement)` + +Provider/system non-removable entitlements (e.g., AD primary group / Domain Users) are +handled safely: if a revoke operation fails, the step emits a structured warning event, +skips the entitlement, and continues. The workflow is not failed for these items. + +Authentication: + +- If `With.AuthSessionName` is present, the step acquires an auth session via + `Context.AcquireAuthSession(Name, Options)` and passes it to provider methods + if the provider supports an `AuthSession` parameter. +- `With.AuthSessionOptions` (optional, hashtable) is passed to the broker for + session selection (e.g., `@{ Role = 'Tier0' }`). +- ScriptBlocks in `AuthSessionOptions` are rejected (security boundary). + +## Inputs (With.*) + +The following keys are supported in the step's `With` configuration: + +| Key | Required | Description | +| --- | --- | --- | +| `IdentityKey` | Yes | Unique identifier for the identity whose entitlements to prune | +| `Kind` | Yes | Entitlement kind to prune (e.g. `Group`, `Role`, `License`) — provider-defined | +| `Keep` | No* | Array of entitlement references to keep. Each entry must have an `Id` and optionally a `Kind` and `DisplayName`. At least one of `Keep` or `KeepPattern` is required. Missing `Keep` items are granted. | +| `KeepPattern` | No* | Array of wildcard strings (PowerShell `-like` semantics). Current entitlements whose `Id` or `DisplayName` matches any pattern are kept. At least one of `Keep` or `KeepPattern` is required. Pattern-matched entitlements are never "ensured" — only explicit `Keep` items are granted. | +| `Provider` | No | Alias for the provider in `Context.Providers`. Defaults to `'Identity'`. | +| `AuthSessionName` | No | Name used to acquire an auth session via `Context.AcquireAuthSession(...)`. | +| `AuthSessionOptions` | No | Hashtable of options passed to the auth session broker (e.g., `@{ Role = 'Tier0' }`). ScriptBlocks are rejected. | + +\* At least one of `Keep` or `KeepPattern` **must** be provided. Specifying neither is rejected as a safety guardrail. + +## Capability Requirement + +This step requires the provider to advertise the `IdLE.Entitlement.Prune` capability (explicit opt-in). +This is in addition to the standard `IdLE.Entitlement.List`, `IdLE.Entitlement.Revoke`, and +`IdLE.Entitlement.Grant` capabilities. + +See [Capabilities Reference](../capabilities.md) for details. + +## Behavior + +The step executes the following convergence logic: + +1. Lists all current entitlements of the specified `Kind` for the identity (single read). +2. Normalizes `Keep` item IDs to canonical form via `provider.NormalizeEntitlementId` (when available). +3. Builds a **keep-set** from: + - Explicit `Keep` entries (matched by case-insensitive `Id` comparison after normalization) + - Current entitlements whose `Id` or `DisplayName` matches any `KeepPattern` wildcard +4. Computes **remove-set** = current − keep-set. +5. Revokes each entitlement in the remove-set. If a revoke fails (e.g. non-removable entitlement), the error is recorded as a skip with a warning event; the workflow continues. +6. Grants any explicit `Keep` entitlements that were not in the current set (**ensure phase**). Pattern-matched entitlements are never ensured. + +## Result + +Returns an `IdLE.StepResult` object. In addition to the standard `Status`, `Changed`, and `Error` properties, +a `Skipped` array is included. Each entry in `Skipped` contains: + +| Property | Description | +| --- | --- | +| `EntitlementId` | The `Id` of the entitlement that could not be removed | +| `Reason` | The error message from the provider | + +## Examples + +### Keep one explicit group and ensure it is present + +```powershell +@{ + Name = 'Prune groups and ensure leaver group (leaver)' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + With = @{ + IdentityKey = '{{Request.Intent.SamAccountName}}' + Kind = 'Group' + Keep = @( + @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' } + ) + } +} +``` + +### Keep + wildcard pattern and ensure the explicit leaver group is present + +```powershell +@{ + Name = 'Prune groups and ensure leaver group (leaver with pattern)' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + Condition = @{ Equals = @{ Path = 'Request.Intent.PruneGroups'; Value = $true } } + With = @{ + IdentityKey = '{{Request.Intent.SamAccountName}}' + Provider = 'Identity' + Kind = 'Group' + Keep = @( + @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com'; DisplayName = 'Leaver Retain' } + ) + KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') + AuthSessionName = 'Directory' + } +} +``` + +## See Also + +- [IdLE.Step.PruneEntitlements](./step-prune-entitlements.md) - Remove-only variant (no ensure phase) +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities including `IdLE.Entitlement.Prune` +- [Providers](../providers.md) - Available provider implementations +- [IdLE.Step.EnsureEntitlement](./step-ensure-entitlement.md) - Atomic single-entitlement convergence diff --git a/docs/reference/steps/step-prune-entitlements.md b/docs/reference/steps/step-prune-entitlements.md index 9bff1c3a..8232b333 100644 --- a/docs/reference/steps/step-prune-entitlements.md +++ b/docs/reference/steps/step-prune-entitlements.md @@ -21,12 +21,14 @@ It is intended for leaver and mover workflows where all entitlements of a given (e.g. group memberships) must be removed, except for an explicit keep-set and/or entitlements matching a wildcard keep pattern. +This step is **remove-only**. Use [`IdLE.Step.PruneEntitlementsEnsureKeep`](./step-prune-entitlements-ensure-keep.md) +when you also need to guarantee that explicit `Keep` entitlements are present after the prune. + The host must supply a provider that: - Advertises the `IdLE.Entitlement.Prune` capability (explicit opt-in) - Implements `ListEntitlements(identityKey)` - Implements `RevokeEntitlement(identityKey, entitlement)` -- Implements `GrantEntitlement(identityKey, entitlement)` — required only when `With.EnsureKeepEntitlements` is `$true` Provider/system non-removable entitlements (e.g., AD primary group / Domain Users) are handled safely: if a revoke operation fails, the step emits a structured warning event, @@ -51,7 +53,6 @@ The following keys are supported in the step's `With` configuration: | `Kind` | Yes | Entitlement kind to prune (e.g. `Group`, `Role`, `License`) — provider-defined | | `Keep` | No* | Array of entitlement references to keep. Each entry must have an `Id` and optionally a `Kind` and `DisplayName`. At least one of `Keep` or `KeepPattern` is required. | | `KeepPattern` | No* | Array of wildcard strings (PowerShell `-like` semantics). Current entitlements whose `Id` or `DisplayName` matches any pattern are kept. At least one of `Keep` or `KeepPattern` is required. | -| `EnsureKeepEntitlements` | No | If `$true`, entitlements listed in `Keep` that are not currently present will be granted. Does not apply to pattern-matched entitlements. | | `Provider` | No | Alias for the provider in `Context.Providers`. Defaults to `'Identity'`. | | `AuthSessionName` | No | Name used to acquire an auth session via `Context.AcquireAuthSession(...)`. | | `AuthSessionOptions` | No | Hashtable of options passed to the auth session broker (e.g., `@{ Role = 'Tier0' }`). ScriptBlocks are rejected. | @@ -61,8 +62,7 @@ The following keys are supported in the step's `With` configuration: ## Capability Requirement This step requires the provider to advertise the `IdLE.Entitlement.Prune` capability (explicit opt-in). -This is in addition to the standard `IdLE.Entitlement.List`, `IdLE.Entitlement.Revoke`, and -`IdLE.Entitlement.Grant` capabilities. +This is in addition to the standard `IdLE.Entitlement.List` and `IdLE.Entitlement.Revoke` capabilities. See [Capabilities Reference](../capabilities.md) for details. @@ -70,13 +70,13 @@ See [Capabilities Reference](../capabilities.md) for details. The step executes the following convergence logic: -1. Lists all current entitlements of the specified `Kind` for the identity. -2. Builds a **keep-set** from: - - Explicit `Keep` entries (matched by case-insensitive `Id` comparison) +1. Lists all current entitlements of the specified `Kind` for the identity (single read). +2. Normalizes `Keep` item IDs to canonical form via `provider.NormalizeEntitlementId` (when available). +3. Builds a **keep-set** from: + - Explicit `Keep` entries (matched by case-insensitive `Id` comparison after normalization) - Current entitlements whose `Id` or `DisplayName` matches any `KeepPattern` wildcard -3. Computes **remove-set** = current − keep-set. -4. Revokes each entitlement in the remove-set. If a revoke fails (e.g. non-removable entitlement), the error is recorded as a skip with a warning event; the workflow continues. -5. If `EnsureKeepEntitlements` is `$true`: grants any explicit `Keep` entitlements that were not in the current set. +4. Computes **remove-set** = current − keep-set. +5. Revokes each entitlement in the remove-set. If a revoke fails (e.g. non-removable entitlement), the error is recorded as a skip with a warning event; the workflow continues. ## Result @@ -106,28 +106,28 @@ a `Skipped` array is included. Each entry in `Skipped` contains: } ``` -### With wildcard pattern: keep all LEAVER-* groups and ensure the retain group is present +### With wildcard pattern: keep all LEAVER-* groups ```powershell @{ Name = 'Prune group memberships (leaver with pattern)' Type = 'IdLE.Step.PruneEntitlements' With = @{ - IdentityKey = '{{Request.Intent.SamAccountName}}' - Provider = 'Identity' - Kind = 'Group' - Keep = @( - @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com'; DisplayName = 'Leaver Retain' } + IdentityKey = '{{Request.Intent.SamAccountName}}' + Provider = 'Identity' + Kind = 'Group' + Keep = @( + @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' } ) - KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') - EnsureKeepEntitlements = $true - AuthSessionName = 'Directory' + KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') + AuthSessionName = 'Directory' } } ``` ## See Also +- [IdLE.Step.PruneEntitlementsEnsureKeep](./step-prune-entitlements-ensure-keep.md) - Remove + ensure keep entitlements are present - [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities including `IdLE.Entitlement.Prune` - [Providers](../providers.md) - Available provider implementations - [IdLE.Step.EnsureEntitlement](./step-ensure-entitlement.md) - Atomic single-entitlement convergence diff --git a/examples/workflows/templates/ad-leaver.psd1 b/examples/workflows/templates/ad-leaver.psd1 index cdc529dd..af1e6aed 100644 --- a/examples/workflows/templates/ad-leaver.psd1 +++ b/examples/workflows/templates/ad-leaver.psd1 @@ -28,9 +28,10 @@ # Optional, use with caution: # Removing groups can break business processes unexpectedly. - # PruneEntitlements offers a safer "remove all except" approach for leavers. + # PruneEntitlementsEnsureKeep removes all groups except the keep set AND ensures + # explicit Keep items are present. Use PruneEntitlements if you only need removal. @{ - Type = 'IdLE.Step.PruneEntitlements' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' Name = 'Prune group memberships (leaver)' Condition = @{ Equals = @{ Path = 'Request.Intent.PruneGroups'; Value = $true } } With = @{ @@ -45,9 +46,6 @@ # Also retain any group whose DN starts with CN=LEAVER- (e.g. LEAVER-*) KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') - - # Ensure the explicit keep group is present even if the user was not a member. - EnsureKeepEntitlements = $true } } diff --git a/examples/workflows/templates/entraid-leaver.psd1 b/examples/workflows/templates/entraid-leaver.psd1 index 5f02533f..29279eed 100644 --- a/examples/workflows/templates/entraid-leaver.psd1 +++ b/examples/workflows/templates/entraid-leaver.psd1 @@ -68,11 +68,11 @@ } # Optional & potentially disruptive: - # PruneEntitlements offers a safe "remove all except" approach for leavers. - # Use this instead of removing each group individually. + # PruneEntitlementsEnsureKeep removes all groups except the keep set AND ensures + # explicit Keep items are present. Use PruneEntitlements if you only need removal. @{ Name = 'PruneGroupMemberships_Optional' - Type = 'IdLE.Step.PruneEntitlements' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' Condition = @{ All = @( @{ @@ -96,9 +96,6 @@ # Also retain any group whose displayName starts with LEAVER-. KeepPattern = @('LEAVER-*') - - # Ensure the explicit keep group is present even if the user was not a member. - EnsureKeepEntitlements = $true } } diff --git a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 index 52db61bf..9bffc009 100644 --- a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -177,6 +177,13 @@ function Get-IdleStepRegistry { } } + if (-not $registry.ContainsKey('IdLE.Step.PruneEntitlementsEnsureKeep')) { + $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepPruneEntitlementsEnsureKeep' -ModuleName 'IdLE.Steps.Common' + if (-not [string]::IsNullOrWhiteSpace($handler)) { + $registry['IdLE.Step.PruneEntitlementsEnsureKeep'] = $handler + } + } + if (-not $registry.ContainsKey('IdLE.Step.TriggerDirectorySync')) { $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepTriggerDirectorySync' -ModuleName 'IdLE.Steps.DirectorySync' if (-not [string]::IsNullOrWhiteSpace($handler)) { diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 index ec342289..322706cb 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 @@ -21,7 +21,8 @@ 'Invoke-IdleStepMoveIdentity', 'Invoke-IdleStepDeleteIdentity', 'Invoke-IdleStepRevokeIdentitySessions', - 'Invoke-IdleStepPruneEntitlements' + 'Invoke-IdleStepPruneEntitlements', + 'Invoke-IdleStepPruneEntitlementsEnsureKeep' ) PrivateData = @{ diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 index ced47acb..d964e5ab 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 @@ -55,5 +55,6 @@ Export-ModuleMember -Function @( 'Invoke-IdleStepMoveIdentity', 'Invoke-IdleStepDeleteIdentity', 'Invoke-IdleStepRevokeIdentitySessions', - 'Invoke-IdleStepPruneEntitlements' + 'Invoke-IdleStepPruneEntitlements', + 'Invoke-IdleStepPruneEntitlementsEnsureKeep' ) diff --git a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 index da18b58d..094854cc 100644 --- a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 @@ -68,8 +68,13 @@ function Get-IdleStepMetadataCatalog { RequiredCapabilities = @('IdLE.Identity.RevokeSessions') } - # IdLE.Step.PruneEntitlements - requires explicit prune opt-in capability plus list/revoke/grant + # IdLE.Step.PruneEntitlements - remove-only: requires explicit prune opt-in capability plus list/revoke $catalog['IdLE.Step.PruneEntitlements'] = @{ + RequiredCapabilities = @('IdLE.Entitlement.Prune', 'IdLE.Entitlement.List', 'IdLE.Entitlement.Revoke') + } + + # IdLE.Step.PruneEntitlementsEnsureKeep - remove + ensure keep present: requires prune + list/revoke/grant + $catalog['IdLE.Step.PruneEntitlementsEnsureKeep'] = @{ RequiredCapabilities = @('IdLE.Entitlement.Prune', 'IdLE.Entitlement.List', 'IdLE.Entitlement.Revoke', 'IdLE.Entitlement.Grant') } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 index ef072777..6af108c8 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -9,12 +9,14 @@ function Invoke-IdleStepPruneEntitlements { (e.g. group memberships) must be removed, except for an explicit keep-set and/or entitlements matching a wildcard keep pattern. + This step is remove-only. Use IdLE.Step.PruneEntitlementsEnsureKeep when you also need + to guarantee that explicit Keep entitlements are present after the prune. + The host must supply a provider that: - Advertises the IdLE.Entitlement.Prune capability (explicit opt-in) - Implements ListEntitlements(identityKey) - Implements RevokeEntitlement(identityKey, entitlement) - - Implements GrantEntitlement(identityKey, entitlement) [required only when With.EnsureKeepEntitlements is $true] Provider/system non-removable entitlements (e.g., AD primary group / Domain Users) are handled safely: if a revoke operation fails, the step emits a structured warning event, @@ -40,14 +42,13 @@ function Invoke-IdleStepPruneEntitlements { Name = 'Prune group memberships (leaver)' Type = 'IdLE.Step.PruneEntitlements' With = @{ - IdentityKey = 'jsmith' - Provider = 'Identity' - Kind = 'Group' - Keep = @( + IdentityKey = 'jsmith' + Provider = 'Identity' + Kind = 'Group' + Keep = @( @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' } ) - KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') - EnsureKeepEntitlements = $true + KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') } } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 new file mode 100644 index 00000000..cf748915 --- /dev/null +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 @@ -0,0 +1,89 @@ +function Invoke-IdleStepPruneEntitlementsEnsureKeep { + <# + .SYNOPSIS + Converges an identity's entitlements by removing all non-kept entitlements and ensuring kept ones are present. + + .DESCRIPTION + This provider-agnostic step implements "remove all except … and ensure those are present" semantics for + entitlements. It is intended for leaver and mover workflows where all entitlements of a given kind + (e.g. group memberships) must be removed except for an explicit keep-set, and the kept entitlements + must be guaranteed to be present. + + This step always grants any explicit Keep items that are not yet present. Use IdLE.Step.PruneEntitlements + when you only need removal without the ensure-grant phase. + + The host must supply a provider that: + + - Advertises the IdLE.Entitlement.Prune capability (explicit opt-in) + - Implements ListEntitlements(identityKey) + - Implements RevokeEntitlement(identityKey, entitlement) + - Implements GrantEntitlement(identityKey, entitlement) + + Provider/system non-removable entitlements (e.g., AD primary group / Domain Users) are + handled safely: if a revoke operation fails, the step emits a structured warning event, + skips the entitlement, and continues. The workflow is not failed for these items. + + Authentication: + + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to provider methods + if the provider supports an AuthSession parameter. + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). + - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + + .PARAMETER Context + Execution context created by IdLE.Core. + + .PARAMETER Step + Normalized step object from the plan. Must contain a 'With' hashtable. + + .EXAMPLE + Invoke-IdleStepPruneEntitlementsEnsureKeep -Context $context -Step [pscustomobject]@{ + Name = 'Prune group memberships and ensure leaver group (leaver)' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + With = @{ + IdentityKey = 'jsmith' + Provider = 'Identity' + Kind = 'Group' + Keep = @( + @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' } + ) + KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') + } + } + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + # Inject EnsureKeepEntitlements = $true into With, then delegate to Invoke-IdleStepPruneEntitlements. + # This ensures the ensure-grant phase always runs for this step type. + $ensureWith = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($key in $Step.With.Keys) { + $ensureWith[$key] = $Step.With[$key] + } + $ensureWith['EnsureKeepEntitlements'] = $true + + # Shallow clone the step with the updated With + $ensureStep = [pscustomobject]@{ + Name = [string]$Step.Name + Type = [string]$Step.Type + With = $ensureWith + } + if ($Step.PSObject.Properties.Name -contains 'Condition') { + $ensureStep | Add-Member -MemberType NoteProperty -Name Condition -Value $Step.Condition + } + + return Invoke-IdleStepPruneEntitlements -Context $Context -Step $ensureStep +} diff --git a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 index 66c5fcd8..685699c1 100644 --- a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 @@ -435,3 +435,162 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } } } + +Describe 'Invoke-IdleStepPruneEntitlementsEnsureKeep (built-in step)' { + BeforeEach { + $script:Provider = New-IdleMockIdentityProvider + $script:Context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Plan = $null + Providers = @{ Identity = $script:Provider } + EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + } + + # Seed the identity with some entitlements + $null = $script:Provider.EnsureAttribute('user1', 'Seed', 'Value') + $null = $script:Provider.GrantEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=G-All,DC=contoso,DC=com' }) + $null = $script:Provider.GrantEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=G-HR,DC=contoso,DC=com'; DisplayName = 'HR Group' }) + $null = $script:Provider.GrantEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,DC=contoso,DC=com'; DisplayName = 'Leaver Retain' }) + $null = $script:Provider.GrantEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=LEAVER-EXTRA,DC=contoso,DC=com'; DisplayName = 'Leaver Extra' }) + + $script:Handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlementsEnsureKeep' + $script:StepTemplate = [pscustomobject]@{ + Name = 'Prune and ensure keep (leaver)' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + With = @{ + IdentityKey = 'user1' + Provider = 'Identity' + Kind = 'Group' + Keep = @( + @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,DC=contoso,DC=com' } + ) + } + } + } + + Context 'Step registration' { + It 'is registered in the step registry' { + $catalog = IdLE.Steps.Common\Get-IdleStepMetadataCatalog + $catalog.ContainsKey('IdLE.Step.PruneEntitlementsEnsureKeep') | Should -BeTrue + } + + It 'requires IdLE.Entitlement.Grant capability' { + $catalog = IdLE.Steps.Common\Get-IdleStepMetadataCatalog + $catalog['IdLE.Step.PruneEntitlementsEnsureKeep'].RequiredCapabilities | Should -Contain 'IdLE.Entitlement.Grant' + } + + It 'PruneEntitlements does NOT require IdLE.Entitlement.Grant capability' { + $catalog = IdLE.Steps.Common\Get-IdleStepMetadataCatalog + $catalog['IdLE.Step.PruneEntitlements'].RequiredCapabilities | Should -Not -Contain 'IdLE.Entitlement.Grant' + } + } + + Context 'Behavior: Keep only (prune + ensure)' { + It 'removes non-kept entitlements and reports Changed' { + $result = & $script:Handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + + $remaining = $script:Provider.ListEntitlements('user1') + @($remaining).Count | Should -Be 1 + $remaining[0].Id | Should -Be 'CN=LEAVER-RETAIN,DC=contoso,DC=com' + } + + It 'grants an explicit Keep item that is not yet present' { + $step = $script:StepTemplate + $step.With.Keep = @( + @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,DC=contoso,DC=com' } + @{ Kind = 'Group'; Id = 'CN=LEAVER-NEW,DC=contoso,DC=com'; DisplayName = 'Leaver New' } + ) + + $result = & $script:Handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + + $remaining = $script:Provider.ListEntitlements('user1') + ($remaining | Select-Object -ExpandProperty Id) | Should -Contain 'CN=LEAVER-NEW,DC=contoso,DC=com' + } + + It 'is idempotent when keep set is already the only entitlements' { + $null = $script:Provider.RevokeEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=G-All,DC=contoso,DC=com' }) + $null = $script:Provider.RevokeEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=G-HR,DC=contoso,DC=com' }) + $null = $script:Provider.RevokeEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=LEAVER-EXTRA,DC=contoso,DC=com' }) + + $result = & $script:Handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeFalse + } + } + + Context 'Behavior: Keep + KeepPattern union (prune + ensure)' { + It 'keeps entitlements matching wildcard KeepPattern and ensures explicit Keep items' { + $step = $script:StepTemplate + $step.With.KeepPattern = @('CN=LEAVER-*,DC=contoso,DC=com') + $step.With.Keep = @( + @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,DC=contoso,DC=com' } + @{ Kind = 'Group'; Id = 'CN=LEAVER-NEWGROUP,DC=contoso,DC=com' } + ) + + $result = & $script:Handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + + $remaining = $script:Provider.ListEntitlements('user1') + $remainingIds = $remaining | Select-Object -ExpandProperty Id + # LEAVER-RETAIN + LEAVER-EXTRA (pattern) + LEAVER-NEWGROUP (ensured) + $remainingIds | Should -Contain 'CN=LEAVER-RETAIN,DC=contoso,DC=com' + $remainingIds | Should -Contain 'CN=LEAVER-EXTRA,DC=contoso,DC=com' + $remainingIds | Should -Contain 'CN=LEAVER-NEWGROUP,DC=contoso,DC=com' + $remainingIds | Should -Not -Contain 'CN=G-All,DC=contoso,DC=com' + $remainingIds | Should -Not -Contain 'CN=G-HR,DC=contoso,DC=com' + } + + It 'does not grant pattern-matched entitlements (only explicit Keep items)' { + $step = $script:StepTemplate + $step.With.KeepPattern = @('CN=LEAVER-*,DC=contoso,DC=com') + + # No new explicit Keep items beyond what's already present + $result = & $script:Handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + + # LEAVER-EXTRA was kept by pattern but not granted (it was already present) + $remaining = $script:Provider.ListEntitlements('user1') + $remainingIds = $remaining | Select-Object -ExpandProperty Id + $remainingIds | Should -Contain 'CN=LEAVER-RETAIN,DC=contoso,DC=com' + $remainingIds | Should -Contain 'CN=LEAVER-EXTRA,DC=contoso,DC=com' + } + } + + Context 'Validation: Guardrail - missing Keep and KeepPattern fails fast' { + It 'throws when neither Keep nor KeepPattern is provided' { + $step = [pscustomobject]@{ + Name = 'bad' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + With = @{ IdentityKey = 'user1'; Kind = 'Group'; Provider = 'Identity' } + } + + { & $script:Handler -Context $script:Context -Step $step } | Should -Throw -ExpectedMessage '*at least one*' + } + } + + Context 'Behavior: Non-removable entitlement handling' { + It 'skips non-removable entitlements with a structured warning and continues' { + $script:Provider.ProtectedEntitlementIds = @('CN=G-All,DC=contoso,DC=com') + + $result = & $script:Handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Skipped | Should -Not -BeNullOrEmpty + $result.Skipped[0].EntitlementId | Should -Be 'CN=G-All,DC=contoso,DC=com' + + # Non-protected items should still have been removed + $remaining = $script:Provider.ListEntitlements('user1') + ($remaining | Select-Object -ExpandProperty Id) | Should -Not -Contain 'CN=G-HR,DC=contoso,DC=com' + } + } +} From d3e788531d7876088c6d705af71a34ce5b79bf3d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 07:47:46 +0000 Subject: [PATCH 07/27] Optimize providers: remove redundant ListEntitlements reads; add idempotent adapter bool returns; add Entra ID BulkRevoke/BulkGrant via Graph batch; step uses bulk when available Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/New-IdleADAdapter.ps1 | 24 ++- .../Public/New-IdleADIdentityProvider.ps1 | 18 +-- .../Private/New-IdleEntraIDAdapter.ps1 | 91 +++++++++++- .../New-IdleEntraIDIdentityProvider.ps1 | 137 +++++++++++++++--- .../Invoke-IdleStepPruneEntitlements.ps1 | 127 ++++++++++++---- tests/Providers/ADIdentityProvider.Tests.ps1 | 4 + .../EntraIDIdentityProvider.Tests.ps1 | 128 +++++++++++++++- ...Invoke-IdleStepPruneEntitlements.Tests.ps1 | 64 ++++++++ 8 files changed, 521 insertions(+), 72 deletions(-) diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index 28b77786..21e22d16 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -687,7 +687,17 @@ function New-IdleADAdapter { $params['Credential'] = $this.Credential } - Add-ADGroupMember @params + try { + Add-ADGroupMember @params + return $true + } + catch { + # Idempotency: already a member is a no-op + if ($_.Exception.Message -match 'already a member') { + return $false + } + throw + } } -Force $adapter | Add-Member -MemberType ScriptMethod -Name RemoveGroupMember -Value { @@ -711,7 +721,17 @@ function New-IdleADAdapter { $params['Credential'] = $this.Credential } - Remove-ADGroupMember @params + try { + Remove-ADGroupMember @params + return $true + } + catch { + # Idempotency: not a member is a no-op + if ($_.Exception.Message -match 'not a member|Member does not exist') { + return $false + } + throw + } } -Force $adapter | Add-Member -MemberType ScriptMethod -Name GetUserGroups -Value { diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index 4e46cfab..e093ac39 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -807,14 +807,7 @@ function New-IdleADIdentityProvider { $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $groupDn = $this.NormalizeGroupId($normalized.Id, $AuthSession) - $currentGroups = $this.ListEntitlements($IdentityKey, $AuthSession) - $existing = $currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) } - - $changed = $false - if (@($existing).Count -eq 0) { - $adapter.AddGroupMember($groupDn, $user.DistinguishedName) - $changed = $true - } + $changed = [bool]$adapter.AddGroupMember($groupDn, $user.DistinguishedName) return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' @@ -847,14 +840,7 @@ function New-IdleADIdentityProvider { $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $groupDn = $this.NormalizeGroupId($normalized.Id, $AuthSession) - $currentGroups = $this.ListEntitlements($IdentityKey, $AuthSession) - $existing = $currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) } - - $changed = $false - if (@($existing).Count -gt 0) { - $adapter.RemoveGroupMember($groupDn, $user.DistinguishedName) - $changed = $true - } + $changed = [bool]$adapter.RemoveGroupMember($groupDn, $user.DistinguishedName) return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' diff --git a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 index 61b615ad..c03bb220 100644 --- a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 +++ b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 @@ -390,11 +390,12 @@ function New-IdleEntraIDAdapter { try { $null = $this.InvokeGraphRequest('POST', $uri, $AccessToken, $body) + return $true } catch { - # Idempotency: if already a member, treat as success + # Idempotency: if already a member, treat as no-op if ($_.Exception.Message -match 'already exists|already a member') { - return + return $false } throw } @@ -419,16 +420,98 @@ function New-IdleEntraIDAdapter { try { $null = $this.InvokeGraphRequest('DELETE', $uri, $AccessToken, $null) + return $true } catch { - # Idempotency: if not a member, treat as success + # Idempotency: if not a member, treat as no-op if ($_.Exception.Message -match '404|not found|does not exist') { - return + return $false } throw } } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name BatchMembershipChanges -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object[]] $Operations, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccessToken + ) + + $results = [System.Collections.Generic.List[object]]::new() + $batchSize = 20 + + for ($i = 0; $i -lt $Operations.Count; $i += $batchSize) { + $end = [Math]::Min($i + $batchSize - 1, $Operations.Count - 1) + $batch = @($Operations[$i..$end]) + + $requests = @() + foreach ($op in $batch) { + if ($op.Action -eq 'remove') { + $requests += @{ + id = $op.RequestId + method = 'DELETE' + url = "/groups/$($op.GroupObjectId)/members/$($op.UserObjectId)/`$ref" + } + } + else { + $requests += @{ + id = $op.RequestId + method = 'POST' + url = "/groups/$($op.GroupObjectId)/members/`$ref" + headers = @{ 'Content-Type' = 'application/json' } + body = @{ '@odata.id' = "$($this.BaseUri)/directoryObjects/$($op.UserObjectId)" } + } + } + } + + $batchUri = "$($this.BaseUri)/`$batch" + $batchResponse = $this.InvokeGraphRequest('POST', $batchUri, $AccessToken, @{ requests = $requests }) + + foreach ($resp in $batchResponse.responses) { + $op = $batch | Where-Object { $_.RequestId -eq $resp.id } + $changed = $false + $errorMsg = $null + + if ($resp.status -ge 200 -and $resp.status -lt 300) { + $changed = $true + } + elseif ($resp.status -eq 404 -and $op.Action -eq 'remove') { + # Not a member — idempotent no-op + $changed = $false + } + elseif ($resp.status -eq 400 -and $op.Action -eq 'add') { + $msg = if ($resp.body -and $resp.body.error) { $resp.body.error.message } else { '' } + if ($msg -match 'already exists|already a member') { + $changed = $false + } + else { + $errorMsg = "HTTP $($resp.status): $msg" + } + } + else { + $msg = if ($resp.body -and $resp.body.error) { $resp.body.error.message } else { '' } + $errorMsg = "HTTP $($resp.status): $msg" + } + + $results.Add([pscustomobject]@{ + PSTypeName = 'IdLE.BatchMembershipResult' + RequestId = $resp.id + GroupObjectId = $op.GroupObjectId + Action = $op.Action + Changed = $changed + Error = $errorMsg + }) + } + } + + return @($results) + } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name RevokeSignInSessions -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 50acd4d7..cdaf7d3d 100644 --- a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 +++ b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 @@ -875,15 +875,8 @@ function New-IdleEntraIDIdentityProvider { # Update normalized entitlement with canonical group ID $normalized.Id = $groupObjectId - # Check if already a member (idempotency) - $currentGroups = $this.ListEntitlements($IdentityKey, $AuthSession) - $existing = $currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) } - - $changed = $false - if (@($existing).Count -eq 0) { - $this.Adapter.AddGroupMember($groupObjectId, $user.id, $accessToken) - $changed = $true - } + # Adapter handles idempotency (already a member → no-op); returns $true if changed + $changed = [bool]$this.Adapter.AddGroupMember($groupObjectId, $user.id, $accessToken) return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' @@ -929,15 +922,8 @@ function New-IdleEntraIDIdentityProvider { # Update normalized entitlement with canonical group ID $normalized.Id = $groupObjectId - # Check if currently a member (idempotency) - $currentGroups = $this.ListEntitlements($IdentityKey, $AuthSession) - $existing = $currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) } - - $changed = $false - if (@($existing).Count -gt 0) { - $this.Adapter.RemoveGroupMember($groupObjectId, $user.id, $accessToken) - $changed = $true - } + # Adapter handles idempotency (not a member → no-op); returns $true if changed + $changed = [bool]$this.Adapter.RemoveGroupMember($groupObjectId, $user.id, $accessToken) return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' @@ -948,6 +934,121 @@ function New-IdleEntraIDIdentityProvider { } } -Force + $provider | Add-Member -MemberType ScriptMethod -Name BulkRevokeEntitlements -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object[]] $Entitlements, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + + $operations = @() + foreach ($ent in $Entitlements) { + $normalized = $this.ConvertToEntitlement($ent) + if ($null -ne $normalized.Kind -and $normalized.Kind -ne 'Group') { + throw [System.ArgumentException]::new( + "BulkRevokeEntitlements only supports entitlements with Kind 'Group'. Received Kind '$($normalized.Kind)'." + ) + } + $groupObjectId = $this.NormalizeGroupId($normalized.Id, $AuthSession) + $operations += @{ + RequestId = [guid]::NewGuid().ToString('N').Substring(0, 8) + GroupObjectId = $groupObjectId + UserObjectId = $user.id + Action = 'remove' + Entitlement = $normalized + } + } + + if ($operations.Count -eq 0) { + return @() + } + + $batchResults = $this.Adapter.BatchMembershipChanges($operations, $accessToken) + + $results = @() + foreach ($br in $batchResults) { + $op = $operations | Where-Object { $_.RequestId -eq $br.RequestId } + $results += [pscustomobject]@{ + PSTypeName = 'IdLE.BulkProviderResult' + Operation = 'RevokeEntitlement' + IdentityKey = $IdentityKey + Changed = $br.Changed + Error = $br.Error + Entitlement = $op.Entitlement + } + } + return $results + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name BulkGrantEntitlements -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object[]] $Entitlements, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + + $operations = @() + foreach ($ent in $Entitlements) { + $normalized = $this.ConvertToEntitlement($ent) + if ($null -ne $normalized.Kind -and $normalized.Kind -ne 'Group') { + throw [System.ArgumentException]::new( + "BulkGrantEntitlements only supports entitlements with Kind 'Group'. Received Kind '$($normalized.Kind)'." + ) + } + $groupObjectId = $this.NormalizeGroupId($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 @() + } + + $batchResults = $this.Adapter.BatchMembershipChanges($operations, $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 + } + } + return $results + } -Force + $provider | Add-Member -MemberType ScriptMethod -Name NormalizeEntitlementId -Value { param( [Parameter(Mandatory)] diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 index 6af108c8..f478559e 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -267,6 +267,16 @@ function Invoke-IdleStepPruneEntitlements { Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['GrantEntitlement'] -ParameterName 'AuthSession' } else { $false } + # Detect bulk-capable provider methods (e.g. Entra ID uses Graph $batch for efficiency) + $hasBulkRevoke = $null -ne $provider.PSObject.Methods['BulkRevokeEntitlements'] + $bulkRevokeSupportsAuthSession = if ($hasBulkRevoke) { + Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['BulkRevokeEntitlements'] -ParameterName 'AuthSession' + } else { $false } + $hasBulkGrant = $ensureKeep -and ($null -ne $provider.PSObject.Methods['BulkGrantEntitlements']) + $bulkGrantSupportsAuthSession = if ($hasBulkGrant) { + Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['BulkGrantEntitlements'] -ParameterName 'AuthSession' + } else { $false } + # 1. List current entitlements, filter by Kind $allCurrent = if ($listSupportsAuthSession -and $null -ne $authSession) { @($provider.ListEntitlements($identityKey, $authSession)) @@ -306,49 +316,106 @@ function Invoke-IdleStepPruneEntitlements { $skippedItems = @() # 3. Revoke each entitlement in remove-set - foreach ($ent in $toRemove) { - try { - if ($revokeSupportsAuthSession -and $null -ne $authSession) { - $null = $provider.RevokeEntitlement($identityKey, $ent, $authSession) - } else { - $null = $provider.RevokeEntitlement($identityKey, $ent) - } - $changed = $true + if ($hasBulkRevoke -and $toRemove.Count -gt 0) { + # Bulk path: provider batches operations and returns per-item results with distinct status + $bulkResults = if ($bulkRevokeSupportsAuthSession -and $null -ne $authSession) { + @($provider.BulkRevokeEntitlements($identityKey, $toRemove, $authSession)) + } else { + @($provider.BulkRevokeEntitlements($identityKey, $toRemove)) + } - if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and - $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { - $Context.EventSink.WriteEvent('Information', "PruneEntitlements: revoked entitlement '$($ent.Id)'", $Step.Name, @{ - Kind = $kind - EntitlementId = [string]$ent.Id - }) + foreach ($br in $bulkResults) { + if ($br.Error) { + $skippedItems += [pscustomobject]@{ + EntitlementId = [string]$br.Entitlement.Id + Reason = [string]$br.Error + } + if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and + $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { + $Context.EventSink.WriteEvent('Warning', "PruneEntitlements: skipped non-removable entitlement '$($br.Entitlement.Id)': $($br.Error)", $Step.Name, @{ + Kind = $kind + EntitlementId = [string]$br.Entitlement.Id + Reason = [string]$br.Error + }) + } + } else { + if ($br.Changed) { $changed = $true } + if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and + $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { + $Context.EventSink.WriteEvent('Information', "PruneEntitlements: revoked entitlement '$($br.Entitlement.Id)'", $Step.Name, @{ + Kind = $kind + EntitlementId = [string]$br.Entitlement.Id + }) + } } } - catch { - # Non-removable or permission-denied entitlement: skip with warning - $reason = $_.Exception.Message - $skippedItems += [pscustomobject]@{ - EntitlementId = [string]$ent.Id - Reason = $reason - } + } else { + # Per-item path: each revoke is attempted independently + foreach ($ent in $toRemove) { + try { + if ($revokeSupportsAuthSession -and $null -ne $authSession) { + $null = $provider.RevokeEntitlement($identityKey, $ent, $authSession) + } else { + $null = $provider.RevokeEntitlement($identityKey, $ent) + } + $changed = $true - if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and - $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { - $Context.EventSink.WriteEvent('Warning', "PruneEntitlements: skipped non-removable entitlement '$($ent.Id)': $reason", $Step.Name, @{ - Kind = $kind + if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and + $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { + $Context.EventSink.WriteEvent('Information', "PruneEntitlements: revoked entitlement '$($ent.Id)'", $Step.Name, @{ + Kind = $kind + EntitlementId = [string]$ent.Id + }) + } + } + catch { + # Non-removable or permission-denied entitlement: skip with warning + $reason = $_.Exception.Message + $skippedItems += [pscustomobject]@{ EntitlementId = [string]$ent.Id Reason = $reason - }) + } + + if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and + $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { + $Context.EventSink.WriteEvent('Warning', "PruneEntitlements: skipped non-removable entitlement '$($ent.Id)': $reason", $Step.Name, @{ + Kind = $kind + EntitlementId = [string]$ent.Id + Reason = $reason + }) + } } } } # 4. If EnsureKeepEntitlements: grant any explicit Keep items that are missing if ($ensureKeep -and $keepItems.Count -gt 0) { - foreach ($k in $keepItems) { - $alreadyPresent = @($current | Where-Object { + $toEnsure = @($keepItems | Where-Object { $k = $_ + @($current | Where-Object { [string]::Equals([string]$_.Id, [string]$k.Id, [System.StringComparison]::OrdinalIgnoreCase) - }) - if (@($alreadyPresent).Count -eq 0) { + }).Count -eq 0 + }) + + if ($hasBulkGrant -and $toEnsure.Count -gt 0) { + # Bulk grant path + $bulkResults = if ($bulkGrantSupportsAuthSession -and $null -ne $authSession) { + @($provider.BulkGrantEntitlements($identityKey, $toEnsure, $authSession)) + } else { + @($provider.BulkGrantEntitlements($identityKey, $toEnsure)) + } + + foreach ($br in $bulkResults) { + if ($br.Changed) { $changed = $true } + if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and + $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { + $Context.EventSink.WriteEvent('Information', "PruneEntitlements: granted keep entitlement '$($br.Entitlement.Id)'", $Step.Name, @{ + Kind = $kind + EntitlementId = [string]$br.Entitlement.Id + }) + } + } + } else { + foreach ($k in $toEnsure) { if ($grantSupportsAuthSession -and $null -ne $authSession) { $null = $provider.GrantEntitlement($identityKey, $k, $authSession) } else { diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index 99a43bff..40f1d424 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -500,7 +500,9 @@ Describe 'AD identity provider' { $existingGroup = $user.Groups | Where-Object { $_.Id -eq $GroupIdentity } if ($null -eq $existingGroup) { $user.Groups = @($user.Groups) + @([pscustomobject]@{ Id = $GroupIdentity; Kind = 'Group' }) + return $true } + return $false } -Force $adapter | Add-Member -MemberType ScriptMethod -Name RemoveGroupMember -Value { @@ -518,9 +520,11 @@ Describe 'AD identity provider' { throw "User not found: $MemberIdentity" } + $wasMember = $null -ne ($user.Groups | Where-Object { $_.Id -eq $GroupIdentity }) if ($null -ne $user.Groups) { $user.Groups = @($user.Groups | Where-Object { $_.Id -ne $GroupIdentity }) } + return $wasMember } -Force $adapter | Add-Member -MemberType ScriptMethod -Name GetUserGroups -Value { diff --git a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 index 82a6176e..8594f135 100644 --- a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 +++ b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 @@ -175,7 +175,6 @@ Describe 'EntraID identity provider - Contract tests' { $this.Store[$key] = @() } - # Check if already a member (idempotency) $alreadyMember = $false foreach ($existingGroup in $this.Store[$key]) { if ($existingGroup.id -eq $GroupObjectId) { @@ -191,15 +190,40 @@ Describe 'EntraID identity provider - Contract tests' { mail = "group-$GroupObjectId@test.local" } $this.Store[$key] += $group + return $true } + return $false } $fakeAdapter | Add-Member -MemberType ScriptMethod -Name RemoveGroupMember -Value { param($GroupObjectId, $UserObjectId, $AccessToken) $key = "groups:$UserObjectId" if ($this.Store.ContainsKey($key)) { - $this.Store[$key] = $this.Store[$key] | Where-Object { $_.id -ne $GroupObjectId } + $wasMember = $null -ne ($this.Store[$key] | Where-Object { $_.id -eq $GroupObjectId }) + $this.Store[$key] = @($this.Store[$key] | Where-Object { $_.id -ne $GroupObjectId }) + return $wasMember } + return $false + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name BatchMembershipChanges -Value { + param($Operations, $AccessToken) + $results = @() + foreach ($op in $Operations) { + if ($op.Action -eq 'remove') { + $changed = [bool]$this.RemoveGroupMember($op.GroupObjectId, $op.UserObjectId, $AccessToken) + } else { + $changed = [bool]$this.AddGroupMember($op.GroupObjectId, $op.UserObjectId, $AccessToken) + } + $results += [pscustomobject]@{ + RequestId = $op.RequestId + GroupObjectId = $op.GroupObjectId + Action = $op.Action + Changed = $changed + Error = $null + } + } + return $results } $script:FakeAdapter = $fakeAdapter @@ -715,15 +739,40 @@ Describe 'EntraID identity provider - Entitlement operations' { mail = "group-$GroupObjectId@test.local" } $this.Store[$key] += $group + return $true } + return $false } $adapter | Add-Member -MemberType ScriptMethod -Name RemoveGroupMember -Value { param($GroupObjectId, $UserObjectId, $AccessToken) $key = "groups:$UserObjectId" if ($this.Store.ContainsKey($key)) { + $wasMember = $null -ne ($this.Store[$key] | Where-Object { $_.id -eq $GroupObjectId }) $this.Store[$key] = @($this.Store[$key] | Where-Object { $_.id -ne $GroupObjectId }) + return $wasMember + } + return $false + } + + $adapter | Add-Member -MemberType ScriptMethod -Name BatchMembershipChanges -Value { + param($Operations, $AccessToken) + $results = @() + foreach ($op in $Operations) { + if ($op.Action -eq 'remove') { + $changed = [bool]$this.RemoveGroupMember($op.GroupObjectId, $op.UserObjectId, $AccessToken) + } else { + $changed = [bool]$this.AddGroupMember($op.GroupObjectId, $op.UserObjectId, $AccessToken) + } + $results += [pscustomobject]@{ + RequestId = $op.RequestId + GroupObjectId = $op.GroupObjectId + Action = $op.Action + Changed = $changed + Error = $null + } } + return $results } return $adapter @@ -738,6 +787,8 @@ Describe 'EntraID identity provider - Entitlement operations' { $script:EntProvider.PSObject.Methods.Name | Should -Contain 'ListEntitlements' $script:EntProvider.PSObject.Methods.Name | Should -Contain 'GrantEntitlement' $script:EntProvider.PSObject.Methods.Name | Should -Contain 'RevokeEntitlement' + $script:EntProvider.PSObject.Methods.Name | Should -Contain 'BulkRevokeEntitlements' + $script:EntProvider.PSObject.Methods.Name | Should -Contain 'BulkGrantEntitlements' } It 'GrantEntitlement returns stable result shape with Kind=Group' { @@ -812,6 +863,79 @@ Describe 'EntraID identity provider - Entitlement operations' { @($afterGrant | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq $entitlement.Id }).Count | Should -Be 1 @($afterRevoke | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq $entitlement.Id }).Count | Should -Be 0 } + + It 'BulkRevokeEntitlements removes multiple groups and returns per-item Changed' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + + $g1 = [guid]::NewGuid().ToString() + $g2 = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GrantEntitlement($userId, @{ Kind = 'Group'; Id = $g1 }) + [void]$script:EntProvider.GrantEntitlement($userId, @{ Kind = 'Group'; Id = $g2 }) + + $results = $script:EntProvider.BulkRevokeEntitlements($userId, @( + @{ Kind = 'Group'; Id = $g1 }, + @{ Kind = 'Group'; Id = $g2 } + )) + + @($results).Count | Should -Be 2 + ($results | Where-Object { $_.Changed -eq $true }).Count | Should -Be 2 + ($results | Where-Object { $null -ne $_.Error }).Count | Should -Be 0 + + @($script:EntProvider.ListEntitlements($userId) | Where-Object { $_.Id -eq $g1 }).Count | Should -Be 0 + @($script:EntProvider.ListEntitlements($userId) | Where-Object { $_.Id -eq $g2 }).Count | Should -Be 0 + } + + It 'BulkRevokeEntitlements is idempotent (not a member → Changed=$false)' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + + $g1 = [guid]::NewGuid().ToString() + + $results = $script:EntProvider.BulkRevokeEntitlements($userId, @( + @{ Kind = 'Group'; Id = $g1 } + )) + + @($results).Count | Should -Be 1 + $results[0].Changed | Should -Be $false + $results[0].Error | Should -BeNullOrEmpty + } + + It 'BulkGrantEntitlements adds multiple groups and returns per-item Changed' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + + $g1 = [guid]::NewGuid().ToString() + $g2 = [guid]::NewGuid().ToString() + + $results = $script:EntProvider.BulkGrantEntitlements($userId, @( + @{ Kind = 'Group'; Id = $g1 }, + @{ Kind = 'Group'; Id = $g2 } + )) + + @($results).Count | Should -Be 2 + ($results | Where-Object { $_.Changed -eq $true }).Count | Should -Be 2 + ($results | Where-Object { $null -ne $_.Error }).Count | Should -Be 0 + + @($script:EntProvider.ListEntitlements($userId) | Where-Object { $_.Id -eq $g1 }).Count | Should -Be 1 + @($script:EntProvider.ListEntitlements($userId) | Where-Object { $_.Id -eq $g2 }).Count | Should -Be 1 + } + + It 'BulkGrantEntitlements is idempotent (already a member → Changed=$false)' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + + $g1 = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GrantEntitlement($userId, @{ Kind = 'Group'; Id = $g1 }) + + $results = $script:EntProvider.BulkGrantEntitlements($userId, @( + @{ Kind = 'Group'; Id = $g1 } + )) + + @($results).Count | Should -Be 1 + $results[0].Changed | Should -Be $false + $results[0].Error | Should -BeNullOrEmpty + } } } diff --git a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 index 685699c1..c837cb8b 100644 --- a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 @@ -434,6 +434,70 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { $mockProvider.RevokedIds | Should -Not -Contain 'CN=G-All,OU=Groups,DC=contoso,DC=com' } } + + Context 'Behavior: step uses BulkRevokeEntitlements when provider exposes it' { + It 'delegates remove-set to BulkRevokeEntitlements and surfaces per-item errors as Skipped' { + $mockProvider = [pscustomobject]@{ + PSTypeName = 'MockBulkProvider' + BulkCalled = $false + BulkInputIds = [System.Collections.Generic.List[string]]::new() + } + + $mockProvider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { + param($IdentityKey) + return @( + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=G-Keep,DC=contoso,DC=com' }, + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=G-Remove1,DC=contoso,DC=com' }, + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=G-Protected,DC=contoso,DC=com' } + ) + } -Force + + $mockProvider | Add-Member -MemberType ScriptMethod -Name RevokeEntitlement -Value { + param($IdentityKey, $Entitlement) # fallback; not called when BulkRevokeEntitlements is present + } -Force + + $mockProvider | Add-Member -MemberType ScriptMethod -Name BulkRevokeEntitlements -Value { + param($IdentityKey, $Entitlements) + $this.BulkCalled = $true + $results = @() + foreach ($ent in $Entitlements) { + $id = if ($ent -is [hashtable]) { $ent['Id'] } else { $ent.Id } + $this.BulkInputIds.Add($id) + if ($id -match 'Protected') { + $results += [pscustomobject]@{ Changed = $false; Error = 'Cannot remove primary group'; Entitlement = $ent } + } else { + $results += [pscustomobject]@{ Changed = $true; Error = $null; Entitlement = $ent } + } + } + return $results + } -Force + + $script:Context.Providers['Identity'] = $mockProvider + + $step = [pscustomobject]@{ + Name = 'Bulk prune test' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ + IdentityKey = 'user1' + Kind = 'Group' + Provider = 'Identity' + Keep = @( @{ Kind = 'Group'; Id = 'CN=G-Keep,DC=contoso,DC=com' } ) + } + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + $mockProvider.BulkCalled | Should -BeTrue + # G-Remove1 bulk-revoked, G-Protected returned as error → Skipped + $mockProvider.BulkInputIds | Should -Contain 'CN=G-Remove1,DC=contoso,DC=com' + $mockProvider.BulkInputIds | Should -Contain 'CN=G-Protected,DC=contoso,DC=com' + $result.Skipped | Should -Not -BeNullOrEmpty + $result.Skipped[0].EntitlementId | Should -Match 'Protected' + } + } } Describe 'Invoke-IdleStepPruneEntitlementsEnsureKeep (built-in step)' { From 732c333777e83ff2d993ddc04dd2e9f36b53a4c9 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:22:18 +0100 Subject: [PATCH 08/27] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Public/Invoke-IdleStepPruneEntitlements.ps1 | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 index f478559e..2165353a 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -354,11 +354,13 @@ function Invoke-IdleStepPruneEntitlements { foreach ($ent in $toRemove) { try { if ($revokeSupportsAuthSession -and $null -ne $authSession) { - $null = $provider.RevokeEntitlement($identityKey, $ent, $authSession) + $revokeResult = $provider.RevokeEntitlement($identityKey, $ent, $authSession) } else { - $null = $provider.RevokeEntitlement($identityKey, $ent) + $revokeResult = $provider.RevokeEntitlement($identityKey, $ent) + } + if ($revokeResult -and $revokeResult.Changed) { + $changed = $true } - $changed = $true if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { From d7ce22aba11766e8ea34ac08a28031b29154cdab Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:23:15 +0100 Subject: [PATCH 09/27] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Public/Invoke-IdleStepPruneEntitlements.ps1 | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 index 2165353a..a86c6843 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -419,12 +419,19 @@ function Invoke-IdleStepPruneEntitlements { } else { foreach ($k in $toEnsure) { if ($grantSupportsAuthSession -and $null -ne $authSession) { - $null = $provider.GrantEntitlement($identityKey, $k, $authSession) + $result = $provider.GrantEntitlement($identityKey, $k, $authSession) } else { - $null = $provider.GrantEntitlement($identityKey, $k) + $result = $provider.GrantEntitlement($identityKey, $k) } - $changed = $true + if ($null -ne $result -and $result.PSObject.Properties.Name -contains 'Changed') { + if ($result.Changed) { + $changed = $true + } + } else { + # Fall back to assuming a change occurred if the provider does not return a standard result object + $changed = $true + } if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { $Context.EventSink.WriteEvent('Information', "PruneEntitlements: granted keep entitlement '$($k.Id)'", $Step.Name, @{ From 241aaf8c6ac5f80995413024bdf5f1895f7a0fdd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:02:08 +0000 Subject: [PATCH 10/27] =?UTF-8?q?Rename=20NormalizeGroupId=E2=86=92Resolve?= =?UTF-8?q?Group=20and=20NormalizeEntitlementId=E2=86=92ResolveEntitlement?= =?UTF-8?q?;=20fix=20BulkRevokeEntitlements=20canonical=20ID;=20fix=20bulk?= =?UTF-8?q?=20ensure=20error=20handling;=20extract=20prune=20helpers=20to?= =?UTF-8?q?=20Private?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Public/New-IdleADIdentityProvider.ps1 | 13 +- .../New-IdleEntraIDIdentityProvider.ps1 | 18 +-- .../ConvertTo-IdlePruneEntitlement.ps1 | 48 +++++++ .../Test-IdlePruneEntitlementShouldKeep.ps1 | 37 ++++++ .../Invoke-IdleStepPruneEntitlements.ps1 | 125 ++++-------------- tests/Providers/ADIdentityProvider.Tests.ps1 | 10 +- .../EntraIDIdentityProvider.Tests.ps1 | 22 +-- ...Invoke-IdleStepPruneEntitlements.Tests.ps1 | 9 +- 8 files changed, 151 insertions(+), 131 deletions(-) create mode 100644 src/IdLE.Steps.Common/Private/ConvertTo-IdlePruneEntitlement.ps1 create mode 100644 src/IdLE.Steps.Common/Private/Test-IdlePruneEntitlementShouldKeep.ps1 diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index e093ac39..e69a06a9 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -230,7 +230,7 @@ function New-IdleADIdentityProvider { throw "Identity with sAMAccountName '$IdentityKey' not found." } - $normalizeGroupId = { + $resolveGroup = { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] @@ -340,7 +340,7 @@ function New-IdleADIdentityProvider { $provider | Add-Member -MemberType ScriptMethod -Name ConvertToEntitlement -Value $convertToEntitlement -Force $provider | Add-Member -MemberType ScriptMethod -Name TestEntitlementEquals -Value $testEntitlementEquals -Force $provider | Add-Member -MemberType ScriptMethod -Name ResolveIdentity -Value $resolveIdentity -Force - $provider | Add-Member -MemberType ScriptMethod -Name NormalizeGroupId -Value $normalizeGroupId -Force + $provider | Add-Member -MemberType ScriptMethod -Name ResolveGroup -Value $resolveGroup -Force $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { $caps = @( @@ -805,7 +805,7 @@ function New-IdleADIdentityProvider { $normalized = $this.ConvertToEntitlement($Entitlement) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) - $groupDn = $this.NormalizeGroupId($normalized.Id, $AuthSession) + $groupDn = $this.ResolveGroup($normalized.Id, $AuthSession) $changed = [bool]$adapter.AddGroupMember($groupDn, $user.DistinguishedName) @@ -838,7 +838,7 @@ function New-IdleADIdentityProvider { $normalized = $this.ConvertToEntitlement($Entitlement) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) - $groupDn = $this.NormalizeGroupId($normalized.Id, $AuthSession) + $groupDn = $this.ResolveGroup($normalized.Id, $AuthSession) $changed = [bool]$adapter.RemoveGroupMember($groupDn, $user.DistinguishedName) @@ -851,10 +851,11 @@ function New-IdleADIdentityProvider { } } -Force - $provider | Add-Member -MemberType ScriptMethod -Name NormalizeEntitlementId -Value { + $provider | Add-Member -MemberType ScriptMethod -Name ResolveEntitlement -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Kind', Justification = 'Contract parameter; Kind is validated against the entitlement object and reserved for future multi-Kind support.')] [string] $Kind, [Parameter(Mandatory)] @@ -870,7 +871,7 @@ function New-IdleADIdentityProvider { # AD only supports Group entitlements; normalize to canonical DN if ([string]::Equals($converted.Kind, 'Group', [System.StringComparison]::OrdinalIgnoreCase)) { - $canonicalId = $this.NormalizeGroupId($converted.Id, $AuthSession) + $canonicalId = $this.ResolveGroup($converted.Id, $AuthSession) return [pscustomobject]@{ PSTypeName = 'IdLE.Entitlement' Kind = $converted.Kind diff --git a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 index cdaf7d3d..4f3cafcd 100644 --- a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 +++ b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 @@ -252,7 +252,7 @@ function New-IdleEntraIDIdentityProvider { throw "Identity key '$IdentityKey' is not in a recognized format (objectId GUID, UPN, or mail)." } - $normalizeGroupId = { + $resolveGroup = { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] @@ -295,7 +295,7 @@ function New-IdleEntraIDIdentityProvider { $provider | Add-Member -MemberType ScriptMethod -Name ConvertToEntitlement -Value $convertToEntitlement -Force $provider | Add-Member -MemberType ScriptMethod -Name TestEntitlementEquals -Value $testEntitlementEquals -Force $provider | Add-Member -MemberType ScriptMethod -Name ResolveIdentity -Value $resolveIdentity -Force - $provider | Add-Member -MemberType ScriptMethod -Name NormalizeGroupId -Value $normalizeGroupId -Force + $provider | Add-Member -MemberType ScriptMethod -Name ResolveGroup -Value $resolveGroup -Force $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { $caps = @( @@ -870,7 +870,7 @@ function New-IdleEntraIDIdentityProvider { } $user = $this.ResolveIdentity($IdentityKey, $AuthSession) - $groupObjectId = $this.NormalizeGroupId($normalized.Id, $AuthSession) + $groupObjectId = $this.ResolveGroup($normalized.Id, $AuthSession) # Update normalized entitlement with canonical group ID $normalized.Id = $groupObjectId @@ -917,7 +917,7 @@ function New-IdleEntraIDIdentityProvider { $normalized.Kind = 'Group' } $user = $this.ResolveIdentity($IdentityKey, $AuthSession) - $groupObjectId = $this.NormalizeGroupId($normalized.Id, $AuthSession) + $groupObjectId = $this.ResolveGroup($normalized.Id, $AuthSession) # Update normalized entitlement with canonical group ID $normalized.Id = $groupObjectId @@ -960,7 +960,8 @@ function New-IdleEntraIDIdentityProvider { "BulkRevokeEntitlements only supports entitlements with Kind 'Group'. Received Kind '$($normalized.Kind)'." ) } - $groupObjectId = $this.NormalizeGroupId($normalized.Id, $AuthSession) + $groupObjectId = $this.ResolveGroup($normalized.Id, $AuthSession) + $normalized.Id = $groupObjectId $operations += @{ RequestId = [guid]::NewGuid().ToString('N').Substring(0, 8) GroupObjectId = $groupObjectId @@ -1017,7 +1018,7 @@ function New-IdleEntraIDIdentityProvider { "BulkGrantEntitlements only supports entitlements with Kind 'Group'. Received Kind '$($normalized.Kind)'." ) } - $groupObjectId = $this.NormalizeGroupId($normalized.Id, $AuthSession) + $groupObjectId = $this.ResolveGroup($normalized.Id, $AuthSession) $normalized.Id = $groupObjectId $operations += @{ RequestId = [guid]::NewGuid().ToString('N').Substring(0, 8) @@ -1049,10 +1050,11 @@ function New-IdleEntraIDIdentityProvider { return $results } -Force - $provider | Add-Member -MemberType ScriptMethod -Name NormalizeEntitlementId -Value { + $provider | Add-Member -MemberType ScriptMethod -Name ResolveEntitlement -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Kind', Justification = 'Contract parameter; Kind is validated against the entitlement object and reserved for future multi-Kind support.')] [string] $Kind, [Parameter(Mandatory)] @@ -1068,7 +1070,7 @@ function New-IdleEntraIDIdentityProvider { # Entra ID only supports Group entitlements; normalize to canonical objectId if ([string]::Equals($converted.Kind, 'Group', [System.StringComparison]::OrdinalIgnoreCase)) { - $canonicalId = $this.NormalizeGroupId($converted.Id, $AuthSession) + $canonicalId = $this.ResolveGroup($converted.Id, $AuthSession) return [pscustomobject]@{ PSTypeName = 'IdLE.Entitlement' Kind = $converted.Kind diff --git a/src/IdLE.Steps.Common/Private/ConvertTo-IdlePruneEntitlement.ps1 b/src/IdLE.Steps.Common/Private/ConvertTo-IdlePruneEntitlement.ps1 new file mode 100644 index 00000000..3f08c20b --- /dev/null +++ b/src/IdLE.Steps.Common/Private/ConvertTo-IdlePruneEntitlement.ps1 @@ -0,0 +1,48 @@ +function ConvertTo-IdlePruneEntitlement { + # Converts a raw hashtable or object Keep entry into a normalized pscustomobject with Kind, Id and optional DisplayName. + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Value, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $DefaultKind + ) + + $kind = $null + $id = $null + $displayName = $null + + if ($Value -is [System.Collections.IDictionary]) { + if ($Value.Contains('Kind')) { $kind = $Value['Kind'] } + if ($Value.Contains('Id')) { $id = $Value['Id'] } + if ($Value.Contains('DisplayName')) { $displayName = $Value['DisplayName'] } + } + else { + $props = $Value.PSObject.Properties + if ($props.Name -contains 'Kind') { $kind = $Value.Kind } + if ($props.Name -contains 'Id') { $id = $Value.Id } + if ($props.Name -contains 'DisplayName') { $displayName = $Value.DisplayName } + } + + if ([string]::IsNullOrWhiteSpace([string]$kind)) { + $kind = $DefaultKind + } + + if ([string]::IsNullOrWhiteSpace([string]$id)) { + throw "PruneEntitlements: each Keep entry requires an Id." + } + + $normalized = [ordered]@{ + Kind = [string]$kind + Id = [string]$id + } + + if ($null -ne $displayName -and -not [string]::IsNullOrWhiteSpace([string]$displayName)) { + $normalized['DisplayName'] = [string]$displayName + } + + return [pscustomobject]$normalized +} diff --git a/src/IdLE.Steps.Common/Private/Test-IdlePruneEntitlementShouldKeep.ps1 b/src/IdLE.Steps.Common/Private/Test-IdlePruneEntitlementShouldKeep.ps1 new file mode 100644 index 00000000..07e64162 --- /dev/null +++ b/src/IdLE.Steps.Common/Private/Test-IdlePruneEntitlementShouldKeep.ps1 @@ -0,0 +1,37 @@ +function Test-IdlePruneEntitlementShouldKeep { + # Returns $true if the given entitlement should be kept based on explicit Keep items or KeepPattern wildcards. + # Used by both IdLE.Step.PruneEntitlements and IdLE.Step.PruneEntitlementsEnsureKeep. + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Ent, + + [Parameter(Mandatory)] + [AllowNull()] + [AllowEmptyCollection()] + [object[]] $KeepItems, + + [Parameter(Mandatory)] + [AllowNull()] + [AllowEmptyCollection()] + [string[]] $KeepPatterns + ) + + # Check explicit Keep items (case-insensitive Id match) + foreach ($k in $KeepItems) { + if ([string]::Equals([string]$Ent.Id, [string]$k.Id, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + + # Check KeepPattern (wildcard -like against Id and DisplayName) + foreach ($pattern in $KeepPatterns) { + if ([string]$Ent.Id -like $pattern) { return $true } + if ($Ent.PSObject.Properties.Name -contains 'DisplayName' -and + $null -ne $Ent.DisplayName -and + [string]$Ent.DisplayName -like $pattern) { return $true } + } + + return $false +} diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 index a86c6843..2b27ca10 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -66,90 +66,6 @@ function Invoke-IdleStepPruneEntitlements { [object] $Step ) - function ConvertTo-IdleStepPruneEntitlement { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Value, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $DefaultKind - ) - - $kind = $null - $id = $null - $displayName = $null - - if ($Value -is [System.Collections.IDictionary]) { - if ($Value.Contains('Kind')) { $kind = $Value['Kind'] } - if ($Value.Contains('Id')) { $id = $Value['Id'] } - if ($Value.Contains('DisplayName')) { $displayName = $Value['DisplayName'] } - } - else { - $props = $Value.PSObject.Properties - if ($props.Name -contains 'Kind') { $kind = $Value.Kind } - if ($props.Name -contains 'Id') { $id = $Value.Id } - if ($props.Name -contains 'DisplayName') { $displayName = $Value.DisplayName } - } - - if ([string]::IsNullOrWhiteSpace([string]$kind)) { - $kind = $DefaultKind - } - - if ([string]::IsNullOrWhiteSpace([string]$id)) { - throw "PruneEntitlements: each Keep entry requires an Id." - } - - $normalized = [ordered]@{ - Kind = [string]$kind - Id = [string]$id - } - - if ($null -ne $displayName -and -not [string]::IsNullOrWhiteSpace([string]$displayName)) { - $normalized['DisplayName'] = [string]$displayName - } - - return [pscustomobject]$normalized - } - - function Test-IdleStepPruneEntitlementShouldKeep { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Ent, - - [Parameter(Mandatory)] - [AllowNull()] - [AllowEmptyCollection()] - [object[]] $KeepItems, - - [Parameter(Mandatory)] - [AllowNull()] - [AllowEmptyCollection()] - [string[]] $KeepPatterns - ) - - # Check explicit Keep items (case-insensitive Id match) - foreach ($k in $KeepItems) { - if ([string]::Equals([string]$Ent.Id, [string]$k.Id, [System.StringComparison]::OrdinalIgnoreCase)) { - return $true - } - } - - # Check KeepPattern (wildcard -like against Id and DisplayName) - foreach ($pattern in $KeepPatterns) { - if ([string]$Ent.Id -like $pattern) { return $true } - if ($Ent.PSObject.Properties.Name -contains 'DisplayName' -and - $null -ne $Ent.DisplayName -and - [string]$Ent.DisplayName -like $pattern) { return $true } - } - - return $false - } - $with = $Step.With if ($null -eq $with -or -not ($with -is [hashtable])) { throw "PruneEntitlements requires 'With' to be a hashtable." @@ -180,7 +96,7 @@ function Invoke-IdleStepPruneEntitlements { if ($item -is [scriptblock]) { throw "PruneEntitlements: Keep entries must not contain ScriptBlocks." } - $keepItems += ConvertTo-IdleStepPruneEntitlement -Value $item -DefaultKind $kind + $keepItems += ConvertTo-IdlePruneEntitlement -Value $item -DefaultKind $kind } } @@ -250,13 +166,13 @@ function Invoke-IdleStepPruneEntitlements { # This ensures correct comparison with the canonical IDs returned by ListEntitlements. # Each provider handles its own ID-type detection (e.g., GUID/DN/sAMAccountName for AD; # objectId/displayName for Entra ID). - if ($keepItems.Count -gt 0 -and $provider.PSObject.Methods.Name -contains 'NormalizeEntitlementId') { - $normalizeSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['NormalizeEntitlementId'] -ParameterName 'AuthSession' + if ($keepItems.Count -gt 0 -and $provider.PSObject.Methods.Name -contains 'ResolveEntitlement') { + $resolveSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['ResolveEntitlement'] -ParameterName 'AuthSession' $keepItems = @($keepItems | ForEach-Object { - if ($normalizeSupportsAuthSession -and $null -ne $authSession) { - $provider.NormalizeEntitlementId($kind, $_, $authSession) + if ($resolveSupportsAuthSession -and $null -ne $authSession) { + $provider.ResolveEntitlement($kind, $_, $authSession) } else { - $provider.NormalizeEntitlementId($kind, $_) + $provider.ResolveEntitlement($kind, $_) } }) } @@ -295,7 +211,7 @@ function Invoke-IdleStepPruneEntitlements { $toRemove = @() foreach ($ent in $current) { - if (Test-IdleStepPruneEntitlementShouldKeep -Ent $ent -KeepItems $keepItems -KeepPatterns $keepPatterns) { + if (Test-IdlePruneEntitlementShouldKeep -Ent $ent -KeepItems $keepItems -KeepPatterns $keepPatterns) { $toKeep += $ent } else { $toRemove += $ent @@ -407,13 +323,28 @@ function Invoke-IdleStepPruneEntitlements { } foreach ($br in $bulkResults) { - if ($br.Changed) { $changed = $true } - if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and - $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { - $Context.EventSink.WriteEvent('Information', "PruneEntitlements: granted keep entitlement '$($br.Entitlement.Id)'", $Step.Name, @{ - Kind = $kind + if ($br.Error) { + $skippedItems += [pscustomobject]@{ EntitlementId = [string]$br.Entitlement.Id - }) + Reason = [string]$br.Error + } + if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and + $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { + $Context.EventSink.WriteEvent('Warning', "PruneEntitlements: failed to grant keep entitlement '$($br.Entitlement.Id)': $($br.Error)", $Step.Name, @{ + Kind = $kind + EntitlementId = [string]$br.Entitlement.Id + Reason = [string]$br.Error + }) + } + } else { + if ($br.Changed) { $changed = $true } + if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and + $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { + $Context.EventSink.WriteEvent('Information', "PruneEntitlements: granted keep entitlement '$($br.Entitlement.Id)'", $Step.Name, @{ + Kind = $kind + EntitlementId = [string]$br.Entitlement.Id + }) + } } } } else { diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index 40f1d424..8e553ecc 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -2032,26 +2032,26 @@ Describe 'AD identity provider' { } } - Context 'NormalizeEntitlementId' { + Context 'ResolveEntitlement' { BeforeAll { $adapter = New-FakeADAdapter $script:NormProvider = New-IdleADIdentityProvider -Adapter $adapter } - It 'Exposes NormalizeEntitlementId as a ScriptMethod' { - $script:NormProvider.PSObject.Methods.Name | Should -Contain 'NormalizeEntitlementId' + It 'Exposes ResolveEntitlement as a ScriptMethod' { + $script:NormProvider.PSObject.Methods.Name | Should -Contain 'ResolveEntitlement' } It 'Normalizes a Group entitlement with a DN Id to canonical DN' { $ent = @{ Kind = 'Group'; Id = 'CN=TestGroup,OU=Groups,DC=domain,DC=local' } - $result = $script:NormProvider.NormalizeEntitlementId('Group', $ent, $null) + $result = $script:NormProvider.ResolveEntitlement('Group', $ent, $null) $result.Kind | Should -Be 'Group' $result.Id | Should -Be 'CN=TestGroup,OU=Groups,DC=domain,DC=local' } It 'Returns entitlement unchanged when Kind is not Group' { $ent = [pscustomobject]@{ Kind = 'License'; Id = 'Some-License-Id' } - $result = $script:NormProvider.NormalizeEntitlementId('License', $ent, $null) + $result = $script:NormProvider.ResolveEntitlement('License', $ent, $null) $result.Kind | Should -Be 'License' $result.Id | Should -Be 'Some-License-Id' } diff --git a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 index 8594f135..6cd5f007 100644 --- a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 +++ b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 @@ -655,7 +655,7 @@ Describe 'EntraID identity provider - Group resolution' { $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter $groupGuid = [guid]::NewGuid().ToString() - $resolvedId = $provider.NormalizeGroupId($groupGuid, 'fake-token') + $resolvedId = $provider.ResolveGroup($groupGuid, 'fake-token') $resolvedId | Should -Be $groupGuid } @@ -663,14 +663,14 @@ Describe 'EntraID identity provider - Group resolution' { It 'Resolves group by displayName' { $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - $resolvedId = $provider.NormalizeGroupId('UniqueGroup', 'fake-token') + $resolvedId = $provider.ResolveGroup('UniqueGroup', 'fake-token') $resolvedId | Should -Be 'resolved-UniqueGroup' } It 'Throws when multiple groups match displayName' { $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter - { $provider.NormalizeGroupId('AmbiguousGroup', 'fake-token') } | Should -Throw '*Multiple groups found*' + { $provider.ResolveGroup('AmbiguousGroup', 'fake-token') } | Should -Throw '*Multiple groups found*' } } } @@ -1247,20 +1247,20 @@ Describe 'EntraID identity provider - Password generation' { } } -Describe 'EntraID identity provider - NormalizeEntitlementId' { +Describe 'EntraID identity provider - ResolveEntitlement' { BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule } - Context 'Exposes NormalizeEntitlementId' { - It 'Provider exposes NormalizeEntitlementId as a ScriptMethod' { + Context 'Exposes ResolveEntitlement' { + It 'Provider exposes ResolveEntitlement as a ScriptMethod' { $provider = New-IdleEntraIDIdentityProvider -Adapter ([pscustomobject]@{}) - $provider.PSObject.Methods.Name | Should -Contain 'NormalizeEntitlementId' + $provider.PSObject.Methods.Name | Should -Contain 'ResolveEntitlement' } } - Context 'NormalizeEntitlementId behavior' { + Context 'ResolveEntitlement behavior' { BeforeAll { # Fake adapter that returns canonical objectId for groups (mimics real Graph lookup) $fakeAdapter = [pscustomobject]@{ PSTypeName = 'IdLE.EntraIDAdapter.Fake'; Store = @{} } @@ -1278,21 +1278,21 @@ Describe 'EntraID identity provider - NormalizeEntitlementId' { It 'Normalizes a Group entitlement with a GUID Id to canonical objectId' { $groupGuid = [guid]::NewGuid().ToString() $ent = @{ Kind = 'Group'; Id = $groupGuid } - $result = $script:NormProvider.NormalizeEntitlementId('Group', $ent, 'fake-token') + $result = $script:NormProvider.ResolveEntitlement('Group', $ent, 'fake-token') $result.Kind | Should -Be 'Group' $result.Id | Should -Be $groupGuid } It 'Normalizes a Group entitlement with a displayName to canonical objectId' { $ent = @{ Kind = 'Group'; Id = 'HR Team' } - $result = $script:NormProvider.NormalizeEntitlementId('Group', $ent, 'fake-token') + $result = $script:NormProvider.ResolveEntitlement('Group', $ent, 'fake-token') $result.Kind | Should -Be 'Group' $result.Id | Should -Be 'resolved-HR Team' } It 'Returns entitlement unchanged when Kind is not Group' { $ent = [pscustomobject]@{ Kind = 'License'; Id = 'Some-License-Id' } - $result = $script:NormProvider.NormalizeEntitlementId('License', $ent, 'fake-token') + $result = $script:NormProvider.ResolveEntitlement('License', $ent, 'fake-token') $result.Kind | Should -Be 'License' $result.Id | Should -Be 'Some-License-Id' } diff --git a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 index c837cb8b..135ef117 100644 --- a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 @@ -368,10 +368,10 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } } - Context 'Behavior: step normalizes Keep IDs via provider.NormalizeEntitlementId when available' { + Context 'Behavior: step normalizes Keep IDs via provider.ResolveEntitlement when available' { It 'normalizes Keep item IDs before comparison so non-canonical IDs are matched correctly' { # Build a provider that: returns canonical IDs from ListEntitlements, - # normalizes 'short-name' Keep IDs → canonical 'CN=...' form via NormalizeEntitlementId, + # normalizes 'short-name' Keep IDs → canonical 'CN=...' form via ResolveEntitlement, # and tracks which IDs were passed to RevokeEntitlement. $revokedIds = @() $mockProvider = [pscustomobject]@{ @@ -388,7 +388,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { ) } -Force - $mockProvider | Add-Member -MemberType ScriptMethod -Name NormalizeEntitlementId -Value { + $mockProvider | Add-Member -MemberType ScriptMethod -Name ResolveEntitlement -Value { param($Kind, $Entitlement, $AuthSession) # Map short sAMAccountName-style IDs to canonical DNs $idMap = @{ @@ -405,12 +405,13 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { param($IdentityKey, $Entitlement) $id = if ($Entitlement -is [hashtable]) { $Entitlement['Id'] } else { $Entitlement.Id } $this.RevokedIds += $id + return [pscustomobject]@{ Changed = $true } } -Force $script:Context.Providers['Identity'] = $mockProvider $step = [pscustomobject]@{ - Name = 'Prune via NormalizeEntitlementId' + Name = 'Prune via ResolveEntitlement' Type = 'IdLE.Step.PruneEntitlements' With = @{ IdentityKey = 'user1' From 4092659bf2229c1c8a65dae14e4ecaab784d963e Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:48:16 +0100 Subject: [PATCH 11/27] docs: fixing sidebar, adding new step types for prune --- website/sidebars.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/sidebars.js b/website/sidebars.js index 1fd0815f..5833120d 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -108,6 +108,8 @@ const sidebars = { 'reference/steps/step-emit-event', 'reference/steps/step-ensure-attributes', 'reference/steps/step-ensure-entitlement', + 'reference/steps/step-prune-entitlements', + 'reference/steps/step-prune-entitlements-ensure-keep', 'reference/steps/step-move-identity', 'reference/steps/step-trigger-directory-sync', 'reference/steps/step-mailbox-get-info', From 6c5c40d0e72231410ccf164c4a3f787d211ee0e6 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:48:30 +0100 Subject: [PATCH 12/27] docs: generating new step types reference --- .../step-prune-entitlements-ensure-keep.md | 118 ++++-------------- .../steps/step-prune-entitlements.md | 106 ++++------------ 2 files changed, 51 insertions(+), 173 deletions(-) diff --git a/docs/reference/steps/step-prune-entitlements-ensure-keep.md b/docs/reference/steps/step-prune-entitlements-ensure-keep.md index 6111678b..1f8d69dd 100644 --- a/docs/reference/steps/step-prune-entitlements-ensure-keep.md +++ b/docs/reference/steps/step-prune-entitlements-ensure-keep.md @@ -8,7 +8,7 @@ - **Step Type**: `IdLE.Step.PruneEntitlementsEnsureKeep` - **Module**: `IdLE.Steps.Common` - **Implementation**: `Invoke-IdleStepPruneEntitlementsEnsureKeep` -- **Idempotent**: `Yes` +- **Idempotent**: `Unknown` ## Synopsis @@ -16,19 +16,23 @@ Converges an identity's entitlements by removing all non-kept entitlements and e ## Description -This provider-agnostic step combines "remove all except" semantics with "ensure kept entitlements are present". -It is intended for leaver and mover workflows where all entitlements of a given kind must be removed except -for an explicit keep-set, and where those kept entitlements must also be guaranteed to be present. +This provider-agnostic step implements "remove all except … and ensure those are present" semantics for +entitlements. It is intended for leaver and mover workflows where all entitlements of a given kind +(e.g. group memberships) must be removed except for an explicit keep-set, and the kept entitlements +must be guaranteed to be present. -Use [`IdLE.Step.PruneEntitlements`](./step-prune-entitlements.md) when you only need removal without the -ensure-grant phase. Use this step type when you need both prune and grant semantics in a single operation. +This step always grants any explicit Keep items that are not yet present. Use IdLE.Step.PruneEntitlements +when you only need removal without the ensure-grant phase. The host must supply a provider that: -- Advertises the `IdLE.Entitlement.Prune` capability (explicit opt-in) -- Implements `ListEntitlements(identityKey)` -- Implements `RevokeEntitlement(identityKey, entitlement)` -- Implements `GrantEntitlement(identityKey, entitlement)` +- Advertises the IdLE.Entitlement.Prune capability (explicit opt-in) + +- Implements ListEntitlements(identityKey) + +- Implements RevokeEntitlement(identityKey, entitlement) + +- Implements GrantEntitlement(identityKey, entitlement) Provider/system non-removable entitlements (e.g., AD primary group / Domain Users) are handled safely: if a revoke operation fails, the step emits a structured warning event, @@ -36,101 +40,33 @@ skips the entitlement, and continues. The workflow is not failed for these items Authentication: -- If `With.AuthSessionName` is present, the step acquires an auth session via - `Context.AcquireAuthSession(Name, Options)` and passes it to provider methods - if the provider supports an `AuthSession` parameter. -- `With.AuthSessionOptions` (optional, hashtable) is passed to the broker for - session selection (e.g., `@{ Role = 'Tier0' }`). -- ScriptBlocks in `AuthSessionOptions` are rejected (security boundary). - -## Inputs (With.*) - -The following keys are supported in the step's `With` configuration: - -| Key | Required | Description | -| --- | --- | --- | -| `IdentityKey` | Yes | Unique identifier for the identity whose entitlements to prune | -| `Kind` | Yes | Entitlement kind to prune (e.g. `Group`, `Role`, `License`) — provider-defined | -| `Keep` | No* | Array of entitlement references to keep. Each entry must have an `Id` and optionally a `Kind` and `DisplayName`. At least one of `Keep` or `KeepPattern` is required. Missing `Keep` items are granted. | -| `KeepPattern` | No* | Array of wildcard strings (PowerShell `-like` semantics). Current entitlements whose `Id` or `DisplayName` matches any pattern are kept. At least one of `Keep` or `KeepPattern` is required. Pattern-matched entitlements are never "ensured" — only explicit `Keep` items are granted. | -| `Provider` | No | Alias for the provider in `Context.Providers`. Defaults to `'Identity'`. | -| `AuthSessionName` | No | Name used to acquire an auth session via `Context.AcquireAuthSession(...)`. | -| `AuthSessionOptions` | No | Hashtable of options passed to the auth session broker (e.g., `@{ Role = 'Tier0' }`). ScriptBlocks are rejected. | - -\* At least one of `Keep` or `KeepPattern` **must** be provided. Specifying neither is rejected as a safety guardrail. - -## Capability Requirement - -This step requires the provider to advertise the `IdLE.Entitlement.Prune` capability (explicit opt-in). -This is in addition to the standard `IdLE.Entitlement.List`, `IdLE.Entitlement.Revoke`, and -`IdLE.Entitlement.Grant` capabilities. - -See [Capabilities Reference](../capabilities.md) for details. +- If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to provider methods + if the provider supports an AuthSession parameter. -## Behavior +- With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @\{ Role = 'Tier0' \}). -The step executes the following convergence logic: +- ScriptBlocks in AuthSessionOptions are rejected (security boundary). -1. Lists all current entitlements of the specified `Kind` for the identity (single read). -2. Normalizes `Keep` item IDs to canonical form via `provider.NormalizeEntitlementId` (when available). -3. Builds a **keep-set** from: - - Explicit `Keep` entries (matched by case-insensitive `Id` comparison after normalization) - - Current entitlements whose `Id` or `DisplayName` matches any `KeepPattern` wildcard -4. Computes **remove-set** = current − keep-set. -5. Revokes each entitlement in the remove-set. If a revoke fails (e.g. non-removable entitlement), the error is recorded as a skip with a warning event; the workflow continues. -6. Grants any explicit `Keep` entitlements that were not in the current set (**ensure phase**). Pattern-matched entitlements are never ensured. - -## Result - -Returns an `IdLE.StepResult` object. In addition to the standard `Status`, `Changed`, and `Error` properties, -a `Skipped` array is included. Each entry in `Skipped` contains: - -| Property | Description | -| --- | --- | -| `EntitlementId` | The `Id` of the entitlement that could not be removed | -| `Reason` | The error message from the provider | +## Inputs (With.*) -## Examples +The required input keys could not be detected automatically. +Please refer to the step description and examples for usage details. -### Keep one explicit group and ensure it is present +## Example ```powershell @{ - Name = 'Prune groups and ensure leaver group (leaver)' + Name = 'IdLE.Step.PruneEntitlementsEnsureKeep Example' Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' With = @{ - IdentityKey = '{{Request.Intent.SamAccountName}}' - Kind = 'Group' - Keep = @( - @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' } - ) - } -} -``` - -### Keep + wildcard pattern and ensure the explicit leaver group is present - -```powershell -@{ - Name = 'Prune groups and ensure leaver group (leaver with pattern)' - Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' - Condition = @{ Equals = @{ Path = 'Request.Intent.PruneGroups'; Value = $true } } - With = @{ - IdentityKey = '{{Request.Intent.SamAccountName}}' - Provider = 'Identity' - Kind = 'Group' - Keep = @( - @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com'; DisplayName = 'Leaver Retain' } - ) - KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') - AuthSessionName = 'Directory' + # See step description for available options } } ``` ## See Also -- [IdLE.Step.PruneEntitlements](./step-prune-entitlements.md) - Remove-only variant (no ensure phase) -- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities including `IdLE.Entitlement.Prune` +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities - [Providers](../providers.md) - Available provider implementations -- [IdLE.Step.EnsureEntitlement](./step-ensure-entitlement.md) - Atomic single-entitlement convergence diff --git a/docs/reference/steps/step-prune-entitlements.md b/docs/reference/steps/step-prune-entitlements.md index 8232b333..b683abc2 100644 --- a/docs/reference/steps/step-prune-entitlements.md +++ b/docs/reference/steps/step-prune-entitlements.md @@ -8,7 +8,7 @@ - **Step Type**: `IdLE.Step.PruneEntitlements` - **Module**: `IdLE.Steps.Common` - **Implementation**: `Invoke-IdleStepPruneEntitlements` -- **Idempotent**: `Yes` +- **Idempotent**: `Unknown` ## Synopsis @@ -21,14 +21,16 @@ It is intended for leaver and mover workflows where all entitlements of a given (e.g. group memberships) must be removed, except for an explicit keep-set and/or entitlements matching a wildcard keep pattern. -This step is **remove-only**. Use [`IdLE.Step.PruneEntitlementsEnsureKeep`](./step-prune-entitlements-ensure-keep.md) -when you also need to guarantee that explicit `Keep` entitlements are present after the prune. +This step is remove-only. Use IdLE.Step.PruneEntitlementsEnsureKeep when you also need +to guarantee that explicit Keep entitlements are present after the prune. The host must supply a provider that: -- Advertises the `IdLE.Entitlement.Prune` capability (explicit opt-in) -- Implements `ListEntitlements(identityKey)` -- Implements `RevokeEntitlement(identityKey, entitlement)` +- Advertises the IdLE.Entitlement.Prune capability (explicit opt-in) + +- Implements ListEntitlements(identityKey) + +- Implements RevokeEntitlement(identityKey, entitlement) Provider/system non-removable entitlements (e.g., AD primary group / Domain Users) are handled safely: if a revoke operation fails, the step emits a structured warning event, @@ -36,98 +38,38 @@ skips the entitlement, and continues. The workflow is not failed for these items Authentication: -- If `With.AuthSessionName` is present, the step acquires an auth session via - `Context.AcquireAuthSession(Name, Options)` and passes it to provider methods - if the provider supports an `AuthSession` parameter. -- `With.AuthSessionOptions` (optional, hashtable) is passed to the broker for - session selection (e.g., `@{ Role = 'Tier0' }`). -- ScriptBlocks in `AuthSessionOptions` are rejected (security boundary). +- If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to provider methods + if the provider supports an AuthSession parameter. + +- With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @\{ Role = 'Tier0' \}). + +- ScriptBlocks in AuthSessionOptions are rejected (security boundary). ## Inputs (With.*) -The following keys are supported in the step's `With` configuration: +The following keys are required in the step's ``With`` configuration: | Key | Required | Description | | --- | --- | --- | -| `IdentityKey` | Yes | Unique identifier for the identity whose entitlements to prune | -| `Kind` | Yes | Entitlement kind to prune (e.g. `Group`, `Role`, `License`) — provider-defined | -| `Keep` | No* | Array of entitlement references to keep. Each entry must have an `Id` and optionally a `Kind` and `DisplayName`. At least one of `Keep` or `KeepPattern` is required. | -| `KeepPattern` | No* | Array of wildcard strings (PowerShell `-like` semantics). Current entitlements whose `Id` or `DisplayName` matches any pattern are kept. At least one of `Keep` or `KeepPattern` is required. | -| `Provider` | No | Alias for the provider in `Context.Providers`. Defaults to `'Identity'`. | -| `AuthSessionName` | No | Name used to acquire an auth session via `Context.AcquireAuthSession(...)`. | -| `AuthSessionOptions` | No | Hashtable of options passed to the auth session broker (e.g., `@{ Role = 'Tier0' }`). ScriptBlocks are rejected. | - -\* At least one of `Keep` or `KeepPattern` **must** be provided. Specifying neither is rejected as a safety guardrail. - -## Capability Requirement - -This step requires the provider to advertise the `IdLE.Entitlement.Prune` capability (explicit opt-in). -This is in addition to the standard `IdLE.Entitlement.List` and `IdLE.Entitlement.Revoke` capabilities. - -See [Capabilities Reference](../capabilities.md) for details. - -## Behavior - -The step executes the following convergence logic: - -1. Lists all current entitlements of the specified `Kind` for the identity (single read). -2. Normalizes `Keep` item IDs to canonical form via `provider.NormalizeEntitlementId` (when available). -3. Builds a **keep-set** from: - - Explicit `Keep` entries (matched by case-insensitive `Id` comparison after normalization) - - Current entitlements whose `Id` or `DisplayName` matches any `KeepPattern` wildcard -4. Computes **remove-set** = current − keep-set. -5. Revokes each entitlement in the remove-set. If a revoke fails (e.g. non-removable entitlement), the error is recorded as a skip with a warning event; the workflow continues. - -## Result - -Returns an `IdLE.StepResult` object. In addition to the standard `Status`, `Changed`, and `Error` properties, -a `Skipped` array is included. Each entry in `Skipped` contains: - -| Property | Description | -| --- | --- | -| `EntitlementId` | The `Id` of the entitlement that could not be removed | -| `Reason` | The error message from the provider | - -## Examples - -### Basic: prune all groups except one explicit group - -```powershell -@{ - Name = 'Prune group memberships (leaver)' - Type = 'IdLE.Step.PruneEntitlements' - With = @{ - IdentityKey = '{{Request.Intent.SamAccountName}}' - Kind = 'Group' - Keep = @( - @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' } - ) - } -} -``` +| `IdentityKey` | Yes | Unique identifier for the identity | +| `Kind` | Yes | See step description for details | -### With wildcard pattern: keep all LEAVER-* groups +## Example ```powershell @{ - Name = 'Prune group memberships (leaver with pattern)' + Name = 'IdLE.Step.PruneEntitlements Example' Type = 'IdLE.Step.PruneEntitlements' With = @{ - IdentityKey = '{{Request.Intent.SamAccountName}}' - Provider = 'Identity' - Kind = 'Group' - Keep = @( - @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' } - ) - KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') - AuthSessionName = 'Directory' + IdentityKey = 'user.name' + Kind = '' } } ``` ## See Also -- [IdLE.Step.PruneEntitlementsEnsureKeep](./step-prune-entitlements-ensure-keep.md) - Remove + ensure keep entitlements are present -- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities including `IdLE.Entitlement.Prune` +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities - [Providers](../providers.md) - Available provider implementations -- [IdLE.Step.EnsureEntitlement](./step-ensure-entitlement.md) - Atomic single-entitlement convergence From 18401ef6cd30b2366271856cabee66cc0de73581 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:17:54 +0000 Subject: [PATCH 13/27] Fix test failures: add @() wrapping before .Count on Where-Object pipeline in strict mode Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- tests/Providers/EntraIDIdentityProvider.Tests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 index 6cd5f007..912f4c51 100644 --- a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 +++ b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 @@ -880,7 +880,7 @@ Describe 'EntraID identity provider - Entitlement operations' { @($results).Count | Should -Be 2 ($results | Where-Object { $_.Changed -eq $true }).Count | Should -Be 2 - ($results | Where-Object { $null -ne $_.Error }).Count | Should -Be 0 + @($results | Where-Object { $null -ne $_.Error }).Count | Should -Be 0 @($script:EntProvider.ListEntitlements($userId) | Where-Object { $_.Id -eq $g1 }).Count | Should -Be 0 @($script:EntProvider.ListEntitlements($userId) | Where-Object { $_.Id -eq $g2 }).Count | Should -Be 0 @@ -915,7 +915,7 @@ Describe 'EntraID identity provider - Entitlement operations' { @($results).Count | Should -Be 2 ($results | Where-Object { $_.Changed -eq $true }).Count | Should -Be 2 - ($results | Where-Object { $null -ne $_.Error }).Count | Should -Be 0 + @($results | Where-Object { $null -ne $_.Error }).Count | Should -Be 0 @($script:EntProvider.ListEntitlements($userId) | Where-Object { $_.Id -eq $g1 }).Count | Should -Be 1 @($script:EntProvider.ListEntitlements($userId) | Where-Object { $_.Id -eq $g2 }).Count | Should -Be 1 From dc01dd2d676c542687b5ea62f04bb4ce7af5b9bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:46:40 +0000 Subject: [PATCH 14/27] Extend step reference pages: add With.* parameter tables, workflow-style examples; update generator to use .EXAMPLE from help Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../step-mailbox-ensure-out-of-office.md | 140 +++++++++++++++++- .../steps/step-mailbox-ensure-permissions.md | 54 ++++++- .../steps/step-mailbox-ensure-type.md | 14 +- docs/reference/steps/step-mailbox-get-info.md | 12 +- .../step-prune-entitlements-ensure-keep.md | 33 ++++- .../steps/step-prune-entitlements.md | 33 ++++- .../steps/step-revoke-identity-sessions.md | 14 +- .../steps/step-trigger-directory-sync.md | 15 +- .../Invoke-IdleStepPruneEntitlements.ps1 | 36 +++-- ...ke-IdleStepPruneEntitlementsEnsureKeep.ps1 | 35 +++-- tools/Generate-IdleStepReference.ps1 | 130 +++++++++++----- 11 files changed, 418 insertions(+), 98 deletions(-) diff --git a/docs/reference/steps/step-mailbox-ensure-out-of-office.md b/docs/reference/steps/step-mailbox-ensure-out-of-office.md index a8315cfc..b4178e3c 100644 --- a/docs/reference/steps/step-mailbox-ensure-out-of-office.md +++ b/docs/reference/steps/step-mailbox-ensure-out-of-office.md @@ -63,17 +63,145 @@ The following keys are required in the step's ``With`` configuration: ## Example +### Example 1 + +```powershell +# In workflow definition (enable OOF): +@{ + Name = 'Enable Out of Office' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Enabled' + InternalMessage = 'I am out of office.' + ExternalMessage = 'I am currently unavailable.' + ExternalAudience = 'All' + MessageFormat = 'Text' + } + } +} +``` + +### Example 2 + ```powershell +# In workflow definition (with ValueFrom for dynamic values): @{ - Name = 'IdLE.Step.Mailbox.EnsureOutOfOffice Example' - Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' - With = @{ - Config = '' - IdentityKey = 'user.name' - } + Name = 'Enable Out of Office for Leaver' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } + Config = @{ + Mode = 'Enabled' + InternalMessage = 'This person is no longer with the organization. For assistance, please contact their manager or the main office.' + ExternalMessage = 'This person is no longer with the organization. Please contact the main office for assistance.' + ExternalAudience = 'All' + } + } } ``` +### Example 3 + +```powershell +# In workflow definition (scheduled OOF): +@{ + Name = 'Schedule Out of Office' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Scheduled' + Start = '2025-02-01T00:00:00Z' + End = '2025-02-15T00:00:00Z' + InternalMessage = 'I am on vacation until February 15.' + ExternalMessage = 'I am currently out of office.' + } + } +} +``` + +### Example 4 + +```powershell +# In workflow definition (disable OOF): +@{ + Name = 'Disable Out of Office' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Disabled' + } + } +} +``` + +### Example 5 + +```powershell +# In workflow definition (HTML formatted message): +@{ + Name = 'Enable Out of Office with HTML' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Enabled' + MessageFormat = 'Html' + InternalMessage = '

I am out of office.

For urgent matters, contact my manager.

' + ExternalMessage = '

I am currently unavailable.

Please contact our Service Desk at servicedesk@contoso.com.

' + ExternalAudience = 'All' + } + } +} +``` + +### Example 6 + +# Host-side enrichment (example): +# $user = Get-ADUser -Identity 'max.power' -Properties Manager +# $mgr = if ($user.Manager) \{ +# Get-ADUser -Identity $user.Manager -Properties DisplayName, Mail +# \} else \{ +# # Fallback manager/contact to avoid null template values +# [pscustomobject]@\{ +# DisplayName = 'Service Desk' +# Mail = 'servicedesk@contoso.com' +# \} +# \} +# $req = New-IdleRequest -LifecycleEvent 'Leaver' -Actor $env:USERNAME -Intent @\{ +# Manager = @\{ DisplayName = $mgr.DisplayName; Mail = $mgr.Mail \} +# \} + +# Workflow step with template variables: +@\{ + Name = 'Set OOF with Manager Contact' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @\{ + Provider = 'ExchangeOnline' + IdentityKey = 'max.power@contoso.com' + Config = @\{ + Mode = 'Enabled' + InternalMessage = 'This mailbox is no longer monitored. Please contact \{\{Request.Intent.Manager.DisplayName\}\} (\{\{Request.Intent.Manager.Mail\}\}).' + ExternalMessage = 'This mailbox is no longer monitored. Please contact \{\{Request.Intent.Manager.Mail\}\}.' + ExternalAudience = 'All' + \} + \} +\} + +```powershell +# Template usage with dynamic manager attributes (Leaver scenario): +# Note: Templates are resolved during planning against the request object. +# Host must enrich request.Intent with manager data before calling New-IdlePlan. +``` + ## See Also - [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities diff --git a/docs/reference/steps/step-mailbox-ensure-permissions.md b/docs/reference/steps/step-mailbox-ensure-permissions.md index a118e8ab..1815a1e6 100644 --- a/docs/reference/steps/step-mailbox-ensure-permissions.md +++ b/docs/reference/steps/step-mailbox-ensure-permissions.md @@ -63,14 +63,56 @@ The following keys are required in the step's ``With`` configuration: ## Example +### Example 1 + +```powershell +# In workflow definition (grant FullAccess and SendAs): +@{ + Name = 'Set Shared Mailbox Permissions' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'shared@contoso.com' + Permissions = @( + @{ AssignedUser = 'user1@contoso.com'; Right = 'FullAccess'; Ensure = 'Present' } + @{ AssignedUser = 'user2@contoso.com'; Right = 'SendAs'; Ensure = 'Present' } + ) + } +} +``` + +### Example 2 + +```powershell +# In workflow definition (revoke access): +@{ + Name = 'Revoke Mailbox Access' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'shared@contoso.com' + Permissions = @( + @{ AssignedUser = 'leaver@contoso.com'; Right = 'FullAccess'; Ensure = 'Absent' } + @{ AssignedUser = 'leaver@contoso.com'; Right = 'SendOnBehalf'; Ensure = 'Absent' } + ) + } +} +``` + +### Example 3 + ```powershell +# With dynamic identity from request: @{ - Name = 'IdLE.Step.Mailbox.EnsurePermissions Example' - Type = 'IdLE.Step.Mailbox.EnsurePermissions' - With = @{ - IdentityKey = 'user.name' - Permissions = '' - } + Name = 'Grant Team Mailbox Access' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'team@contoso.com' + Permissions = @( + @{ AssignedUser = @{ ValueFrom = 'Request.Intent.UserPrincipalName' }; Right = 'FullAccess'; Ensure = 'Present' } + ) + } } ``` diff --git a/docs/reference/steps/step-mailbox-ensure-type.md b/docs/reference/steps/step-mailbox-ensure-type.md index 4f89f8e9..5245e144 100644 --- a/docs/reference/steps/step-mailbox-ensure-type.md +++ b/docs/reference/steps/step-mailbox-ensure-type.md @@ -55,13 +55,15 @@ The following keys are required in the step's ``With`` configuration: ## Example ```powershell +# In workflow definition (convert to shared mailbox): @{ - Name = 'IdLE.Step.Mailbox.EnsureType Example' - Type = 'IdLE.Step.Mailbox.EnsureType' - With = @{ - IdentityKey = 'user.name' - MailboxType = '' - } + Name = 'Convert to shared mailbox' + Type = 'IdLE.Step.Mailbox.EnsureType' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + MailboxType = 'Shared' + } } ``` diff --git a/docs/reference/steps/step-mailbox-get-info.md b/docs/reference/steps/step-mailbox-get-info.md index 4e772fe7..8b9989b4 100644 --- a/docs/reference/steps/step-mailbox-get-info.md +++ b/docs/reference/steps/step-mailbox-get-info.md @@ -43,12 +43,14 @@ The following keys are required in the step's ``With`` configuration: ## Example ```powershell +# In workflow definition: @{ - Name = 'IdLE.Step.Mailbox.GetInfo Example' - Type = 'IdLE.Step.Mailbox.GetInfo' - With = @{ - IdentityKey = 'user.name' - } + Name = 'Get mailbox info' + Type = 'IdLE.Step.Mailbox.GetInfo' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + } } ``` diff --git a/docs/reference/steps/step-prune-entitlements-ensure-keep.md b/docs/reference/steps/step-prune-entitlements-ensure-keep.md index 1f8d69dd..552e0c13 100644 --- a/docs/reference/steps/step-prune-entitlements-ensure-keep.md +++ b/docs/reference/steps/step-prune-entitlements-ensure-keep.md @@ -49,6 +49,18 @@ Authentication: - ScriptBlocks in AuthSessionOptions are rejected (security boundary). +### With.* Parameters + +| Key | Required | Type | Description | +| -------------------- | -------- | ------------ | ----------- | +| IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | +| Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | +| Keep | No | array | Explicit entitlement objects to retain AND ensure are present. Each entry must have an Id property; Kind and DisplayName are optional. At least one of Keep or KeepPattern is required. | +| KeepPattern | No | string array | Wildcard strings (PowerShell -like semantics). Entitlements whose Id matches any pattern are kept but NOT ensured (patterns cannot be granted). | +| Provider | No | string | Provider alias from Context.Providers (default: Identity). | +| AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | +| AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | + ## Inputs (With.*) The required input keys could not be detected automatically. @@ -57,12 +69,23 @@ Please refer to the step description and examples for usage details. ## Example ```powershell +# Leaver workflow: remove all group memberships AND ensure the leaver retention group is present. +# With.Keep entries are both kept (not removed) and ensured (granted if missing after the prune). +# With.KeepPattern entries are kept but NOT ensured — patterns cannot be granted. @{ - Name = 'IdLE.Step.PruneEntitlementsEnsureKeep Example' - Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' - With = @{ - # See step description for available options - } + Name = 'Prune group memberships and ensure retention group (leaver)' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + Condition = @{ Equals = @{ Path = 'Request.Intent.PruneGroups'; Value = $true } } + With = @{ + IdentityKey = '{{Request.Identity.SamAccountName}}' + Provider = 'Identity' + Kind = 'Group' + Keep = @( + @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' } + ) + KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') + AuthSessionName = 'Directory' + } } ``` diff --git a/docs/reference/steps/step-prune-entitlements.md b/docs/reference/steps/step-prune-entitlements.md index b683abc2..008e428b 100644 --- a/docs/reference/steps/step-prune-entitlements.md +++ b/docs/reference/steps/step-prune-entitlements.md @@ -47,6 +47,18 @@ Authentication: - ScriptBlocks in AuthSessionOptions are rejected (security boundary). +### With.* Parameters + +| Key | Required | Type | Description | +| -------------------- | -------- | ------------ | ----------- | +| IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | +| Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | +| Keep | No | array | Explicit entitlement objects to retain. Each entry must have an Id property; Kind and DisplayName are optional. At least one of Keep or KeepPattern is required. | +| KeepPattern | No | string array | Wildcard strings (PowerShell -like semantics). Entitlements whose Id matches any pattern are kept. No regex or ScriptBlocks. | +| Provider | No | string | Provider alias from Context.Providers (default: Identity). | +| AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | +| AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | + ## Inputs (With.*) The following keys are required in the step's ``With`` configuration: @@ -59,13 +71,22 @@ The following keys are required in the step's ``With`` configuration: ## Example ```powershell +# Leaver workflow: remove all group memberships, keeping an explicit group and pattern matches. +# This is remove-only. Use IdLE.Step.PruneEntitlementsEnsureKeep to also grant missing Keep entries. @{ - Name = 'IdLE.Step.PruneEntitlements Example' - Type = 'IdLE.Step.PruneEntitlements' - With = @{ - IdentityKey = 'user.name' - Kind = '' - } + Name = 'Prune group memberships (leaver)' + Type = 'IdLE.Step.PruneEntitlements' + Condition = @{ Equals = @{ Path = 'Request.Intent.PruneGroups'; Value = $true } } + With = @{ + IdentityKey = '{{Request.Identity.SamAccountName}}' + Provider = 'Identity' + Kind = 'Group' + Keep = @( + @{ Kind = 'Group'; Id = 'CN=All-Users,OU=Groups,DC=contoso,DC=com' } + ) + KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') + AuthSessionName = 'Directory' + } } ``` diff --git a/docs/reference/steps/step-revoke-identity-sessions.md b/docs/reference/steps/step-revoke-identity-sessions.md index a0a6f861..e2a2ac3c 100644 --- a/docs/reference/steps/step-revoke-identity-sessions.md +++ b/docs/reference/steps/step-revoke-identity-sessions.md @@ -49,12 +49,16 @@ The following keys are required in the step's ``With`` configuration: ## Example ```powershell +# In a workflow definition (PSD1): @{ - Name = 'IdLE.Step.RevokeIdentitySessions Example' - Type = 'IdLE.Step.RevokeIdentitySessions' - With = @{ - IdentityKey = 'user.name' - } + Name = 'Revoke Entra sessions' + Type = 'IdLE.Step.RevokeIdentitySessions' + With = @{ + Provider = 'Entra' + IdentityKey = 'max.power@contoso.com' + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + } } ``` diff --git a/docs/reference/steps/step-trigger-directory-sync.md b/docs/reference/steps/step-trigger-directory-sync.md index f3f01743..f4797ae9 100644 --- a/docs/reference/steps/step-trigger-directory-sync.md +++ b/docs/reference/steps/step-trigger-directory-sync.md @@ -46,13 +46,14 @@ The following keys are required in the step's ``With`` configuration: ## Example ```powershell -@{ - Name = 'IdLE.Step.TriggerDirectorySync Example' - Type = 'IdLE.Step.TriggerDirectorySync' - With = @{ - AuthSessionName = 'AdminSession' - PolicyType = 'Delta' - } +$step = @{ + Name = 'Trigger directory sync' + Type = 'IdLE.Step.TriggerDirectorySync' + With = @{ + AuthSessionName = 'DirectorySync' + PolicyType = 'Delta' + Wait = $true + } } ``` diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 index 2b27ca10..94a8f9d5 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -31,6 +31,18 @@ function Invoke-IdleStepPruneEntitlements { session selection (e.g., @{ Role = 'Tier0' }). - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + ### With.* Parameters + + | Key | Required | Type | Description | + | -------------------- | -------- | ------------ | ----------- | + | IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | + | Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | + | Keep | No | array | Explicit entitlement objects to retain. Each entry must have an Id property; Kind and DisplayName are optional. At least one of Keep or KeepPattern is required. | + | KeepPattern | No | string array | Wildcard strings (PowerShell -like semantics). Entitlements whose Id matches any pattern are kept. No regex or ScriptBlocks. | + | Provider | No | string | Provider alias from Context.Providers (default: Identity). | + | AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | + | AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | + .PARAMETER Context Execution context created by IdLE.Core. @@ -38,17 +50,21 @@ function Invoke-IdleStepPruneEntitlements { Normalized step object from the plan. Must contain a 'With' hashtable. .EXAMPLE - Invoke-IdleStepPruneEntitlements -Context $context -Step [pscustomobject]@{ - Name = 'Prune group memberships (leaver)' - Type = 'IdLE.Step.PruneEntitlements' - With = @{ - IdentityKey = 'jsmith' - Provider = 'Identity' - Kind = 'Group' - Keep = @( - @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' } + # Leaver workflow: remove all group memberships, keeping an explicit group and pattern matches. + # This is remove-only. Use IdLE.Step.PruneEntitlementsEnsureKeep to also grant missing Keep entries. + @{ + Name = 'Prune group memberships (leaver)' + Type = 'IdLE.Step.PruneEntitlements' + Condition = @{ Equals = @{ Path = 'Request.Intent.PruneGroups'; Value = $true } } + With = @{ + IdentityKey = '{{Request.Identity.SamAccountName}}' + Provider = 'Identity' + Kind = 'Group' + Keep = @( + @{ Kind = 'Group'; Id = 'CN=All-Users,OU=Groups,DC=contoso,DC=com' } ) - KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') + KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') + AuthSessionName = 'Directory' } } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 index cf748915..1298644b 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 @@ -32,6 +32,18 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { session selection (e.g., @{ Role = 'Tier0' }). - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + ### With.* Parameters + + | Key | Required | Type | Description | + | -------------------- | -------- | ------------ | ----------- | + | IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | + | Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | + | Keep | No | array | Explicit entitlement objects to retain AND ensure are present. Each entry must have an Id property; Kind and DisplayName are optional. At least one of Keep or KeepPattern is required. | + | KeepPattern | No | string array | Wildcard strings (PowerShell -like semantics). Entitlements whose Id matches any pattern are kept but NOT ensured (patterns cannot be granted). | + | Provider | No | string | Provider alias from Context.Providers (default: Identity). | + | AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | + | AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | + .PARAMETER Context Execution context created by IdLE.Core. @@ -39,17 +51,22 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { Normalized step object from the plan. Must contain a 'With' hashtable. .EXAMPLE - Invoke-IdleStepPruneEntitlementsEnsureKeep -Context $context -Step [pscustomobject]@{ - Name = 'Prune group memberships and ensure leaver group (leaver)' - Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' - With = @{ - IdentityKey = 'jsmith' - Provider = 'Identity' - Kind = 'Group' - Keep = @( + # Leaver workflow: remove all group memberships AND ensure the leaver retention group is present. + # With.Keep entries are both kept (not removed) and ensured (granted if missing after the prune). + # With.KeepPattern entries are kept but NOT ensured — patterns cannot be granted. + @{ + Name = 'Prune group memberships and ensure retention group (leaver)' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + Condition = @{ Equals = @{ Path = 'Request.Intent.PruneGroups'; Value = $true } } + With = @{ + IdentityKey = '{{Request.Identity.SamAccountName}}' + Provider = 'Identity' + Kind = 'Group' + Keep = @( @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' } ) - KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') + KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') + AuthSessionName = 'Directory' } } diff --git a/tools/Generate-IdleStepReference.ps1 b/tools/Generate-IdleStepReference.ps1 index 6d6e8572..36ebf156 100644 --- a/tools/Generate-IdleStepReference.ps1 +++ b/tools/Generate-IdleStepReference.ps1 @@ -507,6 +507,46 @@ function New-IdleStepDocModel { $slug = ConvertTo-IdleStepSlug -StepType $stepType + # Extract workflow-style examples from comment-based help. + # Only workflow-style examples (not starting with 'Invoke-') are included; these map + # directly to what a workflow author would write in a PSD1 step definition. + $examples = @() + if ($null -ne $help -and + ($help.PSObject.Properties.Name -contains 'Examples') -and + $null -ne $help.Examples -and + ($help.Examples.PSObject.Properties.Name -contains 'Example') -and + $null -ne $help.Examples.Example) { + foreach ($ex in @($help.Examples.Example)) { + $codeText = '' + if ($null -ne $ex -and ($ex.PSObject.Properties.Name -contains 'Code') -and $null -ne $ex.Code) { + $codeText = ($ex.Code | Out-String).Trim() + } + # Skip function-call-style examples (e.g. "Invoke-IdleStep... -Context $ctx ..."). + # Only include workflow hashtable / variable-assignment style examples. + if ([string]::IsNullOrWhiteSpace($codeText) -or $codeText.TrimStart().StartsWith('Invoke-')) { + continue + } + $remarksText = '' + if ($null -ne $ex -and ($ex.PSObject.Properties.Name -contains 'Remarks') -and $null -ne $ex.Remarks) { + $remarksText = ((@($ex.Remarks) | ForEach-Object { + $r = $_ + if ($null -ne $r -and ($r.PSObject.Properties.Name -contains 'Text') -and $null -ne $r.Text) { + [string]$r.Text + } else { + '' + } + }) -join "`n").Trim() + if (-not [string]::IsNullOrWhiteSpace($remarksText)) { + $remarksText = ConvertTo-IdleMdxSafeText -Text $remarksText + } + } + $examples += [pscustomobject]@{ + Code = $codeText + Remarks = $remarksText + } + } + } + return [pscustomobject]@{ StepType = $stepType Slug = $slug @@ -517,6 +557,7 @@ function New-IdleStepDocModel { RequiredWithKeys = $requiredWithKeys Idempotent = $idempotent RequiredCapabilities = $requiredCapabilities + Examples = $examples } } @@ -603,44 +644,67 @@ function New-IdleStepDetailPageContent { # Add examples section [void]$sb.AppendLine('## Example') [void]$sb.AppendLine() - [void]$sb.AppendLine('```powershell') - [void]$sb.AppendLine('@{') - [void]$sb.AppendLine((" Name = '{0} Example'" -f $Model.StepType)) - # StepType already includes the full name (e.g., 'IdLE.Step.Mailbox.EnsureType') - [void]$sb.AppendLine((" Type = '{0}'" -f $Model.StepType)) - [void]$sb.AppendLine(' With = @{') - - if ($Model.RequiredWithKeys.Count -gt 0) { - foreach ($k in $Model.RequiredWithKeys) { - $exampleValue = switch ($k) { - 'IdentityKey' { '''user.name''' } - 'Name' { '''AttributeName''' } - 'Value' { '''AttributeValue''' } - 'Attributes' { "@{ GivenName = 'First'; Surname = 'Last' }" } - 'DestinationPath' { '''OU=Users,DC=domain,DC=com''' } - 'Message' { '''Custom event message''' } - 'EntitlementType' { '''Group''' } - 'EntitlementValue' { '''CN=GroupName,OU=Groups,DC=domain,DC=com''' } - 'Entitlement' { "@{ Kind = 'Group'; Id = 'GroupId'; DisplayName = 'Example Group' }" } - 'State' { '''Present''' } - 'Ensure' { '''Present''' } - 'Provider' { '''Identity''' } - 'AuthSessionName' { '''AdminSession''' } - 'PolicyType' { '''Delta''' } - 'Wait' { '$true' } - default { '''''' } + + if ($Model.Examples -and $Model.Examples.Count -gt 0) { + # Use workflow-style examples extracted from comment-based help. + $exIdx = 0 + foreach ($ex in $Model.Examples) { + $exIdx++ + if ($Model.Examples.Count -gt 1) { + [void]$sb.AppendLine("### Example $exIdx") + [void]$sb.AppendLine() + } + if (-not [string]::IsNullOrWhiteSpace($ex.Remarks)) { + [void]$sb.AppendLine($ex.Remarks) + [void]$sb.AppendLine() } - [void]$sb.AppendLine((" {0,-20} = {1}" -f $k, $exampleValue)) + [void]$sb.AppendLine('```powershell') + [void]$sb.AppendLine($ex.Code) + [void]$sb.AppendLine('```') + [void]$sb.AppendLine() } } else { - [void]$sb.AppendLine(' # See step description for available options') + # Auto-generated fallback from detected required With.* keys. + [void]$sb.AppendLine('```powershell') + [void]$sb.AppendLine('@{') + [void]$sb.AppendLine((" Name = '{0} Example'" -f $Model.StepType)) + # StepType already includes the full name (e.g., 'IdLE.Step.Mailbox.EnsureType') + [void]$sb.AppendLine((" Type = '{0}'" -f $Model.StepType)) + [void]$sb.AppendLine(' With = @{') + + if ($Model.RequiredWithKeys.Count -gt 0) { + foreach ($k in $Model.RequiredWithKeys) { + $exampleValue = switch ($k) { + 'IdentityKey' { '''user.name''' } + 'Name' { '''AttributeName''' } + 'Value' { '''AttributeValue''' } + 'Attributes' { "@{ GivenName = 'First'; Surname = 'Last' }" } + 'DestinationPath' { '''OU=Users,DC=domain,DC=com''' } + 'Message' { '''Custom event message''' } + 'EntitlementType' { '''Group''' } + 'EntitlementValue' { '''CN=GroupName,OU=Groups,DC=domain,DC=com''' } + 'Entitlement' { "@{ Kind = 'Group'; Id = 'GroupId'; DisplayName = 'Example Group' }" } + 'State' { '''Present''' } + 'Ensure' { '''Present''' } + 'Provider' { '''Identity''' } + 'AuthSessionName' { '''AdminSession''' } + 'PolicyType' { '''Delta''' } + 'Wait' { '$true' } + default { '''''' } + } + [void]$sb.AppendLine((" {0,-20} = {1}" -f $k, $exampleValue)) + } + } + else { + [void]$sb.AppendLine(' # See step description for available options') + } + + [void]$sb.AppendLine(' }') + [void]$sb.AppendLine('}') + [void]$sb.AppendLine('```') + [void]$sb.AppendLine() } - - [void]$sb.AppendLine(' }') - [void]$sb.AppendLine('}') - [void]$sb.AppendLine('```') - [void]$sb.AppendLine() # Add "See Also" section for consistency across all step pages [void]$sb.AppendLine('## See Also') From 36384fa48ef8d4a6512d98067c12f8a890b189b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 08:41:35 +0000 Subject: [PATCH 15/27] Differentiate PruneEntitlements vs PruneEntitlementsEnsureKeep docs: distinct synopsis, description, examples and Keep/KeepPattern behaviour callouts Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/steps.md | 4 +- .../step-prune-entitlements-ensure-keep.md | 98 ++++++++++++++----- .../steps/step-prune-entitlements.md | 87 +++++++++++----- .../Invoke-IdleStepPruneEntitlements.ps1 | 78 ++++++++++----- ...ke-IdleStepPruneEntitlementsEnsureKeep.ps1 | 90 ++++++++++++----- 5 files changed, 251 insertions(+), 106 deletions(-) diff --git a/docs/reference/steps.md b/docs/reference/steps.md index a675f86b..a7c912bc 100644 --- a/docs/reference/steps.md +++ b/docs/reference/steps.md @@ -17,7 +17,7 @@ | [IdLE.Step.Mailbox.EnsureType](steps/step-mailbox-ensure-type.md) | ``IdLE.Steps.Mailbox`` | Ensures that a mailbox is of the desired type (User, Shared, Room, Equipment). | | [IdLE.Step.Mailbox.GetInfo](steps/step-mailbox-get-info.md) | ``IdLE.Steps.Mailbox`` | Retrieves mailbox details and returns a structured report. | | [IdLE.Step.MoveIdentity](steps/step-move-identity.md) | ``IdLE.Steps.Common`` | Moves an identity to a different container/OU in the target system. | -| [IdLE.Step.PruneEntitlements](steps/step-prune-entitlements.md) | ``IdLE.Steps.Common`` | Converges an identity's entitlements by removing all non-kept entitlements of a given kind. | -| [IdLE.Step.PruneEntitlementsEnsureKeep](steps/step-prune-entitlements-ensure-keep.md) | ``IdLE.Steps.Common`` | Converges an identity's entitlements by removing all non-kept entitlements and ensuring kept ones are present. | +| [IdLE.Step.PruneEntitlements](steps/step-prune-entitlements.md) | ``IdLE.Steps.Common`` | Removes all non-kept entitlements of a given kind from an identity. Remove-only — does not grant anything. | +| [IdLE.Step.PruneEntitlementsEnsureKeep](steps/step-prune-entitlements-ensure-keep.md) | ``IdLE.Steps.Common`` | Removes all non-kept entitlements and GUARANTEES explicit Keep entries are present (grants if missing). | | [IdLE.Step.RevokeIdentitySessions](steps/step-revoke-identity-sessions.md) | ``IdLE.Steps.Common`` | Revokes all active sign-in sessions for an identity in the target system. | | [IdLE.Step.TriggerDirectorySync](steps/step-trigger-directory-sync.md) | ``IdLE.Steps.DirectorySync`` | Triggers a directory sync cycle and optionally waits for completion. | diff --git a/docs/reference/steps/step-prune-entitlements-ensure-keep.md b/docs/reference/steps/step-prune-entitlements-ensure-keep.md index 552e0c13..1e21dc60 100644 --- a/docs/reference/steps/step-prune-entitlements-ensure-keep.md +++ b/docs/reference/steps/step-prune-entitlements-ensure-keep.md @@ -12,42 +12,55 @@ ## Synopsis -Converges an identity's entitlements by removing all non-kept entitlements and ensuring kept ones are present. +Removes all non-kept entitlements and GUARANTEES explicit Keep entries are present (grants if missing). ## Description -This provider-agnostic step implements "remove all except … and ensure those are present" semantics for -entitlements. It is intended for leaver and mover workflows where all entitlements of a given kind -(e.g. group memberships) must be removed except for an explicit keep-set, and the kept entitlements -must be guaranteed to be present. +*** REMOVE + ENSURE step. This step REMOVES non-kept entitlements AND GRANTS missing Keep entries. *** -This step always grants any explicit Keep items that are not yet present. Use IdLE.Step.PruneEntitlements -when you only need removal without the ensure-grant phase. +Use this step when you want to: -The host must supply a provider that: +1. Strip all entitlements of a given kind (e.g., all group memberships), AND -- Advertises the IdLE.Entitlement.Prune capability (explicit opt-in) +2. Guarantee that specific entitlements from With.Keep are present afterwards — even if they were + not present before the step ran (they will be granted). -- Implements ListEntitlements(identityKey) +Use IdLE.Step.PruneEntitlements instead when you only need removal and do NOT need any grants +(e.g., cleanup-only without a mandatory retention group). -- Implements RevokeEntitlement(identityKey, entitlement) +Key behavioral difference vs PruneEntitlements — how With.Keep and With.KeepPattern behave: -- Implements GrantEntitlement(identityKey, entitlement) + With.Keep entries → kept (NOT removed) AND ensured (GRANTED if currently missing) + With.KeepPattern → kept (NOT removed) but NOT ensured (patterns cannot be granted) -Provider/system non-removable entitlements (e.g., AD primary group / Domain Users) are -handled safely: if a revoke operation fails, the step emits a structured warning event, -skips the entitlement, and continues. The workflow is not failed for these items. +This means after this step completes, every identity referenced by a With.Keep entry is +guaranteed to hold that entitlement — regardless of whether it was already present or not. +Pattern-matched entitlements that were already present are kept, but the step does not +search for or grant patterns that are not yet present. + +At least one of With.Keep or With.KeepPattern must be supplied. + +Provider contract: + +- Must advertise the IdLE.Entitlement.Prune capability (explicit opt-in) + +- Must implement ListEntitlements(identityKey) + +- Must implement RevokeEntitlement(identityKey, entitlement) + +- Must implement GrantEntitlement(identityKey, entitlement) ← required; absent in PruneEntitlements + +Non-removable entitlements (e.g., AD primary group / Domain Users) are handled safely: if a revoke +operation fails, the step emits a structured warning event, records the item as Skipped, and +continues. The workflow is not failed. Authentication: - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to provider methods - if the provider supports an AuthSession parameter. - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @\{ Role = 'Tier0' \}). + Context.AcquireAuthSession(Name, Options) and passes it to provider methods. -- ScriptBlocks in AuthSessionOptions are rejected (security boundary). +- With.AuthSessionOptions (optional, hashtable) is passed to the broker for session selection + (e.g., @\{ Role = 'Tier0' \}). ScriptBlocks in AuthSessionOptions are rejected. ### With.* Parameters @@ -55,8 +68,8 @@ Authentication: | -------------------- | -------- | ------------ | ----------- | | IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | | Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | -| Keep | No | array | Explicit entitlement objects to retain AND ensure are present. Each entry must have an Id property; Kind and DisplayName are optional. At least one of Keep or KeepPattern is required. | -| KeepPattern | No | string array | Wildcard strings (PowerShell -like semantics). Entitlements whose Id matches any pattern are kept but NOT ensured (patterns cannot be granted). | +| Keep | No* | array | Explicit entitlement objects to retain AND ensure are present. Each entry must have an Id property; Kind and DisplayName are optional. **These entries are GRANTED if missing after the prune.** *At least one of Keep or KeepPattern is required. | +| KeepPattern | No* | string array | Wildcard strings (PowerShell -like semantics). Current entitlements whose Id matches any pattern are kept but NOT ensured — patterns cannot be granted. *At least one of Keep or KeepPattern is required. | | Provider | No | string | Provider alias from Context.Providers (default: Identity). | | AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | | AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | @@ -68,27 +81,58 @@ Please refer to the step description and examples for usage details. ## Example +### Example 1 + ```powershell -# Leaver workflow: remove all group memberships AND ensure the leaver retention group is present. -# With.Keep entries are both kept (not removed) and ensured (granted if missing after the prune). -# With.KeepPattern entries are kept but NOT ensured — patterns cannot be granted. +# Leaver workflow: remove ALL group memberships AND guarantee the identity is in the leaver-retention +# group. The retention group is both protected from removal AND granted if it is currently missing. +# This is the most common leaver scenario — contrast with PruneEntitlements (remove-only, no grants). +# +# After this step: +# - CN=LEAVER-RETAIN,... is present (kept + granted if it was missing) +# - CN=LEAVER-*,... are present (kept if they were already there; not granted if missing) +# - All other groups are removed @{ - Name = 'Prune group memberships and ensure retention group (leaver)' + Name = 'Prune groups and ensure leaver-retention group (leaver)' Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' Condition = @{ Equals = @{ Path = 'Request.Intent.PruneGroups'; Value = $true } } With = @{ IdentityKey = '{{Request.Identity.SamAccountName}}' Provider = 'Identity' Kind = 'Group' + # KEPT + GRANTED if missing: after the step, the identity is guaranteed to be a member. Keep = @( @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' } ) + # KEPT but NOT granted: already-present LEAVER-* groups are preserved; absent ones are not added. KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') AuthSessionName = 'Directory' } } ``` +### Example 2 + +```powershell +# Mover workflow: strip all license assignments except a baseline license, and guarantee the +# identity holds the baseline even if it was somehow removed before this step runs. +@{ + Name = 'Reset license assignments to baseline (mover)' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + Condition = @{ Equals = @{ Path = 'Request.Intent.ResetLicenses'; Value = $true } } + With = @{ + IdentityKey = '{{Request.Identity.UserPrincipalName}}' + Provider = 'Licensing' + Kind = 'License' + # This license is KEPT and GRANTED if missing — always present after this step. + Keep = @( + @{ Kind = 'License'; Id = 'BASELINE-E1' } + ) + AuthSessionName = 'Licensing' + } +} +``` + ## See Also - [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities diff --git a/docs/reference/steps/step-prune-entitlements.md b/docs/reference/steps/step-prune-entitlements.md index 008e428b..3b5b3f66 100644 --- a/docs/reference/steps/step-prune-entitlements.md +++ b/docs/reference/steps/step-prune-entitlements.md @@ -12,40 +12,51 @@ ## Synopsis -Converges an identity's entitlements by removing all non-kept entitlements of a given kind. +Removes all non-kept entitlements of a given kind from an identity. Remove-only — does not grant anything. ## Description -This provider-agnostic step implements "remove all except" semantics for entitlements. -It is intended for leaver and mover workflows where all entitlements of a given kind -(e.g. group memberships) must be removed, except for an explicit keep-set and/or -entitlements matching a wildcard keep pattern. +*** REMOVE-ONLY step. This step NEVER grants entitlements. *** -This step is remove-only. Use IdLE.Step.PruneEntitlementsEnsureKeep when you also need -to guarantee that explicit Keep entitlements are present after the prune. +Use this step when you want to strip an identity of all entitlements of a given kind (e.g., all group +memberships) and you do NOT need to guarantee that any specific entitlement is actually present +afterwards. The step reads the current entitlements once, computes the remove-set (all entitlements +that are NOT in the keep-set), and revokes each one individually. -The host must supply a provider that: +Use IdLE.Step.PruneEntitlementsEnsureKeep instead when you also need to guarantee that one or more +explicit Keep entries are present after the prune (e.g., a leaver-retention group must be granted +if it is missing). -- Advertises the IdLE.Entitlement.Prune capability (explicit opt-in) +How the keep-set is built: -- Implements ListEntitlements(identityKey) +- With.Keep — explicit entitlement references (kept AND matched case-insensitively by Id) -- Implements RevokeEntitlement(identityKey, entitlement) +- With.KeepPattern — wildcard strings (-like semantics); any current entitlement whose Id matches + is kept. Patterns are NEVER granted, only protected from removal. -Provider/system non-removable entitlements (e.g., AD primary group / Domain Users) are -handled safely: if a revoke operation fails, the step emits a structured warning event, -skips the entitlement, and continues. The workflow is not failed for these items. +At least one of With.Keep or With.KeepPattern must be supplied. + +Provider contract: + +- Must advertise the IdLE.Entitlement.Prune capability (explicit opt-in) + +- Must implement ListEntitlements(identityKey) + +- Must implement RevokeEntitlement(identityKey, entitlement) + +- GrantEntitlement is NOT called by this step. + +Non-removable entitlements (e.g., AD primary group / Domain Users) are handled safely: if a revoke +operation fails, the step emits a structured warning event, records the item as Skipped, and +continues. The workflow is not failed. Authentication: - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to provider methods - if the provider supports an AuthSession parameter. - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @\{ Role = 'Tier0' \}). + Context.AcquireAuthSession(Name, Options) and passes it to provider methods. -- ScriptBlocks in AuthSessionOptions are rejected (security boundary). +- With.AuthSessionOptions (optional, hashtable) is passed to the broker for session selection + (e.g., @\{ Role = 'Tier0' \}). ScriptBlocks in AuthSessionOptions are rejected. ### With.* Parameters @@ -53,8 +64,8 @@ Authentication: | -------------------- | -------- | ------------ | ----------- | | IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | | Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | -| Keep | No | array | Explicit entitlement objects to retain. Each entry must have an Id property; Kind and DisplayName are optional. At least one of Keep or KeepPattern is required. | -| KeepPattern | No | string array | Wildcard strings (PowerShell -like semantics). Entitlements whose Id matches any pattern are kept. No regex or ScriptBlocks. | +| Keep | No* | array | Explicit entitlement objects to retain (kept, never removed). Each entry must have an Id property; Kind and DisplayName are optional. *At least one of Keep or KeepPattern is required. These entries are NOT granted — use PruneEntitlementsEnsureKeep for that. | +| KeepPattern | No* | string array | Wildcard strings (PowerShell -like semantics). Current entitlements whose Id matches any pattern are kept. Patterns are NEVER granted. *At least one of Keep or KeepPattern is required. | | Provider | No | string | Provider alias from Context.Providers (default: Identity). | | AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | | AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | @@ -70,11 +81,36 @@ The following keys are required in the step's ``With`` configuration: ## Example +### Example 1 + +```powershell +# Mover workflow: strip all role assignments except those matching a wildcard pattern. +# REMOVE-ONLY — no groups are granted. If you also need to ensure a specific role is present, +# use IdLE.Step.PruneEntitlementsEnsureKeep instead. +@{ + Name = 'Strip role assignments (mover)' + Type = 'IdLE.Step.PruneEntitlements' + Condition = @{ Equals = @{ Path = 'Request.Intent.StripRoles'; Value = $true } } + With = @{ + IdentityKey = '{{Request.Identity.UserPrincipalName}}' + Provider = 'Identity' + Kind = 'Role' + # Keep any role whose Id matches this pattern — everything else is removed. + # No entitlements are granted; this is a cleanup-only operation. + KeepPattern = @('ROLE-READONLY-*') + AuthSessionName = 'Directory' + } +} +``` + +### Example 2 + ```powershell -# Leaver workflow: remove all group memberships, keeping an explicit group and pattern matches. -# This is remove-only. Use IdLE.Step.PruneEntitlementsEnsureKeep to also grant missing Keep entries. +# Leaver workflow: remove all group memberships except a static keep-list. +# The identity will NOT be added to any group — only existing memberships outside the keep-list +# are removed. For a guaranteed leaver-retention group use PruneEntitlementsEnsureKeep. @{ - Name = 'Prune group memberships (leaver)' + Name = 'Remove group memberships (leaver, remove-only)' Type = 'IdLE.Step.PruneEntitlements' Condition = @{ Equals = @{ Path = 'Request.Intent.PruneGroups'; Value = $true } } With = @{ @@ -82,6 +118,7 @@ The following keys are required in the step's ``With`` configuration: Provider = 'Identity' Kind = 'Group' Keep = @( + # Kept if currently a member — but NOT granted if missing. @{ Kind = 'Group'; Id = 'CN=All-Users,OU=Groups,DC=contoso,DC=com' } ) KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 index 94a8f9d5..950dda9b 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -1,35 +1,42 @@ function Invoke-IdleStepPruneEntitlements { <# .SYNOPSIS - Converges an identity's entitlements by removing all non-kept entitlements of a given kind. + Removes all non-kept entitlements of a given kind from an identity. Remove-only — does not grant anything. .DESCRIPTION - This provider-agnostic step implements "remove all except" semantics for entitlements. - It is intended for leaver and mover workflows where all entitlements of a given kind - (e.g. group memberships) must be removed, except for an explicit keep-set and/or - entitlements matching a wildcard keep pattern. + *** REMOVE-ONLY step. This step NEVER grants entitlements. *** - This step is remove-only. Use IdLE.Step.PruneEntitlementsEnsureKeep when you also need - to guarantee that explicit Keep entitlements are present after the prune. + Use this step when you want to strip an identity of all entitlements of a given kind (e.g., all group + memberships) and you do NOT need to guarantee that any specific entitlement is actually present + afterwards. The step reads the current entitlements once, computes the remove-set (all entitlements + that are NOT in the keep-set), and revokes each one individually. - The host must supply a provider that: + Use IdLE.Step.PruneEntitlementsEnsureKeep instead when you also need to guarantee that one or more + explicit Keep entries are present after the prune (e.g., a leaver-retention group must be granted + if it is missing). - - Advertises the IdLE.Entitlement.Prune capability (explicit opt-in) - - Implements ListEntitlements(identityKey) - - Implements RevokeEntitlement(identityKey, entitlement) + How the keep-set is built: + - With.Keep — explicit entitlement references (kept AND matched case-insensitively by Id) + - With.KeepPattern — wildcard strings (-like semantics); any current entitlement whose Id matches + is kept. Patterns are NEVER granted, only protected from removal. - Provider/system non-removable entitlements (e.g., AD primary group / Domain Users) are - handled safely: if a revoke operation fails, the step emits a structured warning event, - skips the entitlement, and continues. The workflow is not failed for these items. + At least one of With.Keep or With.KeepPattern must be supplied. - Authentication: + Provider contract: + - Must advertise the IdLE.Entitlement.Prune capability (explicit opt-in) + - Must implement ListEntitlements(identityKey) + - Must implement RevokeEntitlement(identityKey, entitlement) + - GrantEntitlement is NOT called by this step. + + Non-removable entitlements (e.g., AD primary group / Domain Users) are handled safely: if a revoke + operation fails, the step emits a structured warning event, records the item as Skipped, and + continues. The workflow is not failed. + Authentication: - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to provider methods - if the provider supports an AuthSession parameter. - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @{ Role = 'Tier0' }). - - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + Context.AcquireAuthSession(Name, Options) and passes it to provider methods. + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for session selection + (e.g., @{ Role = 'Tier0' }). ScriptBlocks in AuthSessionOptions are rejected. ### With.* Parameters @@ -37,8 +44,8 @@ function Invoke-IdleStepPruneEntitlements { | -------------------- | -------- | ------------ | ----------- | | IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | | Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | - | Keep | No | array | Explicit entitlement objects to retain. Each entry must have an Id property; Kind and DisplayName are optional. At least one of Keep or KeepPattern is required. | - | KeepPattern | No | string array | Wildcard strings (PowerShell -like semantics). Entitlements whose Id matches any pattern are kept. No regex or ScriptBlocks. | + | Keep | No* | array | Explicit entitlement objects to retain (kept, never removed). Each entry must have an Id property; Kind and DisplayName are optional. *At least one of Keep or KeepPattern is required. These entries are NOT granted — use PruneEntitlementsEnsureKeep for that. | + | KeepPattern | No* | string array | Wildcard strings (PowerShell -like semantics). Current entitlements whose Id matches any pattern are kept. Patterns are NEVER granted. *At least one of Keep or KeepPattern is required. | | Provider | No | string | Provider alias from Context.Providers (default: Identity). | | AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | | AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | @@ -50,10 +57,30 @@ function Invoke-IdleStepPruneEntitlements { Normalized step object from the plan. Must contain a 'With' hashtable. .EXAMPLE - # Leaver workflow: remove all group memberships, keeping an explicit group and pattern matches. - # This is remove-only. Use IdLE.Step.PruneEntitlementsEnsureKeep to also grant missing Keep entries. + # Mover workflow: strip all role assignments except those matching a wildcard pattern. + # REMOVE-ONLY — no groups are granted. If you also need to ensure a specific role is present, + # use IdLE.Step.PruneEntitlementsEnsureKeep instead. + @{ + Name = 'Strip role assignments (mover)' + Type = 'IdLE.Step.PruneEntitlements' + Condition = @{ Equals = @{ Path = 'Request.Intent.StripRoles'; Value = $true } } + With = @{ + IdentityKey = '{{Request.Identity.UserPrincipalName}}' + Provider = 'Identity' + Kind = 'Role' + # Keep any role whose Id matches this pattern — everything else is removed. + # No entitlements are granted; this is a cleanup-only operation. + KeepPattern = @('ROLE-READONLY-*') + AuthSessionName = 'Directory' + } + } + + .EXAMPLE + # Leaver workflow: remove all group memberships except a static keep-list. + # The identity will NOT be added to any group — only existing memberships outside the keep-list + # are removed. For a guaranteed leaver-retention group use PruneEntitlementsEnsureKeep. @{ - Name = 'Prune group memberships (leaver)' + Name = 'Remove group memberships (leaver, remove-only)' Type = 'IdLE.Step.PruneEntitlements' Condition = @{ Equals = @{ Path = 'Request.Intent.PruneGroups'; Value = $true } } With = @{ @@ -61,6 +88,7 @@ function Invoke-IdleStepPruneEntitlements { Provider = 'Identity' Kind = 'Group' Keep = @( + # Kept if currently a member — but NOT granted if missing. @{ Kind = 'Group'; Id = 'CN=All-Users,OU=Groups,DC=contoso,DC=com' } ) KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 index 1298644b..323d8c15 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 @@ -1,36 +1,46 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { <# .SYNOPSIS - Converges an identity's entitlements by removing all non-kept entitlements and ensuring kept ones are present. + Removes all non-kept entitlements and GUARANTEES explicit Keep entries are present (grants if missing). .DESCRIPTION - This provider-agnostic step implements "remove all except … and ensure those are present" semantics for - entitlements. It is intended for leaver and mover workflows where all entitlements of a given kind - (e.g. group memberships) must be removed except for an explicit keep-set, and the kept entitlements - must be guaranteed to be present. + *** REMOVE + ENSURE step. This step REMOVES non-kept entitlements AND GRANTS missing Keep entries. *** - This step always grants any explicit Keep items that are not yet present. Use IdLE.Step.PruneEntitlements - when you only need removal without the ensure-grant phase. + Use this step when you want to: + 1. Strip all entitlements of a given kind (e.g., all group memberships), AND + 2. Guarantee that specific entitlements from With.Keep are present afterwards — even if they were + not present before the step ran (they will be granted). - The host must supply a provider that: + Use IdLE.Step.PruneEntitlements instead when you only need removal and do NOT need any grants + (e.g., cleanup-only without a mandatory retention group). - - Advertises the IdLE.Entitlement.Prune capability (explicit opt-in) - - Implements ListEntitlements(identityKey) - - Implements RevokeEntitlement(identityKey, entitlement) - - Implements GrantEntitlement(identityKey, entitlement) + Key behavioral difference vs PruneEntitlements — how With.Keep and With.KeepPattern behave: - Provider/system non-removable entitlements (e.g., AD primary group / Domain Users) are - handled safely: if a revoke operation fails, the step emits a structured warning event, - skips the entitlement, and continues. The workflow is not failed for these items. + With.Keep entries → kept (NOT removed) AND ensured (GRANTED if currently missing) + With.KeepPattern → kept (NOT removed) but NOT ensured (patterns cannot be granted) - Authentication: + This means after this step completes, every identity referenced by a With.Keep entry is + guaranteed to hold that entitlement — regardless of whether it was already present or not. + Pattern-matched entitlements that were already present are kept, but the step does not + search for or grant patterns that are not yet present. + + At least one of With.Keep or With.KeepPattern must be supplied. + + Provider contract: + - Must advertise the IdLE.Entitlement.Prune capability (explicit opt-in) + - Must implement ListEntitlements(identityKey) + - Must implement RevokeEntitlement(identityKey, entitlement) + - Must implement GrantEntitlement(identityKey, entitlement) ← required; absent in PruneEntitlements + + Non-removable entitlements (e.g., AD primary group / Domain Users) are handled safely: if a revoke + operation fails, the step emits a structured warning event, records the item as Skipped, and + continues. The workflow is not failed. + Authentication: - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to provider methods - if the provider supports an AuthSession parameter. - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @{ Role = 'Tier0' }). - - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + Context.AcquireAuthSession(Name, Options) and passes it to provider methods. + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for session selection + (e.g., @{ Role = 'Tier0' }). ScriptBlocks in AuthSessionOptions are rejected. ### With.* Parameters @@ -38,8 +48,8 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { | -------------------- | -------- | ------------ | ----------- | | IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | | Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | - | Keep | No | array | Explicit entitlement objects to retain AND ensure are present. Each entry must have an Id property; Kind and DisplayName are optional. At least one of Keep or KeepPattern is required. | - | KeepPattern | No | string array | Wildcard strings (PowerShell -like semantics). Entitlements whose Id matches any pattern are kept but NOT ensured (patterns cannot be granted). | + | Keep | No* | array | Explicit entitlement objects to retain AND ensure are present. Each entry must have an Id property; Kind and DisplayName are optional. **These entries are GRANTED if missing after the prune.** *At least one of Keep or KeepPattern is required. | + | KeepPattern | No* | string array | Wildcard strings (PowerShell -like semantics). Current entitlements whose Id matches any pattern are kept but NOT ensured — patterns cannot be granted. *At least one of Keep or KeepPattern is required. | | Provider | No | string | Provider alias from Context.Providers (default: Identity). | | AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | | AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | @@ -51,25 +61,51 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { Normalized step object from the plan. Must contain a 'With' hashtable. .EXAMPLE - # Leaver workflow: remove all group memberships AND ensure the leaver retention group is present. - # With.Keep entries are both kept (not removed) and ensured (granted if missing after the prune). - # With.KeepPattern entries are kept but NOT ensured — patterns cannot be granted. + # Leaver workflow: remove ALL group memberships AND guarantee the identity is in the leaver-retention + # group. The retention group is both protected from removal AND granted if it is currently missing. + # This is the most common leaver scenario — contrast with PruneEntitlements (remove-only, no grants). + # + # After this step: + # - CN=LEAVER-RETAIN,... is present (kept + granted if it was missing) + # - CN=LEAVER-*,... are present (kept if they were already there; not granted if missing) + # - All other groups are removed @{ - Name = 'Prune group memberships and ensure retention group (leaver)' + Name = 'Prune groups and ensure leaver-retention group (leaver)' Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' Condition = @{ Equals = @{ Path = 'Request.Intent.PruneGroups'; Value = $true } } With = @{ IdentityKey = '{{Request.Identity.SamAccountName}}' Provider = 'Identity' Kind = 'Group' + # KEPT + GRANTED if missing: after the step, the identity is guaranteed to be a member. Keep = @( @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' } ) + # KEPT but NOT granted: already-present LEAVER-* groups are preserved; absent ones are not added. KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') AuthSessionName = 'Directory' } } + .EXAMPLE + # Mover workflow: strip all license assignments except a baseline license, and guarantee the + # identity holds the baseline even if it was somehow removed before this step runs. + @{ + Name = 'Reset license assignments to baseline (mover)' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + Condition = @{ Equals = @{ Path = 'Request.Intent.ResetLicenses'; Value = $true } } + With = @{ + IdentityKey = '{{Request.Identity.UserPrincipalName}}' + Provider = 'Licensing' + Kind = 'License' + # This license is KEPT and GRANTED if missing — always present after this step. + Keep = @( + @{ Kind = 'License'; Id = 'BASELINE-E1' } + ) + AuthSessionName = 'Licensing' + } + } + .OUTPUTS PSCustomObject (PSTypeName: IdLE.StepResult) #> From c3329ca46a3e304e91e248f146a6dd732ab6cceb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 08:50:20 +0000 Subject: [PATCH 16/27] Add explicit IdLE.Entitlement.Prune capability tests for AD and Mock providers; bump all three provider module versions to 0.9.6 Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- src/IdLE.Provider.AD/IdLE.Provider.AD.psd1 | 2 +- .../IdLE.Provider.EntraID.psd1 | 2 +- .../IdLE.Provider.Mock.psd1 | 2 +- tests/Providers/ADIdentityProvider.Tests.ps1 | 21 +++++++++++++++++++ .../Providers/MockIdentityProvider.Tests.ps1 | 19 +++++++++++++++++ 5 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/IdLE.Provider.AD/IdLE.Provider.AD.psd1 b/src/IdLE.Provider.AD/IdLE.Provider.AD.psd1 index 6ab17e8e..f309417c 100644 --- a/src/IdLE.Provider.AD/IdLE.Provider.AD.psd1 +++ b/src/IdLE.Provider.AD/IdLE.Provider.AD.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'IdLE.Provider.AD.psm1' - ModuleVersion = '0.9.5' + ModuleVersion = '0.9.6' GUID = '8a7f3c2e-9b4d-4e1a-a8c6-5f9d2b1e3a4c' Author = 'Matthias Fleschuetz' Copyright = '(c) Matthias Fleschuetz. All rights reserved.' diff --git a/src/IdLE.Provider.EntraID/IdLE.Provider.EntraID.psd1 b/src/IdLE.Provider.EntraID/IdLE.Provider.EntraID.psd1 index 54cac65c..eac12359 100644 --- a/src/IdLE.Provider.EntraID/IdLE.Provider.EntraID.psd1 +++ b/src/IdLE.Provider.EntraID/IdLE.Provider.EntraID.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'IdLE.Provider.EntraID.psm1' - ModuleVersion = '0.9.5' + ModuleVersion = '0.9.6' GUID = 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e' Author = 'Matthias Fleschuetz' Copyright = '(c) Matthias Fleschuetz. All rights reserved.' diff --git a/src/IdLE.Provider.Mock/IdLE.Provider.Mock.psd1 b/src/IdLE.Provider.Mock/IdLE.Provider.Mock.psd1 index fffdb5af..d06f0bc2 100644 --- a/src/IdLE.Provider.Mock/IdLE.Provider.Mock.psd1 +++ b/src/IdLE.Provider.Mock/IdLE.Provider.Mock.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'IdLE.Provider.Mock.psm1' - ModuleVersion = '0.9.5' + ModuleVersion = '0.9.6' GUID = 'e661d3d6-1797-4cb1-b173-474982dbd653' Author = 'Matthias Fleschuetz' Copyright = '(c) Matthias Fleschuetz. All rights reserved.' diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index 8e553ecc..faa975a3 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -902,6 +902,27 @@ Describe 'AD identity provider' { } } + Context 'Capabilities' { + It 'Advertises IdLE.Entitlement.Prune capability' { + $adapter = New-FakeADAdapter + $provider = New-IdleADIdentityProvider -Adapter $adapter + + $caps = $provider.GetCapabilities() + $caps | Should -Contain 'IdLE.Entitlement.Prune' + } + + It 'Advertises all expected entitlement capabilities' { + $adapter = New-FakeADAdapter + $provider = New-IdleADIdentityProvider -Adapter $adapter + + $caps = $provider.GetCapabilities() + $caps | Should -Contain 'IdLE.Entitlement.List' + $caps | Should -Contain 'IdLE.Entitlement.Grant' + $caps | Should -Contain 'IdLE.Entitlement.Revoke' + $caps | Should -Contain 'IdLE.Entitlement.Prune' + } + } + Context 'AllowDelete gating' { It 'Advertises Delete capability when AllowDelete=$true' { $adapter = New-FakeADAdapter diff --git a/tests/Providers/MockIdentityProvider.Tests.ps1 b/tests/Providers/MockIdentityProvider.Tests.ps1 index 8595262a..af353ace 100644 --- a/tests/Providers/MockIdentityProvider.Tests.ps1 +++ b/tests/Providers/MockIdentityProvider.Tests.ps1 @@ -34,4 +34,23 @@ Describe 'Mock identity provider' { Invoke-IdleProviderCapabilitiesContractTests -ProviderFactory { New-IdleMockIdentityProvider } Invoke-IdleEntitlementProviderContractTests -NewProvider { New-IdleMockIdentityProvider } } + + Context 'Capabilities' { + It 'Advertises IdLE.Entitlement.Prune capability' { + $provider = New-IdleMockIdentityProvider + + $caps = $provider.GetCapabilities() + $caps | Should -Contain 'IdLE.Entitlement.Prune' + } + + It 'Advertises all expected entitlement capabilities' { + $provider = New-IdleMockIdentityProvider + + $caps = $provider.GetCapabilities() + $caps | Should -Contain 'IdLE.Entitlement.List' + $caps | Should -Contain 'IdLE.Entitlement.Grant' + $caps | Should -Contain 'IdLE.Entitlement.Revoke' + $caps | Should -Contain 'IdLE.Entitlement.Prune' + } + } } From a4527955fc4d2841974f935886555aa9230eaf73 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:15:48 +0100 Subject: [PATCH 17/27] indent fixing --- .../Public/New-IdleADIdentityProvider.ps1 | 2 +- .../Invoke-IdleStepPruneEntitlements.ps1 | 99 +++++++++++-------- 2 files changed, 57 insertions(+), 44 deletions(-) diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index e69a06a9..24b17d26 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -290,7 +290,7 @@ function New-IdleADIdentityProvider { # Check TypeNames collection (PSTypeName in hashtable adds to TypeNames, not as a property) if ($null -eq $AuthSession) { $isRealAdapter = ($this.Adapter.PSObject.TypeNames -contains 'IdLE.ADAdapter') - + if ($isRealAdapter) { $prereqCheck = Test-IdleADPrerequisites if (-not $prereqCheck.IsHealthy) { diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 index 950dda9b..6bd868b5 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -213,12 +213,13 @@ function Invoke-IdleStepPruneEntitlements { if ($keepItems.Count -gt 0 -and $provider.PSObject.Methods.Name -contains 'ResolveEntitlement') { $resolveSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['ResolveEntitlement'] -ParameterName 'AuthSession' $keepItems = @($keepItems | ForEach-Object { - if ($resolveSupportsAuthSession -and $null -ne $authSession) { - $provider.ResolveEntitlement($kind, $_, $authSession) - } else { - $provider.ResolveEntitlement($kind, $_) + if ($resolveSupportsAuthSession -and $null -ne $authSession) { + $provider.ResolveEntitlement($kind, $_, $authSession) + } else { + $provider.ResolveEntitlement($kind, $_) + } } - }) + ) } $listSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['ListEntitlements'] -ParameterName 'AuthSession' @@ -244,11 +245,12 @@ function Invoke-IdleStepPruneEntitlements { @($provider.ListEntitlements($identityKey)) } - $current = @($allCurrent | Where-Object { - $null -ne $_ -and - ($_.PSObject.Properties.Name -contains 'Kind') -and - [string]::Equals([string]$_.Kind, $kind, [System.StringComparison]::OrdinalIgnoreCase) - }) + $current = @( + $allCurrent | Where-Object { $null -ne $_ -and + ($_.PSObject.Properties.Name -contains 'Kind') -and + [string]::Equals([string]$_.Kind, $kind, [System.StringComparison]::OrdinalIgnoreCase) + } + ) # 2. Compute keep-set and remove-set $toKeep = @() @@ -265,11 +267,13 @@ function Invoke-IdleStepPruneEntitlements { # Emit plan intent event if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { - $Context.EventSink.WriteEvent('Information', "PruneEntitlements: plan - keep=$(@($toKeep).Count), remove=$(@($toRemove).Count)", $Step.Name, @{ - Kind = $kind - KeepCount = @($toKeep).Count - PruneCount = @($toRemove).Count - }) + $Context.EventSink.WriteEvent('Information', "PruneEntitlements: plan - keep=$( + @($toKeep).Count), remove=$(@($toRemove).Count)", $Step.Name, @{ + Kind = $kind + KeepCount = @($toKeep).Count + PruneCount = @($toRemove).Count + } + ) } $changed = $false @@ -293,19 +297,21 @@ function Invoke-IdleStepPruneEntitlements { if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { $Context.EventSink.WriteEvent('Warning', "PruneEntitlements: skipped non-removable entitlement '$($br.Entitlement.Id)': $($br.Error)", $Step.Name, @{ - Kind = $kind - EntitlementId = [string]$br.Entitlement.Id - Reason = [string]$br.Error - }) + Kind = $kind + EntitlementId = [string]$br.Entitlement.Id + Reason = [string]$br.Error + } + ) } } else { if ($br.Changed) { $changed = $true } if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { $Context.EventSink.WriteEvent('Information', "PruneEntitlements: revoked entitlement '$($br.Entitlement.Id)'", $Step.Name, @{ - Kind = $kind - EntitlementId = [string]$br.Entitlement.Id - }) + Kind = $kind + EntitlementId = [string]$br.Entitlement.Id + } + ) } } } @@ -325,9 +331,10 @@ function Invoke-IdleStepPruneEntitlements { if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { $Context.EventSink.WriteEvent('Information', "PruneEntitlements: revoked entitlement '$($ent.Id)'", $Step.Name, @{ - Kind = $kind - EntitlementId = [string]$ent.Id - }) + Kind = $kind + EntitlementId = [string]$ent.Id + } + ) } } catch { @@ -341,10 +348,11 @@ function Invoke-IdleStepPruneEntitlements { if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { $Context.EventSink.WriteEvent('Warning', "PruneEntitlements: skipped non-removable entitlement '$($ent.Id)': $reason", $Step.Name, @{ - Kind = $kind - EntitlementId = [string]$ent.Id - Reason = $reason - }) + Kind = $kind + EntitlementId = [string]$ent.Id + Reason = $reason + } + ) } } } @@ -353,10 +361,12 @@ function Invoke-IdleStepPruneEntitlements { # 4. If EnsureKeepEntitlements: grant any explicit Keep items that are missing if ($ensureKeep -and $keepItems.Count -gt 0) { $toEnsure = @($keepItems | Where-Object { $k = $_ - @($current | Where-Object { - [string]::Equals([string]$_.Id, [string]$k.Id, [System.StringComparison]::OrdinalIgnoreCase) - }).Count -eq 0 - }) + @($current | Where-Object { + [string]::Equals([string]$_.Id, [string]$k.Id, [System.StringComparison]::OrdinalIgnoreCase) + } + ).Count -eq 0 + } + ) if ($hasBulkGrant -and $toEnsure.Count -gt 0) { # Bulk grant path @@ -375,19 +385,21 @@ function Invoke-IdleStepPruneEntitlements { if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { $Context.EventSink.WriteEvent('Warning', "PruneEntitlements: failed to grant keep entitlement '$($br.Entitlement.Id)': $($br.Error)", $Step.Name, @{ - Kind = $kind - EntitlementId = [string]$br.Entitlement.Id - Reason = [string]$br.Error - }) + Kind = $kind + EntitlementId = [string]$br.Entitlement.Id + Reason = [string]$br.Error + } + ) } } else { if ($br.Changed) { $changed = $true } if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { $Context.EventSink.WriteEvent('Information', "PruneEntitlements: granted keep entitlement '$($br.Entitlement.Id)'", $Step.Name, @{ - Kind = $kind - EntitlementId = [string]$br.Entitlement.Id - }) + Kind = $kind + EntitlementId = [string]$br.Entitlement.Id + } + ) } } } @@ -410,9 +422,10 @@ function Invoke-IdleStepPruneEntitlements { if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { $Context.EventSink.WriteEvent('Information', "PruneEntitlements: granted keep entitlement '$($k.Id)'", $Step.Name, @{ - Kind = $kind - EntitlementId = [string]$k.Id - }) + Kind = $kind + EntitlementId = [string]$k.Id + } + ) } } } From 29ff07e5ecd438c8eed9a111d880af927a93529c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:20:13 +0000 Subject: [PATCH 18/27] Filter AD primary group from ListEntitlements to prevent spurious RevokeEntitlement attempts during prune Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> --- .../Private/New-IdleADAdapter.ps1 | 48 +++++++++++++++++++ .../Public/New-IdleADIdentityProvider.ps1 | 7 +++ tests/Providers/ADIdentityProvider.Tests.ps1 | 29 +++++++++++ 3 files changed, 84 insertions(+) diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index b4a87e33..4bc0bd15 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -758,6 +758,54 @@ function New-IdleADAdapter { } } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name GetPrimaryGroupDN -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $UserIdentity + ) + + $params = @{ + Identity = $UserIdentity + Properties = @('primaryGroupID', 'objectSid') + ErrorAction = 'Stop' + } + if ($null -ne $this.Credential) { + $params['Credential'] = $this.Credential + } + + try { + $user = Get-ADUser @params + if ($null -eq $user -or $null -eq $user.primaryGroupID -or $null -eq $user.objectSid) { + return $null + } + + # Build primary group SID: strip the last RID from the user SID and append primaryGroupID + $userSid = $user.objectSid.Value + $domainSid = $userSid -replace '-\d+$', '' + $primaryGroupSid = "$domainSid-$($user.primaryGroupID)" + + $groupParams = @{ + Filter = "objectSid -eq '$primaryGroupSid'" + ErrorAction = 'Stop' + } + if ($null -ne $this.Credential) { + $groupParams['Credential'] = $this.Credential + } + + $group = Get-ADGroup @groupParams + if ($null -ne $group) { + return $group.DistinguishedName + } + } + catch { + # Fail-open: cannot determine primary group → do not filter anything + Write-Verbose "GetPrimaryGroupDN: could not resolve primary group for '$UserIdentity': $_" + } + + return $null + } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name ListUsers -Value { param( [Parameter()] diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index 24b17d26..9f229322 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -772,8 +772,15 @@ function New-IdleADIdentityProvider { $groups = $adapter.GetUserGroups($user.DistinguishedName) + # Exclude the user's primary group — AD does not allow removing it via + # group membership revocation; filtering here avoids spurious Skipped events. + $primaryGroupDN = $adapter.GetPrimaryGroupDN($user.DistinguishedName) + $result = @() foreach ($group in $groups) { + if ($null -ne $primaryGroupDN -and $group.DistinguishedName -eq $primaryGroupDN) { + continue + } $result += [pscustomobject]@{ PSTypeName = 'IdLE.Entitlement' Kind = 'Group' diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index faa975a3..03006680 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -61,6 +61,8 @@ Describe 'AD identity provider' { PasswordGenerationRequireDigit = $true PasswordGenerationRequireSpecial = $true PasswordGenerationSpecialCharSet = '!@#$%&*+-_=?' + # Configurable: set to a DN string to simulate a primary group for all users + PrimaryGroupDN = $null } # Add Manager DN validation helper (matching real adapter) @@ -555,6 +557,12 @@ Describe 'AD identity provider' { return $groups } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name GetPrimaryGroupDN -Value { + param([string]$UserIdentity) + # Configurable via $this.PrimaryGroupDN; $null = no primary group filtering + return $this.PrimaryGroupDN + } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name ListUsers -Value { param([hashtable]$Filter) @@ -684,6 +692,27 @@ Describe 'AD identity provider' { @($afterGrant | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq $entitlement.Id }).Count | Should -Be 1 @($afterRevoke | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq $entitlement.Id }).Count | Should -Be 0 } + + It 'ListEntitlements excludes the primary group (AD primary group cannot be revoked)' { + $primaryGroupDN = 'CN=Domain Users,CN=Users,DC=domain,DC=local' + $adapter = New-FakeADAdapter + $adapter.PrimaryGroupDN = $primaryGroupDN + $provider = New-IdleADIdentityProvider -Adapter $adapter + + $testUser = $adapter.NewUser('EntTest5', @{ SamAccountName = 'enttest5' }, $true) + $id = $testUser.ObjectGuid.ToString() + + # Grant both a regular group and the primary group + $provider.GrantEntitlement($id, @{ Kind = 'Group'; Id = 'CN=RegularGroup,OU=Groups,DC=domain,DC=local' }) | Out-Null + $provider.GrantEntitlement($id, @{ Kind = 'Group'; Id = $primaryGroupDN }) | Out-Null + + $result = @($provider.ListEntitlements($id)) + + # Regular group must appear + @($result | Where-Object { $_.Id -eq 'CN=RegularGroup,OU=Groups,DC=domain,DC=local' }).Count | Should -Be 1 + # Primary group must NOT appear + @($result | Where-Object { $_.Id -eq $primaryGroupDN }).Count | Should -Be 0 + } } Context 'Identity resolution' { From 8d250b3d7e4723ede4104e3f8e956e1671a55356 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:38:21 +0000 Subject: [PATCH 19/27] =?UTF-8?q?Fix=20GetPrimaryGroupDN:=20use=20primaryG?= =?UTF-8?q?roupToken=20filter=20instead=20of=20objectSid=20string=20match?= =?UTF-8?q?=20=E2=80=94=20reliable=20across=20all=20AD=20environments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> --- .../Private/New-IdleADAdapter.ps1 | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index 4bc0bd15..cd120282 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -767,7 +767,7 @@ function New-IdleADAdapter { $params = @{ Identity = $UserIdentity - Properties = @('primaryGroupID', 'objectSid') + Properties = @('primaryGroupID') ErrorAction = 'Stop' } if ($null -ne $this.Credential) { @@ -776,17 +776,16 @@ function New-IdleADAdapter { try { $user = Get-ADUser @params - if ($null -eq $user -or $null -eq $user.primaryGroupID -or $null -eq $user.objectSid) { + if ($null -eq $user -or $null -eq $user.primaryGroupID) { return $null } - # Build primary group SID: strip the last RID from the user SID and append primaryGroupID - $userSid = $user.objectSid.Value - $domainSid = $userSid -replace '-\d+$', '' - $primaryGroupSid = "$domainSid-$($user.primaryGroupID)" - + # primaryGroupToken is a constructed attribute on every group that equals the group's RID. + # Matching on it is simpler and more reliable than parsing/comparing objectSid strings. + $rid = [int]$user.primaryGroupID $groupParams = @{ - Filter = "objectSid -eq '$primaryGroupSid'" + Filter = "primaryGroupToken -eq $rid" + Properties = @('DistinguishedName') ErrorAction = 'Stop' } if ($null -ne $this.Credential) { From f10f8b0fcad0635c256602747863175833c6b636 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:49:32 +0000 Subject: [PATCH 20/27] =?UTF-8?q?Fix=20GetPrimaryGroupDN:=20use=20direct?= =?UTF-8?q?=20SID=20identity=20lookup=20(domain=20SID=20+=20RID)=20instead?= =?UTF-8?q?=20of=20primaryGroupToken=20filter=20=E2=80=94=20works=20on=20a?= =?UTF-8?q?ll=20AD=20DC=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> --- .../Private/New-IdleADAdapter.ps1 | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index cd120282..55009a8c 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -767,7 +767,7 @@ function New-IdleADAdapter { $params = @{ Identity = $UserIdentity - Properties = @('primaryGroupID') + Properties = @('primaryGroupID', 'objectSid') ErrorAction = 'Stop' } if ($null -ne $this.Credential) { @@ -776,15 +776,20 @@ function New-IdleADAdapter { try { $user = Get-ADUser @params - if ($null -eq $user -or $null -eq $user.primaryGroupID) { + if ($null -eq $user -or $null -eq $user.primaryGroupID -or $null -eq $user.objectSid) { return $null } - # primaryGroupToken is a constructed attribute on every group that equals the group's RID. - # Matching on it is simpler and more reliable than parsing/comparing objectSid strings. - $rid = [int]$user.primaryGroupID + # Build the primary group SID from the user's domain SID + primaryGroupID (RID). + # Using Get-ADGroup -Identity is the most reliable approach — it does a direct + # SID lookup and works regardless of AD environment or DC version, unlike filtering on + # constructed attributes (primaryGroupToken) which are not filterable on all DCs. + $userSidStr = $user.objectSid.Value # e.g. S-1-5-21-x-y-z-1000 + $domainSid = $userSidStr.Substring(0, $userSidStr.LastIndexOf('-')) # S-1-5-21-x-y-z + $primaryGroupSid = "$domainSid-$($user.primaryGroupID)" # S-1-5-21-x-y-z-513 + $groupParams = @{ - Filter = "primaryGroupToken -eq $rid" + Identity = $primaryGroupSid Properties = @('DistinguishedName') ErrorAction = 'Stop' } From dc81864952e974b5f3913c2bc72f465e2a8a11de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 22:14:30 +0000 Subject: [PATCH 21/27] =?UTF-8?q?Simplify=20GetPrimaryGroupDN:=20read=20pr?= =?UTF-8?q?imaryGroup=20attribute=20directly=20=E2=80=94=20no=20SID=20arit?= =?UTF-8?q?hmetic,=20works=20on=20all=20AD=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> --- .../Private/New-IdleADAdapter.ps1 | 31 +++---------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index 55009a8c..1617822e 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -767,7 +767,7 @@ function New-IdleADAdapter { $params = @{ Identity = $UserIdentity - Properties = @('primaryGroupID', 'objectSid') + Properties = @('primaryGroup') ErrorAction = 'Stop' } if ($null -ne $this.Credential) { @@ -776,35 +776,12 @@ function New-IdleADAdapter { try { $user = Get-ADUser @params - if ($null -eq $user -or $null -eq $user.primaryGroupID -or $null -eq $user.objectSid) { - return $null - } - - # Build the primary group SID from the user's domain SID + primaryGroupID (RID). - # Using Get-ADGroup -Identity is the most reliable approach — it does a direct - # SID lookup and works regardless of AD environment or DC version, unlike filtering on - # constructed attributes (primaryGroupToken) which are not filterable on all DCs. - $userSidStr = $user.objectSid.Value # e.g. S-1-5-21-x-y-z-1000 - $domainSid = $userSidStr.Substring(0, $userSidStr.LastIndexOf('-')) # S-1-5-21-x-y-z - $primaryGroupSid = "$domainSid-$($user.primaryGroupID)" # S-1-5-21-x-y-z-513 - - $groupParams = @{ - Identity = $primaryGroupSid - Properties = @('DistinguishedName') - ErrorAction = 'Stop' - } - if ($null -ne $this.Credential) { - $groupParams['Credential'] = $this.Credential - } - - $group = Get-ADGroup @groupParams - if ($null -ne $group) { - return $group.DistinguishedName - } + # primaryGroup is a constructed attribute that directly returns the DN of the primary group. + return $user.primaryGroup } catch { # Fail-open: cannot determine primary group → do not filter anything - Write-Verbose "GetPrimaryGroupDN: could not resolve primary group for '$UserIdentity': $_" + Write-Verbose "GetPrimaryGroupDN: could not determine primary group for '$UserIdentity': $_" } return $null From d440ae22f88736a4984169e5054c287d84db8069 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 22:38:34 +0000 Subject: [PATCH 22/27] =?UTF-8?q?Fix=20GetPrimaryGroupDN:=20use=20MemberOf?= =?UTF-8?q?=20vs=20Get-ADPrincipalGroupMembership=20set-difference=20?= =?UTF-8?q?=E2=80=94=20reliable=20on=20all=20AD=20environments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> --- .../Private/New-IdleADAdapter.ps1 | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index 1617822e..b43b1420 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -765,19 +765,40 @@ function New-IdleADAdapter { [string] $UserIdentity ) - $params = @{ + # Strategy: the 'MemberOf' attribute on user objects is a direct (non-constructed) LDAP + # attribute that lists every group the user belongs to EXCEPT the primary group — by + # Active Directory design, the primary group is never included in MemberOf. + # Get-ADPrincipalGroupMembership returns ALL groups including the primary group. + # Therefore: PrimaryGroup DN = (AllGroups − MemberOf). + # This approach works on all AD DC versions and does not rely on any computed/constructed + # attribute (primaryGroup, primaryGroupToken) or SID string operations. + + $userParams = @{ Identity = $UserIdentity - Properties = @('primaryGroup') + Properties = @('MemberOf') ErrorAction = 'Stop' } - if ($null -ne $this.Credential) { - $params['Credential'] = $this.Credential + if ($null -ne $this.Credential) { $userParams['Credential'] = $this.Credential } + + $allGroupsParams = @{ + Identity = $UserIdentity + ErrorAction = 'Stop' } + if ($null -ne $this.Credential) { $allGroupsParams['Credential'] = $this.Credential } try { - $user = Get-ADUser @params - # primaryGroup is a constructed attribute that directly returns the DN of the primary group. - return $user.primaryGroup + $user = Get-ADUser @userParams + $memberOfSet = [System.Collections.Generic.HashSet[string]]::new( + [string[]]@($user.MemberOf), + [System.StringComparer]::OrdinalIgnoreCase + ) + + $allGroups = Get-ADPrincipalGroupMembership @allGroupsParams + foreach ($g in $allGroups) { + if (-not $memberOfSet.Contains($g.DistinguishedName)) { + return $g.DistinguishedName + } + } } catch { # Fail-open: cannot determine primary group → do not filter anything From 863bb26b4e73dfb64fe5bad51b1e85a4db8be972 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 1 Mar 2026 13:56:03 +0100 Subject: [PATCH 23/27] docs: remove keeppattern from PruneEntitlementsEnsureKeep --- .../step-prune-entitlements-ensure-keep.md | 33 ++++++------ examples/workflows/templates/ad-leaver.psd1 | 6 +-- .../workflows/templates/entraid-leaver.psd1 | 6 +-- .../Private/New-IdleADAdapter.ps1 | 50 ++++++++++++++++++- ...ke-IdleStepPruneEntitlementsEnsureKeep.ps1 | 49 ++++++++++++------ ...Invoke-IdleStepPruneEntitlements.Tests.ps1 | 43 ++++------------ 6 files changed, 114 insertions(+), 73 deletions(-) diff --git a/docs/reference/steps/step-prune-entitlements-ensure-keep.md b/docs/reference/steps/step-prune-entitlements-ensure-keep.md index 1e21dc60..eb3075b2 100644 --- a/docs/reference/steps/step-prune-entitlements-ensure-keep.md +++ b/docs/reference/steps/step-prune-entitlements-ensure-keep.md @@ -28,17 +28,17 @@ Use this step when you want to: Use IdLE.Step.PruneEntitlements instead when you only need removal and do NOT need any grants (e.g., cleanup-only without a mandatory retention group). -Key behavioral difference vs PruneEntitlements — how With.Keep and With.KeepPattern behave: +Key behavioral difference vs PruneEntitlements: this EnsureKeep variant only accepts explicit +With.Keep entries. Wildcard retention via With.KeepPattern is not supported because patterns +cannot be granted reliably. If you need to protect entitlements via wildcard matches without +granting them, run IdLE.Step.PruneEntitlements or another cleanup step before this EnsureKeep +step. - With.Keep entries → kept (NOT removed) AND ensured (GRANTED if currently missing) - With.KeepPattern → kept (NOT removed) but NOT ensured (patterns cannot be granted) +With.Keep entries -> kept (NOT removed) AND ensured (GRANTED if currently missing). After this +step completes, every identity referenced by With.Keep is guaranteed to hold that entitlement — +regardless of whether it was already present. -This means after this step completes, every identity referenced by a With.Keep entry is -guaranteed to hold that entitlement — regardless of whether it was already present or not. -Pattern-matched entitlements that were already present are kept, but the step does not -search for or grant patterns that are not yet present. - -At least one of With.Keep or With.KeepPattern must be supplied. +At least one With.Keep entry must be supplied. Provider contract: @@ -68,16 +68,18 @@ Authentication: | -------------------- | -------- | ------------ | ----------- | | IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | | Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | -| Keep | No* | array | Explicit entitlement objects to retain AND ensure are present. Each entry must have an Id property; Kind and DisplayName are optional. **These entries are GRANTED if missing after the prune.** *At least one of Keep or KeepPattern is required. | -| KeepPattern | No* | string array | Wildcard strings (PowerShell -like semantics). Current entitlements whose Id matches any pattern are kept but NOT ensured — patterns cannot be granted. *At least one of Keep or KeepPattern is required. | +| Keep | Yes | array | Explicit entitlement objects to retain AND ensure are present. Each entry must have an Id property; Kind and DisplayName are optional. **These entries are GRANTED if missing after the prune.** At least one Keep entry is required. | | Provider | No | string | Provider alias from Context.Providers (default: Identity). | | AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | | AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | ## Inputs (With.*) -The required input keys could not be detected automatically. -Please refer to the step description and examples for usage details. +The following keys are required in the step's ``With`` configuration: + +| Key | Required | Description | +| --- | --- | --- | +| `Keep` | Yes | See step description for details | ## Example @@ -90,7 +92,6 @@ Please refer to the step description and examples for usage details. # # After this step: # - CN=LEAVER-RETAIN,... is present (kept + granted if it was missing) -# - CN=LEAVER-*,... are present (kept if they were already there; not granted if missing) # - All other groups are removed @{ Name = 'Prune groups and ensure leaver-retention group (leaver)' @@ -104,8 +105,8 @@ Please refer to the step description and examples for usage details. Keep = @( @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' } ) - # KEPT but NOT granted: already-present LEAVER-* groups are preserved; absent ones are not added. - KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') + # Pattern-based retention is not supported by EnsureKeep. Use IdLE.Step.PruneEntitlements + # earlier in the workflow if you must preserve wildcard-matched entitlements without grants. AuthSessionName = 'Directory' } } diff --git a/examples/workflows/templates/ad-leaver.psd1 b/examples/workflows/templates/ad-leaver.psd1 index af1e6aed..f4e2ef45 100644 --- a/examples/workflows/templates/ad-leaver.psd1 +++ b/examples/workflows/templates/ad-leaver.psd1 @@ -43,9 +43,9 @@ Keep = @( @{ Kind = 'Group'; Id = '{{Request.Intent.LeaverRetainGroupDn}}'; DisplayName = 'Leaver Retain' } ) - - # Also retain any group whose DN starts with CN=LEAVER- (e.g. LEAVER-*) - KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') + # Pattern-based retention is not supported by PruneEntitlementsEnsureKeep. Use a + # separate IdLE.Step.PruneEntitlements step earlier in the workflow if you need to + # preserve wildcard-matched memberships without granting them. } } diff --git a/examples/workflows/templates/entraid-leaver.psd1 b/examples/workflows/templates/entraid-leaver.psd1 index 29279eed..e0e2caac 100644 --- a/examples/workflows/templates/entraid-leaver.psd1 +++ b/examples/workflows/templates/entraid-leaver.psd1 @@ -93,9 +93,9 @@ Keep = @( @{ Kind = 'Group'; Id = '{{Request.Intent.LeaverRetainGroupId}}'; DisplayName = 'Leaver Retain' } ) - - # Also retain any group whose displayName starts with LEAVER-. - KeepPattern = @('LEAVER-*') + # Pattern-based retention is not supported by PruneEntitlementsEnsureKeep. Use a + # separate IdLE.Step.PruneEntitlements step earlier if you must protect wildcard + # matches without granting them. } } diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index b43b1420..33192505 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -734,11 +734,53 @@ function New-IdleADAdapter { } } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name BulkRevokeEntitlements -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [object[]] $Entitlements + ) + + $primaryGroupDN = $this.GetPrimaryGroupDN($IdentityKey) + $results = @() + + foreach ($ent in $Entitlements) { + $groupId = $ent.Id + $result = [pscustomobject]@{ + PSTypeName = 'IdLE.BulkOperationResult' + Entitlement = $ent + Changed = $false + Error = $null + } + + if ($null -ne $primaryGroupDN -and [string]::Equals($groupId, $primaryGroupDN, [System.StringComparison]::OrdinalIgnoreCase)) { + $result.Error = 'Cannot remove primary group.' + $results += $result + continue + } + + try { + $result.Changed = $this.RemoveGroupMember($groupId, $IdentityKey) + } + catch { + $result.Error = $_.Exception.Message + } + $results += $result + } + return $results + } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserGroups -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $Identity + [string] $Identity, + + [Parameter()] + [bool] $ExcludePrimaryGroup = $false ) $params = @{ @@ -751,6 +793,12 @@ function New-IdleADAdapter { try { $groups = Get-ADPrincipalGroupMembership @params + if ($ExcludePrimaryGroup) { + $primaryGroupDN = $this.GetPrimaryGroupDN($Identity) + if ($null -ne $primaryGroupDN) { + $groups = @($groups | Where-Object { $_.DistinguishedName -ne $primaryGroupDN }) + } + } return $groups } catch { diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 index 323d8c15..1ea4fb3a 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 @@ -14,17 +14,17 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { Use IdLE.Step.PruneEntitlements instead when you only need removal and do NOT need any grants (e.g., cleanup-only without a mandatory retention group). - Key behavioral difference vs PruneEntitlements — how With.Keep and With.KeepPattern behave: + Key behavioral difference vs PruneEntitlements: this EnsureKeep variant only accepts explicit + With.Keep entries. Wildcard retention via With.KeepPattern is not supported because patterns + cannot be granted reliably. If you need to protect entitlements via wildcard matches without + granting them, run IdLE.Step.PruneEntitlements or another cleanup step before this EnsureKeep + step. - With.Keep entries → kept (NOT removed) AND ensured (GRANTED if currently missing) - With.KeepPattern → kept (NOT removed) but NOT ensured (patterns cannot be granted) + With.Keep entries -> kept (NOT removed) AND ensured (GRANTED if currently missing). After this + step completes, every identity referenced by With.Keep is guaranteed to hold that entitlement — + regardless of whether it was already present. - This means after this step completes, every identity referenced by a With.Keep entry is - guaranteed to hold that entitlement — regardless of whether it was already present or not. - Pattern-matched entitlements that were already present are kept, but the step does not - search for or grant patterns that are not yet present. - - At least one of With.Keep or With.KeepPattern must be supplied. + At least one With.Keep entry must be supplied. Provider contract: - Must advertise the IdLE.Entitlement.Prune capability (explicit opt-in) @@ -48,8 +48,7 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { | -------------------- | -------- | ------------ | ----------- | | IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | | Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | - | Keep | No* | array | Explicit entitlement objects to retain AND ensure are present. Each entry must have an Id property; Kind and DisplayName are optional. **These entries are GRANTED if missing after the prune.** *At least one of Keep or KeepPattern is required. | - | KeepPattern | No* | string array | Wildcard strings (PowerShell -like semantics). Current entitlements whose Id matches any pattern are kept but NOT ensured — patterns cannot be granted. *At least one of Keep or KeepPattern is required. | + | Keep | Yes | array | Explicit entitlement objects to retain AND ensure are present. Each entry must have an Id property; Kind and DisplayName are optional. **These entries are GRANTED if missing after the prune.** At least one Keep entry is required. | | Provider | No | string | Provider alias from Context.Providers (default: Identity). | | AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | | AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | @@ -67,7 +66,6 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { # # After this step: # - CN=LEAVER-RETAIN,... is present (kept + granted if it was missing) - # - CN=LEAVER-*,... are present (kept if they were already there; not granted if missing) # - All other groups are removed @{ Name = 'Prune groups and ensure leaver-retention group (leaver)' @@ -81,8 +79,8 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { Keep = @( @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' } ) - # KEPT but NOT granted: already-present LEAVER-* groups are preserved; absent ones are not added. - KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') + # Pattern-based retention is not supported by EnsureKeep. Use IdLE.Step.PruneEntitlements + # earlier in the workflow if you must preserve wildcard-matched entitlements without grants. AuthSessionName = 'Directory' } } @@ -120,11 +118,30 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { [object] $Step ) + if ($null -eq $Step.With -or -not ($Step.With -is [hashtable])) { + throw "PruneEntitlementsEnsureKeep requires 'With' to be a hashtable." + } + + $sourceWith = $Step.With + + if ($sourceWith.ContainsKey('KeepPattern')) { + throw "PruneEntitlementsEnsureKeep does not support With.KeepPattern. Use With.Keep for explicit entitlements to retain and ensure." + } + + if (-not ($sourceWith.ContainsKey('Keep'))) { + throw "PruneEntitlementsEnsureKeep requires With.Keep to contain at least one entitlement. Use IdLE.Step.PruneEntitlements when only pattern-based retention is needed." + } + + $keepEntries = @($sourceWith['Keep']) | Where-Object { $null -ne $_ } + if ($keepEntries.Count -eq 0) { + throw "PruneEntitlementsEnsureKeep requires With.Keep to contain at least one entitlement. Use IdLE.Step.PruneEntitlements when only pattern-based retention is needed." + } + # Inject EnsureKeepEntitlements = $true into With, then delegate to Invoke-IdleStepPruneEntitlements. # This ensures the ensure-grant phase always runs for this step type. $ensureWith = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($key in $Step.With.Keys) { - $ensureWith[$key] = $Step.With[$key] + foreach ($key in $sourceWith.Keys) { + $ensureWith[$key] = $sourceWith[$key] } $ensureWith['EnsureKeepEntitlements'] = $true diff --git a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 index 135ef117..ed92dbd8 100644 --- a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 @@ -590,56 +590,31 @@ Describe 'Invoke-IdleStepPruneEntitlementsEnsureKeep (built-in step)' { } } - Context 'Behavior: Keep + KeepPattern union (prune + ensure)' { - It 'keeps entitlements matching wildcard KeepPattern and ensures explicit Keep items' { + Context 'Validation: KeepPattern unsupported' { + It 'throws when KeepPattern is provided' { $step = $script:StepTemplate $step.With.KeepPattern = @('CN=LEAVER-*,DC=contoso,DC=com') - $step.With.Keep = @( - @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,DC=contoso,DC=com' } - @{ Kind = 'Group'; Id = 'CN=LEAVER-NEWGROUP,DC=contoso,DC=com' } - ) - - $result = & $script:Handler -Context $script:Context -Step $step - - $result.Status | Should -Be 'Completed' - $result.Changed | Should -BeTrue - $remaining = $script:Provider.ListEntitlements('user1') - $remainingIds = $remaining | Select-Object -ExpandProperty Id - # LEAVER-RETAIN + LEAVER-EXTRA (pattern) + LEAVER-NEWGROUP (ensured) - $remainingIds | Should -Contain 'CN=LEAVER-RETAIN,DC=contoso,DC=com' - $remainingIds | Should -Contain 'CN=LEAVER-EXTRA,DC=contoso,DC=com' - $remainingIds | Should -Contain 'CN=LEAVER-NEWGROUP,DC=contoso,DC=com' - $remainingIds | Should -Not -Contain 'CN=G-All,DC=contoso,DC=com' - $remainingIds | Should -Not -Contain 'CN=G-HR,DC=contoso,DC=com' + { & $script:Handler -Context $script:Context -Step $step } | Should -Throw -ExpectedMessage '*KeepPattern*' } - It 'does not grant pattern-matched entitlements (only explicit Keep items)' { + It 'throws when KeepPattern is provided even if empty' { $step = $script:StepTemplate - $step.With.KeepPattern = @('CN=LEAVER-*,DC=contoso,DC=com') - - # No new explicit Keep items beyond what's already present - $result = & $script:Handler -Context $script:Context -Step $step - - $result.Status | Should -Be 'Completed' + $step.With.KeepPattern = @() - # LEAVER-EXTRA was kept by pattern but not granted (it was already present) - $remaining = $script:Provider.ListEntitlements('user1') - $remainingIds = $remaining | Select-Object -ExpandProperty Id - $remainingIds | Should -Contain 'CN=LEAVER-RETAIN,DC=contoso,DC=com' - $remainingIds | Should -Contain 'CN=LEAVER-EXTRA,DC=contoso,DC=com' + { & $script:Handler -Context $script:Context -Step $step } | Should -Throw -ExpectedMessage '*KeepPattern*' } } - Context 'Validation: Guardrail - missing Keep and KeepPattern fails fast' { - It 'throws when neither Keep nor KeepPattern is provided' { + Context 'Validation: Keep is required' { + It 'throws when Keep is missing' { $step = [pscustomobject]@{ Name = 'bad' Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' With = @{ IdentityKey = 'user1'; Kind = 'Group'; Provider = 'Identity' } } - { & $script:Handler -Context $script:Context -Step $step } | Should -Throw -ExpectedMessage '*at least one*' + { & $script:Handler -Context $script:Context -Step $step } | Should -Throw -ExpectedMessage '*With.Keep*' } } From 32a67e4b1b8724455f76629bc0a9f734d6bb9f67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 13:14:15 +0000 Subject: [PATCH 24/27] KeepPattern forbidden at plan time for EnsureKeep; Keep/KeepPattern no longer required; AD EnsureKeep integration tests added Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> --- .../step-prune-entitlements-ensure-keep.md | 13 +- .../steps/step-prune-entitlements.md | 8 +- .../Private/ConvertTo-IdleWorkflowSteps.ps1 | 15 ++ .../Public/Get-IdleStepMetadataCatalog.ps1 | 2 + .../Invoke-IdleStepPruneEntitlements.ps1 | 13 +- ...ke-IdleStepPruneEntitlementsEnsureKeep.ps1 | 15 +- tests/Core/New-IdlePlan.Tests.ps1 | 29 ++++ tests/Providers/ADIdentityProvider.Tests.ps1 | 134 ++++++++++++++++++ ...Invoke-IdleStepPruneEntitlements.Tests.ps1 | 72 ++++++---- 9 files changed, 247 insertions(+), 54 deletions(-) diff --git a/docs/reference/steps/step-prune-entitlements-ensure-keep.md b/docs/reference/steps/step-prune-entitlements-ensure-keep.md index eb3075b2..85b9d535 100644 --- a/docs/reference/steps/step-prune-entitlements-ensure-keep.md +++ b/docs/reference/steps/step-prune-entitlements-ensure-keep.md @@ -38,7 +38,9 @@ With.Keep entries -> kept (NOT removed) AND ensured (GRANTED if currently missin step completes, every identity referenced by With.Keep is guaranteed to hold that entitlement — regardless of whether it was already present. -At least one With.Keep entry must be supplied. +With.Keep is optional. If omitted, all current entitlements of the given Kind are removed and no +grants are made (equivalent to PruneEntitlements with no keep-set). The AD provider always +excludes the primary group from the remove-set. Provider contract: @@ -68,18 +70,15 @@ Authentication: | -------------------- | -------- | ------------ | ----------- | | IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | | Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | -| Keep | Yes | array | Explicit entitlement objects to retain AND ensure are present. Each entry must have an Id property; Kind and DisplayName are optional. **These entries are GRANTED if missing after the prune.** At least one Keep entry is required. | +| Keep | No | array | Explicit entitlement objects to retain AND ensure are present. Each entry must have an Id property; Kind and DisplayName are optional. **These entries are GRANTED if missing after the prune.** If omitted, all entitlements of the given Kind are removed and no grants are made. | | Provider | No | string | Provider alias from Context.Providers (default: Identity). | | AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | | AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | ## Inputs (With.*) -The following keys are required in the step's ``With`` configuration: - -| Key | Required | Description | -| --- | --- | --- | -| `Keep` | Yes | See step description for details | +The required input keys could not be detected automatically. +Please refer to the step description and examples for usage details. ## Example diff --git a/docs/reference/steps/step-prune-entitlements.md b/docs/reference/steps/step-prune-entitlements.md index 3b5b3f66..54921853 100644 --- a/docs/reference/steps/step-prune-entitlements.md +++ b/docs/reference/steps/step-prune-entitlements.md @@ -34,7 +34,9 @@ How the keep-set is built: - With.KeepPattern — wildcard strings (-like semantics); any current entitlement whose Id matches is kept. Patterns are NEVER granted, only protected from removal. -At least one of With.Keep or With.KeepPattern must be supplied. +If neither With.Keep nor With.KeepPattern is supplied, ALL current entitlements of the given Kind +are removed (no keep-set). On the AD provider the primary group is always excluded by ListEntitlements +and is never placed in the remove-set. Provider contract: @@ -64,8 +66,8 @@ Authentication: | -------------------- | -------- | ------------ | ----------- | | IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | | Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | -| Keep | No* | array | Explicit entitlement objects to retain (kept, never removed). Each entry must have an Id property; Kind and DisplayName are optional. *At least one of Keep or KeepPattern is required. These entries are NOT granted — use PruneEntitlementsEnsureKeep for that. | -| KeepPattern | No* | string array | Wildcard strings (PowerShell -like semantics). Current entitlements whose Id matches any pattern are kept. Patterns are NEVER granted. *At least one of Keep or KeepPattern is required. | +| Keep | No | array | Explicit entitlement objects to retain (kept, never removed). Each entry must have an Id property; Kind and DisplayName are optional. These entries are NOT granted — use PruneEntitlementsEnsureKeep for that. | +| KeepPattern | No | string array | Wildcard strings (PowerShell -like semantics). Current entitlements whose Id matches any pattern are kept. Patterns are NEVER granted. | | Provider | No | string | Provider alias from Context.Providers (default: Identity). | | AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | | AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 48a5e741..010f30ff 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -121,6 +121,21 @@ function ConvertTo-IdleWorkflowSteps { # Resolve template placeholders in With (planning-time resolution) $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName + # Validate ForbiddenWithKeys declared by step metadata (fail-fast plan-time schema check) + if ($StepMetadataCatalog.ContainsKey($stepType)) { + $md = $StepMetadataCatalog[$stepType] + if ($null -ne $md -and $md -is [hashtable] -and $md.ContainsKey('ForbiddenWithKeys')) { + foreach ($fk in @($md['ForbiddenWithKeys'])) { + if (-not [string]::IsNullOrWhiteSpace([string]$fk) -and $with.ContainsKey([string]$fk)) { + throw [System.ArgumentException]::new( + ("Step '{0}' (type '{1}') does not support With.{2}. Remove this key from the step definition." -f $stepName, $stepType, [string]$fk), + 'Workflow' + ) + } + } + } + } + $retryProfile = if (Test-IdleWorkflowStepKey -Step $s -Key 'RetryProfile') { [string](Get-IdlePropertyValue -Object $s -Name 'RetryProfile') } diff --git a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 index 094854cc..22cc5dc5 100644 --- a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 @@ -74,8 +74,10 @@ function Get-IdleStepMetadataCatalog { } # IdLE.Step.PruneEntitlementsEnsureKeep - remove + ensure keep present: requires prune + list/revoke/grant + # ForbiddenWithKeys: KeepPattern is not supported because patterns cannot be "ensured" (granted). $catalog['IdLE.Step.PruneEntitlementsEnsureKeep'] = @{ RequiredCapabilities = @('IdLE.Entitlement.Prune', 'IdLE.Entitlement.List', 'IdLE.Entitlement.Revoke', 'IdLE.Entitlement.Grant') + ForbiddenWithKeys = @('KeepPattern') } return $catalog diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 index 6bd868b5..aacd7c2d 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -20,7 +20,9 @@ function Invoke-IdleStepPruneEntitlements { - With.KeepPattern — wildcard strings (-like semantics); any current entitlement whose Id matches is kept. Patterns are NEVER granted, only protected from removal. - At least one of With.Keep or With.KeepPattern must be supplied. + If neither With.Keep nor With.KeepPattern is supplied, ALL current entitlements of the given Kind + are removed (no keep-set). On the AD provider the primary group is always excluded by ListEntitlements + and is never placed in the remove-set. Provider contract: - Must advertise the IdLE.Entitlement.Prune capability (explicit opt-in) @@ -44,8 +46,8 @@ function Invoke-IdleStepPruneEntitlements { | -------------------- | -------- | ------------ | ----------- | | IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | | Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | - | Keep | No* | array | Explicit entitlement objects to retain (kept, never removed). Each entry must have an Id property; Kind and DisplayName are optional. *At least one of Keep or KeepPattern is required. These entries are NOT granted — use PruneEntitlementsEnsureKeep for that. | - | KeepPattern | No* | string array | Wildcard strings (PowerShell -like semantics). Current entitlements whose Id matches any pattern are kept. Patterns are NEVER granted. *At least one of Keep or KeepPattern is required. | + | Keep | No | array | Explicit entitlement objects to retain (kept, never removed). Each entry must have an Id property; Kind and DisplayName are optional. These entries are NOT granted — use PruneEntitlementsEnsureKeep for that. | + | KeepPattern | No | string array | Wildcard strings (PowerShell -like semantics). Current entitlements whose Id matches any pattern are kept. Patterns are NEVER granted. | | Provider | No | string | Provider alias from Context.Providers (default: Identity). | | AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | | AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | @@ -159,10 +161,7 @@ function Invoke-IdleStepPruneEntitlements { } } - # At least one keep rule required (safety guardrail) - if ($keepItems.Count -eq 0 -and $keepPatterns.Count -eq 0) { - throw "PruneEntitlements requires at least one of: With.Keep or With.KeepPattern. At least one keep rule must be specified to prevent accidental removal of all entitlements." - } + # (No guardrail: empty keep-set is valid — prune everything of the given Kind) $ensureKeep = $false if ($with.ContainsKey('EnsureKeepEntitlements') -and $null -ne $with.EnsureKeepEntitlements) { diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 index 1ea4fb3a..09959bbd 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 @@ -24,7 +24,9 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { step completes, every identity referenced by With.Keep is guaranteed to hold that entitlement — regardless of whether it was already present. - At least one With.Keep entry must be supplied. + With.Keep is optional. If omitted, all current entitlements of the given Kind are removed and no + grants are made (equivalent to PruneEntitlements with no keep-set). The AD provider always + excludes the primary group from the remove-set. Provider contract: - Must advertise the IdLE.Entitlement.Prune capability (explicit opt-in) @@ -48,7 +50,7 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { | -------------------- | -------- | ------------ | ----------- | | IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | | Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | - | Keep | Yes | array | Explicit entitlement objects to retain AND ensure are present. Each entry must have an Id property; Kind and DisplayName are optional. **These entries are GRANTED if missing after the prune.** At least one Keep entry is required. | + | Keep | No | array | Explicit entitlement objects to retain AND ensure are present. Each entry must have an Id property; Kind and DisplayName are optional. **These entries are GRANTED if missing after the prune.** If omitted, all entitlements of the given Kind are removed and no grants are made. | | Provider | No | string | Provider alias from Context.Providers (default: Identity). | | AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | | AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | @@ -128,15 +130,6 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { throw "PruneEntitlementsEnsureKeep does not support With.KeepPattern. Use With.Keep for explicit entitlements to retain and ensure." } - if (-not ($sourceWith.ContainsKey('Keep'))) { - throw "PruneEntitlementsEnsureKeep requires With.Keep to contain at least one entitlement. Use IdLE.Step.PruneEntitlements when only pattern-based retention is needed." - } - - $keepEntries = @($sourceWith['Keep']) | Where-Object { $null -ne $_ } - if ($keepEntries.Count -eq 0) { - throw "PruneEntitlementsEnsureKeep requires With.Keep to contain at least one entitlement. Use IdLE.Step.PruneEntitlements when only pattern-based retention is needed." - } - # Inject EnsureKeepEntitlements = $true into With, then delegate to Invoke-IdleStepPruneEntitlements. # This ensures the ensure-grant phase always runs for this step type. $ensureWith = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) diff --git a/tests/Core/New-IdlePlan.Tests.ps1 b/tests/Core/New-IdlePlan.Tests.ps1 index 05849d82..0ca0fa56 100644 --- a/tests/Core/New-IdlePlan.Tests.ps1 +++ b/tests/Core/New-IdlePlan.Tests.ps1 @@ -202,5 +202,34 @@ Describe 'New-IdlePlan' { { New-IdlePlan -WorkflowPath $wfPath -Request $req } | Should -Throw -ExpectedMessage '*does not match request LifecycleEvent*' } + + It 'fails plan building when PruneEntitlementsEnsureKeep step contains forbidden With.KeepPattern key' { + $wfPath = New-IdleTestWorkflowFile -FileName 'leaver-bad.psd1' -Content @' +@{ + Name = 'Leaver - Bad KeepPattern' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Prune with forbidden KeepPattern' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + With = @{ + IdentityKey = 'user1' + Kind = 'Group' + Provider = 'AD' + KeepPattern = @('CN=*') + } + } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Leaver' + $adProvider = [pscustomobject]@{} + $adProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { @('IdLE.Entitlement.Prune','IdLE.Entitlement.List','IdLE.Entitlement.Revoke','IdLE.Entitlement.Grant') } + $providers = @{ AD = $adProvider } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*KeepPattern*' + } } } diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index 03006680..6deae1bf 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -2106,4 +2106,138 @@ Describe 'AD identity provider' { $result.Id | Should -Be 'Some-License-Id' } } + + Context 'PruneEntitlementsEnsureKeep step integration with AD provider' { + BeforeEach { + $adapter = New-FakeADAdapter + $adapter.PrimaryGroupDN = 'CN=Domain Users,CN=Users,DC=domain,DC=local' + $script:ADProvider = New-IdleADIdentityProvider -Adapter $adapter + + $testUser = $adapter.NewUser('PruneUser', @{ SamAccountName = 'pruneuser' }, $true) + $script:PruneUserId = $testUser.ObjectGuid.ToString() + + # Seed groups: primary (auto-excluded), a keep group, two to-be-removed groups + $script:ADProvider.GrantEntitlement($script:PruneUserId, @{ Kind = 'Group'; Id = 'CN=Domain Users,CN=Users,DC=domain,DC=local' }) | Out-Null + $script:ADProvider.GrantEntitlement($script:PruneUserId, @{ Kind = 'Group'; Id = 'CN=Keep-Group,OU=Groups,DC=domain,DC=local' }) | Out-Null + $script:ADProvider.GrantEntitlement($script:PruneUserId, @{ Kind = 'Group'; Id = 'CN=Remove-A,OU=Groups,DC=domain,DC=local' }) | Out-Null + $script:ADProvider.GrantEntitlement($script:PruneUserId, @{ Kind = 'Group'; Id = 'CN=Remove-B,OU=Groups,DC=domain,DC=local' }) | Out-Null + + $script:StepContext = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Plan = $null + Providers = @{ AD = $script:ADProvider } + EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + } + } + + It 'removes non-kept groups and retains the explicit Keep group' { + $step = [pscustomobject]@{ + Name = 'Prune AD groups (EnsureKeep)' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + With = @{ + IdentityKey = $script:PruneUserId + Provider = 'AD' + Kind = 'Group' + Keep = @( + @{ Kind = 'Group'; Id = 'CN=Keep-Group,OU=Groups,DC=domain,DC=local' } + ) + } + } + + $result = Invoke-IdleStepPruneEntitlementsEnsureKeep -Context $script:StepContext -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + + $remaining = @($script:ADProvider.ListEntitlements($script:PruneUserId)) + @($remaining | Where-Object { $_.Kind -eq 'Group' }).Count | Should -Be 1 + $remaining[0].Id | Should -Be 'CN=Keep-Group,OU=Groups,DC=domain,DC=local' + } + + It 'grants a Keep group that is not yet present, and removes all others' { + $step = [pscustomobject]@{ + Name = 'Prune AD groups and ensure new group' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + With = @{ + IdentityKey = $script:PruneUserId + Provider = 'AD' + Kind = 'Group' + Keep = @( + @{ Kind = 'Group'; Id = 'CN=New-Leaver-Group,OU=Groups,DC=domain,DC=local' } + ) + } + } + + $result = Invoke-IdleStepPruneEntitlementsEnsureKeep -Context $script:StepContext -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + + $remaining = @($script:ADProvider.ListEntitlements($script:PruneUserId)) + @($remaining | Where-Object { $_.Kind -eq 'Group' }).Count | Should -Be 1 + $remaining[0].Id | Should -Be 'CN=New-Leaver-Group,OU=Groups,DC=domain,DC=local' + } + + It 'does NOT remove the primary group even when no Keep is specified (prune all)' { + $step = [pscustomobject]@{ + Name = 'Prune AD groups (no keep)' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + With = @{ + IdentityKey = $script:PruneUserId + Provider = 'AD' + Kind = 'Group' + } + } + + $result = Invoke-IdleStepPruneEntitlementsEnsureKeep -Context $script:StepContext -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + @($result.Skipped).Count | Should -Be 0 + + # All non-primary groups removed; primary group (Domain Users) is never in the remove-set + $remaining = @($script:ADProvider.ListEntitlements($script:PruneUserId)) + @($remaining | Where-Object { $_.Kind -eq 'Group' }).Count | Should -Be 0 + } + + It 'is idempotent when the identity already holds only the Keep group' { + # Remove the to-be-pruned groups first so the state is already converged + $script:ADProvider.RevokeEntitlement($script:PruneUserId, @{ Kind = 'Group'; Id = 'CN=Remove-A,OU=Groups,DC=domain,DC=local' }) | Out-Null + $script:ADProvider.RevokeEntitlement($script:PruneUserId, @{ Kind = 'Group'; Id = 'CN=Remove-B,OU=Groups,DC=domain,DC=local' }) | Out-Null + + $step = [pscustomobject]@{ + Name = 'Idempotent prune (already converged)' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + With = @{ + IdentityKey = $script:PruneUserId + Provider = 'AD' + Kind = 'Group' + Keep = @( + @{ Kind = 'Group'; Id = 'CN=Keep-Group,OU=Groups,DC=domain,DC=local' } + ) + } + } + + $result = Invoke-IdleStepPruneEntitlementsEnsureKeep -Context $script:StepContext -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeFalse + } + + It 'throws at execution when KeepPattern is supplied (defense-in-depth; plan-time check is primary)' { + $step = [pscustomobject]@{ + Name = 'Bad EnsureKeep with KeepPattern' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + With = @{ + IdentityKey = $script:PruneUserId + Provider = 'AD' + Kind = 'Group' + KeepPattern = @('CN=LEAVER-*') + } + } + + { Invoke-IdleStepPruneEntitlementsEnsureKeep -Context $script:StepContext -Step $step } | + Should -Throw -ExpectedMessage '*KeepPattern*' + } + } } diff --git a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 index ed92dbd8..b9cb1fcd 100644 --- a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 @@ -78,6 +78,42 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } } + Context 'Behavior: No keep-set (prune all)' { + It 'removes all entitlements when neither Keep nor KeepPattern is provided' { + $step = [pscustomobject]@{ + Name = 'Prune all groups' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ IdentityKey = 'user1'; Provider = 'Identity'; Kind = 'Group' } + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + + $remaining = $script:Provider.ListEntitlements('user1') + @($remaining | Where-Object { $_.Kind -eq 'Group' }).Count | Should -Be 0 + } + + It 'removes all entitlements when Keep is an empty array and KeepPattern is absent' { + $step = [pscustomobject]@{ + Name = 'Prune all groups (empty keep)' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ IdentityKey = 'user1'; Provider = 'Identity'; Kind = 'Group'; Keep = @() } + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + + $remaining = $script:Provider.ListEntitlements('user1') + @($remaining | Where-Object { $_.Kind -eq 'Group' }).Count | Should -Be 0 + } + } + Context 'Behavior: Keep + KeepPattern union' { It 'keeps entitlements matching wildcard KeepPattern' { $step = $script:StepTemplate @@ -293,28 +329,6 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { { & $handler -Context $script:Context -Step $step } | Should -Throw } - It 'throws when neither Keep nor KeepPattern is provided' { - $step = [pscustomobject]@{ - Name = 'bad' - Type = 'IdLE.Step.PruneEntitlements' - With = @{ IdentityKey = 'user1'; Kind = 'Group'; Provider = 'Identity' } - } - - $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - { & $handler -Context $script:Context -Step $step } | Should -Throw -ExpectedMessage '*at least one*' - } - - It 'throws when Keep is empty array and KeepPattern is absent' { - $step = [pscustomobject]@{ - Name = 'bad' - Type = 'IdLE.Step.PruneEntitlements' - With = @{ IdentityKey = 'user1'; Kind = 'Group'; Provider = 'Identity'; Keep = @() } - } - - $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - { & $handler -Context $script:Context -Step $step } | Should -Throw - } - It 'throws when Keep item is missing an Id' { $step = [pscustomobject]@{ Name = 'bad' @@ -606,15 +620,21 @@ Describe 'Invoke-IdleStepPruneEntitlementsEnsureKeep (built-in step)' { } } - Context 'Validation: Keep is required' { - It 'throws when Keep is missing' { + Context 'Behavior: No keep-set (prune all, no grants)' { + It 'removes all entitlements and makes no grants when Keep is absent' { $step = [pscustomobject]@{ - Name = 'bad' + Name = 'Prune all groups' Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' With = @{ IdentityKey = 'user1'; Kind = 'Group'; Provider = 'Identity' } } - { & $script:Handler -Context $script:Context -Step $step } | Should -Throw -ExpectedMessage '*With.Keep*' + $result = & $script:Handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + + $remaining = $script:Provider.ListEntitlements('user1') + @($remaining | Where-Object { $_.Kind -eq 'Group' }).Count | Should -Be 0 } } From ec860db4d6de60910db77d2d8642410814652661 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 13:27:18 +0000 Subject: [PATCH 25/27] Replace ForbiddenWithKeys denylist with AllowedWithKeys allowlist for step With-key schema validation Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> --- .../Private/ConvertTo-IdleWorkflowSteps.ps1 | 17 ++++++++++++----- .../Public/Get-IdleStepMetadataCatalog.ps1 | 6 ++++-- tests/Core/New-IdlePlan.Tests.ps1 | 2 +- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 010f30ff..f4ce2177 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -121,14 +121,21 @@ function ConvertTo-IdleWorkflowSteps { # Resolve template placeholders in With (planning-time resolution) $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName - # Validate ForbiddenWithKeys declared by step metadata (fail-fast plan-time schema check) + # Validate AllowedWithKeys declared by step metadata (fail-fast plan-time schema check). + # Steps that declare AllowedWithKeys accept only those keys in With; any other key is rejected. + # Steps that do not declare AllowedWithKeys skip this validation (backward compatible). if ($StepMetadataCatalog.ContainsKey($stepType)) { $md = $StepMetadataCatalog[$stepType] - if ($null -ne $md -and $md -is [hashtable] -and $md.ContainsKey('ForbiddenWithKeys')) { - foreach ($fk in @($md['ForbiddenWithKeys'])) { - if (-not [string]::IsNullOrWhiteSpace([string]$fk) -and $with.ContainsKey([string]$fk)) { + if ($null -ne $md -and $md -is [hashtable] -and $md.ContainsKey('AllowedWithKeys')) { + $allowedSet = [System.Collections.Generic.HashSet[string]]::new( + [string[]]@($md['AllowedWithKeys']), + [System.StringComparer]::OrdinalIgnoreCase + ) + foreach ($wk in @($with.Keys)) { + if (-not $allowedSet.Contains([string]$wk)) { + $allowedList = [string]::Join(', ', ([string[]]@($md['AllowedWithKeys']) | Sort-Object)) throw [System.ArgumentException]::new( - ("Step '{0}' (type '{1}') does not support With.{2}. Remove this key from the step definition." -f $stepName, $stepType, [string]$fk), + ("Step '{0}' (type '{1}') does not support With.{2}. Allowed With keys: {3}." -f $stepName, $stepType, [string]$wk, $allowedList), 'Workflow' ) } diff --git a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 index 22cc5dc5..d875a215 100644 --- a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 @@ -71,13 +71,15 @@ function Get-IdleStepMetadataCatalog { # IdLE.Step.PruneEntitlements - remove-only: requires explicit prune opt-in capability plus list/revoke $catalog['IdLE.Step.PruneEntitlements'] = @{ RequiredCapabilities = @('IdLE.Entitlement.Prune', 'IdLE.Entitlement.List', 'IdLE.Entitlement.Revoke') + AllowedWithKeys = @('IdentityKey', 'Kind', 'Provider', 'Keep', 'KeepPattern', 'AuthSessionName', 'AuthSessionOptions') } # IdLE.Step.PruneEntitlementsEnsureKeep - remove + ensure keep present: requires prune + list/revoke/grant - # ForbiddenWithKeys: KeepPattern is not supported because patterns cannot be "ensured" (granted). + # KeepPattern is NOT in AllowedWithKeys because patterns cannot be "ensured" (granted); plan-time + # validation rejects any With key that is not in this list. $catalog['IdLE.Step.PruneEntitlementsEnsureKeep'] = @{ RequiredCapabilities = @('IdLE.Entitlement.Prune', 'IdLE.Entitlement.List', 'IdLE.Entitlement.Revoke', 'IdLE.Entitlement.Grant') - ForbiddenWithKeys = @('KeepPattern') + AllowedWithKeys = @('IdentityKey', 'Kind', 'Provider', 'Keep', 'AuthSessionName', 'AuthSessionOptions') } return $catalog diff --git a/tests/Core/New-IdlePlan.Tests.ps1 b/tests/Core/New-IdlePlan.Tests.ps1 index 0ca0fa56..222ca56c 100644 --- a/tests/Core/New-IdlePlan.Tests.ps1 +++ b/tests/Core/New-IdlePlan.Tests.ps1 @@ -203,7 +203,7 @@ Describe 'New-IdlePlan' { { New-IdlePlan -WorkflowPath $wfPath -Request $req } | Should -Throw -ExpectedMessage '*does not match request LifecycleEvent*' } - It 'fails plan building when PruneEntitlementsEnsureKeep step contains forbidden With.KeepPattern key' { + It 'fails plan building when PruneEntitlementsEnsureKeep step contains unsupported With.KeepPattern key (not in AllowedWithKeys)' { $wfPath = New-IdleTestWorkflowFile -FileName 'leaver-bad.psd1' -Content @' @{ Name = 'Leaver - Bad KeepPattern' From 0a9a51bc058d0112388d811c43e211061aaa2e27 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, 1 Mar 2026 15:59:00 +0100 Subject: [PATCH 26/27] =?UTF-8?q?hashtable=20keyed=20by=20RequestId=20once?= =?UTF-8?q?=20to=20avoid=20O(n=C2=B2)=20$operation=20invocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Public/New-IdleEntraIDIdentityProvider.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 index 4f3cafcd..6558a27d 100644 --- a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 +++ b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 @@ -977,9 +977,15 @@ function New-IdleEntraIDIdentityProvider { $batchResults = $this.Adapter.BatchMembershipChanges($operations, $accessToken) + # Build lookup table for operations by RequestId to avoid O(n²) Where-Object scans + $operationByRequestId = @{} + foreach ($op in $operations) { + $operationByRequestId[$op.RequestId] = $op + } + $results = @() foreach ($br in $batchResults) { - $op = $operations | Where-Object { $_.RequestId -eq $br.RequestId } + $op = $operationByRequestId[$br.RequestId] $results += [pscustomobject]@{ PSTypeName = 'IdLE.BulkProviderResult' Operation = 'RevokeEntitlement' From f3a38acccc8f6a679f98f72545a4b9fa4ccc41ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:04:35 +0000 Subject: [PATCH 27/27] Remove dead BulkRevokeEntitlements from AD adapter; fix NormalizeEntitlementId comment; add GrantEntitlement not-called assertion Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> --- .../Private/New-IdleADAdapter.ps1 | 39 ------------------- .../Invoke-IdleStepPruneEntitlements.ps1 | 2 +- ...Invoke-IdleStepPruneEntitlements.Tests.ps1 | 11 +++++- 3 files changed, 10 insertions(+), 42 deletions(-) diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index 33192505..c938f803 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -734,45 +734,6 @@ function New-IdleADAdapter { } } -Force - $adapter | Add-Member -MemberType ScriptMethod -Name BulkRevokeEntitlements -Value { - param( - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $IdentityKey, - - [Parameter(Mandatory)] - [object[]] $Entitlements - ) - - $primaryGroupDN = $this.GetPrimaryGroupDN($IdentityKey) - $results = @() - - foreach ($ent in $Entitlements) { - $groupId = $ent.Id - $result = [pscustomobject]@{ - PSTypeName = 'IdLE.BulkOperationResult' - Entitlement = $ent - Changed = $false - Error = $null - } - - if ($null -ne $primaryGroupDN -and [string]::Equals($groupId, $primaryGroupDN, [System.StringComparison]::OrdinalIgnoreCase)) { - $result.Error = 'Cannot remove primary group.' - $results += $result - continue - } - - try { - $result.Changed = $this.RemoveGroupMember($groupId, $IdentityKey) - } - catch { - $result.Error = $_.Exception.Message - } - $results += $result - } - return $results - } -Force - $adapter | Add-Member -MemberType ScriptMethod -Name GetUserGroups -Value { param( [Parameter(Mandatory)] diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 index aacd7c2d..70cbb3a0 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -205,7 +205,7 @@ function Invoke-IdleStepPruneEntitlements { } } - # Normalize Keep IDs to canonical form via provider.NormalizeEntitlementId (when available). + # Normalize Keep IDs to canonical form via provider.ResolveEntitlement (when available). # This ensures correct comparison with the canonical IDs returned by ListEntitlements. # Each provider handles its own ID-type detection (e.g., GUID/DN/sAMAccountName for AD; # objectId/displayName for Entra ID). diff --git a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 index b9cb1fcd..850778df 100644 --- a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 @@ -205,13 +205,20 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { $step = $script:StepTemplate $step.With.EnsureKeepEntitlements = $true - $grantCount = 0 - $originalGrant = $script:Provider.PSObject.Methods['GrantEntitlement'] + # Replace GrantEntitlement with a counter to verify it is never called + # (CN=LEAVER-RETAIN is already present in the seeded entitlements) + $script:grantCount = 0 + $script:Provider | Add-Member -MemberType ScriptMethod -Name GrantEntitlement -Value { + param($IdentityKey, $Entitlement) + $script:grantCount++ + return [pscustomobject]@{ Changed = $true } + } -Force $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' $result = & $handler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' + $script:grantCount | Should -Be 0 -Because 'CN=LEAVER-RETAIN is already present; GrantEntitlement must not be called' $remaining = $script:Provider.ListEntitlements('user1') $remainingIds = $remaining | Select-Object -ExpandProperty Id