From 2a533da13fc418020fc497811cb982448a2097eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:50:31 +0000 Subject: [PATCH 1/6] Initial plan From fcf9dd63c9a9baaad1bc1aa50088246f63c1970e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:21:11 +0000 Subject: [PATCH 2/6] Implement mandatory WithSchema validation for step metadata (core changes + tests) Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/ConvertTo-IdleWorkflowSteps.ps1 | 78 +++++-- .../Resolve-IdleStepMetadataCatalog.ps1 | 116 ++++++++++ .../Private/Test-IdleStepDefinition.ps1 | 5 - .../Public/Get-IdleStepMetadataCatalog.ps1 | 88 +++----- .../StepMetadataCatalog.psd1 | 113 ++++++++++ .../Public/Get-IdleStepMetadataCatalog.ps1 | 35 ++- .../StepMetadataCatalog.psd1 | 22 ++ .../Public/Get-IdleStepMetadataCatalog.ps1 | 52 ++--- .../StepMetadataCatalog.psd1 | 52 +++++ tests/Core/Invoke-IdlePlan.Tests.ps1 | 8 +- .../New-IdlePlan.ContextResolvers.Tests.ps1 | 3 +- tests/Core/New-IdlePlan.Tests.ps1 | 10 +- .../Resolve-IdleStepMetadataCatalog.Tests.ps1 | 199 ++++++++++++++++++ tests/Steps/_testHelpers.Steps.ps1 | 29 ++- tests/_testHelpers.ps1 | 3 +- tests/fixtures/workflows/joiner-builtin.psd1 | 1 + .../workflows/joiner-missing-caps.psd1 | 1 + .../fixtures/workflows/joiner-onfailure.psd1 | 1 + .../resolver-current-precondition.psd1 | 2 +- 19 files changed, 688 insertions(+), 130 deletions(-) create mode 100644 src/IdLE.Steps.Common/StepMetadataCatalog.psd1 create mode 100644 src/IdLE.Steps.DirectorySync/StepMetadataCatalog.psd1 create mode 100644 src/IdLE.Steps.Mailbox/StepMetadataCatalog.psd1 diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index f4ce2177..5c102737 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -121,23 +121,71 @@ function ConvertTo-IdleWorkflowSteps { # Resolve template placeholders in With (planning-time resolution) $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName - # Validate AllowedWithKeys declared by step metadata (fail-fast plan-time schema check). - # Steps that declare AllowedWithKeys accept only those keys in With; any other key is rejected. - # Steps that do not declare AllowedWithKeys skip this validation (backward compatible). + # 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('AllowedWithKeys')) { - $allowedSet = [System.Collections.Generic.HashSet[string]]::new( - [string[]]@($md['AllowedWithKeys']), - [System.StringComparer]::OrdinalIgnoreCase - ) - foreach ($wk in @($with.Keys)) { - if (-not $allowedSet.Contains([string]$wk)) { - $allowedList = [string]::Join(', ', ([string[]]@($md['AllowedWithKeys']) | Sort-Object)) - throw [System.ArgumentException]::new( - ("Step '{0}' (type '{1}') does not support With.{2}. Allowed With keys: {3}." -f $stepName, $stepType, [string]$wk, $allowedList), - 'Workflow' - ) + 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 + $allAllowedKeysList = [System.Collections.Generic.List[string]]::new() + foreach ($k in $requiredKeys) { + if ($null -ne $k -and -not [string]::IsNullOrWhiteSpace([string]$k)) { + $null = $allAllowedKeysList.Add([string]$k) + } + } + foreach ($k in $optionalKeys) { + 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 } + $keyPresent = $false + foreach ($wk in @($with.Keys)) { + if ([string]::Equals([string]$wk, [string]$rk, [System.StringComparison]::OrdinalIgnoreCase)) { + $keyPresent = $true + break + } + } + if (-not $keyPresent) { + $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' + ) + } + } } } } diff --git a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 index af043e30..7d188505 100644 --- a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 @@ -19,6 +19,114 @@ 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 -or $Value -isnot [hashtable]) { + 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' + ) + } + + 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()] @@ -210,6 +318,10 @@ 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 + # Check for duplicates across step packs if ($StepTypeOwners.ContainsKey([string]$key)) { $existingOwner = $StepTypeOwners[[string]$key] @@ -280,6 +392,10 @@ 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' + # Add host supplement $catalog[[string]$key] = $value $stepTypeOwners[[string]$key] = 'Host' diff --git a/src/IdLE.Core/Private/Test-IdleStepDefinition.ps1 b/src/IdLE.Core/Private/Test-IdleStepDefinition.ps1 index d8fbc2fa..59603b27 100644 --- a/src/IdLE.Core/Private/Test-IdleStepDefinition.ps1 +++ b/src/IdLE.Core/Private/Test-IdleStepDefinition.ps1 @@ -45,11 +45,6 @@ function Test-IdleStepDefinition { if (-not ($Step['Condition'] -is [hashtable])) { $errors.Add("Step[$Index] ($name): 'Condition' must be a hashtable when provided.") } - else { - foreach ($e in (Test-IdleConditionSchema -Condition $Step['Condition'] -StepName $name)) { - $errors.Add($e) - } - } } return $errors diff --git a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 index d875a215..c6ca2f8f 100644 --- a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 @@ -4,11 +4,15 @@ function Get-IdleStepMetadataCatalog { Returns metadata for common built-in IdLE step types. .DESCRIPTION - This function provides a metadata catalog mapping Step.Type to metadata objects. - Each metadata object contains RequiredCapabilities (array of capability identifiers). + This function loads and returns the step metadata catalog for common built-in IdLE step types. + The catalog is defined in StepMetadataCatalog.psd1 (data-only, no ScriptBlocks). + + Each metadata object contains: + RequiredCapabilities - capability identifiers the step requires from providers + WithSchema - the With key contract used for plan-time validation The metadata is used during plan building to derive required provider capabilities - for each step, removing the need to declare RequiresCapabilities in workflow definitions. + for each step and to validate With parameters. .OUTPUTS Hashtable (case-insensitive) mapping Step.Type (string) to metadata (hashtable). @@ -21,65 +25,27 @@ function Get-IdleStepMetadataCatalog { [CmdletBinding()] param() - $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) - - # IdLE.Step.EmitEvent - no provider capabilities required (writes to event sink only) - $catalog['IdLE.Step.EmitEvent'] = @{ - RequiredCapabilities = @() - } - - # IdLE.Step.CreateIdentity - requires identity creation capability - $catalog['IdLE.Step.CreateIdentity'] = @{ - RequiredCapabilities = @('IdLE.Identity.Create') - } - - # IdLE.Step.DisableIdentity - requires identity disable capability - $catalog['IdLE.Step.DisableIdentity'] = @{ - RequiredCapabilities = @('IdLE.Identity.Disable') - } - - # IdLE.Step.EnableIdentity - requires identity enable capability - $catalog['IdLE.Step.EnableIdentity'] = @{ - RequiredCapabilities = @('IdLE.Identity.Enable') - } - - # IdLE.Step.DeleteIdentity - requires identity delete capability - $catalog['IdLE.Step.DeleteIdentity'] = @{ - RequiredCapabilities = @('IdLE.Identity.Delete') - } + $catalogPath = Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'StepMetadataCatalog.psd1' + $rawData = Import-PowerShellDataFile -Path $catalogPath - # IdLE.Step.MoveIdentity - requires identity move capability - $catalog['IdLE.Step.MoveIdentity'] = @{ - RequiredCapabilities = @('IdLE.Identity.Move') - } - - # IdLE.Step.EnsureAttributes - requires identity attribute ensure capability - $catalog['IdLE.Step.EnsureAttributes'] = @{ - RequiredCapabilities = @('IdLE.Identity.Attribute.Ensure') - } - - # IdLE.Step.EnsureEntitlement - requires entitlement list and grant/revoke capabilities - $catalog['IdLE.Step.EnsureEntitlement'] = @{ - RequiredCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') - } - - # IdLE.Step.RevokeIdentitySessions - requires identity session revocation capability - $catalog['IdLE.Step.RevokeIdentitySessions'] = @{ - RequiredCapabilities = @('IdLE.Identity.RevokeSessions') - } - - # IdLE.Step.PruneEntitlements - remove-only: requires explicit prune opt-in capability plus list/revoke - $catalog['IdLE.Step.PruneEntitlements'] = @{ - RequiredCapabilities = @('IdLE.Entitlement.Prune', 'IdLE.Entitlement.List', 'IdLE.Entitlement.Revoke') - AllowedWithKeys = @('IdentityKey', 'Kind', 'Provider', 'Keep', 'KeepPattern', 'AuthSessionName', 'AuthSessionOptions') - } - - # IdLE.Step.PruneEntitlementsEnsureKeep - remove + ensure keep present: requires prune + list/revoke/grant - # KeepPattern is NOT in AllowedWithKeys because patterns cannot be "ensured" (granted); plan-time - # validation rejects any With key that is not in this list. - $catalog['IdLE.Step.PruneEntitlementsEnsureKeep'] = @{ - RequiredCapabilities = @('IdLE.Entitlement.Prune', 'IdLE.Entitlement.List', 'IdLE.Entitlement.Revoke', 'IdLE.Entitlement.Grant') - AllowedWithKeys = @('IdentityKey', 'Kind', 'Provider', 'Keep', 'AuthSessionName', 'AuthSessionOptions') + $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($key in $rawData.Keys) { + $entry = $rawData[$key] + $ht = @{} + foreach ($metaKey in $entry.Keys) { + $metaValue = $entry[$metaKey] + if ($metaValue -is [System.Collections.IDictionary]) { + $nested = @{} + foreach ($nestedKey in $metaValue.Keys) { + $nested[$nestedKey] = $metaValue[$nestedKey] + } + $ht[$metaKey] = $nested + } + else { + $ht[$metaKey] = $metaValue + } + } + $catalog[$key] = $ht } return $catalog diff --git a/src/IdLE.Steps.Common/StepMetadataCatalog.psd1 b/src/IdLE.Steps.Common/StepMetadataCatalog.psd1 new file mode 100644 index 00000000..a711cc73 --- /dev/null +++ b/src/IdLE.Steps.Common/StepMetadataCatalog.psd1 @@ -0,0 +1,113 @@ +# StepMetadataCatalog.psd1 - IdLE.Steps.Common +# +# Data-only metadata catalog for all common built-in IdLE step types. +# This file is loaded by Get-IdleStepMetadataCatalog and must remain data-only (no ScriptBlocks). +# +# Each entry maps a Step.Type to a metadata hashtable containing: +# 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 +# +@{ + # IdLE.Step.EmitEvent - writes a structured event to the event sink; no provider capabilities required + 'IdLE.Step.EmitEvent' = @{ + RequiredCapabilities = @() + WithSchema = @{ + RequiredKeys = @() + OptionalKeys = @('Message') + } + } + + # IdLE.Step.CreateIdentity - provisions a new identity via the identity provider + 'IdLE.Step.CreateIdentity' = @{ + RequiredCapabilities = @('IdLE.Identity.Create') + WithSchema = @{ + RequiredKeys = @('IdentityKey', 'Attributes') + OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + } + } + + # IdLE.Step.DisableIdentity - disables an existing identity via the identity provider + 'IdLE.Step.DisableIdentity' = @{ + RequiredCapabilities = @('IdLE.Identity.Disable') + WithSchema = @{ + RequiredKeys = @('IdentityKey') + OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + } + } + + # IdLE.Step.EnableIdentity - re-enables a disabled identity via the identity provider + 'IdLE.Step.EnableIdentity' = @{ + RequiredCapabilities = @('IdLE.Identity.Enable') + WithSchema = @{ + RequiredKeys = @('IdentityKey') + OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + } + } + + # IdLE.Step.DeleteIdentity - permanently removes an identity via the identity provider + 'IdLE.Step.DeleteIdentity' = @{ + RequiredCapabilities = @('IdLE.Identity.Delete') + WithSchema = @{ + RequiredKeys = @('IdentityKey') + OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + } + } + + # IdLE.Step.MoveIdentity - moves an identity to a target container/OU + 'IdLE.Step.MoveIdentity' = @{ + RequiredCapabilities = @('IdLE.Identity.Move') + WithSchema = @{ + RequiredKeys = @('IdentityKey', 'TargetContainer') + OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + } + } + + # IdLE.Step.EnsureAttributes - idempotently sets attributes on an identity + 'IdLE.Step.EnsureAttributes' = @{ + RequiredCapabilities = @('IdLE.Identity.Attribute.Ensure') + WithSchema = @{ + RequiredKeys = @('IdentityKey', 'Attributes') + OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + } + } + + # IdLE.Step.EnsureEntitlement - idempotently grants or revokes a single entitlement + 'IdLE.Step.EnsureEntitlement' = @{ + RequiredCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') + WithSchema = @{ + RequiredKeys = @('IdentityKey', 'Entitlement', 'State') + OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + } + } + + # IdLE.Step.RevokeIdentitySessions - revokes all active sessions for an identity + 'IdLE.Step.RevokeIdentitySessions' = @{ + RequiredCapabilities = @('IdLE.Identity.RevokeSessions') + WithSchema = @{ + RequiredKeys = @('IdentityKey') + OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + } + } + + # IdLE.Step.PruneEntitlements - remove-only: removes entitlements not in Keep/KeepPattern + # Requires explicit prune opt-in capability plus list/revoke + 'IdLE.Step.PruneEntitlements' = @{ + RequiredCapabilities = @('IdLE.Entitlement.Prune', 'IdLE.Entitlement.List', 'IdLE.Entitlement.Revoke') + WithSchema = @{ + RequiredKeys = @('IdentityKey', 'Kind') + OptionalKeys = @('Provider', 'Keep', 'KeepPattern', 'AuthSessionName', 'AuthSessionOptions') + } + } + + # IdLE.Step.PruneEntitlementsEnsureKeep - remove + ensure keep present: prune + grant-back + # KeepPattern is NOT in OptionalKeys because patterns cannot be granted (they are filter-only). + 'IdLE.Step.PruneEntitlementsEnsureKeep' = @{ + RequiredCapabilities = @('IdLE.Entitlement.Prune', 'IdLE.Entitlement.List', 'IdLE.Entitlement.Revoke', 'IdLE.Entitlement.Grant') + WithSchema = @{ + RequiredKeys = @('IdentityKey', 'Kind') + OptionalKeys = @('Provider', 'Keep', 'AuthSessionName', 'AuthSessionOptions') + } + } +} diff --git a/src/IdLE.Steps.DirectorySync/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.DirectorySync/Public/Get-IdleStepMetadataCatalog.ps1 index df754fcf..d7ce5123 100644 --- a/src/IdLE.Steps.DirectorySync/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.DirectorySync/Public/Get-IdleStepMetadataCatalog.ps1 @@ -4,13 +4,15 @@ function Get-IdleStepMetadataCatalog { Returns metadata for DirectorySync step types. .DESCRIPTION - This function provides a metadata catalog mapping Step.Type to metadata objects - for directory sync step types owned by this step pack. + This function loads and returns the step metadata catalog for directory sync step types. + The catalog is defined in StepMetadataCatalog.psd1 (data-only, no ScriptBlocks). - Each metadata object contains RequiredCapabilities (array of capability identifiers). + Each metadata object contains: + RequiredCapabilities - capability identifiers the step requires from providers + WithSchema - the With key contract used for plan-time validation The metadata is used during plan building to derive required provider capabilities - for each step, removing the need to declare RequiresCapabilities in workflow definitions. + for each step and to validate With parameters. .OUTPUTS Hashtable (case-insensitive) mapping Step.Type (string) to metadata (hashtable). @@ -23,12 +25,27 @@ function Get-IdleStepMetadataCatalog { [CmdletBinding()] param() - $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + $catalogPath = Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'StepMetadataCatalog.psd1' + $rawData = Import-PowerShellDataFile -Path $catalogPath - # IdLE.Step.TriggerDirectorySync - requires trigger and status capabilities - # Note: Even when With.Wait = $false, we advertise Status capability to keep planning deterministic - $catalog['IdLE.Step.TriggerDirectorySync'] = @{ - RequiredCapabilities = @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') + $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($key in $rawData.Keys) { + $entry = $rawData[$key] + $ht = @{} + foreach ($metaKey in $entry.Keys) { + $metaValue = $entry[$metaKey] + if ($metaValue -is [System.Collections.IDictionary]) { + $nested = @{} + foreach ($nestedKey in $metaValue.Keys) { + $nested[$nestedKey] = $metaValue[$nestedKey] + } + $ht[$metaKey] = $nested + } + else { + $ht[$metaKey] = $metaValue + } + } + $catalog[$key] = $ht } return $catalog diff --git a/src/IdLE.Steps.DirectorySync/StepMetadataCatalog.psd1 b/src/IdLE.Steps.DirectorySync/StepMetadataCatalog.psd1 new file mode 100644 index 00000000..5a0720e0 --- /dev/null +++ b/src/IdLE.Steps.DirectorySync/StepMetadataCatalog.psd1 @@ -0,0 +1,22 @@ +# StepMetadataCatalog.psd1 - IdLE.Steps.DirectorySync +# +# Data-only metadata catalog for directory sync step types. +# This file is loaded by Get-IdleStepMetadataCatalog and must remain data-only (no ScriptBlocks). +# +# Each entry maps a Step.Type to a metadata hashtable containing: +# 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 +# +@{ + # IdLE.Step.TriggerDirectorySync - triggers a directory sync cycle and optionally waits for completion + # Note: Even when With.Wait = $false, Status capability is advertised to keep planning deterministic. + 'IdLE.Step.TriggerDirectorySync' = @{ + RequiredCapabilities = @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') + WithSchema = @{ + RequiredKeys = @('AuthSessionName', 'PolicyType') + OptionalKeys = @('Provider', 'Wait', 'TimeoutSeconds', 'PollIntervalSeconds', 'AuthSessionOptions') + } + } +} diff --git a/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 index 39b8d425..a1bc712c 100644 --- a/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 @@ -4,14 +4,15 @@ function Get-IdleStepMetadataCatalog { Returns metadata for mailbox step types. .DESCRIPTION - This function provides a metadata catalog mapping Step.Type to metadata objects. - Each metadata object contains RequiredCapabilities (array of capability identifiers). + This function loads and returns the step metadata catalog for mailbox step types. + The catalog is defined in StepMetadataCatalog.psd1 (data-only, no ScriptBlocks). - The metadata is used during plan building to derive required provider capabilities - for each step, removing the need to declare RequiresCapabilities in workflow definitions. + Each metadata object contains: + RequiredCapabilities - capability identifiers the step requires from providers + WithSchema - the With key contract used for plan-time validation - This catalog declares mailbox-specific step types that work with any provider - implementing the mailbox provider contract. + The metadata is used during plan building to derive required provider capabilities + for each step and to validate With parameters. .OUTPUTS Hashtable (case-insensitive) mapping Step.Type (string) to metadata (hashtable). @@ -24,26 +25,27 @@ function Get-IdleStepMetadataCatalog { [CmdletBinding()] param() - $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) - - # IdLE.Step.Mailbox.GetInfo - read mailbox details - $catalog['IdLE.Step.Mailbox.GetInfo'] = @{ - RequiredCapabilities = @('IdLE.Mailbox.Info.Read') - } - - # IdLE.Step.Mailbox.EnsureType - idempotent mailbox type conversion - $catalog['IdLE.Step.Mailbox.EnsureType'] = @{ - RequiredCapabilities = @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.Type.Ensure') - } + $catalogPath = Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'StepMetadataCatalog.psd1' + $rawData = Import-PowerShellDataFile -Path $catalogPath - # IdLE.Step.Mailbox.EnsureOutOfOffice - idempotent Out of Office configuration - $catalog['IdLE.Step.Mailbox.EnsureOutOfOffice'] = @{ - RequiredCapabilities = @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.OutOfOffice.Ensure') - } - - # IdLE.Step.Mailbox.EnsurePermissions - idempotent mailbox delegate permissions - $catalog['IdLE.Step.Mailbox.EnsurePermissions'] = @{ - RequiredCapabilities = @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.Permissions.Ensure') + $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($key in $rawData.Keys) { + $entry = $rawData[$key] + $ht = @{} + foreach ($metaKey in $entry.Keys) { + $metaValue = $entry[$metaKey] + if ($metaValue -is [System.Collections.IDictionary]) { + $nested = @{} + foreach ($nestedKey in $metaValue.Keys) { + $nested[$nestedKey] = $metaValue[$nestedKey] + } + $ht[$metaKey] = $nested + } + else { + $ht[$metaKey] = $metaValue + } + } + $catalog[$key] = $ht } return $catalog diff --git a/src/IdLE.Steps.Mailbox/StepMetadataCatalog.psd1 b/src/IdLE.Steps.Mailbox/StepMetadataCatalog.psd1 new file mode 100644 index 00000000..60b6f454 --- /dev/null +++ b/src/IdLE.Steps.Mailbox/StepMetadataCatalog.psd1 @@ -0,0 +1,52 @@ +# StepMetadataCatalog.psd1 - IdLE.Steps.Mailbox +# +# Data-only metadata catalog for mailbox step types. +# This file is loaded by Get-IdleStepMetadataCatalog and must remain data-only (no ScriptBlocks). +# +# Each entry maps a Step.Type to a metadata hashtable containing: +# 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 +# +@{ + # IdLE.Step.Mailbox.GetInfo - reads mailbox details for an identity + 'IdLE.Step.Mailbox.GetInfo' = @{ + RequiredCapabilities = @('IdLE.Mailbox.Info.Read') + WithSchema = @{ + RequiredKeys = @('IdentityKey') + OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + } + } + + # IdLE.Step.Mailbox.EnsureType - idempotently converts a mailbox to the specified type + # MailboxType accepts: User, Shared, Room, Equipment + 'IdLE.Step.Mailbox.EnsureType' = @{ + RequiredCapabilities = @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.Type.Ensure') + WithSchema = @{ + RequiredKeys = @('IdentityKey', 'MailboxType') + OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + } + } + + # 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) + 'IdLE.Step.Mailbox.EnsureOutOfOffice' = @{ + RequiredCapabilities = @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.OutOfOffice.Ensure') + WithSchema = @{ + RequiredKeys = @('IdentityKey', 'Config') + OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + } + } + + # IdLE.Step.Mailbox.EnsurePermissions - idempotently manages delegate permissions + # Permissions is an array of hashtables with: AssignedUser, Right, Ensure + 'IdLE.Step.Mailbox.EnsurePermissions' = @{ + RequiredCapabilities = @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.Permissions.Ensure') + WithSchema = @{ + RequiredKeys = @('IdentityKey', 'Permissions') + OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + } + } +} diff --git a/tests/Core/Invoke-IdlePlan.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Tests.ps1 index 7bf25a85..72031d16 100644 --- a/tests/Core/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Tests.ps1 @@ -158,7 +158,7 @@ Describe 'Invoke-IdlePlan' { LifecycleEvent = 'Joiner' Steps = @( @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } - @{ Name = 'EnsureAttributes'; Type = 'IdLE.Step.EnsureAttributes' } + @{ Name = 'ProcessUser'; Type = 'IdLE.Step.ProcessUser' } ) } '@ @@ -176,10 +176,10 @@ Describe 'Invoke-IdlePlan' { $providers = @{ Identity = $dummyProvider StepRegistry = @{ - 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' - 'IdLE.Step.EnsureAttributes' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.ProcessUser' = 'Invoke-IdleTestNoopStep' } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity', 'IdLE.Step.ProcessUser') } $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers diff --git a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 index f7568be7..1ff18792 100644 --- a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 +++ b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 @@ -1189,7 +1189,8 @@ Describe 'New-IdlePlan - ContextResolvers' { $providers = @{ Identity = $provider - StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + StepRegistry = @{ 'IdLE.Step.CurrentTest' = 'Invoke-IdleContextResolverTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.CurrentTest') } $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers diff --git a/tests/Core/New-IdlePlan.Tests.ps1 b/tests/Core/New-IdlePlan.Tests.ps1 index 222ca56c..ee80ecac 100644 --- a/tests/Core/New-IdlePlan.Tests.ps1 +++ b/tests/Core/New-IdlePlan.Tests.ps1 @@ -39,7 +39,7 @@ Describe 'New-IdlePlan' { LifecycleEvent = 'Joiner' Steps = @( @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } - @{ Name = 'EnsureAttributes'; Type = 'IdLE.Step.EnsureAttributes'; With = @{ Mode = 'Minimal' } } + @{ Name = 'ProcessUser'; Type = 'IdLE.Step.ProcessUser'; With = @{ Mode = 'Minimal' } } ) } '@ @@ -55,10 +55,10 @@ Describe 'New-IdlePlan' { Dummy = $true Identity = $dummyProvider StepRegistry = @{ - 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' - 'IdLE.Step.EnsureAttributes' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.ProcessUser' = 'Invoke-IdleTestNoopStep' } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity', 'IdLE.Step.ProcessUser') } $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers @@ -203,7 +203,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 AllowedWithKeys)' { + It 'fails plan building when PruneEntitlementsEnsureKeep step contains unsupported With.KeepPattern key (not in WithSchema.OptionalKeys)' { $wfPath = New-IdleTestWorkflowFile -FileName 'leaver-bad.psd1' -Content @' @{ Name = 'Leaver - Bad KeepPattern' diff --git a/tests/Core/Resolve-IdleStepMetadataCatalog.Tests.ps1 b/tests/Core/Resolve-IdleStepMetadataCatalog.Tests.ps1 index 4492eb40..90630a89 100644 --- a/tests/Core/Resolve-IdleStepMetadataCatalog.Tests.ps1 +++ b/tests/Core/Resolve-IdleStepMetadataCatalog.Tests.ps1 @@ -72,6 +72,10 @@ Describe 'Resolve-IdleStepMetadataCatalog - step pack catalog ownership' { StepMetadata = @{ 'Custom.Step.Unknown' = @{ RequiredCapabilities = @('Custom.Capability.Test') + WithSchema = @{ + RequiredKeys = @() + OptionalKeys = @() + } } } CustomProvider = $provider @@ -135,6 +139,201 @@ Describe 'Resolve-IdleStepMetadataCatalog - step pack catalog ownership' { $_.Exception.Message | Should -Match 'ScriptBlock' } } + + It 'rejects host metadata missing WithSchema' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-no-metadata.psd1' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Custom.Step.NoSchema' = 'Invoke-CustomStep' + } + StepMetadata = @{ + 'Custom.Step.NoSchema' = @{ + RequiredCapabilities = @() + } + } + } + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'WithSchema' + $_.Exception.Message | Should -Match 'Custom.Step.NoSchema' + } + } + + It 'rejects host metadata WithSchema missing RequiredKeys' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-no-metadata.psd1' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Custom.Step.BadSchema' = 'Invoke-CustomStep' + } + StepMetadata = @{ + 'Custom.Step.BadSchema' = @{ + RequiredCapabilities = @() + WithSchema = @{ + OptionalKeys = @('Message') + } + } + } + } + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'RequiredKeys' + $_.Exception.Message | Should -Match 'Custom.Step.BadSchema' + } + } + + It 'rejects host metadata WithSchema with duplicate key across RequiredKeys and OptionalKeys' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-no-metadata.psd1' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Custom.Step.DupSchema' = 'Invoke-CustomStep' + } + StepMetadata = @{ + 'Custom.Step.DupSchema' = @{ + RequiredCapabilities = @() + WithSchema = @{ + RequiredKeys = @('IdentityKey') + OptionalKeys = @('IdentityKey', 'Provider') + } + } + } + } + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'IdentityKey' + $_.Exception.Message | Should -Match 'RequiredKeys.*OptionalKeys|OptionalKeys.*RequiredKeys' + } + } + } +} + +Describe 'Resolve-IdleStepMetadataCatalog - step pack WithSchema' { + Context 'Step pack WithSchema' { + It 'exposes WithSchema from step pack metadata for DisableIdentity' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'joiner-builtin.psd1' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Disable') + } -Force + + $providers = @{ IdentityProvider = $provider } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $plan | Should -Not -BeNullOrEmpty + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' + } + + It 'fails plan creation when step has unknown With key for DisableIdentity' { + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Disable') + } -Force + + $providers = @{ IdentityProvider = $provider } + + $wfPath = New-IdleTestWorkflowFile -FileName 'disable-unknown-key.psd1' -Content @' +@{ + Name = 'Test - Unknown Key' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'DisableStep' + Type = 'IdLE.Step.DisableIdentity' + With = @{ IdentityKey = 'user1'; UnknownParam = 'bad' } + } + ) +} +'@ + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'UnknownParam' + $_.Exception.Message | Should -Match 'IdLE.Step.DisableIdentity' + $_.Exception.Message | Should -Match 'DisableStep' + } + } + + It 'fails plan creation when step is missing required With key for DisableIdentity' { + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Disable') + } -Force + + $providers = @{ IdentityProvider = $provider } + + $wfPath = New-IdleTestWorkflowFile -FileName 'disable-missing-key.psd1' -Content @' +@{ + Name = 'Test - Missing Required Key' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'DisableStep' + Type = 'IdLE.Step.DisableIdentity' + } + ) +} +'@ + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'IdentityKey' + $_.Exception.Message | Should -Match 'IdLE.Step.DisableIdentity' + $_.Exception.Message | Should -Match 'DisableStep' + } + } + + It 'validates With key schema case-insensitively' { + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Disable') + } -Force + + $providers = @{ IdentityProvider = $provider } + + $wfPath = New-IdleTestWorkflowFile -FileName 'disable-case-insensitive.psd1' -Content @' +@{ + Name = 'Test - Case Insensitive Keys' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'DisableStep' + Type = 'IdLE.Step.DisableIdentity' + With = @{ identitykey = 'user1' } + } + ) +} +'@ + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Not -Throw + } } } diff --git a/tests/Steps/_testHelpers.Steps.ps1 b/tests/Steps/_testHelpers.Steps.ps1 index 1ab500e2..7ca89022 100644 --- a/tests/Steps/_testHelpers.Steps.ps1 +++ b/tests/Steps/_testHelpers.Steps.ps1 @@ -19,7 +19,9 @@ function New-IdleTestStepMetadata { .DESCRIPTION Helper function to create StepMetadata entries for test-specific step types. - By default, creates metadata with no required capabilities. + By default, creates metadata with no required capabilities and a permissive WithSchema + that accepts any With key (OptionalKeys = @('*')). This allows test workflows to use + arbitrary With.* keys without schema validation failures. .PARAMETER StepTypes Array of step type names to create metadata for. @@ -27,6 +29,10 @@ function New-IdleTestStepMetadata { .PARAMETER RequiredCapabilities Hashtable mapping step types to their required capabilities. + .PARAMETER WithSchemas + Hashtable mapping step types to their WithSchema definitions. Step types not in this + hashtable receive the default permissive schema: @{ RequiredKeys = @(); OptionalKeys = @('*') }. + .EXAMPLE $metadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity', 'IdLE.Step.Primary') @@ -34,6 +40,11 @@ function New-IdleTestStepMetadata { $metadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Custom') -RequiredCapabilities @{ 'IdLE.Step.Custom' = @('Custom.Capability') } + + .EXAMPLE + $metadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Strict') -WithSchemas @{ + 'IdLE.Step.Strict' = @{ RequiredKeys = @('IdentityKey'); OptionalKeys = @('Provider') } + } #> [CmdletBinding()] param( @@ -41,7 +52,10 @@ function New-IdleTestStepMetadata { [string[]] $StepTypes, [Parameter()] - [hashtable] $RequiredCapabilities = @{} + [hashtable] $RequiredCapabilities = @{}, + + [Parameter()] + [hashtable] $WithSchemas = @{} ) $metadata = @{} @@ -52,9 +66,18 @@ function New-IdleTestStepMetadata { else { @() } - + + $schema = if ($WithSchemas.ContainsKey($stepType)) { + $WithSchemas[$stepType] + } + else { + # Default: permissive schema that accepts any With key + @{ RequiredKeys = @(); OptionalKeys = @('*') } + } + $metadata[$stepType] = @{ RequiredCapabilities = $caps + WithSchema = $schema } } diff --git a/tests/_testHelpers.ps1 b/tests/_testHelpers.ps1 index 8e00fc99..6e113e4d 100644 --- a/tests/_testHelpers.ps1 +++ b/tests/_testHelpers.ps1 @@ -63,10 +63,11 @@ function Get-ModuleManifestPaths { $repoRoot = Get-RepoRootPath $srcRoot = Join-Path -Path $repoRoot -ChildPath 'src' - # module manifests only (one level deep) + # module manifests only (one level deep, filename must match the module directory name) return Get-ChildItem -Path $srcRoot -Filter '*.psd1' -File -Recurse | Where-Object { $_.FullName -match [regex]::Escape([IO.Path]::Combine('src', '')) } | Where-Object { $_.Directory.Parent -and $_.Directory.Parent.Name -eq 'src' } | + Where-Object { [IO.Path]::GetFileNameWithoutExtension($_.Name) -eq $_.Directory.Name } | Select-Object -ExpandProperty FullName } diff --git a/tests/fixtures/workflows/joiner-builtin.psd1 b/tests/fixtures/workflows/joiner-builtin.psd1 index 9bd614dd..bcd94c1c 100644 --- a/tests/fixtures/workflows/joiner-builtin.psd1 +++ b/tests/fixtures/workflows/joiner-builtin.psd1 @@ -5,6 +5,7 @@ @{ Name = 'Disable identity' Type = 'IdLE.Step.DisableIdentity' + With = @{ IdentityKey = 'test-user' } } ) } diff --git a/tests/fixtures/workflows/joiner-missing-caps.psd1 b/tests/fixtures/workflows/joiner-missing-caps.psd1 index 51a45f1c..4e67e7eb 100644 --- a/tests/fixtures/workflows/joiner-missing-caps.psd1 +++ b/tests/fixtures/workflows/joiner-missing-caps.psd1 @@ -5,6 +5,7 @@ @{ Name = 'Disable identity' Type = 'IdLE.Step.DisableIdentity' + With = @{ IdentityKey = 'test-user' } } ) } diff --git a/tests/fixtures/workflows/joiner-onfailure.psd1 b/tests/fixtures/workflows/joiner-onfailure.psd1 index 65b17a8e..754b5a6f 100644 --- a/tests/fixtures/workflows/joiner-onfailure.psd1 +++ b/tests/fixtures/workflows/joiner-onfailure.psd1 @@ -12,6 +12,7 @@ @{ Name = 'Containment' Type = 'IdLE.Step.DisableIdentity' + With = @{ IdentityKey = 'test-user' } } ) } diff --git a/tests/fixtures/workflows/resolver-current-precondition.psd1 b/tests/fixtures/workflows/resolver-current-precondition.psd1 index 9464e5a9..b236c061 100644 --- a/tests/fixtures/workflows/resolver-current-precondition.psd1 +++ b/tests/fixtures/workflows/resolver-current-precondition.psd1 @@ -13,7 +13,7 @@ Steps = @( @{ Name = 'CurrentPreconditionStep' - Type = 'IdLE.Step.EmitEvent' + Type = 'IdLE.Step.CurrentTest' With = @{ Provider = 'Identity' } From d1b7d0b8466d1d1dad41f087cba474d0fbdbe0cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:25:07 +0000 Subject: [PATCH 3/6] Fix duplicated key list building in ConvertTo-IdleWorkflowSteps (code review) Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/ConvertTo-IdleWorkflowSteps.ps1 | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 5c102737..51fa75f9 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -138,16 +138,13 @@ function ConvertTo-IdleWorkflowSteps { $optionalKeys = @($schema['OptionalKeys']) } - # Build allowed set from all keys + # Build allowed set from all keys (required and optional combined) $allAllowedKeysList = [System.Collections.Generic.List[string]]::new() - foreach ($k in $requiredKeys) { - if ($null -ne $k -and -not [string]::IsNullOrWhiteSpace([string]$k)) { - $null = $allAllowedKeysList.Add([string]$k) - } - } - foreach ($k in $optionalKeys) { - if ($null -ne $k -and -not [string]::IsNullOrWhiteSpace([string]$k)) { - $null = $allAllowedKeysList.Add([string]$k) + 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( From 48dcf321a023eeb04b3439339bae6b63e39ed636 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:54:48 +0000 Subject: [PATCH 4/6] Use fixture files for WithSchema tests; restore EnsureAttributes and EmitEvent Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../StepMetadataCatalog.psd1 | 6 ++- .../New-IdlePlan.ContextResolvers.Tests.ps1 | 3 +- tests/Core/New-IdlePlan.Tests.ps1 | 18 +++----- .../Resolve-IdleStepMetadataCatalog.Tests.ps1 | 41 ++----------------- .../fixtures/workflows/joiner-normalized.psd1 | 8 ++++ .../resolver-current-precondition.psd1 | 2 +- .../withschema-disable-case-insensitive.psd1 | 11 +++++ .../withschema-disable-missing-key.psd1 | 10 +++++ .../withschema-disable-unknown-key.psd1 | 11 +++++ 9 files changed, 54 insertions(+), 56 deletions(-) create mode 100644 tests/fixtures/workflows/joiner-normalized.psd1 create mode 100644 tests/fixtures/workflows/withschema-disable-case-insensitive.psd1 create mode 100644 tests/fixtures/workflows/withschema-disable-missing-key.psd1 create mode 100644 tests/fixtures/workflows/withschema-disable-unknown-key.psd1 diff --git a/src/IdLE.Steps.Common/StepMetadataCatalog.psd1 b/src/IdLE.Steps.Common/StepMetadataCatalog.psd1 index a711cc73..d83902c4 100644 --- a/src/IdLE.Steps.Common/StepMetadataCatalog.psd1 +++ b/src/IdLE.Steps.Common/StepMetadataCatalog.psd1 @@ -10,12 +10,14 @@ # OptionalKeys - keys that MAY be present in With # @{ - # IdLE.Step.EmitEvent - writes a structured event to the event sink; no provider capabilities required + # 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' = @{ RequiredCapabilities = @() WithSchema = @{ RequiredKeys = @() - OptionalKeys = @('Message') + OptionalKeys = @('Message', 'Provider', 'AuthSessionName', 'AuthSessionOptions') } } diff --git a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 index 1ff18792..f7568be7 100644 --- a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 +++ b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 @@ -1189,8 +1189,7 @@ Describe 'New-IdlePlan - ContextResolvers' { $providers = @{ Identity = $provider - StepRegistry = @{ 'IdLE.Step.CurrentTest' = 'Invoke-IdleContextResolverTestNoopStep' } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.CurrentTest') + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } } $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers diff --git a/tests/Core/New-IdlePlan.Tests.ps1 b/tests/Core/New-IdlePlan.Tests.ps1 index ee80ecac..ffe39f77 100644 --- a/tests/Core/New-IdlePlan.Tests.ps1 +++ b/tests/Core/New-IdlePlan.Tests.ps1 @@ -3,6 +3,7 @@ Set-StrictMode -Version Latest BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule + $script:FixturesPath = Join-Path $PSScriptRoot '..' 'fixtures/workflows' function global:Invoke-IdleTestNoopStep { [CmdletBinding()] @@ -33,16 +34,7 @@ AfterAll { Describe 'New-IdlePlan' { Context 'Plan normalization' { It 'creates a plan with normalized steps' { - $wfPath = New-IdleTestWorkflowFile -FileName 'joiner.psd1' -Content @' -@{ - Name = 'Joiner - Standard' - LifecycleEvent = 'Joiner' - Steps = @( - @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } - @{ Name = 'ProcessUser'; Type = 'IdLE.Step.ProcessUser'; With = @{ Mode = 'Minimal' } } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'joiner-normalized.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' @@ -55,10 +47,10 @@ Describe 'New-IdlePlan' { Dummy = $true Identity = $dummyProvider StepRegistry = @{ - 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' - 'IdLE.Step.ProcessUser' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.EnsureAttributes' = 'Invoke-IdleTestNoopStep' } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity', 'IdLE.Step.ProcessUser') + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') } $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers diff --git a/tests/Core/Resolve-IdleStepMetadataCatalog.Tests.ps1 b/tests/Core/Resolve-IdleStepMetadataCatalog.Tests.ps1 index 90630a89..d36c9593 100644 --- a/tests/Core/Resolve-IdleStepMetadataCatalog.Tests.ps1 +++ b/tests/Core/Resolve-IdleStepMetadataCatalog.Tests.ps1 @@ -252,19 +252,7 @@ Describe 'Resolve-IdleStepMetadataCatalog - step pack WithSchema' { $providers = @{ IdentityProvider = $provider } - $wfPath = New-IdleTestWorkflowFile -FileName 'disable-unknown-key.psd1' -Content @' -@{ - Name = 'Test - Unknown Key' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'DisableStep' - Type = 'IdLE.Step.DisableIdentity' - With = @{ IdentityKey = 'user1'; UnknownParam = 'bad' } - } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'withschema-disable-unknown-key.psd1' try { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null throw 'Expected an exception but none was thrown.' @@ -286,18 +274,7 @@ Describe 'Resolve-IdleStepMetadataCatalog - step pack WithSchema' { $providers = @{ IdentityProvider = $provider } - $wfPath = New-IdleTestWorkflowFile -FileName 'disable-missing-key.psd1' -Content @' -@{ - Name = 'Test - Missing Required Key' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'DisableStep' - Type = 'IdLE.Step.DisableIdentity' - } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'withschema-disable-missing-key.psd1' try { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null throw 'Expected an exception but none was thrown.' @@ -319,19 +296,7 @@ Describe 'Resolve-IdleStepMetadataCatalog - step pack WithSchema' { $providers = @{ IdentityProvider = $provider } - $wfPath = New-IdleTestWorkflowFile -FileName 'disable-case-insensitive.psd1' -Content @' -@{ - Name = 'Test - Case Insensitive Keys' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'DisableStep' - Type = 'IdLE.Step.DisableIdentity' - With = @{ identitykey = 'user1' } - } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'withschema-disable-case-insensitive.psd1' { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Not -Throw } } diff --git a/tests/fixtures/workflows/joiner-normalized.psd1 b/tests/fixtures/workflows/joiner-normalized.psd1 new file mode 100644 index 00000000..4cd78ed7 --- /dev/null +++ b/tests/fixtures/workflows/joiner-normalized.psd1 @@ -0,0 +1,8 @@ +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } + @{ Name = 'EnsureAttributes'; Type = 'IdLE.Step.EnsureAttributes'; With = @{ IdentityKey = 'test-user'; Attributes = @{} } } + ) +} diff --git a/tests/fixtures/workflows/resolver-current-precondition.psd1 b/tests/fixtures/workflows/resolver-current-precondition.psd1 index b236c061..9464e5a9 100644 --- a/tests/fixtures/workflows/resolver-current-precondition.psd1 +++ b/tests/fixtures/workflows/resolver-current-precondition.psd1 @@ -13,7 +13,7 @@ Steps = @( @{ Name = 'CurrentPreconditionStep' - Type = 'IdLE.Step.CurrentTest' + Type = 'IdLE.Step.EmitEvent' With = @{ Provider = 'Identity' } diff --git a/tests/fixtures/workflows/withschema-disable-case-insensitive.psd1 b/tests/fixtures/workflows/withschema-disable-case-insensitive.psd1 new file mode 100644 index 00000000..9221bd04 --- /dev/null +++ b/tests/fixtures/workflows/withschema-disable-case-insensitive.psd1 @@ -0,0 +1,11 @@ +@{ + Name = 'Joiner - WithSchema Case Insensitive Keys' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'DisableStep' + Type = 'IdLE.Step.DisableIdentity' + With = @{ identitykey = 'user1' } + } + ) +} diff --git a/tests/fixtures/workflows/withschema-disable-missing-key.psd1 b/tests/fixtures/workflows/withschema-disable-missing-key.psd1 new file mode 100644 index 00000000..b603ebd5 --- /dev/null +++ b/tests/fixtures/workflows/withschema-disable-missing-key.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'Joiner - WithSchema Missing Required Key' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'DisableStep' + Type = 'IdLE.Step.DisableIdentity' + } + ) +} diff --git a/tests/fixtures/workflows/withschema-disable-unknown-key.psd1 b/tests/fixtures/workflows/withschema-disable-unknown-key.psd1 new file mode 100644 index 00000000..2ae1e67b --- /dev/null +++ b/tests/fixtures/workflows/withschema-disable-unknown-key.psd1 @@ -0,0 +1,11 @@ +@{ + Name = 'Joiner - WithSchema Unknown Key' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'DisableStep' + Type = 'IdLE.Step.DisableIdentity' + With = @{ IdentityKey = 'user1'; UnknownParam = 'bad' } + } + ) +} From a3010e6c0093f41e2f277aeb691af5d64cfa3e4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:30:41 +0000 Subject: [PATCH 5/6] Remove remaining ProcessUser workaround; add WithSchema docs Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/extend/extensibility.md | 4 ++- docs/extend/steps.md | 32 +++++++++++++++++++ tests/Core/Invoke-IdlePlan.Tests.ps1 | 25 ++------------- .../workflows/invoke-two-emitevent-steps.psd1 | 8 +++++ 4 files changed, 46 insertions(+), 23 deletions(-) create mode 100644 tests/fixtures/workflows/invoke-two-emitevent-steps.psd1 diff --git a/docs/extend/extensibility.md b/docs/extend/extensibility.md index 1a29f552..aae7cfb2 100644 --- a/docs/extend/extensibility.md +++ b/docs/extend/extensibility.md @@ -13,11 +13,13 @@ IdLE is designed for change through modules instead of forks. A new step typically involves: -1. A metadata definition (what inputs and outputs are allowed) +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) +See [Steps and Metadata](steps.md#step-metadata-contract) for the required metadata shape. + Steps can emit structured events using the execution context contract: - `Context.EventSink.WriteEvent(Type, Message, StepName, Data)` diff --git a/docs/extend/steps.md b/docs/extend/steps.md index 07c7c7e3..5f06b04b 100644 --- a/docs/extend/steps.md +++ b/docs/extend/steps.md @@ -67,6 +67,38 @@ Typical conceptual metadata includes: - **Side effects** - External systems affected by the step +### 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 + +```powershell +'IdLE.Step.Example' = @{ + RequiredCapabilities = @('Some.Capability') + WithSchema = @{ + RequiredKeys = @('IdentityKey') + OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions') + } +} +``` + +**`WithSchema`** (mandatory): + +- `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). + +**Rules:** + +- Both `RequiredKeys` and `OptionalKeys` must be non-null string arrays (may be empty: `@()`). +- A key must not appear in both sets. +- Metadata must be data-only — ScriptBlocks are rejected. + +Step pack modules expose their catalog via `Get-IdleStepMetadataCatalog`, which loads from a +`StepMetadataCatalog.psd1` data file. Host-supplied step types may supplement (but not override) +the catalog via `Providers.StepMetadata`. + Metadata exists to make steps: - understandable diff --git a/tests/Core/Invoke-IdlePlan.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Tests.ps1 index 72031d16..fa61aad0 100644 --- a/tests/Core/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Tests.ps1 @@ -3,6 +3,7 @@ Set-StrictMode -Version Latest BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule + $script:FixturesPath = Join-Path $PSScriptRoot '..' 'fixtures/workflows' # The engine invokes step handlers by function name (string) inside module scope. # Therefore, test handler functions must be visible to the module (global scope). @@ -152,34 +153,14 @@ AfterAll { Describe 'Invoke-IdlePlan' { Context 'Execution results' { It 'returns an execution result with events in deterministic order' { - $wfPath = New-IdleTestWorkflowFile -FileName 'joiner.psd1' -Content @' -@{ - Name = 'Joiner - Standard' - LifecycleEvent = 'Joiner' - Steps = @( - @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } - @{ Name = 'ProcessUser'; Type = 'IdLE.Step.ProcessUser' } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'invoke-two-emitevent-steps.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - # Create a dummy provider with the required capability for EnsureAttributes - $dummyProvider = [pscustomobject]@{ - PSTypeName = 'IdLE.Provider.TestDummy' - } - $dummyProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Identity.Attribute.Ensure') - } - $providers = @{ - Identity = $dummyProvider StepRegistry = @{ - 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' - 'IdLE.Step.ProcessUser' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.EmitEvent' = 'Invoke-IdleTestNoopStep' } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity', 'IdLE.Step.ProcessUser') } $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers diff --git a/tests/fixtures/workflows/invoke-two-emitevent-steps.psd1 b/tests/fixtures/workflows/invoke-two-emitevent-steps.psd1 new file mode 100644 index 00000000..6885742e --- /dev/null +++ b/tests/fixtures/workflows/invoke-two-emitevent-steps.psd1 @@ -0,0 +1,8 @@ +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent'; With = @{ Message = 'Step 1' } } + @{ Name = 'Step2'; Type = 'IdLE.Step.EmitEvent'; With = @{ Message = 'Step 2' } } + ) +} From 2aaad90117a02b46cd412d3a8d42d69b165b178a Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:50:57 +0100 Subject: [PATCH 6/6] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Private/ConvertTo-IdleWorkflowSteps.ps1 | 10 ++-------- .../Private/Resolve-IdleStepMetadataCatalog.ps1 | 10 +++++++++- .../Public/Get-IdleStepMetadataCatalog.ps1 | 17 +---------------- .../Public/Get-IdleStepMetadataCatalog.ps1 | 17 +---------------- .../Public/Get-IdleStepMetadataCatalog.ps1 | 17 +---------------- 5 files changed, 14 insertions(+), 57 deletions(-) diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 51fa75f9..fd130637 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -156,14 +156,8 @@ function ConvertTo-IdleWorkflowSteps { # Validate required keys are present foreach ($rk in $requiredKeys) { if ([string]::IsNullOrWhiteSpace([string]$rk) -or [string]$rk -eq '*') { continue } - $keyPresent = $false - foreach ($wk in @($with.Keys)) { - if ([string]::Equals([string]$wk, [string]$rk, [System.StringComparison]::OrdinalIgnoreCase)) { - $keyPresent = $true - break - } - } - if (-not $keyPresent) { + + 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), diff --git a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 index 7d188505..d3ff130c 100644 --- a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 @@ -36,13 +36,21 @@ function Resolve-IdleStepMetadataCatalog { [string] $SourceName ) - if ($null -eq $Value -or $Value -isnot [hashtable]) { + 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( diff --git a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 index c6ca2f8f..bd8040d3 100644 --- a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 @@ -30,22 +30,7 @@ function Get-IdleStepMetadataCatalog { $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($key in $rawData.Keys) { - $entry = $rawData[$key] - $ht = @{} - foreach ($metaKey in $entry.Keys) { - $metaValue = $entry[$metaKey] - if ($metaValue -is [System.Collections.IDictionary]) { - $nested = @{} - foreach ($nestedKey in $metaValue.Keys) { - $nested[$nestedKey] = $metaValue[$nestedKey] - } - $ht[$metaKey] = $nested - } - else { - $ht[$metaKey] = $metaValue - } - } - $catalog[$key] = $ht + $catalog[$key] = $rawData[$key] } return $catalog diff --git a/src/IdLE.Steps.DirectorySync/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.DirectorySync/Public/Get-IdleStepMetadataCatalog.ps1 index d7ce5123..2eb8134b 100644 --- a/src/IdLE.Steps.DirectorySync/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.DirectorySync/Public/Get-IdleStepMetadataCatalog.ps1 @@ -30,22 +30,7 @@ function Get-IdleStepMetadataCatalog { $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($key in $rawData.Keys) { - $entry = $rawData[$key] - $ht = @{} - foreach ($metaKey in $entry.Keys) { - $metaValue = $entry[$metaKey] - if ($metaValue -is [System.Collections.IDictionary]) { - $nested = @{} - foreach ($nestedKey in $metaValue.Keys) { - $nested[$nestedKey] = $metaValue[$nestedKey] - } - $ht[$metaKey] = $nested - } - else { - $ht[$metaKey] = $metaValue - } - } - $catalog[$key] = $ht + $catalog[$key] = $rawData[$key] } return $catalog diff --git a/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 index a1bc712c..7a3a5e04 100644 --- a/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 @@ -30,22 +30,7 @@ function Get-IdleStepMetadataCatalog { $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($key in $rawData.Keys) { - $entry = $rawData[$key] - $ht = @{} - foreach ($metaKey in $entry.Keys) { - $metaValue = $entry[$metaKey] - if ($metaValue -is [System.Collections.IDictionary]) { - $nested = @{} - foreach ($nestedKey in $metaValue.Keys) { - $nested[$nestedKey] = $metaValue[$nestedKey] - } - $ht[$metaKey] = $nested - } - else { - $ht[$metaKey] = $metaValue - } - } - $catalog[$key] = $ht + $catalog[$key] = $rawData[$key] } return $catalog