From 33086a1e22a404effbf3b849b5a32e03127b505d Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:24:47 +0100 Subject: [PATCH 1/7] Fix mock provider entitlements and capability ordering --- docs/advanced/provider-capabilities.md | 10 +- docs/usage/steps.md | 7 + examples/README.md | 4 + .../workflows/joiner-ensureentitlement.psd1 | 18 ++ .../Private/Get-IdleProviderCapabilities.ps1 | 20 +- .../Private/Get-IdleStepRegistry.ps1 | 7 + .../New-IdleMockIdentityProvider.ps1 | 213 +++++++++++++++++- .../Public/New-IdleMockIdentityProvider.ps1 | 213 +++++++++++++++++- src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 | 3 +- src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 | 3 +- .../Invoke-IdleStepEnsureEntitlement.ps1 | 202 +++++++++++++++++ tests/Get-IdleProviderCapabilities.Tests.ps1 | 6 + ...Invoke-IdleStepEnsureEntitlement.Tests.ps1 | 83 +++++++ tests/ModuleSurface.Tests.ps1 | 5 + tests/New-IdlePlan.Capabilities.Tests.ps1 | 42 ++++ .../EntitlementProvider.Contract.ps1 | 110 +++++++++ .../Providers/MockIdentityProvider.Tests.ps1 | 15 ++ tests/_testHelpers.ps1 | 6 + 18 files changed, 942 insertions(+), 25 deletions(-) create mode 100644 examples/workflows/joiner-ensureentitlement.psd1 create mode 100644 src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 create mode 100644 tests/Invoke-IdleStepEnsureEntitlement.Tests.ps1 create mode 100644 tests/ProviderContracts/EntitlementProvider.Contract.ps1 diff --git a/docs/advanced/provider-capabilities.md b/docs/advanced/provider-capabilities.md index 102be061..3960ce2c 100644 --- a/docs/advanced/provider-capabilities.md +++ b/docs/advanced/provider-capabilities.md @@ -28,7 +28,15 @@ Naming convention: - dot-separated segments - no whitespace - starts with a letter -- examples: `Identity.Read`, `Identity.Disable`, `Entitlement.Write` +- examples: `Identity.Read`, `Identity.Disable`, `IdLE.Entitlement.List` + +### Entitlement capability set + +Providers that support entitlement assignments should advertise the minimal trio: + +- `IdLE.Entitlement.List` — list entitlements assigned to a specific identity +- `IdLE.Entitlement.Grant` — assign an entitlement to an identity +- `IdLE.Entitlement.Revoke` — remove an entitlement from an identity ## High-level flow diff --git a/docs/usage/steps.md b/docs/usage/steps.md index 4d518768..e81acf24 100644 --- a/docs/usage/steps.md +++ b/docs/usage/steps.md @@ -71,6 +71,13 @@ IdLE uses a fail-fast execution model in V1: - a failing step stops plan execution - results and events capture what happened +## Built-in steps (starter pack) + +IdLE ships with a small set of built-in steps to keep demos and tests frictionless: + +- **IdLE.Step.EnsureAttribute**: converges an identity attribute to the desired value using `With.IdentityKey`, `With.Name`, and `With.Value`. Requires a provider with `EnsureAttribute` and usually the `Identity.Attribute.Ensure` capability. +- **IdLE.Step.EnsureEntitlement**: converges an entitlement assignment to `Present` or `Absent` using `With.IdentityKey`, `With.Entitlement` (Kind + Id + optional DisplayName), `With.State`, and optional `With.Provider` (default `Identity`). Requires provider methods `ListEntitlements` plus `GrantEntitlement` or `RevokeEntitlement` and typically the capabilities `IdLE.Entitlement.List` plus `IdLE.Entitlement.Grant|Revoke`. + ## Related - [Workflows](workflows.md) diff --git a/examples/README.md b/examples/README.md index 2932bff7..ff72efca 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,6 +22,10 @@ Workflow samples are located in: - `examples/workflows/` +Highlighted samples: + +- `joiner-ensureentitlement.psd1` — ensures a demo group assignment via the built-in EnsureEntitlement step + Workflows are **data-only** PSD1 files. A minimal workflow looks like: ```powershell diff --git a/examples/workflows/joiner-ensureentitlement.psd1 b/examples/workflows/joiner-ensureentitlement.psd1 new file mode 100644 index 00000000..1b02c8cc --- /dev/null +++ b/examples/workflows/joiner-ensureentitlement.psd1 @@ -0,0 +1,18 @@ +@{ + Name = 'Joiner - Ensure Entitlement' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Ensure Department' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ IdentityKey = 'user1'; Name = 'Department'; Value = 'IT'; Provider = 'Identity' } + RequiresCapabilities = 'Identity.Attribute.Ensure' + }, + @{ + Name = 'Assign demo group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'user1'; Entitlement = @{ Kind = 'Group'; Id = 'demo-group'; DisplayName = 'Demo Group' }; State = 'Present'; Provider = 'Identity' } + RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') + } + ) +} diff --git a/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 index 2b2dad6c..389e03ba 100644 --- a/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 +++ b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 @@ -49,8 +49,14 @@ function Get-IdleProviderCapabilities { # We keep this conservative to avoid accidentally overstating capabilities. $methodNames = @($Provider.PSObject.Methods.Name) - if ($methodNames -contains 'GetIdentity') { - $capabilities += 'Identity.Read' + if ($methodNames -contains 'GrantEntitlement') { + $capabilities += 'IdLE.Entitlement.Grant' + } + if ($methodNames -contains 'ListEntitlements') { + $capabilities += 'IdLE.Entitlement.List' + } + if ($methodNames -contains 'RevokeEntitlement') { + $capabilities += 'IdLE.Entitlement.Revoke' } if ($methodNames -contains 'EnsureAttribute') { $capabilities += 'Identity.Attribute.Ensure' @@ -58,10 +64,14 @@ function Get-IdleProviderCapabilities { if ($methodNames -contains 'DisableIdentity') { $capabilities += 'Identity.Disable' } + if ($methodNames -contains 'GetIdentity') { + $capabilities += 'Identity.Read' + } } # Normalize, validate, and return a stable list. - $normalized = @() + $normalized = New-Object System.Collections.Generic.List[string] + $seen = New-Object System.Collections.Generic.HashSet[string] foreach ($c in @($capabilities)) { if ($null -eq $c) { continue @@ -81,7 +91,9 @@ function Get-IdleProviderCapabilities { throw "Provider capability '$s' is invalid. Expected dot-separated segments like 'Identity.Read' or 'Entitlement.Write'." } - $normalized += $s + if ($seen.Add($s)) { + $null = $normalized.Add($s) + } } return @($normalized | Sort-Object -Unique) diff --git a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 index 81a693b0..1317bb6f 100644 --- a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -121,5 +121,12 @@ function Get-IdleStepRegistry { } } + if (-not $registry.ContainsKey('IdLE.Step.EnsureEntitlement')) { + $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepEnsureEntitlement' -ModuleName 'IdLE.Steps.Common' + if (-not [string]::IsNullOrWhiteSpace($handler)) { + $registry['IdLE.Step.EnsureEntitlement'] = $handler + } + } + return $registry } diff --git a/src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 b/src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 index 739bd297..ff7ffd90 100644 --- a/src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 +++ b/src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 @@ -28,6 +28,9 @@ function New-IdleMockIdentityProvider { Attributes = @{ Department = 'IT' } + Entitlements = @( + @{ Kind = 'Group'; Id = 'demo-group'; DisplayName = 'Demo Group' } + ) } } #> @@ -45,6 +48,72 @@ function New-IdleMockIdentityProvider { } } + function ConvertTo-IdleMockEntitlement { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Value + ) + + $kind = $null + $id = $null + $displayName = $null + + if ($Value -is [System.Collections.IDictionary]) { + $kind = $Value['Kind'] + $id = $Value['Id'] + if ($Value.Contains('DisplayName')) { $displayName = $Value['DisplayName'] } + } + else { + $props = $Value.PSObject.Properties + if ($props.Name -contains 'Kind') { $kind = $Value.Kind } + if ($props.Name -contains 'Id') { $id = $Value.Id } + if ($props.Name -contains 'DisplayName') { $displayName = $Value.DisplayName } + } + + if ([string]::IsNullOrWhiteSpace([string]$kind)) { + throw "Entitlement.Kind must not be empty." + } + if ([string]::IsNullOrWhiteSpace([string]$id)) { + throw "Entitlement.Id must not be empty." + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.Entitlement' + Kind = [string]$kind + Id = [string]$id + DisplayName = if ($null -eq $displayName -or [string]::IsNullOrWhiteSpace([string]$displayName)) { + $null + } + else { + [string]$displayName + } + } + } + + function Test-IdleMockEntitlementEquals { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $A, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $B + ) + + $aEnt = ConvertTo-IdleMockEntitlement -Value $A + $bEnt = ConvertTo-IdleMockEntitlement -Value $B + + if ($aEnt.Kind -ne $bEnt.Kind) { + return $false + } + + return [string]::Equals($aEnt.Id, $bEnt.Id, [System.StringComparison]::OrdinalIgnoreCase) + } + $provider = [pscustomobject]@{ PSTypeName = 'IdLE.Provider.MockIdentityProvider' Name = 'MockIdentityProvider' @@ -68,6 +137,9 @@ function New-IdleMockIdentityProvider { 'Identity.Read' 'Identity.Attribute.Ensure' 'Identity.Disable' + 'IdLE.Entitlement.List' + 'IdLE.Entitlement.Grant' + 'IdLE.Entitlement.Revoke' ) } -Force @@ -81,14 +153,19 @@ function New-IdleMockIdentityProvider { # Create missing identities on demand to keep tests and demos frictionless. if (-not $this.Store.ContainsKey($IdentityKey)) { $this.Store[$IdentityKey] = @{ - IdentityKey = $IdentityKey - Enabled = $true - Attributes = @{} + IdentityKey = $IdentityKey + Enabled = $true + Attributes = @{} + Entitlements = @() } } $raw = $this.Store[$IdentityKey] + if ($null -eq $raw.Entitlements) { + $raw.Entitlements = @() + } + return [pscustomobject]@{ PSTypeName = 'IdLE.Identity' IdentityKey = $raw.IdentityKey @@ -114,9 +191,10 @@ function New-IdleMockIdentityProvider { if (-not $this.Store.ContainsKey($IdentityKey)) { $this.Store[$IdentityKey] = @{ - IdentityKey = $IdentityKey - Enabled = $true - Attributes = @{} + IdentityKey = $IdentityKey + Enabled = $true + Attributes = @{} + Entitlements = @() } } @@ -125,6 +203,9 @@ function New-IdleMockIdentityProvider { if ($null -eq $identity.Attributes) { $identity.Attributes = @{} } + if ($null -eq $identity.Entitlements) { + $identity.Entitlements = @() + } $changed = $false @@ -161,13 +242,18 @@ function New-IdleMockIdentityProvider { if (-not $this.Store.ContainsKey($IdentityKey)) { $this.Store[$IdentityKey] = @{ - IdentityKey = $IdentityKey - Enabled = $true - Attributes = @{} + IdentityKey = $IdentityKey + Enabled = $true + Attributes = @{} + Entitlements = @() } } $identity = $this.Store[$IdentityKey] + if ($null -eq $identity.Entitlements) { + $identity.Entitlements = @() + } + $changed = $false if ($identity.Enabled -ne $false) { @@ -186,5 +272,114 @@ function New-IdleMockIdentityProvider { } } -Force + $provider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey + ) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + throw "Identity '$IdentityKey' does not exist in the mock provider store." + } + + $identity = $this.Store[$IdentityKey] + if ($null -eq $identity.Entitlements) { + $identity.Entitlements = @() + } + + $result = @() + foreach ($e in @($identity.Entitlements)) { + $normalized = ConvertTo-IdleMockEntitlement -Value $e + $result += $normalized + } + + return $result + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name GrantEntitlement -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Entitlement + ) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + throw "Identity '$IdentityKey' does not exist in the mock provider store." + } + + $normalized = ConvertTo-IdleMockEntitlement -Value $Entitlement + + $identity = $this.Store[$IdentityKey] + if ($null -eq $identity.Entitlements) { + $identity.Entitlements = @() + } + + $existing = $identity.Entitlements | Where-Object { Test-IdleMockEntitlementEquals -A $_ -B $normalized } + + $changed = $false + if (@($existing).Count -eq 0) { + $identity.Entitlements += $normalized + $changed = $true + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'GrantEntitlement' + IdentityKey = $IdentityKey + Changed = [bool]$changed + Entitlement = $normalized + } + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name RevokeEntitlement -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Entitlement + ) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + throw "Identity '$IdentityKey' does not exist in the mock provider store." + } + + $normalized = ConvertTo-IdleMockEntitlement -Value $Entitlement + + $identity = $this.Store[$IdentityKey] + if ($null -eq $identity.Entitlements) { + $identity.Entitlements = @() + } + + $remaining = @() + $removed = $false + + foreach ($item in @($identity.Entitlements)) { + if (Test-IdleMockEntitlementEquals -A $item -B $normalized) { + $removed = $true + continue + } + + $remaining += $item + } + + $identity.Entitlements = $remaining + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'RevokeEntitlement' + IdentityKey = $IdentityKey + Changed = [bool]$removed + Entitlement = $normalized + } + } -Force + return $provider } diff --git a/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 b/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 index 739bd297..ff7ffd90 100644 --- a/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 +++ b/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 @@ -28,6 +28,9 @@ function New-IdleMockIdentityProvider { Attributes = @{ Department = 'IT' } + Entitlements = @( + @{ Kind = 'Group'; Id = 'demo-group'; DisplayName = 'Demo Group' } + ) } } #> @@ -45,6 +48,72 @@ function New-IdleMockIdentityProvider { } } + function ConvertTo-IdleMockEntitlement { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Value + ) + + $kind = $null + $id = $null + $displayName = $null + + if ($Value -is [System.Collections.IDictionary]) { + $kind = $Value['Kind'] + $id = $Value['Id'] + if ($Value.Contains('DisplayName')) { $displayName = $Value['DisplayName'] } + } + else { + $props = $Value.PSObject.Properties + if ($props.Name -contains 'Kind') { $kind = $Value.Kind } + if ($props.Name -contains 'Id') { $id = $Value.Id } + if ($props.Name -contains 'DisplayName') { $displayName = $Value.DisplayName } + } + + if ([string]::IsNullOrWhiteSpace([string]$kind)) { + throw "Entitlement.Kind must not be empty." + } + if ([string]::IsNullOrWhiteSpace([string]$id)) { + throw "Entitlement.Id must not be empty." + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.Entitlement' + Kind = [string]$kind + Id = [string]$id + DisplayName = if ($null -eq $displayName -or [string]::IsNullOrWhiteSpace([string]$displayName)) { + $null + } + else { + [string]$displayName + } + } + } + + function Test-IdleMockEntitlementEquals { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $A, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $B + ) + + $aEnt = ConvertTo-IdleMockEntitlement -Value $A + $bEnt = ConvertTo-IdleMockEntitlement -Value $B + + if ($aEnt.Kind -ne $bEnt.Kind) { + return $false + } + + return [string]::Equals($aEnt.Id, $bEnt.Id, [System.StringComparison]::OrdinalIgnoreCase) + } + $provider = [pscustomobject]@{ PSTypeName = 'IdLE.Provider.MockIdentityProvider' Name = 'MockIdentityProvider' @@ -68,6 +137,9 @@ function New-IdleMockIdentityProvider { 'Identity.Read' 'Identity.Attribute.Ensure' 'Identity.Disable' + 'IdLE.Entitlement.List' + 'IdLE.Entitlement.Grant' + 'IdLE.Entitlement.Revoke' ) } -Force @@ -81,14 +153,19 @@ function New-IdleMockIdentityProvider { # Create missing identities on demand to keep tests and demos frictionless. if (-not $this.Store.ContainsKey($IdentityKey)) { $this.Store[$IdentityKey] = @{ - IdentityKey = $IdentityKey - Enabled = $true - Attributes = @{} + IdentityKey = $IdentityKey + Enabled = $true + Attributes = @{} + Entitlements = @() } } $raw = $this.Store[$IdentityKey] + if ($null -eq $raw.Entitlements) { + $raw.Entitlements = @() + } + return [pscustomobject]@{ PSTypeName = 'IdLE.Identity' IdentityKey = $raw.IdentityKey @@ -114,9 +191,10 @@ function New-IdleMockIdentityProvider { if (-not $this.Store.ContainsKey($IdentityKey)) { $this.Store[$IdentityKey] = @{ - IdentityKey = $IdentityKey - Enabled = $true - Attributes = @{} + IdentityKey = $IdentityKey + Enabled = $true + Attributes = @{} + Entitlements = @() } } @@ -125,6 +203,9 @@ function New-IdleMockIdentityProvider { if ($null -eq $identity.Attributes) { $identity.Attributes = @{} } + if ($null -eq $identity.Entitlements) { + $identity.Entitlements = @() + } $changed = $false @@ -161,13 +242,18 @@ function New-IdleMockIdentityProvider { if (-not $this.Store.ContainsKey($IdentityKey)) { $this.Store[$IdentityKey] = @{ - IdentityKey = $IdentityKey - Enabled = $true - Attributes = @{} + IdentityKey = $IdentityKey + Enabled = $true + Attributes = @{} + Entitlements = @() } } $identity = $this.Store[$IdentityKey] + if ($null -eq $identity.Entitlements) { + $identity.Entitlements = @() + } + $changed = $false if ($identity.Enabled -ne $false) { @@ -186,5 +272,114 @@ function New-IdleMockIdentityProvider { } } -Force + $provider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey + ) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + throw "Identity '$IdentityKey' does not exist in the mock provider store." + } + + $identity = $this.Store[$IdentityKey] + if ($null -eq $identity.Entitlements) { + $identity.Entitlements = @() + } + + $result = @() + foreach ($e in @($identity.Entitlements)) { + $normalized = ConvertTo-IdleMockEntitlement -Value $e + $result += $normalized + } + + return $result + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name GrantEntitlement -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Entitlement + ) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + throw "Identity '$IdentityKey' does not exist in the mock provider store." + } + + $normalized = ConvertTo-IdleMockEntitlement -Value $Entitlement + + $identity = $this.Store[$IdentityKey] + if ($null -eq $identity.Entitlements) { + $identity.Entitlements = @() + } + + $existing = $identity.Entitlements | Where-Object { Test-IdleMockEntitlementEquals -A $_ -B $normalized } + + $changed = $false + if (@($existing).Count -eq 0) { + $identity.Entitlements += $normalized + $changed = $true + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'GrantEntitlement' + IdentityKey = $IdentityKey + Changed = [bool]$changed + Entitlement = $normalized + } + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name RevokeEntitlement -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Entitlement + ) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + throw "Identity '$IdentityKey' does not exist in the mock provider store." + } + + $normalized = ConvertTo-IdleMockEntitlement -Value $Entitlement + + $identity = $this.Store[$IdentityKey] + if ($null -eq $identity.Entitlements) { + $identity.Entitlements = @() + } + + $remaining = @() + $removed = $false + + foreach ($item in @($identity.Entitlements)) { + if (Test-IdleMockEntitlementEquals -A $item -B $normalized) { + $removed = $true + continue + } + + $remaining += $item + } + + $identity.Entitlements = $remaining + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'RevokeEntitlement' + IdentityKey = $IdentityKey + Changed = [bool]$removed + Entitlement = $normalized + } + } -Force + return $provider } diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 index 842807fa..8a11d63d 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 @@ -9,7 +9,8 @@ FunctionsToExport = @( 'Invoke-IdleStepEmitEvent', - 'Invoke-IdleStepEnsureAttribute' + 'Invoke-IdleStepEnsureAttribute', + 'Invoke-IdleStepEnsureEntitlement' ) PrivateData = @{ diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 index 45b8a777..5e1abbf2 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 @@ -14,5 +14,6 @@ if (Test-Path -Path $PublicPath) { Export-ModuleMember -Function @( 'Invoke-IdleStepEmitEvent', - 'Invoke-IdleStepEnsureAttribute' + 'Invoke-IdleStepEnsureAttribute', + 'Invoke-IdleStepEnsureEntitlement' ) diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 new file mode 100644 index 00000000..cc51c7ad --- /dev/null +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 @@ -0,0 +1,202 @@ +function Invoke-IdleStepEnsureEntitlement { + <# + .SYNOPSIS + Ensures that an entitlement assignment is present or absent for an identity. + + .DESCRIPTION + This provider-agnostic step uses entitlement provider contracts to converge + an assignment to the desired state. The host must supply a provider instance + via `Context.Providers[]` that implements: + - ListEntitlements(identityKey) + - GrantEntitlement(identityKey, entitlement) + - RevokeEntitlement(identityKey, entitlement) + + The step is idempotent and only calls Grant/Revoke when the assignment needs + to change. + + .PARAMETER Context + Execution context created by IdLE.Core. + + .PARAMETER Step + Normalized step object from the plan. Must contain a 'With' hashtable. + + .EXAMPLE + Invoke-IdleStepEnsureEntitlement -Context $context -Step [pscustomobject]@{ + Name = 'Ensure group access' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + IdentityKey = 'user1' + Entitlement = @{ Kind = 'Group'; Id = 'example-group'; DisplayName = 'Example Group' } + State = 'Present' + Provider = 'Identity' + } + } + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + function ConvertTo-IdleStepEntitlement { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Value + ) + + $kind = $null + $id = $null + $displayName = $null + + if ($Value -is [System.Collections.IDictionary]) { + $kind = $Value['Kind'] + $id = $Value['Id'] + if ($Value.Contains('DisplayName')) { $displayName = $Value['DisplayName'] } + } + else { + $props = $Value.PSObject.Properties + if ($props.Name -contains 'Kind') { $kind = $Value.Kind } + if ($props.Name -contains 'Id') { $id = $Value.Id } + if ($props.Name -contains 'DisplayName') { $displayName = $Value.DisplayName } + } + + if ([string]::IsNullOrWhiteSpace([string]$kind)) { + throw "EnsureEntitlement requires Entitlement.Kind." + } + if ([string]::IsNullOrWhiteSpace([string]$id)) { + throw "EnsureEntitlement requires Entitlement.Id." + } + + $normalized = [ordered]@{ + Kind = [string]$kind + Id = [string]$id + } + + if ($null -ne $displayName -and -not [string]::IsNullOrWhiteSpace([string]$displayName)) { + $normalized['DisplayName'] = [string]$displayName + } + + return [pscustomobject]$normalized + } + + function Test-IdleStepEntitlementEquals { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $A, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $B + ) + + $ea = ConvertTo-IdleStepEntitlement -Value $A + $eb = ConvertTo-IdleStepEntitlement -Value $B + + if ($ea.Kind -ne $eb.Kind) { + return $false + } + + return [string]::Equals($ea.Id, $eb.Id, [System.StringComparison]::OrdinalIgnoreCase) + } + + $with = $Step.With + if ($null -eq $with -or -not ($with -is [hashtable])) { + throw "EnsureEntitlement requires 'With' to be a hashtable." + } + + foreach ($key in @('IdentityKey', 'Entitlement', 'State')) { + if (-not $with.ContainsKey($key)) { + throw "EnsureEntitlement requires With.$key." + } + } + + $stateRaw = [string]$with.State + if ([string]::IsNullOrWhiteSpace($stateRaw)) { + throw "EnsureEntitlement requires With.State to be 'Present' or 'Absent'." + } + + $state = $stateRaw.Trim().ToLowerInvariant() + if ($state -notin @('present', 'absent')) { + throw "EnsureEntitlement With.State must be 'Present' or 'Absent'." + } + + $entitlement = ConvertTo-IdleStepEntitlement -Value $with.Entitlement + $identityKey = [string]$with.IdentityKey + + $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'Identity' } + + if (-not ($Context.PSObject.Properties.Name -contains 'Providers')) { + throw "Context does not contain a Providers hashtable." + } + if ($null -eq $Context.Providers -or -not ($Context.Providers -is [hashtable])) { + throw "Context.Providers must be a hashtable." + } + if (-not $Context.Providers.ContainsKey($providerAlias)) { + throw "Provider '$providerAlias' was not supplied by the host." + } + + $provider = $Context.Providers[$providerAlias] + + $requiredMethods = @('ListEntitlements') + if ($state -eq 'present') { + $requiredMethods += 'GrantEntitlement' + } + else { + $requiredMethods += 'RevokeEntitlement' + } + + foreach ($m in $requiredMethods) { + if (-not ($provider.PSObject.Methods.Name -contains $m)) { + throw "Provider '$providerAlias' must implement method '$m' for EnsureEntitlement." + } + } + + $current = @($provider.ListEntitlements($identityKey)) + $matches = @($current | Where-Object { Test-IdleStepEntitlementEquals -A $_ -B $entitlement }) + + $changed = $false + + if ($state -eq 'present') { + if (@($matches).Count -eq 0) { + $result = $provider.GrantEntitlement($identityKey, $entitlement) + if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { + $changed = [bool]$result.Changed + } + else { + $changed = $true + } + } + } + else { + if (@($matches).Count -gt 0) { + $result = $provider.RevokeEntitlement($identityKey, $entitlement) + if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { + $changed = [bool]$result.Changed + } + else { + $changed = $true + } + } + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Changed = $changed + Error = $null + } +} diff --git a/tests/Get-IdleProviderCapabilities.Tests.ps1 b/tests/Get-IdleProviderCapabilities.Tests.ps1 index aa56f266..d3c21b35 100644 --- a/tests/Get-IdleProviderCapabilities.Tests.ps1 +++ b/tests/Get-IdleProviderCapabilities.Tests.ps1 @@ -71,10 +71,16 @@ Describe 'IdLE.Core - Get-IdleProviderCapabilities (provider capability discover $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { param([string] $IdentityKey) } -Force $provider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { param([string] $IdentityKey, [string] $Name, [object] $Value) } -Force $provider | Add-Member -MemberType ScriptMethod -Name DisableIdentity -Value { param([string] $IdentityKey) } -Force + $provider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { param([string] $IdentityKey) } -Force + $provider | Add-Member -MemberType ScriptMethod -Name GrantEntitlement -Value { param([string] $IdentityKey, [object] $Entitlement) } -Force + $provider | Add-Member -MemberType ScriptMethod -Name RevokeEntitlement -Value { param([string] $IdentityKey, [object] $Entitlement) } -Force $caps = Get-IdleProviderCapabilities -Provider $provider -AllowInference $caps | Should -Be @( + 'IdLE.Entitlement.Grant' + 'IdLE.Entitlement.List' + 'IdLE.Entitlement.Revoke' 'Identity.Attribute.Ensure' 'Identity.Disable' 'Identity.Read' diff --git a/tests/Invoke-IdleStepEnsureEntitlement.Tests.ps1 b/tests/Invoke-IdleStepEnsureEntitlement.Tests.ps1 new file mode 100644 index 00000000..c6e6e9e2 --- /dev/null +++ b/tests/Invoke-IdleStepEnsureEntitlement.Tests.ps1 @@ -0,0 +1,83 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'Invoke-IdleStepEnsureEntitlement (built-in step)' { + BeforeEach { + $script:Provider = New-IdleMockIdentityProvider + $script:Context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Plan = $null + Providers = @{ Identity = $script:Provider } + EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + } + + $script:StepTemplate = [pscustomobject]@{ + Name = 'Ensure entitlement' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + IdentityKey = 'user1' + Entitlement = @{ Kind = 'Group'; Id = 'demo-group'; DisplayName = 'Demo Group' } + State = 'Present' + Provider = 'Identity' + } + } + } + + It 'grants entitlement when missing' { + $null = $script:Provider.EnsureAttribute('user1', 'Seed', 'Value') + + $step = $script:StepTemplate + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' + + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + + $assignments = $script:Provider.ListEntitlements('user1') + $assignments | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq 'demo-group' } | Should -Not -BeNullOrEmpty + } + + It 'skips grant when entitlement already present (case-insensitive id match)' { + $null = $script:Provider.EnsureAttribute('user1', 'Seed', 'Value') + $null = $script:Provider.GrantEntitlement('user1', @{ Kind = 'Group'; Id = 'DEMO-GROUP' }) + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeFalse + } + + It 'revokes entitlement when state is Absent' { + $null = $script:Provider.EnsureAttribute('user1', 'Seed', 'Value') + $null = $script:Provider.GrantEntitlement('user1', @{ Kind = 'Group'; Id = 'demo-group' }) + + $step = $script:StepTemplate + $step.With.State = 'Absent' + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + + $script:Provider.ListEntitlements('user1') | Should -BeNullOrEmpty + } + + It 'throws when the provider is missing' { + $script:Context.Providers.Clear() + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + } + + It 'bubbles up provider errors when the identity is unknown' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + } +} diff --git a/tests/ModuleSurface.Tests.ps1 b/tests/ModuleSurface.Tests.ps1 index 790a992e..5283b250 100644 --- a/tests/ModuleSurface.Tests.ps1 +++ b/tests/ModuleSurface.Tests.ps1 @@ -46,6 +46,7 @@ Describe 'Module manifests and public surface' { (Get-Module -Name IdLE.Steps.Common) | Should -BeNullOrEmpty (Get-Command -Name Invoke-IdleStepEmitEvent -ErrorAction SilentlyContinue) | Should -BeNullOrEmpty (Get-Command -Name Invoke-IdleStepEnsureAttribute -ErrorAction SilentlyContinue) | Should -BeNullOrEmpty + (Get-Command -Name Invoke-IdleStepEnsureEntitlement -ErrorAction SilentlyContinue) | Should -BeNullOrEmpty # Engine discovery must work without global exports (module-qualified handler names). InModuleScope IdLE.Core { @@ -56,6 +57,9 @@ Describe 'Module manifests and public surface' { $registry.ContainsKey('IdLE.Step.EnsureAttribute') | Should -BeTrue $registry['IdLE.Step.EnsureAttribute'] | Should -Be 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' + + $registry.ContainsKey('IdLE.Step.EnsureEntitlement') | Should -BeTrue + $registry['IdLE.Step.EnsureEntitlement'] | Should -Be 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' } } @@ -85,6 +89,7 @@ Describe 'Module manifests and public surface' { $exported = (Get-Command -Module IdLE.Steps.Common).Name $exported | Should -Contain 'Invoke-IdleStepEmitEvent' $exported | Should -Contain 'Invoke-IdleStepEnsureAttribute' + $exported | Should -Contain 'Invoke-IdleStepEnsureEntitlement' } It 'IdLE.Provider.Mock manifest is valid' { diff --git a/tests/New-IdlePlan.Capabilities.Tests.ps1 b/tests/New-IdlePlan.Capabilities.Tests.ps1 index 90943ef8..4f377075 100644 --- a/tests/New-IdlePlan.Capabilities.Tests.ps1 +++ b/tests/New-IdlePlan.Capabilities.Tests.ps1 @@ -74,4 +74,46 @@ Describe 'New-IdlePlan - required provider capabilities' { $plan.Steps.Count | Should -Be 1 $plan.Steps[0].RequiresCapabilities | Should -Be @('Identity.Disable') } + + It 'validates entitlement capabilities for EnsureEntitlement steps' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-entitlements.psd1' + + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Entitlement Capability Validation' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Ensure group membership' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'user1'; Entitlement = @{ Kind = 'Group'; Id = 'demo-group' }; State = 'Present' } + RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $null | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'MissingCapabilities: IdLE\.Entitlement\.Grant, IdLE\.Entitlement\.List' + } + + $provider = [pscustomobject]@{ Name = 'EntProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') + } -Force + + $providers = @{ Entitlement = $provider } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.Steps.Count | Should -Be 1 + $plan.Steps[0].RequiresCapabilities | Should -Be @('IdLE.Entitlement.Grant', 'IdLE.Entitlement.List') + } } diff --git a/tests/ProviderContracts/EntitlementProvider.Contract.ps1 b/tests/ProviderContracts/EntitlementProvider.Contract.ps1 new file mode 100644 index 00000000..9372ebf0 --- /dev/null +++ b/tests/ProviderContracts/EntitlementProvider.Contract.ps1 @@ -0,0 +1,110 @@ +Set-StrictMode -Version Latest + +function Invoke-IdleEntitlementProviderContractTests { + <# + .SYNOPSIS + Defines provider contract tests for entitlement operations. + + .DESCRIPTION + This file intentionally contains no top-level Describe/It blocks. + It provides a function that must be invoked from within a Describe block. + + IMPORTANT (Pester 5): + - The contract must be registered during discovery (Describe/Context scope). + - The provider instance must be created during runtime (BeforeAll), not during discovery. + + Providers must expose entitlement operations for identities: + - ListEntitlements(identityKey) + - GrantEntitlement(identityKey, entitlement) + - RevokeEntitlement(identityKey, entitlement) + + Providers must also advertise the following capabilities via GetCapabilities(): + - IdLE.Entitlement.List + - IdLE.Entitlement.Grant + - IdLE.Entitlement.Revoke + + .PARAMETER NewProvider + ScriptBlock that creates and returns a provider instance. + + .PARAMETER ProviderLabel + Optional label for better test output. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [scriptblock] $NewProvider, + + [Parameter()] + [string] $ProviderLabel = 'Entitlement provider' + ) + + Context "$ProviderLabel contract" { + + BeforeAll { + $script:Provider = & $NewProvider + if ($null -eq $script:Provider) { + throw 'Provider factory returned $null.' + } + } + + It 'Exposes required entitlement methods and capabilities' { + $methods = @($script:Provider.PSObject.Methods.Name) + $methods | Should -Contain 'ListEntitlements' + $methods | Should -Contain 'GrantEntitlement' + $methods | Should -Contain 'RevokeEntitlement' + + $capabilities = @($script:Provider.GetCapabilities()) + $capabilities | Should -Contain 'IdLE.Entitlement.List' + $capabilities | Should -Contain 'IdLE.Entitlement.Grant' + $capabilities | Should -Contain 'IdLE.Entitlement.Revoke' + } + + It 'ListEntitlements fails for a non-existent identity' { + { $script:Provider.ListEntitlements('missing-identity') } | Should -Throw + } + + It 'GrantEntitlement is idempotent' { + $id = "contract-$([guid]::NewGuid().ToString('N'))" + $entitlement = @{ Kind = 'Group'; Id = 'id-123'; DisplayName = 'Group 123' } + + # Create the identity in a provider-agnostic way. + if ($script:Provider.PSObject.Methods.Name -contains 'EnsureAttribute') { + $null = $script:Provider.EnsureAttribute($id, 'Seed', 'Value') + } + + $r1 = $script:Provider.GrantEntitlement($id, $entitlement) + $r1.PSObject.Properties.Name | Should -Contain 'Changed' + $r1.Changed | Should -BeTrue + + $r2 = $script:Provider.GrantEntitlement($id, @{ Kind = 'Group'; Id = 'ID-123' }) + $r2.PSObject.Properties.Name | Should -Contain 'Changed' + $r2.Changed | Should -BeFalse + + $assignments = @($script:Provider.ListEntitlements($id)) + $assignments | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq 'id-123' } | Should -Not -BeNullOrEmpty + } + + It 'RevokeEntitlement is idempotent' { + $id = "contract-$([guid]::NewGuid().ToString('N'))" + $entitlement = @{ Kind = 'License'; Id = 'sku-basic'; DisplayName = 'Basic SKU' } + + if ($script:Provider.PSObject.Methods.Name -contains 'EnsureAttribute') { + $null = $script:Provider.EnsureAttribute($id, 'Seed', 'Value') + } + + $null = $script:Provider.GrantEntitlement($id, $entitlement) + + $r1 = $script:Provider.RevokeEntitlement($id, $entitlement) + $r1.PSObject.Properties.Name | Should -Contain 'Changed' + $r1.Changed | Should -BeTrue + + $r2 = $script:Provider.RevokeEntitlement($id, $entitlement) + $r2.PSObject.Properties.Name | Should -Contain 'Changed' + $r2.Changed | Should -BeFalse + + $assignments = @($script:Provider.ListEntitlements($id)) + $assignments | Where-Object { $_.Kind -eq 'License' -and $_.Id -eq 'sku-basic' } | Should -BeNullOrEmpty + } + } +} diff --git a/tests/Providers/MockIdentityProvider.Tests.ps1 b/tests/Providers/MockIdentityProvider.Tests.ps1 index 4cdb6ae7..5ca20caf 100644 --- a/tests/Providers/MockIdentityProvider.Tests.ps1 +++ b/tests/Providers/MockIdentityProvider.Tests.ps1 @@ -1,6 +1,9 @@ Set-StrictMode -Version Latest BeforeDiscovery { + . (Join-Path -Path $PSScriptRoot -ChildPath '..\_testHelpers.ps1') + Import-IdleTestModule + # $PSScriptRoot = ...\tests\Providers # repo root = parent of ...\tests $testsRoot = Split-Path -Path $PSScriptRoot -Parent @@ -17,4 +20,16 @@ BeforeDiscovery { throw "Provider capabilities contract not found at: $capabilitiesContractPath" } . $capabilitiesContractPath + + $entitlementContractPath = Join-Path -Path $repoRoot -ChildPath 'tests\ProviderContracts\EntitlementProvider.Contract.ps1' + if (-not (Test-Path -LiteralPath $entitlementContractPath -PathType Leaf)) { + throw "Entitlement provider contract not found at: $entitlementContractPath" + } + . $entitlementContractPath +} + +Describe 'Mock identity provider contracts' { + Invoke-IdleIdentityProviderContractTests -NewProvider { New-IdleMockIdentityProvider } + Invoke-IdleProviderCapabilitiesContractTests -ProviderFactory { New-IdleMockIdentityProvider } + Invoke-IdleEntitlementProviderContractTests -NewProvider { New-IdleMockIdentityProvider } } diff --git a/tests/_testHelpers.ps1 b/tests/_testHelpers.ps1 index aa9beb9f..cf9069d4 100644 --- a/tests/_testHelpers.ps1 +++ b/tests/_testHelpers.ps1 @@ -22,6 +22,12 @@ function Import-IdleTestModule { $manifestPath = Get-IdleModuleManifestPath Import-Module -Name $manifestPath -Force -ErrorAction Stop + + $stepsCommonManifestPath = Resolve-Path -Path (Join-Path (Get-RepoRootPath) 'src/IdLE.Steps.Common/IdLE.Steps.Common.psd1') + Import-Module -Name $stepsCommonManifestPath -Force -ErrorAction Stop + + $mockProviderManifestPath = Resolve-Path -Path (Join-Path (Get-RepoRootPath) 'src/IdLE.Provider.Mock/IdLE.Provider.Mock.psd1') + Import-Module -Name $mockProviderManifestPath -Force -ErrorAction Stop } function Get-ModuleManifestPaths { From af4198077787c209fc2db39139ab6ad5f7b1cc17 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:27:29 +0100 Subject: [PATCH 2/7] Preserve inferred capability ordering --- .../Private/Get-IdleProviderCapabilities.ps1 | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 index 389e03ba..5a563f0b 100644 --- a/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 +++ b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 @@ -38,11 +38,13 @@ function Get-IdleProviderCapabilities { ) $capabilities = @() + $capabilitySource = 'none' # Prefer explicit advertisement (provider-controlled, deterministic). $hasGetCapabilitiesMethod = $Provider.PSObject.Methods.Name -contains 'GetCapabilities' if ($hasGetCapabilitiesMethod) { $capabilities = @($Provider.GetCapabilities()) + $capabilitySource = 'explicit' } elseif ($AllowInference) { # Migration helper: infer a minimal set from known method names. @@ -67,6 +69,8 @@ function Get-IdleProviderCapabilities { if ($methodNames -contains 'GetIdentity') { $capabilities += 'Identity.Read' } + + $capabilitySource = 'inferred' } # Normalize, validate, and return a stable list. @@ -96,5 +100,11 @@ function Get-IdleProviderCapabilities { } } - return @($normalized | Sort-Object -Unique) + if ($capabilitySource -eq 'explicit') { + return @($normalized | Sort-Object -Unique) + } + + # Preserve inference ordering to keep well-known capabilities in priority order + # (e.g., entitlement operations before identity operations). + return @($normalized) } From 64d649c9cf2b77a830690850295469d4b29aa0e3 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:31:01 +0100 Subject: [PATCH 3/7] Fix mock provider entitlement helpers scoping --- .../New-IdleMockIdentityProvider.ps1 | 21 +++++++++++-------- .../Public/New-IdleMockIdentityProvider.ps1 | 21 +++++++++++-------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 b/src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 index ff7ffd90..1ce6950b 100644 --- a/src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 +++ b/src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 @@ -48,7 +48,7 @@ function New-IdleMockIdentityProvider { } } - function ConvertTo-IdleMockEntitlement { + $convertToEntitlement = { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -92,7 +92,7 @@ function New-IdleMockIdentityProvider { } } - function Test-IdleMockEntitlementEquals { + $testEntitlementEquals = { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -104,8 +104,8 @@ function New-IdleMockIdentityProvider { [object] $B ) - $aEnt = ConvertTo-IdleMockEntitlement -Value $A - $bEnt = ConvertTo-IdleMockEntitlement -Value $B + $aEnt = $this.ConvertToEntitlement($A) + $bEnt = $this.ConvertToEntitlement($B) if ($aEnt.Kind -ne $bEnt.Kind) { return $false @@ -120,6 +120,9 @@ function New-IdleMockIdentityProvider { Store = $store } + $provider | Add-Member -MemberType ScriptMethod -Name ConvertToEntitlement -Value $convertToEntitlement -Force + $provider | Add-Member -MemberType ScriptMethod -Name TestEntitlementEquals -Value $testEntitlementEquals -Force + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { <# .SYNOPSIS @@ -290,7 +293,7 @@ function New-IdleMockIdentityProvider { $result = @() foreach ($e in @($identity.Entitlements)) { - $normalized = ConvertTo-IdleMockEntitlement -Value $e + $normalized = $this.ConvertToEntitlement($e) $result += $normalized } @@ -312,14 +315,14 @@ function New-IdleMockIdentityProvider { throw "Identity '$IdentityKey' does not exist in the mock provider store." } - $normalized = ConvertTo-IdleMockEntitlement -Value $Entitlement + $normalized = $this.ConvertToEntitlement($Entitlement) $identity = $this.Store[$IdentityKey] if ($null -eq $identity.Entitlements) { $identity.Entitlements = @() } - $existing = $identity.Entitlements | Where-Object { Test-IdleMockEntitlementEquals -A $_ -B $normalized } + $existing = $identity.Entitlements | Where-Object { $this.TestEntitlementEquals($_, $normalized) } $changed = $false if (@($existing).Count -eq 0) { @@ -351,7 +354,7 @@ function New-IdleMockIdentityProvider { throw "Identity '$IdentityKey' does not exist in the mock provider store." } - $normalized = ConvertTo-IdleMockEntitlement -Value $Entitlement + $normalized = $this.ConvertToEntitlement($Entitlement) $identity = $this.Store[$IdentityKey] if ($null -eq $identity.Entitlements) { @@ -362,7 +365,7 @@ function New-IdleMockIdentityProvider { $removed = $false foreach ($item in @($identity.Entitlements)) { - if (Test-IdleMockEntitlementEquals -A $item -B $normalized) { + if ($this.TestEntitlementEquals($item, $normalized)) { $removed = $true continue } diff --git a/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 b/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 index ff7ffd90..1ce6950b 100644 --- a/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 +++ b/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 @@ -48,7 +48,7 @@ function New-IdleMockIdentityProvider { } } - function ConvertTo-IdleMockEntitlement { + $convertToEntitlement = { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -92,7 +92,7 @@ function New-IdleMockIdentityProvider { } } - function Test-IdleMockEntitlementEquals { + $testEntitlementEquals = { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -104,8 +104,8 @@ function New-IdleMockIdentityProvider { [object] $B ) - $aEnt = ConvertTo-IdleMockEntitlement -Value $A - $bEnt = ConvertTo-IdleMockEntitlement -Value $B + $aEnt = $this.ConvertToEntitlement($A) + $bEnt = $this.ConvertToEntitlement($B) if ($aEnt.Kind -ne $bEnt.Kind) { return $false @@ -120,6 +120,9 @@ function New-IdleMockIdentityProvider { Store = $store } + $provider | Add-Member -MemberType ScriptMethod -Name ConvertToEntitlement -Value $convertToEntitlement -Force + $provider | Add-Member -MemberType ScriptMethod -Name TestEntitlementEquals -Value $testEntitlementEquals -Force + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { <# .SYNOPSIS @@ -290,7 +293,7 @@ function New-IdleMockIdentityProvider { $result = @() foreach ($e in @($identity.Entitlements)) { - $normalized = ConvertTo-IdleMockEntitlement -Value $e + $normalized = $this.ConvertToEntitlement($e) $result += $normalized } @@ -312,14 +315,14 @@ function New-IdleMockIdentityProvider { throw "Identity '$IdentityKey' does not exist in the mock provider store." } - $normalized = ConvertTo-IdleMockEntitlement -Value $Entitlement + $normalized = $this.ConvertToEntitlement($Entitlement) $identity = $this.Store[$IdentityKey] if ($null -eq $identity.Entitlements) { $identity.Entitlements = @() } - $existing = $identity.Entitlements | Where-Object { Test-IdleMockEntitlementEquals -A $_ -B $normalized } + $existing = $identity.Entitlements | Where-Object { $this.TestEntitlementEquals($_, $normalized) } $changed = $false if (@($existing).Count -eq 0) { @@ -351,7 +354,7 @@ function New-IdleMockIdentityProvider { throw "Identity '$IdentityKey' does not exist in the mock provider store." } - $normalized = ConvertTo-IdleMockEntitlement -Value $Entitlement + $normalized = $this.ConvertToEntitlement($Entitlement) $identity = $this.Store[$IdentityKey] if ($null -eq $identity.Entitlements) { @@ -362,7 +365,7 @@ function New-IdleMockIdentityProvider { $removed = $false foreach ($item in @($identity.Entitlements)) { - if (Test-IdleMockEntitlementEquals -A $item -B $normalized) { + if ($this.TestEntitlementEquals($item, $normalized)) { $removed = $true continue } From 179253673e5a51b29abc5898f66652a279a55d0b Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:36:13 +0100 Subject: [PATCH 4/7] Harden provider capabilities contract setup --- .../ProviderCapabilities.Contract.ps1 | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 b/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 index 3033333b..5ca1b2ed 100644 --- a/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 +++ b/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 @@ -32,15 +32,23 @@ function Invoke-IdleProviderCapabilitiesContractTests { [switch] $AllowEmpty ) - BeforeAll { - $script:Provider = & $ProviderFactory - if ($null -eq $script:Provider) { - throw 'ProviderFactory returned $null. A provider instance is required for contract tests.' - } - } - Context 'Capability advertisement' { + BeforeAll { + if ($null -eq $ProviderFactory) { + throw 'ProviderFactory scriptblock is required for capability contract tests.' + } + + if ($ProviderFactory -isnot [scriptblock]) { + throw 'ProviderFactory must be a scriptblock that returns a provider instance.' + } + + $script:Provider = & ($ProviderFactory.GetNewClosure()) + if ($null -eq $script:Provider) { + throw 'ProviderFactory returned $null. A provider instance is required for contract tests.' + } + } + It 'Exposes GetCapabilities as a method' { $script:Provider.PSObject.Methods.Name | Should -Contain 'GetCapabilities' } From 7e7b288164ea48c2397e42b29175443dbd832479 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:40:00 +0100 Subject: [PATCH 5/7] Harden provider contract factory setup --- .../EntitlementProvider.Contract.ps1 | 10 +++++++++- .../ProviderContracts/IdentityProvider.Contract.ps1 | 13 ++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/ProviderContracts/EntitlementProvider.Contract.ps1 b/tests/ProviderContracts/EntitlementProvider.Contract.ps1 index 9372ebf0..cb1e75e4 100644 --- a/tests/ProviderContracts/EntitlementProvider.Contract.ps1 +++ b/tests/ProviderContracts/EntitlementProvider.Contract.ps1 @@ -42,7 +42,15 @@ function Invoke-IdleEntitlementProviderContractTests { Context "$ProviderLabel contract" { BeforeAll { - $script:Provider = & $NewProvider + if ($null -eq $NewProvider) { + throw 'NewProvider scriptblock is required for entitlement provider contract tests.' + } + + if ($NewProvider -isnot [scriptblock]) { + throw 'NewProvider must be a scriptblock that returns a provider instance.' + } + + $script:Provider = & ($NewProvider.GetNewClosure()) if ($null -eq $script:Provider) { throw 'Provider factory returned $null.' } diff --git a/tests/ProviderContracts/IdentityProvider.Contract.ps1 b/tests/ProviderContracts/IdentityProvider.Contract.ps1 index 10fae951..18aafe45 100644 --- a/tests/ProviderContracts/IdentityProvider.Contract.ps1 +++ b/tests/ProviderContracts/IdentityProvider.Contract.ps1 @@ -35,7 +35,18 @@ function Invoke-IdleIdentityProviderContractTests { Context "$ProviderLabel contract" { BeforeAll { - $script:Provider = & $NewProvider + if ($null -eq $NewProvider) { + throw 'NewProvider scriptblock is required for identity provider contract tests.' + } + + if ($NewProvider -isnot [scriptblock]) { + throw 'NewProvider must be a scriptblock that returns a provider instance.' + } + + $script:Provider = & ($NewProvider.GetNewClosure()) + if ($null -eq $script:Provider) { + throw 'NewProvider returned $null. A provider instance is required for contract tests.' + } } It 'Exposes required methods' { From a23f1c427e2d16a03774b57d28dd889d2466dce7 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:44:03 +0100 Subject: [PATCH 6/7] Fix provider contract factory capture --- .../EntitlementProvider.Contract.ps1 | 11 +++++++---- tests/ProviderContracts/IdentityProvider.Contract.ps1 | 11 +++++++---- .../ProviderCapabilities.Contract.ps1 | 11 +++++++---- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/tests/ProviderContracts/EntitlementProvider.Contract.ps1 b/tests/ProviderContracts/EntitlementProvider.Contract.ps1 index cb1e75e4..16e9b90a 100644 --- a/tests/ProviderContracts/EntitlementProvider.Contract.ps1 +++ b/tests/ProviderContracts/EntitlementProvider.Contract.ps1 @@ -39,18 +39,21 @@ function Invoke-IdleEntitlementProviderContractTests { [string] $ProviderLabel = 'Entitlement provider' ) - Context "$ProviderLabel contract" { + Context "$ProviderLabel contract" -ForEach @{ ProviderFactory = $NewProvider } { + param($ctx) BeforeAll { - if ($null -eq $NewProvider) { + $providerFactory = $ctx.ProviderFactory + + if ($null -eq $providerFactory) { throw 'NewProvider scriptblock is required for entitlement provider contract tests.' } - if ($NewProvider -isnot [scriptblock]) { + if ($providerFactory -isnot [scriptblock]) { throw 'NewProvider must be a scriptblock that returns a provider instance.' } - $script:Provider = & ($NewProvider.GetNewClosure()) + $script:Provider = & ($providerFactory.GetNewClosure()) if ($null -eq $script:Provider) { throw 'Provider factory returned $null.' } diff --git a/tests/ProviderContracts/IdentityProvider.Contract.ps1 b/tests/ProviderContracts/IdentityProvider.Contract.ps1 index 18aafe45..a498c2be 100644 --- a/tests/ProviderContracts/IdentityProvider.Contract.ps1 +++ b/tests/ProviderContracts/IdentityProvider.Contract.ps1 @@ -32,18 +32,21 @@ function Invoke-IdleIdentityProviderContractTests { [string] $ProviderLabel = 'Identity provider' ) - Context "$ProviderLabel contract" { + Context "$ProviderLabel contract" -ForEach @{ ProviderFactory = $NewProvider } { + param($ctx) BeforeAll { - if ($null -eq $NewProvider) { + $providerFactory = $ctx.ProviderFactory + + if ($null -eq $providerFactory) { throw 'NewProvider scriptblock is required for identity provider contract tests.' } - if ($NewProvider -isnot [scriptblock]) { + if ($providerFactory -isnot [scriptblock]) { throw 'NewProvider must be a scriptblock that returns a provider instance.' } - $script:Provider = & ($NewProvider.GetNewClosure()) + $script:Provider = & ($providerFactory.GetNewClosure()) if ($null -eq $script:Provider) { throw 'NewProvider returned $null. A provider instance is required for contract tests.' } diff --git a/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 b/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 index 5ca1b2ed..10a29e72 100644 --- a/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 +++ b/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 @@ -32,18 +32,21 @@ function Invoke-IdleProviderCapabilitiesContractTests { [switch] $AllowEmpty ) - Context 'Capability advertisement' { + Context 'Capability advertisement' -ForEach @{ ProviderFactory = $ProviderFactory } { + param($ctx) BeforeAll { - if ($null -eq $ProviderFactory) { + $providerFactory = $ctx.ProviderFactory + + if ($null -eq $providerFactory) { throw 'ProviderFactory scriptblock is required for capability contract tests.' } - if ($ProviderFactory -isnot [scriptblock]) { + if ($providerFactory -isnot [scriptblock]) { throw 'ProviderFactory must be a scriptblock that returns a provider instance.' } - $script:Provider = & ($ProviderFactory.GetNewClosure()) + $script:Provider = & ($providerFactory.GetNewClosure()) if ($null -eq $script:Provider) { throw 'ProviderFactory returned $null. A provider instance is required for contract tests.' } From 4206c7bc2631d61ed15ad95090e409653893b87d Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:48:48 +0100 Subject: [PATCH 7/7] Fix provider contract ForEach context data --- tests/ProviderContracts/EntitlementProvider.Contract.ps1 | 2 +- tests/ProviderContracts/IdentityProvider.Contract.ps1 | 2 +- tests/ProviderContracts/ProviderCapabilities.Contract.ps1 | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ProviderContracts/EntitlementProvider.Contract.ps1 b/tests/ProviderContracts/EntitlementProvider.Contract.ps1 index 16e9b90a..5bdedc57 100644 --- a/tests/ProviderContracts/EntitlementProvider.Contract.ps1 +++ b/tests/ProviderContracts/EntitlementProvider.Contract.ps1 @@ -39,7 +39,7 @@ function Invoke-IdleEntitlementProviderContractTests { [string] $ProviderLabel = 'Entitlement provider' ) - Context "$ProviderLabel contract" -ForEach @{ ProviderFactory = $NewProvider } { + Context "$ProviderLabel contract" -ForEach @(@{ ProviderFactory = $NewProvider }) { param($ctx) BeforeAll { diff --git a/tests/ProviderContracts/IdentityProvider.Contract.ps1 b/tests/ProviderContracts/IdentityProvider.Contract.ps1 index a498c2be..358541b1 100644 --- a/tests/ProviderContracts/IdentityProvider.Contract.ps1 +++ b/tests/ProviderContracts/IdentityProvider.Contract.ps1 @@ -32,7 +32,7 @@ function Invoke-IdleIdentityProviderContractTests { [string] $ProviderLabel = 'Identity provider' ) - Context "$ProviderLabel contract" -ForEach @{ ProviderFactory = $NewProvider } { + Context "$ProviderLabel contract" -ForEach @(@{ ProviderFactory = $NewProvider }) { param($ctx) BeforeAll { diff --git a/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 b/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 index 10a29e72..7413c766 100644 --- a/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 +++ b/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 @@ -32,7 +32,7 @@ function Invoke-IdleProviderCapabilitiesContractTests { [switch] $AllowEmpty ) - Context 'Capability advertisement' -ForEach @{ ProviderFactory = $ProviderFactory } { + Context 'Capability advertisement' -ForEach @(@{ ProviderFactory = $ProviderFactory }) { param($ctx) BeforeAll {