From 7bf4dfc265fa79a890a3d3386e67cfcb5fd260a2 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:44:24 +0100 Subject: [PATCH 01/19] core: add provider capability discovery helper --- .../Private/Get-IdleProviderCapabilities.ps1 | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 diff --git a/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 new file mode 100644 index 00000000..2d6beffb --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 @@ -0,0 +1,88 @@ +Set-StrictMode -Version Latest + +function Get-IdleProviderCapabilities { + <# + .SYNOPSIS + Returns the advertised capabilities of a provider instance. + + .DESCRIPTION + Capabilities are stable string identifiers that describe what a provider can do. + Steps will declare required capabilities, and the core will validate that the + required capabilities are available before executing a plan. + + Providers can advertise capabilities explicitly by implementing a ScriptMethod + named 'GetCapabilities' that returns a list of capability strings. + + For backward compatibility (during the migration), this function can infer a + minimal set of capabilities from well-known provider methods when no explicit + advertisement exists. + + .PARAMETER Provider + The provider instance to read capabilities from. + + .PARAMETER AllowInference + When set, capabilities may be inferred from provider methods if the provider + does not explicitly advertise capabilities via GetCapabilities(). + + .OUTPUTS + System.String[] + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Provider, + + [Parameter()] + [switch] $AllowInference + ) + + $capabilities = @() + + # Prefer explicit advertisement (provider-controlled, deterministic). + $hasGetCapabilitiesMethod = $Provider.PSObject.Methods.Name -contains 'GetCapabilities' + if ($hasGetCapabilitiesMethod) { + $capabilities = @(& $Provider.GetCapabilities()) + } + elseif ($AllowInference) { + # Migration helper: infer a minimal set from known method names. + # We keep this conservative to avoid accidentally overstating capabilities. + $methodNames = @($Provider.PSObject.Methods.Name) + + if ($methodNames -contains 'GetIdentity') { + $capabilities += 'Identity.Read' + } + if ($methodNames -contains 'EnsureAttribute') { + $capabilities += 'Identity.Attribute.Ensure' + } + if ($methodNames -contains 'DisableIdentity') { + $capabilities += 'Identity.Disable' + } + } + + # Normalize, validate, and return a stable list. + $normalized = @() + foreach ($c in @($capabilities)) { + if ($null -eq $c) { + continue + } + + $s = ($c -as [string]).Trim() + if ([string]::IsNullOrWhiteSpace($s)) { + continue + } + + # Capability naming convention: + # - dot-separated segments + # - no whitespace + # - starts with a letter + # Example: 'Entitlement.Write', 'Identity.Attribute.Ensure' + if ($s -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { + throw "Provider capability '$s' is invalid. Expected dot-separated segments like 'Identity.Read' or 'Entitlement.Write'." + } + + $normalized += $s + } + + return @($normalized | Sort-Object -Unique) +} From d003f127edd8382cd3207ec37ef050106a9df159 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:47:14 +0100 Subject: [PATCH 02/19] provider: advertise mock provider capabilities --- .../New-IdleMockIdentityProvider.ps1 | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 diff --git a/src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 b/src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 new file mode 100644 index 00000000..739bd297 --- /dev/null +++ b/src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 @@ -0,0 +1,190 @@ +function New-IdleMockIdentityProvider { + <# + .SYNOPSIS + Creates an in-memory identity provider for tests and demos. + + .DESCRIPTION + This provider is deterministic and has no external dependencies. + It is designed to be used in unit tests, contract tests, and example workflows. + + The provider keeps all state in a private in-memory store that is scoped to the + returned provider object instance (no global state). This makes tests predictable. + + .PARAMETER InitialStore + Optional initial store content. This is useful when a test wants to start with + pre-seeded identities. The input is shallow-copied to avoid unintended mutations + from the outside. + + .EXAMPLE + $provider = New-IdleMockIdentityProvider + $provider.EnsureAttribute('user1', 'Department', 'IT') | Out-Null + $provider.GetIdentity('user1') | Format-List + + .EXAMPLE + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'user1' = @{ + IdentityKey = 'user1' + Enabled = $true + Attributes = @{ + Department = 'IT' + } + } + } + #> + [CmdletBinding()] + param( + [Parameter()] + [hashtable] $InitialStore + ) + + $store = @{} + + if ($null -ne $InitialStore) { + foreach ($key in $InitialStore.Keys) { + $store[$key] = $InitialStore[$key] + } + } + + $provider = [pscustomobject]@{ + PSTypeName = 'IdLE.Provider.MockIdentityProvider' + Name = 'MockIdentityProvider' + Store = $store + } + + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + <# + .SYNOPSIS + Advertises the capabilities provided by this provider instance. + + .DESCRIPTION + Capabilities are stable string identifiers used by IdLE to validate that + a workflow plan can be executed with the available providers. + + This mock provider intentionally advertises only the capabilities that it + implements to keep tests deterministic. + #> + + return @( + 'Identity.Read' + 'Identity.Attribute.Ensure' + 'Identity.Disable' + ) + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey + ) + + # 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 = @{} + } + } + + $raw = $this.Store[$IdentityKey] + + return [pscustomobject]@{ + PSTypeName = 'IdLE.Identity' + IdentityKey = $raw.IdentityKey + Enabled = [bool]$raw.Enabled + Attributes = [hashtable]$raw.Attributes + } + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Name, + + [Parameter()] + [AllowNull()] + [object] $Value + ) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + $this.Store[$IdentityKey] = @{ + IdentityKey = $IdentityKey + Enabled = $true + Attributes = @{} + } + } + + $identity = $this.Store[$IdentityKey] + + if ($null -eq $identity.Attributes) { + $identity.Attributes = @{} + } + + $changed = $false + + if (-not $identity.Attributes.ContainsKey($Name)) { + $changed = $true + $identity.Attributes[$Name] = $Value + } + else { + $existing = $identity.Attributes[$Name] + + # Compare loosely because values may come in as different but equivalent types in tests. + if ($existing -ne $Value) { + $changed = $true + $identity.Attributes[$Name] = $Value + } + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureAttribute' + IdentityKey = $IdentityKey + Changed = [bool]$changed + Name = $Name + Value = $Value + } + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name DisableIdentity -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey + ) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + $this.Store[$IdentityKey] = @{ + IdentityKey = $IdentityKey + Enabled = $true + Attributes = @{} + } + } + + $identity = $this.Store[$IdentityKey] + $changed = $false + + if ($identity.Enabled -ne $false) { + $changed = $true + } + + if ($changed) { + $identity.Enabled = $false + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'DisableIdentity' + IdentityKey = $IdentityKey + Changed = [bool]$changed + } + } -Force + + return $provider +} From b2dc3bc860b4dc71742ce0a86220e526c86e14dc Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:47:14 +0100 Subject: [PATCH 03/19] provider: advertise mock provider capabilities --- .../Public/New-IdleMockIdentityProvider.ps1 | 126 ++++++++++++------ 1 file changed, 84 insertions(+), 42 deletions(-) diff --git a/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 b/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 index 58b65f38..739bd297 100644 --- a/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 +++ b/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 @@ -18,30 +18,31 @@ function New-IdleMockIdentityProvider { .EXAMPLE $provider = New-IdleMockIdentityProvider $provider.EnsureAttribute('user1', 'Department', 'IT') | Out-Null + $provider.GetIdentity('user1') | Format-List .EXAMPLE $provider = New-IdleMockIdentityProvider -InitialStore @{ 'user1' = @{ IdentityKey = 'user1' Enabled = $true - Attributes = @{ Department = 'HR' } + Attributes = @{ + Department = 'IT' + } } } - - .OUTPUTS - PSCustomObject (PSTypeName: IdLE.Provider.MockIdentityProvider) #> [CmdletBinding()] param( [Parameter()] - [hashtable] $InitialStore = @{} + [hashtable] $InitialStore ) - # Shallow-copy the initial store to keep the provider instance deterministic. - # We avoid referencing external hashtables directly, so tests cannot mutate the provider by accident. $store = @{} - foreach ($key in $InitialStore.Keys) { - $store[$key] = $InitialStore[$key] + + if ($null -ne $InitialStore) { + foreach ($key in $InitialStore.Keys) { + $store[$key] = $InitialStore[$key] + } } $provider = [pscustomobject]@{ @@ -50,6 +51,26 @@ function New-IdleMockIdentityProvider { Store = $store } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + <# + .SYNOPSIS + Advertises the capabilities provided by this provider instance. + + .DESCRIPTION + Capabilities are stable string identifiers used by IdLE to validate that + a workflow plan can be executed with the available providers. + + This mock provider intentionally advertises only the capabilities that it + implements to keep tests deterministic. + #> + + return @( + 'Identity.Read' + 'Identity.Attribute.Ensure' + 'Identity.Disable' + ) + } -Force + $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { param( [Parameter(Mandatory)] @@ -66,21 +87,14 @@ function New-IdleMockIdentityProvider { } } - # Ensure required sub-structures exist even when the identity was pre-seeded. - if (-not ($this.Store[$IdentityKey] -is [hashtable])) { - throw "Mock identity store entry '$IdentityKey' must be a hashtable." - } - if (-not $this.Store[$IdentityKey].ContainsKey('Attributes') -or $null -eq $this.Store[$IdentityKey].Attributes) { - $this.Store[$IdentityKey].Attributes = @{} - } - if (-not ($this.Store[$IdentityKey].Attributes -is [hashtable])) { - throw "Mock identity '$IdentityKey' property 'Attributes' must be a hashtable." - } - if (-not $this.Store[$IdentityKey].ContainsKey('Enabled')) { - $this.Store[$IdentityKey].Enabled = $true - } + $raw = $this.Store[$IdentityKey] - return $this.Store[$IdentityKey] + return [pscustomobject]@{ + PSTypeName = 'IdLE.Identity' + IdentityKey = $raw.IdentityKey + Enabled = [bool]$raw.Enabled + Attributes = [hashtable]$raw.Attributes + } } -Force $provider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { @@ -93,31 +107,48 @@ function New-IdleMockIdentityProvider { [ValidateNotNullOrEmpty()] [string] $Name, - [Parameter(Mandatory)] + [Parameter()] [AllowNull()] - $Value + [object] $Value ) - $identity = $this.GetIdentity($IdentityKey) - $attrs = $identity.Attributes + if (-not $this.Store.ContainsKey($IdentityKey)) { + $this.Store[$IdentityKey] = @{ + IdentityKey = $IdentityKey + Enabled = $true + Attributes = @{} + } + } + + $identity = $this.Store[$IdentityKey] - $hasCurrent = $attrs.ContainsKey($Name) - $current = if ($hasCurrent) { $attrs[$Name] } else { $null } + if ($null -eq $identity.Attributes) { + $identity.Attributes = @{} + } - # Idempotent convergence: only change state if the desired value differs. - $changed = (-not $hasCurrent) -or ($current -ne $Value) + $changed = $false - if ($changed) { - $attrs[$Name] = $Value + if (-not $identity.Attributes.ContainsKey($Name)) { + $changed = $true + $identity.Attributes[$Name] = $Value + } + else { + $existing = $identity.Attributes[$Name] + + # Compare loosely because values may come in as different but equivalent types in tests. + if ($existing -ne $Value) { + $changed = $true + $identity.Attributes[$Name] = $Value + } } return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Operation = 'EnsureAttribute' - IdentityKey = $IdentityKey - Name = $Name - PreviousValue = $current - Changed = [bool]$changed + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureAttribute' + IdentityKey = $IdentityKey + Changed = [bool]$changed + Name = $Name + Value = $Value } } -Force @@ -128,10 +159,21 @@ function New-IdleMockIdentityProvider { [string] $IdentityKey ) - $identity = $this.GetIdentity($IdentityKey) + if (-not $this.Store.ContainsKey($IdentityKey)) { + $this.Store[$IdentityKey] = @{ + IdentityKey = $IdentityKey + Enabled = $true + Attributes = @{} + } + } + + $identity = $this.Store[$IdentityKey] + $changed = $false + + if ($identity.Enabled -ne $false) { + $changed = $true + } - # Idempotent convergence: if already disabled, do nothing. - $changed = ($identity.Enabled -ne $false) if ($changed) { $identity.Enabled = $false } From 1647a886df344d40c283387baae5c4c21b380932 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:53:09 +0100 Subject: [PATCH 04/19] tests: use provider contracts for mock provider --- .../Providers/MockIdentityProvider.Tests.ps1 | 60 ++++++------------- 1 file changed, 19 insertions(+), 41 deletions(-) diff --git a/tests/Providers/MockIdentityProvider.Tests.ps1 b/tests/Providers/MockIdentityProvider.Tests.ps1 index 675bb23c..c7ed343a 100644 --- a/tests/Providers/MockIdentityProvider.Tests.ps1 +++ b/tests/Providers/MockIdentityProvider.Tests.ps1 @@ -11,6 +11,19 @@ Describe 'IdLE.Provider.Mock - Mock identity provider' { } Import-Module -Name $modulePath -Force -ErrorAction Stop + + # Load provider contract helpers (no Describe/It at top-level; safe for Pester discovery). + $identityContractPath = Join-Path -Path (Get-Location).Path -ChildPath 'tests\ProviderContracts\IdentityProvider.Contract.ps1' + if (-not (Test-Path -LiteralPath $identityContractPath -PathType Leaf)) { + throw "Identity provider contract not found at: $identityContractPath" + } + . $identityContractPath + + $capabilitiesContractPath = Join-Path -Path (Get-Location).Path -ChildPath 'tests\ProviderContracts\ProviderCapabilities.Contract.ps1' + if (-not (Test-Path -LiteralPath $capabilitiesContractPath -PathType Leaf)) { + throw "Provider capabilities contract not found at: $capabilitiesContractPath" + } + . $capabilitiesContractPath } It 'Creates a provider instance' { @@ -20,49 +33,14 @@ Describe 'IdLE.Provider.Mock - Mock identity provider' { $provider.Name | Should -Be 'MockIdentityProvider' } - Context 'Provider contract (inline)' { - - BeforeAll { - $script:Provider = New-IdleMockIdentityProvider - } - - It 'Exposes required methods' { - $script:Provider.PSObject.Methods.Name | Should -Contain 'GetIdentity' - $script:Provider.PSObject.Methods.Name | Should -Contain 'EnsureAttribute' - $script:Provider.PSObject.Methods.Name | Should -Contain 'DisableIdentity' - } - - It 'GetIdentity returns a hashtable with required keys' { - $id = "contract-$([guid]::NewGuid().ToString('N'))" - $identity = $script:Provider.GetIdentity($id) - - $identity | Should -BeOfType [hashtable] - $identity.Keys | Should -Contain 'IdentityKey' - $identity.Keys | Should -Contain 'Enabled' - $identity.Keys | Should -Contain 'Attributes' - - $identity.IdentityKey | Should -Be $id - $identity.Attributes | Should -BeOfType [hashtable] - } - - It 'EnsureAttribute is idempotent' { - $id = "contract-$([guid]::NewGuid().ToString('N'))" - - $r1 = $script:Provider.EnsureAttribute($id, 'Department', 'IT') - $r2 = $script:Provider.EnsureAttribute($id, 'Department', 'IT') - - $r1.Changed | Should -BeTrue - $r2.Changed | Should -BeFalse - } - - It 'DisableIdentity is idempotent' { - $id = "contract-$([guid]::NewGuid().ToString('N'))" + Context 'Provider contracts' { - $r1 = $script:Provider.DisableIdentity($id) - $r2 = $script:Provider.DisableIdentity($id) + Invoke-IdleIdentityProviderContractTests -NewProvider { + New-IdleMockIdentityProvider + } -ProviderLabel 'Mock identity provider' - $r1.Changed | Should -BeTrue - $r2.Changed | Should -BeFalse + Invoke-IdleProviderCapabilitiesContractTests -ProviderFactory { + New-IdleMockIdentityProvider } } From 5f88e976c46fa9f2a71d0aef7c85d5d54f79e40d Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:53:11 +0100 Subject: [PATCH 05/19] tests: add provider capabilities contract --- .../ProviderCapabilities.Contract.ps1 | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/ProviderContracts/ProviderCapabilities.Contract.ps1 diff --git a/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 b/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 new file mode 100644 index 00000000..3033333b --- /dev/null +++ b/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 @@ -0,0 +1,75 @@ +Set-StrictMode -Version Latest + +function Invoke-IdleProviderCapabilitiesContractTests { + <# + .SYNOPSIS + Defines provider contract tests for capability advertisement. + + .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 advertise capabilities via a ScriptMethod named 'GetCapabilities' + which returns a list of stable capability identifiers (strings). + + .PARAMETER ProviderFactory + ScriptBlock that creates and returns a provider instance. + + .PARAMETER AllowEmpty + When set, the provider may return an empty capability list (rare; generally discouraged). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [scriptblock] $ProviderFactory, + + [Parameter()] + [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' { + + It 'Exposes GetCapabilities as a method' { + $script:Provider.PSObject.Methods.Name | Should -Contain 'GetCapabilities' + } + + It 'GetCapabilities returns stable capability identifiers' { + $c1 = @(& $script:Provider.GetCapabilities()) + $c2 = @(& $script:Provider.GetCapabilities()) + + if (-not $AllowEmpty) { + $c1.Count | Should -BeGreaterThan 0 + } + + foreach ($c in $c1) { + $c | Should -BeOfType [string] + $c.Trim() | Should -Not -BeNullOrEmpty + + # Capability naming convention: + # - dot-separated segments + # - no whitespace + # - starts with a letter + # Example: 'Identity.Read', 'Entitlement.Write' + $c | Should -Match '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$' + } + + # No duplicates (providers should not over-advertise or double-advertise). + (@($c1 | Sort-Object -Unique)).Count | Should -Be $c1.Count + + # Deterministic set (order-insensitive). + @($c1 | Sort-Object) | Should -Be @($c2 | Sort-Object) + } + } +} From dd62ce07df6f7fc1f78cd470c1428c45175fed96 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:55:00 +0100 Subject: [PATCH 06/19] tests: add unit tests for provider capability discovery --- tests/Get-IdleProviderCapabilities.Tests.ps1 | 102 +++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/Get-IdleProviderCapabilities.Tests.ps1 diff --git a/tests/Get-IdleProviderCapabilities.Tests.ps1 b/tests/Get-IdleProviderCapabilities.Tests.ps1 new file mode 100644 index 00000000..aa56f266 --- /dev/null +++ b/tests/Get-IdleProviderCapabilities.Tests.ps1 @@ -0,0 +1,102 @@ +Set-StrictMode -Version Latest + +BeforeDiscovery { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'IdLE.Core - Get-IdleProviderCapabilities (provider capability discovery)' { + + InModuleScope 'IdLE.Core' { + + BeforeAll { + # Guard: ensure the helper is available inside the module scope. + Get-Command Get-IdleProviderCapabilities -ErrorAction Stop | Out-Null + } + + It 'returns explicitly advertised capabilities (sorted and unique)' { + $provider = [pscustomobject]@{ + Name = 'TestProvider' + } + + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @( + 'Identity.Disable' + 'Identity.Read' + 'Identity.Read' # duplicate on purpose + 'Identity.Attribute.Ensure' + ) + } -Force + + $caps = Get-IdleProviderCapabilities -Provider $provider + + $caps | Should -Be @( + 'Identity.Attribute.Ensure' + 'Identity.Disable' + 'Identity.Read' + ) + } + + It 'throws when provider advertises invalid capability identifiers' { + $provider = [pscustomobject]@{ + Name = 'BadProvider' + } + + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @( + 'Identity Read' # whitespace => invalid + ) + } -Force + + { Get-IdleProviderCapabilities -Provider $provider } | Should -Throw + } + + It 'returns an empty list when no GetCapabilities exists and inference is disabled' { + $provider = [pscustomobject]@{ + Name = 'LegacyProvider' + } + + # No GetCapabilities method here. + + $caps = Get-IdleProviderCapabilities -Provider $provider + @($caps).Count | Should -Be 0 + } + + It 'can infer minimal capabilities when inference is enabled' { + $provider = [pscustomobject]@{ + Name = 'LegacyProvider' + } + + # Simulate a legacy provider by adding known methods. + $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 + + $caps = Get-IdleProviderCapabilities -Provider $provider -AllowInference + + $caps | Should -Be @( + 'Identity.Attribute.Ensure' + 'Identity.Disable' + 'Identity.Read' + ) + } + + It 'prefers explicit advertisement over inference when both are available' { + $provider = [pscustomobject]@{ + Name = 'HybridProvider' + } + + # Add legacy methods (would be inferred) + $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { param([string] $IdentityKey) } -Force + + # Also add explicit GetCapabilities (must win) + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('Identity.Read') + } -Force + + $caps = Get-IdleProviderCapabilities -Provider $provider -AllowInference + + $caps | Should -Be @('Identity.Read') + } + } +} From b4f0274b9361f91573dc25bc6cd4521bcba5d9a0 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:58:17 +0100 Subject: [PATCH 07/19] core: validate required step capabilities during planning --- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 274 +++++++++++++++++++- 1 file changed, 267 insertions(+), 7 deletions(-) diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 46fc91a4..3a0b918a 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -107,6 +107,257 @@ function New-IdlePlanObject { return [string] $Value } + function Normalize-IdleRequiredCapabilities { + <# + .SYNOPSIS + Normalizes the optional RequiresCapabilities key from a workflow step. + + .DESCRIPTION + A workflow step may declare required capabilities via RequiresCapabilities. + Supported shapes: + - missing / $null -> empty list + - string -> single capability + - array/enumerable of strings -> list of capabilities + + The output is a stable, sorted, unique string array. + #> + [CmdletBinding()] + param( + [Parameter()] + [object] $Value, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $StepName + ) + + if ($null -eq $Value) { + return @() + } + + $items = @() + + if ($Value -is [string]) { + $items = @($Value) + } + elseif ($Value -is [System.Collections.IEnumerable]) { + foreach ($v in $Value) { + $items += $v + } + } + else { + throw [System.ArgumentException]::new( + ("Workflow step '{0}' has invalid RequiresCapabilities value. Expected string or string array." -f $StepName), + 'Workflow' + ) + } + + $normalized = @() + foreach ($c in $items) { + if ($null -eq $c) { + continue + } + + $s = ([string]$c).Trim() + if ([string]::IsNullOrWhiteSpace($s)) { + continue + } + + # Keep convention aligned with Get-IdleProviderCapabilities: + # - dot-separated segments + # - no whitespace + # - starts with a letter + if ($s -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { + throw [System.ArgumentException]::new( + ("Workflow step '{0}' declares invalid capability '{1}'. Expected dot-separated segments like 'Identity.Read'." -f $StepName, $s), + 'Workflow' + ) + } + + $normalized += $s + } + + return @($normalized | Sort-Object -Unique) + } + + function Get-IdleProvidersFromMap { + <# + .SYNOPSIS + Extracts provider instances from the -Providers argument. + + .DESCRIPTION + The engine currently treats -Providers as a host-controlled bag of objects. + This function extracts candidate provider objects for capability discovery. + + Supported shapes: + - $null -> no providers + - hashtable -> iterate values, ignoring known non-provider keys like 'StepRegistry' + - PSCustomObject -> read public properties as provider entries + + This is intentionally conservative: only values that look like provider instances + (non-null objects) are returned. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + if ($null -eq $Providers) { + return @() + } + + $result = @() + + if ($Providers -is [hashtable]) { + foreach ($k in $Providers.Keys) { + # 'StepRegistry' is explicitly not a provider; it is a host extension point. + if ([string]$k -eq 'StepRegistry') { + continue + } + + $v = $Providers[$k] + if ($null -ne $v) { + $result += $v + } + } + + return $result + } + + # Allow an object bag (Providers.IdentityProvider, Providers.Directory, ...). + $props = @($Providers.PSObject.Properties) + foreach ($p in $props) { + if ($p.MemberType -ne 'NoteProperty' -and $p.MemberType -ne 'Property') { + continue + } + + if ([string]$p.Name -eq 'StepRegistry') { + continue + } + + if ($null -ne $p.Value) { + $result += $p.Value + } + } + + return $result + } + + function Get-IdleAvailableCapabilities { + <# + .SYNOPSIS + Builds a stable set of capabilities available from the provided providers. + + .DESCRIPTION + Capabilities are discovered from each provider via Get-IdleProviderCapabilities. + During the migration phase we allow minimal inference for legacy providers + to avoid breaking existing demos/tests. + + The returned list is stable (sorted, unique). + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + $all = @() + + foreach ($p in @(Get-IdleProvidersFromMap -Providers $Providers)) { + # AllowInference is a migration aid. Once the ecosystem advertises capabilities + # explicitly, we can tighten this to explicit-only for maximum determinism. + $all += @(Get-IdleProviderCapabilities -Provider $p -AllowInference) + } + + return @($all | Sort-Object -Unique) + } + + function Assert-IdlePlanCapabilitiesSatisfied { + <# + .SYNOPSIS + Validates that all required step capabilities are available. + + .DESCRIPTION + This is a fail-fast validation executed during planning. + If one or more capabilities are missing, an ArgumentException is thrown with a + deterministic error message that lists missing capabilities and affected steps. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object[]] $Steps, + + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + $required = @() + $requiredByStep = @{} + + foreach ($s in @($Steps)) { + $stepName = if ($s.PSObject.Properties.Name -contains 'Name') { [string]$s.Name } else { '' } + $caps = @() + + if ($s.PSObject.Properties.Name -contains 'RequiresCapabilities') { + $caps = @($s.RequiresCapabilities) + } + + if (@($caps).Count -gt 0) { + $required += $caps + $requiredByStep[$stepName] = @($caps) + } + } + + $required = @($required | Sort-Object -Unique) + + # Nothing required -> nothing to validate. + if (@($required).Count -eq 0) { + return + } + + $available = Get-IdleAvailableCapabilities -Providers $Providers + + $missing = @() + foreach ($c in $required) { + if ($available -notcontains $c) { + $missing += $c + } + } + + $missing = @($missing | Sort-Object -Unique) + + if (@($missing).Count -eq 0) { + return + } + + # Determine which steps are affected for better UX. + $affectedSteps = @() + foreach ($k in $requiredByStep.Keys) { + $caps = @($requiredByStep[$k]) + foreach ($m in $missing) { + if ($caps -contains $m) { + $affectedSteps += $k + break + } + } + } + + $affectedSteps = @($affectedSteps | Sort-Object -Unique) + + $msg = @() + $msg += "Plan cannot be built because required provider capabilities are missing." + $msg += ("MissingCapabilities: {0}" -f ([string]::Join(', ', $missing))) + $msg += ("AffectedSteps: {0}" -f ([string]::Join(', ', $affectedSteps))) + $msg += ("AvailableCapabilities: {0}" -f ([string]::Join(', ', $available))) + + throw [System.ArgumentException]::new(([string]::Join(' ', $msg)), 'Providers') + } + # Ensure required request properties exist without hard-typing the request class. $reqProps = $Request.PSObject.Properties.Name if ($reqProps -notcontains 'LifecycleEvent') { @@ -192,19 +443,28 @@ function New-IdlePlanObject { } } + $requiresCaps = @() + if ($s.ContainsKey('RequiresCapabilities')) { + $requiresCaps = Normalize-IdleRequiredCapabilities -Value $s.RequiresCapabilities -StepName ([string]$s.Name) + } + $normalizedSteps += [pscustomobject]@{ - PSTypeName = 'IdLE.PlanStep' - Name = [string]$s.Name - Type = [string]$s.Type - Description = if ($s.ContainsKey('Description')) { [string]$s.Description } else { '' } - Condition = $condition - With = if ($s.ContainsKey('With')) { $s.With } else { @{} } - Status = $status + PSTypeName = 'IdLE.PlanStep' + Name = [string]$s.Name + Type = [string]$s.Type + Description = if ($s.ContainsKey('Description')) { [string]$s.Description } else { '' } + Condition = $condition + With = if ($s.ContainsKey('With')) { $s.With } else { @{} } + RequiresCapabilities = $requiresCaps + Status = $status } } # Attach steps to the plan after normalization. $plan.Steps = $normalizedSteps + # Fail-fast capability validation (only if at least one step declares requirements). + Assert-IdlePlanCapabilitiesSatisfied -Steps $plan.Steps -Providers $Providers + return $plan } From 9dfa48acd3b873851db9d56e8c4b59456332ca74 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:47:14 +0100 Subject: [PATCH 08/19] provider: advertise mock provider capabilities --- .../New-IdleMockIdentityProvider.ps1 | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 diff --git a/src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 b/src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 new file mode 100644 index 00000000..739bd297 --- /dev/null +++ b/src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 @@ -0,0 +1,190 @@ +function New-IdleMockIdentityProvider { + <# + .SYNOPSIS + Creates an in-memory identity provider for tests and demos. + + .DESCRIPTION + This provider is deterministic and has no external dependencies. + It is designed to be used in unit tests, contract tests, and example workflows. + + The provider keeps all state in a private in-memory store that is scoped to the + returned provider object instance (no global state). This makes tests predictable. + + .PARAMETER InitialStore + Optional initial store content. This is useful when a test wants to start with + pre-seeded identities. The input is shallow-copied to avoid unintended mutations + from the outside. + + .EXAMPLE + $provider = New-IdleMockIdentityProvider + $provider.EnsureAttribute('user1', 'Department', 'IT') | Out-Null + $provider.GetIdentity('user1') | Format-List + + .EXAMPLE + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'user1' = @{ + IdentityKey = 'user1' + Enabled = $true + Attributes = @{ + Department = 'IT' + } + } + } + #> + [CmdletBinding()] + param( + [Parameter()] + [hashtable] $InitialStore + ) + + $store = @{} + + if ($null -ne $InitialStore) { + foreach ($key in $InitialStore.Keys) { + $store[$key] = $InitialStore[$key] + } + } + + $provider = [pscustomobject]@{ + PSTypeName = 'IdLE.Provider.MockIdentityProvider' + Name = 'MockIdentityProvider' + Store = $store + } + + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + <# + .SYNOPSIS + Advertises the capabilities provided by this provider instance. + + .DESCRIPTION + Capabilities are stable string identifiers used by IdLE to validate that + a workflow plan can be executed with the available providers. + + This mock provider intentionally advertises only the capabilities that it + implements to keep tests deterministic. + #> + + return @( + 'Identity.Read' + 'Identity.Attribute.Ensure' + 'Identity.Disable' + ) + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey + ) + + # 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 = @{} + } + } + + $raw = $this.Store[$IdentityKey] + + return [pscustomobject]@{ + PSTypeName = 'IdLE.Identity' + IdentityKey = $raw.IdentityKey + Enabled = [bool]$raw.Enabled + Attributes = [hashtable]$raw.Attributes + } + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Name, + + [Parameter()] + [AllowNull()] + [object] $Value + ) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + $this.Store[$IdentityKey] = @{ + IdentityKey = $IdentityKey + Enabled = $true + Attributes = @{} + } + } + + $identity = $this.Store[$IdentityKey] + + if ($null -eq $identity.Attributes) { + $identity.Attributes = @{} + } + + $changed = $false + + if (-not $identity.Attributes.ContainsKey($Name)) { + $changed = $true + $identity.Attributes[$Name] = $Value + } + else { + $existing = $identity.Attributes[$Name] + + # Compare loosely because values may come in as different but equivalent types in tests. + if ($existing -ne $Value) { + $changed = $true + $identity.Attributes[$Name] = $Value + } + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureAttribute' + IdentityKey = $IdentityKey + Changed = [bool]$changed + Name = $Name + Value = $Value + } + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name DisableIdentity -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey + ) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + $this.Store[$IdentityKey] = @{ + IdentityKey = $IdentityKey + Enabled = $true + Attributes = @{} + } + } + + $identity = $this.Store[$IdentityKey] + $changed = $false + + if ($identity.Enabled -ne $false) { + $changed = $true + } + + if ($changed) { + $identity.Enabled = $false + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'DisableIdentity' + IdentityKey = $IdentityKey + Changed = [bool]$changed + } + } -Force + + return $provider +} From aff99e1c0a007d07b9da63e8b38d3cb895efcfd3 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:53:09 +0100 Subject: [PATCH 09/19] tests: use provider contracts for mock provider --- .../Providers/MockIdentityProvider.Tests.ps1 | 60 ++++++------------- 1 file changed, 19 insertions(+), 41 deletions(-) diff --git a/tests/Providers/MockIdentityProvider.Tests.ps1 b/tests/Providers/MockIdentityProvider.Tests.ps1 index 675bb23c..c7ed343a 100644 --- a/tests/Providers/MockIdentityProvider.Tests.ps1 +++ b/tests/Providers/MockIdentityProvider.Tests.ps1 @@ -11,6 +11,19 @@ Describe 'IdLE.Provider.Mock - Mock identity provider' { } Import-Module -Name $modulePath -Force -ErrorAction Stop + + # Load provider contract helpers (no Describe/It at top-level; safe for Pester discovery). + $identityContractPath = Join-Path -Path (Get-Location).Path -ChildPath 'tests\ProviderContracts\IdentityProvider.Contract.ps1' + if (-not (Test-Path -LiteralPath $identityContractPath -PathType Leaf)) { + throw "Identity provider contract not found at: $identityContractPath" + } + . $identityContractPath + + $capabilitiesContractPath = Join-Path -Path (Get-Location).Path -ChildPath 'tests\ProviderContracts\ProviderCapabilities.Contract.ps1' + if (-not (Test-Path -LiteralPath $capabilitiesContractPath -PathType Leaf)) { + throw "Provider capabilities contract not found at: $capabilitiesContractPath" + } + . $capabilitiesContractPath } It 'Creates a provider instance' { @@ -20,49 +33,14 @@ Describe 'IdLE.Provider.Mock - Mock identity provider' { $provider.Name | Should -Be 'MockIdentityProvider' } - Context 'Provider contract (inline)' { - - BeforeAll { - $script:Provider = New-IdleMockIdentityProvider - } - - It 'Exposes required methods' { - $script:Provider.PSObject.Methods.Name | Should -Contain 'GetIdentity' - $script:Provider.PSObject.Methods.Name | Should -Contain 'EnsureAttribute' - $script:Provider.PSObject.Methods.Name | Should -Contain 'DisableIdentity' - } - - It 'GetIdentity returns a hashtable with required keys' { - $id = "contract-$([guid]::NewGuid().ToString('N'))" - $identity = $script:Provider.GetIdentity($id) - - $identity | Should -BeOfType [hashtable] - $identity.Keys | Should -Contain 'IdentityKey' - $identity.Keys | Should -Contain 'Enabled' - $identity.Keys | Should -Contain 'Attributes' - - $identity.IdentityKey | Should -Be $id - $identity.Attributes | Should -BeOfType [hashtable] - } - - It 'EnsureAttribute is idempotent' { - $id = "contract-$([guid]::NewGuid().ToString('N'))" - - $r1 = $script:Provider.EnsureAttribute($id, 'Department', 'IT') - $r2 = $script:Provider.EnsureAttribute($id, 'Department', 'IT') - - $r1.Changed | Should -BeTrue - $r2.Changed | Should -BeFalse - } - - It 'DisableIdentity is idempotent' { - $id = "contract-$([guid]::NewGuid().ToString('N'))" + Context 'Provider contracts' { - $r1 = $script:Provider.DisableIdentity($id) - $r2 = $script:Provider.DisableIdentity($id) + Invoke-IdleIdentityProviderContractTests -NewProvider { + New-IdleMockIdentityProvider + } -ProviderLabel 'Mock identity provider' - $r1.Changed | Should -BeTrue - $r2.Changed | Should -BeFalse + Invoke-IdleProviderCapabilitiesContractTests -ProviderFactory { + New-IdleMockIdentityProvider } } From 50abd3f72eac32a14647f26220adc4c9fca0a621 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:53:11 +0100 Subject: [PATCH 10/19] tests: add provider capabilities contract --- .../ProviderCapabilities.Contract.ps1 | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/ProviderContracts/ProviderCapabilities.Contract.ps1 diff --git a/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 b/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 new file mode 100644 index 00000000..3033333b --- /dev/null +++ b/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 @@ -0,0 +1,75 @@ +Set-StrictMode -Version Latest + +function Invoke-IdleProviderCapabilitiesContractTests { + <# + .SYNOPSIS + Defines provider contract tests for capability advertisement. + + .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 advertise capabilities via a ScriptMethod named 'GetCapabilities' + which returns a list of stable capability identifiers (strings). + + .PARAMETER ProviderFactory + ScriptBlock that creates and returns a provider instance. + + .PARAMETER AllowEmpty + When set, the provider may return an empty capability list (rare; generally discouraged). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [scriptblock] $ProviderFactory, + + [Parameter()] + [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' { + + It 'Exposes GetCapabilities as a method' { + $script:Provider.PSObject.Methods.Name | Should -Contain 'GetCapabilities' + } + + It 'GetCapabilities returns stable capability identifiers' { + $c1 = @(& $script:Provider.GetCapabilities()) + $c2 = @(& $script:Provider.GetCapabilities()) + + if (-not $AllowEmpty) { + $c1.Count | Should -BeGreaterThan 0 + } + + foreach ($c in $c1) { + $c | Should -BeOfType [string] + $c.Trim() | Should -Not -BeNullOrEmpty + + # Capability naming convention: + # - dot-separated segments + # - no whitespace + # - starts with a letter + # Example: 'Identity.Read', 'Entitlement.Write' + $c | Should -Match '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$' + } + + # No duplicates (providers should not over-advertise or double-advertise). + (@($c1 | Sort-Object -Unique)).Count | Should -Be $c1.Count + + # Deterministic set (order-insensitive). + @($c1 | Sort-Object) | Should -Be @($c2 | Sort-Object) + } + } +} From 6b978b42e69180adee718beca87cf371eeec2138 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:55:00 +0100 Subject: [PATCH 11/19] tests: add unit tests for provider capability discovery --- tests/Get-IdleProviderCapabilities.Tests.ps1 | 102 +++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/Get-IdleProviderCapabilities.Tests.ps1 diff --git a/tests/Get-IdleProviderCapabilities.Tests.ps1 b/tests/Get-IdleProviderCapabilities.Tests.ps1 new file mode 100644 index 00000000..aa56f266 --- /dev/null +++ b/tests/Get-IdleProviderCapabilities.Tests.ps1 @@ -0,0 +1,102 @@ +Set-StrictMode -Version Latest + +BeforeDiscovery { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'IdLE.Core - Get-IdleProviderCapabilities (provider capability discovery)' { + + InModuleScope 'IdLE.Core' { + + BeforeAll { + # Guard: ensure the helper is available inside the module scope. + Get-Command Get-IdleProviderCapabilities -ErrorAction Stop | Out-Null + } + + It 'returns explicitly advertised capabilities (sorted and unique)' { + $provider = [pscustomobject]@{ + Name = 'TestProvider' + } + + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @( + 'Identity.Disable' + 'Identity.Read' + 'Identity.Read' # duplicate on purpose + 'Identity.Attribute.Ensure' + ) + } -Force + + $caps = Get-IdleProviderCapabilities -Provider $provider + + $caps | Should -Be @( + 'Identity.Attribute.Ensure' + 'Identity.Disable' + 'Identity.Read' + ) + } + + It 'throws when provider advertises invalid capability identifiers' { + $provider = [pscustomobject]@{ + Name = 'BadProvider' + } + + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @( + 'Identity Read' # whitespace => invalid + ) + } -Force + + { Get-IdleProviderCapabilities -Provider $provider } | Should -Throw + } + + It 'returns an empty list when no GetCapabilities exists and inference is disabled' { + $provider = [pscustomobject]@{ + Name = 'LegacyProvider' + } + + # No GetCapabilities method here. + + $caps = Get-IdleProviderCapabilities -Provider $provider + @($caps).Count | Should -Be 0 + } + + It 'can infer minimal capabilities when inference is enabled' { + $provider = [pscustomobject]@{ + Name = 'LegacyProvider' + } + + # Simulate a legacy provider by adding known methods. + $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 + + $caps = Get-IdleProviderCapabilities -Provider $provider -AllowInference + + $caps | Should -Be @( + 'Identity.Attribute.Ensure' + 'Identity.Disable' + 'Identity.Read' + ) + } + + It 'prefers explicit advertisement over inference when both are available' { + $provider = [pscustomobject]@{ + Name = 'HybridProvider' + } + + # Add legacy methods (would be inferred) + $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { param([string] $IdentityKey) } -Force + + # Also add explicit GetCapabilities (must win) + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('Identity.Read') + } -Force + + $caps = Get-IdleProviderCapabilities -Provider $provider -AllowInference + + $caps | Should -Be @('Identity.Read') + } + } +} From 266d052c7525307bfb4c9b9d161b015bf8f3b79e Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:58:17 +0100 Subject: [PATCH 12/19] core: validate required step capabilities during planning --- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 274 +++++++++++++++++++- 1 file changed, 267 insertions(+), 7 deletions(-) diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 46fc91a4..3a0b918a 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -107,6 +107,257 @@ function New-IdlePlanObject { return [string] $Value } + function Normalize-IdleRequiredCapabilities { + <# + .SYNOPSIS + Normalizes the optional RequiresCapabilities key from a workflow step. + + .DESCRIPTION + A workflow step may declare required capabilities via RequiresCapabilities. + Supported shapes: + - missing / $null -> empty list + - string -> single capability + - array/enumerable of strings -> list of capabilities + + The output is a stable, sorted, unique string array. + #> + [CmdletBinding()] + param( + [Parameter()] + [object] $Value, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $StepName + ) + + if ($null -eq $Value) { + return @() + } + + $items = @() + + if ($Value -is [string]) { + $items = @($Value) + } + elseif ($Value -is [System.Collections.IEnumerable]) { + foreach ($v in $Value) { + $items += $v + } + } + else { + throw [System.ArgumentException]::new( + ("Workflow step '{0}' has invalid RequiresCapabilities value. Expected string or string array." -f $StepName), + 'Workflow' + ) + } + + $normalized = @() + foreach ($c in $items) { + if ($null -eq $c) { + continue + } + + $s = ([string]$c).Trim() + if ([string]::IsNullOrWhiteSpace($s)) { + continue + } + + # Keep convention aligned with Get-IdleProviderCapabilities: + # - dot-separated segments + # - no whitespace + # - starts with a letter + if ($s -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { + throw [System.ArgumentException]::new( + ("Workflow step '{0}' declares invalid capability '{1}'. Expected dot-separated segments like 'Identity.Read'." -f $StepName, $s), + 'Workflow' + ) + } + + $normalized += $s + } + + return @($normalized | Sort-Object -Unique) + } + + function Get-IdleProvidersFromMap { + <# + .SYNOPSIS + Extracts provider instances from the -Providers argument. + + .DESCRIPTION + The engine currently treats -Providers as a host-controlled bag of objects. + This function extracts candidate provider objects for capability discovery. + + Supported shapes: + - $null -> no providers + - hashtable -> iterate values, ignoring known non-provider keys like 'StepRegistry' + - PSCustomObject -> read public properties as provider entries + + This is intentionally conservative: only values that look like provider instances + (non-null objects) are returned. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + if ($null -eq $Providers) { + return @() + } + + $result = @() + + if ($Providers -is [hashtable]) { + foreach ($k in $Providers.Keys) { + # 'StepRegistry' is explicitly not a provider; it is a host extension point. + if ([string]$k -eq 'StepRegistry') { + continue + } + + $v = $Providers[$k] + if ($null -ne $v) { + $result += $v + } + } + + return $result + } + + # Allow an object bag (Providers.IdentityProvider, Providers.Directory, ...). + $props = @($Providers.PSObject.Properties) + foreach ($p in $props) { + if ($p.MemberType -ne 'NoteProperty' -and $p.MemberType -ne 'Property') { + continue + } + + if ([string]$p.Name -eq 'StepRegistry') { + continue + } + + if ($null -ne $p.Value) { + $result += $p.Value + } + } + + return $result + } + + function Get-IdleAvailableCapabilities { + <# + .SYNOPSIS + Builds a stable set of capabilities available from the provided providers. + + .DESCRIPTION + Capabilities are discovered from each provider via Get-IdleProviderCapabilities. + During the migration phase we allow minimal inference for legacy providers + to avoid breaking existing demos/tests. + + The returned list is stable (sorted, unique). + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + $all = @() + + foreach ($p in @(Get-IdleProvidersFromMap -Providers $Providers)) { + # AllowInference is a migration aid. Once the ecosystem advertises capabilities + # explicitly, we can tighten this to explicit-only for maximum determinism. + $all += @(Get-IdleProviderCapabilities -Provider $p -AllowInference) + } + + return @($all | Sort-Object -Unique) + } + + function Assert-IdlePlanCapabilitiesSatisfied { + <# + .SYNOPSIS + Validates that all required step capabilities are available. + + .DESCRIPTION + This is a fail-fast validation executed during planning. + If one or more capabilities are missing, an ArgumentException is thrown with a + deterministic error message that lists missing capabilities and affected steps. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object[]] $Steps, + + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + $required = @() + $requiredByStep = @{} + + foreach ($s in @($Steps)) { + $stepName = if ($s.PSObject.Properties.Name -contains 'Name') { [string]$s.Name } else { '' } + $caps = @() + + if ($s.PSObject.Properties.Name -contains 'RequiresCapabilities') { + $caps = @($s.RequiresCapabilities) + } + + if (@($caps).Count -gt 0) { + $required += $caps + $requiredByStep[$stepName] = @($caps) + } + } + + $required = @($required | Sort-Object -Unique) + + # Nothing required -> nothing to validate. + if (@($required).Count -eq 0) { + return + } + + $available = Get-IdleAvailableCapabilities -Providers $Providers + + $missing = @() + foreach ($c in $required) { + if ($available -notcontains $c) { + $missing += $c + } + } + + $missing = @($missing | Sort-Object -Unique) + + if (@($missing).Count -eq 0) { + return + } + + # Determine which steps are affected for better UX. + $affectedSteps = @() + foreach ($k in $requiredByStep.Keys) { + $caps = @($requiredByStep[$k]) + foreach ($m in $missing) { + if ($caps -contains $m) { + $affectedSteps += $k + break + } + } + } + + $affectedSteps = @($affectedSteps | Sort-Object -Unique) + + $msg = @() + $msg += "Plan cannot be built because required provider capabilities are missing." + $msg += ("MissingCapabilities: {0}" -f ([string]::Join(', ', $missing))) + $msg += ("AffectedSteps: {0}" -f ([string]::Join(', ', $affectedSteps))) + $msg += ("AvailableCapabilities: {0}" -f ([string]::Join(', ', $available))) + + throw [System.ArgumentException]::new(([string]::Join(' ', $msg)), 'Providers') + } + # Ensure required request properties exist without hard-typing the request class. $reqProps = $Request.PSObject.Properties.Name if ($reqProps -notcontains 'LifecycleEvent') { @@ -192,19 +443,28 @@ function New-IdlePlanObject { } } + $requiresCaps = @() + if ($s.ContainsKey('RequiresCapabilities')) { + $requiresCaps = Normalize-IdleRequiredCapabilities -Value $s.RequiresCapabilities -StepName ([string]$s.Name) + } + $normalizedSteps += [pscustomobject]@{ - PSTypeName = 'IdLE.PlanStep' - Name = [string]$s.Name - Type = [string]$s.Type - Description = if ($s.ContainsKey('Description')) { [string]$s.Description } else { '' } - Condition = $condition - With = if ($s.ContainsKey('With')) { $s.With } else { @{} } - Status = $status + PSTypeName = 'IdLE.PlanStep' + Name = [string]$s.Name + Type = [string]$s.Type + Description = if ($s.ContainsKey('Description')) { [string]$s.Description } else { '' } + Condition = $condition + With = if ($s.ContainsKey('With')) { $s.With } else { @{} } + RequiresCapabilities = $requiresCaps + Status = $status } } # Attach steps to the plan after normalization. $plan.Steps = $normalizedSteps + # Fail-fast capability validation (only if at least one step declares requirements). + Assert-IdlePlanCapabilitiesSatisfied -Steps $plan.Steps -Providers $Providers + return $plan } From 92770efec49fe81deee63705d8b573c5189abf52 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:59:47 +0100 Subject: [PATCH 13/19] tests: validate required capabilities during plan build --- tests/New-IdlePlan.Capabilities.Tests.ps1 | 77 +++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/New-IdlePlan.Capabilities.Tests.ps1 diff --git a/tests/New-IdlePlan.Capabilities.Tests.ps1 b/tests/New-IdlePlan.Capabilities.Tests.ps1 new file mode 100644 index 00000000..d56ff44e --- /dev/null +++ b/tests/New-IdlePlan.Capabilities.Tests.ps1 @@ -0,0 +1,77 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'New-IdlePlan - required provider capabilities' { + + It 'fails fast when a step requires capabilities that no provider advertises' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-capabilities.psd1' + + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Capability Validation' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Disable identity' + Type = 'IdLE.Step.EmitEvent' + With = @{ Message = 'Disable identity (planning only test)' } + RequiresCapabilities = 'Identity.Disable' + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'required provider capabilities are missing' + $_.Exception.Message | Should -Match 'MissingCapabilities:\s+Identity\.Disable' + $_.Exception.Message | Should -Match 'AffectedSteps:\s+Disable identity' + } + } + + It 'builds the plan when required capabilities are available' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-capabilities.psd1' + + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Capability Validation' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Disable identity' + Type = 'IdLE.Step.EmitEvent' + With = @{ Message = 'Disable identity (planning only test)' } + RequiresCapabilities = 'Identity.Disable' + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + # Minimal provider that advertises the required capability. + $provider = [pscustomobject]@{ + Name = 'TestProvider' + } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('Identity.Disable') + } -Force + + $providers = @{ + IdentityProvider = $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 @('Identity.Disable') + } +} From cff0075ae52ef55f642dfad1cdc1fd9573caa2ed Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 20:09:17 +0100 Subject: [PATCH 14/19] docs: document capability-based provider model and link from architecture --- docs/_config.yml | 2 + docs/_sidebar.md | 7 + docs/advanced/architecture.md | 16 ++- docs/advanced/extensibility.md | 16 +++ docs/advanced/provider-capabilities.md | 173 +++++++++++++++++++++++++ docs/index.md | 1 + 6 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 docs/advanced/provider-capabilities.md diff --git a/docs/_config.yml b/docs/_config.yml index d2718496..82dae2b0 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -15,5 +15,7 @@ header_pages: - usage/steps.md - usage/providers.md - advanced/architecture.md + - advanced/provider-capabilities.md - advanced/extensibility.md + - advanced/security.md - advanced/testing.md \ No newline at end of file diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 6b4bda99..7bbf508b 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -1,18 +1,22 @@ - [Home](index.md) ### Overview + - [Concept](overview/concept.md) ### Getting started + - [Installation](getting-started/installation.md) - [Quickstart](getting-started/quickstart.md) ### Usage + - [Workflows](usage/workflows.md) - [Steps](usage/steps.md) - [Providers](usage/providers.md) ### Reference + - [Cmdlet Reference](reference/cmdlets.md) - [Events and Observability](reference/events-and-observability.md) - [Providers and Contracts](reference/providers-and-contracts.md) @@ -21,10 +25,13 @@ - [Step Catalog](reference/steps.md) ### Specifications + - [Plan export (JSON)](specs/plan-export.md) ### Advanced + - [Architecture](advanced/architecture.md) +- [Provider Capabilities](advanced/provider-capabilities.md) - [Security](advanced/security.md) - [Extensibility](advanced/extensibility.md) - [Testing](advanced/testing.md) diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index 9cc3541a..68d9d651 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -22,13 +22,27 @@ IdLE splits orchestration into two phases. ### Plan -Planning creates a deterministic plan: +IdLE builds a deterministic execution plan before any step is executed. +During this planning phase, the engine validates structural correctness, +conditions, and execution prerequisites. - evaluates declarative conditions - validates inputs and references - produces data-only actions - captures a **data-only request intent snapshot** (e.g. IdentityKeys / DesiredState / Changes) for auditing and export +#### Provider Capabilities (Planning-time Validation) + +IdLE uses a **capability-based provider model** to validate execution +prerequisites during plan build. + +Steps may declare required capabilities, while providers explicitly +advertise which capabilities they support. The engine matches both sides +and fails fast if required functionality is missing. + +For details on the capability-based provider model and the validation flow, +see [Provider Capabilities](provider-capabilities.md). + ### Execute Execution runs the plan exactly as built: diff --git a/docs/advanced/extensibility.md b/docs/advanced/extensibility.md index 972ad0e7..23c2e973 100644 --- a/docs/advanced/extensibility.md +++ b/docs/advanced/extensibility.md @@ -19,6 +19,9 @@ Keep steps host-agnostic: do not call UI APIs directly. ## Add a new provider +Providers are responsible for interacting with external systems (directories, +cloud services, APIs, etc.). + A new provider typically involves: 1. A contract interface (if not already present) @@ -26,6 +29,19 @@ A new provider typically involves: 3. Session acquisition via host execution context 4. Contract tests and unit tests +### Capability Advertisement + +Providers must explicitly advertise their supported capabilities via a +`GetCapabilities()` method. These capabilities are used by the engine +during plan build to validate whether all required functionality is +available. + +The full contract, naming rules, and validation behavior are described in +[Provider Capabilities](provider-capabilities.md). + +Providers should include the corresponding provider capability contract tests +to ensure compliance. + ## Versioning strategy Keep workflows stable by treating step identifiers as contracts. diff --git a/docs/advanced/provider-capabilities.md b/docs/advanced/provider-capabilities.md new file mode 100644 index 00000000..102be061 --- /dev/null +++ b/docs/advanced/provider-capabilities.md @@ -0,0 +1,173 @@ +# Provider capabilities + +This document describes IdLE's capability-based provider model and how capability validation fits into the planning and execution flow. + +## Motivation + +IdLE is designed to run in different environments with different provider implementations. +To keep the core engine generic and portable, IdLE uses **capabilities** as the contract boundary between: + +- **Steps** (what is required) +- **Providers** (what is available) + +Capabilities enable: + +- deterministic, fail-fast validation during planning +- clear error messages when prerequisites are missing +- provider depth without hard-coding provider-specific assumptions into the engine +- re-usable contract tests for any provider module + +## Terminology + +### Capability + +A capability is a **stable string identifier** describing a feature a provider can perform. + +Naming convention: + +- dot-separated segments +- no whitespace +- starts with a letter +- examples: `Identity.Read`, `Identity.Disable`, `Entitlement.Write` + +## High-level flow + +The following describes the end-to-end flow with capability validation included. + +```text +Workflow Definition (PSD1) + | + v +Plan Builder (New-IdlePlan / New-IdlePlanObject) + | + |-- normalizes steps (Name/Type/With/Condition/RequiresCapabilities) + | + |-- NEW: capability validation (fail fast) + | - collect required capabilities from steps + | - discover available capabilities from providers + | - compare and throw on missing capabilities + | + v +Plan artifact (IdLE.Plan) is created + | + v +Plan execution (Invoke-IdlePlan / Invoke-IdlePlanObject) + | + v +Steps execute (optional runtime defensive checks may be added later) +``` + +Key point: **planning is the primary enforcement point**. +If a plan cannot be executed due to missing provider functionality, the plan build fails early and deterministically. + +## Provider advertisement + +Providers advertise capabilities explicitly via a method named: + +- `GetCapabilities()` + +The method returns a string list, e.g.: + +```powershell +$provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @( + 'Identity.Read' + 'Identity.Attribute.Ensure' + 'Identity.Disable' + ) +} -Force +``` + +### Contract requirement + +Every provider intended for use with IdLE should expose `GetCapabilities()` and return: + +- only valid capability identifiers +- no duplicates +- a deterministic set (order-insensitive) + +IdLE includes a reusable Pester contract to enforce this. + +## Step requirements + +Steps can declare required capabilities in workflow definitions using the optional key: + +- `RequiresCapabilities` + +Supported shapes: + +- missing / `$null` -> no requirements +- string -> single capability +- string array -> multiple capabilities + +Example: + +```powershell +@{ + Name = 'Disable identity' + Type = 'DisableIdentity' + RequiresCapabilities = @('Identity.Read', 'Identity.Disable') +} +``` + +During planning, IdLE normalizes this into a stable, sorted, unique string array on each plan step. + +## Capability validation + +Capability validation is performed during plan build: + +1. Collect required capabilities from all steps (`RequiresCapabilities`) +2. Discover available capabilities from all provider instances passed via `-Providers` +3. Compare required vs. available +4. Throw a deterministic error if any required capabilities are missing + +The thrown error message includes: + +- `MissingCapabilities: ...` +- `AffectedSteps: ...` +- `AvailableCapabilities: ...` + +This is designed for good UX and for automated diagnostics in CI logs. + +## Provider discovery from `-Providers` + +The engine treats the `-Providers` argument as a host-controlled "bag of objects". +For capability discovery, IdLE currently extracts candidate providers from: + +- hashtable values (excluding known non-provider keys like `StepRegistry`) +- public properties on PSCustomObject provider bags (also excluding `StepRegistry`) + +This keeps the engine host-agnostic while still allowing deterministic capability validation. + +## Migration and inference + +During migration, IdLE may infer a minimal capability set for legacy providers that do not yet implement `GetCapabilities()`. + +This inference is intentionally conservative to avoid overstating what a provider can do. + +Once all providers in the ecosystem advertise capabilities explicitly, inference can be disabled to make the contract stricter. + +## Testing + +### Provider contract tests + +Providers should include contract tests that validate capability advertisement: + +- `tests/ProviderContracts/ProviderCapabilities.Contract.ps1` + +A provider test binds the contract to a provider instance via a factory function. + +### Planning tests + +Planning tests should cover: + +- fail-fast behavior when capabilities are missing +- successful plan build when capabilities are available + +## Future extensions + +Potential follow-ups (not required for the initial capability model): + +- runtime defensive checks (optional) during step execution +- richer capability metadata (versioning, parameters) if ever needed +- mapping capabilities to provider identities (which provider satisfied what), if multi-provider routing becomes necessary diff --git a/docs/index.md b/docs/index.md index 49ef85e9..2f17b2c1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -38,6 +38,7 @@ used between IdLE and its hosts. ## Advanced - [Architecture](advanced/architecture.md) +- [Provider Capabilities](advanced/provider-capabilities.md) - [Security](advanced/security.md) - [Extensibility](advanced/extensibility.md) - [Testing](advanced/testing.md) From 51fad65d6f519de9b635a6574734dac66c1e28d4 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 22:28:48 +0100 Subject: [PATCH 15/19] core: fix invocation of provider GetCapabilities method --- src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 index 2d6beffb..2b2dad6c 100644 --- a/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 +++ b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 @@ -42,7 +42,7 @@ function Get-IdleProviderCapabilities { # Prefer explicit advertisement (provider-controlled, deterministic). $hasGetCapabilitiesMethod = $Provider.PSObject.Methods.Name -contains 'GetCapabilities' if ($hasGetCapabilitiesMethod) { - $capabilities = @(& $Provider.GetCapabilities()) + $capabilities = @($Provider.GetCapabilities()) } elseif ($AllowInference) { # Migration helper: infer a minimal set from known method names. From 57a094ee47177369848fd80c852115c676321bcf Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 22:28:59 +0100 Subject: [PATCH 16/19] tests: load provider contracts during discovery using stable paths --- .../Providers/MockIdentityProvider.Tests.ps1 | 83 ++++--------------- 1 file changed, 14 insertions(+), 69 deletions(-) diff --git a/tests/Providers/MockIdentityProvider.Tests.ps1 b/tests/Providers/MockIdentityProvider.Tests.ps1 index c7ed343a..4cdb6ae7 100644 --- a/tests/Providers/MockIdentityProvider.Tests.ps1 +++ b/tests/Providers/MockIdentityProvider.Tests.ps1 @@ -1,75 +1,20 @@ Set-StrictMode -Version Latest -Describe 'IdLE.Provider.Mock - Mock identity provider' { - - BeforeAll { - # Use a relative import from the current working directory (repo root) used by the test runner. - # This keeps the test simple and avoids repo-root discovery issues in Pester discovery/runtime. - $modulePath = Join-Path -Path (Get-Location).Path -ChildPath 'src\IdLE.Provider.Mock\IdLE.Provider.Mock.psd1' - if (-not (Test-Path -LiteralPath $modulePath -PathType Leaf)) { - throw "Provider module manifest not found at: $modulePath" - } - - Import-Module -Name $modulePath -Force -ErrorAction Stop - - # Load provider contract helpers (no Describe/It at top-level; safe for Pester discovery). - $identityContractPath = Join-Path -Path (Get-Location).Path -ChildPath 'tests\ProviderContracts\IdentityProvider.Contract.ps1' - if (-not (Test-Path -LiteralPath $identityContractPath -PathType Leaf)) { - throw "Identity provider contract not found at: $identityContractPath" - } - . $identityContractPath - - $capabilitiesContractPath = Join-Path -Path (Get-Location).Path -ChildPath 'tests\ProviderContracts\ProviderCapabilities.Contract.ps1' - if (-not (Test-Path -LiteralPath $capabilitiesContractPath -PathType Leaf)) { - throw "Provider capabilities contract not found at: $capabilitiesContractPath" - } - . $capabilitiesContractPath - } - - It 'Creates a provider instance' { - $provider = New-IdleMockIdentityProvider - - $provider | Should -Not -BeNullOrEmpty - $provider.Name | Should -Be 'MockIdentityProvider' - } - - Context 'Provider contracts' { - - Invoke-IdleIdentityProviderContractTests -NewProvider { - New-IdleMockIdentityProvider - } -ProviderLabel 'Mock identity provider' - - Invoke-IdleProviderCapabilitiesContractTests -ProviderFactory { - New-IdleMockIdentityProvider - } +BeforeDiscovery { + # $PSScriptRoot = ...\tests\Providers + # repo root = parent of ...\tests + $testsRoot = Split-Path -Path $PSScriptRoot -Parent + $repoRoot = Split-Path -Path $testsRoot -Parent + + $identityContractPath = Join-Path -Path $repoRoot -ChildPath 'tests\ProviderContracts\IdentityProvider.Contract.ps1' + if (-not (Test-Path -LiteralPath $identityContractPath -PathType Leaf)) { + throw "Identity provider contract not found at: $identityContractPath" } + . $identityContractPath - It 'Keeps state scoped to the provider instance' { - $p1 = New-IdleMockIdentityProvider - $p2 = New-IdleMockIdentityProvider - - $null = $p1.EnsureAttribute('user1', 'Department', 'IT') - - $i1 = $p1.GetIdentity('user1') - $i2 = $p2.GetIdentity('user1') - - $i1.Attributes['Department'] | Should -Be 'IT' - $i2.Attributes.ContainsKey('Department') | Should -BeFalse - } - - It 'Supports pre-seeded identities via InitialStore' { - $provider = New-IdleMockIdentityProvider -InitialStore @{ - 'user1' = @{ - IdentityKey = 'user1' - Enabled = $true - Attributes = @{ Department = 'HR' } - } - } - - $identity = $provider.GetIdentity('user1') - $identity.Attributes['Department'] | Should -Be 'HR' - - $r = $provider.EnsureAttribute('user1', 'Department', 'HR') - $r.Changed | Should -BeFalse + $capabilitiesContractPath = Join-Path -Path $repoRoot -ChildPath 'tests\ProviderContracts\ProviderCapabilities.Contract.ps1' + if (-not (Test-Path -LiteralPath $capabilitiesContractPath -PathType Leaf)) { + throw "Provider capabilities contract not found at: $capabilitiesContractPath" } + . $capabilitiesContractPath } From f821d8e5464574e9f5a1b88638a957fcefc81189 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 22:29:06 +0100 Subject: [PATCH 17/19] core: allow RequiresCapabilities in workflow step schema --- src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index f1ff9d22..1b434f2d 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -42,7 +42,7 @@ function Test-IdleWorkflowSchema { continue } - $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description') + $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RequiresCapabilities') foreach ($k in $step.Keys) { if ($allowedStepKeys -notcontains $k) { $errors.Add("Unknown key '$k' in $stepPath. Allowed keys: $($allowedStepKeys -join ', ').") From f34761066238f8234f8997102e91eb77b0882e12 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 22:29:13 +0100 Subject: [PATCH 18/19] core: normalize workflow steps from hashtable or object for planning --- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 148 ++++++++++++++++---- 1 file changed, 124 insertions(+), 24 deletions(-) diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 3a0b918a..0d7d274f 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -284,11 +284,13 @@ function New-IdlePlanObject { This is a fail-fast validation executed during planning. If one or more capabilities are missing, an ArgumentException is thrown with a deterministic error message that lists missing capabilities and affected steps. + + No-op when the plan contains no steps. #> [CmdletBinding()] param( - [Parameter(Mandatory)] - [ValidateNotNull()] + [Parameter()] + [AllowNull()] [object[]] $Steps, [Parameter()] @@ -296,6 +298,10 @@ function New-IdlePlanObject { [object] $Providers ) + if ($null -eq $Steps -or @($Steps).Count -eq 0) { + return + } + $required = @() $requiredByStep = @{} @@ -320,7 +326,7 @@ function New-IdlePlanObject { return } - $available = Get-IdleAvailableCapabilities -Providers $Providers + $available = @(Get-IdleAvailableCapabilities -Providers $Providers) $missing = @() foreach ($c in $required) { @@ -351,9 +357,9 @@ function New-IdlePlanObject { $msg = @() $msg += "Plan cannot be built because required provider capabilities are missing." - $msg += ("MissingCapabilities: {0}" -f ([string]::Join(', ', $missing))) - $msg += ("AffectedSteps: {0}" -f ([string]::Join(', ', $affectedSteps))) - $msg += ("AvailableCapabilities: {0}" -f ([string]::Join(', ', $available))) + $msg += ("MissingCapabilities: {0}" -f ([string]::Join(', ', @($missing)))) + $msg += ("AffectedSteps: {0}" -f ([string]::Join(', ', @($affectedSteps)))) + $msg += ("AvailableCapabilities: {0}" -f ([string]::Join(', ', @($available)))) throw [System.ArgumentException]::new(([string]::Join(' ', $msg)), 'Providers') } @@ -407,32 +413,112 @@ function New-IdlePlanObject { Workflow = $workflow } + function Test-IdleWorkflowStepKey { + <# + .SYNOPSIS + Checks whether a workflow step contains a given key. + + .DESCRIPTION + Workflow steps can be represented as hashtables (IDictionary) or as objects + (PSCustomObject) depending on how they were imported. This helper provides a + stable way to check for keys across both representations. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Key + ) + + if ($Step -is [System.Collections.IDictionary]) { + return $Step.ContainsKey($Key) + } + + return ($Step.PSObject.Properties.Name -contains $Key) + } + + function Get-IdleWorkflowStepValue { + <# + .SYNOPSIS + Gets a value from a workflow step by key. + + .DESCRIPTION + Workflow steps can be represented as hashtables (IDictionary) or as objects + (PSCustomObject) depending on how they were imported. This helper provides a + stable way to read values across both representations. + + IMPORTANT: + Call Test-IdleWorkflowStepKey before calling this function when the key may be optional. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Key + ) + + if ($Step -is [System.Collections.IDictionary]) { + return $Step[$Key] + } + + return $Step.PSObject.Properties[$Key].Value + } + # Normalize steps into a stable internal representation. # We deliberately keep step entries as PSCustomObject to avoid cross-module class loading issues. # Step conditions are evaluated during planning and may mark steps as NotApplicable. $normalizedSteps = @() foreach ($s in @($workflow.Steps)) { - if (-not $s.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$s.Name)) { + $stepName = if (Test-IdleWorkflowStepKey -Step $s -Key 'Name') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'Name') + } + else { + '' + } + + if ([string]::IsNullOrWhiteSpace($stepName)) { throw [System.ArgumentException]::new('Workflow step is missing required key "Name".', 'Workflow') } - if (-not $s.ContainsKey('Type') -or [string]::IsNullOrWhiteSpace([string]$s.Type)) { - throw [System.ArgumentException]::new(("Workflow step '{0}' is missing required key 'Type'." -f [string]$s.Name), 'Workflow') + + $stepType = if (Test-IdleWorkflowStepKey -Step $s -Key 'Type') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'Type') } - if ($s.ContainsKey('When')) { + else { + '' + } + + if ([string]::IsNullOrWhiteSpace($stepType)) { + throw [System.ArgumentException]::new(("Workflow step '{0}' is missing required key 'Type'." -f $stepName), 'Workflow') + } + + if (Test-IdleWorkflowStepKey -Step $s -Key 'When') { throw [System.ArgumentException]::new( - "Workflow step '$($s.Name)' uses key 'When'. 'When' has been renamed to 'Condition'. Please update the workflow definition.", + ("Workflow step '{0}' uses key 'When'. 'When' has been renamed to 'Condition'. Please update the workflow definition." -f $stepName), 'Workflow' ) } - $condition = if ($s.ContainsKey('Condition')) { $s.Condition } else { $null } + $condition = if (Test-IdleWorkflowStepKey -Step $s -Key 'Condition') { + Get-IdleWorkflowStepValue -Step $s -Key 'Condition' + } + else { + $null + } $status = 'Planned' if ($null -ne $condition) { - $schemaErrors = Test-IdleConditionSchema -Condition $condition -StepName ([string]$s.Name) + $schemaErrors = Test-IdleConditionSchema -Condition $condition -StepName $stepName if (@($schemaErrors).Count -gt 0) { throw [System.ArgumentException]::new( - ("Invalid Condition on step '{0}': {1}" -f [string]$s.Name, ([string]::Join(' ', @($schemaErrors)))), + ("Invalid Condition on step '{0}': {1}" -f $stepName, ([string]::Join(' ', @($schemaErrors)))), 'Workflow' ) } @@ -444,19 +530,33 @@ function New-IdlePlanObject { } $requiresCaps = @() - if ($s.ContainsKey('RequiresCapabilities')) { - $requiresCaps = Normalize-IdleRequiredCapabilities -Value $s.RequiresCapabilities -StepName ([string]$s.Name) + if (Test-IdleWorkflowStepKey -Step $s -Key 'RequiresCapabilities') { + $requiresCaps = Normalize-IdleRequiredCapabilities -Value (Get-IdleWorkflowStepValue -Step $s -Key 'RequiresCapabilities') -StepName $stepName + } + + $description = if (Test-IdleWorkflowStepKey -Step $s -Key 'Description') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'Description') + } + else { + '' + } + + $with = if (Test-IdleWorkflowStepKey -Step $s -Key 'With') { + Get-IdleWorkflowStepValue -Step $s -Key 'With' + } + else { + @{} } $normalizedSteps += [pscustomobject]@{ - PSTypeName = 'IdLE.PlanStep' - Name = [string]$s.Name - Type = [string]$s.Type - Description = if ($s.ContainsKey('Description')) { [string]$s.Description } else { '' } - Condition = $condition - With = if ($s.ContainsKey('With')) { $s.With } else { @{} } - RequiresCapabilities = $requiresCaps - Status = $status + PSTypeName = 'IdLE.PlanStep' + Name = $stepName + Type = $stepType + Description = $description + Condition = $condition + With = $with + RequiresCapabilities = $requiresCaps + Status = $status } } From 65f2ec1e33ad292785924a819d991663b4a0acc7 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 3 Jan 2026 22:29:34 +0100 Subject: [PATCH 19/19] tests: add capability contracts and plan validation tests --- tests/New-IdlePlan.Capabilities.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/New-IdlePlan.Capabilities.Tests.ps1 b/tests/New-IdlePlan.Capabilities.Tests.ps1 index d56ff44e..90943ef8 100644 --- a/tests/New-IdlePlan.Capabilities.Tests.ps1 +++ b/tests/New-IdlePlan.Capabilities.Tests.ps1 @@ -26,7 +26,7 @@ Describe 'New-IdlePlan - required provider capabilities' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $null | Out-Null throw 'Expected an exception but none was thrown.' } catch {