From 18cb01158d96df3ceb0cc127d17f055e64c10666 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:08:49 +0100 Subject: [PATCH 1/7] entitlements: add contracts and ensure step --- docs/advanced/provider-capabilities.md | 10 +- docs/usage/steps.md | 7 + examples/README.md | 4 + .../workflows/joiner-ensureentitlement.psd1 | 18 ++ .../Private/Get-IdleProviderCapabilities.ps1 | 9 + .../Private/Get-IdleStepRegistry.ps1 | 7 + .../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 | 12 + 16 files changed, 722 insertions(+), 12 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..fa1698a9 100644 --- a/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 +++ b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 @@ -58,6 +58,15 @@ function Get-IdleProviderCapabilities { if ($methodNames -contains 'DisableIdentity') { $capabilities += 'Identity.Disable' } + if ($methodNames -contains 'ListEntitlements') { + $capabilities += 'IdLE.Entitlement.List' + } + if ($methodNames -contains 'GrantEntitlement') { + $capabilities += 'IdLE.Entitlement.Grant' + } + if ($methodNames -contains 'RevokeEntitlement') { + $capabilities += 'IdLE.Entitlement.Revoke' + } } # Normalize, validate, and return a stable list. 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.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..79ef7ad9 100644 --- a/tests/Providers/MockIdentityProvider.Tests.ps1 +++ b/tests/Providers/MockIdentityProvider.Tests.ps1 @@ -17,4 +17,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 } } From e99783aa6ffbf66e2e50b2f338383ff02bb70ae4 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:12:05 +0100 Subject: [PATCH 2/7] Load mock provider in test helpers --- tests/_testHelpers.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/_testHelpers.ps1 b/tests/_testHelpers.ps1 index aa9beb9f..fe29b7f6 100644 --- a/tests/_testHelpers.ps1 +++ b/tests/_testHelpers.ps1 @@ -22,6 +22,9 @@ function Import-IdleTestModule { $manifestPath = Get-IdleModuleManifestPath Import-Module -Name $manifestPath -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 e386f9537f4ab818170a42a7489ede2056d4b96b Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:16:53 +0100 Subject: [PATCH 3/7] Fix capability inference and load steps module --- .../Private/Get-IdleProviderCapabilities.ps1 | 27 ++++++++++--------- tests/_testHelpers.ps1 | 3 +++ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 index fa1698a9..8d607d46 100644 --- a/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 +++ b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 @@ -49,15 +49,6 @@ 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 'EnsureAttribute') { - $capabilities += 'Identity.Attribute.Ensure' - } - if ($methodNames -contains 'DisableIdentity') { - $capabilities += 'Identity.Disable' - } if ($methodNames -contains 'ListEntitlements') { $capabilities += 'IdLE.Entitlement.List' } @@ -67,10 +58,20 @@ function Get-IdleProviderCapabilities { if ($methodNames -contains 'RevokeEntitlement') { $capabilities += 'IdLE.Entitlement.Revoke' } + if ($methodNames -contains 'EnsureAttribute') { + $capabilities += 'Identity.Attribute.Ensure' + } + 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 @@ -90,8 +91,10 @@ 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) + return @($normalized) } diff --git a/tests/_testHelpers.ps1 b/tests/_testHelpers.ps1 index fe29b7f6..cf9069d4 100644 --- a/tests/_testHelpers.ps1 +++ b/tests/_testHelpers.ps1 @@ -23,6 +23,9 @@ 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 } From 438ecabd3af9287e9b9602e2083bc11c97c3dbdb Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:18:33 +0100 Subject: [PATCH 4/7] adding agents.md --- AGENTS.md | 159 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..006477fe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,159 @@ +# AGENTS.md + +This repository welcomes contributions from both humans and automated agents. + +Use this document as the **default operating manual** for any agent (AI assistant, code generator, refactoring bot, CI helper) +working in the repo. + +> If this file conflicts with any other repo document, follow the more specific rule (e.g., STYLEGUIDE.md for code style). + +--- + +## 1. Project intent (read first) + +**IdentityLifecycleEngine (IdLE)** is a **generic, headless, configuration-driven** identity lifecycle orchestration engine +(Joiner / Mover / Leaver) built for **PowerShell 7+**. + +Core principles: + +- Portable, modular, testable, highly configurable +- **Plan → Execute** separation (deterministic planning, repeatable execution) +- Workflow configuration is **data-only** (no script blocks / no dynamic expressions) +- Engine stays **host-agnostic** (no UI / no service-host coupling) + +Authoritative docs: + +- `README.md` (high-level) +- `docs/index.md` (documentation entry point) +- `docs/advanced/architecture.md` (architecture decisions) +- `docs/advanced/security.md` (trust boundaries) + +--- + +## 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. + +--- + +## 3. Coding standards (PowerShell) + +Follow `STYLEGUIDE.md` for the full rule set. In short: + +- PowerShell **Core 7+** +- Use **approved PowerShell verbs** (Verb-Noun) +- 4 spaces indentation, UTF-8, LF +- Public cmdlets require **comment-based help** (`.SYNOPSIS`, `.DESCRIPTION`, `.PARAMETER`, `.EXAMPLE`, `.OUTPUTS`) +- 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 +- Validate early and fail with actionable errors. + +--- + +## 4. Architectural constraints + +### 4.1 Headless core +The engine (`IdLE.Core`) must **not** depend on: + +- UI frameworks +- interactive prompts +- 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)` + +Do not introduce alternative eventing APIs unless explicitly planned and documented. + +--- + +## 5. Testing expectations + +Follow `docs/advanced/testing.md` and `CONTRIBUTING.md`. + +- Use **Pester** for tests. +- Unit tests must not call live systems. +- Provider implementations require **provider contract tests**. + +**PR rule:** New behavior should include tests. Bug fixes must include a regression test. + +--- + +## 6. Documentation responsibilities + +- Keep docs short and linkable. +- 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`. + +--- + +## 7. Security and trust boundaries + +Follow `docs/advanced/security.md`. + +- Treat workflow definitions and lifecycle requests as **untrusted inputs** +- Reject executable objects in untrusted inputs (e.g., ScriptBlocks) +- Treat step registry, providers, and external event sinks as **trusted extension points**, but validate their shapes + +--- + +## 8. PR checklist (Definition of Done) + +Before proposing or finalizing a PR, ensure: + +- [ ] Changes are scoped to a single issue/theme +- [ ] All tests pass (`Invoke-Pester -Path ./tests`) +- [ ] Public APIs have comment-based help +- [ ] Docs updated where needed (`README.md`, `docs/`, `examples/`) +- [ ] Generated docs regenerated if required (`docs/reference/*`) +- [ ] No architecture rules violated (`docs/advanced/architecture.md`) +- [ ] No security boundary regressions (`docs/advanced/security.md`) + +--- + +## 9. Where to put new guidance for agents + +- General, cross-cutting agent rules → `AGENTS.md` (repo root) +- Code style details → `STYLEGUIDE.md` +- Contributor workflow and DoD → `CONTRIBUTING.md` +- Architecture decisions → `docs/advanced/architecture.md` +- Security boundaries → `docs/advanced/security.md` + +--- + +## 10. When in doubt + +Prefer: + +- clarity over cleverness +- explicit validation over implicit behavior +- small PRs over large rewrites +- documentation + tests as part of the same change + From c96259a0eb9ba16a2fee65de84c159965f9a33be Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:08:49 +0100 Subject: [PATCH 5/7] entitlements: add contracts and ensure step --- docs/advanced/provider-capabilities.md | 10 +- docs/usage/steps.md | 7 + examples/README.md | 4 + .../workflows/joiner-ensureentitlement.psd1 | 18 ++ .../Private/Get-IdleProviderCapabilities.ps1 | 9 + .../Private/Get-IdleStepRegistry.ps1 | 7 + .../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 | 12 + 16 files changed, 722 insertions(+), 12 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..fa1698a9 100644 --- a/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 +++ b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 @@ -58,6 +58,15 @@ function Get-IdleProviderCapabilities { if ($methodNames -contains 'DisableIdentity') { $capabilities += 'Identity.Disable' } + if ($methodNames -contains 'ListEntitlements') { + $capabilities += 'IdLE.Entitlement.List' + } + if ($methodNames -contains 'GrantEntitlement') { + $capabilities += 'IdLE.Entitlement.Grant' + } + if ($methodNames -contains 'RevokeEntitlement') { + $capabilities += 'IdLE.Entitlement.Revoke' + } } # Normalize, validate, and return a stable list. 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.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..79ef7ad9 100644 --- a/tests/Providers/MockIdentityProvider.Tests.ps1 +++ b/tests/Providers/MockIdentityProvider.Tests.ps1 @@ -17,4 +17,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 } } From c82c50e08215c02f66a77150dc639793429b3f10 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:12:05 +0100 Subject: [PATCH 6/7] Load mock provider in test helpers --- tests/_testHelpers.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/_testHelpers.ps1 b/tests/_testHelpers.ps1 index aa9beb9f..fe29b7f6 100644 --- a/tests/_testHelpers.ps1 +++ b/tests/_testHelpers.ps1 @@ -22,6 +22,9 @@ function Import-IdleTestModule { $manifestPath = Get-IdleModuleManifestPath Import-Module -Name $manifestPath -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 1cf5f59c6aba72a69ee3374832e4c3f3be48395e Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:16:53 +0100 Subject: [PATCH 7/7] Fix capability inference and load steps module --- .../Private/Get-IdleProviderCapabilities.ps1 | 27 ++++++++++--------- tests/_testHelpers.ps1 | 3 +++ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 index fa1698a9..8d607d46 100644 --- a/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 +++ b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 @@ -49,15 +49,6 @@ 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 'EnsureAttribute') { - $capabilities += 'Identity.Attribute.Ensure' - } - if ($methodNames -contains 'DisableIdentity') { - $capabilities += 'Identity.Disable' - } if ($methodNames -contains 'ListEntitlements') { $capabilities += 'IdLE.Entitlement.List' } @@ -67,10 +58,20 @@ function Get-IdleProviderCapabilities { if ($methodNames -contains 'RevokeEntitlement') { $capabilities += 'IdLE.Entitlement.Revoke' } + if ($methodNames -contains 'EnsureAttribute') { + $capabilities += 'Identity.Attribute.Ensure' + } + 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 @@ -90,8 +91,10 @@ 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) + return @($normalized) } diff --git a/tests/_testHelpers.ps1 b/tests/_testHelpers.ps1 index fe29b7f6..cf9069d4 100644 --- a/tests/_testHelpers.ps1 +++ b/tests/_testHelpers.ps1 @@ -23,6 +23,9 @@ 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 }