From b9536016e05edd295d4c0f66e5ecab96ea0f7e27 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:53:42 +0100 Subject: [PATCH 1/6] docs: design for the step contract (#324) Typed WithSchema, provider declaration in the catalog, provider handle, handler result contract and error codes. Includes implementation notes. Co-Authored-By: Claude Opus 5.5 --- docs/develop/design/step-contract.md | 290 +++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 docs/develop/design/step-contract.md diff --git a/docs/develop/design/step-contract.md b/docs/develop/design/step-contract.md new file mode 100644 index 00000000..d70471f5 --- /dev/null +++ b/docs/develop/design/step-contract.md @@ -0,0 +1,290 @@ +--- +title: "Design: Step contract (engine-owned validation and provider resolution)" +sidebar_label: "Design: Step contract" +--- + +# Design: Step contract + +| | | +| --- | --- | +| **Status** | Implemented (see [Implementation notes](#implementation-notes)) | +| **Issue** | [#324](https://github.com/blindzero/IdentityLifecycleEngine/issues/324) | +| **Target** | v1.0.0 | +| **Affects** | Step handlers, step metadata catalogs, `Providers.StepMetadata` / `Providers.StepRegistry` (host-defined steps) | +| **Unchanged** | Host API (`New-IdlePlan`, `Invoke-IdlePlan`, `Providers`), provider contract, workflow `With` values | + +## Problem + +Every step handler repeats work that the engine already knows how to do: + +- `With` is a hashtable, required keys exist (already validated at plan time via `WithSchema`). +- `Context.Providers` exists, the provider alias is resolved (`With.Provider` or a hard-coded default) and supplied. +- Auth sessions are acquired (`Invoke-IdleProviderMethod`). +- `IdLE.StepResult` is constructed by hand. + +In `Invoke-IdleStepDisableIdentity` this is ~35 of ~50 lines. `Provider`, `AuthSessionName` and +`AuthSessionOptions` are repeated in all 16 catalog entries. `WithSchema` lists only key names, so type errors +(e.g. `State = 'present '`, `Entitlement = 'CN=...'`) surface at execution time inside handlers or providers. + +## Principle + +The engine owns validation, provider resolution, authentication and the result shape. +A step handler contains only its domain logic. + +## 1. Catalog entry + +```powershell +'IdLE.Step.EnsureEntitlement' = @{ + Handler = 'Invoke-IdleStepEnsureEntitlement' + RequiredCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') + + # Optional. Present = the step uses exactly one provider; the engine resolves it. + Provider = @{ DefaultAlias = 'Identity' } + + WithSchema = @{ + Keys = @{ + IdentityKey = @{ Type = 'String'; Required = $true } + Entitlement = @{ + Type = 'Hashtable'; Required = $true + Keys = @{ + Kind = @{ Type = 'String'; Required = $true } + Id = @{ Type = 'String'; Required = $true } + DisplayName = @{ Type = 'String' } + } + } + State = @{ Type = 'String'; AllowedValues = @('Present', 'Absent'); Default = 'Present' } + } + } +} +``` + +### Provider declaration + +- `Provider = @{ DefaultAlias = '' }` declares that the step uses one provider. + The engine then adds the **common keys** to the step's schema; they must not be declared in `Keys`: + + | Key | Type | Meaning | + | --- | --- | --- | + | `Provider` | String | Provider alias; defaults to `DefaultAlias`. | + | `AuthSessionName` | String | Auth session routing key passed to the broker. | + | `AuthSessionOptions` | Hashtable | Data-only broker options (ScriptBlocks rejected). | + +- `AuthSessionNameFromAlias = $true` (optional) names the auth session after the resolved alias when + `With.AuthSessionName` is not set (mailbox steps). +- No `Provider` entry: the step uses no provider (e.g. `IdLE.Step.EmitEvent`); the common keys are not accepted. +- All current steps use at most one provider alias. Multiple providers per step are out of scope; if needed later, + `Provider` can become a map of named roles without breaking the single-provider form. + +### WithSchema + +`WithSchema.Keys` maps each key to a key schema. `RequiredKeys` / `OptionalKeys` are removed. + +| Property | Applies to | Meaning | +| --- | --- | --- | +| `Type` | all (required) | `String`, `Int`, `Bool`, `Hashtable`, `Array`, `Any` | +| `Required` | all | `$true` = must be present and non-null. Default `$false`. | +| `Default` | all | Value used when the key is absent. Must match `Type`. Not allowed with `Required`. | +| `AllowedValues` | `String`, `Int` | Value must be one of the list (strings compared case-insensitively, result normalized to the listed casing). | +| `AllowEmpty` | `String`, `Array` | `$true` = empty string / empty array allowed. Default `$false`. | +| `Keys` | `Hashtable` | Nested key schemas (same format, recursive). Unknown nested keys fail. | +| `AllowAdditionalKeys` | `Hashtable` (and `WithSchema` root) | `$true` = keys not listed in `Keys` are accepted unchecked. | +| `Items` | `Array` | Key schema applied to every element (e.g. `@{ Type = 'Hashtable'; Keys = @{ ... } }`). | + +Rules: + +- A `Hashtable` without `Keys` accepts any content (e.g. `EnsureAttributes.Attributes`). +- No implicit type coercion: `'600'` is not an `Int`, `'true'` is not a `Bool`. PSD1 literals and pure template + placeholders (`'{{Request.Intent.TimeoutSeconds}}'`) already keep their type, so plan-time checking is exact. +- `Int` accepts `[int]` and `[long]`; `Array` accepts any non-string `IEnumerable` except dictionaries. +- Optional keys set to `$null` are treated as absent (defaults apply). `$null` values inside a `Hashtable` without + `Keys` (e.g. "clear attribute" values in `Attributes`) are passed through. +- The schema itself is data-only and validated when the catalog is loaded (`InvalidWithSchema`). + +### Validation time + +All `With` checks run at **plan time** after template resolution, with the full path in the error +(`With.Entitlement.Kind`). Defaults are applied at plan time, so the plan (and plan export) shows the effective `With`. + +## 2. Handler signature + +```powershell +function Invoke-IdleStepDisableIdentity { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [hashtable] $With, + [Parameter(Mandatory)] [object] $Provider + ) + + $result = $Provider.Invoke('DisableIdentity', @($With.IdentityKey)) + return @{ Changed = [bool]$result.Changed } +} +``` + +| Parameter | Passed | Content | +| --- | --- | --- | +| `With` | always (required) | Validated `With` **without** the common keys, defaults applied. | +| `Provider` | only if the catalog declares `Provider` | Provider handle (see below). | +| `Context` | if declared | Execution context (`EventSink`, `Request`, `Plan`, `CorrelationId`, `Actor`). | +| `Step` | if declared | Read-only step metadata (`Name`, `Type`). | + +- The engine checks the handler's parameters before the first attempt: + `With` is mandatory; `Provider` must be declared if and only if the catalog declares `Provider`. +- Handlers written against the old contract (no `With` parameter) fail with `LegacyStepHandler`. + +### Provider handle + +The engine resolves the alias (`With.Provider` or `DefaultAlias`), looks it up in `Providers` and passes a handle +(`PSTypeName = 'IdLE.ProviderHandle'`): + +| Member | Meaning | +| --- | --- | +| `Alias` | Resolved provider alias. | +| `Invoke(MethodName, Arguments)` | Calls the provider method. `Arguments` exclude `AuthSession`; the session is inserted at the position the method declares it (e.g. `ListEntitlements(IdentityKey, AuthSession, Kind)`). | +| `HasMethod(MethodName)` | `$true` if the provider implements the method (optional capabilities such as bulk operations). | +| `HasParameter(MethodName, ParameterName)` | `$true` if the method declares the parameter (e.g. `Kind` on `ListEntitlements`). | + +- Auth session behavior is unchanged from today's `Invoke-IdleProviderMethod`: acquired on the first `Invoke` that + needs it (method declares `AuthSession`, or `AuthSessionName` is set), then cached for the step attempt. +- The handle does not expose the raw provider object. Steps cannot bypass the auth logic. +- Providers that expose an `EventSink` property receive the run's event sink (previously done by the mailbox + permissions step itself). +- The same resolved alias feeds `Request.Context.Current` for preconditions (today `Current` ignores the step's + default alias when `With.Provider` is not set). + +## 3. Handler result + +A handler returns `$null` or a hashtable: + +| Key | Type | Meaning | +| --- | --- | --- | +| `Changed` | Bool | The step changed external state. Default `$false`. | +| `Data` | Hashtable | Structured step output (e.g. per-attribute results, skipped entitlements). | +| `State` | Hashtable | Values for downstream steps (reserved; see Open questions). | + +- The engine builds `IdLE.StepResult` (`Name`, `Type`, `Status`, `Changed`, `Data`, `Error`, `Attempts`, `Source`). +- **Failure is always an exception.** Returning a status is not supported. Transient failures keep using the + existing transient marker for retries. +- Unknown result keys or a non-hashtable result fail the step (`InvalidStepHandlerResult`). +- Step-specific result properties move under `Data`: `PruneEntitlements.Skipped` -> `Data.Skipped`, + `EnsureAttributes.Attributes` -> `Data.Attributes` (already partly there). + +## 4. Error codes + +| Code | When | Raised at | +| --- | --- | --- | +| `LegacyWithSchema` | Catalog uses `RequiredKeys` / `OptionalKeys`. | Catalog load | +| `InvalidWithSchema` | Unknown `Type`, `Default` not matching `Type`, `Keys` on a non-hashtable, common key declared in `Keys`, ... | Catalog load | +| `LegacyStepHandler` | Handler has no `With` parameter. | Before the first attempt | +| `InvalidStepHandlerSignature` | `Provider` declared without catalog `Provider` (or vice versa). | Before the first attempt | +| `WithKeyMissing` | Required key absent or null. | Plan | +| `WithKeyUnknown` | Key not in schema (and no `AllowAdditionalKeys`). | Plan | +| `WithKeyTypeMismatch` | Value does not match `Type`. | Plan | +| `WithValueNotAllowed` | Value not in `AllowedValues`, or empty without `AllowEmpty`. | Plan | +| `ProviderNotSupplied` | Resolved alias not in `Providers`. | Execution (hosts may pass providers only to `Invoke-IdlePlan`) | +| `ProviderMethodNotImplemented` | `Invoke` of a method the provider does not implement. | Execution | +| `InvalidStepHandlerResult` | Result is not `$null` / hashtable, or contains unknown keys. | Execution | + +Messages start with the code, followed by step name, step type and the path (e.g. `With.Entitlement.Kind`), +matching the existing `ReservedNamespace` style. + +## 5. Migration example + +### Catalog + +```powershell +# Before +'IdLE.Step.DisableIdentity' = @{ + Handler = 'Invoke-IdleStepDisableIdentity' + RequiredCapabilities = @('IdLE.Identity.Disable') + WithSchema = @{ + RequiredKeys = @('IdentityKey') + OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + } +} + +# After +'IdLE.Step.DisableIdentity' = @{ + Handler = 'Invoke-IdleStepDisableIdentity' + RequiredCapabilities = @('IdLE.Identity.Disable') + Provider = @{ DefaultAlias = 'Identity' } + WithSchema = @{ + Keys = @{ IdentityKey = @{ Type = 'String'; Required = $true } } + } +} +``` + +### Handler + +```powershell +# Before (~50 lines): checks With, Context.Providers, alias, calls Invoke-IdleProviderMethod, builds IdLE.StepResult +function Invoke-IdleStepDisableIdentity { + param([object] $Context, [object] $Step) + + $with = $Step.With + if ($null -eq $with -or -not ($with -is [hashtable])) { throw "DisableIdentity requires 'With' to be a hashtable." } + if (-not $with.ContainsKey('IdentityKey')) { throw "DisableIdentity requires With.IdentityKey." } + $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'Identity' } + # ... Context.Providers checks ... + $result = Invoke-IdleProviderMethod -Context $Context -With $with -ProviderAlias $providerAlias ` + -MethodName 'DisableIdentity' -MethodArguments @([string]$with.IdentityKey) + # ... Changed extraction, [pscustomobject]@{ PSTypeName = 'IdLE.StepResult'; ... } ... +} + +# After +function Invoke-IdleStepDisableIdentity { + param( + [Parameter(Mandatory)] [object] $Context, + [Parameter(Mandatory)] [hashtable] $With, + [Parameter(Mandatory)] [object] $Provider + ) + + $result = $Provider.Invoke('DisableIdentity', @($With.IdentityKey)) + return @{ Changed = [bool]$result.Changed } +} +``` + +### Host-defined steps + +Host steps (`Providers.StepMetadata` + `Providers.StepRegistry`) use the same catalog entry format and handler +signature. Workflows are unchanged unless they contain values that were silently accepted before but violate the +new types (these now fail at plan time). + +## 6. Breaking changes + +- Catalog format: `RequiredKeys` / `OptionalKeys` replaced by `Keys` (+ `Provider`). No compatibility layer. +- Handler signature: `-With` / `-Provider`, hashtable result, failure only via exception. No compatibility layer. +- `IdLE.StepResult`: step-specific properties move under `Data` (`Skipped`, `Attributes`). +- Workflows with wrongly typed `With` values now fail at plan time instead of execution time. +- `Invoke-IdleProviderMethod` is removed (replaced by the provider handle); `Test-IdleProviderMethodParameter` + becomes private. + +All first-party catalogs and handlers move in one change; the changelog/release notes carry the migration example. + +## 7. Implementation plan + +1. **Core** (one PR, atomic because there is no compatibility layer): schema validator, catalog loader + (`LegacyWithSchema`, `InvalidWithSchema`), handler signature check, provider handle, result normalization, + `Current` via resolved alias; migrate all 16 first-party catalogs and handlers and their tests. +2. **Docs and tooling**: `docs/extend/steps.md` (contract), step reference generator renders the typed `With` table + from the schema (types, required, defaults, allowed values) instead of "see step description". +3. **#289** (entitlement `Kind` per entry) builds on the nested schema (`Entitlements.Items.Keys.Kind`). + +## Implementation notes + +Differences from the proposal and findings during implementation: + +- `-Context` is optional (passed when declared), so handlers without context needs carry no unused parameter. +- `ProviderNotSupplied` is checked at execution only; plans are often built with a subset of providers. +- `Provider.AuthSessionNameFromAlias` keeps the mailbox steps' convention (session named after the alias). +- The engine wires `Context.EventSink` into providers exposing an `EventSink` property. +- Provider exceptions keep a single level of `MethodInvocationException` wrapping, as with direct calls. +- `Context.AcquireAuthSession` now accepts an empty name, which requests the broker's default session. Before, the + default-session path failed for every provider method declaring `AuthSession` when `With.AuthSessionName` was + not set. +- `Copy-IdleDataObject` and template resolution returned single-element arrays as scalars and empty arrays as + `$null` (PowerShell pipeline unrolling); both now keep arrays. +- `IdLE.Step.EmitEvent` no longer accepts `Provider` / `AuthSessionName` / `AuthSessionOptions`. +- `IdLE.Step.PruneEntitlementsEnsureKeep` shares a private implementation with `IdLE.Step.PruneEntitlements` + instead of injecting an undeclared `EnsureKeepEntitlements` key. +- Workflow examples used `@{ ValueFrom = '...' }`, which the engine never supported; they now use templates. +- Open: `State` propagation to later steps (the result key is reserved and returned as-is), tracked in #346. From 6ca29519fce11a1b9c464578838185bbcc148d71 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:53:42 +0100 Subject: [PATCH 2/6] core: move step boilerplate into the engine (#324) - Catalog: Provider = @{ DefaultAlias; AuthSessionNameFromAlias } and a typed WithSchema (Type, Required, Default, AllowedValues, AllowEmpty, nested Keys/Items); RequiredKeys/OptionalKeys fail with LegacyWithSchema - With is validated at plan time with path-aware errors; defaults and the provider alias are written into the plan; common keys (Provider, AuthSessionName, AuthSessionOptions) exist only for steps that declare Provider - Handlers receive -With, -Provider (IdLE.ProviderHandle) and optionally -Context/-Step; pre-1.0 handlers fail with LegacyStepHandler - Provider handle resolves the provider, acquires the auth session once per attempt, inserts it where the method declares it and wires EventSink - Handlers return $null or @{ Changed; Data; State }; the engine builds IdLE.StepResult, failures are exceptions (InvalidStepHandlerResult otherwise) - Invoke-IdleProviderMethod removed; Test-IdleProviderMethodParameter is private - Fix: Context.AcquireAuthSession accepts an empty name (default session) - Fix: Copy-IdleDataObject and template resolution kept single-element and empty arrays intact Co-Authored-By: Claude Opus 5.5 --- src/IdLE.Core/IdLE.Core.psd1 | 2 - src/IdLE.Core/IdLE.Core.psm1 | 2 - .../Assert-IdleStepMetadataContract.ps1 | 180 ++++++++ .../Private/ConvertTo-IdleSchemaValue.ps1 | 181 ++++++++ .../Private/ConvertTo-IdleStepResult.ps1 | 105 +++++ .../Private/ConvertTo-IdleStepWith.ps1 | 61 +++ .../Private/ConvertTo-IdleWorkflowSteps.ps1 | 63 +-- src/IdLE.Core/Private/Copy-IdleDataObject.ps1 | 3 +- .../Private/Get-IdleCommandParameterNames.ps1 | 38 -- .../Private/Get-IdleStepWithSchema.ps1 | 41 ++ .../Private/New-IdleProviderHandle.ps1 | 169 +++++++ .../Private/New-IdleStepHandlerArguments.ps1 | 103 +++++ .../Resolve-IdleStepMetadataCatalog.ps1 | 126 +----- .../Private/Resolve-IdleWorkflowTemplates.ps1 | 3 +- .../Test-IdleProviderMethodParameter.ps1 | 2 +- .../Public/Invoke-IdlePlanObject.ps1 | 163 ++----- .../Public/Invoke-IdleProviderMethod.ps1 | 149 ------- tests/Core/IdentifierNamespaces.Tests.ps1 | 2 +- ...Invoke-IdlePlan.ExecutionOptions.Tests.ps1 | 25 +- .../Invoke-IdlePlan.Preconditions.Tests.ps1 | 37 +- ...Invoke-IdlePlan.ProviderFallback.Tests.ps1 | 11 +- tests/Core/Invoke-IdlePlan.Retry.Tests.ps1 | 14 +- tests/Core/Invoke-IdlePlan.Tests.ps1 | 62 +-- tests/Core/New-IdlePlan.Tests.ps1 | 17 +- tests/Core/New-IdleRequest.Tests.ps1 | 8 +- tests/Core/Provenance.Tests.ps1 | 10 +- .../Resolve-IdleStepMetadataCatalog.Tests.ps1 | 14 +- .../Resolve-IdleWorkflowTemplates.Tests.ps1 | 11 +- tests/Core/StepContract.Tests.ps1 | 419 ++++++++++++++++++ .../StepPackHandlerRegistration.Tests.ps1 | 20 +- tests/Packaging/ModuleSurface.Tests.ps1 | 11 +- tests/Steps/_testHelpers.Steps.ps1 | 118 ++++- .../resolver-current-precondition.psd1 | 5 +- 33 files changed, 1516 insertions(+), 659 deletions(-) create mode 100644 src/IdLE.Core/Private/Assert-IdleStepMetadataContract.ps1 create mode 100644 src/IdLE.Core/Private/ConvertTo-IdleSchemaValue.ps1 create mode 100644 src/IdLE.Core/Private/ConvertTo-IdleStepResult.ps1 create mode 100644 src/IdLE.Core/Private/ConvertTo-IdleStepWith.ps1 delete mode 100644 src/IdLE.Core/Private/Get-IdleCommandParameterNames.ps1 create mode 100644 src/IdLE.Core/Private/Get-IdleStepWithSchema.ps1 create mode 100644 src/IdLE.Core/Private/New-IdleProviderHandle.ps1 create mode 100644 src/IdLE.Core/Private/New-IdleStepHandlerArguments.ps1 rename src/IdLE.Core/{Public => Private}/Test-IdleProviderMethodParameter.ps1 (96%) delete mode 100644 src/IdLE.Core/Public/Invoke-IdleProviderMethod.ps1 create mode 100644 tests/Core/StepContract.Tests.ps1 diff --git a/src/IdLE.Core/IdLE.Core.psd1 b/src/IdLE.Core/IdLE.Core.psd1 index f18aef2c..15a12059 100644 --- a/src/IdLE.Core/IdLE.Core.psd1 +++ b/src/IdLE.Core/IdLE.Core.psd1 @@ -12,11 +12,9 @@ 'Assert-IdleNoScriptBlock', 'Export-IdlePlanObject', 'Invoke-IdlePlanObject', - 'Invoke-IdleProviderMethod', 'New-IdleAuthSessionBroker', 'New-IdleRequestObject', 'New-IdlePlanObject', - 'Test-IdleProviderMethodParameter', 'Test-IdleWorkflowDefinitionObject' ) CmdletsToExport = @() diff --git a/src/IdLE.Core/IdLE.Core.psm1 b/src/IdLE.Core/IdLE.Core.psm1 index 1bd09aac..9e6b8fd7 100644 --- a/src/IdLE.Core/IdLE.Core.psm1 +++ b/src/IdLE.Core/IdLE.Core.psm1 @@ -43,11 +43,9 @@ Export-ModuleMember -Function @( 'Assert-IdleNoScriptBlock', 'Export-IdlePlanObject', 'Invoke-IdlePlanObject', - 'Invoke-IdleProviderMethod', 'New-IdleAuthSessionBroker', 'New-IdleRequestObject', 'New-IdlePlanObject', - 'Test-IdleProviderMethodParameter', 'Test-IdleWorkflowDefinitionObject' ) -Alias @() diff --git a/src/IdLE.Core/Private/Assert-IdleStepMetadataContract.ps1 b/src/IdLE.Core/Private/Assert-IdleStepMetadataContract.ps1 new file mode 100644 index 00000000..b445adcd --- /dev/null +++ b/src/IdLE.Core/Private/Assert-IdleStepMetadataContract.ps1 @@ -0,0 +1,180 @@ +function Assert-IdleStepMetadataContract { + <# + .SYNOPSIS + Validates the WithSchema and Provider entries of a step metadata entry. + + .DESCRIPTION + Throws ArgumentException with a message starting with LegacyWithSchema (RequiredKeys/OptionalKeys format) + or InvalidWithSchema (any other shape error). See docs/extend/steps.md for the format. + + .PARAMETER Metadata + Step metadata entry. + + .PARAMETER StepType + Step type the entry belongs to. + + .PARAMETER SourceName + Source of the entry (step pack module name or 'Providers.StepMetadata'). + + .OUTPUTS + None. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Metadata, + + [Parameter(Mandatory)] + [string] $StepType, + + [Parameter(Mandatory)] + [string] $SourceName + ) + + $prefix = "$SourceName entry for step type '$StepType'" + $commonKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + $types = @('String', 'Int', 'Bool', 'Hashtable', 'Array', 'Any') + $keySchemaProperties = @('Type', 'Required', 'Default', 'AllowedValues', 'AllowEmpty', 'Keys', 'AllowAdditionalKeys', 'Items') + $example = "WithSchema = @{ Keys = @{ IdentityKey = @{ Type = 'String'; Required = `$true } } }" + + function New-IdleContractError { + param([string] $Text) + return [System.ArgumentException]::new("InvalidWithSchema: $prefix $Text", 'Providers') + } + + function Assert-IdleKeySchema { + param([object] $KeySchema, [string] $Path) + + if ($KeySchema -isnot [hashtable]) { + throw (New-IdleContractError "$Path must be a hashtable, e.g. @{ Type = 'String'; Required = `$true }.") + } + + foreach ($property in $KeySchema.PSBase.Keys) { + if ($keySchemaProperties -notcontains [string]$property) { + throw (New-IdleContractError "$Path has unknown property '$property'. Supported: $([string]::Join(', ', $keySchemaProperties)).") + } + } + + $type = $KeySchema['Type'] + if ($types -notcontains [string]$type) { + throw (New-IdleContractError "$Path.Type must be one of: $([string]::Join(', ', $types)) (got '$type').") + } + + foreach ($flag in @('Required', 'AllowEmpty', 'AllowAdditionalKeys')) { + if ($KeySchema.ContainsKey($flag) -and $KeySchema[$flag] -isnot [bool]) { + throw (New-IdleContractError "$Path.$flag must be a Bool.") + } + } + + $applicability = @{ + AllowedValues = @('String', 'Int') + AllowEmpty = @('String', 'Array') + Keys = @('Hashtable') + AllowAdditionalKeys = @('Hashtable') + Items = @('Array') + } + foreach ($property in $applicability.PSBase.Keys) { + if ($KeySchema.ContainsKey($property) -and $applicability[$property] -notcontains $type) { + throw (New-IdleContractError "$Path.$property is not supported for Type '$type'.") + } + } + + if ($KeySchema.ContainsKey('AllowedValues')) { + $allowed = @($KeySchema['AllowedValues']) + if ($allowed.Count -eq 0) { + throw (New-IdleContractError "$Path.AllowedValues must not be empty.") + } + foreach ($item in $allowed) { + $itemOk = if ($type -eq 'String') { $item -is [string] } else { $item -is [int] -or $item -is [long] } + if (-not $itemOk) { + throw (New-IdleContractError "$Path.AllowedValues must only contain $type values.") + } + } + } + + if ($KeySchema.ContainsKey('Keys')) { + Assert-IdleKeysSchema -Keys $KeySchema['Keys'] -Path "$Path.Keys" + } + + if ($KeySchema.ContainsKey('Items')) { + Assert-IdleKeySchema -KeySchema $KeySchema['Items'] -Path "$Path.Items" + } + + if ($KeySchema.ContainsKey('Default')) { + if ($KeySchema['Required'] -eq $true) { + throw (New-IdleContractError "$Path must not declare both Required and Default.") + } + try { + $null = ConvertTo-IdleSchemaValue -Schema $KeySchema -Value $KeySchema['Default'] -Path "$Path.Default" -ErrorPrefix $prefix + } + catch { + throw (New-IdleContractError "$Path.Default does not match its schema: $($_.Exception.Message)") + } + } + } + + function Assert-IdleKeysSchema { + param([object] $Keys, [string] $Path) + + if ($Keys -isnot [hashtable]) { + throw (New-IdleContractError "$Path must be a hashtable that maps key names to key schemas.") + } + foreach ($key in $Keys.PSBase.Keys) { + if ([string]::IsNullOrWhiteSpace([string]$key)) { + throw (New-IdleContractError "$Path contains an empty key name.") + } + Assert-IdleKeySchema -KeySchema $Keys[$key] -Path "$Path.$key" + } + } + + if (-not $Metadata.ContainsKey('WithSchema') -or $null -eq $Metadata['WithSchema']) { + throw (New-IdleContractError "is missing 'WithSchema'. Every step type must declare its With contract, e.g. $example.") + } + + $withSchema = $Metadata['WithSchema'] + if ($withSchema -isnot [hashtable]) { + throw (New-IdleContractError "has an invalid 'WithSchema'. Expected a hashtable, e.g. $example.") + } + + if ($withSchema.ContainsKey('RequiredKeys') -or $withSchema.ContainsKey('OptionalKeys')) { + throw [System.ArgumentException]::new( + "LegacyWithSchema: $prefix uses WithSchema.RequiredKeys/OptionalKeys. Declare typed keys instead, e.g. $example. Provider, AuthSessionName and AuthSessionOptions are added by the engine when the entry declares Provider = @{ DefaultAlias = '' }.", + 'Providers' + ) + } + + foreach ($property in $withSchema.PSBase.Keys) { + if (@('Keys', 'AllowAdditionalKeys') -notcontains [string]$property) { + throw (New-IdleContractError "WithSchema has unknown property '$property'. Supported: Keys, AllowAdditionalKeys.") + } + } + + if (-not $withSchema.ContainsKey('Keys')) { + throw (New-IdleContractError "WithSchema is missing 'Keys' (may be empty: @{}).") + } + if ($withSchema.ContainsKey('AllowAdditionalKeys') -and $withSchema['AllowAdditionalKeys'] -isnot [bool]) { + throw (New-IdleContractError 'WithSchema.AllowAdditionalKeys must be a Bool.') + } + + Assert-IdleKeysSchema -Keys $withSchema['Keys'] -Path 'WithSchema.Keys' + + foreach ($key in $withSchema['Keys'].PSBase.Keys) { + if ($commonKeys -contains [string]$key) { + throw (New-IdleContractError "WithSchema.Keys must not declare '$key'. Declare Provider = @{ DefaultAlias = '' } instead; the engine adds Provider, AuthSessionName and AuthSessionOptions.") + } + } + + if ($Metadata.ContainsKey('Provider')) { + $provider = $Metadata['Provider'] + $providerOk = $false + if ($provider -is [hashtable] -and $provider['DefaultAlias'] -is [string] -and -not [string]::IsNullOrWhiteSpace($provider['DefaultAlias'])) { + $unknownProperties = @($provider.PSBase.Keys | Where-Object { @('DefaultAlias', 'AuthSessionNameFromAlias') -notcontains [string]$_ }) + $flagOk = -not $provider.ContainsKey('AuthSessionNameFromAlias') -or $provider['AuthSessionNameFromAlias'] -is [bool] + $providerOk = $unknownProperties.Count -eq 0 -and $flagOk + } + if (-not $providerOk) { + throw (New-IdleContractError "has an invalid 'Provider'. Expected @{ DefaultAlias = ''; AuthSessionNameFromAlias = }, e.g. @{ DefaultAlias = 'Identity' }.") + } + } +} diff --git a/src/IdLE.Core/Private/ConvertTo-IdleSchemaValue.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleSchemaValue.ps1 new file mode 100644 index 00000000..8038a940 --- /dev/null +++ b/src/IdLE.Core/Private/ConvertTo-IdleSchemaValue.ps1 @@ -0,0 +1,181 @@ +function ConvertTo-IdleSchemaValue { + <# + .SYNOPSIS + Validates a value against a key schema and returns the normalized value. + + .DESCRIPTION + Key schema properties: Type (String, Int, Bool, Hashtable, Array, Any), Required, Default, + AllowedValues, AllowEmpty, Keys, AllowAdditionalKeys, Items (see docs/extend/steps.md). + + Normalization: + - Hashtable keys declared in Keys use the declared casing; Defaults are applied for absent keys. + - String values matching AllowedValues (case-insensitive) use the declared casing. + - Optional keys with a $null value are treated as absent. + + No implicit type coercion is performed. Failures throw an ArgumentException whose message starts with + WithKeyMissing, WithKeyUnknown, WithKeyTypeMismatch or WithValueNotAllowed. + + .PARAMETER Schema + Key schema hashtable. + + .PARAMETER Value + Value to validate. + + .PARAMETER Path + Path used in error messages (e.g. 'With.Entitlement.Kind'). + + .PARAMETER ErrorPrefix + Context prefix used in error messages (e.g. "Step 'Disable' (type 'IdLE.Step.DisableIdentity')"). + + .PARAMETER ExcludedKeys + Hashtable keys that are never accepted as additional keys (common keys of steps without a Provider). + + .OUTPUTS + The normalized value. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Schema, + + [Parameter()] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [string] $Path, + + [Parameter(Mandatory)] + [string] $ErrorPrefix, + + [Parameter()] + [string[]] $ExcludedKeys = @() + ) + + function New-IdleSchemaError { + param([string] $Code, [string] $Text) + return [System.ArgumentException]::new("${Code}: $ErrorPrefix $Text", 'Workflow') + } + + $type = [string]$Schema['Type'] + $actualType = if ($null -eq $Value) { 'null' } else { $Value.GetType().Name } + + switch ($type) { + 'Any' { + return $Value + } + + 'String' { + if ($Value -isnot [string]) { + throw (New-IdleSchemaError 'WithKeyTypeMismatch' "$Path must be a String (got $actualType).") + } + if ($Value -eq '' -and -not ($Schema['AllowEmpty'] -eq $true)) { + throw (New-IdleSchemaError 'WithValueNotAllowed' "$Path must not be empty.") + } + if ($Schema.ContainsKey('AllowedValues')) { + foreach ($allowed in @($Schema['AllowedValues'])) { + if ([string]::Equals([string]$allowed, $Value, [System.StringComparison]::OrdinalIgnoreCase)) { + return [string]$allowed + } + } + $allowedList = [string]::Join(', ', @($Schema['AllowedValues'])) + throw (New-IdleSchemaError 'WithValueNotAllowed' "$Path must be one of: $allowedList (got '$Value').") + } + return $Value + } + + 'Int' { + if (($Value -isnot [int] -and $Value -isnot [long])) { + throw (New-IdleSchemaError 'WithKeyTypeMismatch' "$Path must be an Int (got $actualType).") + } + if ($Schema.ContainsKey('AllowedValues') -and @($Schema['AllowedValues']) -notcontains $Value) { + $allowedList = [string]::Join(', ', @($Schema['AllowedValues'])) + throw (New-IdleSchemaError 'WithValueNotAllowed' "$Path must be one of: $allowedList (got $Value).") + } + return $Value + } + + 'Bool' { + if ($Value -isnot [bool]) { + throw (New-IdleSchemaError 'WithKeyTypeMismatch' "$Path must be a Bool (got $actualType).") + } + return $Value + } + + 'Array' { + if ($null -eq $Value -or $Value -is [string] -or $Value -is [System.Collections.IDictionary] -or $Value -isnot [System.Collections.IEnumerable]) { + throw (New-IdleSchemaError 'WithKeyTypeMismatch' "$Path must be an Array (got $actualType).") + } + $items = @($Value) + if ($items.Count -eq 0 -and -not ($Schema['AllowEmpty'] -eq $true)) { + throw (New-IdleSchemaError 'WithValueNotAllowed' "$Path must not be empty.") + } + if (-not $Schema.ContainsKey('Items')) { + return , $items + } + $result = [System.Collections.Generic.List[object]]::new() + for ($index = 0; $index -lt $items.Count; $index++) { + $itemPath = "$Path[$index]" + if ($null -eq $items[$index]) { + throw (New-IdleSchemaError 'WithKeyTypeMismatch' "$itemPath must not be null.") + } + $result.Add((ConvertTo-IdleSchemaValue -Schema $Schema['Items'] -Value $items[$index] -Path $itemPath -ErrorPrefix $ErrorPrefix)) + } + return , $result.ToArray() + } + + 'Hashtable' { + if ($Value -isnot [System.Collections.IDictionary]) { + throw (New-IdleSchemaError 'WithKeyTypeMismatch' "$Path must be a Hashtable (got $actualType).") + } + if (-not $Schema.ContainsKey('Keys')) { + # No declared keys: free-form content (e.g. attribute maps). + return $Value + } + + $declared = $Schema['Keys'] + $allowAdditional = $Schema['AllowAdditionalKeys'] -eq $true + $result = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + + foreach ($inputKey in @($Value.PSBase.Keys)) { + $keyName = [string]$inputKey + $declaredKey = @($declared.PSBase.Keys | Where-Object { [string]::Equals([string]$_, $keyName, [System.StringComparison]::OrdinalIgnoreCase) }) | Select-Object -First 1 + if ($null -eq $declaredKey) { + if (-not $allowAdditional -or $ExcludedKeys -contains $keyName) { + $supported = [string]::Join(', ', @($declared.PSBase.Keys | Sort-Object)) + throw (New-IdleSchemaError 'WithKeyUnknown' "$Path.$keyName is not supported. Supported keys: $supported.") + } + $result[$keyName] = $Value[$inputKey] + continue + } + + $inputValue = $Value[$inputKey] + if ($null -eq $inputValue) { + # Optional keys set to $null are treated as absent (Required keys are checked below). + continue + } + + $result[[string]$declaredKey] = ConvertTo-IdleSchemaValue -Schema $declared[$declaredKey] -Value $inputValue -Path "$Path.$declaredKey" -ErrorPrefix $ErrorPrefix + } + + foreach ($declaredKey in @($declared.PSBase.Keys | Sort-Object)) { + if ($result.ContainsKey([string]$declaredKey)) { continue } + + $keySchema = $declared[$declaredKey] + if ($keySchema['Required'] -eq $true) { + throw (New-IdleSchemaError 'WithKeyMissing' "$Path.$declaredKey is required.") + } + if ($keySchema.ContainsKey('Default')) { + $result[[string]$declaredKey] = Copy-IdleDataObject -Value $keySchema['Default'] + } + } + + return $result + } + + default { + throw [System.InvalidOperationException]::new("Unsupported schema type '$type' at $Path.") + } + } +} diff --git a/src/IdLE.Core/Private/ConvertTo-IdleStepResult.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleStepResult.ps1 new file mode 100644 index 00000000..17df60eb --- /dev/null +++ b/src/IdLE.Core/Private/ConvertTo-IdleStepResult.ps1 @@ -0,0 +1,105 @@ +function ConvertTo-IdleStepResult { + <# + .SYNOPSIS + Builds an IdLE.StepResult from a step handler's output. + + .DESCRIPTION + Handlers return $null or a hashtable with the optional keys Changed (Bool), Data (Hashtable) and + State (Hashtable). Failures are reported by throwing, never by the return value. + Any other output fails with InvalidStepHandlerResult. + + .PARAMETER Output + Handler output (as captured from the pipeline). + + .PARAMETER StepName + Step name. + + .PARAMETER StepType + Step type. + + .PARAMETER Attempts + Number of attempts (retries included). + + .PARAMETER Source + Handler provenance (ConvertTo-IdleSourceInfo). + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Output, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $StepName, + + [Parameter(Mandatory)] + [string] $StepType, + + [Parameter(Mandatory)] + [int] $Attempts, + + [Parameter()] + [AllowNull()] + [object] $Source + ) + + $prefix = "InvalidStepHandlerResult: Step '$StepName' (type '$StepType')" + + # A handler that leaks pipeline output (e.g. an unassigned method call) returns several objects. + if ($Output -is [array]) { + throw [System.InvalidOperationException]::new( + "$prefix wrote $($Output.Count) objects to the output stream. Return a single hashtable (@{ Changed; Data; State }) or nothing; assign or discard other output with `$null = ..." + ) + } + + $changed = $false + $data = $null + $state = $null + + if ($null -ne $Output) { + if ($Output -isnot [System.Collections.IDictionary]) { + throw [System.InvalidOperationException]::new( + "$prefix returned a [$($Output.GetType().Name)]. Return a hashtable (@{ Changed; Data; State }) or nothing; report failures by throwing." + ) + } + + foreach ($key in $Output.PSBase.Keys) { + if (@('Changed', 'Data', 'State') -notcontains [string]$key) { + throw [System.InvalidOperationException]::new("$prefix returned unknown key '$key'. Supported keys: Changed, Data, State.") + } + } + + if ($Output.Contains('Changed') -and $null -ne $Output['Changed']) { + if ($Output['Changed'] -isnot [bool]) { + throw [System.InvalidOperationException]::new("$prefix returned Changed of type [$($Output['Changed'].GetType().Name)]; expected a Bool.") + } + $changed = $Output['Changed'] + } + + foreach ($key in @('Data', 'State')) { + if ($Output.Contains($key) -and $null -ne $Output[$key] -and $Output[$key] -isnot [System.Collections.IDictionary]) { + throw [System.InvalidOperationException]::new("$prefix returned $key of type [$($Output[$key].GetType().Name)]; expected a Hashtable.") + } + } + + if ($Output.Contains('Data')) { $data = $Output['Data'] } + if ($Output.Contains('State')) { $state = $Output['State'] } + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $StepName + Type = $StepType + Status = 'Completed' + Changed = $changed + Data = $data + State = $state + Error = $null + Attempts = $Attempts + Source = $Source + } +} diff --git a/src/IdLE.Core/Private/ConvertTo-IdleStepWith.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleStepWith.ps1 new file mode 100644 index 00000000..ac4ff27e --- /dev/null +++ b/src/IdLE.Core/Private/ConvertTo-IdleStepWith.ps1 @@ -0,0 +1,61 @@ +function ConvertTo-IdleStepWith { + <# + .SYNOPSIS + Validates a step's With against its metadata and returns the effective With. + + .DESCRIPTION + Plan-time step input contract (see docs/extend/steps.md): + - With is validated against WithSchema (types, required keys, allowed values, nested keys). + - Defaults are applied, including Provider = Provider.DefaultAlias for steps that declare a Provider. + - Common keys (Provider, AuthSessionName, AuthSessionOptions) are only accepted for steps that declare a Provider. + - Provider.AuthSessionNameFromAlias: AuthSessionName defaults to the resolved provider alias. + + .PARAMETER Metadata + Step metadata entry. + + .PARAMETER With + With hashtable from the workflow (templates already resolved). + + .PARAMETER StepName + Step name (error messages). + + .PARAMETER StepType + Step type (error messages). + + .OUTPUTS + System.Collections.Hashtable + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Metadata, + + [Parameter()] + [AllowNull()] + [object] $With, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $StepName, + + [Parameter(Mandatory)] + [string] $StepType + ) + + if ($null -eq $With) { $With = @{} } + + $effective = ConvertTo-IdleSchemaValue ` + -Schema (Get-IdleStepWithSchema -Metadata $Metadata) ` + -Value $With ` + -Path 'With' ` + -ErrorPrefix ("Step '{0}' (type '{1}'):" -f $StepName, $StepType) ` + -ExcludedKeys @('Provider', 'AuthSessionName', 'AuthSessionOptions') + + # Steps whose auth sessions are named after the provider alias (e.g. 'ExchangeOnline'). + if ($Metadata.ContainsKey('Provider') -and $Metadata['Provider']['AuthSessionNameFromAlias'] -eq $true -and -not $effective.ContainsKey('AuthSessionName')) { + $effective['AuthSessionName'] = [string]$effective['Provider'] + } + + return $effective +} diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index f0c8b05e..7d0342c7 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -135,66 +135,9 @@ function ConvertTo-IdleWorkflowSteps { # Resolve template placeholders in With (planning-time resolution) $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName - # Validate WithSchema declared by step metadata (fail-fast plan-time schema check). - # Every step type must declare WithSchema. Required keys must be present; unknown keys are rejected. - # If OptionalKeys contains '*', any additional key is accepted (permissive schema for test/internal use). - if ($StepMetadataCatalog.ContainsKey($stepType)) { - $md = $StepMetadataCatalog[$stepType] - if ($null -ne $md -and $md -is [hashtable] -and $md.ContainsKey('WithSchema')) { - $schema = $md['WithSchema'] - if ($null -ne $schema -and $schema -is [hashtable]) { - $requiredKeys = @() - if ($schema.ContainsKey('RequiredKeys') -and $null -ne $schema['RequiredKeys']) { - $requiredKeys = @($schema['RequiredKeys']) - } - $optionalKeys = @() - if ($schema.ContainsKey('OptionalKeys') -and $null -ne $schema['OptionalKeys']) { - $optionalKeys = @($schema['OptionalKeys']) - } - - # Build allowed set from all keys (required and optional combined) - $allAllowedKeysList = [System.Collections.Generic.List[string]]::new() - foreach ($keyList in @($requiredKeys, $optionalKeys)) { - foreach ($k in $keyList) { - if ($null -ne $k -and -not [string]::IsNullOrWhiteSpace([string]$k)) { - $null = $allAllowedKeysList.Add([string]$k) - } - } - } - $allowedSet = [System.Collections.Generic.HashSet[string]]::new( - $allAllowedKeysList, - [System.StringComparer]::OrdinalIgnoreCase - ) - $permissive = $allowedSet.Contains('*') - - # Validate required keys are present - foreach ($rk in $requiredKeys) { - if ([string]::IsNullOrWhiteSpace([string]$rk) -or [string]$rk -eq '*') { continue } - - if (-not $with.ContainsKey($rk)) { - $requiredList = [string]::Join(', ', ($requiredKeys | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace([string]$_) } | Sort-Object)) - throw [System.ArgumentException]::new( - ("Step '{0}' (type '{1}') is missing required With.{2}. Required With keys: {3}." -f $stepName, $stepType, $rk, $requiredList), - 'Workflow' - ) - } - } - - # Validate no unknown keys (skip if permissive wildcard) - if (-not $permissive) { - foreach ($wk in @($with.Keys)) { - if (-not $allowedSet.Contains([string]$wk)) { - $supportedList = [string]::Join(', ', ($allAllowedKeysList | Sort-Object)) - throw [System.ArgumentException]::new( - ("Step '{0}' (type '{1}') does not support With.{2}. Supported With keys: {3}." -f $stepName, $stepType, [string]$wk, $supportedList), - 'Workflow' - ) - } - } - } - } - } - } + # Validate With against the typed WithSchema (fail-fast plan-time check) and apply defaults, + # so the plan carries the effective With (including the resolved Provider alias). + $with = ConvertTo-IdleStepWith -Metadata $StepMetadataCatalog[$stepType] -With $with -StepName $stepName -StepType $stepType } $retryProfile = if (Test-IdleWorkflowStepKey -Step $s -Key 'RetryProfile') { diff --git a/src/IdLE.Core/Private/Copy-IdleDataObject.ps1 b/src/IdLE.Core/Private/Copy-IdleDataObject.ps1 index 804fa1b3..e36f4300 100644 --- a/src/IdLE.Core/Private/Copy-IdleDataObject.ps1 +++ b/src/IdLE.Core/Private/Copy-IdleDataObject.ps1 @@ -44,7 +44,8 @@ function Copy-IdleDataObject { foreach ($item in $Value) { $arr += Copy-IdleDataObject -Value $item } - return $arr + # Comma operator: keep single-element and empty arrays as arrays (no pipeline unrolling). + return , $arr } $props = @($Value.PSObject.Properties | Where-Object MemberType -in @('NoteProperty', 'Property')) diff --git a/src/IdLE.Core/Private/Get-IdleCommandParameterNames.ps1 b/src/IdLE.Core/Private/Get-IdleCommandParameterNames.ps1 deleted file mode 100644 index d6054bfb..00000000 --- a/src/IdLE.Core/Private/Get-IdleCommandParameterNames.ps1 +++ /dev/null @@ -1,38 +0,0 @@ -Set-StrictMode -Version Latest - -function Get-IdleCommandParameterNames { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Handler - ) - - # Returns a HashSet[string] of parameter names supported by the handler. - $set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - - if ($Handler -is [scriptblock]) { - - $paramBlock = $Handler.Ast.ParamBlock - if ($null -eq $paramBlock) { - return $set - } - - foreach ($p in $paramBlock.Parameters) { - # Parameter name is stored without the leading '$' - $null = $set.Add([string]$p.Name.VariablePath.UserPath) - } - - return $set - } - - if ($Handler -is [System.Management.Automation.CommandInfo]) { - foreach ($n in $Handler.Parameters.Keys) { - $null = $set.Add([string]$n) - } - return $set - } - - # Unknown handler shape: return an empty set. - return $set -} diff --git a/src/IdLE.Core/Private/Get-IdleStepWithSchema.ps1 b/src/IdLE.Core/Private/Get-IdleStepWithSchema.ps1 new file mode 100644 index 00000000..19bc4ec4 --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleStepWithSchema.ps1 @@ -0,0 +1,41 @@ +function Get-IdleStepWithSchema { + <# + .SYNOPSIS + Returns the effective root schema for a step's With hashtable. + + .DESCRIPTION + Wraps WithSchema.Keys into a Hashtable key schema and adds the engine-provided common keys + (Provider, AuthSessionName, AuthSessionOptions) when the step metadata declares a Provider. + + .PARAMETER Metadata + Step metadata entry (validated by Assert-IdleStepMetadataContract). + + .OUTPUTS + System.Collections.Hashtable (key schema with Type = 'Hashtable'). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Metadata + ) + + $withSchema = $Metadata['WithSchema'] + + $keys = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($key in $withSchema['Keys'].PSBase.Keys) { + $keys[[string]$key] = $withSchema['Keys'][$key] + } + + if ($Metadata.ContainsKey('Provider')) { + $keys['Provider'] = @{ Type = 'String'; Default = [string]$Metadata['Provider']['DefaultAlias'] } + $keys['AuthSessionName'] = @{ Type = 'String' } + $keys['AuthSessionOptions'] = @{ Type = 'Hashtable' } + } + + return @{ + Type = 'Hashtable' + Keys = $keys + AllowAdditionalKeys = ($withSchema.ContainsKey('AllowAdditionalKeys') -and $withSchema['AllowAdditionalKeys'] -eq $true) + } +} diff --git a/src/IdLE.Core/Private/New-IdleProviderHandle.ps1 b/src/IdLE.Core/Private/New-IdleProviderHandle.ps1 new file mode 100644 index 00000000..2d84c2fb --- /dev/null +++ b/src/IdLE.Core/Private/New-IdleProviderHandle.ps1 @@ -0,0 +1,169 @@ +function New-IdleProviderHandle { + <# + .SYNOPSIS + Creates the provider handle passed to step handlers as -Provider. + + .DESCRIPTION + The handle binds one provider from Context.Providers to a step. Step handlers call provider methods + through it and never handle authentication themselves: + + - Invoke(MethodName, Arguments): calls the method; appends the auth session when the method declares an + AuthSession parameter. The session is acquired via Context.AcquireAuthSession on first use and cached + for the lifetime of the handle (one step attempt). + - HasMethod(MethodName): the provider implements the method (optional capabilities, e.g. bulk operations). + - HasParameter(MethodName, ParameterName): the method declares the parameter. + + The raw provider object is not exposed, so steps cannot bypass the auth logic. + + .PARAMETER Context + Execution context (Providers, AcquireAuthSession). + + .PARAMETER Alias + Provider alias (resolved With.Provider). + + .PARAMETER AuthSessionName + Optional auth session routing key (With.AuthSessionName). + + .PARAMETER AuthSessionOptions + Optional data-only auth session options (With.AuthSessionOptions). + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.ProviderHandle) + #> + [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AuthSessionOptions', Justification = 'Used by the Invoke script method via closure.')] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Alias, + + [Parameter()] + [AllowNull()] + [string] $AuthSessionName, + + [Parameter()] + [AllowNull()] + [hashtable] $AuthSessionOptions + ) + + $providers = $Context.Providers + $provider = $null + if ($providers -is [System.Collections.IDictionary]) { + if ($providers.Contains($Alias)) { $provider = $providers[$Alias] } + } + elseif ($null -ne $providers -and $null -ne $providers.PSObject.Properties[$Alias]) { + $provider = $providers.$Alias + } + + if ($null -eq $provider) { + throw [System.InvalidOperationException]::new( + "ProviderNotSupplied: Provider '$Alias' was not supplied by the host. Pass it in Providers['$Alias'] or set With.Provider to a supplied alias." + ) + } + + # Providers that expose an EventSink property emit diagnostics events through the run's event sink + # (e.g. IdLE.Provider.ExchangeOnline.*). Steps no longer see the raw provider, so the engine wires it. + # Indexer lookups: member enumeration (.Properties.Name) fails under StrictMode for objects without properties. + if ($null -ne $provider.PSObject.Properties['EventSink'] -and $null -ne $Context.PSObject.Properties['EventSink']) { + $provider.EventSink = $Context.EventSink + } + + # Capture command references once: script methods created with GetNewClosure() cannot resolve + # private module functions by name. + $testParameterCmd = Get-Command -Name 'Test-IdleProviderMethodParameter' -CommandType Function -ErrorAction Stop + $sessionState = @{ Acquired = $false; Session = $null } + $hasSessionName = -not [string]::IsNullOrWhiteSpace($AuthSessionName) + + $handle = [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderHandle' + Alias = $Alias + } + + $hasMethod = { + param([string] $MethodName) + return $null -ne $provider.PSObject.Methods[$MethodName] + }.GetNewClosure() + + $hasParameter = { + param([string] $MethodName, [string] $ParameterName) + $method = $provider.PSObject.Methods[$MethodName] + if ($null -eq $method) { return $false } + return [bool](& $testParameterCmd -ProviderMethod $method -ParameterName $ParameterName) + }.GetNewClosure() + + $invoke = { + param([string] $MethodName, [object[]] $Arguments) + + $method = $provider.PSObject.Methods[$MethodName] + if ($null -eq $method) { + throw [System.InvalidOperationException]::new( + "ProviderMethodNotImplemented: Provider '$Alias' does not implement method '$MethodName'." + ) + } + + # [object[]] keeps a single argument an array (an if-expression would unroll it to a scalar). + [object[]] $callArguments = @() + if ($null -ne $Arguments) { $callArguments = @($Arguments) } + $supportsAuthSession = [bool](& $testParameterCmd -ProviderMethod $method -ParameterName 'AuthSession') + $canAcquire = $null -ne $Context.PSObject.Methods['AcquireAuthSession'] + + if (-not $sessionState.Acquired -and ($hasSessionName -or ($supportsAuthSession -and $canAcquire))) { + if (-not $canAcquire) { + throw [System.InvalidOperationException]::new( + "With.AuthSessionName is set to '$AuthSessionName' but the execution context cannot acquire auth sessions." + ) + } + + if ($hasSessionName) { + $sessionState.Session = $Context.AcquireAuthSession($AuthSessionName, $AuthSessionOptions) + } + else { + # An empty name requests the broker's default session. + $sessionState.Session = $Context.AcquireAuthSession('', $null) + } + $sessionState.Acquired = $true + } + + if ($supportsAuthSession) { + # Arguments are the method's parameters without AuthSession; insert the session at its declared + # position (e.g. ListEntitlements(IdentityKey, AuthSession, Kind)). Compiled methods expose no + # parameter order, so the session is appended for them. + $position = $callArguments.Count + if ($method.MemberType -eq 'ScriptMethod' -and $null -ne $method.Script.Ast.ParamBlock) { + $names = @($method.Script.Ast.ParamBlock.Parameters | ForEach-Object { $_.Name.VariablePath.UserPath }) + $declared = [array]::IndexOf($names, 'AuthSession') + if ($declared -ge 0 -and $declared -lt $position) { $position = $declared } + } + + if ($null -ne $sessionState.Session -or $position -lt $callArguments.Count) { + $withSession = [System.Collections.Generic.List[object]]::new() + for ($index = 0; $index -lt $callArguments.Count; $index++) { + if ($index -eq $position) { $withSession.Add($sessionState.Session) } + $withSession.Add($callArguments[$index]) + } + if ($position -ge $callArguments.Count) { $withSession.Add($sessionState.Session) } + $callArguments = $withSession.ToArray() + } + } + + try { + return $method.Invoke($callArguments) + } + catch [System.Management.Automation.MethodInvocationException] { + # The handle's own Invoke is a script method that wraps again; pass on the provider's exception + # so callers see one level of wrapping, as with direct provider calls. + if ($null -ne $_.Exception.InnerException) { throw $_.Exception.InnerException } + throw + } + }.GetNewClosure() + + $null = Add-Member -InputObject $handle -MemberType ScriptMethod -Name 'Invoke' -Value $invoke -Force + $null = Add-Member -InputObject $handle -MemberType ScriptMethod -Name 'HasMethod' -Value $hasMethod -Force + $null = Add-Member -InputObject $handle -MemberType ScriptMethod -Name 'HasParameter' -Value $hasParameter -Force + + return $handle +} diff --git a/src/IdLE.Core/Private/New-IdleStepHandlerArguments.ps1 b/src/IdLE.Core/Private/New-IdleStepHandlerArguments.ps1 new file mode 100644 index 00000000..644c6b13 --- /dev/null +++ b/src/IdLE.Core/Private/New-IdleStepHandlerArguments.ps1 @@ -0,0 +1,103 @@ +function New-IdleStepHandlerArguments { + <# + .SYNOPSIS + Builds the parameters for invoking a step handler and checks the handler signature. + + .DESCRIPTION + Step handler contract (see docs/extend/steps.md): + - -With (always; validated With without the common keys). + - -Context (execution context) if the handler declares it. + - -Provider (provider handle) if and only if the step declares a Provider. Steps that declare a Provider + carry With.Provider after planning (default applied from Provider.DefaultAlias). + - -Step (optional): read-only step metadata (Name, Type). + + Handler signature errors start with LegacyStepHandler or InvalidStepHandlerSignature. + + .PARAMETER Handler + Resolved handler command. + + .PARAMETER Step + Plan step (Name, Type, With). + + .PARAMETER Context + Execution context. + + .OUTPUTS + System.Collections.Hashtable (splattable parameters). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [System.Management.Automation.CommandInfo] $Handler, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context + ) + + $stepName = [string](Get-IdlePropertyValue -Object $Step -Name 'Name') + $stepType = [string](Get-IdlePropertyValue -Object $Step -Name 'Type') + $stepWith = Get-IdlePropertyValue -Object $Step -Name 'With' + if ($null -eq $stepWith) { $stepWith = @{} } + + $commonKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + $usesProvider = $stepWith -is [System.Collections.IDictionary] -and $stepWith.Contains('Provider') + + $parameters = $Handler.Parameters + $handlerLabel = "Step handler '$($Handler.Name)' for step type '$stepType'" + + if (-not $parameters.ContainsKey('With')) { + throw [System.InvalidOperationException]::new( + "LegacyStepHandler: $handlerLabel uses the pre-1.0 handler contract (no -With parameter). Handlers declare -With (plus -Context, -Provider and -Step as needed) and return `$null or @{ Changed; Data; State }. See docs/extend/steps.md." + ) + } + + if ($usesProvider -and -not $parameters.ContainsKey('Provider')) { + throw [System.InvalidOperationException]::new( + "InvalidStepHandlerSignature: $handlerLabel must declare a -Provider parameter because the step metadata declares a Provider." + ) + } + + if (-not $usesProvider -and $parameters.ContainsKey('Provider')) { + throw [System.InvalidOperationException]::new( + "InvalidStepHandlerSignature: $handlerLabel declares -Provider, but the step metadata declares no Provider. Add Provider = @{ DefaultAlias = '' } to the metadata or remove the parameter." + ) + } + + # The handler sees only its domain keys; provider routing and authentication are engine concerns. + $handlerWith = @{} + foreach ($key in @($stepWith.PSBase.Keys)) { + if ($usesProvider -and $commonKeys -contains [string]$key) { continue } + $handlerWith[[string]$key] = $stepWith[$key] + } + + $arguments = @{ With = $handlerWith } + + if ($parameters.ContainsKey('Context')) { + $arguments.Context = $Context + } + + if ($usesProvider) { + $authSessionOptions = if ($stepWith.Contains('AuthSessionOptions')) { $stepWith['AuthSessionOptions'] } else { $null } + $arguments.Provider = New-IdleProviderHandle ` + -Context $Context ` + -Alias ([string]$stepWith['Provider']) ` + -AuthSessionName $(if ($stepWith.Contains('AuthSessionName')) { [string]$stepWith['AuthSessionName'] } else { $null }) ` + -AuthSessionOptions $authSessionOptions + } + + if ($parameters.ContainsKey('Step')) { + $arguments.Step = [pscustomobject]@{ + PSTypeName = 'IdLE.StepInfo' + Name = $stepName + Type = $stepType + } + } + + return $arguments +} diff --git a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 index 64819bee..cdaa0d19 100644 --- a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 @@ -19,122 +19,6 @@ function Resolve-IdleStepMetadataCatalog { $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) - # Helper: Validate WithSchema structure. - # Every step type must declare WithSchema with RequiredKeys and OptionalKeys string arrays. - # A key name of '*' in OptionalKeys is allowed as a permissive wildcard for test/internal use. - function Test-IdleWithSchema { - [CmdletBinding()] - param( - [Parameter()] - [AllowNull()] - [object] $Value, - - [Parameter(Mandatory)] - [string] $StepType, - - [Parameter(Mandatory)] - [string] $SourceName - ) - - if ($null -eq $Value) { - throw [System.ArgumentException]::new( - "$SourceName entry for step type '$StepType' is missing 'WithSchema'. Every step type must declare its With key contract as WithSchema = @{ RequiredKeys = @(...); OptionalKeys = @(...) }.", - 'Providers' - ) - } - - if ($Value -isnot [hashtable]) { - $valueType = $Value.GetType().FullName - throw [System.ArgumentException]::new( - "$SourceName entry for step type '$StepType' has an invalid 'WithSchema' type '$valueType'. WithSchema must be a hashtable: @{ RequiredKeys = @(...); OptionalKeys = @(...) }.", - 'Providers' - ) - } - - foreach ($schemaKey in @('RequiredKeys', 'OptionalKeys')) { - if (-not $Value.ContainsKey($schemaKey)) { - throw [System.ArgumentException]::new( - "$SourceName entry for step type '$StepType' WithSchema is missing '$schemaKey'. Expected a string array (may be empty: @()).", - 'Providers' - ) - } - - $keyList = $Value[$schemaKey] - if ($null -eq $keyList) { - throw [System.ArgumentException]::new( - "$SourceName entry for step type '$StepType' WithSchema.$schemaKey must be a string array (got null).", - 'Providers' - ) - } - - # Scalar string is valid as a single-element array - if ($keyList -is [string]) { - if ([string]::IsNullOrWhiteSpace($keyList)) { - throw [System.ArgumentException]::new( - "$SourceName entry for step type '$StepType' WithSchema.$schemaKey contains an empty or whitespace-only key name.", - 'Providers' - ) - } - continue - } - - if ($keyList -is [System.Collections.IDictionary]) { - throw [System.ArgumentException]::new( - "$SourceName entry for step type '$StepType' WithSchema.$schemaKey must be a string array, not a hashtable.", - 'Providers' - ) - } - - if ($keyList -is [System.Collections.IEnumerable]) { - foreach ($k in $keyList) { - if ($null -eq $k -or $k -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$k)) { - throw [System.ArgumentException]::new( - "$SourceName entry for step type '$StepType' WithSchema.$schemaKey contains a null, non-string, or empty key name.", - 'Providers' - ) - } - } - continue - } - - # If it's not IEnumerable and not a string, it's invalid - if ($keyList -isnot [System.Collections.IEnumerable]) { - throw [System.ArgumentException]::new( - "$SourceName entry for step type '$StepType' WithSchema.$schemaKey must be a string array.", - 'Providers' - ) - } - } - - # Check for duplicates across RequiredKeys and OptionalKeys (case-insensitive) - $requiredSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - $reqKeys = $Value['RequiredKeys'] - if ($reqKeys -is [string]) { $null = $requiredSet.Add($reqKeys) } - elseif ($reqKeys -is [System.Collections.IEnumerable]) { - foreach ($k in $reqKeys) { if ($null -ne $k) { $null = $requiredSet.Add([string]$k) } } - } - - $optKeys = $Value['OptionalKeys'] - if ($optKeys -is [string]) { - if ($requiredSet.Contains($optKeys)) { - throw [System.ArgumentException]::new( - "$SourceName entry for step type '$StepType' WithSchema has key '$optKeys' in both RequiredKeys and OptionalKeys. Keys must be unique across both sets.", - 'Providers' - ) - } - } - elseif ($optKeys -is [System.Collections.IEnumerable]) { - foreach ($k in $optKeys) { - if ($null -ne $k -and $requiredSet.Contains([string]$k)) { - throw [System.ArgumentException]::new( - "$SourceName entry for step type '$StepType' WithSchema has key '$k' in both RequiredKeys and OptionalKeys. Keys must be unique across both sets.", - 'Providers' - ) - } - } - } - } - # Helper: Validate RequiredCapabilities value. function Test-IdleRequiredCapabilities { [CmdletBinding()] @@ -458,9 +342,8 @@ function Resolve-IdleStepMetadataCatalog { } } - # Validate WithSchema is present and structurally valid - $withSchemaValue = if ($value.ContainsKey('WithSchema')) { $value['WithSchema'] } else { $null } - Test-IdleWithSchema -Value $withSchemaValue -StepType $key -SourceName $SourceModuleName + # Validate WithSchema and Provider (typed With contract). + Assert-IdleStepMetadataContract -Metadata $value -StepType $key -SourceName $SourceModuleName # Check for duplicates across step packs if ($StepTypeOwners.ContainsKey([string]$key)) { @@ -542,9 +425,8 @@ function Resolve-IdleStepMetadataCatalog { } } - # Validate WithSchema is present and structurally valid - $withSchemaValue = if ($value.ContainsKey('WithSchema')) { $value['WithSchema'] } else { $null } - Test-IdleWithSchema -Value $withSchemaValue -StepType $key -SourceName 'Providers.StepMetadata' + # Validate WithSchema and Provider (typed With contract). + Assert-IdleStepMetadataContract -Metadata $value -StepType $key -SourceName 'Providers.StepMetadata' # Host handlers are registered via Providers.StepRegistry only, so that execution does not depend on # the metadata passed at plan time. diff --git a/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 b/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 index e8d2b7ce..5fd928a6 100644 --- a/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 @@ -67,7 +67,8 @@ function Resolve-IdleWorkflowTemplates { foreach ($item in $Value) { $resolved += Resolve-IdleWorkflowTemplates -Value $item -Request $Request -StepName $StepName } - return $resolved + # Comma operator: keep single-element and empty arrays as arrays (no pipeline unrolling). + return , $resolved } # PSCustomObject: recurse on properties diff --git a/src/IdLE.Core/Public/Test-IdleProviderMethodParameter.ps1 b/src/IdLE.Core/Private/Test-IdleProviderMethodParameter.ps1 similarity index 96% rename from src/IdLE.Core/Public/Test-IdleProviderMethodParameter.ps1 rename to src/IdLE.Core/Private/Test-IdleProviderMethodParameter.ps1 index 434a6b61..dadb697d 100644 --- a/src/IdLE.Core/Public/Test-IdleProviderMethodParameter.ps1 +++ b/src/IdLE.Core/Private/Test-IdleProviderMethodParameter.ps1 @@ -14,7 +14,7 @@ function Test-IdleProviderMethodParameter { - ScriptMethod (AST inspection via PowerShell parser) - Compiled methods (reflection-based inspection) - Used by Invoke-IdleProviderMethod to detect backwards-compatible method signatures. + Used by the provider handle (New-IdleProviderHandle) to detect optional method parameters such as AuthSession. .PARAMETER ProviderMethod PSMethodInfo object representing the provider method to inspect. diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 13bb4e1e..e825d698 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -135,8 +135,9 @@ function Invoke-IdlePlanObject { $null = $context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { [CmdletBinding()] param( + # An empty name requests the broker's default session (DefaultAuthSession). [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] + [AllowEmptyString()] [string] $Name, [Parameter()] @@ -431,31 +432,23 @@ function Invoke-IdlePlanObject { $impl = Resolve-IdleStepHandler -StepType ([string]$stepType) -StepRegistry $stepRegistry $handlerSource = ConvertTo-IdleSourceInfo -Module $impl.Module - $supportedParams = Get-IdleCommandParameterNames -Handler $impl - - $invokeParams = @{} - - # Backwards compatibility: pass -Context only when the handler supports it. - if ($supportedParams.Contains('Context')) { - $invokeParams.Context = $context - } - - if ($null -ne $stepWith -and $supportedParams.Contains('With')) { - $invokeParams.With = $stepWith - } - - if ($supportedParams.Contains('Step')) { - $invokeParams.Step = $step - } + # Fail before the first attempt if the handler does not follow the step contract + # or the provider is not supplied. + $null = New-IdleStepHandlerArguments -Handler $impl -Step $step -Context $context # Safe-by-default transient retries: # - Only retries if the thrown exception is explicitly marked transient. - # - Emits 'StepRetrying' events and uses deterministic jitter/backoff. + # - Emits 'IdLE.Step.Retrying' events and uses deterministic jitter/backoff. # - Retry parameters resolved from ExecutionOptions if provided. + # - Arguments are built per attempt so that each attempt gets a fresh provider handle (auth session cache). $retrySeed = "$corr|$stepType|$stepName|$i" $retryParams = Resolve-IdleStepRetryParameters -Step $step -ExecutionOptions $ExecutionOptions + $operation = { + $handlerArguments = New-IdleStepHandlerArguments -Handler $impl -Step $step -Context $context + & $impl @handlerArguments + } $retry = Invoke-IdleWithRetry ` - -Operation { & $impl @invokeParams } ` + -Operation $operation ` -MaxAttempts $retryParams.MaxAttempts ` -InitialDelayMilliseconds $retryParams.InitialDelayMilliseconds ` -BackoffFactor $retryParams.BackoffFactor ` @@ -466,46 +459,13 @@ function Invoke-IdlePlanObject { -OperationName 'StepExecution' ` -DeterministicSeed $retrySeed - $result = $retry.Value - $attempts = [int]$retry.Attempts - - if ($null -eq $result) { - $result = [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = $stepName - Type = $stepType - Status = 'Completed' - Attempts = $attempts - } - } - else { - # Normalize result to include Attempts for observability (non-breaking). - if ($result.PSObject.Properties.Name -notcontains 'Attempts') { - $null = $result | Add-Member -MemberType NoteProperty -Name Attempts -Value $attempts -Force - } - } - - $null = $result | Add-Member -MemberType NoteProperty -Name Source -Value $handlerSource -Force - - $stepResults += $result - - if ($result.Status -eq 'Failed') { - $failed = $true - - $context.EventSink.WriteEvent( - 'IdLE.Step.Failed', - "Step '$stepName' failed.", - $stepName, - @{ - StepType = $stepType - Index = $i - Error = $result.Error - }, - 'Error' - ) - - break - } + # The engine owns the result shape; handlers report failures by throwing. + $stepResults += ConvertTo-IdleStepResult ` + -Output $retry.Value ` + -StepName $stepName ` + -StepType ([string]$stepType) ` + -Attempts ([int]$retry.Attempts) ` + -Source $handlerSource $context.EventSink.WriteEvent( 'IdLE.Step.Completed', @@ -589,7 +549,6 @@ function Invoke-IdlePlanObject { $ofPropNames = @($ofStep.PSObject.Properties.Name) $ofName = if ($ofPropNames -contains 'Name') { [string]$ofStep.Name } else { '' } $ofType = if ($ofPropNames -contains 'Type') { $ofStep.Type } else { $null } - $ofWith = if ($ofPropNames -contains 'With') { $ofStep.With } else { $null } $ofStatus = if ($ofPropNames -contains 'Status') { [string]$ofStep.Status } else { '' } # Conditions for OnFailure steps are evaluated during planning as well. @@ -728,29 +687,18 @@ function Invoke-IdlePlanObject { $impl = Resolve-IdleStepHandler -StepType ([string]$ofType) -StepRegistry $stepRegistry $handlerSource = ConvertTo-IdleSourceInfo -Module $impl.Module - $supportedParams = Get-IdleCommandParameterNames -Handler $impl - - $invokeParams = @{} - - # Backwards compatibility: pass -Context only when the handler supports it. - if ($supportedParams.Contains('Context')) { - $invokeParams.Context = $context - } - - if ($null -ne $ofWith -and $supportedParams.Contains('With')) { - $invokeParams.With = $ofWith - } - - if ($supportedParams.Contains('Step')) { - $invokeParams.Step = $ofStep - } + $null = New-IdleStepHandlerArguments -Handler $impl -Step $ofStep -Context $context # Reuse safe-by-default transient retries for OnFailure steps. # - Retry parameters resolved from ExecutionOptions if provided. $retrySeed = "$corr|OnFailure|$ofType|$ofName|$j" $retryParams = Resolve-IdleStepRetryParameters -Step $ofStep -ExecutionOptions $ExecutionOptions + $operation = { + $handlerArguments = New-IdleStepHandlerArguments -Handler $impl -Step $ofStep -Context $context + & $impl @handlerArguments + } $retry = Invoke-IdleWithRetry ` - -Operation { & $impl @invokeParams } ` + -Operation $operation ` -MaxAttempts $retryParams.MaxAttempts ` -InitialDelayMilliseconds $retryParams.InitialDelayMilliseconds ` -BackoffFactor $retryParams.BackoffFactor ` @@ -761,55 +709,22 @@ function Invoke-IdlePlanObject { -OperationName 'OnFailureStepExecution' ` -DeterministicSeed $retrySeed - $result = $retry.Value - $attempts = [int]$retry.Attempts + $onFailureStepResults += ConvertTo-IdleStepResult ` + -Output $retry.Value ` + -StepName $ofName ` + -StepType ([string]$ofType) ` + -Attempts ([int]$retry.Attempts) ` + -Source $handlerSource - if ($null -eq $result) { - $result = [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = $ofName - Type = $ofType - Status = 'Completed' - Attempts = $attempts - } - } - else { - # Normalize result to include Attempts for observability (non-breaking). - if ($result.PSObject.Properties.Name -notcontains 'Attempts') { - $null = $result | Add-Member -MemberType NoteProperty -Name Attempts -Value $attempts -Force + $context.EventSink.WriteEvent( + 'IdLE.OnFailure.StepCompleted', + "OnFailure step '$ofName' completed.", + $ofName, + @{ + StepType = $ofType + Index = $j } - } - - $null = $result | Add-Member -MemberType NoteProperty -Name Source -Value $handlerSource -Force - - $onFailureStepResults += $result - - if ($result.Status -eq 'Failed') { - $onFailureHadFailures = $true - - $context.EventSink.WriteEvent( - 'IdLE.OnFailure.StepFailed', - "OnFailure step '$ofName' failed.", - $ofName, - @{ - StepType = $ofType - Index = $j - Error = $result.Error - }, - 'Error' - ) - } - else { - $context.EventSink.WriteEvent( - 'IdLE.OnFailure.StepCompleted', - "OnFailure step '$ofName' completed.", - $ofName, - @{ - StepType = $ofType - Index = $j - } - ) - } + ) } catch { $onFailureHadFailures = $true diff --git a/src/IdLE.Core/Public/Invoke-IdleProviderMethod.ps1 b/src/IdLE.Core/Public/Invoke-IdleProviderMethod.ps1 deleted file mode 100644 index 1dbd3947..00000000 --- a/src/IdLE.Core/Public/Invoke-IdleProviderMethod.ps1 +++ /dev/null @@ -1,149 +0,0 @@ -# Invokes a provider method with optional AuthSession support. -# Handles auth session acquisition, parameter detection, and backwards-compatible fallback. - -function Invoke-IdleProviderMethod { - <# - .SYNOPSIS - Invokes a provider method with optional AuthSession support. - - .DESCRIPTION - This is a foundational helper for step implementations that need to invoke - provider methods with proper authentication handling. - - Key features: - - Acquires auth sessions via Context.AcquireAuthSession when AuthSessionBroker is available - - If With.AuthSessionName is present, uses it for session routing - - If With.AuthSessionName is absent but broker exists, attempts to acquire default session - - Detects whether provider methods support AuthSession parameter (backwards compatible) - - Passes AuthSession to provider methods that support it - - Validates provider existence and method implementation - - .PARAMETER Context - Execution context created by IdLE.Core. Must contain Providers hashtable and - AcquireAuthSession method. - - .PARAMETER With - Step configuration hashtable. May contain: - - AuthSessionName (string): Name of auth session to acquire - - AuthSessionOptions (hashtable): Optional session selection options - - .PARAMETER ProviderAlias - Key to look up the provider in Context.Providers. - - .PARAMETER MethodName - Name of the provider method to invoke. - - .PARAMETER MethodArguments - Array of arguments to pass to the provider method (excluding AuthSession). - - .OUTPUTS - Object returned by the provider method. - - .EXAMPLE - $result = Invoke-IdleProviderMethod ` - -Context $Context ` - -With @{ AuthSessionName = 'ExchangeOnline' } ` - -ProviderAlias 'ExchangeOnline' ` - -MethodName 'EnsureMailboxType' ` - -MethodArguments @('user@contoso.com', 'Shared') - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Context, - - [Parameter(Mandatory)] - [ValidateNotNull()] - [hashtable] $With, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $ProviderAlias, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $MethodName, - - [Parameter(Mandatory)] - [AllowNull()] - [AllowEmptyCollection()] - [object[]] $MethodArguments - ) - - # Auth session acquisition (optional, data-only) - $authSession = $null - - # [AllowNull()] on $MethodArguments is required so PowerShell's parameter binder accepts - # [object[]] arrays that contain $null elements (e.g., a $null attribute value to clear). - # However, the array reference itself must never be null - that would indicate a caller bug. - if ($null -eq $MethodArguments) { - throw "MethodArguments must not be null. Pass an empty array if no arguments are needed." - } - - # Validate AuthSessionOptions early (regardless of broker availability) - if ($With.ContainsKey('AuthSessionOptions')) { - $sessionOptions = $With.AuthSessionOptions - if ($null -ne $sessionOptions -and -not ($sessionOptions -is [hashtable])) { - throw "With.AuthSessionOptions must be a hashtable or null." - } - } - - $provider = $Context.Providers[$ProviderAlias] - - # Check if provider method exists - $providerMethod = $provider.PSObject.Methods[$MethodName] - if ($null -eq $providerMethod) { - throw "Provider '$ProviderAlias' does not implement $MethodName method." - } - - # Check if method supports AuthSession parameter - $supportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $providerMethod -ParameterName 'AuthSession' - - # Check if context can acquire auth sessions - $canAcquireAuth = $Context.PSObject.Methods.Name -contains 'AcquireAuthSession' - - # Only acquire auth session if provider method can use it or if explicitly requested - $shouldAcquireAuth = $With.ContainsKey('AuthSessionName') -or ($supportsAuthSession -and $canAcquireAuth) - - if ($shouldAcquireAuth) { - if (-not $canAcquireAuth) { - # AuthSessionName was explicitly set but context cannot acquire sessions - throw "With.AuthSessionName is set to '$($With.AuthSessionName)' but Context does not have an AcquireAuthSession method." - } - - # If AuthSessionName is provided, use it for routing - if ($With.ContainsKey('AuthSessionName')) { - $sessionName = [string]$With.AuthSessionName - $sessionOptions = if ($With.ContainsKey('AuthSessionOptions')) { $With.AuthSessionOptions } else { $null } - - $authSession = $Context.AcquireAuthSession($sessionName, $sessionOptions) - } - else { - # No AuthSessionName provided but provider supports auth - try to acquire default session - # Use empty string to signal default request without conflicting with user session names - try { - $authSession = $Context.AcquireAuthSession('', $null) - } - catch { - # If provider method supports AuthSession and default acquisition fails, rethrow - if ($supportsAuthSession) { - throw - } - # Otherwise, continue without auth session for backward compatibility - $authSession = $null - } - } - } - - # Call provider method with appropriate signature - if ($supportsAuthSession -and $null -ne $authSession) { - # Provider supports AuthSession and we have one - pass it - $allArgs = $MethodArguments + $authSession - return $provider.$MethodName.Invoke($allArgs) - } - else { - # Legacy signature (no AuthSession parameter) or no session acquired - return $provider.$MethodName.Invoke($MethodArguments) - } -} diff --git a/tests/Core/IdentifierNamespaces.Tests.ps1 b/tests/Core/IdentifierNamespaces.Tests.ps1 index b41126fa..6cee3cca 100644 --- a/tests/Core/IdentifierNamespaces.Tests.ps1 +++ b/tests/Core/IdentifierNamespaces.Tests.ps1 @@ -102,7 +102,7 @@ Describe 'Identifier namespaces' { It 'rejects host step types that do not follow Owner.IdLE.Step.* (InvalidExtensionNamespace)' { $providers = @{ StepMetadata = @{ - 'Contoso.Step.Hr.Sync' = @{ WithSchema = @{ RequiredKeys = @(); OptionalKeys = @() } } + 'Contoso.Step.Hr.Sync' = @{ WithSchema = @{ Keys = @{} } } } } diff --git a/tests/Core/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 index f3094874..bcc13158 100644 --- a/tests/Core/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.ExecutionOptions.Tests.ps1 @@ -17,6 +17,9 @@ BeforeAll { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step @@ -27,13 +30,7 @@ BeforeAll { Timestamp = [DateTimeOffset]::UtcNow }) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } function Invoke-IdleRetryProfileTransientStep { @@ -43,6 +40,9 @@ BeforeAll { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step @@ -63,13 +63,7 @@ BeforeAll { throw $ex } - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = $stepName - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } function Reset-IdleRetryProfileTestState { @@ -369,6 +363,9 @@ Describe 'Invoke-IdlePlan - ExecutionOptions' { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step diff --git a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 index 98b62892..e93b2edb 100644 --- a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 @@ -12,18 +12,15 @@ BeforeAll { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step ) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } function global:Invoke-IdlePreconditionTestSecondStep { @@ -33,6 +30,9 @@ BeforeAll { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step @@ -40,13 +40,7 @@ BeforeAll { $Context.EventSink.WriteEvent('SecondStepRan', 'Second step executed', $Step.Name, @{ StepType = $Step.Type }) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } function global:Invoke-IdlePreconditionTestOnFailureStep { @@ -56,6 +50,9 @@ BeforeAll { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step @@ -63,13 +60,7 @@ BeforeAll { $Context.EventSink.WriteEvent('OnFailureRan', 'OnFailure step executed', $Step.Name, @{ StepType = $Step.Type }) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } } @@ -412,7 +403,7 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { $providers = @{ Identity = $provider StepRegistry = @{ 'Test.IdLE.Step.CurrentCleanupTest' = 'Invoke-IdlePreconditionTestNoopStep' } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.IdLE.Step.CurrentCleanupTest') + StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.IdLE.Step.CurrentCleanupTest') -Providers @{ 'Test.IdLE.Step.CurrentCleanupTest' = 'Identity' } } $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers @@ -441,7 +432,7 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { $providers = @{ Identity = $provider StepRegistry = @{ 'Test.IdLE.Step.CurrentCleanupTest' = 'Invoke-IdlePreconditionTestNoopStep' } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.IdLE.Step.CurrentCleanupTest') + StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.IdLE.Step.CurrentCleanupTest') -Providers @{ 'Test.IdLE.Step.CurrentCleanupTest' = 'Identity' } } $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers diff --git a/tests/Core/Invoke-IdlePlan.ProviderFallback.Tests.ps1 b/tests/Core/Invoke-IdlePlan.ProviderFallback.Tests.ps1 index 3ee8bf76..318aeba7 100644 --- a/tests/Core/Invoke-IdlePlan.ProviderFallback.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.ProviderFallback.Tests.ps1 @@ -11,6 +11,9 @@ BeforeAll { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step @@ -20,13 +23,7 @@ BeforeAll { throw 'Context.Providers must be a hashtable.' } - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } } diff --git a/tests/Core/Invoke-IdlePlan.Retry.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Retry.Tests.ps1 index 8bd58a33..19b36781 100644 --- a/tests/Core/Invoke-IdlePlan.Retry.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Retry.Tests.ps1 @@ -20,6 +20,9 @@ BeforeAll { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step @@ -33,13 +36,7 @@ BeforeAll { throw $ex } - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } function Invoke-IdleRetryTestNonTransientStep { @@ -49,6 +46,9 @@ BeforeAll { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step diff --git a/tests/Core/Invoke-IdlePlan.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Tests.ps1 index af510ec9..90027199 100644 --- a/tests/Core/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Tests.ps1 @@ -14,18 +14,15 @@ BeforeAll { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step ) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } function global:Invoke-IdleTestEmitStep { @@ -35,6 +32,9 @@ BeforeAll { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step @@ -42,13 +42,7 @@ BeforeAll { $Context.EventSink.WriteEvent('Test.IdLE.Emit.Emitted', 'Hello from test step', $Step.Name, @{ StepType = $Step.Type }) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } function global:Invoke-IdleTestFailStep { @@ -58,18 +52,15 @@ BeforeAll { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step ) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Failed' - Error = 'Boom' - } + throw 'Boom' } function global:Invoke-IdleTestAcquireAuthSessionStep { @@ -79,12 +70,15 @@ BeforeAll { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step ) - $with = $Step.With + $with = $With $name = $null $options = $null @@ -113,13 +107,7 @@ BeforeAll { throw [System.InvalidOperationException]::new('AcquireAuthSession returned null.') } - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } # Legacy step handler without Context parameter for backwards compatibility testing @@ -131,13 +119,7 @@ BeforeAll { [object] $Step ) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } } @@ -675,7 +657,7 @@ Describe 'Invoke-IdlePlan' { } Context 'Legacy step handlers' { - It 'supports step handlers without Context parameter (backwards compatibility)' { + It 'fails steps whose handler uses the pre-1.0 contract (LegacyStepHandler)' { $wfPath = New-IdleTestWorkflowFile -FileName 'legacy.psd1' -Content @' @{ Name = 'Legacy' @@ -699,9 +681,9 @@ Describe 'Invoke-IdlePlan' { $result = Invoke-IdlePlan -Plan $plan -Providers $providers - $result.Status | Should -Be 'Completed' - $result.Steps[0].Status | Should -Be 'Completed' - $result.Steps[0].Name | Should -Be 'LegacyStep' + $result.Status | Should -Be 'Failed' + $result.Steps[0].Status | Should -Be 'Failed' + $result.Steps[0].Error | Should -BeLike 'LegacyStepHandler:*Invoke-IdleTestLegacyStep*' } } } diff --git a/tests/Core/New-IdlePlan.Tests.ps1 b/tests/Core/New-IdlePlan.Tests.ps1 index fb74bcbd..f22dabe6 100644 --- a/tests/Core/New-IdlePlan.Tests.ps1 +++ b/tests/Core/New-IdlePlan.Tests.ps1 @@ -12,18 +12,15 @@ BeforeAll { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step ) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } } @@ -220,7 +217,7 @@ Describe 'New-IdlePlan' { $providers = @{ StepRegistry = @{ 'Test.IdLE.Step.StrictSchemaTest' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.IdLE.Step.StrictSchemaTest') -WithSchemas @{ - 'Test.IdLE.Step.StrictSchemaTest' = @{ RequiredKeys = @('IdentityKey'); OptionalKeys = @() } + 'Test.IdLE.Step.StrictSchemaTest' = @{ Keys = @{ IdentityKey = @{ Type = 'String'; Required = $true } } } } } @@ -239,7 +236,7 @@ Describe 'New-IdlePlan' { $providers = @{ StepRegistry = @{ 'Test.IdLE.Step.StrictApplicableTest' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('Test.IdLE.Step.StrictApplicableTest') -WithSchemas @{ - 'Test.IdLE.Step.StrictApplicableTest' = @{ RequiredKeys = @('IdentityKey'); OptionalKeys = @() } + 'Test.IdLE.Step.StrictApplicableTest' = @{ Keys = @{ IdentityKey = @{ Type = 'String'; Required = $true } } } } } @@ -289,7 +286,7 @@ Describe 'New-IdlePlan' { { New-IdlePlan -WorkflowPath $wfPath -Request $req } | Should -Throw -ExpectedMessage '*does not match request LifecycleEvent*' } - It 'fails plan building when PruneEntitlementsEnsureKeep step contains unsupported With.KeepPattern key (not in WithSchema.OptionalKeys)' { + It 'fails plan building when PruneEntitlementsEnsureKeep step contains unsupported With.KeepPattern key (not in WithSchema.Keys)' { $wfPath = New-IdleTestWorkflowFile -FileName 'leaver-bad.psd1' -Content @' @{ Name = 'Leaver - Bad KeepPattern' diff --git a/tests/Core/New-IdleRequest.Tests.ps1 b/tests/Core/New-IdleRequest.Tests.ps1 index fa80dd2c..5df6d2c4 100644 --- a/tests/Core/New-IdleRequest.Tests.ps1 +++ b/tests/Core/New-IdleRequest.Tests.ps1 @@ -113,13 +113,7 @@ Describe 'New-IdlePlan - Request.Identity rejection' { [Parameter(Mandatory)][ValidateNotNull()][object] $Context, [Parameter(Mandatory)][ValidateNotNull()][object] $Step ) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } } diff --git a/tests/Core/Provenance.Tests.ps1 b/tests/Core/Provenance.Tests.ps1 index 8a79e6a7..5af4a997 100644 --- a/tests/Core/Provenance.Tests.ps1 +++ b/tests/Core/Provenance.Tests.ps1 @@ -8,22 +8,18 @@ BeforeAll { [CmdletBinding()] param( [Parameter(Mandatory)] [object] $Context, + [Parameter(Mandatory)] [hashtable] $With, [Parameter(Mandatory)] [object] $Step ) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } function global:Invoke-IdleProvenanceTestFailingStep { [CmdletBinding()] param( [Parameter(Mandatory)] [object] $Context, + [Parameter(Mandatory)] [hashtable] $With, [Parameter(Mandatory)] [object] $Step ) diff --git a/tests/Core/Resolve-IdleStepMetadataCatalog.Tests.ps1 b/tests/Core/Resolve-IdleStepMetadataCatalog.Tests.ps1 index 77e8a0da..3933084e 100644 --- a/tests/Core/Resolve-IdleStepMetadataCatalog.Tests.ps1 +++ b/tests/Core/Resolve-IdleStepMetadataCatalog.Tests.ps1 @@ -73,8 +73,7 @@ Describe 'Resolve-IdleStepMetadataCatalog - step pack catalog ownership' { 'Custom.IdLE.Step.Unknown' = @{ RequiredCapabilities = @('Custom.IdLE.Capability.Test') WithSchema = @{ - RequiredKeys = @() - OptionalKeys = @() + Keys = @{} } } } @@ -164,7 +163,7 @@ Describe 'Resolve-IdleStepMetadataCatalog - step pack catalog ownership' { } } - It 'rejects host metadata WithSchema missing RequiredKeys' { + It 'rejects host metadata in the legacy RequiredKeys/OptionalKeys format (LegacyWithSchema)' { $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-no-metadata.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' @@ -187,12 +186,12 @@ Describe 'Resolve-IdleStepMetadataCatalog - step pack catalog ownership' { throw 'Expected an exception but none was thrown.' } catch { - $_.Exception.Message | Should -Match 'RequiredKeys' + $_.Exception.Message | Should -Match 'LegacyWithSchema' $_.Exception.Message | Should -Match 'Custom.IdLE.Step.BadSchema' } } - It 'rejects host metadata WithSchema with duplicate key across RequiredKeys and OptionalKeys' { + It 'rejects host metadata WithSchema with an unknown key type (InvalidWithSchema)' { $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-no-metadata.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' @@ -204,8 +203,7 @@ Describe 'Resolve-IdleStepMetadataCatalog - step pack catalog ownership' { 'Custom.IdLE.Step.DupSchema' = @{ RequiredCapabilities = @() WithSchema = @{ - RequiredKeys = @('IdentityKey') - OptionalKeys = @('IdentityKey', 'Provider') + Keys = @{ IdentityKey = @{ Type = 'Guid' } } } } } @@ -217,7 +215,7 @@ Describe 'Resolve-IdleStepMetadataCatalog - step pack catalog ownership' { } catch { $_.Exception.Message | Should -Match 'IdentityKey' - $_.Exception.Message | Should -Match 'RequiredKeys.*OptionalKeys|OptionalKeys.*RequiredKeys' + $_.Exception.Message | Should -Match 'InvalidWithSchema' } } } diff --git a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 index 048bce0b..0c59ba7a 100644 --- a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 +++ b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 @@ -11,18 +11,15 @@ BeforeAll { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step ) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } $script:FixtureRoot = Join-Path $PSScriptRoot '..' 'fixtures/workflows/template-tests' diff --git a/tests/Core/StepContract.Tests.ps1 b/tests/Core/StepContract.Tests.ps1 new file mode 100644 index 00000000..2fcb9854 --- /dev/null +++ b/tests/Core/StepContract.Tests.ps1 @@ -0,0 +1,419 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule + + function global:Invoke-IdleContractTestHandler { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Context, + [Parameter(Mandatory)] [hashtable] $With + ) + + $global:IdleContractTestLastWith = $With + if ($With.ContainsKey('Output')) { + return $With.Output + } + return $null + } + + function global:Invoke-IdleContractTestProviderHandler { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Context, + [Parameter(Mandatory)] [hashtable] $With, + [Parameter(Mandatory)] [object] $Provider + ) + + $global:IdleContractTestLastWith = $With + $result = $Provider.Invoke('Echo', @($With.Value)) + return @{ Changed = $true; Data = @{ Echo = $result } } + } + + function global:Invoke-IdleContractTestLegacyHandler { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Context, + [Parameter(Mandatory)] [object] $Step + ) + + return $null + } + + function global:Invoke-IdleContractTestNoContextHandler { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [hashtable] $With + ) + + return $null + } + + function global:Invoke-IdleContractTestOutputHandler { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Context, + [Parameter(Mandatory)] [hashtable] $With + ) + + 'leaked' + return @{ Changed = $true } + } + + function New-ContractEchoProvider { + param([switch] $WithEventSink) + + $provider = [pscustomobject]@{ Sessions = [System.Collections.Generic.List[object]]::new() } + if ($WithEventSink) { + $provider | Add-Member -MemberType NoteProperty -Name EventSink -Value $null + } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { @() } + $provider | Add-Member -MemberType ScriptMethod -Name Echo -Value { + param($Value, $AuthSession) + $this.Sessions.Add($AuthSession) + return "$Value|$AuthSession" + } + $provider | Add-Member -MemberType ScriptMethod -Name ListWithKind -Value { + param($IdentityKey, $AuthSession, $Kind) + return "$IdentityKey|$AuthSession|$Kind" + } + $provider | Add-Member -MemberType ScriptMethod -Name NoAuth -Value { + param($Value) + return "$Value" + } + return $provider + } + + function New-ContractBroker { + $broker = [pscustomobject]@{ Calls = 0 } + $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + $this.Calls++ + return "session-$Name" + } + return $broker + } + + function Invoke-ContractWorkflow { + param( + [Parameter(Mandatory)] [string] $With, + [Parameter()] [string] $Handler = 'Invoke-IdleContractTestProviderHandler', + [Parameter()] [hashtable] $Metadata, + [Parameter()] [hashtable] $ExtraProviders = @{} + ) + + if ($null -eq $Metadata) { + $Metadata = @{ + RequiredCapabilities = @() + Provider = @{ DefaultAlias = 'Echo' } + WithSchema = @{ Keys = @{ Value = @{ Type = 'String'; Required = $true } } } + } + } + + $wfPath = New-IdleTestWorkflowFile -FileName 'step-contract.psd1' -Content @" +@{ + Name = 'Contract' + LifecycleEvent = 'Joiner' + Steps = @( @{ Name = 'S1'; Type = 'Test.IdLE.Step.Contract'; With = $With } ) +} +"@ + $providers = @{ + StepRegistry = @{ 'Test.IdLE.Step.Contract' = $Handler } + StepMetadata = @{ 'Test.IdLE.Step.Contract' = $Metadata } + AuthSessionBroker = (New-ContractBroker) + } + foreach ($key in $ExtraProviders.Keys) { $providers[$key] = $ExtraProviders[$key] } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request (New-IdleTestRequest -LifecycleEvent 'Joiner') -Providers $providers + return [pscustomobject]@{ + Plan = $plan + Result = Invoke-IdlePlan -Plan $plan -Providers $providers + Providers = $providers + } + } +} + +AfterAll { + foreach ($name in @('Invoke-IdleContractTestHandler', 'Invoke-IdleContractTestProviderHandler', 'Invoke-IdleContractTestLegacyHandler', 'Invoke-IdleContractTestNoContextHandler', 'Invoke-IdleContractTestOutputHandler')) { + Remove-Item -Path "Function:\$name" -ErrorAction SilentlyContinue + } + Remove-Variable -Name 'IdleContractTestLastWith' -Scope Global -ErrorAction SilentlyContinue +} + +Describe 'Step contract' { + Context 'With validation (ConvertTo-IdleSchemaValue)' { + It ' -> ' -ForEach @( + @{ Case = 'missing required key'; Schema = @{ Type = 'Hashtable'; Keys = @{ A = @{ Type = 'String'; Required = $true } } }; Value = @{}; Expected = 'WithKeyMissing' } + @{ Case = 'required key set to null'; Schema = @{ Type = 'Hashtable'; Keys = @{ A = @{ Type = 'String'; Required = $true } } }; Value = @{ A = $null }; Expected = 'WithKeyMissing' } + @{ Case = 'unknown key'; Schema = @{ Type = 'Hashtable'; Keys = @{ A = @{ Type = 'String' } } }; Value = @{ B = 'x' }; Expected = 'WithKeyUnknown' } + @{ Case = 'string expected, int given'; Schema = @{ Type = 'String' }; Value = 1; Expected = 'WithKeyTypeMismatch' } + @{ Case = 'int expected, string given (no coercion)'; Schema = @{ Type = 'Int' }; Value = '600'; Expected = 'WithKeyTypeMismatch' } + @{ Case = 'bool expected, string given (no coercion)'; Schema = @{ Type = 'Bool' }; Value = 'true'; Expected = 'WithKeyTypeMismatch' } + @{ Case = 'array expected, string given'; Schema = @{ Type = 'Array' }; Value = 'x'; Expected = 'WithKeyTypeMismatch' } + @{ Case = 'hashtable expected, array given'; Schema = @{ Type = 'Hashtable' }; Value = @(1); Expected = 'WithKeyTypeMismatch' } + @{ Case = 'empty string'; Schema = @{ Type = 'String' }; Value = ''; Expected = 'WithValueNotAllowed' } + @{ Case = 'empty array'; Schema = @{ Type = 'Array' }; Value = @(); Expected = 'WithValueNotAllowed' } + @{ Case = 'value not allowed'; Schema = @{ Type = 'String'; AllowedValues = @('Present', 'Absent') }; Value = 'Maybe'; Expected = 'WithValueNotAllowed' } + ) { + InModuleScope 'IdLE.Core' -Parameters $_ { + param($Schema, $Value, $Expected) + { ConvertTo-IdleSchemaValue -Schema $Schema -Value $Value -Path 'With' -ErrorPrefix 'Step' } | + Should -Throw -ExpectedMessage "${Expected}:*" + } + } + + It 'reports the full path of nested keys and array items' { + InModuleScope 'IdLE.Core' { + $schema = @{ + Type = 'Hashtable' + Keys = @{ + Entitlement = @{ Type = 'Hashtable'; Keys = @{ Kind = @{ Type = 'String'; Required = $true } } } + Keep = @{ Type = 'Array'; Items = @{ Type = 'Hashtable'; Keys = @{ Id = @{ Type = 'String'; Required = $true } } } } + } + } + + { ConvertTo-IdleSchemaValue -Schema $schema -Value @{ Entitlement = @{} } -Path 'With' -ErrorPrefix 'Step' } | + Should -Throw -ExpectedMessage '*With.Entitlement.Kind is required*' + { ConvertTo-IdleSchemaValue -Schema $schema -Value @{ Keep = @(@{ Id = 'a' }, @{}) } -Path 'With' -ErrorPrefix 'Step' } | + Should -Throw -ExpectedMessage '*With.Keep`[1`].Id is required*' + } + } + + It 'applies defaults, normalizes allowed values and treats optional nulls as absent' { + InModuleScope 'IdLE.Core' { + $schema = @{ + Type = 'Hashtable' + Keys = @{ + State = @{ Type = 'String'; AllowedValues = @('Present', 'Absent'); Default = 'Present' } + Wait = @{ Type = 'Bool'; Default = $false } + Mode = @{ Type = 'String'; AllowedValues = @('Present', 'Absent') } + Comment = @{ Type = 'String' } + } + } + + $result = ConvertTo-IdleSchemaValue -Schema $schema -Value @{ Mode = 'absent'; Comment = $null } -Path 'With' -ErrorPrefix 'Step' + + $result.State | Should -Be 'Present' + $result.Wait | Should -BeFalse + $result.Mode | Should -BeExactly 'Absent' + $result.ContainsKey('Comment') | Should -BeFalse + } + } + + It 'keeps a single-element array an array through planning (regression)' { + $run = Invoke-ContractWorkflow -Handler 'Invoke-IdleContractTestHandler' -With "@{ Items = @('only') }" -Metadata @{ + RequiredCapabilities = @() + WithSchema = @{ Keys = @{ Items = @{ Type = 'Array'; Items = @{ Type = 'String' } } } } + } + + $run.Result.Status | Should -Be 'Completed' + , $run.Plan.Steps[0].With.Items | Should -BeOfType ([object[]]) + @($run.Plan.Steps[0].With.Items).Count | Should -Be 1 + } + } + + Context 'Catalog contract (Assert-IdleStepMetadataContract)' { + It ' -> ' -ForEach @( + @{ Case = 'legacy RequiredKeys/OptionalKeys'; Metadata = @{ WithSchema = @{ RequiredKeys = @(); OptionalKeys = @() } }; Expected = 'LegacyWithSchema' } + @{ Case = 'missing WithSchema'; Metadata = @{}; Expected = 'InvalidWithSchema' } + @{ Case = 'missing Keys'; Metadata = @{ WithSchema = @{} }; Expected = 'InvalidWithSchema' } + @{ Case = 'unknown type'; Metadata = @{ WithSchema = @{ Keys = @{ A = @{ Type = 'Guid' } } } }; Expected = 'InvalidWithSchema' } + @{ Case = 'unknown key schema property'; Metadata = @{ WithSchema = @{ Keys = @{ A = @{ Type = 'String'; Min = 1 } } } }; Expected = 'InvalidWithSchema' } + @{ Case = 'Required and Default'; Metadata = @{ WithSchema = @{ Keys = @{ A = @{ Type = 'String'; Required = $true; Default = 'x' } } } }; Expected = 'InvalidWithSchema' } + @{ Case = 'Default not matching Type'; Metadata = @{ WithSchema = @{ Keys = @{ A = @{ Type = 'Int'; Default = 'x' } } } }; Expected = 'InvalidWithSchema' } + @{ Case = 'AllowedValues on Bool'; Metadata = @{ WithSchema = @{ Keys = @{ A = @{ Type = 'Bool'; AllowedValues = @($true) } } } }; Expected = 'InvalidWithSchema' } + @{ Case = 'common key declared in Keys'; Metadata = @{ WithSchema = @{ Keys = @{ Provider = @{ Type = 'String' } } } }; Expected = 'InvalidWithSchema' } + @{ Case = 'invalid Provider entry'; Metadata = @{ Provider = @{ Alias = 'x' }; WithSchema = @{ Keys = @{} } }; Expected = 'InvalidWithSchema' } + ) { + InModuleScope 'IdLE.Core' -Parameters $_ { + param($Metadata, $Expected) + { Assert-IdleStepMetadataContract -Metadata $Metadata -StepType 'Test.IdLE.Step.X' -SourceName 'Test' } | + Should -Throw -ExpectedMessage "${Expected}:*" + } + } + + It 'accepts all first-party catalogs' { + InModuleScope 'IdLE.Core' { + $catalog = Resolve-IdleStepMetadataCatalog -Providers $null + $catalog.Count | Should -BeGreaterThan 10 + } + } + } + + Context 'Common keys and provider resolution' { + It 'rejects common keys for steps without Provider, even with AllowAdditionalKeys' { + { Invoke-ContractWorkflow -Handler 'Invoke-IdleContractTestHandler' -With "@{ Provider = 'Echo' }" -Metadata @{ + RequiredCapabilities = @() + WithSchema = @{ Keys = @{}; AllowAdditionalKeys = $true } + } } | Should -Throw -ExpectedMessage '*WithKeyUnknown*With.Provider*' + } + + It 'applies the default provider alias in the plan and hides common keys from the handler' { + $run = Invoke-ContractWorkflow -With "@{ Value = 'v' }" -ExtraProviders @{ Echo = (New-ContractEchoProvider) } + + $run.Plan.Steps[0].With.Provider | Should -Be 'Echo' + $run.Result.Status | Should -Be 'Completed' + $global:IdleContractTestLastWith.ContainsKey('Provider') | Should -BeFalse + } + + It 'fails the step with ProviderNotSupplied when the alias is missing' { + $run = Invoke-ContractWorkflow -With "@{ Value = 'v'; Provider = 'Missing' }" -ExtraProviders @{ Echo = (New-ContractEchoProvider) } + + $run.Result.Status | Should -Be 'Failed' + $run.Result.Steps[0].Error | Should -BeLike 'ProviderNotSupplied:*Missing*' + } + + It 'names the auth session after the alias when the step declares AuthSessionNameFromAlias' { + InModuleScope 'IdLE.Core' { + $metadata = @{ Provider = @{ DefaultAlias = 'ExchangeOnline'; AuthSessionNameFromAlias = $true }; WithSchema = @{ Keys = @{} } } + + (ConvertTo-IdleStepWith -Metadata $metadata -With @{} -StepName 'S' -StepType 'T').AuthSessionName | Should -Be 'ExchangeOnline' + (ConvertTo-IdleStepWith -Metadata $metadata -With @{ Provider = 'Exo2' } -StepName 'S' -StepType 'T').AuthSessionName | Should -Be 'Exo2' + (ConvertTo-IdleStepWith -Metadata $metadata -With @{ AuthSessionName = 'Admin' } -StepName 'S' -StepType 'T').AuthSessionName | Should -Be 'Admin' + } + } + } + + Context 'Handler signature' { + It ' -> ' -ForEach @( + @{ Handler = 'Invoke-IdleContractTestLegacyHandler'; Expected = 'LegacyStepHandler' } + @{ Handler = 'Invoke-IdleContractTestNoContextHandler'; Expected = 'InvalidStepHandlerSignature' } + @{ Handler = 'Invoke-IdleContractTestHandler'; Expected = 'InvalidStepHandlerSignature' } + ) { + # The step declares a Provider, so Invoke-IdleContractTestHandler lacks -Provider. + $run = Invoke-ContractWorkflow -Handler $Handler -With "@{ Value = 'v' }" -ExtraProviders @{ Echo = (New-ContractEchoProvider) } + + $run.Result.Status | Should -Be 'Failed' + $run.Result.Steps[0].Error | Should -BeLike "${Expected}:*" + } + + It 'rejects -Provider on a handler whose step declares no Provider' { + $run = Invoke-ContractWorkflow -With "@{ Value = 'v' }" -Metadata @{ + RequiredCapabilities = @() + WithSchema = @{ Keys = @{ Value = @{ Type = 'String' } } } + } + + $run.Result.Steps[0].Error | Should -BeLike 'InvalidStepHandlerSignature:*declares -Provider*' + } + } + + Context 'Handler result' { + It 'builds IdLE.StepResult from the handler hashtable' { + $run = Invoke-ContractWorkflow -With "@{ Value = 'v' }" -ExtraProviders @{ Echo = (New-ContractEchoProvider) } + + $step = $run.Result.Steps[0] + $step.PSTypeNames | Should -Contain 'IdLE.StepResult' + $step.Changed | Should -BeTrue + $step.Data.Echo | Should -Be 'v|session-' + $step.Attempts | Should -Be 1 + } + + It ' -> InvalidStepHandlerResult' -ForEach @( + @{ Case = 'unknown key'; Output = "@{ Status = 'Failed' }" } + @{ Case = 'non-bool Changed'; Output = "@{ Changed = 'yes' }" } + @{ Case = 'non-hashtable Data'; Output = "@{ Data = 'x' }" } + @{ Case = 'non-hashtable result'; Output = "'done'" } + ) { + $run = Invoke-ContractWorkflow -Handler 'Invoke-IdleContractTestHandler' -With "@{ Output = $Output }" -Metadata @{ + RequiredCapabilities = @() + WithSchema = @{ Keys = @{ Output = @{ Type = 'Any' } } } + } + + $run.Result.Steps[0].Status | Should -Be 'Failed' + $run.Result.Steps[0].Error | Should -BeLike 'InvalidStepHandlerResult:*' + } + + It 'rejects handlers that leak pipeline output' { + $run = Invoke-ContractWorkflow -Handler 'Invoke-IdleContractTestOutputHandler' -With '@{}' -Metadata @{ + RequiredCapabilities = @() + WithSchema = @{ Keys = @{} } + } + + $run.Result.Steps[0].Error | Should -BeLike 'InvalidStepHandlerResult:*wrote 2 objects*' + } + } + + Context 'Provider handle (New-IdleProviderHandle)' { + BeforeEach { + $script:EchoProvider = New-ContractEchoProvider -WithEventSink + $script:Broker = New-ContractBroker + $script:Context = [pscustomobject]@{ + Providers = @{ Echo = $script:EchoProvider } + EventSink = New-IdleTestEventSink + } + $broker = $script:Broker + $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return $broker.AcquireAuthSession($Name, $Options) + }.GetNewClosure() + } + + It 'passes a single argument plus the auth session intact (regression)' { + InModuleScope 'IdLE.Core' -Parameters @{ Context = $script:Context } { + param($Context) + $handle = New-IdleProviderHandle -Context $Context -Alias 'Echo' -AuthSessionName 'Admin' + $handle.Invoke('Echo', @('user@contoso.com')) | Should -Be 'user@contoso.com|session-Admin' + } + } + + It 'inserts the auth session at its declared position' { + InModuleScope 'IdLE.Core' -Parameters @{ Context = $script:Context } { + param($Context) + $handle = New-IdleProviderHandle -Context $Context -Alias 'Echo' -AuthSessionName 'Admin' + $handle.Invoke('ListWithKind', @('user1', 'Group')) | Should -Be 'user1|session-Admin|Group' + } + } + + It 'acquires the auth session once per handle' { + InModuleScope 'IdLE.Core' -Parameters @{ Context = $script:Context } { + param($Context) + $handle = New-IdleProviderHandle -Context $Context -Alias 'Echo' -AuthSessionName 'Admin' + $null = $handle.Invoke('Echo', @('a')) + $null = $handle.Invoke('Echo', @('b')) + } + + $script:Broker.Calls | Should -Be 1 + } + + It 'does not acquire a session for methods without AuthSession when no AuthSessionName is set' { + InModuleScope 'IdLE.Core' -Parameters @{ Context = $script:Context } { + param($Context) + $handle = New-IdleProviderHandle -Context $Context -Alias 'Echo' + $handle.Invoke('NoAuth', @('a')) | Should -Be 'a' + } + + $script:Broker.Calls | Should -Be 0 + } + + It 'reports methods and parameters and rejects unknown methods' { + InModuleScope 'IdLE.Core' -Parameters @{ Context = $script:Context } { + param($Context) + $handle = New-IdleProviderHandle -Context $Context -Alias 'Echo' + + $handle.Alias | Should -Be 'Echo' + $handle.HasMethod('Echo') | Should -BeTrue + $handle.HasMethod('Missing') | Should -BeFalse + $handle.HasParameter('ListWithKind', 'Kind') | Should -BeTrue + $handle.HasParameter('Echo', 'Kind') | Should -BeFalse + { $handle.Invoke('Missing', @()) } | Should -Throw -ExpectedMessage '*ProviderMethodNotImplemented*Missing*' + } + } + + It 'wires the run event sink into providers that expose EventSink' { + InModuleScope 'IdLE.Core' -Parameters @{ Context = $script:Context } { + param($Context) + $null = New-IdleProviderHandle -Context $Context -Alias 'Echo' + } + + $script:EchoProvider.EventSink | Should -Be $script:Context.EventSink + } + + It 'throws ProviderNotSupplied for unknown aliases' { + InModuleScope 'IdLE.Core' -Parameters @{ Context = $script:Context } { + param($Context) + { New-IdleProviderHandle -Context $Context -Alias 'Nope' } | Should -Throw -ExpectedMessage 'ProviderNotSupplied:*' + } + } + } +} diff --git a/tests/Core/StepPackHandlerRegistration.Tests.ps1 b/tests/Core/StepPackHandlerRegistration.Tests.ps1 index 3c1a5cf2..fe287d3f 100644 --- a/tests/Core/StepPackHandlerRegistration.Tests.ps1 +++ b/tests/Core/StepPackHandlerRegistration.Tests.ps1 @@ -36,18 +36,13 @@ function Invoke-ContosoStepHello { [CmdletBinding()] param( [Parameter(Mandatory)] [object] $Context, + [Parameter(Mandatory)] [hashtable] $With, [Parameter(Mandatory)] [object] $Step ) $Context.EventSink.WriteEvent('Test.IdLE.StepPack.Hello', 'Hello from step pack', $Step.Name, @{}) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } function Get-IdleStepMetadataCatalog { @@ -87,8 +82,7 @@ Export-ModuleMember -Function 'Invoke-ContosoStepHello', 'Get-IdleStepMetadataCa Handler = $HandlerLiteral RequiredCapabilities = @() WithSchema = @{ - RequiredKeys = @() - OptionalKeys = @() + Keys = @{} } } } @@ -188,7 +182,7 @@ Describe 'Step pack handler registration (StepMetadataCatalog Handler)' { 'Test.IdLE.Step.Hello' = @{ Handler = 'Invoke-HostHello' RequiredCapabilities = @() - WithSchema = @{ RequiredKeys = @(); OptionalKeys = @() } + WithSchema = @{ Keys = @{} } } } } @@ -227,7 +221,7 @@ Describe 'Step pack handler registration (StepMetadataCatalog Handler)' { InModuleScope 'IdLE.Core' -Parameters @{ StepType = $script:StepType } { param($StepType) - $providers = @{ StepMetadata = @{ $StepType = @{ WithSchema = @{ RequiredKeys = @(); OptionalKeys = @() } } } } + $providers = @{ StepMetadata = @{ $StepType = @{ WithSchema = @{ Keys = @{} } } } } { Resolve-IdleStepMetadataCatalog -Providers $providers } | Should -Throw "*DuplicateStepTypeMetadata*Contoso.IdLE.Steps.Hr*" } } @@ -238,12 +232,12 @@ Describe 'Step pack handler registration (StepMetadataCatalog Handler)' { '$($script:StepType)' = @{ Handler = 'Invoke-ContosoStepHello' RequiredCapabilities = @('IdLE.Identity.Read', 'Contoso.IdLE.Hr.Manager.Read') - WithSchema = @{ RequiredKeys = @(); OptionalKeys = @() } + WithSchema = @{ Keys = @{} } } 'Contoso.IdLE.Step.Hr.Unknown' = @{ Handler = 'Invoke-ContosoStepHello' RequiredCapabilities = @('IdLE.Hr.Manager.Read') - WithSchema = @{ RequiredKeys = @(); OptionalKeys = @() } + WithSchema = @{ Keys = @{} } } } "@ diff --git a/tests/Packaging/ModuleSurface.Tests.ps1 b/tests/Packaging/ModuleSurface.Tests.ps1 index ca469ec0..744a183a 100644 --- a/tests/Packaging/ModuleSurface.Tests.ps1 +++ b/tests/Packaging/ModuleSurface.Tests.ps1 @@ -20,18 +20,15 @@ BeforeAll { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step ) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + return $null } } diff --git a/tests/Steps/_testHelpers.Steps.ps1 b/tests/Steps/_testHelpers.Steps.ps1 index 4bde7b4f..3660e243 100644 --- a/tests/Steps/_testHelpers.Steps.ps1 +++ b/tests/Steps/_testHelpers.Steps.ps1 @@ -19,8 +19,8 @@ function New-IdleTestStepMetadata { .DESCRIPTION Helper function to create StepMetadata entries for test-specific step types. - By default, creates metadata with no required capabilities and a permissive WithSchema - that accepts any With key (OptionalKeys = @('*')). This allows test workflows to use + By default, creates metadata with no required capabilities, no Provider and a permissive WithSchema + that accepts any With key (@{ Keys = @{}; AllowAdditionalKeys = $true }). This allows test workflows to use arbitrary With.* keys without schema validation failures. .PARAMETER StepTypes @@ -31,7 +31,11 @@ function New-IdleTestStepMetadata { .PARAMETER WithSchemas Hashtable mapping step types to their WithSchema definitions. Step types not in this - hashtable receive the default permissive schema: @{ RequiredKeys = @(); OptionalKeys = @('*') }. + hashtable receive the default permissive schema: @{ Keys = @{}; AllowAdditionalKeys = $true }. + + .PARAMETER Providers + Hashtable mapping step types to their default provider alias. Listed step types declare + Provider = @{ DefaultAlias = '' }, so their handlers receive -Provider. .EXAMPLE $metadata = New-IdleTestStepMetadata -StepTypes @('Test.IdLE.Step.ResolveIdentity', 'Test.IdLE.Step.Primary') @@ -43,7 +47,7 @@ function New-IdleTestStepMetadata { .EXAMPLE $metadata = New-IdleTestStepMetadata -StepTypes @('Test.IdLE.Step.Strict') -WithSchemas @{ - 'Test.IdLE.Step.Strict' = @{ RequiredKeys = @('IdentityKey'); OptionalKeys = @('Provider') } + 'Test.IdLE.Step.Strict' = @{ Keys = @{ IdentityKey = @{ Type = 'String'; Required = $true } } } } #> [CmdletBinding()] @@ -55,7 +59,10 @@ function New-IdleTestStepMetadata { [hashtable] $RequiredCapabilities = @{}, [Parameter()] - [hashtable] $WithSchemas = @{} + [hashtable] $WithSchemas = @{}, + + [Parameter()] + [hashtable] $Providers = @{} ) $metadata = @{} @@ -72,14 +79,113 @@ function New-IdleTestStepMetadata { } else { # Default: permissive schema that accepts any With key - @{ RequiredKeys = @(); OptionalKeys = @('*') } + @{ Keys = @{}; AllowAdditionalKeys = $true } } $metadata[$stepType] = @{ RequiredCapabilities = $caps WithSchema = $schema } + + if ($Providers.ContainsKey($stepType)) { + $metadata[$stepType]['Provider'] = @{ DefaultAlias = [string]$Providers[$stepType] } + } } return $metadata } + +function Invoke-IdleTestStepHandler { + <# + .SYNOPSIS + Invokes a step handler the way the engine does. + + .DESCRIPTION + Validates Step.With against the step metadata catalog (plan-time contract: types, defaults, Provider + alias), builds the handler arguments (-Context, -With, -Provider handle, -Step) and converts the handler + output into an IdLE.StepResult. Use it in step unit tests instead of calling the handler directly. + + .PARAMETER Context + Execution context (Providers, EventSink, optional AcquireAuthSession). + + .PARAMETER Step + Step definition with Name, Type and With. + + .PARAMETER StepMetadata + Optional metadata catalog for host-defined step types (Providers.StepMetadata shape). + + .PARAMETER StepRegistry + Optional handler registry for host-defined step types (Providers.StepRegistry shape). + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step, + + [Parameter()] + [hashtable] $StepMetadata, + + [Parameter()] + [hashtable] $StepRegistry + ) + + $core = Get-Module -Name 'IdLE.Core' -All | Select-Object -First 1 + & $core { + param($Context, $Step, $StepMetadata, $StepRegistry) + + # Workflow inputs are data-only; the engine rejects ScriptBlocks before planning. + $stepWith = Get-IdlePropertyValue -Object $Step -Name 'With' + Assert-IdleNoScriptBlock -InputObject $stepWith -Path 'With' + + $hostProviders = @{} + if ($null -ne $StepMetadata) { $hostProviders.StepMetadata = $StepMetadata } + if ($null -ne $StepRegistry) { $hostProviders.StepRegistry = $StepRegistry } + + $stepName = [string]$Step.Name + $stepType = [string]$Step.Type + $catalog = Resolve-IdleStepMetadataCatalog -Providers $hostProviders + $with = ConvertTo-IdleStepWith -Metadata $catalog[$stepType] -With (Copy-IdleDataObject -Value $stepWith) -StepName $stepName -StepType $stepType + + $planStep = [pscustomobject]@{ Name = $stepName; Type = $stepType; With = $with } + $handler = Resolve-IdleStepHandler -StepType $stepType -StepRegistry (Get-IdleStepRegistry -Providers $hostProviders) + $handlerArguments = New-IdleStepHandlerArguments -Handler $handler -Step $planStep -Context $Context + $output = & $handler @handlerArguments + + ConvertTo-IdleStepResult -Output $output -StepName $stepName -StepType $stepType -Attempts 1 -Source $null + } $Context $Step $StepMetadata $StepRegistry +} + +function New-IdleTestEventSink { + <# + .SYNOPSIS + Creates an event sink for step unit tests. + + .DESCRIPTION + Provides the engine's event contract WriteEvent(Type, Message, StepName, Data[, Level]) and records + every event in the Events list. + + .OUTPUTS + PSCustomObject with Events (List[object]) and a WriteEvent script method. + #> + [CmdletBinding()] + param() + + $sink = [pscustomobject]@{ + Events = [System.Collections.Generic.List[object]]::new() + } + + $null = Add-Member -InputObject $sink -MemberType ScriptMethod -Name WriteEvent -Value { + param($Type, $Message, $StepName, $Data, $Level) + $this.Events.Add([pscustomobject]@{ Type = $Type; Message = $Message; StepName = $StepName; Data = $Data; Level = $Level }) + } + + return $sink +} diff --git a/tests/fixtures/workflows/resolver-current-precondition.psd1 b/tests/fixtures/workflows/resolver-current-precondition.psd1 index 9464e5a9..f26c2681 100644 --- a/tests/fixtures/workflows/resolver-current-precondition.psd1 +++ b/tests/fixtures/workflows/resolver-current-precondition.psd1 @@ -13,9 +13,10 @@ Steps = @( @{ Name = 'CurrentPreconditionStep' - Type = 'IdLE.Step.EmitEvent' + Type = 'IdLE.Step.DisableIdentity' + # No explicit With.Provider: Current resolves via the step's default provider alias ('Identity'). With = @{ - Provider = 'Identity' + IdentityKey = 'user1' } # Precondition uses Current alias: resolves to Providers.Identity.Default at execution time Precondition = @{ Exists = 'Request.Context.Current.Identity.Entitlements' } From f438d78208f7567921e6fc2e908f3b914f80b7d5 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:53:42 +0100 Subject: [PATCH 3/6] steps: migrate step packs to the new step contract (#324) - Catalogs of IdLE.Steps.Common, DirectorySync and Mailbox use Provider and typed WithSchema (defaults for EmitEvent Level, DirectorySync wait options, EnsureEntitlement State) - Handlers contain only domain logic and return @{ Changed; Data; State } - PruneEntitlements and PruneEntitlementsEnsureKeep share a private operation; Skipped moved to Data.Skipped, EnsureAttributes results to Data.Attributes - MailboxGetInfo returns its mailbox under State - Examples use templates instead of the unsupported ValueFrom syntax - Step tests run handlers through the engine-like Invoke-IdleTestStepHandler Co-Authored-By: Claude Opus 5.5 --- .../templates/entraid-exo-leaver.psd1 | 12 +- .../Invoke-IdlePruneEntitlementsOperation.ps1 | 192 +++++++++ .../Public/Invoke-IdleStepCreateIdentity.ps1 | 79 +--- .../Public/Invoke-IdleStepDeleteIdentity.ps1 | 73 +--- .../Public/Invoke-IdleStepDisableIdentity.ps1 | 73 +--- .../Public/Invoke-IdleStepEmitEvent.ps1 | 51 +-- .../Public/Invoke-IdleStepEnableIdentity.ps1 | 73 +--- .../Invoke-IdleStepEnsureAttributes.ps1 | 159 ++------ .../Invoke-IdleStepEnsureEntitlement.ps1 | 221 ++--------- .../Public/Invoke-IdleStepMoveIdentity.ps1 | 75 +--- .../Invoke-IdleStepPruneEntitlements.ps1 | 369 +----------------- ...ke-IdleStepPruneEntitlementsEnsureKeep.ps1 | 65 +-- .../Invoke-IdleStepRevokeIdentitySessions.ps1 | 73 +--- .../StepMetadataCatalog.psd1 | 137 +++++-- .../Invoke-IdleStepTriggerDirectorySync.ps1 | 97 ++--- .../StepMetadataCatalog.psd1 | 18 +- .../Public/Invoke-IdleStepMailboxGetInfo.ps1 | 78 +--- ...nvoke-IdleStepMailboxOutOfOfficeEnsure.ps1 | 120 +----- ...nvoke-IdleStepMailboxPermissionsEnsure.ps1 | 121 +----- .../Invoke-IdleStepMailboxTypeEnsure.ps1 | 85 +--- src/IdLE.Steps.Mailbox/README.md | 2 +- .../StepMetadataCatalog.psd1 | 69 +++- tests/Providers/ADIdentityProvider.Tests.ps1 | 19 +- .../Invoke-IdleStepAuthSession.Tests.ps1 | 16 +- .../Invoke-IdleStepEnsureAttributes.Tests.ps1 | 50 +-- ...Invoke-IdleStepEnsureEntitlement.Tests.ps1 | 12 +- .../Invoke-IdleStepMailboxGetInfo.Tests.ps1 | 10 +- ...IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 | 42 +- ...IdleStepMailboxPermissionsEnsure.Tests.ps1 | 30 +- ...Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 | 20 +- ...Invoke-IdleStepPruneEntitlements.Tests.ps1 | 112 +++--- ...e-IdleStepRevokeIdentitySessions.Tests.ps1 | 20 +- ...oke-IdleStepTriggerDirectorySync.Tests.ps1 | 77 ++-- 33 files changed, 856 insertions(+), 1794 deletions(-) create mode 100644 src/IdLE.Steps.Common/Private/Invoke-IdlePruneEntitlementsOperation.ps1 diff --git a/examples/workflows/templates/entraid-exo-leaver.psd1 b/examples/workflows/templates/entraid-exo-leaver.psd1 index c70221de..66fe35f4 100644 --- a/examples/workflows/templates/entraid-exo-leaver.psd1 +++ b/examples/workflows/templates/entraid-exo-leaver.psd1 @@ -8,7 +8,7 @@ Type = 'IdLE.Step.Mailbox.GetInfo' With = @{ Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } + IdentityKey = '{{Request.Intent.UserPrincipalName}}' } } @{ @@ -16,7 +16,7 @@ Type = 'IdLE.Step.Mailbox.EnsureType' With = @{ Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } + IdentityKey = '{{Request.Intent.UserPrincipalName}}' MailboxType = 'Shared' } } @@ -25,7 +25,7 @@ Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Config = @{ Mode = 'Enabled' InternalMessage = 'This person is no longer with the organization. For assistance, please contact their manager or the main office.' @@ -41,7 +41,7 @@ Provider = 'Identity' AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = @{ ValueFrom = 'Request.Intent.UserObjectId' } + IdentityKey = '{{Request.Intent.UserObjectId}}' Desired = @() } } @@ -52,7 +52,7 @@ Provider = 'Identity' AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = @{ ValueFrom = 'Request.Intent.UserObjectId' } + IdentityKey = '{{Request.Intent.UserObjectId}}' Attributes = @{ Manager = $null } @@ -65,7 +65,7 @@ Provider = 'Identity' AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = @{ ValueFrom = 'Request.Intent.UserObjectId' } + IdentityKey = '{{Request.Intent.UserObjectId}}' } } @{ diff --git a/src/IdLE.Steps.Common/Private/Invoke-IdlePruneEntitlementsOperation.ps1 b/src/IdLE.Steps.Common/Private/Invoke-IdlePruneEntitlementsOperation.ps1 new file mode 100644 index 00000000..f4851ef3 --- /dev/null +++ b/src/IdLE.Steps.Common/Private/Invoke-IdlePruneEntitlementsOperation.ps1 @@ -0,0 +1,192 @@ +function Invoke-IdlePruneEntitlementsOperation { + # Shared implementation of IdLE.Step.PruneEntitlements and IdLE.Step.PruneEntitlementsEnsureKeep. + # -EnsureKeep additionally grants explicit Keep entries that are missing. + [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Context', Justification = 'Used by the nested Write-IdlePruneEvent function.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'StepName', Justification = 'Used by the nested Write-IdlePruneEvent function.')] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $With, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Provider, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $StepName, + + [Parameter()] + [switch] $EnsureKeep + ) + + function Write-IdlePruneEvent { + param([string] $Type, [string] $Message, [hashtable] $Data, [string] $Level = 'Information') + $Context.EventSink.WriteEvent($Type, $Message, $StepName, $Data, $Level) + } + + $identityKey = [string]$With.IdentityKey + $kind = [string]$With.Kind + + # Checked up front: the per-item revoke path below treats revoke failures as skipped entitlements, + # which must not hide a provider that cannot revoke at all. + $requiredMethods = @('ListEntitlements', 'RevokeEntitlement') + if ($EnsureKeep) { $requiredMethods += 'GrantEntitlement' } + foreach ($method in $requiredMethods) { + if (-not $Provider.HasMethod($method)) { + throw "Provider '$($Provider.Alias)' must implement method '$method' for PruneEntitlements." + } + } + + $keepItems = @() + if ($With.ContainsKey('Keep')) { + foreach ($item in @($With.Keep)) { + $keepItems += ConvertTo-IdlePruneEntitlement -Value $item -DefaultKind $kind + } + } + + $keepPatterns = if ($With.ContainsKey('KeepPattern')) { @($With.KeepPattern) } else { @() } + + # (No guardrail: empty keep-set is valid - prune everything of the given Kind) + + # Normalize Keep IDs to canonical form via ResolveEntitlement (when available) so that they compare + # correctly with the canonical IDs returned by ListEntitlements. Each provider handles its own + # ID-type detection (e.g., GUID/DN/sAMAccountName for AD; objectId/displayName for Entra ID). + if ($keepItems.Count -gt 0 -and $Provider.HasMethod('ResolveEntitlement')) { + $keepItems = @($keepItems | ForEach-Object { $Provider.Invoke('ResolveEntitlement', @($kind, $_)) }) + } + + # 1. List current entitlements, filter by Kind. + # Pass Kind when supported so the provider can skip unrelated directory objects + # (e.g. avoid AU Graph calls for Group-only prunes). + $listArguments = if ($Provider.HasParameter('ListEntitlements', 'Kind')) { @($identityKey, $kind) } else { @($identityKey) } + $allCurrent = @($Provider.Invoke('ListEntitlements', $listArguments)) + + $current = @( + $allCurrent | Where-Object { $null -ne $_ -and + ($_.PSObject.Properties.Name -contains 'Kind') -and + [string]::Equals([string]$_.Kind, $kind, [System.StringComparison]::OrdinalIgnoreCase) + } + ) + + # 2. Compute keep-set and remove-set + $toKeep = @() + $toRemove = @() + + foreach ($ent in $current) { + if (Test-IdlePruneEntitlementShouldKeep -Ent $ent -KeepItems $keepItems -KeepPatterns $keepPatterns) { + $toKeep += $ent + } + else { + $toRemove += $ent + } + } + + Write-IdlePruneEvent -Type 'IdLE.Step.PruneEntitlements.Planned' ` + -Message "PruneEntitlements: plan - keep=$(@($toKeep).Count), remove=$(@($toRemove).Count)" ` + -Data @{ Kind = $kind; KeepCount = @($toKeep).Count; PruneCount = @($toRemove).Count } + + $changed = $false + $skippedItems = @() + + # 3. Revoke each entitlement in remove-set + if ($Provider.HasMethod('BulkRevokeEntitlements') -and $toRemove.Count -gt 0) { + # Bulk path: provider batches operations and returns per-item results with distinct status + $bulkResults = @($Provider.Invoke('BulkRevokeEntitlements', @($identityKey, $toRemove))) + + foreach ($br in $bulkResults) { + $entitlementId = [string]$br.Entitlement.Id + if ($br.Error) { + $skippedItems += [pscustomobject]@{ EntitlementId = $entitlementId; Reason = [string]$br.Error } + Write-IdlePruneEvent -Type 'IdLE.Step.PruneEntitlements.EntitlementSkipped' ` + -Message "PruneEntitlements: skipped non-removable entitlement '$entitlementId': $($br.Error)" ` + -Data @{ Kind = $kind; EntitlementId = $entitlementId; Reason = [string]$br.Error } -Level 'Warning' + } + else { + if ($br.Changed) { $changed = $true } + Write-IdlePruneEvent -Type 'IdLE.Step.PruneEntitlements.EntitlementRevoked' ` + -Message "PruneEntitlements: revoked entitlement '$entitlementId'" ` + -Data @{ Kind = $kind; EntitlementId = $entitlementId } + } + } + } + else { + # Per-item path: each revoke is attempted independently + foreach ($ent in $toRemove) { + $entitlementId = [string]$ent.Id + try { + $revokeResult = $Provider.Invoke('RevokeEntitlement', @($identityKey, $ent)) + if ($revokeResult -and $revokeResult.Changed) { + $changed = $true + } + + Write-IdlePruneEvent -Type 'IdLE.Step.PruneEntitlements.EntitlementRevoked' ` + -Message "PruneEntitlements: revoked entitlement '$entitlementId'" ` + -Data @{ Kind = $kind; EntitlementId = $entitlementId } + } + catch { + # Non-removable or permission-denied entitlement: skip with warning + $reason = $_.Exception.Message + $skippedItems += [pscustomobject]@{ EntitlementId = $entitlementId; Reason = $reason } + Write-IdlePruneEvent -Type 'IdLE.Step.PruneEntitlements.EntitlementSkipped' ` + -Message "PruneEntitlements: skipped non-removable entitlement '$entitlementId': $reason" ` + -Data @{ Kind = $kind; EntitlementId = $entitlementId; Reason = $reason } -Level 'Warning' + } + } + } + + # 4. EnsureKeep: grant any explicit Keep items that are missing + if ($EnsureKeep -and $keepItems.Count -gt 0) { + $toEnsure = @($keepItems | Where-Object { $k = $_ + @($current | Where-Object { + [string]::Equals([string]$_.Id, [string]$k.Id, [System.StringComparison]::OrdinalIgnoreCase) + } + ).Count -eq 0 + } + ) + + if ($Provider.HasMethod('BulkGrantEntitlements') -and $toEnsure.Count -gt 0) { + $bulkResults = @($Provider.Invoke('BulkGrantEntitlements', @($identityKey, $toEnsure))) + + foreach ($br in $bulkResults) { + $entitlementId = [string]$br.Entitlement.Id + if ($br.Error) { + $skippedItems += [pscustomobject]@{ EntitlementId = $entitlementId; Reason = [string]$br.Error } + Write-IdlePruneEvent -Type 'IdLE.Step.PruneEntitlements.KeepGrantFailed' ` + -Message "PruneEntitlements: failed to grant keep entitlement '$entitlementId': $($br.Error)" ` + -Data @{ Kind = $kind; EntitlementId = $entitlementId; Reason = [string]$br.Error } -Level 'Warning' + } + else { + if ($br.Changed) { $changed = $true } + Write-IdlePruneEvent -Type 'IdLE.Step.PruneEntitlements.KeepGranted' ` + -Message "PruneEntitlements: granted keep entitlement '$entitlementId'" ` + -Data @{ Kind = $kind; EntitlementId = $entitlementId } + } + } + } + else { + foreach ($k in $toEnsure) { + $result = $Provider.Invoke('GrantEntitlement', @($identityKey, $k)) + + # Providers without a result object are assumed to have changed the assignment. + if ($null -eq $result -or $result.PSObject.Properties.Name -notcontains 'Changed' -or $result.Changed) { + $changed = $true + } + + Write-IdlePruneEvent -Type 'IdLE.Step.PruneEntitlements.KeepGranted' ` + -Message "PruneEntitlements: granted keep entitlement '$($k.Id)'" ` + -Data @{ Kind = $kind; EntitlementId = [string]$k.Id } + } + } + } + + return @{ + Changed = $changed + Data = @{ Skipped = $skippedItems } + } +} diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepCreateIdentity.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepCreateIdentity.ps1 index 8460eab8..eea5c4c7 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepCreateIdentity.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepCreateIdentity.ps1 @@ -4,89 +4,40 @@ function Invoke-IdleStepCreateIdentity { Creates a new identity in the target system. .DESCRIPTION - This is a provider-agnostic step. The host must supply a provider instance via - Context.Providers[] that implements CreateIdentity(identityKey, attributes) + This is a provider-agnostic step. The engine passes the provider resolved from With.Provider (default 'Identity') as -Provider. + The provider implements CreateIdentity(identityKey, attributes) and returns an object with properties 'IdentityKey' and 'Changed'. The step is idempotent by design: if the identity already exists, the provider should return Changed = $false without creating a duplicate. - Authentication: - - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method - if the provider supports an AuthSession parameter. - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @{ Role = 'Tier0' }). - - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + Authentication is handled by the engine: With.AuthSessionName and With.AuthSessionOptions select the + auth session passed to provider methods that declare an AuthSession parameter. - .PARAMETER Context - Execution context created by IdLE.Core. - - .PARAMETER Step - Normalized step object from the plan. Must contain a 'With' hashtable with keys: + .PARAMETER With + Validated With hashtable (see the step metadata catalog): - IdentityKey (required): the identity identifier - Attributes (required): hashtable of attributes to set on the new identity - - Provider (optional): provider alias, defaults to 'Identity' + + .PARAMETER Provider + Provider handle created by the engine (see docs/extend/steps.md). .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) + System.Collections.Hashtable (Changed) #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Context, + [hashtable] $With, [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Step + [object] $Provider ) - $with = $Step.With - if ($null -eq $with -or -not ($with -is [hashtable])) { - throw "CreateIdentity requires 'With' to be a hashtable." - } - - foreach ($key in @('IdentityKey', 'Attributes')) { - if (-not $with.ContainsKey($key)) { - throw "CreateIdentity requires With.$key." - } - } - - if (-not ($with.Attributes -is [hashtable])) { - throw "CreateIdentity requires With.Attributes to be a hashtable." - } - - $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." - } - - $result = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $with ` - -ProviderAlias $providerAlias ` - -MethodName 'CreateIdentity' ` - -MethodArguments @([string]$with.IdentityKey, $with.Attributes) - - $changed = $false - if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { - $changed = [bool]$result.Changed - } + $result = $Provider.Invoke('CreateIdentity', @($With.IdentityKey, $With.Attributes)) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Changed = $changed - Error = $null - } + $changed = $null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed') -and [bool]$result.Changed + return @{ Changed = $changed } } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepDeleteIdentity.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepDeleteIdentity.ps1 index 3debd5e9..86a4f4e3 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepDeleteIdentity.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepDeleteIdentity.ps1 @@ -4,8 +4,8 @@ function Invoke-IdleStepDeleteIdentity { Deletes an identity from the target system. .DESCRIPTION - This is a provider-agnostic step. The host must supply a provider instance via - Context.Providers[] that implements DeleteIdentity(identityKey) + This is a provider-agnostic step. The engine passes the provider resolved from With.Provider (default 'Identity') as -Provider. + The provider implements DeleteIdentity(identityKey) and returns an object with properties 'IdentityKey' and 'Changed'. The step is idempotent by design: if the identity is already deleted, the provider @@ -15,75 +15,32 @@ function Invoke-IdleStepDeleteIdentity { capability, which is typically opt-in for safety. The provider must be configured to allow deletion (e.g., AllowDelete = $true for AD provider). - Authentication: - - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method - if the provider supports an AuthSession parameter. - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @{ Role = 'Tier0' }). - - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + Authentication is handled by the engine: With.AuthSessionName and With.AuthSessionOptions select the + auth session passed to provider methods that declare an AuthSession parameter. - .PARAMETER Context - Execution context created by IdLE.Core. - - .PARAMETER Step - Normalized step object from the plan. Must contain a 'With' hashtable with keys: + .PARAMETER With + Validated With hashtable (see the step metadata catalog): - IdentityKey (required): the identity identifier - - Provider (optional): provider alias, defaults to 'Identity' + + .PARAMETER Provider + Provider handle created by the engine (see docs/extend/steps.md). .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) + System.Collections.Hashtable (Changed) #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Context, + [hashtable] $With, [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Step + [object] $Provider ) - $with = $Step.With - if ($null -eq $with -or -not ($with -is [hashtable])) { - throw "DeleteIdentity requires 'With' to be a hashtable." - } - - if (-not $with.ContainsKey('IdentityKey')) { - throw "DeleteIdentity requires 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." - } - - $result = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $with ` - -ProviderAlias $providerAlias ` - -MethodName 'DeleteIdentity' ` - -MethodArguments @([string]$with.IdentityKey) - - $changed = $false - if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { - $changed = [bool]$result.Changed - } + $result = $Provider.Invoke('DeleteIdentity', @($With.IdentityKey)) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Changed = $changed - Error = $null - } + $changed = $null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed') -and [bool]$result.Changed + return @{ Changed = $changed } } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepDisableIdentity.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepDisableIdentity.ps1 index fdd19ce7..ee0de9db 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepDisableIdentity.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepDisableIdentity.ps1 @@ -4,82 +4,39 @@ function Invoke-IdleStepDisableIdentity { Disables an identity in the target system. .DESCRIPTION - This is a provider-agnostic step. The host must supply a provider instance via - Context.Providers[] that implements DisableIdentity(identityKey) + This is a provider-agnostic step. The engine passes the provider resolved from With.Provider (default 'Identity') as -Provider. + The provider implements DisableIdentity(identityKey) and returns an object with properties 'IdentityKey' and 'Changed'. The step is idempotent by design: if the identity is already disabled, the provider should return Changed = $false. - Authentication: - - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method - if the provider supports an AuthSession parameter. - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @{ Role = 'Tier0' }). - - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + Authentication is handled by the engine: With.AuthSessionName and With.AuthSessionOptions select the + auth session passed to provider methods that declare an AuthSession parameter. - .PARAMETER Context - Execution context created by IdLE.Core. - - .PARAMETER Step - Normalized step object from the plan. Must contain a 'With' hashtable with keys: + .PARAMETER With + Validated With hashtable (see the step metadata catalog): - IdentityKey (required): the identity identifier - - Provider (optional): provider alias, defaults to 'Identity' + + .PARAMETER Provider + Provider handle created by the engine (see docs/extend/steps.md). .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) + System.Collections.Hashtable (Changed) #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Context, + [hashtable] $With, [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Step + [object] $Provider ) - $with = $Step.With - if ($null -eq $with -or -not ($with -is [hashtable])) { - throw "DisableIdentity requires 'With' to be a hashtable." - } - - if (-not $with.ContainsKey('IdentityKey')) { - throw "DisableIdentity requires 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." - } - - $result = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $with ` - -ProviderAlias $providerAlias ` - -MethodName 'DisableIdentity' ` - -MethodArguments @([string]$with.IdentityKey) - - $changed = $false - if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { - $changed = [bool]$result.Changed - } + $result = $Provider.Invoke('DisableIdentity', @($With.IdentityKey)) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Changed = $changed - Error = $null - } + $changed = $null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed') -and [bool]$result.Changed + return @{ Changed = $changed } } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEmitEvent.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEmitEvent.ps1 index cbc3dda3..1232ac9c 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEmitEvent.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEmitEvent.ps1 @@ -18,11 +18,14 @@ function Invoke-IdleStepEmitEvent { .PARAMETER Context Execution context created by IdLE.Core. + .PARAMETER With + Validated With hashtable (Message, Type, Level). + .PARAMETER Step - The plan step object (Name, Type, With, When). + Step metadata (Name, Type). .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) + None. #> [CmdletBinding()] param( @@ -30,26 +33,20 @@ function Invoke-IdleStepEmitEvent { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $With, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step ) - $with = $Step.With - if ($null -eq $with -or -not ($with -is [hashtable])) { - $with = @{} - } - - $message = if ($with.ContainsKey('Message') -and -not [string]::IsNullOrWhiteSpace([string]$with.Message)) { - [string]$with.Message - } - else { - "Event emitted by step '$([string]$Step.Name)'." - } + $message = if ($With.ContainsKey('Message')) { [string]$With.Message } else { "Event emitted by step '$([string]$Step.Name)'." } $eventType = 'IdLE.Step.EmitEvent.Emitted' - if ($with.ContainsKey('Type') -and -not [string]::IsNullOrWhiteSpace([string]$with.Type)) { - $eventType = [string]$with.Type + if ($With.ContainsKey('Type')) { + $eventType = [string]$With.Type # 'IdLE.*' event types are reserved for events emitted by IdLE itself. if ($eventType.StartsWith('IdLE.', [System.StringComparison]::OrdinalIgnoreCase)) { throw [System.ArgumentException]::new( @@ -59,27 +56,5 @@ function Invoke-IdleStepEmitEvent { } } - $level = if ($with.ContainsKey('Level') -and -not [string]::IsNullOrWhiteSpace([string]$with.Level)) { - [string]$with.Level - } - else { - 'Information' - } - - # The engine provides an EventSink contract with a WriteEvent(...) method. - # If the host is not interested in streaming events, the sink will still buffer events - # for the execution result. This keeps the step deterministic and host-agnostic. - if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink) { - if ($Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { - $Context.EventSink.WriteEvent($eventType, $message, [string]$Step.Name, @{ StepType = [string]$Step.Type }, $level) - } - } - - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Error = $null - } + $Context.EventSink.WriteEvent($eventType, $message, [string]$Step.Name, @{ StepType = [string]$Step.Type }, [string]$With.Level) } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnableIdentity.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnableIdentity.ps1 index 9ea7c360..6cab8448 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnableIdentity.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnableIdentity.ps1 @@ -4,82 +4,39 @@ function Invoke-IdleStepEnableIdentity { Enables an identity in the target system. .DESCRIPTION - This is a provider-agnostic step. The host must supply a provider instance via - Context.Providers[] that implements EnableIdentity(identityKey) + This is a provider-agnostic step. The engine passes the provider resolved from With.Provider (default 'Identity') as -Provider. + The provider implements EnableIdentity(identityKey) and returns an object with properties 'IdentityKey' and 'Changed'. The step is idempotent by design: if the identity is already enabled, the provider should return Changed = $false. - Authentication: - - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method - if the provider supports an AuthSession parameter. - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @{ Role = 'Tier0' }). - - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + Authentication is handled by the engine: With.AuthSessionName and With.AuthSessionOptions select the + auth session passed to provider methods that declare an AuthSession parameter. - .PARAMETER Context - Execution context created by IdLE.Core. - - .PARAMETER Step - Normalized step object from the plan. Must contain a 'With' hashtable with keys: + .PARAMETER With + Validated With hashtable (see the step metadata catalog): - IdentityKey (required): the identity identifier - - Provider (optional): provider alias, defaults to 'Identity' + + .PARAMETER Provider + Provider handle created by the engine (see docs/extend/steps.md). .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) + System.Collections.Hashtable (Changed) #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Context, + [hashtable] $With, [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Step + [object] $Provider ) - $with = $Step.With - if ($null -eq $with -or -not ($with -is [hashtable])) { - throw "EnableIdentity requires 'With' to be a hashtable." - } - - if (-not $with.ContainsKey('IdentityKey')) { - throw "EnableIdentity requires 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." - } - - $result = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $with ` - -ProviderAlias $providerAlias ` - -MethodName 'EnableIdentity' ` - -MethodArguments @([string]$with.IdentityKey) - - $changed = $false - if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { - $changed = [bool]$result.Changed - } + $result = $Provider.Invoke('EnableIdentity', @($With.IdentityKey)) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Changed = $changed - Error = $null - } + $changed = $null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed') -and [bool]$result.Changed + return @{ Changed = $changed } } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 index 30d50f61..761b4b8a 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 @@ -5,7 +5,7 @@ function Invoke-IdleStepEnsureAttributes { .DESCRIPTION This is a provider-agnostic step that can ensure multiple attributes in a single step. - The host must supply a provider instance via Context.Providers[]. + The engine passes the provider resolved from With.Provider (default 'Identity') as -Provider. Provider interaction strategy: 1. If the provider implements EnsureAttributes(IdentityKey, AttributesHashtable), it is called once (fast path). @@ -13,165 +13,86 @@ function Invoke-IdleStepEnsureAttributes { The step is idempotent by design: it converges state to the desired values. - Authentication: - - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method - if the provider supports an AuthSession parameter. - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @{ Role = 'Tier0' }). - - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + Authentication is handled by the engine (With.AuthSessionName, With.AuthSessionOptions). - .PARAMETER Context - Execution context created by IdLE.Core. + .PARAMETER With + Validated With hashtable: IdentityKey, Attributes (non-empty hashtable of attribute name -> value). - .PARAMETER Step - Normalized step object from the plan. Must contain a 'With' hashtable. + .PARAMETER Provider + Provider handle created by the engine (see docs/extend/steps.md). .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) + System.Collections.Hashtable (Changed, Data.Attributes) #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Context, + [hashtable] $With, [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Step + [object] $Provider ) - $with = $null - if ($Step.PSObject.Properties.Name -contains 'With') { - $with = $Step.With - } - - if ($null -eq $with -or -not ($with -is [hashtable])) { - throw "EnsureAttributes requires 'With' to be a hashtable." - } - - if (-not $with.ContainsKey('IdentityKey')) { - throw "EnsureAttributes requires With.IdentityKey." - } - - if (-not $with.ContainsKey('Attributes')) { - throw "EnsureAttributes requires With.Attributes." - } - - $attributes = $with.Attributes - if ($null -eq $attributes -or -not ($attributes -is [hashtable])) { - throw "EnsureAttributes requires With.Attributes to be a hashtable." - } - + $identityKey = [string]$With.IdentityKey + $attributes = $With.Attributes if ($attributes.Count -eq 0) { throw "EnsureAttributes requires With.Attributes to contain at least one attribute." } - $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] - - # Check if provider has EnsureAttributes method (fast path) - $hasEnsureAttributes = $null -ne $provider.PSObject.Methods['EnsureAttributes'] - $anyChanged = $false $attributeResults = @() - - if ($hasEnsureAttributes) { - # Fast path: call EnsureAttributes once - $result = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $with ` - -ProviderAlias $providerAlias ` - -MethodName 'EnsureAttributes' ` - -MethodArguments @([string]$with.IdentityKey, $attributes) - + + if ($Provider.HasMethod('EnsureAttributes')) { + # Fast path: one provider call for all attributes. + $result = $Provider.Invoke('EnsureAttributes', @($identityKey, $attributes)) + if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { $anyChanged = [bool]$result.Changed } - - # If provider returns per-attribute details, use them + if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Attributes')) { $attributeResults = $result.Attributes - } else { - # Provider doesn't return per-attribute details, so we can't determine individual attribute changes - # Report overall status but mark individual attribute change status as unknown + } + else { + # Without per-attribute details from the provider, report the overall result per attribute. foreach ($key in $attributes.Keys) { $attributeResults += @{ Name = $key - Changed = $anyChanged # Overall result - individual changes unknown without provider details + Changed = $anyChanged Error = $null } } } } else { - # Fallback: call EnsureAttribute for each attribute foreach ($key in $attributes.Keys) { $attrName = [string]$key - $attrValue = $attributes[$key] - - try { - # Explicitly construct the arguments array to preserve $null values. - # Using @() with a $null element can drop the null in some PowerShell contexts; - # a typed object array guarantees the null is included as a positional argument. - $callArgs = [object[]]::new(2) - $callArgs[0] = [string]$with.IdentityKey - $callArgs[1] = $attrName - # Append value as third element preserving $null - $callArgsWithValue = [object[]]::new(3) - $callArgsWithValue[0] = $callArgs[0] - $callArgsWithValue[1] = $callArgs[1] - $callArgsWithValue[2] = $attrValue - - $result = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $with ` - -ProviderAlias $providerAlias ` - -MethodName 'EnsureAttribute' ` - -MethodArguments $callArgsWithValue - - $changed = $false - if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { - $changed = [bool]$result.Changed - } - - if ($changed) { - $anyChanged = $true - } - - $attributeResults += @{ - Name = $attrName - Changed = $changed - Error = $null - } + + # A typed object array keeps a $null value as positional argument ("clear attribute"). + $callArguments = [object[]]::new(3) + $callArguments[0] = $identityKey + $callArguments[1] = $attrName + $callArguments[2] = $attributes[$key] + + $result = $Provider.Invoke('EnsureAttribute', $callArguments) + + $changed = $null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed') -and [bool]$result.Changed + if ($changed) { + $anyChanged = $true } - catch { - throw + $attributeResults += @{ + Name = $attrName + Changed = $changed + Error = $null } } } - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Changed = $anyChanged - Error = $null - Data = @{ - Attributes = $attributeResults - } + return @{ + Changed = $anyChanged + Data = @{ Attributes = $attributeResults } } } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 index 3f027731..257fd44c 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 @@ -5,8 +5,8 @@ function Invoke-IdleStepEnsureEntitlement { .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: + an assignment to the desired state. The engine passes the provider resolved from With.Provider (default 'Identity') + as -Provider. The provider implements: - ListEntitlements(identityKey) - GrantEntitlement(identityKey, entitlement) @@ -15,228 +15,75 @@ function Invoke-IdleStepEnsureEntitlement { The step is idempotent and only calls Grant/Revoke when the assignment needs to change. - Authentication: - - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider methods - if the provider supports an AuthSession parameter. - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @{ Role = 'Tier0' }). - - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + Authentication is handled by the engine (With.AuthSessionName, With.AuthSessionOptions). - .PARAMETER Context - Execution context created by IdLE.Core. + .PARAMETER With + Validated With hashtable: IdentityKey, Entitlement (@{ Kind; Id; DisplayName }), State ('Present' or 'Absent'). - .PARAMETER Step - Normalized step object from the plan. Must contain a 'With' hashtable. + .PARAMETER Provider + Provider handle created by the engine (see docs/extend/steps.md). .EXAMPLE - Invoke-IdleStepEnsureEntitlement -Context $context -Step [pscustomobject]@{ + @{ Name = 'Ensure group access' Type = 'IdLE.Step.EnsureEntitlement' With = @{ IdentityKey = 'user1' Entitlement = @{ Kind = 'Group'; Id = 'example-group' } State = 'Present' - Provider = 'Identity' } } .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) + System.Collections.Hashtable (Changed) #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Context, + [hashtable] $With, [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Step + [object] $Provider ) - function ConvertTo-IdleStepEntitlement { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Value - ) - - $kind = $null - $id = $null - - if ($Value -is [System.Collections.IDictionary]) { - $kind = $Value['Kind'] - $id = $Value['Id'] - } - else { - $props = $Value.PSObject.Properties - if ($props.Name -contains 'Kind') { $kind = $Value.Kind } - if ($props.Name -contains 'Id') { $id = $Value.Id } - } - - if ([string]::IsNullOrWhiteSpace([string]$kind)) { - throw "EnsureEntitlement requires Entitlement.Kind." - } - if ([string]::IsNullOrWhiteSpace([string]$id)) { - throw "EnsureEntitlement requires Entitlement.Id." - } - - return [pscustomobject]@{ - Kind = [string]$kind - Id = [string]$id - } - } - function Test-IdleStepEntitlementEquals { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $A, + param([object] $A, [object] $B) - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $B - ) + $kindA = if ($A -is [System.Collections.IDictionary]) { $A['Kind'] } else { $A.Kind } + $idA = if ($A -is [System.Collections.IDictionary]) { $A['Id'] } else { $A.Id } - $ea = ConvertTo-IdleStepEntitlement -Value $A - $eb = ConvertTo-IdleStepEntitlement -Value $B - - if ($ea.Kind -ne $eb.Kind) { + if (-not [string]::Equals([string]$kindA, [string]$B.Kind, [System.StringComparison]::OrdinalIgnoreCase)) { 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." + return [string]::Equals([string]$idA, [string]$B.Id, [System.StringComparison]::OrdinalIgnoreCase) } - 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." - } - - # Auth session acquisition (optional, data-only) - $authSession = $null - if ($with.ContainsKey('AuthSessionName')) { - $sessionName = [string]$with.AuthSessionName - $sessionOptions = if ($with.ContainsKey('AuthSessionOptions')) { $with.AuthSessionOptions } else { $null } - if ($null -ne $sessionOptions -and -not ($sessionOptions -is [hashtable])) { - throw "With.AuthSessionOptions must be a hashtable or null." - } - - $authSession = $Context.AcquireAuthSession($sessionName, $sessionOptions) + $identityKey = [string]$With.IdentityKey + $entitlement = [pscustomobject]@{ + Kind = [string]$With.Entitlement.Kind + Id = [string]$With.Entitlement.Id } - $provider = $Context.Providers[$providerAlias] + # Pass Kind to the provider when supported, so it can skip fetching unrelated objects + # (e.g. avoid AU Graph calls for a Group entitlement check). + $listArguments = if ($Provider.HasParameter('ListEntitlements', 'Kind')) { @($identityKey, $entitlement.Kind) } else { @($identityKey) } + $current = @($Provider.Invoke('ListEntitlements', $listArguments)) + $isAssigned = @($current | Where-Object { Test-IdleStepEntitlementEquals -A $_ -B $entitlement }).Count -gt 0 - $requiredMethods = @('ListEntitlements') - if ($state -eq 'present') { - $requiredMethods += 'GrantEntitlement' - } - else { - $requiredMethods += 'RevokeEntitlement' - } + $method = $null + if ($With.State -eq 'Present' -and -not $isAssigned) { $method = 'GrantEntitlement' } + if ($With.State -eq 'Absent' -and $isAssigned) { $method = 'RevokeEntitlement' } - foreach ($m in $requiredMethods) { - if (-not ($provider.PSObject.Methods.Name -contains $m)) { - throw "Provider '$providerAlias' must implement method '$m' for EnsureEntitlement." - } + if ($null -eq $method) { + return @{ Changed = $false } } - # Check AuthSession support for each method - $listSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['ListEntitlements'] -ParameterName 'AuthSession' - $listSupportsKind = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['ListEntitlements'] -ParameterName 'Kind' - $grantSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['GrantEntitlement'] -ParameterName 'AuthSession' - $revokeSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['RevokeEntitlement'] -ParameterName 'AuthSession' + $result = $Provider.Invoke($method, @($identityKey, $entitlement)) - # Pass Kind to the provider when supported, so it can skip fetching unrelated objects - # (e.g. avoid AU Graph calls for a Group entitlement check). - $entitlementKind = [string]$entitlement.Kind - if ($listSupportsKind -and $listSupportsAuthSession -and $null -ne $authSession) { - $current = @($provider.ListEntitlements($identityKey, $authSession, $entitlementKind)) - } elseif ($listSupportsKind) { - $current = @($provider.ListEntitlements($identityKey, $null, $entitlementKind)) - } elseif ($listSupportsAuthSession -and $null -ne $authSession) { - $current = @($provider.ListEntitlements($identityKey, $authSession)) - } else { - $current = @($provider.ListEntitlements($identityKey)) - } - $matches = @($current | Where-Object { Test-IdleStepEntitlementEquals -A $_ -B $entitlement }) - - $changed = $false - - if ($state -eq 'present') { - if (@($matches).Count -eq 0) { - if ($grantSupportsAuthSession -and $null -ne $authSession) { - $result = $provider.GrantEntitlement($identityKey, $entitlement, $authSession) - } - else { - $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) { - if ($revokeSupportsAuthSession -and $null -ne $authSession) { - $result = $provider.RevokeEntitlement($identityKey, $entitlement, $authSession) - } - else { - $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 - } + # Providers without a result object are assumed to have changed the assignment. + $changed = if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { [bool]$result.Changed } else { $true } + return @{ Changed = $changed } } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepMoveIdentity.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepMoveIdentity.ps1 index e6043a0d..55160568 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepMoveIdentity.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepMoveIdentity.ps1 @@ -4,85 +4,40 @@ function Invoke-IdleStepMoveIdentity { Moves an identity to a different container/OU in the target system. .DESCRIPTION - This is a provider-agnostic step. The host must supply a provider instance via - Context.Providers[] that implements MoveIdentity(identityKey, targetContainer) + This is a provider-agnostic step. The engine passes the provider resolved from With.Provider (default 'Identity') as -Provider. + The provider implements MoveIdentity(identityKey, targetContainer) and returns an object with properties 'IdentityKey' and 'Changed'. The step is idempotent by design: if the identity is already in the target container, the provider should return Changed = $false. - Authentication: - - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method - if the provider supports an AuthSession parameter. - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @{ Role = 'Tier0' }). - - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + Authentication is handled by the engine: With.AuthSessionName and With.AuthSessionOptions select the + auth session passed to provider methods that declare an AuthSession parameter. - .PARAMETER Context - Execution context created by IdLE.Core. - - .PARAMETER Step - Normalized step object from the plan. Must contain a 'With' hashtable with keys: + .PARAMETER With + Validated With hashtable (see the step metadata catalog): - IdentityKey (required): the identity identifier - TargetContainer (required): the target container/OU DN - - Provider (optional): provider alias, defaults to 'Identity' + + .PARAMETER Provider + Provider handle created by the engine (see docs/extend/steps.md). .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) + System.Collections.Hashtable (Changed) #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Context, + [hashtable] $With, [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Step + [object] $Provider ) - $with = $Step.With - if ($null -eq $with -or -not ($with -is [hashtable])) { - throw "MoveIdentity requires 'With' to be a hashtable." - } - - foreach ($key in @('IdentityKey', 'TargetContainer')) { - if (-not $with.ContainsKey($key)) { - throw "MoveIdentity requires With.$key." - } - } - - $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." - } - - $result = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $with ` - -ProviderAlias $providerAlias ` - -MethodName 'MoveIdentity' ` - -MethodArguments @([string]$with.IdentityKey, [string]$with.TargetContainer) - - $changed = $false - if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { - $changed = [bool]$result.Changed - } + $result = $Provider.Invoke('MoveIdentity', @($With.IdentityKey, $With.TargetContainer)) - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Changed = $changed - Error = $null - } + $changed = $null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed') -and [bool]$result.Changed + return @{ Changed = $changed } } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 index 9703bc64..a714c81b 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlements.ps1 @@ -34,29 +34,19 @@ function Invoke-IdleStepPruneEntitlements { operation fails, the step emits a structured warning event, records the item as Skipped, and continues. The workflow is not failed. - Authentication: - - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to provider methods. - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for session selection - (e.g., @{ Role = 'Tier0' }). ScriptBlocks in AuthSessionOptions are rejected. - - ### With.* Parameters - - | Key | Required | Type | Description | - | -------------------- | -------- | ------------ | ----------- | - | IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | - | Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | - | Keep | No | array | Explicit entitlement objects to retain (kept, never removed). Each entry must have an Id property; Kind is optional. These entries are NOT granted — use PruneEntitlementsEnsureKeep for that. | - | KeepPattern | No | string array | Wildcard strings (PowerShell -like semantics). Current entitlements whose Id matches any pattern are kept. Patterns are NEVER granted. | - | Provider | No | string | Provider alias from Context.Providers (default: Identity). | - | AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | - | AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | + Authentication is handled by the engine (With.AuthSessionName, With.AuthSessionOptions). .PARAMETER Context Execution context created by IdLE.Core. + .PARAMETER With + Validated With hashtable (see the step metadata catalog). + + .PARAMETER Provider + Provider handle created by the engine (see docs/extend/steps.md). + .PARAMETER Step - Normalized step object from the plan. Must contain a 'With' hashtable. + Step metadata (Name, Type); the name is used for events. .EXAMPLE # Mover workflow: strip all role assignments except those matching a wildcard pattern. @@ -99,7 +89,7 @@ function Invoke-IdleStepPruneEntitlements { } .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) + System.Collections.Hashtable (Changed, Data.Skipped) #> [CmdletBinding()] param( @@ -109,341 +99,16 @@ function Invoke-IdleStepPruneEntitlements { [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Step - ) - - $with = $Step.With - if ($null -eq $with -or -not ($with -is [hashtable])) { - throw "PruneEntitlements requires 'With' to be a hashtable." - } - - foreach ($key in @('IdentityKey', 'Kind')) { - if (-not $with.ContainsKey($key)) { - throw "PruneEntitlements requires With.$key." - } - } - - $identityKey = [string]$with.IdentityKey - $kind = [string]$with.Kind - - if ([string]::IsNullOrWhiteSpace($identityKey)) { - throw "PruneEntitlements requires With.IdentityKey to be non-empty." - } - if ([string]::IsNullOrWhiteSpace($kind)) { - throw "PruneEntitlements requires With.Kind to be non-empty." - } - - $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'Identity' } - - # Parse explicit Keep items - $keepItems = @() - if ($with.ContainsKey('Keep') -and $null -ne $with.Keep) { - foreach ($item in @($with.Keep)) { - if ($item -is [scriptblock]) { - throw "PruneEntitlements: Keep entries must not contain ScriptBlocks." - } - $keepItems += ConvertTo-IdlePruneEntitlement -Value $item -DefaultKind $kind - } - } - - # Parse KeepPattern items (wildcard strings only) - $keepPatterns = @() - if ($with.ContainsKey('KeepPattern') -and $null -ne $with.KeepPattern) { - foreach ($p in @($with.KeepPattern)) { - if ($p -is [scriptblock]) { - throw "PruneEntitlements: KeepPattern entries must be strings, not ScriptBlocks." - } - $pStr = [string]$p - if ([string]::IsNullOrWhiteSpace($pStr)) { - throw "PruneEntitlements: KeepPattern entries must not be empty." - } - $keepPatterns += $pStr - } - } - - # (No guardrail: empty keep-set is valid — prune everything of the given Kind) - - $ensureKeep = $false - if ($with.ContainsKey('EnsureKeepEntitlements') -and $null -ne $with.EnsureKeepEntitlements) { - $ensureKeep = [bool]$with.EnsureKeepEntitlements - } - - # Validate Context and Providers - 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." - } - - # Auth session acquisition (optional, data-only) - $authSession = $null - if ($with.ContainsKey('AuthSessionName')) { - $sessionName = [string]$with.AuthSessionName - $sessionOptions = if ($with.ContainsKey('AuthSessionOptions')) { $with.AuthSessionOptions } else { $null } - - if ($null -ne $sessionOptions -and -not ($sessionOptions -is [hashtable])) { - throw "With.AuthSessionOptions must be a hashtable or null." - } - - $authSession = $Context.AcquireAuthSession($sessionName, $sessionOptions) - } - - $provider = $Context.Providers[$providerAlias] - - # Validate required provider methods (step is the single source of delta computation) - $requiredMethods = @('ListEntitlements', 'RevokeEntitlement') - if ($ensureKeep) { - $requiredMethods += 'GrantEntitlement' - } - foreach ($m in $requiredMethods) { - if (-not ($provider.PSObject.Methods.Name -contains $m)) { - throw "Provider '$providerAlias' must implement method '$m' for PruneEntitlements." - } - } - - # Normalize Keep IDs to canonical form via provider.ResolveEntitlement (when available). - # This ensures correct comparison with the canonical IDs returned by ListEntitlements. - # Each provider handles its own ID-type detection (e.g., GUID/DN/sAMAccountName for AD; - # objectId/displayName for Entra ID). - if ($keepItems.Count -gt 0 -and $provider.PSObject.Methods.Name -contains 'ResolveEntitlement') { - $resolveSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['ResolveEntitlement'] -ParameterName 'AuthSession' - $keepItems = @($keepItems | ForEach-Object { - if ($resolveSupportsAuthSession -and $null -ne $authSession) { - $provider.ResolveEntitlement($kind, $_, $authSession) - } else { - $provider.ResolveEntitlement($kind, $_) - } - } - ) - } + [hashtable] $With, - $listSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['ListEntitlements'] -ParameterName 'AuthSession' - $listSupportsKind = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['ListEntitlements'] -ParameterName 'Kind' - $revokeSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['RevokeEntitlement'] -ParameterName 'AuthSession' - $grantSupportsAuthSession = if ($ensureKeep) { - Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['GrantEntitlement'] -ParameterName 'AuthSession' - } else { $false } - - # Detect bulk-capable provider methods (e.g. Entra ID uses Graph $batch for efficiency) - $hasBulkRevoke = $null -ne $provider.PSObject.Methods['BulkRevokeEntitlements'] - $bulkRevokeSupportsAuthSession = if ($hasBulkRevoke) { - Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['BulkRevokeEntitlements'] -ParameterName 'AuthSession' - } else { $false } - $hasBulkGrant = $ensureKeep -and ($null -ne $provider.PSObject.Methods['BulkGrantEntitlements']) - $bulkGrantSupportsAuthSession = if ($hasBulkGrant) { - Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['BulkGrantEntitlements'] -ParameterName 'AuthSession' - } else { $false } - - # 1. List current entitlements, filter by Kind - # When the provider supports the Kind parameter, pass it so the provider can skip - # fetching unrelated directory objects (e.g. avoid AU Graph calls for Group-only prunes). - $allCurrent = if ($listSupportsKind -and $listSupportsAuthSession -and $null -ne $authSession) { - @($provider.ListEntitlements($identityKey, $authSession, $kind)) - } elseif ($listSupportsKind) { - @($provider.ListEntitlements($identityKey, $null, $kind)) - } elseif ($listSupportsAuthSession -and $null -ne $authSession) { - @($provider.ListEntitlements($identityKey, $authSession)) - } else { - @($provider.ListEntitlements($identityKey)) - } + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Provider, - $current = @( - $allCurrent | Where-Object { $null -ne $_ -and - ($_.PSObject.Properties.Name -contains 'Kind') -and - [string]::Equals([string]$_.Kind, $kind, [System.StringComparison]::OrdinalIgnoreCase) - } + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step ) - # 2. Compute keep-set and remove-set - $toKeep = @() - $toRemove = @() - - foreach ($ent in $current) { - if (Test-IdlePruneEntitlementShouldKeep -Ent $ent -KeepItems $keepItems -KeepPatterns $keepPatterns) { - $toKeep += $ent - } else { - $toRemove += $ent - } - } - - # Emit plan intent event - if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and - $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { - $Context.EventSink.WriteEvent('IdLE.Step.PruneEntitlements.Planned', "PruneEntitlements: plan - keep=$( - @($toKeep).Count), remove=$(@($toRemove).Count)", $Step.Name, @{ - Kind = $kind - KeepCount = @($toKeep).Count - PruneCount = @($toRemove).Count - } - ) - } - - $changed = $false - $skippedItems = @() - - # 3. Revoke each entitlement in remove-set - if ($hasBulkRevoke -and $toRemove.Count -gt 0) { - # Bulk path: provider batches operations and returns per-item results with distinct status - $bulkResults = if ($bulkRevokeSupportsAuthSession -and $null -ne $authSession) { - @($provider.BulkRevokeEntitlements($identityKey, $toRemove, $authSession)) - } else { - @($provider.BulkRevokeEntitlements($identityKey, $toRemove)) - } - - foreach ($br in $bulkResults) { - if ($br.Error) { - $skippedItems += [pscustomobject]@{ - EntitlementId = [string]$br.Entitlement.Id - Reason = [string]$br.Error - } - if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and - $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { - $Context.EventSink.WriteEvent('IdLE.Step.PruneEntitlements.EntitlementSkipped', "PruneEntitlements: skipped non-removable entitlement '$($br.Entitlement.Id)': $($br.Error)", $Step.Name, @{ - Kind = $kind - EntitlementId = [string]$br.Entitlement.Id - Reason = [string]$br.Error - }, 'Warning' - ) - } - } else { - if ($br.Changed) { $changed = $true } - if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and - $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { - $Context.EventSink.WriteEvent('IdLE.Step.PruneEntitlements.EntitlementRevoked', "PruneEntitlements: revoked entitlement '$($br.Entitlement.Id)'", $Step.Name, @{ - Kind = $kind - EntitlementId = [string]$br.Entitlement.Id - } - ) - } - } - } - } else { - # Per-item path: each revoke is attempted independently - foreach ($ent in $toRemove) { - try { - if ($revokeSupportsAuthSession -and $null -ne $authSession) { - $revokeResult = $provider.RevokeEntitlement($identityKey, $ent, $authSession) - } else { - $revokeResult = $provider.RevokeEntitlement($identityKey, $ent) - } - if ($revokeResult -and $revokeResult.Changed) { - $changed = $true - } - - if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and - $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { - $Context.EventSink.WriteEvent('IdLE.Step.PruneEntitlements.EntitlementRevoked', "PruneEntitlements: revoked entitlement '$($ent.Id)'", $Step.Name, @{ - Kind = $kind - EntitlementId = [string]$ent.Id - } - ) - } - } - catch { - # Non-removable or permission-denied entitlement: skip with warning - $reason = $_.Exception.Message - $skippedItems += [pscustomobject]@{ - EntitlementId = [string]$ent.Id - Reason = $reason - } - - if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and - $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { - $Context.EventSink.WriteEvent('IdLE.Step.PruneEntitlements.EntitlementSkipped', "PruneEntitlements: skipped non-removable entitlement '$($ent.Id)': $reason", $Step.Name, @{ - Kind = $kind - EntitlementId = [string]$ent.Id - Reason = $reason - }, 'Warning' - ) - } - } - } - } - - # 4. If EnsureKeepEntitlements: grant any explicit Keep items that are missing - if ($ensureKeep -and $keepItems.Count -gt 0) { - $toEnsure = @($keepItems | Where-Object { $k = $_ - @($current | Where-Object { - [string]::Equals([string]$_.Id, [string]$k.Id, [System.StringComparison]::OrdinalIgnoreCase) - } - ).Count -eq 0 - } - ) - - if ($hasBulkGrant -and $toEnsure.Count -gt 0) { - # Bulk grant path - $bulkResults = if ($bulkGrantSupportsAuthSession -and $null -ne $authSession) { - @($provider.BulkGrantEntitlements($identityKey, $toEnsure, $authSession)) - } else { - @($provider.BulkGrantEntitlements($identityKey, $toEnsure)) - } - - foreach ($br in $bulkResults) { - if ($br.Error) { - $skippedItems += [pscustomobject]@{ - EntitlementId = [string]$br.Entitlement.Id - Reason = [string]$br.Error - } - if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and - $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { - $Context.EventSink.WriteEvent('IdLE.Step.PruneEntitlements.KeepGrantFailed', "PruneEntitlements: failed to grant keep entitlement '$($br.Entitlement.Id)': $($br.Error)", $Step.Name, @{ - Kind = $kind - EntitlementId = [string]$br.Entitlement.Id - Reason = [string]$br.Error - }, 'Warning' - ) - } - } else { - if ($br.Changed) { $changed = $true } - if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and - $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { - $Context.EventSink.WriteEvent('IdLE.Step.PruneEntitlements.KeepGranted', "PruneEntitlements: granted keep entitlement '$($br.Entitlement.Id)'", $Step.Name, @{ - Kind = $kind - EntitlementId = [string]$br.Entitlement.Id - } - ) - } - } - } - } else { - foreach ($k in $toEnsure) { - if ($grantSupportsAuthSession -and $null -ne $authSession) { - $result = $provider.GrantEntitlement($identityKey, $k, $authSession) - } else { - $result = $provider.GrantEntitlement($identityKey, $k) - } - - if ($null -ne $result -and $result.PSObject.Properties.Name -contains 'Changed') { - if ($result.Changed) { - $changed = $true - } - } else { - # Fall back to assuming a change occurred if the provider does not return a standard result object - $changed = $true - } - if ($Context.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $Context.EventSink -and - $Context.EventSink.PSObject.Methods.Name -contains 'WriteEvent') { - $Context.EventSink.WriteEvent('IdLE.Step.PruneEntitlements.KeepGranted', "PruneEntitlements: granted keep entitlement '$($k.Id)'", $Step.Name, @{ - Kind = $kind - EntitlementId = [string]$k.Id - } - ) - } - } - } - } - - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Changed = $changed - Error = $null - Skipped = $skippedItems - } + return Invoke-IdlePruneEntitlementsOperation -Context $Context -With $With -Provider $Provider -StepName ([string]$Step.Name) } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 index 22c35281..1edb4c3b 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepPruneEntitlementsEnsureKeep.ps1 @@ -38,28 +38,19 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { operation fails, the step emits a structured warning event, records the item as Skipped, and continues. The workflow is not failed. - Authentication: - - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to provider methods. - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for session selection - (e.g., @{ Role = 'Tier0' }). ScriptBlocks in AuthSessionOptions are rejected. - - ### With.* Parameters - - | Key | Required | Type | Description | - | -------------------- | -------- | ------------ | ----------- | - | IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | - | Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | - | Keep | No | array | Explicit entitlement objects to retain AND ensure are present. Each entry must have an Id property; Kind is optional. **These entries are GRANTED if missing after the prune.** If omitted, all entitlements of the given Kind are removed and no grants are made. | - | Provider | No | string | Provider alias from Context.Providers (default: Identity). | - | AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | - | AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | + Authentication is handled by the engine (With.AuthSessionName, With.AuthSessionOptions). .PARAMETER Context Execution context created by IdLE.Core. + .PARAMETER With + Validated With hashtable (see the step metadata catalog). + + .PARAMETER Provider + Provider handle created by the engine (see docs/extend/steps.md). + .PARAMETER Step - Normalized step object from the plan. Must contain a 'With' hashtable. + Step metadata (Name, Type); the name is used for events. .EXAMPLE # Leaver workflow: remove ALL group memberships AND guarantee the identity is in the leaver-retention @@ -107,7 +98,7 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { } .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) + System.Collections.Hashtable (Changed, Data.Skipped) #> [CmdletBinding()] param( @@ -117,36 +108,16 @@ function Invoke-IdleStepPruneEntitlementsEnsureKeep { [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Step - ) - - if ($null -eq $Step.With -or -not ($Step.With -is [hashtable])) { - throw "PruneEntitlementsEnsureKeep requires 'With' to be a hashtable." - } - - $sourceWith = $Step.With - - if ($sourceWith.ContainsKey('KeepPattern')) { - throw "PruneEntitlementsEnsureKeep does not support With.KeepPattern. Use With.Keep for explicit entitlements to retain and ensure." - } + [hashtable] $With, - # Inject EnsureKeepEntitlements = $true into With, then delegate to Invoke-IdleStepPruneEntitlements. - # This ensures the ensure-grant phase always runs for this step type. - $ensureWith = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($key in $sourceWith.Keys) { - $ensureWith[$key] = $sourceWith[$key] - } - $ensureWith['EnsureKeepEntitlements'] = $true + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Provider, - # Shallow clone the step with the updated With - $ensureStep = [pscustomobject]@{ - Name = [string]$Step.Name - Type = [string]$Step.Type - With = $ensureWith - } - if ($Step.PSObject.Properties.Name -contains 'Condition') { - $ensureStep | Add-Member -MemberType NoteProperty -Name Condition -Value $Step.Condition - } + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) - return Invoke-IdleStepPruneEntitlements -Context $Context -Step $ensureStep + return Invoke-IdlePruneEntitlementsOperation -Context $Context -With $With -Provider $Provider -StepName ([string]$Step.Name) -EnsureKeep } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepRevokeIdentitySessions.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepRevokeIdentitySessions.ps1 index 0f27ceea..322b50af 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepRevokeIdentitySessions.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepRevokeIdentitySessions.ps1 @@ -5,8 +5,8 @@ function Invoke-IdleStepRevokeIdentitySessions { .DESCRIPTION This is a provider-agnostic step that revokes active sign-in sessions (refresh tokens) - for a given identity. The host must supply a provider instance via - Context.Providers[] that implements RevokeSessions(identityKey) + for a given identity. The engine passes the provider resolved from With.Provider (default 'Identity') as -Provider. + The provider implements RevokeSessions(identityKey) and returns an object with properties 'IdentityKey' and 'Changed'. This step is typically used in Leaver workflows after disabling an identity to ensure @@ -15,26 +15,20 @@ function Invoke-IdleStepRevokeIdentitySessions { The step does not modify the identity itself (e.g., does not disable the account). Use IdLE.Step.DisableIdentity separately if account disabling is also required. - Authentication: - - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method - if the provider supports an AuthSession parameter. - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @{ Role = 'Tier0' }). - - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + Authentication is handled by the engine: With.AuthSessionName and With.AuthSessionOptions select the + auth session passed to provider methods that declare an AuthSession parameter. - .PARAMETER Context - Execution context created by IdLE.Core. - - .PARAMETER Step - Normalized step object from the plan. Must contain a 'With' hashtable with keys: + .PARAMETER With + Validated With hashtable (see the step metadata catalog): - IdentityKey (required): the identity identifier - - Provider (optional): provider alias, defaults to 'Identity' + + .PARAMETER Provider + Provider handle created by the engine (see docs/extend/steps.md). - AuthSessionName (optional): name for auth session acquisition - AuthSessionOptions (optional): routing options for auth session broker .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) + System.Collections.Hashtable (Changed) .EXAMPLE # In a workflow definition (PSD1): @@ -64,52 +58,15 @@ function Invoke-IdleStepRevokeIdentitySessions { param( [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Context, + [hashtable] $With, [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Step + [object] $Provider ) - $with = $Step.With - if ($null -eq $with -or -not ($with -is [hashtable])) { - throw "RevokeIdentitySessions requires 'With' to be a hashtable." - } - - if (-not $with.ContainsKey('IdentityKey')) { - throw "RevokeIdentitySessions requires 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." - } - - $result = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $with ` - -ProviderAlias $providerAlias ` - -MethodName 'RevokeSessions' ` - -MethodArguments @([string]$with.IdentityKey) + $result = $Provider.Invoke('RevokeSessions', @($With.IdentityKey)) - $changed = $false - if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { - $changed = [bool]$result.Changed - } - - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Changed = $changed - Error = $null - } + $changed = $null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed') -and [bool]$result.Changed + return @{ Changed = $changed } } diff --git a/src/IdLE.Steps.Common/StepMetadataCatalog.psd1 b/src/IdLE.Steps.Common/StepMetadataCatalog.psd1 index 239f6bae..4fcf96e8 100644 --- a/src/IdLE.Steps.Common/StepMetadataCatalog.psd1 +++ b/src/IdLE.Steps.Common/StepMetadataCatalog.psd1 @@ -7,20 +7,22 @@ # Each entry maps a Step.Type to a metadata hashtable containing: # Handler - handler function exported by this module (bound to this module by the engine) # RequiredCapabilities - capability identifiers the step requires from providers -# WithSchema - declares the With key contract for plan-time validation: -# RequiredKeys - keys that MUST be present in With -# OptionalKeys - keys that MAY be present in With +# Provider - (optional) the step uses one provider: @{ DefaultAlias = '' }. +# The engine resolves it and adds the With keys Provider, AuthSessionName, AuthSessionOptions. +# WithSchema - typed With contract validated at plan time (see docs/extend/steps.md): +# Keys - key name -> @{ Type; Required; Default; AllowedValues; AllowEmpty; Keys; Items; ... } # @{ - # IdLE.Step.EmitEvent - writes a structured event to the event sink; no provider capabilities required. - # Provider/AuthSessionName/AuthSessionOptions are accepted as optional routing hints: - # the execution engine reads these from any step's With to resolve Request.Context.Current.*. + # IdLE.Step.EmitEvent - writes a structured event to the event sink; uses no provider. 'IdLE.Step.EmitEvent' = @{ Handler = 'Invoke-IdleStepEmitEvent' RequiredCapabilities = @() WithSchema = @{ - RequiredKeys = @() - OptionalKeys = @('Message', 'Type', 'Level', 'Provider', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ + Message = @{ Type = 'String' } + Type = @{ Type = 'String' } + Level = @{ Type = 'String'; AllowedValues = @('Information', 'Warning', 'Error'); Default = 'Information' } + } } } @@ -28,9 +30,12 @@ 'IdLE.Step.CreateIdentity' = @{ Handler = 'Invoke-IdleStepCreateIdentity' RequiredCapabilities = @('IdLE.Identity.Create') + Provider = @{ DefaultAlias = 'Identity' } WithSchema = @{ - RequiredKeys = @('IdentityKey', 'Attributes') - OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ + IdentityKey = @{ Type = 'String'; Required = $true } + Attributes = @{ Type = 'Hashtable'; Required = $true } + } } } @@ -38,69 +43,98 @@ 'IdLE.Step.DisableIdentity' = @{ Handler = 'Invoke-IdleStepDisableIdentity' RequiredCapabilities = @('IdLE.Identity.Disable') + Provider = @{ DefaultAlias = 'Identity' } WithSchema = @{ - RequiredKeys = @('IdentityKey') - OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ + IdentityKey = @{ Type = 'String'; Required = $true } + } } } - # IdLE.Step.EnableIdentity - re-enables a disabled identity via the identity provider + # IdLE.Step.EnableIdentity - enables a disabled identity via the identity provider 'IdLE.Step.EnableIdentity' = @{ Handler = 'Invoke-IdleStepEnableIdentity' RequiredCapabilities = @('IdLE.Identity.Enable') + Provider = @{ DefaultAlias = 'Identity' } WithSchema = @{ - RequiredKeys = @('IdentityKey') - OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ + IdentityKey = @{ Type = 'String'; Required = $true } + } } } - # IdLE.Step.DeleteIdentity - permanently removes an identity via the identity provider + # IdLE.Step.DeleteIdentity - deletes an identity via the identity provider 'IdLE.Step.DeleteIdentity' = @{ Handler = 'Invoke-IdleStepDeleteIdentity' RequiredCapabilities = @('IdLE.Identity.Delete') + Provider = @{ DefaultAlias = 'Identity' } WithSchema = @{ - RequiredKeys = @('IdentityKey') - OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ + IdentityKey = @{ Type = 'String'; Required = $true } + } } } - # IdLE.Step.MoveIdentity - moves an identity to a target container/OU + # IdLE.Step.MoveIdentity - moves an identity to a different container/OU 'IdLE.Step.MoveIdentity' = @{ Handler = 'Invoke-IdleStepMoveIdentity' RequiredCapabilities = @('IdLE.Identity.Move') + Provider = @{ DefaultAlias = 'Identity' } WithSchema = @{ - RequiredKeys = @('IdentityKey', 'TargetContainer') - OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ + IdentityKey = @{ Type = 'String'; Required = $true } + TargetContainer = @{ Type = 'String'; Required = $true } + } } } - # IdLE.Step.EnsureAttributes - idempotently sets attributes on an identity + # IdLE.Step.EnsureAttributes - ensures multiple identity attributes match desired values. + # Attributes is free-form (attribute name -> value; $null clears an attribute). 'IdLE.Step.EnsureAttributes' = @{ Handler = 'Invoke-IdleStepEnsureAttributes' RequiredCapabilities = @('IdLE.Identity.Attribute.Ensure') + Provider = @{ DefaultAlias = 'Identity' } WithSchema = @{ - RequiredKeys = @('IdentityKey', 'Attributes') - OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ + IdentityKey = @{ Type = 'String'; Required = $true } + Attributes = @{ Type = 'Hashtable'; Required = $true } + } } } - # IdLE.Step.EnsureEntitlement - idempotently grants or revokes a single entitlement + # IdLE.Step.EnsureEntitlement - ensures an entitlement assignment is present or absent 'IdLE.Step.EnsureEntitlement' = @{ Handler = 'Invoke-IdleStepEnsureEntitlement' RequiredCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') + Provider = @{ DefaultAlias = 'Identity' } WithSchema = @{ - RequiredKeys = @('IdentityKey', 'Entitlement', 'State') - OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ + IdentityKey = @{ Type = 'String'; Required = $true } + Entitlement = @{ + Type = 'Hashtable' + Required = $true + # Provider-specific entitlement properties are passed through. + AllowAdditionalKeys = $true + Keys = @{ + Kind = @{ Type = 'String'; Required = $true } + Id = @{ Type = 'String'; Required = $true } + DisplayName = @{ Type = 'String' } + } + } + State = @{ Type = 'String'; AllowedValues = @('Present', 'Absent'); Default = 'Present' } + } } } - # IdLE.Step.RevokeIdentitySessions - revokes all active sessions for an identity + # IdLE.Step.RevokeIdentitySessions - revokes all active sign-in sessions for an identity 'IdLE.Step.RevokeIdentitySessions' = @{ Handler = 'Invoke-IdleStepRevokeIdentitySessions' RequiredCapabilities = @('IdLE.Identity.RevokeSessions') + Provider = @{ DefaultAlias = 'Identity' } WithSchema = @{ - RequiredKeys = @('IdentityKey') - OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ + IdentityKey = @{ Type = 'String'; Required = $true } + } } } @@ -109,20 +143,53 @@ 'IdLE.Step.PruneEntitlements' = @{ Handler = 'Invoke-IdleStepPruneEntitlements' RequiredCapabilities = @('IdLE.Entitlement.Prune', 'IdLE.Entitlement.List', 'IdLE.Entitlement.Revoke') + Provider = @{ DefaultAlias = 'Identity' } WithSchema = @{ - RequiredKeys = @('IdentityKey', 'Kind') - OptionalKeys = @('Provider', 'Keep', 'KeepPattern', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ + IdentityKey = @{ Type = 'String'; Required = $true } + Kind = @{ Type = 'String'; Required = $true } + Keep = @{ + Type = 'Array' + AllowEmpty = $true + Items = @{ + Type = 'Hashtable' + AllowAdditionalKeys = $true + Keys = @{ + Kind = @{ Type = 'String' } + Id = @{ Type = 'String'; Required = $true } + DisplayName = @{ Type = 'String' } + } + } + } + KeepPattern = @{ Type = 'Array'; AllowEmpty = $true; Items = @{ Type = 'String' } } + } } } # IdLE.Step.PruneEntitlementsEnsureKeep - remove + ensure keep present: prune + grant-back - # KeepPattern is NOT in OptionalKeys because patterns cannot be granted (they are filter-only). + # KeepPattern is not supported because patterns cannot be granted (they are filter-only). 'IdLE.Step.PruneEntitlementsEnsureKeep' = @{ Handler = 'Invoke-IdleStepPruneEntitlementsEnsureKeep' RequiredCapabilities = @('IdLE.Entitlement.Prune', 'IdLE.Entitlement.List', 'IdLE.Entitlement.Revoke', 'IdLE.Entitlement.Grant') + Provider = @{ DefaultAlias = 'Identity' } WithSchema = @{ - RequiredKeys = @('IdentityKey', 'Kind') - OptionalKeys = @('Provider', 'Keep', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ + IdentityKey = @{ Type = 'String'; Required = $true } + Kind = @{ Type = 'String'; Required = $true } + Keep = @{ + Type = 'Array' + AllowEmpty = $true + Items = @{ + Type = 'Hashtable' + AllowAdditionalKeys = $true + Keys = @{ + Kind = @{ Type = 'String' } + Id = @{ Type = 'String'; Required = $true } + DisplayName = @{ Type = 'String' } + } + } + } + } } } } diff --git a/src/IdLE.Steps.DirectorySync/Public/Invoke-IdleStepTriggerDirectorySync.ps1 b/src/IdLE.Steps.DirectorySync/Public/Invoke-IdleStepTriggerDirectorySync.ps1 index ff1a5499..469009b9 100644 --- a/src/IdLE.Steps.DirectorySync/Public/Invoke-IdleStepTriggerDirectorySync.ps1 +++ b/src/IdLE.Steps.DirectorySync/Public/Invoke-IdleStepTriggerDirectorySync.ps1 @@ -4,37 +4,37 @@ function Invoke-IdleStepTriggerDirectorySync { Triggers a directory sync cycle and optionally waits for completion. .DESCRIPTION - This is a provider-agnostic step. The host must supply a provider instance via - Context.Providers[] that implements: + This is a provider-agnostic step. The engine passes the provider resolved from With.Provider + (default 'DirectorySync') as -Provider. The provider implements: - StartSyncCycle(PolicyType, ComputerName, AuthSession) - GetSyncCycleState(ComputerName, AuthSession) The step is designed for remote execution and requires an elevated auth session provided by the host's AuthSessionBroker. - Authentication: - - With.AuthSessionName (optional): routing key for AuthSessionBroker - - With.AuthSessionOptions (optional, hashtable): forwarded to broker for session selection - - If AuthSessionName is omitted, the broker is asked for a default session - - ComputerName and PolicyType are provider-specific inputs and are validated by the selected provider - - ScriptBlocks in AuthSessionOptions are rejected (security boundary) + Authentication is handled by the engine: With.AuthSessionName (optional) and With.AuthSessionOptions + select the session; without AuthSessionName the broker is asked for a default session. + ComputerName and PolicyType are provider-specific inputs and are validated by the selected provider. .PARAMETER Context Execution context created by IdLE.Core. - .PARAMETER Step - Normalized step object from the plan. Must contain a 'With' hashtable with keys: - - AuthSessionName (optional, string): auth session name for broker (default session is used when omitted) + .PARAMETER With + Validated With hashtable: - ComputerName (optional, string): provider-specific target server input - PolicyType (optional, string): provider-specific policy input - - Provider (optional, string): provider alias, defaults to 'DirectorySync' - Wait (optional, bool): wait for cycle completion, defaults to $false - TimeoutSeconds (optional, int): wait timeout, defaults to 600 - PollIntervalSeconds (optional, int): poll interval, defaults to 10 - - AuthSessionOptions (optional, hashtable): forwarded to broker + + .PARAMETER Provider + Provider handle created by the engine (see docs/extend/steps.md). + + .PARAMETER Step + Step metadata (Name, Type); the name is used for events. .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) + System.Collections.Hashtable (Changed) .EXAMPLE $step = @{ @@ -54,24 +54,26 @@ function Invoke-IdleStepTriggerDirectorySync { [ValidateNotNull()] [object] $Context, + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $With, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Provider, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step ) - $with = $Step.With - if ($null -eq $with -or -not ($with -is [hashtable])) { - throw "TriggerDirectorySync requires 'With' to be a hashtable." - } - - $policyType = if ($with.ContainsKey('PolicyType')) { [string]$with.PolicyType } else { $null } - $computerName = if ($with.ContainsKey('ComputerName')) { [string]$with.ComputerName } else { $null } + $policyType = if ($With.ContainsKey('PolicyType')) { [string]$With.PolicyType } else { $null } + $computerName = if ($With.ContainsKey('ComputerName')) { [string]$With.ComputerName } else { $null } - # Optional inputs with defaults - $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'DirectorySync' } - $wait = if ($with.ContainsKey('Wait')) { [bool]$with.Wait } else { $false } - $timeoutSeconds = if ($with.ContainsKey('TimeoutSeconds')) { [int]$with.TimeoutSeconds } else { 600 } - $pollIntervalSeconds = if ($with.ContainsKey('PollIntervalSeconds')) { [int]$with.PollIntervalSeconds } else { 10 } + # Defaults come from the step metadata (Wait = $false, TimeoutSeconds = 600, PollIntervalSeconds = 10). + $wait = [bool]$With.Wait + $timeoutSeconds = [int]$With.TimeoutSeconds + $pollIntervalSeconds = [int]$With.PollIntervalSeconds # Validate timeout and poll interval if ($timeoutSeconds -le 0) { @@ -81,18 +83,7 @@ function Invoke-IdleStepTriggerDirectorySync { throw "TriggerDirectorySync: With.PollIntervalSeconds must be greater than 0. Got: $pollIntervalSeconds" } - # Validate provider exists - 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." - } - - $stepName = if ($Step.PSObject.Properties.Name -contains 'Name') { [string]$Step.Name } else { 'TriggerDirectorySync' } + $stepName = [string]$Step.Name try { # Trigger sync cycle @@ -109,12 +100,7 @@ function Invoke-IdleStepTriggerDirectorySync { ComputerName = $computerName }) - $startResult = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $with ` - -ProviderAlias $providerAlias ` - -MethodName 'StartSyncCycle' ` - -MethodArguments @($policyType, $computerName) + $startResult = $Provider.Invoke('StartSyncCycle', @($policyType, $computerName)) $changed = $false if ($null -ne $startResult -and ($startResult.PSObject.Properties.Name -contains 'Started')) { @@ -137,12 +123,7 @@ function Invoke-IdleStepTriggerDirectorySync { if ($elapsed -ge $timeoutSeconds) { # Timeout reached - fail - $stateResult = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $with ` - -ProviderAlias $providerAlias ` - -MethodName 'GetSyncCycleState' ` - -MethodArguments @($computerName) + $stateResult = $Provider.Invoke('GetSyncCycleState', @($computerName)) $lastState = if ($null -ne $stateResult) { $stateResult.State } else { 'Unknown' } @@ -156,12 +137,7 @@ function Invoke-IdleStepTriggerDirectorySync { } # Poll state - $stateResult = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $with ` - -ProviderAlias $providerAlias ` - -MethodName 'GetSyncCycleState' ` - -MethodArguments @($computerName) + $stateResult = $Provider.Invoke('GetSyncCycleState', @($computerName)) $inProgress = $true if ($null -ne $stateResult -and ($stateResult.PSObject.Properties.Name -contains 'InProgress')) { @@ -197,14 +173,7 @@ function Invoke-IdleStepTriggerDirectorySync { }) } - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = $stepName - Type = [string]$Step.Type - Status = 'Completed' - Changed = $changed - Error = $null - } + return @{ Changed = $changed } } catch { $Context.EventSink.WriteEvent('IdLE.Step.DirectorySync.Failed', "Failed to trigger or wait for sync cycle: $_", $stepName, @{ diff --git a/src/IdLE.Steps.DirectorySync/StepMetadataCatalog.psd1 b/src/IdLE.Steps.DirectorySync/StepMetadataCatalog.psd1 index 5f6241fd..76c6a640 100644 --- a/src/IdLE.Steps.DirectorySync/StepMetadataCatalog.psd1 +++ b/src/IdLE.Steps.DirectorySync/StepMetadataCatalog.psd1 @@ -7,9 +7,10 @@ # Each entry maps a Step.Type to a metadata hashtable containing: # Handler - handler function exported by this module (bound to this module by the engine) # RequiredCapabilities - capability identifiers the step requires from providers -# WithSchema - declares the With key contract for plan-time validation: -# RequiredKeys - keys that MUST be present in With -# OptionalKeys - keys that MAY be present in With +# Provider - (optional) the step uses one provider: @{ DefaultAlias = '' }. +# The engine resolves it and adds the With keys Provider, AuthSessionName, AuthSessionOptions. +# WithSchema - typed With contract validated at plan time (see docs/extend/steps.md): +# Keys - key name -> @{ Type; Required; Default; AllowedValues; AllowEmpty; Keys; Items; ... } # @{ # IdLE.Step.TriggerDirectorySync - triggers a directory sync cycle and optionally waits for completion @@ -17,9 +18,16 @@ 'IdLE.Step.TriggerDirectorySync' = @{ Handler = 'Invoke-IdleStepTriggerDirectorySync' RequiredCapabilities = @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') + Provider = @{ DefaultAlias = 'DirectorySync' } WithSchema = @{ - RequiredKeys = @() - OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions', 'ComputerName', 'PolicyType', 'Wait', 'TimeoutSeconds', 'PollIntervalSeconds') + Keys = @{ + # ComputerName and PolicyType are provider-specific and validated by the provider. + ComputerName = @{ Type = 'String' } + PolicyType = @{ Type = 'String' } + Wait = @{ Type = 'Bool'; Default = $false } + TimeoutSeconds = @{ Type = 'Int'; Default = 600 } + PollIntervalSeconds = @{ Type = 'Int'; Default = 10 } + } } } } diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxGetInfo.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxGetInfo.ps1 index cd6ff218..e53683eb 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxGetInfo.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxGetInfo.ps1 @@ -4,27 +4,23 @@ function Invoke-IdleStepMailboxGetInfo { Retrieves mailbox details and returns a structured report. .DESCRIPTION - This is a provider-agnostic step. The host must supply a provider instance via - Context.Providers[]. The provider must implement a GetMailbox + This is a provider-agnostic step. The engine passes the provider resolved from With.Provider + (default 'ExchangeOnline') as -Provider. The provider implements a GetMailbox method with the signature (IdentityKey, AuthSession) and return a mailbox object. The step is read-only and returns Changed = $false. - Authentication: - - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method. - - If With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @{ Role = 'Admin' }). + Authentication is handled by the engine: without With.AuthSessionName the session is named after the + provider alias (e.g. 'ExchangeOnline'); With.AuthSessionOptions is passed to the broker. - .PARAMETER Context - Execution context created by IdLE.Core. + .PARAMETER With + Validated With hashtable (see the step metadata catalog). - .PARAMETER Step - Normalized step object from the plan. Must contain a 'With' hashtable. + .PARAMETER Provider + Provider handle created by the engine (see docs/extend/steps.md). .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) + System.Collections.Hashtable (State.Mailbox) .EXAMPLE # In workflow definition: @@ -41,61 +37,15 @@ function Invoke-IdleStepMailboxGetInfo { param( [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Context, + [hashtable] $With, [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Step + [object] $Provider ) - $with = $Step.With - if ($null -eq $with -or -not ($with -is [hashtable])) { - throw "Mailbox.GetInfo requires 'With' to be a hashtable." - } - - if (-not $with.ContainsKey('IdentityKey')) { - throw "Mailbox.GetInfo requires With.IdentityKey." - } - - $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'ExchangeOnline' } - - 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." - } + $result = $Provider.Invoke('GetMailbox', @($With.IdentityKey)) - # Create execution-local copy of With to avoid mutating the plan - $effectiveWith = if ($with -is [hashtable]) { $with.Clone() } else { @{} + $with } - - # Apply AuthSessionName convention: default to Provider if not specified - if (-not $effectiveWith.ContainsKey('AuthSessionName')) { - $effectiveWith['AuthSessionName'] = $providerAlias - } - - $result = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $effectiveWith ` - -ProviderAlias $providerAlias ` - -MethodName 'GetMailbox' ` - -MethodArguments @([string]$effectiveWith.IdentityKey) - - # Store mailbox data in State for downstream steps - $state = @{ - Mailbox = $result - } - - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Changed = $false - Error = $null - State = $state - } + # Mailbox data for downstream steps. + return @{ State = @{ Mailbox = $result } } } diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 index 830d718a..3af67e3e 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 @@ -4,8 +4,8 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { Ensures that a mailbox Out of Office (OOF) configuration matches the desired state. .DESCRIPTION - This is a provider-agnostic step. The host must supply a provider instance via - Context.Providers[]. The provider must implement an EnsureOutOfOffice + This is a provider-agnostic step. The engine passes the provider resolved from With.Provider + (default 'ExchangeOnline') as -Provider. The provider implements an EnsureOutOfOffice method with the signature (IdentityKey, Config, AuthSession) and return an object that contains a boolean property 'Changed'. @@ -23,21 +23,17 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { When set to 'Text', messages are treated as plain text. Providers may normalize HTML to ensure stable idempotency (e.g., handling server-side wrapping). - Authentication: - - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method. - - If With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @{ Role = 'Admin' }). + Authentication is handled by the engine: without With.AuthSessionName the session is named after the + provider alias (e.g. 'ExchangeOnline'); With.AuthSessionOptions is passed to the broker. - .PARAMETER Context - Execution context created by IdLE.Core. + .PARAMETER With + Validated With hashtable (see the step metadata catalog). - .PARAMETER Step - Normalized step object from the plan. Must contain a 'With' hashtable. + .PARAMETER Provider + Provider handle created by the engine (see docs/extend/steps.md). .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) + System.Collections.Hashtable (Changed) .EXAMPLE # In workflow definition (enable OOF): @@ -58,13 +54,13 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { } .EXAMPLE - # In workflow definition (with ValueFrom for dynamic values): + # In workflow definition (with a template for dynamic values): @{ Name = 'Enable Out of Office for Leaver' Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Config = @{ Mode = 'Enabled' InternalMessage = 'This person is no longer with the organization. For assistance, please contact their manager or the main office.' @@ -164,98 +160,20 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { param( [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Context, + [hashtable] $With, [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Step + [object] $Provider ) - $with = $Step.With - if ($null -eq $with -or -not ($with -is [hashtable])) { - throw "Mailbox.OutOfOffice.Ensure requires 'With' to be a hashtable." + $config = $With.Config + if ($config.Mode -eq 'Scheduled' -and (-not $config.ContainsKey('Start') -or -not $config.ContainsKey('End'))) { + throw "Mailbox.OutOfOffice.Ensure with Mode 'Scheduled' requires With.Config.Start and With.Config.End." } - foreach ($key in @('IdentityKey', 'Config')) { - if (-not $with.ContainsKey($key)) { - throw "Mailbox.OutOfOffice.Ensure requires With.$key." - } - } - - $config = $with.Config - if ($null -eq $config -or -not ($config -is [hashtable])) { - throw "Mailbox.OutOfOffice.Ensure requires With.Config to be a hashtable." - } - - # Validate Config shape - if (-not $config.ContainsKey('Mode')) { - throw "Mailbox.OutOfOffice.Ensure requires With.Config.Mode (Disabled, Enabled, or Scheduled)." - } - - $validModes = @('Disabled', 'Enabled', 'Scheduled') - if ($config.Mode -notin $validModes) { - throw "Mailbox.OutOfOffice.Ensure requires With.Config.Mode to be one of: $($validModes -join ', '). Got: $($config.Mode)" - } + $result = $Provider.Invoke('EnsureOutOfOffice', @($With.IdentityKey, $config)) - # Validate Scheduled mode requirements - if ($config.Mode -eq 'Scheduled') { - foreach ($key in @('Start', 'End')) { - if (-not $config.ContainsKey($key)) { - throw "Mailbox.OutOfOffice.Ensure with Mode 'Scheduled' requires With.Config.$key." - } - } - } - - # Security: reject ScriptBlocks in Config (data-only constraint) - Assert-IdleNoScriptBlock -InputObject $config -Path 'With.Config' - - # Validate MessageFormat if provided - if ($config.ContainsKey('MessageFormat')) { - $validFormats = @('Text', 'Html') - if ($config.MessageFormat -notin $validFormats) { - throw "Mailbox.OutOfOffice.Ensure requires With.Config.MessageFormat to be one of: $($validFormats -join ', '). Got: $($config.MessageFormat)" - } - } - - $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'ExchangeOnline' } - - 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." - } - - # Create execution-local copy of With to avoid mutating the plan - $effectiveWith = if ($with -is [hashtable]) { $with.Clone() } else { @{} + $with } - - # Apply AuthSessionName convention: default to Provider if not specified - if (-not $effectiveWith.ContainsKey('AuthSessionName')) { - $effectiveWith['AuthSessionName'] = $providerAlias - } - - $result = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $effectiveWith ` - -ProviderAlias $providerAlias ` - -MethodName 'EnsureOutOfOffice' ` - -MethodArguments @([string]$effectiveWith.IdentityKey, $config) - - $changed = $false - if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { - $changed = [bool]$result.Changed - } - - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Changed = $changed - Error = $null - } + $changed = $null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed') -and [bool]$result.Changed + return @{ Changed = $changed } } - diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxPermissionsEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxPermissionsEnsure.ps1 index da119702..7bb8a7eb 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxPermissionsEnsure.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxPermissionsEnsure.ps1 @@ -4,8 +4,8 @@ function Invoke-IdleStepMailboxPermissionsEnsure { Ensures that mailbox delegate permissions match the desired state. .DESCRIPTION - This is a provider-agnostic step. The host must supply a provider instance via - Context.Providers[]. The provider must implement an EnsureMailboxPermissions + This is a provider-agnostic step. The engine passes the provider resolved from With.Provider + (default 'ExchangeOnline') as -Provider. The provider implements an EnsureMailboxPermissions method with the signature (IdentityKey, Permissions, AuthSession) and return an object that contains a boolean property 'Changed'. @@ -24,21 +24,17 @@ function Invoke-IdleStepMailboxPermissionsEnsure { - Right: 'FullAccess' | 'SendAs' | 'SendOnBehalf' (required) - Ensure: 'Present' | 'Absent' (required) - Authentication: - - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method. - - If With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @{ Role = 'Admin' }). + Authentication is handled by the engine: without With.AuthSessionName the session is named after the + provider alias (e.g. 'ExchangeOnline'); With.AuthSessionOptions is passed to the broker. - .PARAMETER Context - Execution context created by IdLE.Core. + .PARAMETER With + Validated With hashtable (see the step metadata catalog). - .PARAMETER Step - Normalized step object from the plan. Must contain a 'With' hashtable. + .PARAMETER Provider + Provider handle created by the engine (see docs/extend/steps.md). .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) + System.Collections.Hashtable (Changed) .EXAMPLE # In workflow definition (grant FullAccess and SendAs): @@ -79,7 +75,7 @@ function Invoke-IdleStepMailboxPermissionsEnsure { Provider = 'ExchangeOnline' IdentityKey = 'team@contoso.com' Permissions = @( - @{ AssignedUser = @{ ValueFrom = 'Request.Intent.UserPrincipalName' }; Right = 'FullAccess'; Ensure = 'Present' } + @{ AssignedUser = '{{Request.Intent.UserPrincipalName}}'; Right = 'FullAccess'; Ensure = 'Present' } ) } } @@ -88,102 +84,15 @@ function Invoke-IdleStepMailboxPermissionsEnsure { param( [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Context, + [hashtable] $With, [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Step + [object] $Provider ) - $with = $Step.With - if ($null -eq $with -or -not ($with -is [hashtable])) { - throw "Mailbox.Permissions.Ensure requires 'With' to be a hashtable." - } - - foreach ($key in @('IdentityKey', 'Permissions')) { - if (-not $with.ContainsKey($key)) { - throw "Mailbox.Permissions.Ensure requires With.$key." - } - } - - $permissions = $with.Permissions - if ($null -eq $permissions) { - throw "Mailbox.Permissions.Ensure requires With.Permissions to be an array." - } - - # Accept single hashtable or array of hashtables - if ($permissions -is [hashtable]) { - $permissions = @($permissions) - } - - $validRights = @('FullAccess', 'SendAs', 'SendOnBehalf') - $validEnsure = @('Present', 'Absent') - - foreach ($entry in $permissions) { - if ($null -eq $entry -or -not ($entry -is [hashtable])) { - throw "Mailbox.Permissions.Ensure: each Permissions entry must be a hashtable." - } - foreach ($key in @('AssignedUser', 'Right', 'Ensure')) { - if (-not $entry.ContainsKey($key)) { - throw "Mailbox.Permissions.Ensure: each Permissions entry requires '$key'." - } - } - if ($entry.Right -notin $validRights) { - throw "Mailbox.Permissions.Ensure: Right must be one of: $($validRights -join ', '). Got: $($entry.Right)" - } - if ($entry.Ensure -notin $validEnsure) { - throw "Mailbox.Permissions.Ensure: Ensure must be one of: $($validEnsure -join ', '). Got: $($entry.Ensure)" - } - } + $result = $Provider.Invoke('EnsureMailboxPermissions', @($With.IdentityKey, $With.Permissions)) - # Security: reject ScriptBlocks in Permissions (data-only constraint) - Assert-IdleNoScriptBlock -InputObject $with.Permissions -Path 'With.Permissions' - - $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'ExchangeOnline' } - - 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." - } - - # Create execution-local copy of With to avoid mutating the plan - $effectiveWith = $with.Clone() - - # Apply AuthSessionName convention: default to Provider if not specified - if (-not $effectiveWith.ContainsKey('AuthSessionName')) { - $effectiveWith['AuthSessionName'] = $providerAlias - } - - # Inject EventSink into the provider so it can emit diagnostics events - $provider = $Context.Providers[$providerAlias] - if ($provider.PSObject.Properties.Name -contains 'EventSink' -and - $Context.PSObject.Properties.Name -contains 'EventSink') { - $provider.EventSink = $Context.EventSink - } - - $result = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $effectiveWith ` - -ProviderAlias $providerAlias ` - -MethodName 'EnsureMailboxPermissions' ` - -MethodArguments @([string]$effectiveWith.IdentityKey, $permissions) - - $changed = $false - if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { - $changed = [bool]$result.Changed - } - - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Changed = $changed - Error = $null - } + $changed = $null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed') -and [bool]$result.Changed + return @{ Changed = $changed } } diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 index 6c848abd..2ff974ef 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 @@ -4,8 +4,8 @@ function Invoke-IdleStepMailboxTypeEnsure { Ensures that a mailbox is of the desired type (User, Shared, Room, Equipment). .DESCRIPTION - This is a provider-agnostic step. The host must supply a provider instance via - Context.Providers[]. The provider must implement an EnsureMailboxType + This is a provider-agnostic step. The engine passes the provider resolved from With.Provider + (default 'ExchangeOnline') as -Provider. The provider implements an EnsureMailboxType method with the signature (IdentityKey, MailboxType, AuthSession) and return an object that contains a boolean property 'Changed'. @@ -17,21 +17,17 @@ function Invoke-IdleStepMailboxTypeEnsure { - Room (room resource mailbox) - Equipment (equipment resource mailbox) - Authentication: - - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method. - - If With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @{ Role = 'Admin' }). + Authentication is handled by the engine: without With.AuthSessionName the session is named after the + provider alias (e.g. 'ExchangeOnline'); With.AuthSessionOptions is passed to the broker. - .PARAMETER Context - Execution context created by IdLE.Core. + .PARAMETER With + Validated With hashtable (see the step metadata catalog). - .PARAMETER Step - Normalized step object from the plan. Must contain a 'With' hashtable. + .PARAMETER Provider + Provider handle created by the engine (see docs/extend/steps.md). .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) + System.Collections.Hashtable (Changed) .EXAMPLE # In workflow definition (convert to shared mailbox): @@ -49,68 +45,15 @@ function Invoke-IdleStepMailboxTypeEnsure { param( [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Context, + [hashtable] $With, [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Step + [object] $Provider ) - $with = $Step.With - if ($null -eq $with -or -not ($with -is [hashtable])) { - throw "Mailbox.Type.Ensure requires 'With' to be a hashtable." - } - - foreach ($key in @('IdentityKey', 'MailboxType')) { - if (-not $with.ContainsKey($key)) { - throw "Mailbox.Type.Ensure requires With.$key." - } - } - - # Validate MailboxType - $validTypes = @('User', 'Shared', 'Room', 'Equipment') - if ($with.MailboxType -notin $validTypes) { - throw "Mailbox.Type.Ensure requires With.MailboxType to be one of: $($validTypes -join ', '). Got: $($with.MailboxType)" - } - - $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'ExchangeOnline' } - - 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." - } - - # Create execution-local copy of With to avoid mutating the plan - $effectiveWith = if ($with -is [hashtable]) { $with.Clone() } else { @{} + $with } - - # Apply AuthSessionName convention: default to Provider if not specified - if (-not $effectiveWith.ContainsKey('AuthSessionName')) { - $effectiveWith['AuthSessionName'] = $providerAlias - } + $result = $Provider.Invoke('EnsureMailboxType', @($With.IdentityKey, $With.MailboxType)) - $result = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $effectiveWith ` - -ProviderAlias $providerAlias ` - -MethodName 'EnsureMailboxType' ` - -MethodArguments @([string]$effectiveWith.IdentityKey, [string]$effectiveWith.MailboxType) - - $changed = $false - if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { - $changed = [bool]$result.Changed - } - - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Changed = $changed - Error = $null - } + $changed = $null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed') -and [bool]$result.Changed + return @{ Changed = $changed } } diff --git a/src/IdLE.Steps.Mailbox/README.md b/src/IdLE.Steps.Mailbox/README.md index 8776c4ce..7beacef7 100644 --- a/src/IdLE.Steps.Mailbox/README.md +++ b/src/IdLE.Steps.Mailbox/README.md @@ -11,7 +11,7 @@ Provider-agnostic mailbox step pack for IdLE. Type = 'IdLE.Step.Mailbox.EnsureType' With = @{ Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } + IdentityKey = '{{Request.Intent.UserPrincipalName}}' MailboxType = 'Shared' } } diff --git a/src/IdLE.Steps.Mailbox/StepMetadataCatalog.psd1 b/src/IdLE.Steps.Mailbox/StepMetadataCatalog.psd1 index b087ab77..840f2a9a 100644 --- a/src/IdLE.Steps.Mailbox/StepMetadataCatalog.psd1 +++ b/src/IdLE.Steps.Mailbox/StepMetadataCatalog.psd1 @@ -7,52 +7,87 @@ # Each entry maps a Step.Type to a metadata hashtable containing: # Handler - handler function exported by this module (bound to this module by the engine) # RequiredCapabilities - capability identifiers the step requires from providers -# WithSchema - declares the With key contract for plan-time validation: -# RequiredKeys - keys that MUST be present in With -# OptionalKeys - keys that MAY be present in With +# Provider - (optional) the step uses one provider: @{ DefaultAlias = '' }. +# The engine resolves it and adds the With keys Provider, AuthSessionName, AuthSessionOptions. +# WithSchema - typed With contract validated at plan time (see docs/extend/steps.md): +# Keys - key name -> @{ Type; Required; Default; AllowedValues; AllowEmpty; Keys; Items; ... } # @{ + # Mailbox steps name their auth session after the provider alias (e.g. 'ExchangeOnline') + # unless With.AuthSessionName is set (Provider.AuthSessionNameFromAlias). + # IdLE.Step.Mailbox.GetInfo - reads mailbox details for an identity - 'IdLE.Step.Mailbox.GetInfo' = @{ + 'IdLE.Step.Mailbox.GetInfo' = @{ Handler = 'Invoke-IdleStepMailboxGetInfo' RequiredCapabilities = @('IdLE.Mailbox.Info.Read') + Provider = @{ DefaultAlias = 'ExchangeOnline'; AuthSessionNameFromAlias = $true } WithSchema = @{ - RequiredKeys = @('IdentityKey') - OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ + IdentityKey = @{ Type = 'String'; Required = $true } + } } } # IdLE.Step.Mailbox.EnsureType - idempotently converts a mailbox to the specified type - # MailboxType accepts: User, Shared, Room, Equipment - 'IdLE.Step.Mailbox.EnsureType' = @{ + 'IdLE.Step.Mailbox.EnsureType' = @{ Handler = 'Invoke-IdleStepMailboxTypeEnsure' RequiredCapabilities = @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.Type.Ensure') + Provider = @{ DefaultAlias = 'ExchangeOnline'; AuthSessionNameFromAlias = $true } WithSchema = @{ - RequiredKeys = @('IdentityKey', 'MailboxType') - OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ + IdentityKey = @{ Type = 'String'; Required = $true } + MailboxType = @{ Type = 'String'; Required = $true; AllowedValues = @('User', 'Shared', 'Room', 'Equipment') } + } } } # IdLE.Step.Mailbox.EnsureOutOfOffice - idempotently configures out-of-office settings - # Config accepts: Mode (required), Start/End (required when Mode=Scheduled), - # InternalMessage, ExternalMessage, ExternalAudience, MessageFormat (optional) + # Start/End are required when Mode = 'Scheduled' (checked by the step); DateTime or string. 'IdLE.Step.Mailbox.EnsureOutOfOffice' = @{ Handler = 'Invoke-IdleStepMailboxOutOfOfficeEnsure' RequiredCapabilities = @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.OutOfOffice.Ensure') + Provider = @{ DefaultAlias = 'ExchangeOnline'; AuthSessionNameFromAlias = $true } WithSchema = @{ - RequiredKeys = @('IdentityKey', 'Config') - OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ + IdentityKey = @{ Type = 'String'; Required = $true } + Config = @{ + Type = 'Hashtable' + Required = $true + Keys = @{ + Mode = @{ Type = 'String'; Required = $true; AllowedValues = @('Disabled', 'Enabled', 'Scheduled') } + Start = @{ Type = 'Any' } + End = @{ Type = 'Any' } + InternalMessage = @{ Type = 'String'; AllowEmpty = $true } + ExternalMessage = @{ Type = 'String'; AllowEmpty = $true } + ExternalAudience = @{ Type = 'String'; AllowedValues = @('None', 'Known', 'All') } + MessageFormat = @{ Type = 'String'; AllowedValues = @('Text', 'Html') } + } + } + } } } # IdLE.Step.Mailbox.EnsurePermissions - idempotently manages delegate permissions - # Permissions is an array of hashtables with: AssignedUser, Right, Ensure 'IdLE.Step.Mailbox.EnsurePermissions' = @{ Handler = 'Invoke-IdleStepMailboxPermissionsEnsure' RequiredCapabilities = @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.Permissions.Ensure') + Provider = @{ DefaultAlias = 'ExchangeOnline'; AuthSessionNameFromAlias = $true } WithSchema = @{ - RequiredKeys = @('IdentityKey', 'Permissions') - OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ + IdentityKey = @{ Type = 'String'; Required = $true } + Permissions = @{ + Type = 'Array' + Required = $true + Items = @{ + Type = 'Hashtable' + Keys = @{ + AssignedUser = @{ Type = 'String'; Required = $true } + Right = @{ Type = 'String'; Required = $true; AllowedValues = @('FullAccess', 'SendAs', 'SendOnBehalf') } + Ensure = @{ Type = 'String'; Required = $true; AllowedValues = @('Present', 'Absent') } + } + } + } + } } } } diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index 6deae1bf..5624cb3c 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -2108,6 +2108,11 @@ Describe 'AD identity provider' { } Context 'PruneEntitlementsEnsureKeep step integration with AD provider' { + BeforeAll { + # Engine-like step invocation (Invoke-IdleTestStepHandler, New-IdleTestEventSink). + . (Join-Path -Path $PSScriptRoot -ChildPath '..\_testHelpers.ps1') + } + BeforeEach { $adapter = New-FakeADAdapter $adapter.PrimaryGroupDN = 'CN=Domain Users,CN=Users,DC=domain,DC=local' @@ -2126,7 +2131,7 @@ Describe 'AD identity provider' { PSTypeName = 'IdLE.ExecutionContext' Plan = $null Providers = @{ AD = $script:ADProvider } - EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + EventSink = New-IdleTestEventSink } } @@ -2144,7 +2149,7 @@ Describe 'AD identity provider' { } } - $result = Invoke-IdleStepPruneEntitlementsEnsureKeep -Context $script:StepContext -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:StepContext -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -2168,7 +2173,7 @@ Describe 'AD identity provider' { } } - $result = Invoke-IdleStepPruneEntitlementsEnsureKeep -Context $script:StepContext -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:StepContext -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -2189,11 +2194,11 @@ Describe 'AD identity provider' { } } - $result = Invoke-IdleStepPruneEntitlementsEnsureKeep -Context $script:StepContext -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:StepContext -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue - @($result.Skipped).Count | Should -Be 0 + @($result.Data.Skipped).Count | Should -Be 0 # All non-primary groups removed; primary group (Domain Users) is never in the remove-set $remaining = @($script:ADProvider.ListEntitlements($script:PruneUserId)) @@ -2218,7 +2223,7 @@ Describe 'AD identity provider' { } } - $result = Invoke-IdleStepPruneEntitlementsEnsureKeep -Context $script:StepContext -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:StepContext -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeFalse @@ -2236,7 +2241,7 @@ Describe 'AD identity provider' { } } - { Invoke-IdleStepPruneEntitlementsEnsureKeep -Context $script:StepContext -Step $step } | + { Invoke-IdleTestStepHandler -Context $script:StepContext -Step $step } | Should -Throw -ExpectedMessage '*KeepPattern*' } } diff --git a/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 b/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 index adbd5cf2..16f29c2c 100644 --- a/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 @@ -69,7 +69,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (auth session routing)' { Context 'Auth session acquisition' { It 'acquires auth session when AuthSessionName is present' { - $result = & $script:Handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result | Should -Not -BeNullOrEmpty $result.PSTypeNames | Should -Contain 'IdLE.StepResult' @@ -93,14 +93,14 @@ Describe 'Invoke-IdleStepEnsureAttributes (auth session routing)' { $step.With.Remove('AuthSessionName') $step.With.Remove('AuthSessionOptions') - $result = & $script:Handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $script:State.SessionAcquired | Should -Be $false } It 'passes auth session to provider when provider supports AuthSession parameter' { - $result = & $script:Handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result | Should -Not -BeNullOrEmpty $result.Status | Should -Be 'Completed' @@ -119,7 +119,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (auth session routing)' { } } -Force - $result = & $script:Handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result | Should -Not -BeNullOrEmpty $result.Status | Should -Be 'Completed' @@ -130,7 +130,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (auth session routing)' { $step = $script:StepTemplate $step.With.AuthSessionOptions = 'invalid-string' - { & $script:Handler -Context $script:Context -Step $step } | + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw '*AuthSessionOptions*hashtable*' } @@ -139,7 +139,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (auth session routing)' { $step.With.Remove('AuthSessionName') $step.With.Remove('AuthSessionOptions') - $result = & $script:Handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result | Should -Not -BeNullOrEmpty $result.Status | Should -Be 'Completed' @@ -154,8 +154,8 @@ Describe 'Invoke-IdleStepEnsureAttributes (auth session routing)' { Providers = @{ Identity = $script:Provider } } - { & $script:Handler -Context $context -Step $script:StepTemplate } | - Should -Throw '*AuthSessionName*AcquireAuthSession*' + { Invoke-IdleTestStepHandler -Context $context -Step $script:StepTemplate } | + Should -Throw '*AuthSessionName*cannot acquire auth sessions*' } } } diff --git a/tests/Steps/Invoke-IdleStepEnsureAttributes.Tests.ps1 b/tests/Steps/Invoke-IdleStepEnsureAttributes.Tests.ps1 index 636b7aba..f2aa812c 100644 --- a/tests/Steps/Invoke-IdleStepEnsureAttributes.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepEnsureAttributes.Tests.ps1 @@ -108,7 +108,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { PSTypeName = 'IdLE.ExecutionContext' Plan = $null Providers = @{ Identity = $script:FakeProviderLegacy } - EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + EventSink = (New-IdleTestEventSink) } $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { @@ -143,7 +143,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { } $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires*With*to be a hashtable*' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw '*WithKeyMissing*' } It 'throws when With is not a hashtable' { @@ -154,7 +154,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { } $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires*With*to be a hashtable*' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw '*WithKeyTypeMismatch*With must be a Hashtable*' } It 'throws when With.IdentityKey is missing' { @@ -162,7 +162,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { $step.With.Remove('IdentityKey') $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.IdentityKey*' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw '*WithKeyMissing*With.IdentityKey*' } It 'throws when With.Attributes is missing' { @@ -170,7 +170,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { $step.With.Remove('Attributes') $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.Attributes*' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw '*WithKeyMissing*With.Attributes*' } It 'throws when With.Attributes is not a hashtable' { @@ -178,7 +178,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { $step.With.Attributes = 'not-a-hashtable' $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.Attributes to be a hashtable*' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw '*WithKeyTypeMismatch*With.Attributes must be a Hashtable*' } It 'throws when With.Attributes is empty' { @@ -186,14 +186,14 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { $step.With.Attributes = @{} $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.Attributes to contain at least one attribute*' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw '*requires With.Attributes to contain at least one attribute*' } It 'throws when provider is missing' { $script:Context.Providers.Clear() $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw '*Provider*was not supplied*' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate } | Should -Throw '*Provider*was not supplied*' } } @@ -204,7 +204,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { It 'calls EnsureAttributes method once when available' { $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $script:FakeProviderOptimized.CallLog.Count | Should -Be 1 $script:FakeProviderOptimized.CallLog[0].Method | Should -Be 'EnsureAttributes' @@ -217,14 +217,14 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { It 'returns Changed=true when provider reports changes' { $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Changed | Should -Be $true } It 'includes per-attribute results from provider' { $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Data | Should -Not -BeNullOrEmpty $result.Data.Attributes | Should -Not -BeNullOrEmpty @@ -237,7 +237,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { $step.With.AuthSessionOptions = @{ Role = 'Admin' } $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $script:FakeProviderOptimized.CallLog[0].AuthSession | Should -Not -BeNullOrEmpty $script:FakeProviderOptimized.CallLog[0].AuthSession.SessionName | Should -Be 'MicrosoftGraph' @@ -247,7 +247,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { Context 'Provider fallback (multiple EnsureAttribute calls)' { It 'calls EnsureAttribute for each attribute when EnsureAttributes not available' { $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $script:FakeProviderLegacy.CallLog.Count | Should -Be 3 $script:FakeProviderLegacy.CallLog[0].Method | Should -Be 'EnsureAttribute' @@ -257,7 +257,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { It 'passes correct IdentityKey to each EnsureAttribute call' { $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $script:FakeProviderLegacy.CallLog | ForEach-Object { $_.IdentityKey | Should -Be 'user@contoso.com' @@ -266,7 +266,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { It 'passes correct attribute name and value to each call' { $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $callsByName = @{} $script:FakeProviderLegacy.CallLog | ForEach-Object { @@ -280,7 +280,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { It 'returns Changed=true when any attribute changed' { $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate # Department and Title return Changed=true in our mock $result.Changed | Should -Be $true @@ -304,14 +304,14 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { } -Force $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Changed | Should -Be $false } It 'includes per-attribute results in fallback mode' { $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Data.Attributes | Should -Not -BeNullOrEmpty $result.Data.Attributes.Count | Should -Be 3 @@ -325,9 +325,9 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { It 'passes $null attribute values without error (regression: MethodArguments binding)' { # When attributes contain $null values (e.g., to unset/clear an attribute), - # Invoke-IdleProviderMethod must not fail with "Cannot bind argument to parameter - # 'MethodArguments' because it is null." - caused by PS binding an [object[]] - # containing $null elements to a [Mandatory] parameter without [AllowNull()]. + # the provider call must not fail with "Cannot bind argument to parameter ... because it is null." + # Originally caused by PS binding an [object[]] containing $null elements to a [Mandatory] + # parameter without [AllowNull()] in the (since removed) Invoke-IdleProviderMethod. $step = [pscustomobject]@{ Name = 'Unset phone attributes' Type = 'IdLE.Step.EnsureAttributes' @@ -342,7 +342,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { } $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - { & $handler -Context $script:Context -Step $step } | Should -Not -Throw + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Not -Throw # Both attributes should have been called with $null value $callsByName = @{} @@ -359,7 +359,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { Context 'StepResult shape' { It 'returns StepResult with correct type and properties' { $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result | Should -Not -BeNullOrEmpty $result.PSObject.TypeNames[0] | Should -Be 'IdLE.StepResult' @@ -379,7 +379,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { $step.With.Remove('Provider') $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $script:FakeProviderLegacy.CallLog.Count | Should -BeGreaterThan 0 @@ -391,7 +391,7 @@ Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { $step.With.Provider = 'CustomAD' $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $script:FakeProviderLegacy.CallLog.Count | Should -BeGreaterThan 0 diff --git a/tests/Steps/Invoke-IdleStepEnsureEntitlement.Tests.ps1 b/tests/Steps/Invoke-IdleStepEnsureEntitlement.Tests.ps1 index 5385c714..1a01916f 100644 --- a/tests/Steps/Invoke-IdleStepEnsureEntitlement.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepEnsureEntitlement.Tests.ps1 @@ -12,7 +12,7 @@ Describe 'Invoke-IdleStepEnsureEntitlement (built-in step)' { PSTypeName = 'IdLE.ExecutionContext' Plan = $null Providers = @{ Identity = $script:Provider } - EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + EventSink = (New-IdleTestEventSink) } $script:StepTemplate = [pscustomobject]@{ @@ -34,7 +34,7 @@ Describe 'Invoke-IdleStepEnsureEntitlement (built-in step)' { $step = $script:StepTemplate $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -48,7 +48,7 @@ Describe 'Invoke-IdleStepEnsureEntitlement (built-in step)' { $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 = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Status | Should -Be 'Completed' $result.Changed | Should -BeFalse @@ -62,7 +62,7 @@ Describe 'Invoke-IdleStepEnsureEntitlement (built-in step)' { $step.With.State = 'Absent' $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -76,12 +76,12 @@ Describe 'Invoke-IdleStepEnsureEntitlement (built-in step)' { $script:Context.Providers.Clear() $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureEntitlement' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + { Invoke-IdleTestStepHandler -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 * + { Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * } } } diff --git a/tests/Steps/Invoke-IdleStepMailboxGetInfo.Tests.ps1 b/tests/Steps/Invoke-IdleStepMailboxGetInfo.Tests.ps1 index 436929ca..37d9bead 100644 --- a/tests/Steps/Invoke-IdleStepMailboxGetInfo.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepMailboxGetInfo.Tests.ps1 @@ -49,7 +49,7 @@ Describe 'Invoke-IdleStepMailboxGetInfo' { PSTypeName = 'IdLE.ExecutionContext' Plan = $null Providers = @{ ExchangeOnline = $script:Provider } - EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + EventSink = (New-IdleTestEventSink) } # Add mock AcquireAuthSession method @@ -71,7 +71,7 @@ Describe 'Invoke-IdleStepMailboxGetInfo' { Context 'Behavior' { It 'retrieves mailbox and returns data in State' { $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Status | Should -Be 'Completed' $result.Changed | Should -Be $false @@ -86,7 +86,7 @@ Describe 'Invoke-IdleStepMailboxGetInfo' { $step.With.Remove('AuthSessionName') $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $step.With.ContainsKey('AuthSessionName') | Should -Be $false @@ -98,7 +98,7 @@ Describe 'Invoke-IdleStepMailboxGetInfo' { $script:Context.Providers.Clear() $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + { Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * } It 'throws when IdentityKey is missing' { @@ -106,7 +106,7 @@ Describe 'Invoke-IdleStepMailboxGetInfo' { $step.With.Remove('IdentityKey') $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw "*WithKeyMissing*With.IdentityKey*" } } } diff --git a/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 b/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 index 56f5c921..2a73dcc0 100644 --- a/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 @@ -60,7 +60,7 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { PSTypeName = 'IdLE.ExecutionContext' Plan = $null Providers = @{ ExchangeOnline = $script:Provider } - EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + EventSink = (New-IdleTestEventSink) } # Add mock AcquireAuthSession method @@ -71,7 +71,7 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { $script:StepTemplate = [pscustomobject]@{ Name = 'Enable Out of Office' - Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' IdentityKey = 'user@contoso.com' @@ -87,7 +87,7 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { Context 'Behavior' { It 'enables Out of Office and reports Changed = true' { $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Status | Should -Be 'Completed' $result.Changed | Should -Be $true @@ -100,7 +100,7 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { $script:Provider.Store['user@contoso.com']['OOFMode'] = 'Enabled' $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Status | Should -Be 'Completed' $result.Changed | Should -Be $false @@ -113,7 +113,7 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { $step.With.Config = @{ Mode = 'Disabled' } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -Be $true @@ -133,7 +133,7 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -Be $true @@ -147,7 +147,7 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { $step.With.Remove('Config') $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.Config*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw "*WithKeyMissing*With.Config*" } It 'throws when Config.Mode is missing' { @@ -155,7 +155,7 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { $step.With.Config = @{ InternalMessage = 'Test' } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.Config.Mode*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw "*WithKeyMissing*With.Config.Mode*" } It 'throws when Config.Mode is invalid' { @@ -163,8 +163,8 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { $step.With.Config.Mode = 'InvalidMode' $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $step } | - Should -Throw "*Mode to be one of: Disabled, Enabled, Scheduled*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | + Should -Throw "*WithValueNotAllowed*" } It 'throws when Scheduled mode is missing Start' { @@ -175,8 +175,8 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $step } | - Should -Throw "*Mode 'Scheduled' requires With.Config.Start*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | + Should -Throw "*Scheduled*requires With.Config.Start and With.Config.End*" } It 'throws when Scheduled mode is missing End' { @@ -187,8 +187,8 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $step } | - Should -Throw "*Mode 'Scheduled' requires With.Config.End*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | + Should -Throw "*Scheduled*requires With.Config.Start and With.Config.End*" } It 'rejects ScriptBlocks in Config (security boundary)' { @@ -196,7 +196,7 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { $step.With.Config.InternalMessage = { Write-Host "malicious" } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $step } | + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw "*ScriptBlocks are not allowed*" } @@ -204,7 +204,7 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { $script:Context.Providers.Clear() $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + { Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * } It 'throws when IdentityKey is missing' { @@ -212,7 +212,7 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { $step.With.Remove('IdentityKey') $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw "*WithKeyMissing*With.IdentityKey*" } } @@ -222,7 +222,7 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { $step.With.Config.MessageFormat = 'Text' $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' } @@ -232,7 +232,7 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { $step.With.Config.MessageFormat = 'Html' $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' } @@ -242,8 +242,8 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { $step.With.Config.MessageFormat = 'InvalidFormat' $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' - { & $handler -Context $script:Context -Step $step } | - Should -Throw "*MessageFormat to be one of: Text, Html*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | + Should -Throw "*WithValueNotAllowed*" } } } diff --git a/tests/Steps/Invoke-IdleStepMailboxPermissionsEnsure.Tests.ps1 b/tests/Steps/Invoke-IdleStepMailboxPermissionsEnsure.Tests.ps1 index cdb76d89..a5dba8b6 100644 --- a/tests/Steps/Invoke-IdleStepMailboxPermissionsEnsure.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepMailboxPermissionsEnsure.Tests.ps1 @@ -94,7 +94,7 @@ Describe 'Invoke-IdleStepMailboxPermissionsEnsure' { PSTypeName = 'IdLE.ExecutionContext' Plan = $null Providers = @{ ExchangeOnline = $script:Provider } - EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + EventSink = (New-IdleTestEventSink) } $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { @@ -118,7 +118,7 @@ Describe 'Invoke-IdleStepMailboxPermissionsEnsure' { Context 'Behavior' { It 'grants FullAccess and reports Changed = true' { $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Status | Should -Be 'Completed' $result.Changed | Should -Be $true @@ -131,7 +131,7 @@ Describe 'Invoke-IdleStepMailboxPermissionsEnsure' { $script:Provider.Store.FullAccess['shared@contoso.com'] = @{ 'user1@contoso.com' = $true } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Status | Should -Be 'Completed' $result.Changed | Should -Be $false @@ -153,7 +153,7 @@ Describe 'Invoke-IdleStepMailboxPermissionsEnsure' { } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -Be $true @@ -174,7 +174,7 @@ Describe 'Invoke-IdleStepMailboxPermissionsEnsure' { } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -Be $false @@ -194,7 +194,7 @@ Describe 'Invoke-IdleStepMailboxPermissionsEnsure' { } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -Be $true @@ -215,7 +215,7 @@ Describe 'Invoke-IdleStepMailboxPermissionsEnsure' { } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -Be $true @@ -238,7 +238,7 @@ Describe 'Invoke-IdleStepMailboxPermissionsEnsure' { } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -Be $true @@ -260,7 +260,7 @@ Describe 'Invoke-IdleStepMailboxPermissionsEnsure' { } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.Permissions*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw "*WithKeyMissing*With.Permissions*" } It 'throws when IdentityKey is missing' { @@ -276,7 +276,7 @@ Describe 'Invoke-IdleStepMailboxPermissionsEnsure' { } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw "*WithKeyMissing*With.IdentityKey*" } It 'throws when a Permissions entry is missing AssignedUser' { @@ -293,7 +293,7 @@ Describe 'Invoke-IdleStepMailboxPermissionsEnsure' { } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires 'AssignedUser'*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw "*WithKeyMissing*With.Permissions[[]0[]].AssignedUser*" } It 'throws when a Permissions entry has an invalid Right' { @@ -310,7 +310,7 @@ Describe 'Invoke-IdleStepMailboxPermissionsEnsure' { } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*Right must be one of*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw "*Right must be one of*" } It 'throws when a Permissions entry has an invalid Ensure value' { @@ -327,7 +327,7 @@ Describe 'Invoke-IdleStepMailboxPermissionsEnsure' { } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*Ensure must be one of*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw "*Ensure must be one of*" } It 'rejects ScriptBlocks in Permissions (security boundary)' { @@ -344,14 +344,14 @@ Describe 'Invoke-IdleStepMailboxPermissionsEnsure' { } $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*ScriptBlocks are not allowed*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw "*ScriptBlocks are not allowed*" } It 'throws when provider is missing' { $script:Context.Providers.Clear() $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + { Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * } } } diff --git a/tests/Steps/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 b/tests/Steps/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 index 98dc93f7..68a56125 100644 --- a/tests/Steps/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 @@ -56,7 +56,7 @@ Describe 'Invoke-IdleStepMailboxTypeEnsure' { PSTypeName = 'IdLE.ExecutionContext' Plan = $null Providers = @{ ExchangeOnline = $script:Provider } - EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + EventSink = (New-IdleTestEventSink) } # Add mock AcquireAuthSession method @@ -67,7 +67,7 @@ Describe 'Invoke-IdleStepMailboxTypeEnsure' { $script:StepTemplate = [pscustomobject]@{ Name = 'Convert to shared mailbox' - Type = 'IdLE.Step.Mailbox.Type.Ensure' + Type = 'IdLE.Step.Mailbox.EnsureType' With = @{ Provider = 'ExchangeOnline' IdentityKey = 'user@contoso.com' @@ -79,7 +79,7 @@ Describe 'Invoke-IdleStepMailboxTypeEnsure' { Context 'Behavior' { It 'converts mailbox type and reports Changed = true' { $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Status | Should -Be 'Completed' $result.Changed | Should -Be $true @@ -91,7 +91,7 @@ Describe 'Invoke-IdleStepMailboxTypeEnsure' { $script:Provider.Store['user@contoso.com']['Type'] = 'Shared' $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Status | Should -Be 'Completed' $result.Changed | Should -Be $false @@ -106,7 +106,7 @@ Describe 'Invoke-IdleStepMailboxTypeEnsure' { $step.With.MailboxType = $type $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Changed | Should -Be $true $script:Provider.Store['user@contoso.com']['Type'] | Should -Be $type @@ -120,15 +120,15 @@ Describe 'Invoke-IdleStepMailboxTypeEnsure' { $step.With.MailboxType = 'InvalidType' $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' - { & $handler -Context $script:Context -Step $step } | - Should -Throw "*MailboxType to be one of: User, Shared, Room, Equipment*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | + Should -Throw "*WithValueNotAllowed*" } It 'throws when provider is missing' { $script:Context.Providers.Clear() $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + { Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * } It 'throws when IdentityKey is missing' { @@ -136,7 +136,7 @@ Describe 'Invoke-IdleStepMailboxTypeEnsure' { $step.With.Remove('IdentityKey') $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw "*WithKeyMissing*With.IdentityKey*" } It 'throws when MailboxType is missing' { @@ -144,7 +144,7 @@ Describe 'Invoke-IdleStepMailboxTypeEnsure' { $step.With.Remove('MailboxType') $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.MailboxType*" + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw "*WithKeyMissing*With.MailboxType*" } } } diff --git a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 index b40865f4..82f97adb 100644 --- a/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepPruneEntitlements.Tests.ps1 @@ -12,7 +12,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { PSTypeName = 'IdLE.ExecutionContext' Plan = $null Providers = @{ Identity = $script:Provider } - EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + EventSink = (New-IdleTestEventSink) } # Seed the identity with some entitlements @@ -39,7 +39,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { Context 'Behavior: Keep only' { It 'removes entitlements not in the keep set' { $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -56,7 +56,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { ) $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' @@ -71,7 +71,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { $null = $script:Provider.RevokeEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=LEAVER-EXTRA,DC=contoso,DC=com' }) $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Status | Should -Be 'Completed' $result.Changed | Should -BeFalse @@ -87,7 +87,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -104,7 +104,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -120,7 +120,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { $step.With.KeepPattern = @('CN=LEAVER-*,DC=contoso,DC=com') $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -142,7 +142,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { $step.With.KeepPattern = @('CN=LEAVER-*,DC=contoso,DC=com') $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -169,7 +169,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' @@ -179,18 +179,18 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } } - Context 'Behavior: EnsureKeepEntitlements' { - It 'grants missing explicit Keep entitlements when EnsureKeepEntitlements is $true' { + Context 'Behavior: EnsureKeep (IdLE.Step.PruneEntitlementsEnsureKeep)' { + It 'grants missing explicit Keep entitlements' { $step = $script:StepTemplate # Add a Keep item that does NOT exist in current entitlements $step.With.Keep = @( @{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,DC=contoso,DC=com' } @{ Kind = 'Group'; Id = 'CN=LEAVER-NEW,DC=contoso,DC=com' } ) - $step.With.EnsureKeepEntitlements = $true + $step.Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -203,7 +203,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { It 'does not re-grant already present Keep entitlements' { $step = $script:StepTemplate - $step.With.EnsureKeepEntitlements = $true + $step.Type = 'IdLE.Step.PruneEntitlementsEnsureKeep' # Replace GrantEntitlement with a counter to verify it is never called # (CN=LEAVER-RETAIN is already present in the seeded entitlements) @@ -215,7 +215,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } -Force $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $script:grantCount | Should -Be 0 -Because 'CN=LEAVER-RETAIN is already present; GrantEntitlement must not be called' @@ -225,22 +225,6 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { $remainingIds | Should -Contain 'CN=LEAVER-RETAIN,DC=contoso,DC=com' } - It 'does not grant pattern-matched entitlements (only explicit Keep items)' { - $step = $script:StepTemplate - $step.With.KeepPattern = @('CN=LEAVER-*,DC=contoso,DC=com') - $step.With.EnsureKeepEntitlements = $true - - $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - $result = & $handler -Context $script:Context -Step $step - - $result.Status | Should -Be 'Completed' - - $remaining = $script:Provider.ListEntitlements('user1') - $remainingIds = $remaining | Select-Object -ExpandProperty Id - # Only explicit Keep items from Keep array are granted if missing; patterns are not granted - $remainingIds | Should -Contain 'CN=LEAVER-RETAIN,DC=contoso,DC=com' - $remainingIds | Should -Contain 'CN=LEAVER-EXTRA,DC=contoso,DC=com' - } } Context 'Behavior: Non-removable entitlement handling' { @@ -248,22 +232,14 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { # Mark CN=G-All as protected (non-removable, like AD primary group) $script:Provider.ProtectedEntitlementIds = @('CN=G-All,DC=contoso,DC=com') - $warningEvents = @() - $script:Context.EventSink = [pscustomobject]@{ - WriteEvent = { - param($Type, $Message, $StepName, $Data) - if ($Type -eq 'IdLE.Step.PruneEntitlements.EntitlementSkipped') { - $script:warningEvents += $Message - } - }.GetNewClosure() - } + $script:Context.EventSink = New-IdleTestEventSink $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Status | Should -Be 'Completed' - $result.Skipped | Should -Not -BeNullOrEmpty - $result.Skipped[0].EntitlementId | Should -Be 'CN=G-All,DC=contoso,DC=com' + $result.Data.Skipped | Should -Not -BeNullOrEmpty + $result.Data.Skipped[0].EntitlementId | Should -Be 'CN=G-All,DC=contoso,DC=com' # Non-protected entitlements should still be removed $remaining = $script:Provider.ListEntitlements('user1') @@ -277,10 +253,10 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { $script:Provider.ProtectedEntitlementIds = @('CN=G-All,DC=contoso,DC=com') $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate - $result.Skipped | Should -Not -BeNullOrEmpty - $result.Skipped[0].Reason | Should -Not -BeNullOrEmpty + $result.Data.Skipped | Should -Not -BeNullOrEmpty + $result.Data.Skipped[0].Reason | Should -Not -BeNullOrEmpty } } @@ -290,7 +266,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { $null = $script:Provider.GrantEntitlement('user1', @{ Kind = 'Role'; Id = 'admin-role' }) $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Status | Should -Be 'Completed' @@ -311,7 +287,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - { & $handler -Context $script:Context -Step $step } | Should -Throw + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw } It 'throws when With.IdentityKey is missing' { @@ -322,7 +298,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - { & $handler -Context $script:Context -Step $step } | Should -Throw + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw } It 'throws when With.Kind is missing' { @@ -333,7 +309,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - { & $handler -Context $script:Context -Step $step } | Should -Throw + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw } It 'throws when Keep item is missing an Id' { @@ -349,7 +325,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - { & $handler -Context $script:Context -Step $step } | Should -Throw + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw } It 'throws when KeepPattern contains a ScriptBlock' { @@ -365,14 +341,14 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - { & $handler -Context $script:Context -Step $step } | Should -Throw + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw } It 'throws when the provider is missing' { $script:Context.Providers.Clear() $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw + { Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate } | Should -Throw } It 'throws when AuthSessionOptions is not a hashtable' { @@ -385,7 +361,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } -Force $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - { & $handler -Context $script:Context -Step $step } | Should -Throw + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw } } @@ -446,7 +422,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -508,7 +484,7 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { } $handler = 'IdLE.Steps.Common\Invoke-IdleStepPruneEntitlements' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -516,8 +492,8 @@ Describe 'Invoke-IdleStepPruneEntitlements (built-in step)' { # G-Remove1 bulk-revoked, G-Protected returned as error → Skipped $mockProvider.BulkInputIds | Should -Contain 'CN=G-Remove1,DC=contoso,DC=com' $mockProvider.BulkInputIds | Should -Contain 'CN=G-Protected,DC=contoso,DC=com' - $result.Skipped | Should -Not -BeNullOrEmpty - $result.Skipped[0].EntitlementId | Should -Match 'Protected' + $result.Data.Skipped | Should -Not -BeNullOrEmpty + $result.Data.Skipped[0].EntitlementId | Should -Match 'Protected' } } } @@ -529,7 +505,7 @@ Describe 'Invoke-IdleStepPruneEntitlementsEnsureKeep (built-in step)' { PSTypeName = 'IdLE.ExecutionContext' Plan = $null Providers = @{ Identity = $script:Provider } - EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + EventSink = (New-IdleTestEventSink) } # Seed the identity with some entitlements @@ -573,7 +549,7 @@ Describe 'Invoke-IdleStepPruneEntitlementsEnsureKeep (built-in step)' { Context 'Behavior: Keep only (prune + ensure)' { It 'removes non-kept entitlements and reports Changed' { - $result = & $script:Handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -590,7 +566,7 @@ Describe 'Invoke-IdleStepPruneEntitlementsEnsureKeep (built-in step)' { @{ Kind = 'Group'; Id = 'CN=LEAVER-NEW,DC=contoso,DC=com' } ) - $result = & $script:Handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -604,7 +580,7 @@ Describe 'Invoke-IdleStepPruneEntitlementsEnsureKeep (built-in step)' { $null = $script:Provider.RevokeEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=G-HR,DC=contoso,DC=com' }) $null = $script:Provider.RevokeEntitlement('user1', @{ Kind = 'Group'; Id = 'CN=LEAVER-EXTRA,DC=contoso,DC=com' }) - $result = & $script:Handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Status | Should -Be 'Completed' $result.Changed | Should -BeFalse @@ -616,14 +592,14 @@ Describe 'Invoke-IdleStepPruneEntitlementsEnsureKeep (built-in step)' { $step = $script:StepTemplate $step.With.KeepPattern = @('CN=LEAVER-*,DC=contoso,DC=com') - { & $script:Handler -Context $script:Context -Step $step } | Should -Throw -ExpectedMessage '*KeepPattern*' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw -ExpectedMessage '*KeepPattern*' } It 'throws when KeepPattern is provided even if empty' { $step = $script:StepTemplate $step.With.KeepPattern = @() - { & $script:Handler -Context $script:Context -Step $step } | Should -Throw -ExpectedMessage '*KeepPattern*' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw -ExpectedMessage '*KeepPattern*' } } @@ -635,7 +611,7 @@ Describe 'Invoke-IdleStepPruneEntitlementsEnsureKeep (built-in step)' { With = @{ IdentityKey = 'user1'; Kind = 'Group'; Provider = 'Identity' } } - $result = & $script:Handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -649,11 +625,11 @@ Describe 'Invoke-IdleStepPruneEntitlementsEnsureKeep (built-in step)' { It 'skips non-removable entitlements with a structured warning and continues' { $script:Provider.ProtectedEntitlementIds = @('CN=G-All,DC=contoso,DC=com') - $result = & $script:Handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Status | Should -Be 'Completed' - $result.Skipped | Should -Not -BeNullOrEmpty - $result.Skipped[0].EntitlementId | Should -Be 'CN=G-All,DC=contoso,DC=com' + $result.Data.Skipped | Should -Not -BeNullOrEmpty + $result.Data.Skipped[0].EntitlementId | Should -Be 'CN=G-All,DC=contoso,DC=com' # Non-protected items should still have been removed $remaining = $script:Provider.ListEntitlements('user1') diff --git a/tests/Steps/Invoke-IdleStepRevokeIdentitySessions.Tests.ps1 b/tests/Steps/Invoke-IdleStepRevokeIdentitySessions.Tests.ps1 index 10b43f35..85a4ed3f 100644 --- a/tests/Steps/Invoke-IdleStepRevokeIdentitySessions.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepRevokeIdentitySessions.Tests.ps1 @@ -44,7 +44,7 @@ Describe 'Invoke-IdleStepRevokeIdentitySessions (built-in step)' { PSTypeName = 'IdLE.ExecutionContext' Plan = $null Providers = @{ Identity = $script:FakeProvider } - EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + EventSink = (New-IdleTestEventSink) } $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { @@ -71,7 +71,7 @@ Describe 'Invoke-IdleStepRevokeIdentitySessions (built-in step)' { $step = $script:StepTemplate $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -Be $true @@ -81,7 +81,7 @@ Describe 'Invoke-IdleStepRevokeIdentitySessions (built-in step)' { It 'returns StepResult with correct shape' { $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result | Should -Not -BeNullOrEmpty $result.PSObject.TypeNames[0] | Should -Be 'IdLE.StepResult' @@ -105,7 +105,7 @@ Describe 'Invoke-IdleStepRevokeIdentitySessions (built-in step)' { } -Force $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - $result = & $handler -Context $script:Context -Step $script:StepTemplate + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $result.Changed | Should -Be $false } @@ -118,7 +118,7 @@ Describe 'Invoke-IdleStepRevokeIdentitySessions (built-in step)' { $step.With.AuthSessionOptions = @{ Role = 'Admin' } $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $script:FakeProvider.CallLog.Count | Should -Be 1 @@ -133,14 +133,14 @@ Describe 'Invoke-IdleStepRevokeIdentitySessions (built-in step)' { $step.With.Remove('IdentityKey') $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.IdentityKey*' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw '*WithKeyMissing*With.IdentityKey*' } It 'throws when provider is missing' { $script:Context.Providers.Clear() $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw '*Provider*was not supplied*' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate } | Should -Throw '*Provider*was not supplied*' } It 'throws when provider does not support RevokeSessions method' { @@ -154,7 +154,7 @@ Describe 'Invoke-IdleStepRevokeIdentitySessions (built-in step)' { $script:Context.Providers['Identity'] = $unsupportedProvider $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + { Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * } } @@ -164,7 +164,7 @@ Describe 'Invoke-IdleStepRevokeIdentitySessions (built-in step)' { $step.With.Remove('Provider') $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $script:FakeProvider.CallLog.Count | Should -Be 1 @@ -176,7 +176,7 @@ Describe 'Invoke-IdleStepRevokeIdentitySessions (built-in step)' { $step.With.Provider = 'CustomEntra' $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $script:FakeProvider.CallLog.Count | Should -Be 1 diff --git a/tests/Steps/Invoke-IdleStepTriggerDirectorySync.Tests.ps1 b/tests/Steps/Invoke-IdleStepTriggerDirectorySync.Tests.ps1 index 690a54f9..3f3b8b30 100644 --- a/tests/Steps/Invoke-IdleStepTriggerDirectorySync.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepTriggerDirectorySync.Tests.ps1 @@ -115,14 +115,16 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { } Context 'Input validation' { - It 'throws when With is missing' { + It 'accepts a missing With (all inputs are optional)' { $step = [pscustomobject]@{ Name = 'Trigger sync' Type = 'IdLE.Step.TriggerDirectorySync' } + & $script:SetPermissiveStartSyncCycle - $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - { & $handler -Context $script:Context -Step $step } | Should -Throw + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' } It 'does not enforce With.AuthSessionName at step level' { @@ -130,7 +132,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $step.With.Remove('AuthSessionName') $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' } @@ -141,7 +143,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { & $script:SetPermissiveStartSyncCycle $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' } @@ -151,7 +153,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $step.With.PolicyType = 'Initial' $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' } @@ -175,7 +177,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' $thrown = $null try { - $null = & $handler -Context $script:Context -Step $step + $null = Invoke-IdleTestStepHandler -Context $script:Context -Step $step } catch { $thrown = $_ @@ -195,7 +197,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { & $script:SetPermissiveStartSyncCycle $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' } @@ -206,22 +208,16 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { & $script:SetPermissiveStartSyncCycle $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' } - It 'coerces non-string PolicyType and ComputerName to strings before provider invocation' { + It 'rejects non-string PolicyType and ComputerName at plan time (WithKeyTypeMismatch)' { $step = $script:StepTemplate $step.With.PolicyType = 123 - $step.With.ComputerName = 456 - - $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - $result = & $handler -Context $script:Context -Step $step - $result.Status | Should -Be 'Completed' - $script:MockProvider.LastPolicyType | Should -Be '123' - $script:MockProvider.LastComputerName | Should -Be '456' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw '*WithKeyTypeMismatch*With.PolicyType must be a String*' } It 'uses default provider alias when not specified' { @@ -229,7 +225,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $step.With.Remove('Provider') $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' } @@ -239,7 +235,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $step.With.TimeoutSeconds = -1 $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - { & $handler -Context $script:Context -Step $step } | Should -Throw -ErrorId * -ExpectedMessage '*TimeoutSeconds*' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw -ErrorId * -ExpectedMessage '*TimeoutSeconds*' } It 'throws when PollIntervalSeconds is invalid' { @@ -247,7 +243,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $step.With.PollIntervalSeconds = 0 $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - { & $handler -Context $script:Context -Step $step } | Should -Throw -ErrorId * -ExpectedMessage '*PollIntervalSeconds*' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw -ErrorId * -ExpectedMessage '*PollIntervalSeconds*' } } @@ -257,7 +253,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $step.With.Wait = $false $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -269,7 +265,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $step = $script:StepTemplate $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' } @@ -282,7 +278,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $step.With.PollIntervalSeconds = 1 $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue @@ -305,7 +301,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $step.With.PollIntervalSeconds = 1 $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - { & $handler -Context $script:Context -Step $step } | Should -Throw -ErrorId * -ExpectedMessage '*Timeout*' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $step } | Should -Throw -ErrorId * -ExpectedMessage '*Timeout*' } It 'polls provider state multiple times' { @@ -327,7 +323,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $step.With.PollIntervalSeconds = 1 $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - $result = & $handler -Context $script:Context -Step $step + $result = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $script:MockProvider.PollCount | Should -BeGreaterThan 1 } @@ -338,7 +334,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $script:Context.Providers.Clear() $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * -ExpectedMessage '*Provider*' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * -ExpectedMessage '*Provider*' } It 'throws when provider does not implement StartSyncCycle' { @@ -346,7 +342,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $script:Context.Providers['DirectorySync'] = $badProvider $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * -ExpectedMessage '*StartSyncCycle*' + { Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * -ExpectedMessage '*StartSyncCycle*' } } @@ -360,7 +356,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { } -Force $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - $null = & $handler -Context $script:Context -Step $script:StepTemplate + $null = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $capturedEvents.Type | Should -Contain 'IdLE.Step.DirectorySync.Triggered' } @@ -378,31 +374,12 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { & $script:SetPermissiveStartSyncCycle $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - $null = & $handler -Context $script:Context -Step $step + $null = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $triggerEvent = $capturedEvents | Where-Object { $_.Type -eq 'IdLE.Step.DirectorySync.Triggered' } | Select-Object -First 1 $triggerEvent.Message | Should -Be 'Triggering directory sync cycle' } - It 'emits string values in IdLE.Step.DirectorySync.Triggered event data for PolicyType and ComputerName' { - $capturedEvents = [System.Collections.ArrayList]::new() - $script:Context.EventSink = [pscustomobject]@{} - $script:Context.EventSink | Add-Member -MemberType ScriptMethod -Name WriteEvent -Value { - param($Type, $Message, $StepName, $Data) - $null = $capturedEvents.Add(@{ Type = $Type; Message = $Message; StepName = $StepName; Data = $Data }) - } -Force - - $step = $script:StepTemplate - $step.With.PolicyType = 123 - $step.With.ComputerName = 456 - - $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - $null = & $handler -Context $script:Context -Step $step - - $triggerEvent = $capturedEvents | Where-Object { $_.Type -eq 'IdLE.Step.DirectorySync.Triggered' } | Select-Object -First 1 - $triggerEvent.Data.PolicyType | Should -Be '123' - $triggerEvent.Data.ComputerName | Should -Be '456' - } It 'emits IdLE.Step.DirectorySync.Completed event' { $capturedEvents = [System.Collections.ArrayList]::new() @@ -413,7 +390,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { } -Force $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - $null = & $handler -Context $script:Context -Step $script:StepTemplate + $null = Invoke-IdleTestStepHandler -Context $script:Context -Step $script:StepTemplate $capturedEvents.Type | Should -Contain 'IdLE.Step.DirectorySync.Completed' } @@ -431,7 +408,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $step.With.PollIntervalSeconds = 1 $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - $null = & $handler -Context $script:Context -Step $step + $null = Invoke-IdleTestStepHandler -Context $script:Context -Step $step $capturedEvents.Type | Should -Contain 'IdLE.Step.DirectorySync.Waiting' } From baccb8dd90fe93e72b0f12be32d6fc76461f1cc7 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:53:42 +0100 Subject: [PATCH 4/6] docs: document the step contract and generate With tables from the schema (#324) - steps.md: step metadata contract and step handler contract - extensibility.md: adding steps and registering handlers - Step reference generator reads With keys (type, required, default, notes) from the catalog schema instead of parsing handler source; references regenerated Co-Authored-By: Claude Opus 5.5 --- docs/extend/extensibility.md | 17 ++- docs/extend/steps.md | 120 ++++++++++++--- docs/reference/steps/step-create-identity.md | 29 ++-- docs/reference/steps/step-delete-identity.md | 27 ++-- docs/reference/steps/step-disable-identity.md | 27 ++-- docs/reference/steps/step-emit-event.md | 9 +- docs/reference/steps/step-enable-identity.md | 27 ++-- .../reference/steps/step-ensure-attributes.md | 26 ++-- .../steps/step-ensure-entitlement.md | 44 +++--- .../step-mailbox-ensure-out-of-office.md | 32 ++-- .../steps/step-mailbox-ensure-permissions.md | 30 ++-- .../steps/step-mailbox-ensure-type.md | 28 ++-- docs/reference/steps/step-mailbox-get-info.md | 26 ++-- docs/reference/steps/step-move-identity.md | 29 ++-- .../step-prune-entitlements-ensure-keep.md | 31 ++-- .../steps/step-prune-entitlements.md | 37 ++--- .../steps/step-revoke-identity-sessions.md | 27 ++-- .../steps/step-trigger-directory-sync.md | 32 ++-- tools/Generate-IdleStepReference.ps1 | 143 +++++++++--------- 19 files changed, 372 insertions(+), 369 deletions(-) diff --git a/docs/extend/extensibility.md b/docs/extend/extensibility.md index d0de6362..deab1b8c 100644 --- a/docs/extend/extensibility.md +++ b/docs/extend/extensibility.md @@ -13,12 +13,13 @@ IdLE is designed for change through modules instead of forks. A new step typically involves: -1. A metadata definition declaring required capabilities and `WithSchema` (allowed `With.*` keys) -2. A planning function (test) that produces data-only actions -3. An execution function (invoke) that performs actions via providers -4. Unit tests (Pester) +1. A metadata definition declaring required capabilities, the provider it uses and a typed `WithSchema` +2. A handler function that receives the validated `With` and a provider handle and returns `@{ Changed; Data }` +3. Unit tests (Pester) -See [Steps and Metadata](steps.md#step-metadata-contract) for the required metadata shape. +The engine validates `With`, resolves the provider, handles authentication and builds the step result, so the +handler contains only the step's domain logic. See [Step metadata contract](steps.md#step-metadata-contract) and +[Step handler contract](steps.md#step-handler-contract). Extension step types and capabilities use your own owner prefix: `.IdLE.Step..` and `.IdLE..` (e.g. `Contoso.IdLE.Step.Hr.SyncManager`, `Contoso.IdLE.Hr.Manager.Read`). @@ -121,9 +122,9 @@ Steps are executed via the step registry. ScriptBlock handlers are intentionally not supported as a secure default. -Step handlers may optionally declare a `Context` parameter. -For backwards compatibility, the engine passes `-Context` only when the handler -supports it. +Handlers follow the [step handler contract](steps.md#step-handler-contract): they declare `-With` (and `-Provider` +if the metadata declares a provider; `-Context` and `-Step` as needed) and return `$null` or `@{ Changed; Data; State }`. +Handlers written for the pre-1.0 contract (`-Context`/`-Step` only) fail with `LegacyStepHandler`. --- diff --git a/docs/extend/steps.md b/docs/extend/steps.md index 42447540..7691460c 100644 --- a/docs/extend/steps.md +++ b/docs/extend/steps.md @@ -69,34 +69,112 @@ Typical conceptual metadata includes: ### Step metadata contract -Every step type registered with the engine **must** declare a data-only metadata entry containing: - -- `RequiredCapabilities` — capability identifiers the step requires from providers -- `WithSchema` — declares the `With.*` key contract used for plan-time validation +Every step type registered with the engine **must** declare a data-only metadata entry: ```powershell -'IdLE.Step.Example' = @{ - RequiredCapabilities = @('Some.Capability') +'IdLE.Step.EnsureEntitlement' = @{ + Handler = 'Invoke-IdleStepEnsureEntitlement' # step packs only + RequiredCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') + Provider = @{ DefaultAlias = 'Identity' } # optional: the step uses one provider WithSchema = @{ - RequiredKeys = @('IdentityKey') - OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ + IdentityKey = @{ Type = 'String'; Required = $true } + Entitlement = @{ + Type = 'Hashtable' + Required = $true + Keys = @{ + Kind = @{ Type = 'String'; Required = $true } + Id = @{ Type = 'String'; Required = $true } + } + } + State = @{ Type = 'String'; AllowedValues = @('Present', 'Absent'); Default = 'Present' } + } } } ``` -**`WithSchema`** (mandatory): +**`Provider`** (optional): `@{ DefaultAlias = '' }` declares that the step uses exactly one provider. +The engine then accepts these `With` keys for the step (they must not be declared in `Keys`): + +| Key | Type | Meaning | +| --- | --- | --- | +| `Provider` | String | Provider alias in `Providers`; defaults to `DefaultAlias`. | +| `AuthSessionName` | String | Auth session routing key passed to the `AuthSessionBroker`. | +| `AuthSessionOptions` | Hashtable | Data-only broker options. | + +`AuthSessionNameFromAlias = $true` names the auth session after the provider alias when `With.AuthSessionName` is +not set (used by the mailbox steps: `ExchangeOnline`). Steps without `Provider` reject the three keys. -- `RequiredKeys` — keys that **must** be present in `With` (plan creation fails if any are missing). -- `OptionalKeys` — keys that **may** be present in `With`. Any key not in `RequiredKeys` or `OptionalKeys` causes a plan-creation error (fail-fast, with step name, type, and offending key in the message). +**`WithSchema`** (mandatory): `Keys` maps each `With` key to a key schema: + +| Property | Applies to | Meaning | +| --- | --- | --- | +| `Type` | all (required) | `String`, `Int`, `Bool`, `Hashtable`, `Array`, `Any` | +| `Required` | all | Key must be present and not `$null`. | +| `Default` | all | Applied when the key is absent; must match `Type`; not combined with `Required`. | +| `AllowedValues` | `String`, `Int` | Strings match case-insensitively and are normalized to the listed casing. | +| `AllowEmpty` | `String`, `Array` | Allow `''` / `@()` (default: not allowed). | +| `Keys` | `Hashtable` | Nested key schemas; unknown nested keys fail. Without `Keys`, any content is accepted. | +| `AllowAdditionalKeys` | `Hashtable`, `WithSchema` | Accept keys not listed in `Keys`. | +| `Items` | `Array` | Key schema for every element. | **Rules:** -- Both `RequiredKeys` and `OptionalKeys` must be non-null string arrays (may be empty: `@()`). -- A key must not appear in both sets. +- `With` is validated at plan time, after template resolution. Defaults are applied, so the plan (and the plan + export) contains the effective `With`, including the resolved `Provider`. +- No implicit type conversion: `'600'` is not an `Int`. Pure template placeholders keep the type of the request value. +- Optional keys set to `$null` are treated as absent. +- Errors name the step, the step type and the full path, e.g. + `WithKeyMissing: Step 'Grant' (type 'IdLE.Step.EnsureEntitlement'): With.Entitlement.Kind is required.` + Codes: `WithKeyMissing`, `WithKeyUnknown`, `WithKeyTypeMismatch`, `WithValueNotAllowed`. +- Invalid metadata fails when the catalog is loaded: `LegacyWithSchema` (old `RequiredKeys`/`OptionalKeys` format), + `InvalidWithSchema` (any other shape error). - Metadata must be data-only — ScriptBlocks are rejected. Host-supplied step types may supplement (but not override) the catalog via `Providers.StepMetadata`. +### Step handler contract + +The engine validates inputs, resolves the provider, handles authentication and builds the step result. +A handler contains only the step's domain logic: + +```powershell +function Invoke-ContosoStepSyncManager { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [hashtable] $With, + [Parameter(Mandatory)] [object] $Provider + ) + + $result = $Provider.Invoke('SyncManager', @($With.IdentityKey)) + return @{ Changed = [bool]$result.Changed } +} +``` + +| Parameter | Passed | Content | +| --- | --- | --- | +| `With` | always (required) | Validated `With` without `Provider`, `AuthSessionName`, `AuthSessionOptions`. | +| `Provider` | if and only if the metadata declares `Provider` | Provider handle (see below). | +| `Context` | if declared | Execution context (`EventSink`, `Plan`, `Request`, `CorrelationId`, `Actor`). | +| `Step` | if declared | Step metadata (`Name`, `Type`), e.g. for events. | + +**Provider handle** (`IdLE.ProviderHandle`): + +- `Invoke(MethodName, Arguments)` calls a provider method. `Arguments` are the method's parameters **without** + `AuthSession`; the engine acquires the session (once per step attempt) and inserts it at the position the method + declares (`ListEntitlements(IdentityKey, AuthSession, Kind)` is called as `Invoke('ListEntitlements', @($id, $kind))`). +- `HasMethod(MethodName)` / `HasParameter(MethodName, ParameterName)` check optional provider features. +- `Alias` is the resolved provider alias. The raw provider object is not exposed. +- Providers that expose an `EventSink` property receive the run's event sink. + +**Result:** return `$null` or a hashtable with the optional keys `Changed` (Bool), `Data` (Hashtable) and `State` +(Hashtable). The engine builds `IdLE.StepResult` (`Name`, `Type`, `Status`, `Changed`, `Data`, `State`, `Error`, +`Attempts`, `Source`). Failures are reported by throwing; transient failures use the transient marker for retries. + +**Errors:** `LegacyStepHandler` (no `-With` parameter, pre-1.0 contract), `InvalidStepHandlerSignature` +(`-Provider` does not match the metadata), `InvalidStepHandlerResult` (other return values, including output leaked +to the pipeline), `ProviderNotSupplied`, `ProviderMethodNotImplemented`. + ### Step packs A step pack is a module that ships a `StepMetadataCatalog.psd1` data file and declares it in its @@ -125,9 +203,9 @@ so hosts do not need `Providers.StepRegistry` for step pack steps: 'Contoso.IdLE.Step.Hr.SyncManager' = @{ Handler = 'Invoke-ContosoStepSyncManager' RequiredCapabilities = @('Contoso.IdLE.Hr.Manager.Read') + Provider = @{ DefaultAlias = 'Hr' } WithSchema = @{ - RequiredKeys = @('IdentityKey') - OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + Keys = @{ IdentityKey = @{ Type = 'String'; Required = $true } } } } } @@ -265,16 +343,16 @@ Steps receive inputs from the workflow under `Inputs` and may reference: **All step inputs must be data-only and must not contain ScriptBlocks.** -Step implementations MUST validate their inputs using the centralized helper: +The engine enforces this before a handler runs: workflow definitions and plans are scanned with +`Assert-IdleNoScriptBlock`, and `With` is validated against the step's `WithSchema`. Handlers do not repeat these checks. + +If a step processes other untrusted data (for example values read from a request at execution time), validate it +with the centralized helper instead of custom ScriptBlock checks: ```powershell -Assert-IdleNoScriptBlock -InputObject $config -Path 'With.Config' +Assert-IdleNoScriptBlock -InputObject $value -Path 'Request.Context.Something' ``` -The `Assert-IdleNoScriptBlock` function is exported from `IdLE.Core` and recursively validates hashtables, arrays, and PSCustomObjects. - -**Do not implement custom ScriptBlock validation.** Use the centralized helper to ensure consistent enforcement across all steps. - --- ## Outputs diff --git a/docs/reference/steps/step-create-identity.md b/docs/reference/steps/step-create-identity.md index 3cb97cc0..f0492191 100644 --- a/docs/reference/steps/step-create-identity.md +++ b/docs/reference/steps/step-create-identity.md @@ -16,32 +16,27 @@ Creates a new identity in the target system. ## Description -The host must supply a provider instance via -Context.Providers[<ProviderAlias>] that implements CreateIdentity(identityKey, attributes) +The engine passes the provider resolved from With.Provider (default 'Identity') as -Provider. +The provider implements CreateIdentity(identityKey, attributes) and returns an object with properties 'IdentityKey' and 'Changed'. The step is idempotent by design: if the identity already exists, the provider should return Changed = $false without creating a duplicate. -Authentication: - -- If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method - if the provider supports an AuthSession parameter. - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @\{ Role = 'Tier0' \}). - -- ScriptBlocks in AuthSessionOptions are rejected (security boundary). +Authentication is handled by the engine: With.AuthSessionName and With.AuthSessionOptions select the +auth session passed to provider methods that declare an AuthSession parameter. ## Inputs (With.*) -The following keys are required in the step's ``With`` configuration: +Validated at plan time against the step's ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)). -| Key | Required | Description | -| --- | --- | --- | -| `Attributes` | Yes | Hashtable of attributes to set | -| `IdentityKey` | Yes | Unique identifier for the identity | +| Key | Type | Required | Default | Notes | +| --- | --- | --- | --- | --- | +| `Attributes` | Hashtable | Yes | | | +| `IdentityKey` | String | Yes | | | +| `AuthSessionName` | String | No | broker default session | Auth session routing key for the AuthSessionBroker. | +| `AuthSessionOptions` | Hashtable | No | | Data-only options passed to the AuthSessionBroker. | +| `Provider` | String | No | `Identity` | Provider alias in `Providers`. | ## Example diff --git a/docs/reference/steps/step-delete-identity.md b/docs/reference/steps/step-delete-identity.md index 1fbdb21e..bb291abd 100644 --- a/docs/reference/steps/step-delete-identity.md +++ b/docs/reference/steps/step-delete-identity.md @@ -16,8 +16,8 @@ Deletes an identity from the target system. ## Description -The host must supply a provider instance via -Context.Providers[<ProviderAlias>] that implements DeleteIdentity(identityKey) +The engine passes the provider resolved from With.Provider (default 'Identity') as -Provider. +The provider implements DeleteIdentity(identityKey) and returns an object with properties 'IdentityKey' and 'Changed'. The step is idempotent by design: if the identity is already deleted, the provider @@ -27,24 +27,19 @@ IMPORTANT: This step requires the provider to advertise the IdLE.Identity.Delete capability, which is typically opt-in for safety. The provider must be configured to allow deletion (e.g., AllowDelete = $true for AD provider). -Authentication: - -- If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method - if the provider supports an AuthSession parameter. - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @\{ Role = 'Tier0' \}). - -- ScriptBlocks in AuthSessionOptions are rejected (security boundary). +Authentication is handled by the engine: With.AuthSessionName and With.AuthSessionOptions select the +auth session passed to provider methods that declare an AuthSession parameter. ## Inputs (With.*) -The following keys are required in the step's ``With`` configuration: +Validated at plan time against the step's ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)). -| Key | Required | Description | -| --- | --- | --- | -| `IdentityKey` | Yes | Unique identifier for the identity | +| Key | Type | Required | Default | Notes | +| --- | --- | --- | --- | --- | +| `IdentityKey` | String | Yes | | | +| `AuthSessionName` | String | No | broker default session | Auth session routing key for the AuthSessionBroker. | +| `AuthSessionOptions` | Hashtable | No | | Data-only options passed to the AuthSessionBroker. | +| `Provider` | String | No | `Identity` | Provider alias in `Providers`. | ## Example diff --git a/docs/reference/steps/step-disable-identity.md b/docs/reference/steps/step-disable-identity.md index 467c9231..530db51a 100644 --- a/docs/reference/steps/step-disable-identity.md +++ b/docs/reference/steps/step-disable-identity.md @@ -16,31 +16,26 @@ Disables an identity in the target system. ## Description -The host must supply a provider instance via -Context.Providers[<ProviderAlias>] that implements DisableIdentity(identityKey) +The engine passes the provider resolved from With.Provider (default 'Identity') as -Provider. +The provider implements DisableIdentity(identityKey) and returns an object with properties 'IdentityKey' and 'Changed'. The step is idempotent by design: if the identity is already disabled, the provider should return Changed = $false. -Authentication: - -- If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method - if the provider supports an AuthSession parameter. - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @\{ Role = 'Tier0' \}). - -- ScriptBlocks in AuthSessionOptions are rejected (security boundary). +Authentication is handled by the engine: With.AuthSessionName and With.AuthSessionOptions select the +auth session passed to provider methods that declare an AuthSession parameter. ## Inputs (With.*) -The following keys are required in the step's ``With`` configuration: +Validated at plan time against the step's ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)). -| Key | Required | Description | -| --- | --- | --- | -| `IdentityKey` | Yes | Unique identifier for the identity | +| Key | Type | Required | Default | Notes | +| --- | --- | --- | --- | --- | +| `IdentityKey` | String | Yes | | | +| `AuthSessionName` | String | No | broker default session | Auth session routing key for the AuthSessionBroker. | +| `AuthSessionOptions` | Hashtable | No | | Data-only options passed to the AuthSessionBroker. | +| `Provider` | String | No | `Identity` | Provider alias in `Providers`. | ## Example diff --git a/docs/reference/steps/step-emit-event.md b/docs/reference/steps/step-emit-event.md index 8ff70bf7..58a4cf86 100644 --- a/docs/reference/steps/step-emit-event.md +++ b/docs/reference/steps/step-emit-event.md @@ -32,8 +32,13 @@ Inputs (all optional): ## Inputs (With.*) -This step has no required ``With.*`` keys at step schema level. -Inputs may still be provider-specific; refer to the step description and examples for usage details. +Validated at plan time against the step's ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)). + +| Key | Type | Required | Default | Notes | +| --- | --- | --- | --- | --- | +| `Level` | String | No | `Information` | One of: `Information`, `Warning`, `Error` | +| `Message` | String | No | | | +| `Type` | String | No | | | ## Example diff --git a/docs/reference/steps/step-enable-identity.md b/docs/reference/steps/step-enable-identity.md index 7ad050eb..a741bad2 100644 --- a/docs/reference/steps/step-enable-identity.md +++ b/docs/reference/steps/step-enable-identity.md @@ -16,31 +16,26 @@ Enables an identity in the target system. ## Description -The host must supply a provider instance via -Context.Providers[<ProviderAlias>] that implements EnableIdentity(identityKey) +The engine passes the provider resolved from With.Provider (default 'Identity') as -Provider. +The provider implements EnableIdentity(identityKey) and returns an object with properties 'IdentityKey' and 'Changed'. The step is idempotent by design: if the identity is already enabled, the provider should return Changed = $false. -Authentication: - -- If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method - if the provider supports an AuthSession parameter. - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @\{ Role = 'Tier0' \}). - -- ScriptBlocks in AuthSessionOptions are rejected (security boundary). +Authentication is handled by the engine: With.AuthSessionName and With.AuthSessionOptions select the +auth session passed to provider methods that declare an AuthSession parameter. ## Inputs (With.*) -The following keys are required in the step's ``With`` configuration: +Validated at plan time against the step's ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)). -| Key | Required | Description | -| --- | --- | --- | -| `IdentityKey` | Yes | Unique identifier for the identity | +| Key | Type | Required | Default | Notes | +| --- | --- | --- | --- | --- | +| `IdentityKey` | String | Yes | | | +| `AuthSessionName` | String | No | broker default session | Auth session routing key for the AuthSessionBroker. | +| `AuthSessionOptions` | Hashtable | No | | Data-only options passed to the AuthSessionBroker. | +| `Provider` | String | No | `Identity` | Provider alias in `Providers`. | ## Example diff --git a/docs/reference/steps/step-ensure-attributes.md b/docs/reference/steps/step-ensure-attributes.md index 21107318..d68e2f47 100644 --- a/docs/reference/steps/step-ensure-attributes.md +++ b/docs/reference/steps/step-ensure-attributes.md @@ -17,7 +17,7 @@ Ensures that multiple identity attributes match their desired values. ## Description This is a provider-agnostic step that can ensure multiple attributes in a single step. -The host must supply a provider instance via Context.Providers[<ProviderAlias>]. +The engine passes the provider resolved from With.Provider (default 'Identity') as -Provider. Provider interaction strategy: @@ -27,25 +27,19 @@ Provider interaction strategy: The step is idempotent by design: it converges state to the desired values. -Authentication: - -- If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method - if the provider supports an AuthSession parameter. - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @\{ Role = 'Tier0' \}). - -- ScriptBlocks in AuthSessionOptions are rejected (security boundary). +Authentication is handled by the engine (With.AuthSessionName, With.AuthSessionOptions). ## Inputs (With.*) -The following keys are required in the step's ``With`` configuration: +Validated at plan time against the step's ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)). -| Key | Required | Description | -| --- | --- | --- | -| `Attributes` | Yes | Hashtable of attributes to set | -| `IdentityKey` | Yes | Unique identifier for the identity | +| Key | Type | Required | Default | Notes | +| --- | --- | --- | --- | --- | +| `Attributes` | Hashtable | Yes | | | +| `IdentityKey` | String | Yes | | | +| `AuthSessionName` | String | No | broker default session | Auth session routing key for the AuthSessionBroker. | +| `AuthSessionOptions` | Hashtable | No | | Data-only options passed to the AuthSessionBroker. | +| `Provider` | String | No | `Identity` | Provider alias in `Providers`. | ## Example diff --git a/docs/reference/steps/step-ensure-entitlement.md b/docs/reference/steps/step-ensure-entitlement.md index e68e7d6c..b363edf0 100644 --- a/docs/reference/steps/step-ensure-entitlement.md +++ b/docs/reference/steps/step-ensure-entitlement.md @@ -17,8 +17,8 @@ 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[<ProviderAlias>]` that implements: +an assignment to the desired state. The engine passes the provider resolved from With.Provider (default 'Identity') +as -Provider. The provider implements: - ListEntitlements(identityKey) @@ -29,38 +29,32 @@ via `Context.Providers[<ProviderAlias>]` that implements: The step is idempotent and only calls Grant/Revoke when the assignment needs to change. -Authentication: - -- If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider methods - if the provider supports an AuthSession parameter. - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @\{ Role = 'Tier0' \}). - -- ScriptBlocks in AuthSessionOptions are rejected (security boundary). +Authentication is handled by the engine (With.AuthSessionName, With.AuthSessionOptions). ## Inputs (With.*) -The following keys are required in the step's ``With`` configuration: +Validated at plan time against the step's ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)). -| Key | Required | Description | -| --- | --- | --- | -| `Entitlement` | Yes | Entitlement identifier or object | -| `IdentityKey` | Yes | Unique identifier for the identity | -| `State` | Yes | Desired state for the entitlement | +| Key | Type | Required | Default | Notes | +| --- | --- | --- | --- | --- | +| `Entitlement` | Hashtable | Yes | | Keys: `DisplayName`, `Id` (required), `Kind` (required) | +| `IdentityKey` | String | Yes | | | +| `State` | String | No | `Present` | One of: `Present`, `Absent` | +| `AuthSessionName` | String | No | broker default session | Auth session routing key for the AuthSessionBroker. | +| `AuthSessionOptions` | Hashtable | No | | Data-only options passed to the AuthSessionBroker. | +| `Provider` | String | No | `Identity` | Provider alias in `Providers`. | ## Example ```powershell @{ - Name = 'IdLE.Step.EnsureEntitlement Example' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ - Entitlement = @{ Kind = 'Group'; Id = 'GroupId' } - IdentityKey = 'user.name' - State = 'Present' - } + Name = 'Ensure group access' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + IdentityKey = 'user1' + Entitlement = @{ Kind = 'Group'; Id = 'example-group' } + State = 'Present' + } } ``` diff --git a/docs/reference/steps/step-mailbox-ensure-out-of-office.md b/docs/reference/steps/step-mailbox-ensure-out-of-office.md index b4178e3c..1553a8e8 100644 --- a/docs/reference/steps/step-mailbox-ensure-out-of-office.md +++ b/docs/reference/steps/step-mailbox-ensure-out-of-office.md @@ -16,8 +16,8 @@ Ensures that a mailbox Out of Office (OOF) configuration matches the desired sta ## Description -The host must supply a provider instance via -Context.Providers[<ProviderAlias>]. The provider must implement an EnsureOutOfOffice +The engine passes the provider resolved from With.Provider +(default 'ExchangeOnline') as -Provider. The provider implements an EnsureOutOfOffice method with the signature (IdentityKey, Config, AuthSession) and return an object that contains a boolean property 'Changed'. @@ -42,24 +42,20 @@ Out of Office Config shape (data-only hashtable): When set to 'Text', messages are treated as plain text. Providers may normalize HTML to ensure stable idempotency (e.g., handling server-side wrapping). -Authentication: - -- If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method. - -- If With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @\{ Role = 'Admin' \}). +Authentication is handled by the engine: without With.AuthSessionName the session is named after the +provider alias (e.g. 'ExchangeOnline'); With.AuthSessionOptions is passed to the broker. ## Inputs (With.*) -The following keys are required in the step's ``With`` configuration: +Validated at plan time against the step's ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)). -| Key | Required | Description | -| --- | --- | --- | -| `Config` | Yes | See step description for details | -| `IdentityKey` | Yes | Unique identifier for the identity | +| Key | Type | Required | Default | Notes | +| --- | --- | --- | --- | --- | +| `Config` | Hashtable | Yes | | Keys: `End`, `ExternalAudience`, `ExternalMessage`, `InternalMessage`, `MessageFormat`, `Mode` (required), `Start` | +| `IdentityKey` | String | Yes | | | +| `AuthSessionName` | String | No | provider alias | Auth session routing key for the AuthSessionBroker. | +| `AuthSessionOptions` | Hashtable | No | | Data-only options passed to the AuthSessionBroker. | +| `Provider` | String | No | `ExchangeOnline` | Provider alias in `Providers`. | ## Example @@ -87,13 +83,13 @@ The following keys are required in the step's ``With`` configuration: ### Example 2 ```powershell -# In workflow definition (with ValueFrom for dynamic values): +# In workflow definition (with a template for dynamic values): @{ Name = 'Enable Out of Office for Leaver' Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Config = @{ Mode = 'Enabled' InternalMessage = 'This person is no longer with the organization. For assistance, please contact their manager or the main office.' diff --git a/docs/reference/steps/step-mailbox-ensure-permissions.md b/docs/reference/steps/step-mailbox-ensure-permissions.md index 1815a1e6..84e325b3 100644 --- a/docs/reference/steps/step-mailbox-ensure-permissions.md +++ b/docs/reference/steps/step-mailbox-ensure-permissions.md @@ -16,8 +16,8 @@ Ensures that mailbox delegate permissions match the desired state. ## Description -The host must supply a provider instance via -Context.Providers[<ProviderAlias>]. The provider must implement an EnsureMailboxPermissions +The engine passes the provider resolved from With.Provider +(default 'ExchangeOnline') as -Provider. The provider implements an EnsureMailboxPermissions method with the signature (IdentityKey, Permissions, AuthSession) and return an object that contains a boolean property 'Changed'. @@ -42,24 +42,20 @@ Each entry must be a hashtable with: - Ensure: 'Present' | 'Absent' (required) -Authentication: - -- If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method. - -- If With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @\{ Role = 'Admin' \}). +Authentication is handled by the engine: without With.AuthSessionName the session is named after the +provider alias (e.g. 'ExchangeOnline'); With.AuthSessionOptions is passed to the broker. ## Inputs (With.*) -The following keys are required in the step's ``With`` configuration: +Validated at plan time against the step's ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)). -| Key | Required | Description | -| --- | --- | --- | -| `IdentityKey` | Yes | Unique identifier for the identity | -| `Permissions` | Yes | See step description for details | +| Key | Type | Required | Default | Notes | +| --- | --- | --- | --- | --- | +| `IdentityKey` | String | Yes | | | +| `Permissions` | Array | Yes | | Items: `AssignedUser` (required), `Ensure` (required), `Right` (required) | +| `AuthSessionName` | String | No | provider alias | Auth session routing key for the AuthSessionBroker. | +| `AuthSessionOptions` | Hashtable | No | | Data-only options passed to the AuthSessionBroker. | +| `Provider` | String | No | `ExchangeOnline` | Provider alias in `Providers`. | ## Example @@ -110,7 +106,7 @@ The following keys are required in the step's ``With`` configuration: Provider = 'ExchangeOnline' IdentityKey = 'team@contoso.com' Permissions = @( - @{ AssignedUser = @{ ValueFrom = 'Request.Intent.UserPrincipalName' }; Right = 'FullAccess'; Ensure = 'Present' } + @{ AssignedUser = '{{Request.Intent.UserPrincipalName}}'; Right = 'FullAccess'; Ensure = 'Present' } ) } } diff --git a/docs/reference/steps/step-mailbox-ensure-type.md b/docs/reference/steps/step-mailbox-ensure-type.md index 5245e144..d6bf0cfb 100644 --- a/docs/reference/steps/step-mailbox-ensure-type.md +++ b/docs/reference/steps/step-mailbox-ensure-type.md @@ -16,8 +16,8 @@ Ensures that a mailbox is of the desired type (User, Shared, Room, Equipment). ## Description -The host must supply a provider instance via -Context.Providers[<ProviderAlias>]. The provider must implement an EnsureMailboxType +The engine passes the provider resolved from With.Provider +(default 'ExchangeOnline') as -Provider. The provider implements an EnsureMailboxType method with the signature (IdentityKey, MailboxType, AuthSession) and return an object that contains a boolean property 'Changed'. @@ -33,24 +33,20 @@ Supported mailbox types: - Equipment (equipment resource mailbox) -Authentication: - -- If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method. - -- If With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @\{ Role = 'Admin' \}). +Authentication is handled by the engine: without With.AuthSessionName the session is named after the +provider alias (e.g. 'ExchangeOnline'); With.AuthSessionOptions is passed to the broker. ## Inputs (With.*) -The following keys are required in the step's ``With`` configuration: +Validated at plan time against the step's ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)). -| Key | Required | Description | -| --- | --- | --- | -| `IdentityKey` | Yes | Unique identifier for the identity | -| `MailboxType` | Yes | See step description for details | +| Key | Type | Required | Default | Notes | +| --- | --- | --- | --- | --- | +| `IdentityKey` | String | Yes | | | +| `MailboxType` | String | Yes | | One of: `User`, `Shared`, `Room`, `Equipment` | +| `AuthSessionName` | String | No | provider alias | Auth session routing key for the AuthSessionBroker. | +| `AuthSessionOptions` | Hashtable | No | | Data-only options passed to the AuthSessionBroker. | +| `Provider` | String | No | `ExchangeOnline` | Provider alias in `Providers`. | ## Example diff --git a/docs/reference/steps/step-mailbox-get-info.md b/docs/reference/steps/step-mailbox-get-info.md index 8b9989b4..9eb1caf9 100644 --- a/docs/reference/steps/step-mailbox-get-info.md +++ b/docs/reference/steps/step-mailbox-get-info.md @@ -16,29 +16,25 @@ Retrieves mailbox details and returns a structured report. ## Description -The host must supply a provider instance via -Context.Providers[<ProviderAlias>]. The provider must implement a GetMailbox +The engine passes the provider resolved from With.Provider +(default 'ExchangeOnline') as -Provider. The provider implements a GetMailbox method with the signature (IdentityKey, AuthSession) and return a mailbox object. The step is read-only and returns Changed = $false. -Authentication: - -- If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method. - -- If With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @\{ Role = 'Admin' \}). +Authentication is handled by the engine: without With.AuthSessionName the session is named after the +provider alias (e.g. 'ExchangeOnline'); With.AuthSessionOptions is passed to the broker. ## Inputs (With.*) -The following keys are required in the step's ``With`` configuration: +Validated at plan time against the step's ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)). -| Key | Required | Description | -| --- | --- | --- | -| `IdentityKey` | Yes | Unique identifier for the identity | +| Key | Type | Required | Default | Notes | +| --- | --- | --- | --- | --- | +| `IdentityKey` | String | Yes | | | +| `AuthSessionName` | String | No | provider alias | Auth session routing key for the AuthSessionBroker. | +| `AuthSessionOptions` | Hashtable | No | | Data-only options passed to the AuthSessionBroker. | +| `Provider` | String | No | `ExchangeOnline` | Provider alias in `Providers`. | ## Example diff --git a/docs/reference/steps/step-move-identity.md b/docs/reference/steps/step-move-identity.md index 0d7bde8a..344e002f 100644 --- a/docs/reference/steps/step-move-identity.md +++ b/docs/reference/steps/step-move-identity.md @@ -16,32 +16,27 @@ Moves an identity to a different container/OU in the target system. ## Description -The host must supply a provider instance via -Context.Providers[<ProviderAlias>] that implements MoveIdentity(identityKey, targetContainer) +The engine passes the provider resolved from With.Provider (default 'Identity') as -Provider. +The provider implements MoveIdentity(identityKey, targetContainer) and returns an object with properties 'IdentityKey' and 'Changed'. The step is idempotent by design: if the identity is already in the target container, the provider should return Changed = $false. -Authentication: - -- If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method - if the provider supports an AuthSession parameter. - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @\{ Role = 'Tier0' \}). - -- ScriptBlocks in AuthSessionOptions are rejected (security boundary). +Authentication is handled by the engine: With.AuthSessionName and With.AuthSessionOptions select the +auth session passed to provider methods that declare an AuthSession parameter. ## Inputs (With.*) -The following keys are required in the step's ``With`` configuration: +Validated at plan time against the step's ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)). -| Key | Required | Description | -| --- | --- | --- | -| `IdentityKey` | Yes | Unique identifier for the identity | -| `TargetContainer` | Yes | See step description for details | +| Key | Type | Required | Default | Notes | +| --- | --- | --- | --- | --- | +| `IdentityKey` | String | Yes | | | +| `TargetContainer` | String | Yes | | | +| `AuthSessionName` | String | No | broker default session | Auth session routing key for the AuthSessionBroker. | +| `AuthSessionOptions` | Hashtable | No | | Data-only options passed to the AuthSessionBroker. | +| `Provider` | String | No | `Identity` | Provider alias in `Providers`. | ## Example diff --git a/docs/reference/steps/step-prune-entitlements-ensure-keep.md b/docs/reference/steps/step-prune-entitlements-ensure-keep.md index 047346c6..a754e936 100644 --- a/docs/reference/steps/step-prune-entitlements-ensure-keep.md +++ b/docs/reference/steps/step-prune-entitlements-ensure-keep.md @@ -56,29 +56,20 @@ Non-removable entitlements (e.g., AD primary group / Domain Users) are handled s operation fails, the step emits a structured warning event, records the item as Skipped, and continues. The workflow is not failed. -Authentication: - -- If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to provider methods. - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for session selection - (e.g., @\{ Role = 'Tier0' \}). ScriptBlocks in AuthSessionOptions are rejected. - -### With.* Parameters - -| Key | Required | Type | Description | -| -------------------- | -------- | ------------ | ----------- | -| IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | -| Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | -| Keep | No | array | Explicit entitlement objects to retain AND ensure are present. Each entry must have an Id property; Kind is optional. **These entries are GRANTED if missing after the prune.** If omitted, all entitlements of the given Kind are removed and no grants are made. | -| Provider | No | string | Provider alias from Context.Providers (default: Identity). | -| AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | -| AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | +Authentication is handled by the engine (With.AuthSessionName, With.AuthSessionOptions). ## Inputs (With.*) -This step has no required ``With.*`` keys at step schema level. -Inputs may still be provider-specific; refer to the step description and examples for usage details. +Validated at plan time against the step's ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)). + +| Key | Type | Required | Default | Notes | +| --- | --- | --- | --- | --- | +| `IdentityKey` | String | Yes | | | +| `Kind` | String | Yes | | | +| `Keep` | Array | No | | Items: `DisplayName`, `Id` (required), `Kind` | +| `AuthSessionName` | String | No | broker default session | Auth session routing key for the AuthSessionBroker. | +| `AuthSessionOptions` | Hashtable | No | | Data-only options passed to the AuthSessionBroker. | +| `Provider` | String | No | `Identity` | Provider alias in `Providers`. | ## Example diff --git a/docs/reference/steps/step-prune-entitlements.md b/docs/reference/steps/step-prune-entitlements.md index 75cc145b..20d450ff 100644 --- a/docs/reference/steps/step-prune-entitlements.md +++ b/docs/reference/steps/step-prune-entitlements.md @@ -52,34 +52,21 @@ Non-removable entitlements (e.g., AD primary group / Domain Users) are handled s operation fails, the step emits a structured warning event, records the item as Skipped, and continues. The workflow is not failed. -Authentication: - -- If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to provider methods. - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for session selection - (e.g., @\{ Role = 'Tier0' \}). ScriptBlocks in AuthSessionOptions are rejected. - -### With.* Parameters - -| Key | Required | Type | Description | -| -------------------- | -------- | ------------ | ----------- | -| IdentityKey | Yes | string | Unique identity reference (e.g. sAMAccountName, UPN, or objectId). | -| Kind | Yes | string | Entitlement kind to prune (provider-defined, e.g. Group, Role, License). | -| Keep | No | array | Explicit entitlement objects to retain (kept, never removed). Each entry must have an Id property; Kind is optional. These entries are NOT granted — use PruneEntitlementsEnsureKeep for that. | -| KeepPattern | No | string array | Wildcard strings (PowerShell -like semantics). Current entitlements whose Id matches any pattern are kept. Patterns are NEVER granted. | -| Provider | No | string | Provider alias from Context.Providers (default: Identity). | -| AuthSessionName | No | string | Name of the auth session to acquire via Context.AcquireAuthSession. | -| AuthSessionOptions | No | hashtable | Options passed to AcquireAuthSession for session selection (e.g. role-scoped sessions). | +Authentication is handled by the engine (With.AuthSessionName, With.AuthSessionOptions). ## Inputs (With.*) -The following keys are required in the step's ``With`` configuration: - -| Key | Required | Description | -| --- | --- | --- | -| `IdentityKey` | Yes | Unique identifier for the identity | -| `Kind` | Yes | See step description for details | +Validated at plan time against the step's ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)). + +| Key | Type | Required | Default | Notes | +| --- | --- | --- | --- | --- | +| `IdentityKey` | String | Yes | | | +| `Kind` | String | Yes | | | +| `Keep` | Array | No | | Items: `DisplayName`, `Id` (required), `Kind` | +| `KeepPattern` | Array | No | | Items: String | +| `AuthSessionName` | String | No | broker default session | Auth session routing key for the AuthSessionBroker. | +| `AuthSessionOptions` | Hashtable | No | | Data-only options passed to the AuthSessionBroker. | +| `Provider` | String | No | `Identity` | Provider alias in `Providers`. | ## Example diff --git a/docs/reference/steps/step-revoke-identity-sessions.md b/docs/reference/steps/step-revoke-identity-sessions.md index e2a2ac3c..d71b0fe8 100644 --- a/docs/reference/steps/step-revoke-identity-sessions.md +++ b/docs/reference/steps/step-revoke-identity-sessions.md @@ -17,8 +17,8 @@ Revokes all active sign-in sessions for an identity in the target system. ## Description This is a provider-agnostic step that revokes active sign-in sessions (refresh tokens) -for a given identity. The host must supply a provider instance via -Context.Providers[<ProviderAlias>] that implements RevokeSessions(identityKey) +for a given identity. The engine passes the provider resolved from With.Provider (default 'Identity') as -Provider. +The provider implements RevokeSessions(identityKey) and returns an object with properties 'IdentityKey' and 'Changed'. This step is typically used in Leaver workflows after disabling an identity to ensure @@ -27,24 +27,19 @@ that existing sessions are terminated immediately, rather than waiting for token The step does not modify the identity itself (e.g., does not disable the account). Use IdLE.Step.DisableIdentity separately if account disabling is also required. -Authentication: - -- If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method - if the provider supports an AuthSession parameter. - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @\{ Role = 'Tier0' \}). - -- ScriptBlocks in AuthSessionOptions are rejected (security boundary). +Authentication is handled by the engine: With.AuthSessionName and With.AuthSessionOptions select the +auth session passed to provider methods that declare an AuthSession parameter. ## Inputs (With.*) -The following keys are required in the step's ``With`` configuration: +Validated at plan time against the step's ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)). -| Key | Required | Description | -| --- | --- | --- | -| `IdentityKey` | Yes | Unique identifier for the identity | +| Key | Type | Required | Default | Notes | +| --- | --- | --- | --- | --- | +| `IdentityKey` | String | Yes | | | +| `AuthSessionName` | String | No | broker default session | Auth session routing key for the AuthSessionBroker. | +| `AuthSessionOptions` | Hashtable | No | | Data-only options passed to the AuthSessionBroker. | +| `Provider` | String | No | `Identity` | Provider alias in `Providers`. | ## Example diff --git a/docs/reference/steps/step-trigger-directory-sync.md b/docs/reference/steps/step-trigger-directory-sync.md index da98cdca..9a0d7838 100644 --- a/docs/reference/steps/step-trigger-directory-sync.md +++ b/docs/reference/steps/step-trigger-directory-sync.md @@ -16,8 +16,8 @@ Triggers a directory sync cycle and optionally waits for completion. ## Description -The host must supply a provider instance via -Context.Providers[<ProviderAlias>] that implements: +The engine passes the provider resolved from With.Provider +(default 'DirectorySync') as -Provider. The provider implements: - StartSyncCycle(PolicyType, ComputerName, AuthSession) @@ -26,22 +26,24 @@ Context.Providers[<ProviderAlias>] that implements: The step is designed for remote execution and requires an elevated auth session provided by the host's AuthSessionBroker. -Authentication: - -- With.AuthSessionName (optional): routing key for AuthSessionBroker - -- With.AuthSessionOptions (optional, hashtable): forwarded to broker for session selection - -- If AuthSessionName is omitted, the broker is asked for a default session - -- ComputerName and PolicyType are provider-specific inputs and are validated by the selected provider - -- ScriptBlocks in AuthSessionOptions are rejected (security boundary) +Authentication is handled by the engine: With.AuthSessionName (optional) and With.AuthSessionOptions +select the session; without AuthSessionName the broker is asked for a default session. +ComputerName and PolicyType are provider-specific inputs and are validated by the selected provider. ## Inputs (With.*) -This step has no required ``With.*`` keys at step schema level. -Inputs may still be provider-specific; refer to the step description and examples for usage details. +Validated at plan time against the step's ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)). + +| Key | Type | Required | Default | Notes | +| --- | --- | --- | --- | --- | +| `ComputerName` | String | No | | | +| `PolicyType` | String | No | | | +| `PollIntervalSeconds` | Int | No | `10` | | +| `TimeoutSeconds` | Int | No | `600` | | +| `Wait` | Bool | No | `$false` | | +| `AuthSessionName` | String | No | broker default session | Auth session routing key for the AuthSessionBroker. | +| `AuthSessionOptions` | Hashtable | No | | Data-only options passed to the AuthSessionBroker. | +| `Provider` | String | No | `DirectorySync` | Provider alias in `Providers`. | ## Example diff --git a/tools/Generate-IdleStepReference.ps1 b/tools/Generate-IdleStepReference.ps1 index ce26e66a..d3f7d5a3 100644 --- a/tools/Generate-IdleStepReference.ps1 +++ b/tools/Generate-IdleStepReference.ps1 @@ -239,69 +239,83 @@ function Get-IdleHelpSafe { } } -function Get-IdleRequiredWithKeysFromSource { +function Get-IdleStepWithKeyRows { [CmdletBinding()] param( [Parameter(Mandatory)] - [ValidateNotNull()] - [System.Management.Automation.CommandInfo] $CommandInfo + [ValidateNotNullOrEmpty()] + [string] $StepType, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ModuleName ) - # Best-effort heuristic: detect patterns like: - # foreach ($key in @('IdentityKey','Name','Value')) { ... } - # OR throw messages like: "StepName requires With.KeyName." - $filePath = $null - if ($CommandInfo.ScriptBlock -and $CommandInfo.ScriptBlock.File) { - $filePath = $CommandInfo.ScriptBlock.File + # Rows are derived from the step's typed WithSchema (StepMetadataCatalog), including the engine-provided + # common keys for steps that declare a Provider. + $module = Get-Module -Name $ModuleName -All -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $module) { + return @() } - if ([string]::IsNullOrWhiteSpace($filePath) -or -not (Test-Path -Path $filePath)) { + $catalog = & $module { Get-IdleStepMetadataCatalog -ErrorAction SilentlyContinue } + if ($catalog -isnot [System.Collections.IDictionary] -or -not $catalog.Contains($StepType)) { return @() } - $content = Get-Content -Path $filePath -Raw -ErrorAction Stop - $keys = @() + $metadata = $catalog[$StepType] + $keys = $metadata['WithSchema']['Keys'] - # Pattern 1: foreach loop with key array - $m = [regex]::Match( - $content, - 'foreach\s*\(\s*\$key\s+in\s+@\((?[^)]*)\)\s*\)', - 'IgnoreCase' - ) + function Get-IdleKeyNotes { + param([hashtable] $KeySchema) - if ($m.Success) { - $listText = $m.Groups['List'].Value - $foreachKeys = [regex]::Matches($listText, "'(?[^']+)'") | ForEach-Object { $_.Groups['Key'].Value } - $keys += $foreachKeys + $notes = @() + if ($KeySchema.ContainsKey('AllowedValues')) { + $notes += 'One of: ' + ((@($KeySchema['AllowedValues']) | ForEach-Object { "``$_``" }) -join ', ') + } + $nested = if ($KeySchema['Type'] -eq 'Array' -and $KeySchema.ContainsKey('Items')) { $KeySchema['Items'] } else { $KeySchema } + if ($nested -is [hashtable] -and $nested.ContainsKey('Keys')) { + $nestedKeys = foreach ($name in @($nested['Keys'].PSBase.Keys | Sort-Object)) { + $required = if ($nested['Keys'][$name]['Required'] -eq $true) { ' (required)' } else { '' } + "``$name``$required" + } + $prefix = if ($KeySchema['Type'] -eq 'Array') { 'Items: ' } else { 'Keys: ' } + $notes += $prefix + ($nestedKeys -join ', ') + } + elseif ($KeySchema['Type'] -eq 'Array' -and $KeySchema.ContainsKey('Items')) { + $notes += "Items: $($KeySchema['Items']['Type'])" + } + return ($notes -join '. ') } - # Pattern 2: throw messages like "requires With.KeyName" - $throwMatches = [regex]::Matches( - $content, - 'throw\s+[^"]*"[^"]*requires\s+With\.(?[A-Za-z][A-Za-z0-9]*)', - 'IgnoreCase' - ) - foreach ($match in $throwMatches) { - $keys += $match.Groups['Key'].Value + $rows = [System.Collections.Generic.List[object]]::new() + foreach ($name in @($keys.PSBase.Keys)) { + $keySchema = $keys[$name] + $default = '' + if ($keySchema.ContainsKey('Default')) { + $value = $keySchema['Default'] + $default = if ($value -is [bool]) { "``$('$' + $value.ToString().ToLowerInvariant())``" } else { "``$value``" } + } + + $rows.Add([pscustomobject]@{ + Name = [string]$name + Type = [string]$keySchema['Type'] + Required = ($keySchema['Required'] -eq $true) + Default = $default + Notes = Get-IdleKeyNotes -KeySchema $keySchema + Common = $false + }) } - # Pattern 3: ContainsKey checks followed by throw - $containsMatches = [regex]::Matches( - $content, - '\$with\.ContainsKey\(''(?[^'']+)''\)', - 'IgnoreCase' - ) - foreach ($match in $containsMatches) { - $keyName = $match.Groups['Key'].Value - # Only include if there's a throw statement nearby (within next 100 chars) - $matchPos = $match.Index - $nextChunk = $content.Substring($matchPos, [Math]::Min(150, $content.Length - $matchPos)) - if ($nextChunk -match 'throw') { - $keys += $keyName - } + if ($metadata.ContainsKey('Provider')) { + $alias = [string]$metadata['Provider']['DefaultAlias'] + $sessionDefault = if ($metadata['Provider']['AuthSessionNameFromAlias'] -eq $true) { 'provider alias' } else { 'broker default session' } + $rows.Add([pscustomobject]@{ Name = 'Provider'; Type = 'String'; Required = $false; Default = "``$alias``"; Notes = 'Provider alias in `Providers`.'; Common = $true }) + $rows.Add([pscustomobject]@{ Name = 'AuthSessionName'; Type = 'String'; Required = $false; Default = $sessionDefault; Notes = 'Auth session routing key for the AuthSessionBroker.'; Common = $true }) + $rows.Add([pscustomobject]@{ Name = 'AuthSessionOptions'; Type = 'Hashtable'; Required = $false; Default = ''; Notes = 'Data-only options passed to the AuthSessionBroker.'; Common = $true }) } - return @($keys | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique | Sort-Object) + return @($rows | Sort-Object -Property @{ Expression = 'Common' }, @{ Expression = 'Required'; Descending = $true }, @{ Expression = 'Name' }) } function Get-IdleProviderMethodHintFromDescription { @@ -496,7 +510,8 @@ function New-IdleStepDocModel { # Remove redundant "This is a provider-agnostic step." sentence $description = $description -replace '(?i)This\s+is\s+a\s+provider-agnostic\s+step\.\s*', '' - $requiredWithKeys = @(Get-IdleRequiredWithKeysFromSource -CommandInfo $CommandInfo) + $withKeys = @(Get-IdleStepWithKeyRows -StepType $stepType -ModuleName $moduleName) + $requiredWithKeys = @($withKeys | Where-Object Required | ForEach-Object Name) $idempotent = 'Unknown' if ($description -match '(?i)\bidempotent\b') { @@ -555,6 +570,7 @@ function New-IdleStepDocModel { CommandName = $commandName Synopsis = $synopsis Description = $description + WithKeys = $withKeys RequiredWithKeys = $requiredWithKeys Idempotent = $idempotent RequiredCapabilities = $requiredCapabilities @@ -607,37 +623,18 @@ function New-IdleStepDetailPageContent { [void]$sb.AppendLine('## Inputs (With.*)') [void]$sb.AppendLine() - if ($Model.RequiredWithKeys.Count -eq 0) { - [void]$sb.AppendLine('This step has no required ``With.*`` keys at step schema level.') - [void]$sb.AppendLine('Inputs may still be provider-specific; refer to the step description and examples for usage details.') + if ($Model.WithKeys.Count -eq 0) { + [void]$sb.AppendLine('This step accepts no ``With.*`` keys.') [void]$sb.AppendLine() } else { - [void]$sb.AppendLine('The following keys are required in the step''s ``With`` configuration:') + [void]$sb.AppendLine('Validated at plan time against the step''s ``WithSchema`` (see [Step metadata contract](../../extend/steps.md#step-metadata-contract)).') [void]$sb.AppendLine() - [void]$sb.AppendLine('| Key | Required | Description |') - [void]$sb.AppendLine('| --- | --- | --- |') - foreach ($k in $Model.RequiredWithKeys) { - # Add basic description based on common key patterns - $keyDescription = switch ($k) { - 'IdentityKey' { 'Unique identifier for the identity' } - 'Name' { 'Name of the attribute or property' } - 'Value' { 'Desired value to set' } - 'Attributes' { 'Hashtable of attributes to set' } - 'DestinationPath' { 'Target location or path' } - 'Message' { 'Message text to emit (optional for EmitEvent step)' } - 'EntitlementType' { 'Type of entitlement (e.g., Group, License)' } - 'EntitlementValue' { 'Specific entitlement identifier' } - 'Entitlement' { 'Entitlement identifier or object' } - 'State' { 'Desired state for the entitlement' } - 'Ensure' { 'Desired state (Present or Absent)' } - 'Provider' { 'Provider alias (optional, defaults vary by step)' } - 'AuthSessionName' { 'Name of auth session to use (optional)' } - 'PolicyType' { 'Type of policy (e.g., Delta, Initial)' } - 'Wait' { 'Whether to wait for completion' } - default { 'See step description for details' } - } - [void]$sb.AppendLine("| ``$k`` | Yes | $keyDescription |") + [void]$sb.AppendLine('| Key | Type | Required | Default | Notes |') + [void]$sb.AppendLine('| --- | --- | --- | --- | --- |') + foreach ($row in $Model.WithKeys) { + $required = if ($row.Required) { 'Yes' } else { 'No' } + [void]$sb.AppendLine(("| ``{0}`` | {1} | {2} | {3} | {4} |" -f $row.Name, $row.Type, $required, $row.Default, $row.Notes)) } [void]$sb.AppendLine() } From 166c394fc2c635809f8db60039e9f7f59e04bc16 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:59:19 +0100 Subject: [PATCH 5/6] docs: fold the step contract design into steps.md (#324) steps.md now carries the full contract: schema rules, provider handle members, result keys, an error code table and a migration section (as in events.md). The separate design document is removed. Co-Authored-By: Claude Opus 5.5 --- docs/develop/design/step-contract.md | 290 --------------------------- docs/extend/steps.md | 126 ++++++++++-- 2 files changed, 109 insertions(+), 307 deletions(-) delete mode 100644 docs/develop/design/step-contract.md diff --git a/docs/develop/design/step-contract.md b/docs/develop/design/step-contract.md deleted file mode 100644 index d70471f5..00000000 --- a/docs/develop/design/step-contract.md +++ /dev/null @@ -1,290 +0,0 @@ ---- -title: "Design: Step contract (engine-owned validation and provider resolution)" -sidebar_label: "Design: Step contract" ---- - -# Design: Step contract - -| | | -| --- | --- | -| **Status** | Implemented (see [Implementation notes](#implementation-notes)) | -| **Issue** | [#324](https://github.com/blindzero/IdentityLifecycleEngine/issues/324) | -| **Target** | v1.0.0 | -| **Affects** | Step handlers, step metadata catalogs, `Providers.StepMetadata` / `Providers.StepRegistry` (host-defined steps) | -| **Unchanged** | Host API (`New-IdlePlan`, `Invoke-IdlePlan`, `Providers`), provider contract, workflow `With` values | - -## Problem - -Every step handler repeats work that the engine already knows how to do: - -- `With` is a hashtable, required keys exist (already validated at plan time via `WithSchema`). -- `Context.Providers` exists, the provider alias is resolved (`With.Provider` or a hard-coded default) and supplied. -- Auth sessions are acquired (`Invoke-IdleProviderMethod`). -- `IdLE.StepResult` is constructed by hand. - -In `Invoke-IdleStepDisableIdentity` this is ~35 of ~50 lines. `Provider`, `AuthSessionName` and -`AuthSessionOptions` are repeated in all 16 catalog entries. `WithSchema` lists only key names, so type errors -(e.g. `State = 'present '`, `Entitlement = 'CN=...'`) surface at execution time inside handlers or providers. - -## Principle - -The engine owns validation, provider resolution, authentication and the result shape. -A step handler contains only its domain logic. - -## 1. Catalog entry - -```powershell -'IdLE.Step.EnsureEntitlement' = @{ - Handler = 'Invoke-IdleStepEnsureEntitlement' - RequiredCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') - - # Optional. Present = the step uses exactly one provider; the engine resolves it. - Provider = @{ DefaultAlias = 'Identity' } - - WithSchema = @{ - Keys = @{ - IdentityKey = @{ Type = 'String'; Required = $true } - Entitlement = @{ - Type = 'Hashtable'; Required = $true - Keys = @{ - Kind = @{ Type = 'String'; Required = $true } - Id = @{ Type = 'String'; Required = $true } - DisplayName = @{ Type = 'String' } - } - } - State = @{ Type = 'String'; AllowedValues = @('Present', 'Absent'); Default = 'Present' } - } - } -} -``` - -### Provider declaration - -- `Provider = @{ DefaultAlias = '' }` declares that the step uses one provider. - The engine then adds the **common keys** to the step's schema; they must not be declared in `Keys`: - - | Key | Type | Meaning | - | --- | --- | --- | - | `Provider` | String | Provider alias; defaults to `DefaultAlias`. | - | `AuthSessionName` | String | Auth session routing key passed to the broker. | - | `AuthSessionOptions` | Hashtable | Data-only broker options (ScriptBlocks rejected). | - -- `AuthSessionNameFromAlias = $true` (optional) names the auth session after the resolved alias when - `With.AuthSessionName` is not set (mailbox steps). -- No `Provider` entry: the step uses no provider (e.g. `IdLE.Step.EmitEvent`); the common keys are not accepted. -- All current steps use at most one provider alias. Multiple providers per step are out of scope; if needed later, - `Provider` can become a map of named roles without breaking the single-provider form. - -### WithSchema - -`WithSchema.Keys` maps each key to a key schema. `RequiredKeys` / `OptionalKeys` are removed. - -| Property | Applies to | Meaning | -| --- | --- | --- | -| `Type` | all (required) | `String`, `Int`, `Bool`, `Hashtable`, `Array`, `Any` | -| `Required` | all | `$true` = must be present and non-null. Default `$false`. | -| `Default` | all | Value used when the key is absent. Must match `Type`. Not allowed with `Required`. | -| `AllowedValues` | `String`, `Int` | Value must be one of the list (strings compared case-insensitively, result normalized to the listed casing). | -| `AllowEmpty` | `String`, `Array` | `$true` = empty string / empty array allowed. Default `$false`. | -| `Keys` | `Hashtable` | Nested key schemas (same format, recursive). Unknown nested keys fail. | -| `AllowAdditionalKeys` | `Hashtable` (and `WithSchema` root) | `$true` = keys not listed in `Keys` are accepted unchecked. | -| `Items` | `Array` | Key schema applied to every element (e.g. `@{ Type = 'Hashtable'; Keys = @{ ... } }`). | - -Rules: - -- A `Hashtable` without `Keys` accepts any content (e.g. `EnsureAttributes.Attributes`). -- No implicit type coercion: `'600'` is not an `Int`, `'true'` is not a `Bool`. PSD1 literals and pure template - placeholders (`'{{Request.Intent.TimeoutSeconds}}'`) already keep their type, so plan-time checking is exact. -- `Int` accepts `[int]` and `[long]`; `Array` accepts any non-string `IEnumerable` except dictionaries. -- Optional keys set to `$null` are treated as absent (defaults apply). `$null` values inside a `Hashtable` without - `Keys` (e.g. "clear attribute" values in `Attributes`) are passed through. -- The schema itself is data-only and validated when the catalog is loaded (`InvalidWithSchema`). - -### Validation time - -All `With` checks run at **plan time** after template resolution, with the full path in the error -(`With.Entitlement.Kind`). Defaults are applied at plan time, so the plan (and plan export) shows the effective `With`. - -## 2. Handler signature - -```powershell -function Invoke-IdleStepDisableIdentity { - [CmdletBinding()] - param( - [Parameter(Mandatory)] [hashtable] $With, - [Parameter(Mandatory)] [object] $Provider - ) - - $result = $Provider.Invoke('DisableIdentity', @($With.IdentityKey)) - return @{ Changed = [bool]$result.Changed } -} -``` - -| Parameter | Passed | Content | -| --- | --- | --- | -| `With` | always (required) | Validated `With` **without** the common keys, defaults applied. | -| `Provider` | only if the catalog declares `Provider` | Provider handle (see below). | -| `Context` | if declared | Execution context (`EventSink`, `Request`, `Plan`, `CorrelationId`, `Actor`). | -| `Step` | if declared | Read-only step metadata (`Name`, `Type`). | - -- The engine checks the handler's parameters before the first attempt: - `With` is mandatory; `Provider` must be declared if and only if the catalog declares `Provider`. -- Handlers written against the old contract (no `With` parameter) fail with `LegacyStepHandler`. - -### Provider handle - -The engine resolves the alias (`With.Provider` or `DefaultAlias`), looks it up in `Providers` and passes a handle -(`PSTypeName = 'IdLE.ProviderHandle'`): - -| Member | Meaning | -| --- | --- | -| `Alias` | Resolved provider alias. | -| `Invoke(MethodName, Arguments)` | Calls the provider method. `Arguments` exclude `AuthSession`; the session is inserted at the position the method declares it (e.g. `ListEntitlements(IdentityKey, AuthSession, Kind)`). | -| `HasMethod(MethodName)` | `$true` if the provider implements the method (optional capabilities such as bulk operations). | -| `HasParameter(MethodName, ParameterName)` | `$true` if the method declares the parameter (e.g. `Kind` on `ListEntitlements`). | - -- Auth session behavior is unchanged from today's `Invoke-IdleProviderMethod`: acquired on the first `Invoke` that - needs it (method declares `AuthSession`, or `AuthSessionName` is set), then cached for the step attempt. -- The handle does not expose the raw provider object. Steps cannot bypass the auth logic. -- Providers that expose an `EventSink` property receive the run's event sink (previously done by the mailbox - permissions step itself). -- The same resolved alias feeds `Request.Context.Current` for preconditions (today `Current` ignores the step's - default alias when `With.Provider` is not set). - -## 3. Handler result - -A handler returns `$null` or a hashtable: - -| Key | Type | Meaning | -| --- | --- | --- | -| `Changed` | Bool | The step changed external state. Default `$false`. | -| `Data` | Hashtable | Structured step output (e.g. per-attribute results, skipped entitlements). | -| `State` | Hashtable | Values for downstream steps (reserved; see Open questions). | - -- The engine builds `IdLE.StepResult` (`Name`, `Type`, `Status`, `Changed`, `Data`, `Error`, `Attempts`, `Source`). -- **Failure is always an exception.** Returning a status is not supported. Transient failures keep using the - existing transient marker for retries. -- Unknown result keys or a non-hashtable result fail the step (`InvalidStepHandlerResult`). -- Step-specific result properties move under `Data`: `PruneEntitlements.Skipped` -> `Data.Skipped`, - `EnsureAttributes.Attributes` -> `Data.Attributes` (already partly there). - -## 4. Error codes - -| Code | When | Raised at | -| --- | --- | --- | -| `LegacyWithSchema` | Catalog uses `RequiredKeys` / `OptionalKeys`. | Catalog load | -| `InvalidWithSchema` | Unknown `Type`, `Default` not matching `Type`, `Keys` on a non-hashtable, common key declared in `Keys`, ... | Catalog load | -| `LegacyStepHandler` | Handler has no `With` parameter. | Before the first attempt | -| `InvalidStepHandlerSignature` | `Provider` declared without catalog `Provider` (or vice versa). | Before the first attempt | -| `WithKeyMissing` | Required key absent or null. | Plan | -| `WithKeyUnknown` | Key not in schema (and no `AllowAdditionalKeys`). | Plan | -| `WithKeyTypeMismatch` | Value does not match `Type`. | Plan | -| `WithValueNotAllowed` | Value not in `AllowedValues`, or empty without `AllowEmpty`. | Plan | -| `ProviderNotSupplied` | Resolved alias not in `Providers`. | Execution (hosts may pass providers only to `Invoke-IdlePlan`) | -| `ProviderMethodNotImplemented` | `Invoke` of a method the provider does not implement. | Execution | -| `InvalidStepHandlerResult` | Result is not `$null` / hashtable, or contains unknown keys. | Execution | - -Messages start with the code, followed by step name, step type and the path (e.g. `With.Entitlement.Kind`), -matching the existing `ReservedNamespace` style. - -## 5. Migration example - -### Catalog - -```powershell -# Before -'IdLE.Step.DisableIdentity' = @{ - Handler = 'Invoke-IdleStepDisableIdentity' - RequiredCapabilities = @('IdLE.Identity.Disable') - WithSchema = @{ - RequiredKeys = @('IdentityKey') - OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') - } -} - -# After -'IdLE.Step.DisableIdentity' = @{ - Handler = 'Invoke-IdleStepDisableIdentity' - RequiredCapabilities = @('IdLE.Identity.Disable') - Provider = @{ DefaultAlias = 'Identity' } - WithSchema = @{ - Keys = @{ IdentityKey = @{ Type = 'String'; Required = $true } } - } -} -``` - -### Handler - -```powershell -# Before (~50 lines): checks With, Context.Providers, alias, calls Invoke-IdleProviderMethod, builds IdLE.StepResult -function Invoke-IdleStepDisableIdentity { - param([object] $Context, [object] $Step) - - $with = $Step.With - if ($null -eq $with -or -not ($with -is [hashtable])) { throw "DisableIdentity requires 'With' to be a hashtable." } - if (-not $with.ContainsKey('IdentityKey')) { throw "DisableIdentity requires With.IdentityKey." } - $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'Identity' } - # ... Context.Providers checks ... - $result = Invoke-IdleProviderMethod -Context $Context -With $with -ProviderAlias $providerAlias ` - -MethodName 'DisableIdentity' -MethodArguments @([string]$with.IdentityKey) - # ... Changed extraction, [pscustomobject]@{ PSTypeName = 'IdLE.StepResult'; ... } ... -} - -# After -function Invoke-IdleStepDisableIdentity { - param( - [Parameter(Mandatory)] [object] $Context, - [Parameter(Mandatory)] [hashtable] $With, - [Parameter(Mandatory)] [object] $Provider - ) - - $result = $Provider.Invoke('DisableIdentity', @($With.IdentityKey)) - return @{ Changed = [bool]$result.Changed } -} -``` - -### Host-defined steps - -Host steps (`Providers.StepMetadata` + `Providers.StepRegistry`) use the same catalog entry format and handler -signature. Workflows are unchanged unless they contain values that were silently accepted before but violate the -new types (these now fail at plan time). - -## 6. Breaking changes - -- Catalog format: `RequiredKeys` / `OptionalKeys` replaced by `Keys` (+ `Provider`). No compatibility layer. -- Handler signature: `-With` / `-Provider`, hashtable result, failure only via exception. No compatibility layer. -- `IdLE.StepResult`: step-specific properties move under `Data` (`Skipped`, `Attributes`). -- Workflows with wrongly typed `With` values now fail at plan time instead of execution time. -- `Invoke-IdleProviderMethod` is removed (replaced by the provider handle); `Test-IdleProviderMethodParameter` - becomes private. - -All first-party catalogs and handlers move in one change; the changelog/release notes carry the migration example. - -## 7. Implementation plan - -1. **Core** (one PR, atomic because there is no compatibility layer): schema validator, catalog loader - (`LegacyWithSchema`, `InvalidWithSchema`), handler signature check, provider handle, result normalization, - `Current` via resolved alias; migrate all 16 first-party catalogs and handlers and their tests. -2. **Docs and tooling**: `docs/extend/steps.md` (contract), step reference generator renders the typed `With` table - from the schema (types, required, defaults, allowed values) instead of "see step description". -3. **#289** (entitlement `Kind` per entry) builds on the nested schema (`Entitlements.Items.Keys.Kind`). - -## Implementation notes - -Differences from the proposal and findings during implementation: - -- `-Context` is optional (passed when declared), so handlers without context needs carry no unused parameter. -- `ProviderNotSupplied` is checked at execution only; plans are often built with a subset of providers. -- `Provider.AuthSessionNameFromAlias` keeps the mailbox steps' convention (session named after the alias). -- The engine wires `Context.EventSink` into providers exposing an `EventSink` property. -- Provider exceptions keep a single level of `MethodInvocationException` wrapping, as with direct calls. -- `Context.AcquireAuthSession` now accepts an empty name, which requests the broker's default session. Before, the - default-session path failed for every provider method declaring `AuthSession` when `With.AuthSessionName` was - not set. -- `Copy-IdleDataObject` and template resolution returned single-element arrays as scalars and empty arrays as - `$null` (PowerShell pipeline unrolling); both now keep arrays. -- `IdLE.Step.EmitEvent` no longer accepts `Provider` / `AuthSessionName` / `AuthSessionOptions`. -- `IdLE.Step.PruneEntitlementsEnsureKeep` shares a private implementation with `IdLE.Step.PruneEntitlements` - instead of injecting an undeclared `EnsureKeepEntitlements` key. -- Workflow examples used `@{ ValueFrom = '...' }`, which the engine never supported; they now use templates. -- Open: `State` propagation to later steps (the result key is reserved and returned as-is), tracked in #346. diff --git a/docs/extend/steps.md b/docs/extend/steps.md index 7691460c..e25f368c 100644 --- a/docs/extend/steps.md +++ b/docs/extend/steps.md @@ -122,13 +122,15 @@ not set (used by the mailbox steps: `ExchangeOnline`). Steps without `Provider` - `With` is validated at plan time, after template resolution. Defaults are applied, so the plan (and the plan export) contains the effective `With`, including the resolved `Provider`. -- No implicit type conversion: `'600'` is not an `Int`. Pure template placeholders keep the type of the request value. -- Optional keys set to `$null` are treated as absent. +- No implicit type conversion: `'600'` is not an `Int`, `'true'` is not a `Bool`. Pure template placeholders + (`'{{Request.Intent.TimeoutSeconds}}'`) keep the type of the request value. +- `Int` accepts `[int]` and `[long]`; `Array` accepts any non-string enumerable except dictionaries. +- Optional keys set to `$null` are treated as absent (defaults apply). `$null` values inside a `Hashtable` without + `Keys` (e.g. "clear attribute" values in `EnsureAttributes.Attributes`) are passed through. - Errors name the step, the step type and the full path, e.g. `WithKeyMissing: Step 'Grant' (type 'IdLE.Step.EnsureEntitlement'): With.Entitlement.Kind is required.` - Codes: `WithKeyMissing`, `WithKeyUnknown`, `WithKeyTypeMismatch`, `WithValueNotAllowed`. -- Invalid metadata fails when the catalog is loaded: `LegacyWithSchema` (old `RequiredKeys`/`OptionalKeys` format), - `InvalidWithSchema` (any other shape error). + See [Error codes](#error-codes). +- Invalid metadata fails when the catalog is loaded. - Metadata must be data-only — ScriptBlocks are rejected. Host-supplied step types may supplement (but not override) the catalog via `Providers.StepMetadata`. @@ -158,22 +160,112 @@ function Invoke-ContosoStepSyncManager { | `Context` | if declared | Execution context (`EventSink`, `Plan`, `Request`, `CorrelationId`, `Actor`). | | `Step` | if declared | Step metadata (`Name`, `Type`), e.g. for events. | -**Provider handle** (`IdLE.ProviderHandle`): +The engine checks the handler's parameters before the first attempt: `-With` is mandatory, and `-Provider` must be +declared if and only if the metadata declares `Provider`. -- `Invoke(MethodName, Arguments)` calls a provider method. `Arguments` are the method's parameters **without** - `AuthSession`; the engine acquires the session (once per step attempt) and inserts it at the position the method - declares (`ListEntitlements(IdentityKey, AuthSession, Kind)` is called as `Invoke('ListEntitlements', @($id, $kind))`). -- `HasMethod(MethodName)` / `HasParameter(MethodName, ParameterName)` check optional provider features. -- `Alias` is the resolved provider alias. The raw provider object is not exposed. +**Provider handle** (`IdLE.ProviderHandle`): the engine resolves the alias (`With.Provider` or `DefaultAlias`), +looks it up in `Providers` and passes a handle. The same alias feeds `Request.Context.Current` for preconditions. + +| Member | Meaning | +| --- | --- | +| `Alias` | Resolved provider alias. | +| `Invoke(MethodName, Arguments)` | Calls a provider method. `Arguments` exclude `AuthSession`; the session is inserted at the position the method declares it: `ListEntitlements(IdentityKey, AuthSession, Kind)` is called as `Invoke('ListEntitlements', @($id, $kind))`. | +| `HasMethod(MethodName)` | `$true` if the provider implements the method (optional features such as bulk operations). | +| `HasParameter(MethodName, ParameterName)` | `$true` if the method declares the parameter (e.g. `Kind` on `ListEntitlements`). | + +- The auth session is acquired on the first `Invoke` that needs it (the method declares `AuthSession`, or + `With.AuthSessionName` is set) and reused for the rest of the step attempt. Without `AuthSessionName` the broker is + asked for its default session. +- The raw provider object is not exposed, so steps cannot bypass the authentication handling. - Providers that expose an `EventSink` property receive the run's event sink. -**Result:** return `$null` or a hashtable with the optional keys `Changed` (Bool), `Data` (Hashtable) and `State` -(Hashtable). The engine builds `IdLE.StepResult` (`Name`, `Type`, `Status`, `Changed`, `Data`, `State`, `Error`, -`Attempts`, `Source`). Failures are reported by throwing; transient failures use the transient marker for retries. +**Result:** return `$null` or a hashtable with the optional keys: + +| Key | Type | Meaning | +| --- | --- | --- | +| `Changed` | Bool | The step changed external state. Default `$false`. | +| `Data` | Hashtable | Structured step output (e.g. per-attribute results, skipped entitlements). | +| `State` | Hashtable | Values intended for later steps. Reserved: returned in the step result, not yet consumed by the engine. | + +The engine builds `IdLE.StepResult` (`Name`, `Type`, `Status`, `Changed`, `Data`, `State`, `Error`, `Attempts`, +`Source`). Failures are reported by throwing; transient failures use the transient marker for retries. + +### Error codes + +Error messages start with the code, followed by the step name, the step type and, for `With` errors, the path. + +| Code | When | Raised at | +| --- | --- | --- | +| `LegacyWithSchema` | Catalog uses `RequiredKeys` / `OptionalKeys`. | Catalog load | +| `InvalidWithSchema` | Invalid schema: unknown `Type`, `Default` not matching `Type`, `Keys` on a non-hashtable, common key declared in `Keys`, ... | Catalog load | +| `WithKeyMissing` | Required key absent or `$null`. | Plan | +| `WithKeyUnknown` | Key not in the schema (and no `AllowAdditionalKeys`). | Plan | +| `WithKeyTypeMismatch` | Value does not match `Type`. | Plan | +| `WithValueNotAllowed` | Value not in `AllowedValues`, or empty without `AllowEmpty`. | Plan | +| `LegacyStepHandler` | Handler has no `-With` parameter (pre-1.0 contract). | Before the first attempt | +| `InvalidStepHandlerSignature` | `-Provider` declared without metadata `Provider`, or vice versa. | Before the first attempt | +| `ProviderNotSupplied` | Resolved alias not in `Providers`. | Execution (hosts may pass providers only to `Invoke-IdlePlan`) | +| `ProviderMethodNotImplemented` | `Invoke` of a method the provider does not implement. | Execution | +| `InvalidStepHandlerResult` | Result is not `$null` / hashtable, has unknown keys, or the handler leaked pipeline output. | Execution | + +### Migration from earlier versions + +Catalogs and handlers changed in one step without a compatibility layer. + +**Catalog:** move provider handling into `Provider` and give every key a type. + +```powershell +# Before +'IdLE.Step.DisableIdentity' = @{ + Handler = 'Invoke-IdleStepDisableIdentity' + RequiredCapabilities = @('IdLE.Identity.Disable') + WithSchema = @{ + RequiredKeys = @('IdentityKey') + OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + } +} + +# After +'IdLE.Step.DisableIdentity' = @{ + Handler = 'Invoke-IdleStepDisableIdentity' + RequiredCapabilities = @('IdLE.Identity.Disable') + Provider = @{ DefaultAlias = 'Identity' } + WithSchema = @{ + Keys = @{ IdentityKey = @{ Type = 'String'; Required = $true } } + } +} +``` + +**Handler:** replace `-Context`/`-Step` input checks, provider lookup, `Invoke-IdleProviderMethod` and the +hand-built `IdLE.StepResult` with `-With`, `-Provider` and a result hashtable. + +```powershell +# Before: checks With, Context.Providers and the alias, calls Invoke-IdleProviderMethod, builds IdLE.StepResult +function Invoke-IdleStepDisableIdentity { + param([object] $Context, [object] $Step) + # ... +} + +# After +function Invoke-IdleStepDisableIdentity { + param( + [Parameter(Mandatory)] [hashtable] $With, + [Parameter(Mandatory)] [object] $Provider + ) + + $result = $Provider.Invoke('DisableIdentity', @($With.IdentityKey)) + return @{ Changed = [bool]$result.Changed } +} +``` + +Other changes: -**Errors:** `LegacyStepHandler` (no `-With` parameter, pre-1.0 contract), `InvalidStepHandlerSignature` -(`-Provider` does not match the metadata), `InvalidStepHandlerResult` (other return values, including output leaked -to the pipeline), `ProviderNotSupplied`, `ProviderMethodNotImplemented`. +- `Invoke-IdleProviderMethod` was removed and `Test-IdleProviderMethodParameter` is no longer exported; use the + provider handle (`Invoke`, `HasParameter`). +- Step-specific result properties moved under `Data`: `PruneEntitlements*` `Skipped` -> `Data.Skipped`, + `EnsureAttributes` -> `Data.Attributes`. +- Workflows with wrongly typed `With` values (e.g. a number where a string is expected) now fail at plan time. +- `IdLE.Step.EmitEvent` no longer accepts `Provider`, `AuthSessionName` or `AuthSessionOptions`. ### Step packs From fae43e4d6c0af82671f5443586ccca8e875ee809 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:03:39 +0100 Subject: [PATCH 6/6] core: match the AuthSession parameter case-insensitively in the provider handle (#324) Test-IdleProviderMethodParameter detected 'authsession' case-insensitively, but the insert position used a case-sensitive IndexOf, so the session was appended and bound to the wrong parameter. Found by review. Co-Authored-By: Claude Opus 5.5 --- src/IdLE.Core/Private/New-IdleProviderHandle.ps1 | 6 ++++-- tests/Core/StepContract.Tests.ps1 | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/IdLE.Core/Private/New-IdleProviderHandle.ps1 b/src/IdLE.Core/Private/New-IdleProviderHandle.ps1 index 2d84c2fb..f1af8d48 100644 --- a/src/IdLE.Core/Private/New-IdleProviderHandle.ps1 +++ b/src/IdLE.Core/Private/New-IdleProviderHandle.ps1 @@ -134,9 +134,11 @@ function New-IdleProviderHandle { # parameter order, so the session is appended for them. $position = $callArguments.Count if ($method.MemberType -eq 'ScriptMethod' -and $null -ne $method.Script.Ast.ParamBlock) { + # PowerShell parameter names are case-insensitive, matching Test-IdleProviderMethodParameter. $names = @($method.Script.Ast.ParamBlock.Parameters | ForEach-Object { $_.Name.VariablePath.UserPath }) - $declared = [array]::IndexOf($names, 'AuthSession') - if ($declared -ge 0 -and $declared -lt $position) { $position = $declared } + for ($index = 0; $index -lt $names.Count -and $index -lt $position; $index++) { + if ($names[$index] -eq 'AuthSession') { $position = $index; break } + } } if ($null -ne $sessionState.Session -or $position -lt $callArguments.Count) { diff --git a/tests/Core/StepContract.Tests.ps1 b/tests/Core/StepContract.Tests.ps1 index 2fcb9854..fbd01cd8 100644 --- a/tests/Core/StepContract.Tests.ps1 +++ b/tests/Core/StepContract.Tests.ps1 @@ -78,6 +78,10 @@ BeforeAll { param($IdentityKey, $AuthSession, $Kind) return "$IdentityKey|$AuthSession|$Kind" } + $provider | Add-Member -MemberType ScriptMethod -Name ListWithKindLowerCase -Value { + param($IdentityKey, $authsession, $Kind) + return "$IdentityKey|$authsession|$Kind" + } $provider | Add-Member -MemberType ScriptMethod -Name NoAuth -Value { param($Value) return "$Value" @@ -365,6 +369,14 @@ Describe 'Step contract' { } } + It 'matches the AuthSession parameter name case-insensitively (regression)' { + InModuleScope 'IdLE.Core' -Parameters @{ Context = $script:Context } { + param($Context) + $handle = New-IdleProviderHandle -Context $Context -Alias 'Echo' -AuthSessionName 'Admin' + $handle.Invoke('ListWithKindLowerCase', @('user1', 'Group')) | Should -Be 'user1|session-Admin|Group' + } + } + It 'acquires the auth session once per handle' { InModuleScope 'IdLE.Core' -Parameters @{ Context = $script:Context } { param($Context)