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 - 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/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 | + +--- 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..5a563f0b 100644 --- a/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 +++ b/src/IdLE.Core/Private/Get-IdleProviderCapabilities.ps1 @@ -38,19 +38,27 @@ 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. # 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 +66,16 @@ function Get-IdleProviderCapabilities { if ($methodNames -contains 'DisableIdentity') { $capabilities += 'Identity.Disable' } + if ($methodNames -contains 'GetIdentity') { + $capabilities += 'Identity.Read' + } + + $capabilitySource = 'inferred' } # 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,8 +95,16 @@ 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) + } + } + + if ($capabilitySource -eq 'explicit') { + return @($normalized | Sort-Object -Unique) } - 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) } 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 deleted file mode 100644 index 739bd297..00000000 --- a/src/IdLE.Provider.Mock/New-IdleMockIdentityProvider.ps1 +++ /dev/null @@ -1,190 +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' - } - } - } - #> - [CmdletBinding()] - param( - [Parameter()] - [hashtable] $InitialStore - ) - - $store = @{} - - if ($null -ne $InitialStore) { - foreach ($key in $InitialStore.Keys) { - $store[$key] = $InitialStore[$key] - } - } - - $provider = [pscustomobject]@{ - PSTypeName = 'IdLE.Provider.MockIdentityProvider' - Name = 'MockIdentityProvider' - Store = $store - } - - $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - <# - .SYNOPSIS - Advertises the capabilities provided by this provider instance. - - .DESCRIPTION - Capabilities are stable string identifiers used by IdLE to validate that - a workflow plan can be executed with the available providers. - - This mock provider intentionally advertises only the capabilities that it - implements to keep tests deterministic. - #> - - return @( - 'Identity.Read' - 'Identity.Attribute.Ensure' - 'Identity.Disable' - ) - } -Force - - $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { - param( - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $IdentityKey - ) - - # Create missing identities on demand to keep tests and demos frictionless. - if (-not $this.Store.ContainsKey($IdentityKey)) { - $this.Store[$IdentityKey] = @{ - IdentityKey = $IdentityKey - Enabled = $true - Attributes = @{} - } - } - - $raw = $this.Store[$IdentityKey] - - return [pscustomobject]@{ - PSTypeName = 'IdLE.Identity' - IdentityKey = $raw.IdentityKey - Enabled = [bool]$raw.Enabled - Attributes = [hashtable]$raw.Attributes - } - } -Force - - $provider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { - param( - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $IdentityKey, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $Name, - - [Parameter()] - [AllowNull()] - [object] $Value - ) - - if (-not $this.Store.ContainsKey($IdentityKey)) { - $this.Store[$IdentityKey] = @{ - IdentityKey = $IdentityKey - Enabled = $true - Attributes = @{} - } - } - - $identity = $this.Store[$IdentityKey] - - if ($null -eq $identity.Attributes) { - $identity.Attributes = @{} - } - - $changed = $false - - if (-not $identity.Attributes.ContainsKey($Name)) { - $changed = $true - $identity.Attributes[$Name] = $Value - } - else { - $existing = $identity.Attributes[$Name] - - # Compare loosely because values may come in as different but equivalent types in tests. - if ($existing -ne $Value) { - $changed = $true - $identity.Attributes[$Name] = $Value - } - } - - return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Operation = 'EnsureAttribute' - IdentityKey = $IdentityKey - Changed = [bool]$changed - Name = $Name - Value = $Value - } - } -Force - - $provider | Add-Member -MemberType ScriptMethod -Name DisableIdentity -Value { - param( - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $IdentityKey - ) - - if (-not $this.Store.ContainsKey($IdentityKey)) { - $this.Store[$IdentityKey] = @{ - IdentityKey = $IdentityKey - Enabled = $true - Attributes = @{} - } - } - - $identity = $this.Store[$IdentityKey] - $changed = $false - - if ($identity.Enabled -ne $false) { - $changed = $true - } - - if ($changed) { - $identity.Enabled = $false - } - - return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Operation = 'DisableIdentity' - IdentityKey = $IdentityKey - Changed = [bool]$changed - } - } -Force - - return $provider -} diff --git a/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 b/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 index 739bd297..1ce6950b 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,12 +48,81 @@ function New-IdleMockIdentityProvider { } } + $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 @@ -68,6 +140,9 @@ function New-IdleMockIdentityProvider { 'Identity.Read' 'Identity.Attribute.Ensure' 'Identity.Disable' + 'IdLE.Entitlement.List' + 'IdLE.Entitlement.Grant' + 'IdLE.Entitlement.Revoke' ) } -Force @@ -81,14 +156,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 +194,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 +206,9 @@ function New-IdleMockIdentityProvider { if ($null -eq $identity.Attributes) { $identity.Attributes = @{} } + if ($null -eq $identity.Entitlements) { + $identity.Entitlements = @() + } $changed = $false @@ -161,13 +245,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 +275,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 = $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 } 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..191f8ecd --- /dev/null +++ b/tests/ProviderContracts/EntitlementProvider.Contract.ps1 @@ -0,0 +1,129 @@ +Set-StrictMode -Version Latest + +function Invoke-IdleEntitlementProviderContractTests { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [scriptblock] $NewProvider, + + [Parameter()] + [string] $ProviderLabel = 'Entitlement provider' + ) + + $cases = @( + @{ + ProviderFactory = $NewProvider + } + ) + + Context "$ProviderLabel contract" -ForEach $cases { + BeforeAll { + $providerFactory = $_.ProviderFactory + + if ($null -eq $providerFactory) { + throw 'NewProvider scriptblock is required for entitlement provider contract tests.' + } + + $script:Provider = & $providerFactory + if ($null -eq $script:Provider) { + throw 'NewProvider returned $null. A provider instance is required for contract tests.' + } + } + + 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 'GrantEntitlement returns a stable result shape' { + $id = "contract-$([guid]::NewGuid().ToString('N'))" + + [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'))" + + [void]$script:Provider.GetIdentity($id) + + $entitlement = [pscustomobject]@{ + Kind = 'Contract' + Id = "entitlement-$([guid]::NewGuid().ToString('N'))" + } + + $r1 = $script:Provider.GrantEntitlement($id, $entitlement) + $r2 = $script:Provider.GrantEntitlement($id, $entitlement) + + $r1.Changed | Should -BeTrue + $r2.Changed | Should -BeFalse + } + + It 'RevokeEntitlement is idempotent (after a grant)' { + $id = "contract-$([guid]::NewGuid().ToString('N'))" + + [void]$script:Provider.GetIdentity($id) + + $entitlement = [pscustomobject]@{ + Kind = 'Contract' + Id = "entitlement-$([guid]::NewGuid().ToString('N'))" + } + + [void]$script:Provider.GrantEntitlement($id, $entitlement) + + $r1 = $script:Provider.RevokeEntitlement($id, $entitlement) + $r2 = $script:Provider.RevokeEntitlement($id, $entitlement) + + $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'))" + } + + $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)) + + ($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 + } + } +} diff --git a/tests/ProviderContracts/IdentityProvider.Contract.ps1 b/tests/ProviderContracts/IdentityProvider.Contract.ps1 index 10fae951..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,10 +11,24 @@ function Invoke-IdleIdentityProviderContractTests { [string] $ProviderLabel = 'Identity provider' ) - Context "$ProviderLabel contract" { + $cases = @( + @{ + ProviderFactory = $NewProvider + } + ) + Context "$ProviderLabel contract" -ForEach $cases { BeforeAll { - $script:Provider = & $NewProvider + $providerFactory = $_.ProviderFactory + + if ($null -eq $providerFactory) { + throw 'NewProvider scriptblock is required for identity provider contract tests.' + } + + $script:Provider = & $providerFactory + if ($null -eq $script:Provider) { + throw 'NewProvider returned $null. A provider instance is required for contract tests.' + } } It 'Exposes required methods' { @@ -44,30 +37,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.IdentityKey | Should -Be $id - $identity.Attributes | Should -BeOfType [hashtable] + $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.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 3033333b..0af8116e 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,44 +11,58 @@ 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.' + $cases = @( + @{ + ProviderFactory = $ProviderFactory + AllowEmpty = [bool]$AllowEmpty } - } + ) + + Context 'Capability advertisement' -ForEach $cases { + BeforeAll { + $providerFactory = $_.ProviderFactory + + if ($null -eq $providerFactory) { + throw 'ProviderFactory scriptblock is required for capability contract tests.' + } - Context 'Capability advertisement' { + $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' { $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 $script:AllowEmpty) { + $caps.Count | Should -BeGreaterThan 0 + } } } } 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 {