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 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] 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 { From 727aa6ebfa7f2c2e991193bcc037195fe82cec8f Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 17:36:54 +0100 Subject: [PATCH 08/12] tests: fix provider contract factories and entitlement interface --- .../EntitlementProvider.Contract.ps1 | 141 ++++++++++++------ .../IdentityProvider.Contract.ps1 | 50 ++++--- .../ProviderCapabilities.Contract.ps1 | 69 +++------ 3 files changed, 147 insertions(+), 113 deletions(-) diff --git a/tests/ProviderContracts/EntitlementProvider.Contract.ps1 b/tests/ProviderContracts/EntitlementProvider.Contract.ps1 index 5bdedc57..28d01416 100644 --- a/tests/ProviderContracts/EntitlementProvider.Contract.ps1 +++ b/tests/ProviderContracts/EntitlementProvider.Contract.ps1 @@ -3,7 +3,7 @@ Set-StrictMode -Version Latest function Invoke-IdleEntitlementProviderContractTests { <# .SYNOPSIS - Defines provider contract tests for entitlement operations. + Defines provider contract tests for an entitlement provider implementation. .DESCRIPTION This file intentionally contains no top-level Describe/It blocks. @@ -13,15 +13,15 @@ function Invoke-IdleEntitlementProviderContractTests { - 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) + This contract expects the following methods on the provider: + - 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 + Entitlement is treated as a value object with at least: + - Kind (string) + - Id (string) + - DisplayName (optional) .PARAMETER NewProvider ScriptBlock that creates and returns a provider instance. @@ -39,12 +39,11 @@ function Invoke-IdleEntitlementProviderContractTests { [string] $ProviderLabel = 'Entitlement provider' ) - Context "$ProviderLabel contract" -ForEach @(@{ ProviderFactory = $NewProvider }) { - param($ctx) + # Capture inside closure for run phase (Pester 5 discovery vs run). + $providerFactory = $NewProvider.GetNewClosure() + Context "$ProviderLabel contract" { BeforeAll { - $providerFactory = $ctx.ProviderFactory - if ($null -eq $providerFactory) { throw 'NewProvider scriptblock is required for entitlement provider contract tests.' } @@ -53,69 +52,113 @@ function Invoke-IdleEntitlementProviderContractTests { throw 'NewProvider must be a scriptblock that returns a provider instance.' } - $script:Provider = & ($providerFactory.GetNewClosure()) + $script:Provider = & $providerFactory if ($null -eq $script:Provider) { - throw 'Provider factory returned $null.' + throw 'NewProvider returned $null. A provider instance is required for contract tests.' } } - 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 'Exposes required methods' { + $script:Provider.PSObject.Methods.Name | Should -Contain 'ListEntitlements' + $script:Provider.PSObject.Methods.Name | Should -Contain 'GrantEntitlement' + $script:Provider.PSObject.Methods.Name | Should -Contain 'RevokeEntitlement' } - It 'ListEntitlements fails for a non-existent identity' { - { $script:Provider.ListEntitlements('missing-identity') } | Should -Throw + It 'GrantEntitlement returns a stable result shape' { + $id = "contract-$([guid]::NewGuid().ToString('N'))" + + # Ensure identity exists (some providers are strict). + [void]$script:Provider.GetIdentity($id) + + $entitlement = [pscustomobject]@{ + Kind = 'Contract' + Id = "entitlement-$([guid]::NewGuid().ToString('N'))" + DisplayName = 'Contract Entitlement' + } + + $result = $script:Provider.GrantEntitlement($id, $entitlement) + + $result | Should -Not -BeNullOrEmpty + $result.PSObject.Properties.Name | Should -Contain 'Changed' + $result.PSObject.Properties.Name | Should -Contain 'IdentityKey' + $result.PSObject.Properties.Name | Should -Contain 'Entitlement' + + $result.IdentityKey | Should -Be $id + $result.Changed | Should -BeOfType [bool] + + $result.Entitlement | Should -Not -BeNullOrEmpty + $result.Entitlement.PSObject.Properties.Name | Should -Contain 'Kind' + $result.Entitlement.PSObject.Properties.Name | Should -Contain 'Id' } 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') + [void]$script:Provider.GetIdentity($id) + + $entitlement = [pscustomobject]@{ + Kind = 'Contract' + Id = "entitlement-$([guid]::NewGuid().ToString('N'))" } $r1 = $script:Provider.GrantEntitlement($id, $entitlement) - $r1.PSObject.Properties.Name | Should -Contain 'Changed' - $r1.Changed | Should -BeTrue + $r2 = $script:Provider.GrantEntitlement($id, $entitlement) - $r2 = $script:Provider.GrantEntitlement($id, @{ Kind = 'Group'; Id = 'ID-123' }) - $r2.PSObject.Properties.Name | Should -Contain 'Changed' + $r1.Changed | Should -BeTrue $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' { + It 'RevokeEntitlement is idempotent (after a grant)' { $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') + [void]$script:Provider.GetIdentity($id) + + $entitlement = [pscustomobject]@{ + Kind = 'Contract' + Id = "entitlement-$([guid]::NewGuid().ToString('N'))" } - $null = $script:Provider.GrantEntitlement($id, $entitlement) + [void]$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' + + $r1.Changed | Should -BeTrue $r2.Changed | Should -BeFalse + } + + It 'ListEntitlements reflects grant and revoke operations' { + $id = "contract-$([guid]::NewGuid().ToString('N'))" + + [void]$script:Provider.GetIdentity($id) + + $entitlement = [pscustomobject]@{ + Kind = 'Contract' + Id = "entitlement-$([guid]::NewGuid().ToString('N'))" + } + + # Normalize ListEntitlements results: + # Providers may return $null to indicate "no entitlements". Treat that as empty. + $before = @($script:Provider.ListEntitlements($id)) + + [void]$script:Provider.GrantEntitlement($id, $entitlement) + + $afterGrant = @($script:Provider.ListEntitlements($id)) + + [void]$script:Provider.RevokeEntitlement($id, $entitlement) + + $afterRevoke = @($script:Provider.ListEntitlements($id)) + + # Sanity: arrays (may be empty). Do NOT use pipeline with empty arrays in Pester. + ($before -is [object[]]) | Should -BeTrue + ($afterGrant -is [object[]]) | Should -BeTrue + ($afterRevoke -is [object[]]) | Should -BeTrue + + # After grant, the entitlement must be present (by Kind+Id). + ($afterGrant | Where-Object { $_.Kind -eq $entitlement.Kind -and $_.Id -eq $entitlement.Id }).Count | Should -Be 1 - $assignments = @($script:Provider.ListEntitlements($id)) - $assignments | Where-Object { $_.Kind -eq 'License' -and $_.Id -eq 'sku-basic' } | Should -BeNullOrEmpty + # After revoke, it must be absent. + ($afterRevoke | Where-Object { $_.Kind -eq $entitlement.Kind -and $_.Id -eq $entitlement.Id }).Count | Should -Be 0 } } } diff --git a/tests/ProviderContracts/IdentityProvider.Contract.ps1 b/tests/ProviderContracts/IdentityProvider.Contract.ps1 index 358541b1..50649b4c 100644 --- a/tests/ProviderContracts/IdentityProvider.Contract.ps1 +++ b/tests/ProviderContracts/IdentityProvider.Contract.ps1 @@ -32,12 +32,11 @@ function Invoke-IdleIdentityProviderContractTests { [string] $ProviderLabel = 'Identity provider' ) - Context "$ProviderLabel contract" -ForEach @(@{ ProviderFactory = $NewProvider }) { - param($ctx) + # Capture inside closure for run phase (Pester 5 discovery vs run). + $providerFactory = $NewProvider.GetNewClosure() + Context "$ProviderLabel contract" { BeforeAll { - $providerFactory = $ctx.ProviderFactory - if ($null -eq $providerFactory) { throw 'NewProvider scriptblock is required for identity provider contract tests.' } @@ -46,7 +45,7 @@ function Invoke-IdleIdentityProviderContractTests { throw 'NewProvider must be a scriptblock that returns a provider instance.' } - $script:Provider = & ($providerFactory.GetNewClosure()) + $script:Provider = & $providerFactory if ($null -eq $script:Provider) { throw 'NewProvider returned $null. A provider instance is required for contract tests.' } @@ -58,30 +57,43 @@ function Invoke-IdleIdentityProviderContractTests { $script:Provider.PSObject.Methods.Name | Should -Contain 'DisableIdentity' } - It 'GetIdentity returns a hashtable with required keys' { + It 'GetIdentity returns an identity object with required keys/properties' { $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 | Should -Not -BeNullOrEmpty + + if ($identity -is [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] + $identity.IdentityKey | Should -Be $id + $identity.Enabled | Should -BeOfType [bool] + $identity.Attributes | Should -BeOfType [hashtable] + } + else { + $identity.PSObject.Properties.Name | Should -Contain 'IdentityKey' + $identity.PSObject.Properties.Name | Should -Contain 'Enabled' + $identity.PSObject.Properties.Name | Should -Contain 'Attributes' + + $identity.IdentityKey | Should -Be $id + $identity.Enabled | Should -BeOfType [bool] + $identity.Attributes | Should -BeOfType [hashtable] + } } - It 'EnsureAttribute is idempotent and returns a Changed flag' { + It 'EnsureAttribute returns a stable result shape' { $id = "contract-$([guid]::NewGuid().ToString('N'))" - $r1 = $script:Provider.EnsureAttribute($id, 'Department', 'IT') - $r2 = $script:Provider.EnsureAttribute($id, 'Department', 'IT') + $result = $script:Provider.EnsureAttribute($id, 'contractKey', 'contractValue') - $r1.PSObject.Properties.Name | Should -Contain 'Changed' - $r1.Changed | Should -BeTrue + $result | Should -Not -BeNullOrEmpty + $result.PSObject.Properties.Name | Should -Contain 'Changed' + $result.PSObject.Properties.Name | Should -Contain 'IdentityKey' - $r2.PSObject.Properties.Name | Should -Contain 'Changed' - $r2.Changed | Should -BeFalse + $result.IdentityKey | Should -Be $id + $result.Changed | Should -BeOfType [bool] } It 'DisableIdentity is idempotent and returns a Changed flag' { diff --git a/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 b/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 index 7413c766..0365c15f 100644 --- a/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 +++ b/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 @@ -1,27 +1,6 @@ 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)] @@ -32,12 +11,11 @@ function Invoke-IdleProviderCapabilitiesContractTests { [switch] $AllowEmpty ) - Context 'Capability advertisement' -ForEach @(@{ ProviderFactory = $ProviderFactory }) { - param($ctx) + # Capture inside closure for run phase. + $providerFactory = $ProviderFactory.GetNewClosure() + Context 'Capability advertisement' { BeforeAll { - $providerFactory = $ctx.ProviderFactory - if ($null -eq $providerFactory) { throw 'ProviderFactory scriptblock is required for capability contract tests.' } @@ -46,41 +24,42 @@ function Invoke-IdleProviderCapabilitiesContractTests { throw 'ProviderFactory must be a scriptblock that returns a provider instance.' } - $script:Provider = & ($providerFactory.GetNewClosure()) - if ($null -eq $script:Provider) { + $provider = & $providerFactory + if ($null -eq $provider) { throw 'ProviderFactory returned $null. A provider instance is required for contract tests.' } + + $script:Provider = $provider } 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()) + It 'GetCapabilities returns a string list' { + $caps = $script:Provider.GetCapabilities() - if (-not $AllowEmpty) { - $c1.Count | Should -BeGreaterThan 0 + $caps | Should -Not -BeNullOrEmpty + foreach ($c in $caps) { + $c | Should -BeOfType [string] + $c | Should -Not -BeNullOrEmpty } + } - foreach ($c in $c1) { - $c | Should -BeOfType [string] - $c.Trim() | Should -Not -BeNullOrEmpty + It 'GetCapabilities returns stable identifiers (no whitespace)' { + $caps = $script:Provider.GetCapabilities() - # 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]+)+$' + foreach ($c in $caps) { + $c | Should -Not -Match '\s' } + } - # No duplicates (providers should not over-advertise or double-advertise). - (@($c1 | Sort-Object -Unique)).Count | Should -Be $c1.Count + It 'GetCapabilities can be empty only when explicitly allowed' { + $caps = $script:Provider.GetCapabilities() - # Deterministic set (order-insensitive). - @($c1 | Sort-Object) | Should -Be @($c2 | Sort-Object) + if (-not $AllowEmpty) { + $caps.Count | Should -BeGreaterThan 0 + } } } } From b7b5f52c3942ee30c9a77cd26bbf18aa34defdf7 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 17:41:11 +0100 Subject: [PATCH 09/12] docs: Updated steps reference --- docs/reference/steps.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/reference/steps.md b/docs/reference/steps.md index a75162cb..f19961cf 100644 --- a/docs/reference/steps.md +++ b/docs/reference/steps.md @@ -61,3 +61,37 @@ The step is idempotent by design: it converges state to the desired value. | Value | Yes | --- + +## EnsureEntitlement + +- **Step Name**: $stepType +- **Implementation**: $commandName +- **Idempotent**: $idempotent +- **Contracts**: $contracts +- **Events**: Unknown + +**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. + +**Inputs (With.\*)** + +| Key | Required | +| --- | --- | +| IdentityKey | Yes | +| Entitlement | Yes | +| State | Yes | + +--- From 50a27a18a56f3645cbc9b6a59c34c1355175ad3a Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 17:47:32 +0100 Subject: [PATCH 10/12] tests: make provider contract factories CI-safe --- .../EntitlementProvider.Contract.ps1 | 59 ++++--------------- .../IdentityProvider.Contract.ps1 | 36 +++-------- .../ProviderCapabilities.Contract.ps1 | 19 +++--- 3 files changed, 31 insertions(+), 83 deletions(-) diff --git a/tests/ProviderContracts/EntitlementProvider.Contract.ps1 b/tests/ProviderContracts/EntitlementProvider.Contract.ps1 index 28d01416..191f8ecd 100644 --- a/tests/ProviderContracts/EntitlementProvider.Contract.ps1 +++ b/tests/ProviderContracts/EntitlementProvider.Contract.ps1 @@ -1,34 +1,6 @@ Set-StrictMode -Version Latest function Invoke-IdleEntitlementProviderContractTests { - <# - .SYNOPSIS - Defines provider contract tests for an entitlement provider implementation. - - .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. - - This contract expects the following methods on the provider: - - ListEntitlements(IdentityKey) - - GrantEntitlement(IdentityKey, Entitlement) - - RevokeEntitlement(IdentityKey, Entitlement) - - Entitlement is treated as a value object with at least: - - Kind (string) - - Id (string) - - DisplayName (optional) - - .PARAMETER NewProvider - ScriptBlock that creates and returns a provider instance. - - .PARAMETER ProviderLabel - Optional label for better test output. - #> [CmdletBinding()] param( [Parameter(Mandatory)] @@ -39,19 +11,20 @@ function Invoke-IdleEntitlementProviderContractTests { [string] $ProviderLabel = 'Entitlement provider' ) - # Capture inside closure for run phase (Pester 5 discovery vs run). - $providerFactory = $NewProvider.GetNewClosure() + $cases = @( + @{ + ProviderFactory = $NewProvider + } + ) - Context "$ProviderLabel contract" { + Context "$ProviderLabel contract" -ForEach $cases { BeforeAll { + $providerFactory = $_.ProviderFactory + if ($null -eq $providerFactory) { throw 'NewProvider scriptblock is required for entitlement provider contract tests.' } - if ($providerFactory -isnot [scriptblock]) { - throw 'NewProvider must be a scriptblock that returns a provider instance.' - } - $script:Provider = & $providerFactory if ($null -eq $script:Provider) { throw 'NewProvider returned $null. A provider instance is required for contract tests.' @@ -67,7 +40,6 @@ function Invoke-IdleEntitlementProviderContractTests { It 'GrantEntitlement returns a stable result shape' { $id = "contract-$([guid]::NewGuid().ToString('N'))" - # Ensure identity exists (some providers are strict). [void]$script:Provider.GetIdentity($id) $entitlement = [pscustomobject]@{ @@ -137,28 +109,21 @@ function Invoke-IdleEntitlementProviderContractTests { Id = "entitlement-$([guid]::NewGuid().ToString('N'))" } - # Normalize ListEntitlements results: - # Providers may return $null to indicate "no entitlements". Treat that as empty. $before = @($script:Provider.ListEntitlements($id)) [void]$script:Provider.GrantEntitlement($id, $entitlement) - $afterGrant = @($script:Provider.ListEntitlements($id)) [void]$script:Provider.RevokeEntitlement($id, $entitlement) - $afterRevoke = @($script:Provider.ListEntitlements($id)) - # Sanity: arrays (may be empty). Do NOT use pipeline with empty arrays in Pester. + ($afterGrant | Where-Object { $_.Kind -eq $entitlement.Kind -and $_.Id -eq $entitlement.Id }).Count | Should -Be 1 + ($afterRevoke | Where-Object { $_.Kind -eq $entitlement.Kind -and $_.Id -eq $entitlement.Id }).Count | Should -Be 0 + + # Sanity: $null is treated as empty. ($before -is [object[]]) | Should -BeTrue ($afterGrant -is [object[]]) | Should -BeTrue ($afterRevoke -is [object[]]) | Should -BeTrue - - # After grant, the entitlement must be present (by Kind+Id). - ($afterGrant | Where-Object { $_.Kind -eq $entitlement.Kind -and $_.Id -eq $entitlement.Id }).Count | Should -Be 1 - - # After revoke, it must be absent. - ($afterRevoke | Where-Object { $_.Kind -eq $entitlement.Kind -and $_.Id -eq $entitlement.Id }).Count | Should -Be 0 } } } diff --git a/tests/ProviderContracts/IdentityProvider.Contract.ps1 b/tests/ProviderContracts/IdentityProvider.Contract.ps1 index 50649b4c..de760c6c 100644 --- a/tests/ProviderContracts/IdentityProvider.Contract.ps1 +++ b/tests/ProviderContracts/IdentityProvider.Contract.ps1 @@ -1,27 +1,6 @@ Set-StrictMode -Version Latest function Invoke-IdleIdentityProviderContractTests { - <# - .SYNOPSIS - Defines provider contract tests for an identity provider implementation. - - .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. - - Therefore the contract takes a provider factory scriptblock and creates the provider - inside its own BeforeAll. - - .PARAMETER NewProvider - ScriptBlock that creates a new provider instance. - - .PARAMETER ProviderLabel - Optional label for better test output. - #> [CmdletBinding()] param( [Parameter(Mandatory)] @@ -32,19 +11,20 @@ function Invoke-IdleIdentityProviderContractTests { [string] $ProviderLabel = 'Identity provider' ) - # Capture inside closure for run phase (Pester 5 discovery vs run). - $providerFactory = $NewProvider.GetNewClosure() + $cases = @( + @{ + ProviderFactory = $NewProvider + } + ) - Context "$ProviderLabel contract" { + Context "$ProviderLabel contract" -ForEach $cases { BeforeAll { + $providerFactory = $_.ProviderFactory + if ($null -eq $providerFactory) { throw 'NewProvider scriptblock is required for identity provider contract tests.' } - if ($providerFactory -isnot [scriptblock]) { - throw 'NewProvider must be a scriptblock that returns a provider instance.' - } - $script:Provider = & $providerFactory 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 0365c15f..0af8116e 100644 --- a/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 +++ b/tests/ProviderContracts/ProviderCapabilities.Contract.ps1 @@ -11,25 +11,28 @@ function Invoke-IdleProviderCapabilitiesContractTests { [switch] $AllowEmpty ) - # Capture inside closure for run phase. - $providerFactory = $ProviderFactory.GetNewClosure() + $cases = @( + @{ + ProviderFactory = $ProviderFactory + AllowEmpty = [bool]$AllowEmpty + } + ) - Context 'Capability advertisement' { + Context 'Capability advertisement' -ForEach $cases { BeforeAll { + $providerFactory = $_.ProviderFactory + 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.' - } - $provider = & $providerFactory if ($null -eq $provider) { throw 'ProviderFactory returned $null. A provider instance is required for contract tests.' } $script:Provider = $provider + $script:AllowEmpty = $_.AllowEmpty } It 'Exposes GetCapabilities as a method' { @@ -57,7 +60,7 @@ function Invoke-IdleProviderCapabilitiesContractTests { It 'GetCapabilities can be empty only when explicitly allowed' { $caps = $script:Provider.GetCapabilities() - if (-not $AllowEmpty) { + if (-not $script:AllowEmpty) { $caps.Count | Should -BeGreaterThan 0 } } From 9c9d903dd06e4fb49ace0c0221a5dd0af1b21862 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:00:46 +0100 Subject: [PATCH 11/12] provider: removed duplicate code in module root (is in public) --- .../New-IdleMockIdentityProvider.ps1 | 388 ------------------ 1 file changed, 388 deletions(-) delete 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 deleted file mode 100644 index 1ce6950b..00000000 --- a/src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 +++ /dev/null @@ -1,388 +0,0 @@ -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' - } - Entitlements = @( - @{ Kind = 'Group'; Id = 'demo-group'; DisplayName = 'Demo Group' } - ) - } - } - #> - [CmdletBinding()] - param( - [Parameter()] - [hashtable] $InitialStore - ) - - $store = @{} - - if ($null -ne $InitialStore) { - foreach ($key in $InitialStore.Keys) { - $store[$key] = $InitialStore[$key] - } - } - - $convertToEntitlement = { - [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 - } - } - } - - $testEntitlementEquals = { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $A, - - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $B - ) - - $aEnt = $this.ConvertToEntitlement($A) - $bEnt = $this.ConvertToEntitlement($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' - 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 - 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' - 'IdLE.Entitlement.List' - 'IdLE.Entitlement.Grant' - 'IdLE.Entitlement.Revoke' - ) - } -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 = @{} - Entitlements = @() - } - } - - $raw = $this.Store[$IdentityKey] - - if ($null -eq $raw.Entitlements) { - $raw.Entitlements = @() - } - - 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 = @{} - Entitlements = @() - } - } - - $identity = $this.Store[$IdentityKey] - - if ($null -eq $identity.Attributes) { - $identity.Attributes = @{} - } - if ($null -eq $identity.Entitlements) { - $identity.Entitlements = @() - } - - $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 = @{} - Entitlements = @() - } - } - - $identity = $this.Store[$IdentityKey] - if ($null -eq $identity.Entitlements) { - $identity.Entitlements = @() - } - - $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 - - $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 = $this.ConvertToEntitlement($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 = $this.ConvertToEntitlement($Entitlement) - - $identity = $this.Store[$IdentityKey] - if ($null -eq $identity.Entitlements) { - $identity.Entitlements = @() - } - - $existing = $identity.Entitlements | Where-Object { $this.TestEntitlementEquals($_, $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 = $this.ConvertToEntitlement($Entitlement) - - $identity = $this.Store[$IdentityKey] - if ($null -eq $identity.Entitlements) { - $identity.Entitlements = @() - } - - $remaining = @() - $removed = $false - - foreach ($item in @($identity.Entitlements)) { - if ($this.TestEntitlementEquals($item, $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 -} From ac0338d2c12756fe2bb935ac0c2978e781e7ac7b Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:05:51 +0100 Subject: [PATCH 12/12] docs: fixing AGENTS.md lint errors --- AGENTS.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 006477fe..2aa3d10d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,14 +33,17 @@ Authoritative docs: ## 2. How to behave as an agent ### 2.1 No assumptions + - If something is unclear, **ask targeted questions**. - Prefer a sensible default proposal, but **explicitly label it** as a default. ### 2.2 One change-set at a time + - Keep PRs focused: one issue / one theme. - Avoid drive-by refactors unless the issue is specifically about refactoring. ### 2.3 Determinism over cleverness + - Prefer explicit validation and deterministic behavior. - Avoid “magic” behavior, hidden fallbacks, or implicit global state. @@ -57,10 +60,12 @@ Follow `STYLEGUIDE.md` for the full rule set. In short: - Inline comments should explain **why**, not what ### 3.1 Public vs. Private + - Keep a clean separation between Public and Private functions. - Treat exported commands as stable contracts. ### 3.2 Configuration is data-only (no code in config) + - Workflow definitions (PSD1) must be **data-only**: - No `ScriptBlock` - No dynamic PowerShell expressions @@ -71,6 +76,7 @@ Follow `STYLEGUIDE.md` for the full rule set. In short: ## 4. Architectural constraints ### 4.1 Headless core + The engine (`IdLE.Core`) must **not** depend on: - UI frameworks @@ -78,11 +84,13 @@ The engine (`IdLE.Core`) must **not** depend on: - service hosts / web servers ### 4.2 Steps vs. Providers + - **Steps**: convergence logic, idempotent intent, no authentication - **Providers**: system adapters, handle authentication and external calls - Steps should only write to declared `State.*` outputs. ### 4.3 Eventing + Use the single event contract: - `Context.EventSink.WriteEvent(Type, Message, StepName, Data)` @@ -109,6 +117,7 @@ Follow `docs/advanced/testing.md` and `CONTRIBUTING.md`. - Update docs when changing contracts, configuration schema, public cmdlets, step behavior, or provider contracts. ### 6.1 Generated references + The cmdlet and step references under `docs/reference/` are generated. Do **not** edit generated files by hand—regenerate via the repository tools as documented in `CONTRIBUTING.md`. @@ -156,4 +165,3 @@ Prefer: - explicit validation over implicit behavior - small PRs over large rewrites - documentation + tests as part of the same change -