Public/Invoke-IdleStepPruneEntitlements.ps1
|
function Invoke-IdleStepPruneEntitlements { <# .SYNOPSIS Removes all non-kept entitlements of a given kind from an identity. Remove-only — does not grant anything. .DESCRIPTION *** REMOVE-ONLY step. This step NEVER grants entitlements. *** 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. 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). 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. 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) - 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. - With.AuthSessionOptions (optional, hashtable) is passed to the broker for session selection (e.g., @{ Role = 'Tier0' }). ScriptBlocks in AuthSessionOptions are rejected. ### 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 (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). | .PARAMETER Context Execution context created by IdLE.Core. .PARAMETER Step Normalized step object from the plan. Must contain a 'With' hashtable. .EXAMPLE # 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 = 'Remove group memberships (leaver, remove-only)' Type = 'IdLE.Step.PruneEntitlements' Condition = @{ Equals = @{ Path = 'Request.Intent.PruneGroups'; Value = $true } } With = @{ IdentityKey = '{{Request.Identity.SamAccountName}}' 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') AuthSessionName = 'Directory' } } .OUTPUTS PSCustomObject (PSTypeName: IdLE.StepResult) #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] [object] $Context, [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step ) $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-IdlePruneEntitlement -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 } } # (No guardrail: empty keep-set is valid — prune everything of the given Kind) $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] # Validate required provider methods (step is the single source of delta computation) $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." } } # 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). 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, $_) } } ) } $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 } # 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)) } 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) { if (Test-IdlePruneEntitlementShouldKeep -Ent $ent -KeepItems $keepItems -KeepPatterns $keepPatterns) { $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 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)) } 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 } ) } } } } else { # Per-item path: each revoke is attempted independently foreach ($ent in $toRemove) { try { if ($revokeSupportsAuthSession -and $null -ne $authSession) { $revokeResult = $provider.RevokeEntitlement($identityKey, $ent, $authSession) } else { $revokeResult = $provider.RevokeEntitlement($identityKey, $ent) } if ($revokeResult -and $revokeResult.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 '$($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) { $toEnsure = @($keepItems | Where-Object { $k = $_ @($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 $bulkResults = if ($bulkGrantSupportsAuthSession -and $null -ne $authSession) { @($provider.BulkGrantEntitlements($identityKey, $toEnsure, $authSession)) } else { @($provider.BulkGrantEntitlements($identityKey, $toEnsure)) } 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: 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 { foreach ($k in $toEnsure) { if ($grantSupportsAuthSession -and $null -ne $authSession) { $result = $provider.GrantEntitlement($identityKey, $k, $authSession) } else { $result = $provider.GrantEntitlement($identityKey, $k) } 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, @{ 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 } } |