From fecffb02ddef01ba5161ba08b11e0f8b42158e71 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:27:10 +0100 Subject: [PATCH 1/7] docs: design for entitlement Kind handling (#289) Co-Authored-By: Claude Opus 5.5 --- docs/develop/design/entitlement-kind.md | 107 ++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/develop/design/entitlement-kind.md diff --git a/docs/develop/design/entitlement-kind.md b/docs/develop/design/entitlement-kind.md new file mode 100644 index 00000000..58b50958 --- /dev/null +++ b/docs/develop/design/entitlement-kind.md @@ -0,0 +1,107 @@ +--- +title: "Design: Entitlement Kind" +sidebar_label: "Design: Entitlement Kind" +--- + +# Design: Entitlement Kind + +| | | +| --- | --- | +| **Status** | Proposed | +| **Issue** | [#289](https://github.com/blindzero/IdentityLifecycleEngine/issues/289) | +| **Target** | v1.0.0 | +| **Affects** | `IdLE.Step.PruneEntitlements`, `IdLE.Step.PruneEntitlementsEnsureKeep`, entitlement provider contract (`ListEntitlements`, `ResolveEntitlement`, Kind validation), AD / Entra ID providers, context resolver `IdLE.Entitlement.List` | +| **Unchanged** | `IdLE.Step.EnsureEntitlement` schema (`With.Entitlement.Kind`), Mock provider Kind handling, Host API | +| **Compatibility** | Breaking, no compatibility layer (as #324) | + +## Problem + +An entitlement is identified by `Kind` + `Id`. Today `Kind` is handled inconsistently: + +- `PruneEntitlements*` require `With.Kind` (the prune scope) and accept an optional `Keep[].Kind`, which repeats the + scope (`Kind = 'Group'` plus `Keep = @(@{ Kind = 'Group'; Id = ... })`). A Keep entry with a different Kind is + resolved with the scope Kind, matched by `Id` only, and granted with its own Kind by `PruneEntitlementsEnsureKeep`. +- `ResolveEntitlement(Kind, Entitlement)` carries a second Kind that providers ignore. +- Providers do not reject Kinds they do not support. The AD provider treats every Kind as a group + (`Kind = 'AdministrativeUnit'; Id = 'Sales'` adds the user to a group named `Sales`) and a prune with a foreign Kind + silently removes nothing. A misspelled Kind (`'Groups'`) behaves the same way on Entra ID. +- The context resolver `IdLE.Entitlement.List` lists all Kinds. On Entra ID it always reads Administrative Units, so + group-only workflows need `AdministrativeUnit.Read.All`. + +## Principle + +`Kind` belongs to the entitlement reference. A step-level Kind exists only where it defines a **scope** (what a prune +may remove, what a resolver lists). Providers own the set of Kinds they support and reject everything else before +changing anything. + +## 1. EnsureEntitlement + +No change: `With.Entitlement = @{ Kind; Id; DisplayName }`, one entitlement per step. Multiple entitlements per step +are out of scope. + +## 2. PruneEntitlements / PruneEntitlementsEnsureKeep + +- `With.Kind` stays required: the explicit scope of what may be removed. +- `Keep[].Kind` is optional. When set it must equal `With.Kind` (case-insensitive); otherwise the step fails before + listing or changing anything: + `PruneEntitlements: Keep entry 'X' has Kind 'AdministrativeUnit', but the step prunes Kind 'Group'.` +- Keep entries are resolved and granted with `With.Kind`. +- Examples omit the redundant `Keep[].Kind`. + +Mixed Kinds in one prune are not supported; use one step per Kind. + +## 3. Provider contract + +| Method | Contract | +| --- | --- | +| `ListEntitlements(IdentityKey[, AuthSession][, Kind])` | Optional `Kind` filters the result. An unsupported `Kind` throws. | +| `GrantEntitlement(IdentityKey, Entitlement[, AuthSession])` | Throws for an unsupported `Entitlement.Kind`. | +| `RevokeEntitlement(IdentityKey, Entitlement[, AuthSession])` | Throws for an unsupported `Entitlement.Kind`. | +| `ResolveEntitlement(Entitlement[, AuthSession])` (optional) | **Changed:** no `Kind` parameter; uses `Entitlement.Kind`. | +| `BulkGrantEntitlements` / `BulkRevokeEntitlements` (optional) | Unsupported Kinds throw (not reported per item). | + +- Kind comparison is case-insensitive; providers return the canonical casing. +- Unsupported Kind error: `Entitlement Kind 'AdministrativeUnit' is not supported by this provider. Supported Kinds: Group.` +- Because both prune steps call `ListEntitlements(Kind)` first, an unsupported scope fails before any revoke. + +| Provider | Supported Kinds | +| --- | --- | +| AD | `Group` (new: `ListEntitlements` accepts `Kind`) | +| Entra ID | `Group`, `AdministrativeUnit` (the unreachable "missing Kind defaults to Group" branches are removed) | +| Mock | any non-empty Kind (test provider) | + +Plan-time Kind validation (providers declaring their Kinds) is deferred to after v1.0; it can be added as an optional +provider method without breaking this contract. + +## 4. Context resolver `IdLE.Entitlement.List` + +- New optional `With.Kind` (non-empty string). The engine passes it to `ListEntitlements` when the method declares + `Kind`, and always filters the result by Kind, so providers without the parameter behave the same. +- Without `With.Kind` the behavior is unchanged (all Kinds). +- **Default (proposed):** two resolvers with the same capability, provider and auth session fail with an error instead + of silently overwriting each other's `Request.Context.Providers...*` result. To list several Kinds + of one provider, omit `With.Kind` and filter in the Condition. + +## 5. Migration + +| Before | After | +| --- | --- | +| `Keep = @(@{ Kind = 'Group'; Id = 'g1' })` with `Kind = 'Group'` | `Keep = @(@{ Id = 'g1' })` (the old form stays valid) | +| `Keep[].Kind` different from `With.Kind` | Error; use one prune step per Kind. | +| Provider `ResolveEntitlement($Kind, $Entitlement, $AuthSession)` | `ResolveEntitlement($Entitlement, $AuthSession)` | +| AD entitlement step with a non-`Group` Kind | Error (was: treated as a group). | +| Entra ID group-only resolver needed `AdministrativeUnit.Read.All` | Add `With.Kind = 'Group'`. | + +## 6. Tests + +- Prune: Keep entry without Kind uses the scope; mismatching Keep Kind fails before any provider call that changes + state (regression); `ResolveEntitlement` receives only the entitlement. +- AD / Entra ID: unsupported Kind throws in List/Grant/Revoke/Resolve (and Bulk for Entra ID); canonical casing. +- Resolver: `With.Kind` passed through and filtered (provider with and without `Kind`); duplicate resolver fails. +- Entitlement provider contract tests: ResolveEntitlement signature when implemented. + +## Documentation + +Folded into `docs/extend/providers.md` (entitlement provider contract), the step help (generated step reference), +`docs/use/workflows/context-resolver.md`, `docs/reference/capabilities.md` and the provider references; this design +document is then removed. From c1f9baa023b2edaf9109ce95781efbe7e352ff68 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:34:55 +0100 Subject: [PATCH 2/7] provider: reject unsupported entitlement Kinds, drop Kind from ResolveEntitlement (#289) - AD supports Kind 'Group' only. Grant/Revoke/Resolve no longer resolve any Kind as a group, and ListEntitlements accepts and validates Kind. - Entra ID supports 'Group' and 'AdministrativeUnit'. ListEntitlements throws for other Kinds instead of returning nothing; the bulk methods validate all entries before changing any membership; unreachable "default to Group" branches removed. - Kind is returned in canonical casing. - ResolveEntitlement(Entitlement[, AuthSession]) takes the Kind from the entitlement; the unused Kind parameter is removed. Co-Authored-By: Claude Opus 5.5 --- .../Private/ConvertTo-IdleADEntitlement.ps1 | 5 +- .../Private/Get-IdleADEntitlementKind.ps1 | 31 +++++ .../Public/New-IdleADIdentityProvider.ps1 | 33 +++--- .../ConvertTo-IdleEntraIDEntitlement.ps1 | 7 +- .../Get-IdleEntraIDEntitlementKind.ps1 | 33 ++++++ .../New-IdleEntraIDIdentityProvider.ps1 | 106 ++++++------------ .../Invoke-IdlePruneEntitlementsOperation.ps1 | 2 +- tests/Providers/ADIdentityProvider.Tests.ps1 | 74 +++++++++++- .../EntraIDIdentityProvider.Tests.ps1 | 67 +++++++++-- ...Invoke-IdleStepPruneEntitlements.Tests.ps1 | 2 +- 10 files changed, 250 insertions(+), 110 deletions(-) create mode 100644 src/IdLE.Provider.AD/Private/Get-IdleADEntitlementKind.ps1 create mode 100644 src/IdLE.Provider.EntraID/Private/Get-IdleEntraIDEntitlementKind.ps1 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/Invoke-IdlePruneEntitlementsOperation.ps1 b/src/IdLE.Steps.Common/Private/Invoke-IdlePruneEntitlementsOperation.ps1 index f4851ef3..c93d803e 100644 --- a/src/IdLE.Steps.Common/Private/Invoke-IdlePruneEntitlementsOperation.ps1 +++ b/src/IdLE.Steps.Common/Private/Invoke-IdlePruneEntitlementsOperation.ps1 @@ -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/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..37b9d495 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' From b60959181f8538c5d508e5511ed2914f11179a57 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:34:55 +0100 Subject: [PATCH 3/7] steps: Keep entries inherit the prune Kind (#289) PruneEntitlements and PruneEntitlementsEnsureKeep apply With.Kind to Keep entries. An explicit Keep[].Kind must match With.Kind; a different Kind fails the step before anything is listed or changed (it was matched by Id only and granted with the foreign Kind by EnsureKeep). Step help and examples omit the redundant Keep[].Kind. Co-Authored-By: Claude Opus 5.5 --- .../ConvertTo-IdlePruneEntitlement.ps1 | 21 ++-- .../Invoke-IdlePruneEntitlementsOperation.ps1 | 2 +- .../Invoke-IdleStepEnsureEntitlement.ps1 | 3 + .../Invoke-IdleStepPruneEntitlements.ps1 | 10 +- ...ke-IdleStepPruneEntitlementsEnsureKeep.ps1 | 9 +- ...Invoke-IdleStepPruneEntitlements.Tests.ps1 | 102 +++++++++++++++++- 6 files changed, 132 insertions(+), 15 deletions(-) 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 c93d803e..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 } } 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/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 index 37b9d495..caeff390 100644 --- a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 @@ -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' { From 0ae5e160c6a4e78164c7bccf2c49745683efeb22 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:34:55 +0100 Subject: [PATCH 4/7] core: With.Kind for IdLE.Entitlement.List resolvers, reject duplicate resolvers (#289) - IdLE.Entitlement.List resolvers accept an optional With.Kind. It is passed to ListEntitlements when the provider declares Kind (Entra ID then skips Administrative Unit calls and their Graph permissions), and the result is always filtered to that Kind. - A second resolver for the same capability, provider and auth session fails instead of silently replacing the first result. Co-Authored-By: Claude Opus 5.5 --- .../Private/Invoke-IdleContextResolvers.ps1 | 53 ++++++++- .../New-IdlePlan.ContextResolvers.Tests.ps1 | 108 ++++++++++++++++++ 2 files changed, 157 insertions(+), 4 deletions(-) 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/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 index 14848835..978c2e16 100644 --- a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 +++ b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 @@ -1143,4 +1143,112 @@ 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 '*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*" + } + } } From ca24b91a4766bc55c00bf14b3ff6a6b0efb4fa7a Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:35:21 +0100 Subject: [PATCH 5/7] docs: drop redundant Keep Kind from examples, show resolver With.Kind (#289) Co-Authored-By: Claude Opus 5.5 --- examples/workflows/mock/joiner-with-context-resolvers.psd1 | 3 +++ examples/workflows/templates/ad-leaver.psd1 | 2 +- examples/workflows/templates/entraid-leaver.psd1 | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) 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}}' } ) } } From f946c71ca3321f24991eecc2ba6c1491324f9da1 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:38:08 +0100 Subject: [PATCH 6/7] docs: document entitlement Kinds and fold the design into the docs (#289) - docs/extend/providers.md: entitlement provider contract (methods, supported Kinds per provider, ResolveEntitlement migration). - Context resolver docs and capability reference: With.Kind for IdLE.Entitlement.List, one resolver per capability/provider/session. - Provider references: supported Kinds; Entra ID resolver permissions note. - Regenerated step and cmdlet references. - Remove the design document. Co-Authored-By: Claude Opus 5.5 --- docs/develop/design/entitlement-kind.md | 107 ------------------ docs/extend/providers.md | 32 ++++++ docs/reference/capabilities.md | 10 +- .../cmdlets/New-IdleADIdentityProvider.md | 1 + .../New-IdleEntraIDIdentityProvider.md | 1 + docs/reference/providers/provider-ad.md | 2 +- docs/reference/providers/provider-entraID.md | 6 +- docs/reference/providers/provider-mock.md | 2 +- .../steps/step-ensure-entitlement.md | 3 + .../step-prune-entitlements-ensure-keep.md | 9 +- .../steps/step-prune-entitlements.md | 10 +- docs/use/workflows/context-resolver.md | 14 +++ 12 files changed, 78 insertions(+), 119 deletions(-) delete mode 100644 docs/develop/design/entitlement-kind.md diff --git a/docs/develop/design/entitlement-kind.md b/docs/develop/design/entitlement-kind.md deleted file mode 100644 index 58b50958..00000000 --- a/docs/develop/design/entitlement-kind.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -title: "Design: Entitlement Kind" -sidebar_label: "Design: Entitlement Kind" ---- - -# Design: Entitlement Kind - -| | | -| --- | --- | -| **Status** | Proposed | -| **Issue** | [#289](https://github.com/blindzero/IdentityLifecycleEngine/issues/289) | -| **Target** | v1.0.0 | -| **Affects** | `IdLE.Step.PruneEntitlements`, `IdLE.Step.PruneEntitlementsEnsureKeep`, entitlement provider contract (`ListEntitlements`, `ResolveEntitlement`, Kind validation), AD / Entra ID providers, context resolver `IdLE.Entitlement.List` | -| **Unchanged** | `IdLE.Step.EnsureEntitlement` schema (`With.Entitlement.Kind`), Mock provider Kind handling, Host API | -| **Compatibility** | Breaking, no compatibility layer (as #324) | - -## Problem - -An entitlement is identified by `Kind` + `Id`. Today `Kind` is handled inconsistently: - -- `PruneEntitlements*` require `With.Kind` (the prune scope) and accept an optional `Keep[].Kind`, which repeats the - scope (`Kind = 'Group'` plus `Keep = @(@{ Kind = 'Group'; Id = ... })`). A Keep entry with a different Kind is - resolved with the scope Kind, matched by `Id` only, and granted with its own Kind by `PruneEntitlementsEnsureKeep`. -- `ResolveEntitlement(Kind, Entitlement)` carries a second Kind that providers ignore. -- Providers do not reject Kinds they do not support. The AD provider treats every Kind as a group - (`Kind = 'AdministrativeUnit'; Id = 'Sales'` adds the user to a group named `Sales`) and a prune with a foreign Kind - silently removes nothing. A misspelled Kind (`'Groups'`) behaves the same way on Entra ID. -- The context resolver `IdLE.Entitlement.List` lists all Kinds. On Entra ID it always reads Administrative Units, so - group-only workflows need `AdministrativeUnit.Read.All`. - -## Principle - -`Kind` belongs to the entitlement reference. A step-level Kind exists only where it defines a **scope** (what a prune -may remove, what a resolver lists). Providers own the set of Kinds they support and reject everything else before -changing anything. - -## 1. EnsureEntitlement - -No change: `With.Entitlement = @{ Kind; Id; DisplayName }`, one entitlement per step. Multiple entitlements per step -are out of scope. - -## 2. PruneEntitlements / PruneEntitlementsEnsureKeep - -- `With.Kind` stays required: the explicit scope of what may be removed. -- `Keep[].Kind` is optional. When set it must equal `With.Kind` (case-insensitive); otherwise the step fails before - listing or changing anything: - `PruneEntitlements: Keep entry 'X' has Kind 'AdministrativeUnit', but the step prunes Kind 'Group'.` -- Keep entries are resolved and granted with `With.Kind`. -- Examples omit the redundant `Keep[].Kind`. - -Mixed Kinds in one prune are not supported; use one step per Kind. - -## 3. Provider contract - -| Method | Contract | -| --- | --- | -| `ListEntitlements(IdentityKey[, AuthSession][, Kind])` | Optional `Kind` filters the result. An unsupported `Kind` throws. | -| `GrantEntitlement(IdentityKey, Entitlement[, AuthSession])` | Throws for an unsupported `Entitlement.Kind`. | -| `RevokeEntitlement(IdentityKey, Entitlement[, AuthSession])` | Throws for an unsupported `Entitlement.Kind`. | -| `ResolveEntitlement(Entitlement[, AuthSession])` (optional) | **Changed:** no `Kind` parameter; uses `Entitlement.Kind`. | -| `BulkGrantEntitlements` / `BulkRevokeEntitlements` (optional) | Unsupported Kinds throw (not reported per item). | - -- Kind comparison is case-insensitive; providers return the canonical casing. -- Unsupported Kind error: `Entitlement Kind 'AdministrativeUnit' is not supported by this provider. Supported Kinds: Group.` -- Because both prune steps call `ListEntitlements(Kind)` first, an unsupported scope fails before any revoke. - -| Provider | Supported Kinds | -| --- | --- | -| AD | `Group` (new: `ListEntitlements` accepts `Kind`) | -| Entra ID | `Group`, `AdministrativeUnit` (the unreachable "missing Kind defaults to Group" branches are removed) | -| Mock | any non-empty Kind (test provider) | - -Plan-time Kind validation (providers declaring their Kinds) is deferred to after v1.0; it can be added as an optional -provider method without breaking this contract. - -## 4. Context resolver `IdLE.Entitlement.List` - -- New optional `With.Kind` (non-empty string). The engine passes it to `ListEntitlements` when the method declares - `Kind`, and always filters the result by Kind, so providers without the parameter behave the same. -- Without `With.Kind` the behavior is unchanged (all Kinds). -- **Default (proposed):** two resolvers with the same capability, provider and auth session fail with an error instead - of silently overwriting each other's `Request.Context.Providers...*` result. To list several Kinds - of one provider, omit `With.Kind` and filter in the Condition. - -## 5. Migration - -| Before | After | -| --- | --- | -| `Keep = @(@{ Kind = 'Group'; Id = 'g1' })` with `Kind = 'Group'` | `Keep = @(@{ Id = 'g1' })` (the old form stays valid) | -| `Keep[].Kind` different from `With.Kind` | Error; use one prune step per Kind. | -| Provider `ResolveEntitlement($Kind, $Entitlement, $AuthSession)` | `ResolveEntitlement($Entitlement, $AuthSession)` | -| AD entitlement step with a non-`Group` Kind | Error (was: treated as a group). | -| Entra ID group-only resolver needed `AdministrativeUnit.Read.All` | Add `With.Kind = 'Group'`. | - -## 6. Tests - -- Prune: Keep entry without Kind uses the scope; mismatching Keep Kind fails before any provider call that changes - state (regression); `ResolveEntitlement` receives only the entitlement. -- AD / Entra ID: unsupported Kind throws in List/Grant/Revoke/Resolve (and Bulk for Entra ID); canonical casing. -- Resolver: `With.Kind` passed through and filtered (provider with and without `Kind`); duplicate resolver fails. -- Entitlement provider contract tests: ResolveEntitlement signature when implemented. - -## Documentation - -Folded into `docs/extend/providers.md` (entitlement provider contract), the step help (generated step reference), -`docs/use/workflows/context-resolver.md`, `docs/reference/capabilities.md` and the provider references; this design -document is then removed. 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 From 2b0c0a93d73a6a0bff67918b09ae17515ba0dc3f Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:40:59 +0100 Subject: [PATCH 7/7] core: validate resolver With.Kind in the workflow schema (#289) Test-IdleWorkflow now rejects a resolver With.Kind that is not a non-empty string or is used with a capability other than IdLE.Entitlement.List, instead of failing only at planning (wrapped as a provider error) or being ignored. Co-Authored-By: Claude Opus 5.5 --- .../Private/Test-IdleWorkflowSchema.ps1 | 10 +++++++ .../New-IdlePlan.ContextResolvers.Tests.ps1 | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+) 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/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 index 978c2e16..09b98d82 100644 --- a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 +++ b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 @@ -1236,6 +1236,34 @@ Describe 'New-IdlePlan - ContextResolvers' { $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*' }