diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index fe6194ea..594ee3c4 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..a7c912bc 100644 --- a/docs/reference/steps.md +++ b/docs/reference/steps.md @@ -17,5 +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`` | 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-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 new file mode 100644 index 00000000..85b9d535 --- /dev/null +++ b/docs/reference/steps/step-prune-entitlements-ensure-keep.md @@ -0,0 +1,139 @@ +# 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**: `Unknown` + +## Synopsis + +Removes all non-kept entitlements and GUARANTEES explicit Keep entries are present (grants if missing). + +## Description + +*** REMOVE + ENSURE step. This step REMOVES non-kept entitlements AND GRANTS missing Keep entries. *** + +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). + +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: 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). After this +step completes, every identity referenced by With.Keep is guaranteed to hold that entitlement — +regardless of whether it was already present. + +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) + +- 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. + +- 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 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 required input keys could not be detected automatically. +Please refer to the step description and examples for usage details. + +## Example + +### Example 1 + +```powershell +# 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) +# - All other groups are removed +@{ + 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' } + ) + # 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' + } +} +``` + +### 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 +- [Providers](../providers.md) - Available provider implementations diff --git a/docs/reference/steps/step-prune-entitlements.md b/docs/reference/steps/step-prune-entitlements.md new file mode 100644 index 00000000..54921853 --- /dev/null +++ b/docs/reference/steps/step-prune-entitlements.md @@ -0,0 +1,135 @@ +# 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**: `Unknown` + +## 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). | + +## Inputs (With.*) + +The following keys are required in the step's ``With`` configuration: + +| Key | Required | Description | +| --- | --- | --- | +| `IdentityKey` | Yes | Unique identifier for the identity | +| `Kind` | Yes | See step description for details | + +## 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 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' + } +} +``` + +## See Also + +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities +- [Providers](../providers.md) - Available provider implementations 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/examples/workflows/templates/ad-leaver.psd1 b/examples/workflows/templates/ad-leaver.psd1 index 3924d76d..f4e2ef45 100644 --- a/examples/workflows/templates/ad-leaver.psd1 +++ b/examples/workflows/templates/ad-leaver.psd1 @@ -28,7 +28,29 @@ # Optional, use with caution: # Removing groups can break business processes unexpectedly. - # Prefer an explicit allow-list or a "remove only managed groups" approach. + # 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.PruneEntitlementsEnsureKeep' + Name = 'Prune group memberships (leaver)' + Condition = @{ Equals = @{ Path = 'Request.Intent.PruneGroups'; Value = $true } } + With = @{ + 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' } + ) + # 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. + } + } + + # 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..e0e2caac 100644 --- a/examples/workflows/templates/entraid-leaver.psd1 +++ b/examples/workflows/templates/entraid-leaver.psd1 @@ -67,6 +67,38 @@ } } + # Optional & potentially disruptive: + # 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.PruneEntitlementsEnsureKeep' + 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' } + ) + # 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. + } + } + # Optional delete (requires provider to be created with -AllowDelete) @{ Name = 'DeleteAccount_Optional' diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 48a5e741..f4ce2177 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -121,6 +121,28 @@ function ConvertTo-IdleWorkflowSteps { # Resolve template placeholders in With (planning-time resolution) $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName + # 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('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}. Allowed With keys: {3}." -f $stepName, $stepType, [string]$wk, $allowedList), + 'Workflow' + ) + } + } + } + } + $retryProfile = if (Test-IdleWorkflowStepKey -Step $s -Key 'RetryProfile') { [string](Get-IdlePropertyValue -Object $s -Name 'RetryProfile') } diff --git a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 index 74d7ed73..9bffc009 100644 --- a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -170,6 +170,20 @@ 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.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.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.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index 461609ad..c938f803 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,14 +721,27 @@ 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 { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $Identity + [string] $Identity, + + [Parameter()] + [bool] $ExcludePrimaryGroup = $false ) $params = @{ @@ -731,6 +754,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 { @@ -738,6 +767,56 @@ function New-IdleADAdapter { } } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name GetPrimaryGroupDN -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $UserIdentity + ) + + # 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 = @('MemberOf') + ErrorAction = 'Stop' + } + 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 @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 + Write-Verbose "GetPrimaryGroupDN: could not determine 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 53a21cf2..9f229322 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()] @@ -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]@{ @@ -277,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) { @@ -327,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 = @( @@ -341,6 +354,7 @@ function New-IdleADIdentityProvider { 'IdLE.Entitlement.List' 'IdLE.Entitlement.Grant' 'IdLE.Entitlement.Revoke' + 'IdLE.Entitlement.Prune' ) if ($this.AllowDelete) { @@ -758,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' @@ -791,16 +812,9 @@ function New-IdleADIdentityProvider { $normalized = $this.ConvertToEntitlement($Entitlement) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) - $groupDn = $this.NormalizeGroupId($normalized.Id, $AuthSession) + $groupDn = $this.ResolveGroup($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' @@ -831,16 +845,9 @@ function New-IdleADIdentityProvider { $normalized = $this.ConvertToEntitlement($Entitlement) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) - $groupDn = $this.NormalizeGroupId($normalized.Id, $AuthSession) + $groupDn = $this.ResolveGroup($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' @@ -851,5 +858,37 @@ function New-IdleADIdentityProvider { } } -Force + $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)] + [ValidateNotNull()] + [object] $Entitlement, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $converted = $this.ConvertToEntitlement($Entitlement) + + # AD only supports Group entitlements; normalize to canonical DN + if ([string]::Equals($converted.Kind, 'Group', [System.StringComparison]::OrdinalIgnoreCase)) { + $canonicalId = $this.ResolveGroup($converted.Id, $AuthSession) + return [pscustomobject]@{ + PSTypeName = 'IdLE.Entitlement' + Kind = $converted.Kind + Id = $canonicalId + DisplayName = $converted.PSObject.Properties.Name -contains 'DisplayName' ? $converted.DisplayName : $null + } + } + + return $converted + } -Force + return $provider } 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.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 9fa90933..6558a27d 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 = @( @@ -309,6 +309,7 @@ function New-IdleEntraIDIdentityProvider { 'IdLE.Entitlement.List' 'IdLE.Entitlement.Grant' 'IdLE.Entitlement.Revoke' + 'IdLE.Entitlement.Prune' ) if ($this.AllowDelete) { @@ -869,20 +870,13 @@ 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 - # 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' @@ -923,20 +917,13 @@ 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 - # 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' @@ -947,5 +934,159 @@ 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.ResolveGroup($normalized.Id, $AuthSession) + $normalized.Id = $groupObjectId + $operations += @{ + RequestId = [guid]::NewGuid().ToString('N').Substring(0, 8) + GroupObjectId = $groupObjectId + UserObjectId = $user.id + Action = 'remove' + Entitlement = $normalized + } + } + + if ($operations.Count -eq 0) { + return @() + } + + $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 = $operationByRequestId[$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.ResolveGroup($normalized.Id, $AuthSession) + $normalized.Id = $groupObjectId + $operations += @{ + RequestId = [guid]::NewGuid().ToString('N').Substring(0, 8) + GroupObjectId = $groupObjectId + UserObjectId = $user.id + Action = 'add' + Entitlement = $normalized + } + } + + if ($operations.Count -eq 0) { + return @() + } + + $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 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)] + [ValidateNotNull()] + [object] $Entitlement, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $converted = $this.ConvertToEntitlement($Entitlement) + + # Entra ID only supports Group entitlements; normalize to canonical objectId + if ([string]::Equals($converted.Kind, 'Group', [System.StringComparison]::OrdinalIgnoreCase)) { + $canonicalId = $this.ResolveGroup($converted.Id, $AuthSession) + return [pscustomobject]@{ + PSTypeName = 'IdLE.Entitlement' + Kind = $converted.Kind + Id = $canonicalId + DisplayName = $converted.PSObject.Properties.Name -contains 'DisplayName' ? $converted.DisplayName : $null + } + } + + return $converted + } -Force + return $provider } 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/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..322706cb 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 @@ -20,7 +20,9 @@ 'Invoke-IdleStepEnableIdentity', 'Invoke-IdleStepMoveIdentity', 'Invoke-IdleStepDeleteIdentity', - 'Invoke-IdleStepRevokeIdentitySessions' + 'Invoke-IdleStepRevokeIdentitySessions', + '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 5437357c..d964e5ab 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 @@ -54,5 +54,7 @@ Export-ModuleMember -Function @( 'Invoke-IdleStepEnableIdentity', 'Invoke-IdleStepMoveIdentity', 'Invoke-IdleStepDeleteIdentity', - 'Invoke-IdleStepRevokeIdentitySessions' + 'Invoke-IdleStepRevokeIdentitySessions', + 'Invoke-IdleStepPruneEntitlements', + 'Invoke-IdleStepPruneEntitlementsEnsureKeep' ) 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/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 index 56d90c7b..d875a215 100644 --- a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 @@ -68,5 +68,19 @@ function Get-IdleStepMetadataCatalog { RequiredCapabilities = @('IdLE.Identity.RevokeSessions') } + # 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 + # 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') + AllowedWithKeys = @('IdentityKey', 'Kind', 'Provider', 'Keep', 'AuthSessionName', 'AuthSessionOptions') + } + 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..70cbb3a0 --- /dev/null +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -0,0 +1,442 @@ +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 + } +} 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..09959bbd --- /dev/null +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 @@ -0,0 +1,152 @@ +function Invoke-IdleStepPruneEntitlementsEnsureKeep { + <# + .SYNOPSIS + Removes all non-kept entitlements and GUARANTEES explicit Keep entries are present (grants if missing). + + .DESCRIPTION + *** REMOVE + ENSURE step. This step REMOVES non-kept entitlements AND GRANTS missing Keep entries. *** + + 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). + + 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: 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). After this + step completes, every identity referenced by With.Keep is guaranteed to hold that entitlement — + regardless of whether it was already present. + + 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) + - 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. + - 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 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). | + + .PARAMETER Context + Execution context created by IdLE.Core. + + .PARAMETER Step + Normalized step object from the plan. Must contain a 'With' hashtable. + + .EXAMPLE + # 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) + # - All other groups are removed + @{ + 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' } + ) + # 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' + } + } + + .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) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [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." + } + + # 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 $sourceWith.Keys) { + $ensureWith[$key] = $sourceWith[$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/Core/New-IdlePlan.Tests.ps1 b/tests/Core/New-IdlePlan.Tests.ps1 index 05849d82..222ca56c 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 unsupported With.KeepPattern key (not in AllowedWithKeys)' { + $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 81842bfa..6deae1bf 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) @@ -500,7 +502,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 +522,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 { @@ -551,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) @@ -680,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' { @@ -898,6 +931,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 @@ -2027,4 +2081,163 @@ Describe 'AD identity provider' { $secure | Should -BeOfType [securestring] } } + + Context 'ResolveEntitlement' { + BeforeAll { + $adapter = New-FakeADAdapter + $script:NormProvider = New-IdleADIdentityProvider -Adapter $adapter + } + + 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.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.ResolveEntitlement('License', $ent, $null) + $result.Kind | Should -Be 'License' + $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/Providers/EntraIDIdentityProvider.Tests.ps1 b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 index 276443cf..912f4c51 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 @@ -237,6 +261,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' } @@ -630,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 } @@ -638,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*' } } } @@ -714,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 @@ -737,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' { @@ -811,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 + } } } @@ -1121,3 +1246,55 @@ Describe 'EntraID identity provider - Password generation' { } } } + +Describe 'EntraID identity provider - ResolveEntitlement' { + BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule + } + + Context 'Exposes ResolveEntitlement' { + It 'Provider exposes ResolveEntitlement as a ScriptMethod' { + $provider = New-IdleEntraIDIdentityProvider -Adapter ([pscustomobject]@{}) + $provider.PSObject.Methods.Name | Should -Contain 'ResolveEntitlement' + } + } + + Context 'ResolveEntitlement 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" } + } + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetGroupByDisplayName -Value { + param($DisplayName, $AccessToken) + return @{ id = "resolved-$DisplayName"; displayName = $DisplayName } + } + $script:NormProvider = New-IdleEntraIDIdentityProvider -Adapter $fakeAdapter + } + + It 'Normalizes a Group entitlement with a GUID Id to canonical objectId' { + $groupGuid = [guid]::NewGuid().ToString() + $ent = @{ Kind = 'Group'; Id = $groupGuid } + $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.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.ResolveEntitlement('License', $ent, 'fake-token') + $result.Kind | Should -Be 'License' + $result.Id | Should -Be 'Some-License-Id' + } + } +} 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' + } + } } diff --git a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 new file mode 100644 index 00000000..850778df --- /dev/null +++ b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 @@ -0,0 +1,663 @@ +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: 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 + $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 + + # 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 + $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 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 + } + } + + 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 ResolveEntitlement, + # and tracks which IDs were passed to RevokeEntitlement. + $revokedIds = @() + $mockProvider = [pscustomobject]@{ + PSTypeName = 'MockNormProvider' + RevokedIds = $revokedIds + } + + $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 ResolveEntitlement -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 + return [pscustomobject]@{ Changed = $true } + } -Force + + $script:Context.Providers['Identity'] = $mockProvider + + $step = [pscustomobject]@{ + Name = 'Prune via ResolveEntitlement' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ + 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 + ) + } + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + # 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' + } + } + + 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)' { + 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 'Validation: KeepPattern unsupported' { + It 'throws when KeepPattern is provided' { + $step = $script:StepTemplate + $step.With.KeepPattern = @('CN=LEAVER-*,DC=contoso,DC=com') + + { & $script:Handler -Context $script:Context -Step $step } | Should -Throw -ExpectedMessage '*KeepPattern*' + } + + It 'throws when KeepPattern is provided even if empty' { + $step = $script:StepTemplate + $step.With.KeepPattern = @() + + { & $script:Handler -Context $script:Context -Step $step } | Should -Throw -ExpectedMessage '*KeepPattern*' + } + } + + Context 'Behavior: No keep-set (prune all, no grants)' { + It 'removes all entitlements and makes no grants when Keep is absent' { + $step = [pscustomobject]@{ + Name = 'Prune all groups' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + With = @{ IdentityKey = 'user1'; Kind = 'Group'; Provider = 'Identity' } + } + + $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 + } + } + + 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' + } + } +} 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') diff --git a/website/sidebars.js b/website/sidebars.js index 157ffc35..d4b7d5e1 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -111,6 +111,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',