diff --git a/docs/extend/providers.md b/docs/extend/providers.md index 8f473626..c1aeeb17 100644 --- a/docs/extend/providers.md +++ b/docs/extend/providers.md @@ -115,6 +115,38 @@ their implementation beyond contract usage. --- +## Entitlement providers + +An entitlement is identified by `Kind` (e.g. `Group`, `AdministrativeUnit`) and `Id`. The entitlement steps +(`IdLE.Step.EnsureEntitlement`, `IdLE.Step.PruneEntitlements`, `IdLE.Step.PruneEntitlementsEnsureKeep`) and the +`IdLE.Entitlement.List` context resolver call these methods: + +| Method | Contract | +| --- | --- | +| `ListEntitlements(IdentityKey[, AuthSession][, Kind])` | Returns entitlement objects with `Kind` and `Id`. The optional `Kind` parameter filters the result; callers pass it when the method declares it. | +| `GrantEntitlement(IdentityKey, Entitlement[, AuthSession])` | Idempotent; returns a result with `Changed`, `IdentityKey`, `Entitlement`. | +| `RevokeEntitlement(IdentityKey, Entitlement[, AuthSession])` | Idempotent; same result shape. | +| `ResolveEntitlement(Entitlement[, AuthSession])` (optional) | Returns the entitlement with its canonical `Id` (e.g. a group DN for a sAMAccountName), so that prune Keep entries match `ListEntitlements` output. The Kind comes from `Entitlement.Kind`. | +| `BulkGrantEntitlements` / `BulkRevokeEntitlements(IdentityKey, Entitlements[, AuthSession])` (optional) | Per-item results with `Changed`, `Error`, `Entitlement`. | + +**Kinds:** a provider supports a fixed set of Kinds and **rejects every other Kind** (including misspellings) with an +error before changing anything, in `ListEntitlements(Kind)` as well as in Grant, Revoke, Resolve and the bulk methods. +Kinds compare case-insensitively; providers return the canonical casing. + +| Provider | Supported Kinds | +| --- | --- | +| AD | `Group` | +| Entra ID | `Group`, `AdministrativeUnit` | +| Mock | any non-empty Kind | + +This keeps workflow mistakes visible: without the check, an `AdministrativeUnit` entitlement on AD would be resolved as +a group with the same name, and a prune with an unsupported Kind would silently find nothing to remove. + +Migration from earlier versions: `ResolveEntitlement($Kind, $Entitlement, $AuthSession)` is now +`ResolveEntitlement($Entitlement, $AuthSession)`. + +--- + ## Auth session acquisition (AuthSessionBroker) Many providers require authenticated connections (tokens, API clients, remote sessions). diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index e5e71f3b..db124d6d 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -160,10 +160,12 @@ Each resolver writes to a **provider/auth-scoped source-of-truth path** under `R Request.Context.Providers... ``` -| Capability | CapabilitySubPath | Required `With` keys | -|---|---|---| -| `IdLE.Entitlement.List` | `Identity.Entitlements` | `IdentityKey` (string) | -| `IdLE.Identity.Read` | `Identity.Profile` | `IdentityKey` (string) | +| Capability | CapabilitySubPath | Required `With` keys | Optional `With` keys | +|---|---|---|---| +| `IdLE.Entitlement.List` | `Identity.Entitlements` | `IdentityKey` (string) | `Kind` (string): list only this entitlement Kind | +| `IdLE.Identity.Read` | `Identity.Profile` | `IdentityKey` (string) | | + +Only one resolver per capability, provider and auth session is allowed. Where `` is `Default` when `With.AuthSessionName` is not specified. diff --git a/docs/reference/cmdlets/New-IdleADIdentityProvider.md b/docs/reference/cmdlets/New-IdleADIdentityProvider.md index 0f876d2e..412b2c7b 100644 --- a/docs/reference/cmdlets/New-IdleADIdentityProvider.md +++ b/docs/reference/cmdlets/New-IdleADIdentityProvider.md @@ -26,6 +26,7 @@ It requires the ActiveDirectory PowerShell module (RSAT) and runs on Windows onl The provider supports common identity operations (Create, Read, Disable, Enable, Move, Delete) and group entitlement management (List, Grant, Revoke). +The only supported entitlement Kind is 'Group'; any other Kind is rejected. Identity addressing supports: - GUID (ObjectGuid) - 36 hex characters with hyphens (D-format) or 32 hex characters (N-format) diff --git a/docs/reference/cmdlets/New-IdleEntraIDIdentityProvider.md b/docs/reference/cmdlets/New-IdleEntraIDIdentityProvider.md index 457a8703..cd813d92 100644 --- a/docs/reference/cmdlets/New-IdleEntraIDIdentityProvider.md +++ b/docs/reference/cmdlets/New-IdleEntraIDIdentityProvider.md @@ -25,6 +25,7 @@ via the host-provided AuthSessionBroker pattern. The provider supports common identity operations (Create, Read, Disable, Enable, Delete) and entitlement management for groups and Administrative Units (List, Grant, Revoke). +Supported entitlement Kinds are 'Group' and 'AdministrativeUnit'; any other Kind is rejected. Identity addressing supports: - objectId (GUID string) - most deterministic diff --git a/docs/reference/providers/provider-ad.md b/docs/reference/providers/provider-ad.md index c631bd0e..3aff8130 100644 --- a/docs/reference/providers/provider-ad.md +++ b/docs/reference/providers/provider-ad.md @@ -166,7 +166,7 @@ $provider = New-IdleADIdentityProvider -AllowDelete - **Identity addressing:** GUID (ObjectGuid), UPN, or sAMAccountName (fallback) - **Safety defaults:** deletion is disabled unless you pass `-AllowDelete` -- **Entitlements:** groups only (`Kind='Group'`) +- **Entitlements:** groups only (`Kind='Group'`); entitlement operations with any other Kind (e.g. `AdministrativeUnit`) fail instead of being treated as a group ## Attribute handling diff --git a/docs/reference/providers/provider-entraID.md b/docs/reference/providers/provider-entraID.md index 254333c2..80153a90 100644 --- a/docs/reference/providers/provider-entraID.md +++ b/docs/reference/providers/provider-entraID.md @@ -124,7 +124,9 @@ Writes to scoped path: `Request.Context.Providers.. 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. @@ -94,7 +99,7 @@ Validated at plan time against the step's ``WithSchema`` (see [Step metadata con 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' } + @{ 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. @@ -118,7 +123,7 @@ Validated at plan time against the step's ``WithSchema`` (see [Step metadata con Kind = 'License' # This license is KEPT and GRANTED if missing — always present after this step. Keep = @( - @{ Kind = 'License'; Id = 'BASELINE-E1' } + @{ Id = 'BASELINE-E1' } ) AuthSessionName = 'Licensing' } diff --git a/docs/reference/steps/step-prune-entitlements.md b/docs/reference/steps/step-prune-entitlements.md index 3176e8d5..dacf16c1 100644 --- a/docs/reference/steps/step-prune-entitlements.md +++ b/docs/reference/steps/step-prune-entitlements.md @@ -28,9 +28,15 @@ Use IdLE.Step.PruneEntitlementsEnsureKeep instead when you also need to guarante explicit Keep entries are present after the prune (e.g., a leaver-retention group must be granted if it is missing). +With.Kind is the prune scope: only current entitlements of this Kind are considered for removal. +The provider rejects a Kind it does not support (e.g., 'AdministrativeUnit' on AD) before anything +is removed. To prune several Kinds, use one step per Kind. + How the keep-set is built: -- With.Keep — explicit entitlement references (kept AND matched case-insensitively by Id) +- With.Keep — explicit entitlement references (kept AND matched case-insensitively by Id). + Entries inherit With.Kind; an entry may repeat Kind, but a different Kind fails + the step before anything is removed. - With.KeepPattern — wildcard strings (-like semantics); any current entitlement whose Id matches is kept. Patterns are NEVER granted, only protected from removal. @@ -109,7 +115,7 @@ Validated at plan time against the step's ``WithSchema`` (see [Step metadata con 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' } + @{ Id = 'CN=All-Users,OU=Groups,DC=contoso,DC=com' } ) KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') AuthSessionName = 'Directory' diff --git a/docs/use/workflows/context-resolver.md b/docs/use/workflows/context-resolver.md index 193a9fce..0b8b5a54 100644 --- a/docs/use/workflows/context-resolver.md +++ b/docs/use/workflows/context-resolver.md @@ -172,6 +172,10 @@ Resolved from `Step.With.Provider` + `Step.With.AuthSessionName` (or `Default`). | `Provider` | `string` | No | Provider alias. If omitted, IdLE auto-selects a provider advertising the capability. Ambiguity (multiple providers matching) is a fail-fast error. Also used to determine `` in the scoped path. | | `AuthSessionName` | `string` | No | Named auth session key. Becomes `` in the scoped path. If omitted, `Default` is used. Requires an `AuthSessionBroker` entry in `Providers`. Must be a valid path segment (no dots). | | `AuthSessionOptions` | `hashtable` | No | Options passed to `AuthSessionBroker.AcquireAuthSession`. Must be a hashtable. ScriptBlocks are rejected. | + | `Kind` | `string` | No | `IdLE.Entitlement.List` only: list only entitlements of this Kind (e.g. `Group`). Passed to the provider, which may then skip other directory objects and their permissions (Entra ID: no Administrative Unit calls for `Group`). The result is always filtered to this Kind. The provider rejects a Kind it does not support. | + +Each resolver owns its scoped path, so only **one resolver per capability, provider and auth session** is allowed; +a second one fails planning. To get several entitlement Kinds from one provider and session, omit `Kind`. --- @@ -218,6 +222,16 @@ ContextResolvers = @( # Views.Identity.Entitlements (merged, both providers) ``` +### Group entitlements only + +List only groups, e.g. to avoid the Administrative Unit Graph permissions on Entra ID: + +```powershell +ContextResolvers = @( + @{ Capability = 'IdLE.Entitlement.List'; With = @{ IdentityKey = 'user1'; Provider = 'Entra'; Kind = 'Group' } } +) +``` + ### Step-relative precondition using Current Use `Request.Context.Current.*` in a step's `Precondition` to check the scoped context diff --git a/examples/workflows/mock/joiner-with-context-resolvers.psd1 b/examples/workflows/mock/joiner-with-context-resolvers.psd1 index e4a8f526..93132d29 100644 --- a/examples/workflows/mock/joiner-with-context-resolvers.psd1 +++ b/examples/workflows/mock/joiner-with-context-resolvers.psd1 @@ -30,6 +30,9 @@ # Provider alias that supports IdLE.Entitlement.List. # If omitted, the provider is auto-selected when exactly one match exists. Provider = 'Identity' + # Optional: list only this entitlement Kind (omit to list all Kinds). + # Only one resolver per capability, provider and auth session is allowed. + Kind = 'Group' } } ) diff --git a/examples/workflows/templates/ad-leaver.psd1 b/examples/workflows/templates/ad-leaver.psd1 index 5ac54d4c..3625fb65 100644 --- a/examples/workflows/templates/ad-leaver.psd1 +++ b/examples/workflows/templates/ad-leaver.psd1 @@ -41,7 +41,7 @@ # Explicitly retain this group and ensure it is present after pruning. Keep = @( - @{ Kind = 'Group'; Id = '{{Request.Intent.LeaverRetainGroupDn}}' } + @{ Id = '{{Request.Intent.LeaverRetainGroupDn}}' } ) # Pattern-based retention is not supported by PruneEntitlementsEnsureKeep. Use a # separate IdLE.Step.PruneEntitlements step earlier in the workflow if you need to diff --git a/examples/workflows/templates/entraid-leaver.psd1 b/examples/workflows/templates/entraid-leaver.psd1 index 48f968a1..ad420247 100644 --- a/examples/workflows/templates/entraid-leaver.psd1 +++ b/examples/workflows/templates/entraid-leaver.psd1 @@ -86,7 +86,7 @@ # Retain this specific leaver group and ensure it is present. Keep = @( - @{ Kind = 'Group'; Id = '{{Request.Intent.LeaverRetainGroupId}}' } + @{ Id = '{{Request.Intent.LeaverRetainGroupId}}' } ) # Pattern-based retention is not supported by PruneEntitlementsEnsureKeep. Use a # separate IdLE.Step.PruneEntitlements step earlier if you must protect wildcard @@ -148,7 +148,7 @@ # This AU is retained AND guaranteed to be present after the step. # Reference by objectId (GUID) or by displayName (must be tenant-unique). Keep = @( - @{ Kind = 'AdministrativeUnit'; Id = '{{Request.Intent.RetainAdministrativeUnitId}}' } + @{ Id = '{{Request.Intent.RetainAdministrativeUnitId}}' } ) } } diff --git a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 index e928608b..aafb89e8 100644 --- a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 +++ b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 @@ -32,6 +32,9 @@ function Invoke-IdleContextResolvers { capability; zero matches or multiple matches both cause a fail-fast error. - Auth sessions are supported via With.AuthSessionName / With.AuthSessionOptions, using the AuthSessionBroker in Providers (same pattern as step execution). + - At most one resolver per capability, provider alias and AuthSessionKey (each owns its scoped path). + - IdLE.Entitlement.List accepts an optional With.Kind: it is passed to ListEntitlements when the + provider declares a Kind parameter, and the result is always filtered to that Kind. This function mutates Request.Context in place so that subsequent condition evaluation can reference the resolved data via scoped paths or Views. @@ -66,6 +69,7 @@ function Invoke-IdleContextResolvers { } $readOnlyCapabilities = @(Get-IdleReadOnlyCapabilities) + $resolvedOutputs = @{} $i = 0 foreach ($resolver in @($Resolvers)) { @@ -123,10 +127,22 @@ function Invoke-IdleContextResolvers { $authSession = $null $authBroker = Get-IdleAuthSessionBroker -Providers $Providers $authSessionKey = 'Default' + if ($with.Contains('AuthSessionName') -and -not [string]::IsNullOrWhiteSpace([string]$with.AuthSessionName)) { + $authSessionKey = [string]$with.AuthSessionName + } + + # Each resolver owns its scoped output path; a second resolver for the same capability, provider and + # session would silently replace the first result (e.g. a 'Group' listing by an 'AdministrativeUnit' one). + $outputKey = "$capability|$resolvedProviderAlias|$authSessionKey" + if ($resolvedOutputs.ContainsKey($outputKey)) { + $message = "$resolverPath resolves capability '$capability' for provider '$resolvedProviderAlias' and auth session " + + "'$authSessionKey', which $($resolvedOutputs[$outputKey]) already resolves. Use one resolver per capability, provider and auth session." + throw [System.ArgumentException]::new($message, 'Workflow') + } + $resolvedOutputs[$outputKey] = $resolverPath if ($with -is [System.Collections.IDictionary] -and $with.Contains('AuthSessionName') -and -not [string]::IsNullOrWhiteSpace([string]$with.AuthSessionName)) { $sessionName = [string]$with.AuthSessionName - $authSessionKey = $sessionName $sessionOptions = if ($with.Contains('AuthSessionOptions')) { $with.AuthSessionOptions } else { $null } if ($null -ne $sessionOptions -and $sessionOptions -isnot [hashtable]) { throw [System.ArgumentException]::new("$resolverPath 'With.AuthSessionOptions' must be a hashtable.", 'Workflow') @@ -714,6 +730,19 @@ function Invoke-IdleResolverCapabilityDispatch { $identityKey = [string]$With.IdentityKey + # Optional Kind narrows the listing (e.g. Entra ID then skips Administrative Unit calls and + # does not need their Graph permissions). + $kind = $null + if ($With.Contains('Kind') -and $null -ne $With.Kind) { + if ($With.Kind -isnot [string] -or [string]::IsNullOrWhiteSpace($With.Kind)) { + throw [System.ArgumentException]::new( + "$ResolverPath with capability 'IdLE.Entitlement.List': With.Kind must be a non-empty string.", + 'Workflow' + ) + } + $kind = [string]$With.Kind + } + $method = $provider.PSObject.Methods['ListEntitlements'] if ($null -eq $method) { throw [System.InvalidOperationException]::new( @@ -721,11 +750,27 @@ function Invoke-IdleResolverCapabilityDispatch { ) } + # Contract order: ListEntitlements(IdentityKey[, AuthSession][, Kind]). + $arguments = [System.Collections.Generic.List[object]]::new() + $arguments.Add($identityKey) $supportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $method -ParameterName 'AuthSession' - if ($supportsAuthSession -and $null -ne $AuthSession) { - return @($provider.ListEntitlements($identityKey, $AuthSession)) + $passKind = $null -ne $kind -and (Test-IdleProviderMethodParameter -ProviderMethod $method -ParameterName 'Kind') + if ($supportsAuthSession -and ($null -ne $AuthSession -or $passKind)) { + $arguments.Add($AuthSession) + } + if ($passKind) { + $arguments.Add($kind) } - return @($provider.ListEntitlements($identityKey)) + + $entitlements = @($method.Invoke($arguments.ToArray())) + if ($null -eq $kind) { + return $entitlements + } + + # Filtered here as well so that providers without a Kind parameter behave the same. + return @($entitlements | Where-Object { + [string]::Equals([string](Get-IdlePropertyValue -Object $_ -Name 'Kind'), $kind, [System.StringComparison]::OrdinalIgnoreCase) + }) } 'IdLE.Identity.Read' { diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 53156bdd..130d3aea 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -286,6 +286,16 @@ function Test-IdleWorkflowSchema { if ($with.ContainsKey('AuthSessionOptions') -and $null -ne $with.AuthSessionOptions -and $with.AuthSessionOptions -isnot [hashtable]) { $errors.Add("'$resolverPath.With.AuthSessionOptions' must be a hashtable.") } + + # 'With.Kind' filters entitlement listings only and must be a non-empty string if present. + if ($with.ContainsKey('Kind') -and $null -ne $with.Kind) { + if ([string]$resolver.Capability -ne 'IdLE.Entitlement.List') { + $errors.Add("'$resolverPath.With.Kind' is only supported for capability 'IdLE.Entitlement.List'.") + } + elseif ($with.Kind -isnot [string] -or [string]::IsNullOrWhiteSpace($with.Kind)) { + $errors.Add("'$resolverPath.With.Kind' must be a non-empty string.") + } + } } } diff --git a/src/IdLE.Provider.AD/Private/ConvertTo-IdleADEntitlement.ps1 b/src/IdLE.Provider.AD/Private/ConvertTo-IdleADEntitlement.ps1 index e47aa639..733b8fde 100644 --- a/src/IdLE.Provider.AD/Private/ConvertTo-IdleADEntitlement.ps1 +++ b/src/IdLE.Provider.AD/Private/ConvertTo-IdleADEntitlement.ps1 @@ -9,7 +9,8 @@ function ConvertTo-IdleADEntitlement { Normalizes and validates entitlement values from various input formats (hashtable, PSCustomObject) into a standard IdLE.Entitlement object. - The function validates that required fields (Kind, Id) are present and not empty. + The function validates that required fields (Kind, Id) are present and not empty and that + Kind is supported by the AD provider ('Group'). Kind is returned in its canonical casing. .PARAMETER Value The input value to convert. Can be a hashtable or PSCustomObject with @@ -53,7 +54,7 @@ function ConvertTo-IdleADEntitlement { return [pscustomobject]@{ PSTypeName = 'IdLE.Entitlement' - Kind = [string]$kind + Kind = Get-IdleADEntitlementKind -Kind ([string]$kind) Id = [string]$id } } diff --git a/src/IdLE.Provider.AD/Private/Get-IdleADEntitlementKind.ps1 b/src/IdLE.Provider.AD/Private/Get-IdleADEntitlementKind.ps1 new file mode 100644 index 00000000..2555b179 --- /dev/null +++ b/src/IdLE.Provider.AD/Private/Get-IdleADEntitlementKind.ps1 @@ -0,0 +1,31 @@ +Set-StrictMode -Version Latest + +function Get-IdleADEntitlementKind { + <# + .SYNOPSIS + Returns the canonical entitlement Kind supported by the AD provider. + + .DESCRIPTION + The AD provider only manages group memberships. Any other Kind is rejected so that, + for example, an 'AdministrativeUnit' entitlement is never resolved as a group of the same name. + + .PARAMETER Kind + The entitlement Kind to validate (case-insensitive). + + .OUTPUTS + System.String + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Kind + ) + + if ([string]::Equals($Kind, 'Group', [System.StringComparison]::OrdinalIgnoreCase)) { + return 'Group' + } + + throw "Entitlement Kind '$Kind' is not supported by the AD provider. Supported Kinds: Group." +} diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index 8a9ded17..083e81d9 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -9,6 +9,7 @@ function New-IdleADIdentityProvider { The provider supports common identity operations (Create, Read, Disable, Enable, Move, Delete) and group entitlement management (List, Grant, Revoke). + The only supported entitlement Kind is 'Group'; any other Kind is rejected. Identity addressing supports: - GUID (ObjectGuid) - 36 hex characters with hyphens (D-format) or 32 hex characters (N-format) @@ -760,9 +761,19 @@ function New-IdleADIdentityProvider { [Parameter()] [AllowNull()] - [object] $AuthSession + [object] $AuthSession, + + [Parameter()] + [AllowNull()] + [string] $Kind = $null ) + # Groups are the only Kind; validating here makes a prune with an unsupported Kind fail + # instead of silently finding nothing to remove. + if (-not [string]::IsNullOrEmpty($Kind)) { + $null = Get-IdleADEntitlementKind -Kind $Kind + } + $adapter = $this.GetEffectiveAdapter($AuthSession) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) @@ -856,11 +867,6 @@ function New-IdleADIdentityProvider { $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, @@ -870,18 +876,13 @@ function New-IdleADIdentityProvider { [object] $AuthSession ) + # ConvertToEntitlement rejects every Kind except 'Group'. $converted = $this.ConvertToEntitlement($Entitlement) - $null = $Kind # Contract parameter — reserved for future multi-Kind dispatch - 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 - } + return [pscustomobject]@{ + PSTypeName = 'IdLE.Entitlement' + Kind = $converted.Kind + Id = $this.ResolveGroup($converted.Id, $AuthSession) } - - return $converted } -Force return $provider diff --git a/src/IdLE.Provider.EntraID/Private/ConvertTo-IdleEntraIDEntitlement.ps1 b/src/IdLE.Provider.EntraID/Private/ConvertTo-IdleEntraIDEntitlement.ps1 index 05653cd4..3210dab6 100644 --- a/src/IdLE.Provider.EntraID/Private/ConvertTo-IdleEntraIDEntitlement.ps1 +++ b/src/IdLE.Provider.EntraID/Private/ConvertTo-IdleEntraIDEntitlement.ps1 @@ -9,8 +9,9 @@ function ConvertTo-IdleEntraIDEntitlement { Normalizes and validates entitlement values from various input formats (hashtable, PSCustomObject) into a standard IdLE.Entitlement object. - The function validates that required fields (Kind, Id) are present and not empty. - Supports optional field: Mail. + The function validates that required fields (Kind, Id) are present and not empty and that + Kind is supported by the Entra ID provider ('Group', 'AdministrativeUnit'). Kind is returned + in its canonical casing. Supports optional field: Mail. .PARAMETER Value The input value to convert. Can be a hashtable or PSCustomObject with @@ -58,7 +59,7 @@ function ConvertTo-IdleEntraIDEntitlement { return [pscustomobject]@{ PSTypeName = 'IdLE.Entitlement' - Kind = [string]$kind + Kind = Get-IdleEntraIDEntitlementKind -Kind ([string]$kind) Id = [string]$id Mail = if ($null -eq $mail -or [string]::IsNullOrWhiteSpace([string]$mail)) { $null diff --git a/src/IdLE.Provider.EntraID/Private/Get-IdleEntraIDEntitlementKind.ps1 b/src/IdLE.Provider.EntraID/Private/Get-IdleEntraIDEntitlementKind.ps1 new file mode 100644 index 00000000..49b7ec8e --- /dev/null +++ b/src/IdLE.Provider.EntraID/Private/Get-IdleEntraIDEntitlementKind.ps1 @@ -0,0 +1,33 @@ +Set-StrictMode -Version Latest + +function Get-IdleEntraIDEntitlementKind { + <# + .SYNOPSIS + Returns the canonical entitlement Kind supported by the Entra ID provider. + + .DESCRIPTION + The Entra ID provider manages group and administrative unit memberships. Any other Kind + (including misspellings such as 'Groups') is rejected so that it cannot silently match nothing. + + .PARAMETER Kind + The entitlement Kind to validate (case-insensitive). + + .OUTPUTS + System.String + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Kind + ) + + foreach ($supportedKind in @('Group', 'AdministrativeUnit')) { + if ([string]::Equals($Kind, $supportedKind, [System.StringComparison]::OrdinalIgnoreCase)) { + return $supportedKind + } + } + + throw "Entitlement Kind '$Kind' is not supported by the Entra ID provider. Supported Kinds: Group, AdministrativeUnit." +} diff --git a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 index 3db487b9..94fadbc4 100644 --- a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 +++ b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 @@ -10,6 +10,7 @@ function New-IdleEntraIDIdentityProvider { The provider supports common identity operations (Create, Read, Disable, Enable, Delete) and entitlement management for groups and Administrative Units (List, Grant, Revoke). + Supported entitlement Kinds are 'Group' and 'AdministrativeUnit'; any other Kind is rejected. Identity addressing supports: - objectId (GUID string) - most deterministic @@ -836,15 +837,18 @@ function New-IdleEntraIDIdentityProvider { [string] $Kind = $null ) + # When Kind is supplied, only fetch the relevant directory objects (and only require the + # Graph permissions for that Kind). When Kind is null/empty, fetch both. + $kindIsEmpty = [string]::IsNullOrEmpty($Kind) + if (-not $kindIsEmpty) { + $Kind = Get-IdleEntraIDEntitlementKind -Kind $Kind + } + $includeGroups = $kindIsEmpty -or $Kind -eq 'Group' + $includeAUs = $kindIsEmpty -or $Kind -eq 'AdministrativeUnit' + $accessToken = $this.ExtractAccessToken($AuthSession) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) - # When Kind is supplied, only fetch the relevant directory objects. - # When Kind is null/empty, fetch both (backward-compatible, e.g. host-level reports). - $kindIsEmpty = [string]::IsNullOrEmpty($Kind) - $includeGroups = $kindIsEmpty -or [string]::Equals($Kind, 'Group', [System.StringComparison]::OrdinalIgnoreCase) - $includeAUs = $kindIsEmpty -or [string]::Equals($Kind, 'AdministrativeUnit', [System.StringComparison]::OrdinalIgnoreCase) - $result = @() if ($includeGroups) { @@ -904,30 +908,20 @@ function New-IdleEntraIDIdentityProvider { ) $accessToken = $this.ExtractAccessToken($AuthSession) + # ConvertToEntitlement rejects Kinds other than 'Group' and 'AdministrativeUnit'. $normalized = $this.ConvertToEntitlement($Entitlement) - - # Default missing Kind to 'Group' for backward compatibility - if (-not $normalized.Kind) { - $normalized.Kind = 'Group' - } + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) if ($normalized.Kind -eq 'AdministrativeUnit') { - $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $auObjectId = $this.ResolveAdministrativeUnit($normalized.Id, $AuthSession) $normalized.Id = $auObjectId $changed = [bool]$this.Adapter.AddAdministrativeUnitMember($auObjectId, $user.id, $accessToken) } - elseif ($normalized.Kind -eq 'Group') { - $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + else { $groupObjectId = $this.ResolveGroup($normalized.Id, $AuthSession) $normalized.Id = $groupObjectId $changed = [bool]$this.Adapter.AddGroupMember($groupObjectId, $user.id, $accessToken) } - else { - throw [System.ArgumentException]::new( - "GrantEntitlement only supports entitlements with Kind 'Group' or 'AdministrativeUnit'. Received Kind '$($normalized.Kind)'." - ) - } return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' @@ -954,30 +948,20 @@ function New-IdleEntraIDIdentityProvider { ) $accessToken = $this.ExtractAccessToken($AuthSession) + # ConvertToEntitlement rejects Kinds other than 'Group' and 'AdministrativeUnit'. $normalized = $this.ConvertToEntitlement($Entitlement) - - # Default missing Kind to 'Group' for backward compatibility - if (-not $normalized.Kind) { - $normalized.Kind = 'Group' - } + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) if ($normalized.Kind -eq 'AdministrativeUnit') { - $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $auObjectId = $this.ResolveAdministrativeUnit($normalized.Id, $AuthSession) $normalized.Id = $auObjectId $changed = [bool]$this.Adapter.RemoveAdministrativeUnitMember($auObjectId, $user.id, $accessToken) } - elseif ($normalized.Kind -eq 'Group') { - $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + else { $groupObjectId = $this.ResolveGroup($normalized.Id, $AuthSession) $normalized.Id = $groupObjectId $changed = [bool]$this.Adapter.RemoveGroupMember($groupObjectId, $user.id, $accessToken) } - else { - throw [System.ArgumentException]::new( - "RevokeEntitlement only supports entitlements with Kind 'Group' or 'AdministrativeUnit'. Received Kind '$($normalized.Kind)'." - ) - } return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' @@ -1011,10 +995,10 @@ function New-IdleEntraIDIdentityProvider { $groupOperations = @() $administrativeUnitResults = @() - foreach ($ent in $Entitlements) { - $normalized = $this.ConvertToEntitlement($ent) - if (-not $normalized.Kind) { $normalized.Kind = 'Group' } + # Normalize all entries first so that an unsupported Kind fails before any membership changes. + $normalizedEntitlements = foreach ($ent in $Entitlements) { $this.ConvertToEntitlement($ent) } + foreach ($normalized in @($normalizedEntitlements)) { if ($normalized.Kind -eq 'Group') { $groupObjectId = $this.ResolveGroup($normalized.Id, $AuthSession) $normalized.Id = $groupObjectId @@ -1026,7 +1010,7 @@ function New-IdleEntraIDIdentityProvider { Entitlement = $normalized } } - elseif ($normalized.Kind -eq 'AdministrativeUnit') { + else { $auObjectId = $this.ResolveAdministrativeUnit($normalized.Id, $AuthSession) $normalized.Id = $auObjectId try { @@ -1051,11 +1035,6 @@ function New-IdleEntraIDIdentityProvider { } } } - else { - throw [System.ArgumentException]::new( - "BulkRevokeEntitlements only supports entitlements with Kind 'Group' or 'AdministrativeUnit'. Received Kind '$($normalized.Kind)'." - ) - } } $results = @() @@ -1109,10 +1088,10 @@ function New-IdleEntraIDIdentityProvider { $groupOperations = @() $administrativeUnitResults = @() - foreach ($ent in $Entitlements) { - $normalized = $this.ConvertToEntitlement($ent) - if (-not $normalized.Kind) { $normalized.Kind = 'Group' } + # Normalize all entries first so that an unsupported Kind fails before any membership changes. + $normalizedEntitlements = foreach ($ent in $Entitlements) { $this.ConvertToEntitlement($ent) } + foreach ($normalized in @($normalizedEntitlements)) { if ($normalized.Kind -eq 'Group') { $groupObjectId = $this.ResolveGroup($normalized.Id, $AuthSession) $normalized.Id = $groupObjectId @@ -1124,7 +1103,7 @@ function New-IdleEntraIDIdentityProvider { Entitlement = $normalized } } - elseif ($normalized.Kind -eq 'AdministrativeUnit') { + else { $auObjectId = $this.ResolveAdministrativeUnit($normalized.Id, $AuthSession) $normalized.Id = $auObjectId try { @@ -1149,11 +1128,6 @@ function New-IdleEntraIDIdentityProvider { } } } - else { - throw [System.ArgumentException]::new( - "BulkGrantEntitlements only supports entitlements with Kind 'Group' or 'AdministrativeUnit'. Received Kind '$($normalized.Kind)'." - ) - } } $results = @() @@ -1186,11 +1160,6 @@ function New-IdleEntraIDIdentityProvider { $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, @@ -1200,27 +1169,20 @@ function New-IdleEntraIDIdentityProvider { [object] $AuthSession ) + # ConvertToEntitlement rejects Kinds other than 'Group' and 'AdministrativeUnit'. $converted = $this.ConvertToEntitlement($Entitlement) - $null = $Kind # Contract parameter — reserved for future multi-Kind dispatch - 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 - } + $canonicalId = if ($converted.Kind -eq 'AdministrativeUnit') { + $this.ResolveAdministrativeUnit($converted.Id, $AuthSession) } - - if ([string]::Equals($converted.Kind, 'AdministrativeUnit', [System.StringComparison]::OrdinalIgnoreCase)) { - $canonicalId = $this.ResolveAdministrativeUnit($converted.Id, $AuthSession) - return [pscustomobject]@{ - PSTypeName = 'IdLE.Entitlement' - Kind = $converted.Kind - Id = $canonicalId - } + else { + $this.ResolveGroup($converted.Id, $AuthSession) } - return $converted + return [pscustomobject]@{ + PSTypeName = 'IdLE.Entitlement' + Kind = $converted.Kind + Id = $canonicalId + } } -Force return $provider diff --git a/src/IdLE.Steps.Common/Private/ConvertTo-IdlePruneEntitlement.ps1 b/src/IdLE.Steps.Common/Private/ConvertTo-IdlePruneEntitlement.ps1 index 87e4be86..a1923f6c 100644 --- a/src/IdLE.Steps.Common/Private/ConvertTo-IdlePruneEntitlement.ps1 +++ b/src/IdLE.Steps.Common/Private/ConvertTo-IdlePruneEntitlement.ps1 @@ -1,5 +1,7 @@ function ConvertTo-IdlePruneEntitlement { # Converts a raw hashtable or object Keep entry into a normalized pscustomobject with Kind and Id. + # Keep entries inherit the prune scope (With.Kind); an explicit Kind must match it, because a Keep + # entry of another Kind could neither protect nor (for EnsureKeep) be granted within this scope. [CmdletBinding()] param( [Parameter(Mandatory)] @@ -8,32 +10,33 @@ function ConvertTo-IdlePruneEntitlement { [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $DefaultKind + [string] $Kind ) - $kind = $null + $entryKind = $null $id = $null if ($Value -is [System.Collections.IDictionary]) { - if ($Value.Contains('Kind')) { $kind = $Value['Kind'] } + if ($Value.Contains('Kind')) { $entryKind = $Value['Kind'] } if ($Value.Contains('Id')) { $id = $Value['Id'] } } else { $props = $Value.PSObject.Properties - if ($props.Name -contains 'Kind') { $kind = $Value.Kind } + if ($props.Name -contains 'Kind') { $entryKind = $Value.Kind } if ($props.Name -contains 'Id') { $id = $Value.Id } } - if ([string]::IsNullOrWhiteSpace([string]$kind)) { - $kind = $DefaultKind - } - if ([string]::IsNullOrWhiteSpace([string]$id)) { throw "PruneEntitlements: each Keep entry requires an Id." } + if (-not [string]::IsNullOrWhiteSpace([string]$entryKind) -and + -not [string]::Equals([string]$entryKind, $Kind, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "PruneEntitlements: Keep entry '$id' has Kind '$entryKind', but the step prunes Kind '$Kind'. Use one prune step per Kind." + } + return [pscustomobject]@{ - Kind = [string]$kind + Kind = $Kind Id = [string]$id } } diff --git a/src/IdLE.Steps.Common/Private/Invoke-IdlePruneEntitlementsOperation.ps1 b/src/IdLE.Steps.Common/Private/Invoke-IdlePruneEntitlementsOperation.ps1 index f4851ef3..55f594a4 100644 --- a/src/IdLE.Steps.Common/Private/Invoke-IdlePruneEntitlementsOperation.ps1 +++ b/src/IdLE.Steps.Common/Private/Invoke-IdlePruneEntitlementsOperation.ps1 @@ -46,7 +46,7 @@ function Invoke-IdlePruneEntitlementsOperation { $keepItems = @() if ($With.ContainsKey('Keep')) { foreach ($item in @($With.Keep)) { - $keepItems += ConvertTo-IdlePruneEntitlement -Value $item -DefaultKind $kind + $keepItems += ConvertTo-IdlePruneEntitlement -Value $item -Kind $kind } } @@ -58,7 +58,7 @@ function Invoke-IdlePruneEntitlementsOperation { # correctly 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.HasMethod('ResolveEntitlement')) { - $keepItems = @($keepItems | ForEach-Object { $Provider.Invoke('ResolveEntitlement', @($kind, $_)) }) + $keepItems = @($keepItems | ForEach-Object { $Provider.Invoke('ResolveEntitlement', @($_)) }) } # 1. List current entitlements, filter by Kind. diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 index 257fd44c..3ca91333 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 @@ -15,6 +15,9 @@ function Invoke-IdleStepEnsureEntitlement { The step is idempotent and only calls Grant/Revoke when the assignment needs to change. + With.Entitlement.Kind selects the entitlement type (e.g., 'Group', 'AdministrativeUnit'). + The provider rejects a Kind it does not support (e.g., 'AdministrativeUnit' on AD). + Authentication is handled by the engine (With.AuthSessionName, With.AuthSessionOptions). .PARAMETER With diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 index a714c81b..8cc502f9 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -15,8 +15,14 @@ function Invoke-IdleStepPruneEntitlements { explicit Keep entries are present after the prune (e.g., a leaver-retention group must be granted if it is missing). + With.Kind is the prune scope: only current entitlements of this Kind are considered for removal. + The provider rejects a Kind it does not support (e.g., 'AdministrativeUnit' on AD) before anything + is removed. To prune several Kinds, use one step per Kind. + How the keep-set is built: - - With.Keep — explicit entitlement references (kept AND matched case-insensitively by Id) + - With.Keep — explicit entitlement references (kept AND matched case-insensitively by Id). + Entries inherit With.Kind; an entry may repeat Kind, but a different Kind fails + the step before anything is removed. - With.KeepPattern — wildcard strings (-like semantics); any current entitlement whose Id matches is kept. Patterns are NEVER granted, only protected from removal. @@ -81,7 +87,7 @@ function Invoke-IdleStepPruneEntitlements { 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' } + @{ Id = 'CN=All-Users,OU=Groups,DC=contoso,DC=com' } ) KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com') AuthSessionName = 'Directory' diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 index 1edb4c3b..c7f98629 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 @@ -20,6 +20,11 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { granting them, run IdLE.Step.PruneEntitlements or another cleanup step before this EnsureKeep step. + With.Kind is the prune scope: only current entitlements of this Kind are removed, and Keep + entries are granted with this Kind. Keep entries inherit With.Kind; an entry may repeat Kind, but + a different Kind fails the step before anything is changed. The provider rejects a Kind it does + not support (e.g., 'AdministrativeUnit' on AD). To prune several Kinds, use one step per Kind. + 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. @@ -70,7 +75,7 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { 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' } + @{ 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. @@ -91,7 +96,7 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { Kind = 'License' # This license is KEPT and GRANTED if missing — always present after this step. Keep = @( - @{ Kind = 'License'; Id = 'BASELINE-E1' } + @{ Id = 'BASELINE-E1' } ) AuthSessionName = 'Licensing' } diff --git a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 index 14848835..09b98d82 100644 --- a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 +++ b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 @@ -1143,4 +1143,140 @@ Describe 'New-IdlePlan - ContextResolvers' { $stepResult.Status | Should -Be 'Completed' } } + + Context 'IdLE.Entitlement.List With.Kind' { + BeforeAll { + function script:New-KindResolverWorkflow { + param([object[]] $ResolverWith) + + $resolvers = foreach ($with in $ResolverWith) { + $pairs = foreach ($key in $with.Keys) { + $value = $with[$key] + if ($value -is [string]) { "$key = '$value'" } else { "$key = $value" } + } + "@{ Capability = 'IdLE.Entitlement.List'; With = @{ $($pairs -join '; ') } }" + } + + $path = Join-Path $TestDrive "kind-resolver-$([guid]::NewGuid().ToString('N')).psd1" + Set-Content -Path $path -Encoding utf8 -Value @" +@{ + Name = 'Kind Resolver' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + $($resolvers -join "`n ") + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} +"@ + return $path + } + + function script:New-KindListProvider { + param([switch] $WithKindParameter) + + $provider = [pscustomobject]@{ CapturedKind = '' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { return @('IdLE.Entitlement.List') } + if ($WithKindParameter) { + $provider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { + param([string] $IdentityKey, [object] $AuthSession, [string] $Kind) + $this.CapturedKind = $Kind + # Returns every Kind to verify the engine's own filter. + return @(@{ Kind = 'Group'; Id = 'g1' }, @{ Kind = 'AdministrativeUnit'; Id = 'au1' }) + } + } + else { + $provider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { + param([string] $IdentityKey) + return @([pscustomobject]@{ Kind = 'Group'; Id = 'g1' }, [pscustomobject]@{ Kind = 'Role'; Id = 'r1' }) + } + } + + return $provider + } + } + + It 'passes Kind to a provider that declares it and filters the result' { + $provider = New-KindListProvider -WithKindParameter + $wfPath = New-KindResolverWorkflow -ResolverWith @(@{ IdentityKey = 'user1'; Provider = 'Identity'; Kind = 'Group' }) + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request (New-IdleTestRequest -LifecycleEvent 'Joiner') -Providers @{ Identity = $provider } + + $provider.CapturedKind | Should -Be 'Group' + $entitlements = @($plan.Request.Context.Providers.Identity.Default.Identity.Entitlements) + $entitlements.Count | Should -Be 1 + $entitlements[0].Id | Should -Be 'g1' + @($plan.Request.Context.Views.Identity.Entitlements).Count | Should -Be 1 + } + + It 'filters the result for a provider without a Kind parameter' { + $provider = New-KindListProvider + $wfPath = New-KindResolverWorkflow -ResolverWith @(@{ IdentityKey = 'user1'; Provider = 'Identity'; Kind = 'role' }) + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request (New-IdleTestRequest -LifecycleEvent 'Joiner') -Providers @{ Identity = $provider } + + $entitlements = @($plan.Request.Context.Providers.Identity.Default.Identity.Entitlements) + $entitlements.Count | Should -Be 1 + $entitlements[0].Id | Should -Be 'r1' + } + + It 'lists all Kinds without With.Kind' { + $provider = New-KindListProvider -WithKindParameter + $wfPath = New-KindResolverWorkflow -ResolverWith @(@{ IdentityKey = 'user1'; Provider = 'Identity' }) + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request (New-IdleTestRequest -LifecycleEvent 'Joiner') -Providers @{ Identity = $provider } + + $provider.CapturedKind | Should -BeNullOrEmpty + @($plan.Request.Context.Providers.Identity.Default.Identity.Entitlements).Count | Should -Be 2 + } + + It 'rejects a With.Kind that is not a non-empty string' { + $provider = New-KindListProvider -WithKindParameter + $wfPath = New-KindResolverWorkflow -ResolverWith @(@{ IdentityKey = 'user1'; Provider = 'Identity'; Kind = 5 }) + + { New-IdlePlan -WorkflowPath $wfPath -Request (New-IdleTestRequest -LifecycleEvent 'Joiner') -Providers @{ Identity = $provider } } | + Should -Throw -ExpectedMessage "*'ContextResolvers``[0``].With.Kind' must be a non-empty string*" + } + + It 'rejects With.Kind for capabilities other than IdLE.Entitlement.List' { + $wfPath = Join-Path $TestDrive 'kind-identity-read.psd1' + Set-Content -Path $wfPath -Encoding utf8 -Value @' +@{ + Name = 'Kind on Identity.Read' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ Capability = 'IdLE.Identity.Read'; With = @{ IdentityKey = 'user1'; Kind = 'Group' } } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} +'@ + + { Test-IdleWorkflow -WorkflowPath $wfPath } | + Should -Throw -ExpectedMessage "*'ContextResolvers``[0``].With.Kind' is only supported for capability 'IdLE.Entitlement.List'*" + } + + It 'rejects a template-resolved With.Kind that is not a string at planning time' { + $provider = New-KindListProvider -WithKindParameter + $wfPath = New-KindResolverWorkflow -ResolverWith @(@{ IdentityKey = 'user1'; Provider = 'Identity'; Kind = '{{Request.Intent.KindNumber}}' }) + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ KindNumber = 5 } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Identity = $provider } } | + Should -Throw -ExpectedMessage '*With.Kind must be a non-empty string*' + } + + It 'rejects a second resolver for the same capability, provider and auth session' { + # Regression: the second resolver silently replaced the first result (e.g. Group by AdministrativeUnit). + $provider = New-KindListProvider -WithKindParameter + $wfPath = New-KindResolverWorkflow -ResolverWith @( + @{ IdentityKey = 'user1'; Provider = 'Identity'; Kind = 'Group' } + @{ IdentityKey = 'user1'; Provider = 'Identity'; Kind = 'AdministrativeUnit' } + ) + + { New-IdlePlan -WorkflowPath $wfPath -Request (New-IdleTestRequest -LifecycleEvent 'Joiner') -Providers @{ Identity = $provider } } | + Should -Throw -ExpectedMessage "*ContextResolvers``[1``] resolves capability 'IdLE.Entitlement.List' for provider 'Identity' and auth session 'Default', which ContextResolvers``[0``] already resolves*" + } + } } diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index 5624cb3c..26a6304e 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -2094,16 +2094,61 @@ Describe 'AD identity provider' { 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 = $script:NormProvider.ResolveEntitlement($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' { + It 'Returns the canonical Kind casing' { + $ent = @{ Kind = 'group'; Id = 'CN=TestGroup,OU=Groups,DC=domain,DC=local' } + $result = $script:NormProvider.ResolveEntitlement($ent, $null) + $result.Kind | Should -BeExactly 'Group' + } + + It 'Throws for a Kind other than 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' + { $script:NormProvider.ResolveEntitlement($ent, $null) } | + Should -Throw -ExpectedMessage "*Kind 'License' is not supported by the AD provider*" + } + } + + Context 'Entitlement Kind validation' { + BeforeEach { + $script:KindAdapter = New-FakeADAdapter + $script:KindProvider = New-IdleADIdentityProvider -Adapter $script:KindAdapter + $testUser = $script:KindAdapter.NewUser('KindUser', @{ SamAccountName = 'kinduser' }, $true) + $script:KindUserId = $testUser.ObjectGuid.ToString() + } + + It 'GrantEntitlement rejects an AdministrativeUnit instead of resolving a group with the same name' { + # Regression: Kind was ignored and the Id was resolved as a group. + $script:KindProvider.GrantEntitlement($script:KindUserId, @{ Kind = 'Group'; Id = 'CN=Sales,OU=Groups,DC=domain,DC=local' }) | Out-Null + $script:KindProvider.RevokeEntitlement($script:KindUserId, @{ Kind = 'Group'; Id = 'CN=Sales,OU=Groups,DC=domain,DC=local' }) | Out-Null + + { $script:KindProvider.GrantEntitlement($script:KindUserId, @{ Kind = 'AdministrativeUnit'; Id = 'CN=Sales,OU=Groups,DC=domain,DC=local' }) } | + Should -Throw -ExpectedMessage "*Kind 'AdministrativeUnit' is not supported by the AD provider. Supported Kinds: Group.*" + + @($script:KindProvider.ListEntitlements($script:KindUserId)).Count | Should -Be 0 + } + + It 'RevokeEntitlement rejects an unsupported Kind' { + { $script:KindProvider.RevokeEntitlement($script:KindUserId, @{ Kind = 'Groups'; Id = 'CN=Sales,OU=Groups,DC=domain,DC=local' }) } | + Should -Throw -ExpectedMessage "*Kind 'Groups' is not supported by the AD provider*" + } + + It 'Accepts Group case-insensitively and returns the canonical casing' { + $result = $script:KindProvider.GrantEntitlement($script:KindUserId, @{ Kind = 'GROUP'; Id = 'CN=Sales,OU=Groups,DC=domain,DC=local' }) + $result.Entitlement.Kind | Should -BeExactly 'Group' + } + + It 'ListEntitlements accepts Kind = Group' { + $script:KindProvider.GrantEntitlement($script:KindUserId, @{ Kind = 'Group'; Id = 'CN=Sales,OU=Groups,DC=domain,DC=local' }) | Out-Null + @($script:KindProvider.ListEntitlements($script:KindUserId, $null, 'Group')).Count | Should -Be 1 + } + + It 'ListEntitlements rejects an unsupported Kind' { + { $script:KindProvider.ListEntitlements($script:KindUserId, $null, 'AdministrativeUnit') } | + Should -Throw -ExpectedMessage "*Kind 'AdministrativeUnit' is not supported by the AD provider*" } } @@ -2183,6 +2228,25 @@ Describe 'AD identity provider' { $remaining[0].Id | Should -Be 'CN=New-Leaver-Group,OU=Groups,DC=domain,DC=local' } + It 'fails without changes when pruning a Kind the AD provider does not support' { + # Regression: the prune silently found nothing to remove and reported success. + $step = [pscustomobject]@{ + Name = 'Prune AUs on AD' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + With = @{ + IdentityKey = $script:PruneUserId + Provider = 'AD' + Kind = 'AdministrativeUnit' + Keep = @(@{ Id = 'CN=Keep-Group,OU=Groups,DC=domain,DC=local' }) + } + } + + { Invoke-IdleTestStepHandler -Context $script:StepContext -Step $step } | + Should -Throw -ExpectedMessage "*Kind 'AdministrativeUnit' is not supported by the AD provider*" + + @($script:ADProvider.ListEntitlements($script:PruneUserId)).Count | Should -Be 3 + } + It 'does NOT remove the primary group even when no Keep is specified (prune all)' { $step = [pscustomobject]@{ Name = 'Prune AD groups (no keep)' diff --git a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 index ed729d81..c2d38292 100644 --- a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 +++ b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 @@ -1302,7 +1302,7 @@ Describe 'EntraID identity provider - Entitlement operations' { [void]$script:EntProvider.GetIdentity($userId) { $script:EntProvider.GrantEntitlement($userId, @{ Kind = 'Unknown'; Id = [guid]::NewGuid().ToString() }) } | - Should -Throw "*Kind 'Group' or 'AdministrativeUnit'*" + Should -Throw "*Kind 'Unknown' is not supported by the Entra ID provider. Supported Kinds: Group, AdministrativeUnit.*" } It 'RevokeEntitlement throws for unsupported Kind' { @@ -1310,7 +1310,55 @@ Describe 'EntraID identity provider - Entitlement operations' { [void]$script:EntProvider.GetIdentity($userId) { $script:EntProvider.RevokeEntitlement($userId, @{ Kind = 'Unknown'; Id = [guid]::NewGuid().ToString() }) } | - Should -Throw "*Kind 'Group' or 'AdministrativeUnit'*" + Should -Throw "*Kind 'Unknown' is not supported by the Entra ID provider*" + } + + It 'ListEntitlements throws for an unsupported Kind instead of returning nothing' { + # Regression: a misspelled Kind ('Groups') silently matched nothing, so a prune removed nothing. + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + + { $script:EntProvider.ListEntitlements($userId, $null, 'Groups') } | + Should -Throw "*Kind 'Groups' is not supported by the Entra ID provider*" + } + + It 'Accepts Kind case-insensitively and returns the canonical casing' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + $auId = [guid]::NewGuid().ToString() + + $result = $script:EntProvider.GrantEntitlement($userId, @{ Kind = 'administrativeunit'; Id = $auId }) + $result.Entitlement.Kind | Should -BeExactly 'AdministrativeUnit' + + $entitlements = @($script:EntProvider.ListEntitlements($userId, $null, 'ADMINISTRATIVEUNIT')) + @($entitlements | Where-Object { $_.Id -eq $auId }).Count | Should -Be 1 + } + + It 'BulkRevokeEntitlements rejects an unsupported Kind before changing any membership' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + $auId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GrantEntitlement($userId, @{ Kind = 'AdministrativeUnit'; Id = $auId }) + + { $script:EntProvider.BulkRevokeEntitlements($userId, @( + @{ Kind = 'AdministrativeUnit'; Id = $auId }, + @{ Kind = 'Unknown'; Id = 'x' } + )) } | Should -Throw "*Kind 'Unknown' is not supported by the Entra ID provider*" + + @($script:EntProvider.ListEntitlements($userId) | Where-Object { $_.Id -eq $auId }).Count | Should -Be 1 + } + + It 'BulkGrantEntitlements rejects an unsupported Kind before changing any membership' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + $auId = [guid]::NewGuid().ToString() + + { $script:EntProvider.BulkGrantEntitlements($userId, @( + @{ Kind = 'AdministrativeUnit'; Id = $auId }, + @{ Kind = 'Unknown'; Id = 'x' } + )) } | Should -Throw "*Kind 'Unknown' is not supported by the Entra ID provider*" + + @($script:EntProvider.ListEntitlements($userId)).Count | Should -Be 0 } } } @@ -1662,14 +1710,14 @@ Describe 'EntraID identity provider - ResolveEntitlement' { 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, 'mock-token') + $result = $script:NormProvider.ResolveEntitlement($ent, 'mock-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, 'mock-token') + $result = $script:NormProvider.ResolveEntitlement($ent, 'mock-token') $result.Kind | Should -Be 'Group' $result.Id | Should -Be 'resolved-HR Team' } @@ -1677,23 +1725,22 @@ Describe 'EntraID identity provider - ResolveEntitlement' { It 'Normalizes an AdministrativeUnit entitlement with a GUID Id to canonical objectId' { $auGuid = [guid]::NewGuid().ToString() $ent = @{ Kind = 'AdministrativeUnit'; Id = $auGuid } - $result = $script:NormProvider.ResolveEntitlement('AdministrativeUnit', $ent, 'mock-token') + $result = $script:NormProvider.ResolveEntitlement($ent, 'mock-token') $result.Kind | Should -Be 'AdministrativeUnit' $result.Id | Should -Be $auGuid } It 'Normalizes an AdministrativeUnit entitlement with a displayName to canonical objectId' { $ent = @{ Kind = 'AdministrativeUnit'; Id = 'EU Region Admins' } - $result = $script:NormProvider.ResolveEntitlement('AdministrativeUnit', $ent, 'mock-token') + $result = $script:NormProvider.ResolveEntitlement($ent, 'mock-token') $result.Kind | Should -Be 'AdministrativeUnit' $result.Id | Should -Be 'resolved-au-EU Region Admins' } - It 'Returns entitlement unchanged when Kind is not Group' { + It 'Throws for an unsupported Kind' { $ent = [pscustomobject]@{ Kind = 'License'; Id = 'Some-License-Id' } - $result = $script:NormProvider.ResolveEntitlement('License', $ent, 'mock-token') - $result.Kind | Should -Be 'License' - $result.Id | Should -Be 'Some-License-Id' + { $script:NormProvider.ResolveEntitlement($ent, 'mock-token') } | + Should -Throw "*Kind 'License' is not supported by the Entra ID provider*" } } } diff --git a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 index 82f97adb..caeff390 100644 --- a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 @@ -386,7 +386,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } -Force $mockProvider | Add-Member -MemberType ScriptMethod -Name ResolveEntitlement -Value { - param($Kind, $Entitlement, $AuthSession) + param($Entitlement, $AuthSession) # Map short sAMAccountName-style IDs to canonical DNs $idMap = @{ 'G-Keep' = 'CN=G-Keep,OU=Groups,DC=contoso,DC=com' @@ -416,7 +416,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { Provider = 'Identity' Keep = @( @{ Kind = 'Group'; Id = 'G-Keep' }, # short/non-canonical ID - @{ Kind = 'Group'; Id = 'G-All' } # short/non-canonical ID + @{ Id = 'G-All' } # short/non-canonical ID, Kind from With.Kind ) } } @@ -431,6 +431,106 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { $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' } + + It 'passes only the Keep entitlement (with the scope Kind) to ResolveEntitlement' { + $mockProvider = [pscustomobject]@{ PSTypeName = 'MockResolveProvider'; ResolveCalls = @() } + $mockProvider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { + param($IdentityKey) + return @() + } -Force + $mockProvider | Add-Member -MemberType ScriptMethod -Name ResolveEntitlement -Value { + param($Entitlement, $AuthSession) + $this.ResolveCalls += $Entitlement + return $Entitlement + } -Force + $mockProvider | Add-Member -MemberType ScriptMethod -Name RevokeEntitlement -Value { + param($IdentityKey, $Entitlement) + return [pscustomobject]@{ Changed = $true } + } -Force + $script:Context.Providers['Identity'] = $mockProvider + + $step = [pscustomobject]@{ + Name = 'Prune' + Type = 'IdLE.Step.PruneEntitlements' + With = @{ IdentityKey = 'user1'; Kind = 'Group'; Keep = @(@{ Id = 'G-Keep' }) } + } + + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + @($mockProvider.ResolveCalls).Count | Should -Be 1 + $mockProvider.ResolveCalls[0].Kind | Should -Be 'Group' + $mockProvider.ResolveCalls[0].Id | Should -Be 'G-Keep' + } + } + + Context 'Behavior: Keep entry Kind' { + It 'applies With.Kind to Keep entries without Kind' { + $step = $script:StepTemplate + $step.With.Keep = @(@{ Id = 'CN=LEAVER-RETAIN,DC=contoso,DC=com' }) + + $result = Invoke-IdleTestStepHandler -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=LEAVER-RETAIN,DC=contoso,DC=com' + } + + It 'accepts a Keep entry that repeats With.Kind in a different casing' { + $step = $script:StepTemplate + $step.With.Keep = @(@{ Kind = 'group'; Id = 'CN=LEAVER-RETAIN,DC=contoso,DC=com' }) + + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + @($script:Provider.ListEntitlements('user1')).Count | Should -Be 1 + } + + It 'fails without changes when a Keep entry has a different Kind ()' -ForEach @( + @{ Type = 'IdLE.Step.PruneEntitlements' } + @{ Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' } + ) { + # Regression: the entry was matched by Id only and EnsureKeep granted it with the foreign Kind. + $step = [pscustomobject]@{ + Name = 'Prune with mixed Keep' + Type = $Type + With = @{ + IdentityKey = 'user1' + Provider = 'Identity' + Kind = 'Group' + Keep = @(@{ Kind = 'Role'; Id = 'admin-role' }) + } + } + + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | + Should -Throw -ExpectedMessage "*Keep entry 'admin-role' has Kind 'Role', but the step prunes Kind 'Group'*" + + $remaining = @($script:Provider.ListEntitlements('user1')) + $remaining.Count | Should -Be 4 + @($remaining | Where-Object { $_.Kind -eq 'Role' }).Count | Should -Be 0 + } + + It 'grants a missing Keep entry without Kind using With.Kind (EnsureKeep)' { + $step = [pscustomobject]@{ + Name = 'Prune and ensure' + Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' + With = @{ + IdentityKey = 'user1' + Provider = 'Identity' + Kind = 'Group' + Keep = @(@{ Id = 'CN=NEW-RETAIN,DC=contoso,DC=com' }) + } + } + + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $remaining = @($script:Provider.ListEntitlements('user1')) + $remaining.Count | Should -Be 1 + $remaining[0].Kind | Should -Be 'Group' + $remaining[0].Id | Should -Be 'CN=NEW-RETAIN,DC=contoso,DC=com' + } } Context 'Behavior: step uses BulkRevokeEntitlements when provider exposes it' {