diff --git a/examples/workflows/ad-joiner-complete.psd1 b/examples/workflows/ad-joiner-complete.psd1 index 91bdd138..231fe447 100644 --- a/examples/workflows/ad-joiner-complete.psd1 +++ b/examples/workflows/ad-joiner-complete.psd1 @@ -3,9 +3,9 @@ LifecycleEvent = 'Joiner' Steps = @( @{ - Name = 'Create AD user account' - Type = 'IdLE.Step.CreateIdentity' - With = @{ + Name = 'Create AD user account' + Type = 'IdLE.Step.CreateIdentity' + With = @{ IdentityKey = 'newuser' Attributes = @{ SamAccountName = 'newuser' @@ -21,34 +21,31 @@ # If omitted, defaults to 'Identity'. Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Create') }, @{ - Name = 'Set Department' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ + Name = 'Set Department' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ IdentityKey = 'newuser@contoso.local' Name = 'Department' Value = 'IT' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Attribute.Ensure') }, @{ - Name = 'Set Title' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ + Name = 'Set Title' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ IdentityKey = 'newuser@contoso.local' Name = 'Title' Value = 'Software Engineer' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Attribute.Ensure') }, @{ - Name = 'Grant base access group' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ + Name = 'Grant base access group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'newuser@contoso.local' Entitlement = @{ Kind = 'Group' @@ -58,12 +55,11 @@ State = 'Present' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') }, @{ - Name = 'Grant IT department group' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ + Name = 'Grant IT department group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'newuser@contoso.local' Entitlement = @{ Kind = 'Group' @@ -73,17 +69,15 @@ State = 'Present' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') }, @{ - Name = 'Move to active users OU' - Type = 'IdLE.Step.MoveIdentity' - With = @{ + Name = 'Move to active users OU' + Type = 'IdLE.Step.MoveIdentity' + With = @{ IdentityKey = 'newuser@contoso.local' TargetContainer = 'OU=Active,OU=Users,DC=contoso,DC=local' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Move') } ) } diff --git a/examples/workflows/ad-leaver-offboarding.psd1 b/examples/workflows/ad-leaver-offboarding.psd1 index 7c0969b7..4496ee04 100644 --- a/examples/workflows/ad-leaver-offboarding.psd1 +++ b/examples/workflows/ad-leaver-offboarding.psd1 @@ -3,46 +3,42 @@ LifecycleEvent = 'Leaver' Steps = @( @{ - Name = 'Disable user account' - Type = 'IdLE.Step.DisableIdentity' - With = @{ + Name = 'Disable user account' + Type = 'IdLE.Step.DisableIdentity' + With = @{ IdentityKey = 'leavinguser@contoso.local' # Provider alias references the provider hashtable key set by the host. # The alias name is flexible and chosen when injecting providers. Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Disable') }, @{ - Name = 'Update Description with termination date' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ + Name = 'Update Description with termination date' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ IdentityKey = 'leavinguser@contoso.local' Name = 'Description' Value = 'Terminated 2026-01-18' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Attribute.Ensure') }, @{ - Name = 'Move to Leavers OU' - Type = 'IdLE.Step.MoveIdentity' - With = @{ + Name = 'Move to Leavers OU' + Type = 'IdLE.Step.MoveIdentity' + With = @{ IdentityKey = 'leavinguser@contoso.local' TargetContainer = 'OU=Leavers,OU=Disabled,DC=contoso,DC=local' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Move') }, @{ - Name = 'Delete user account (opt-in required)' - Type = 'IdLE.Step.DeleteIdentity' - With = @{ + Name = 'Delete user account (opt-in required)' + Type = 'IdLE.Step.DeleteIdentity' + With = @{ IdentityKey = 'leavinguser@contoso.local' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Delete') - Condition = @{ + Condition = @{ Exists = @{ Path = 'Input.AllowDelete' } diff --git a/examples/workflows/ad-mover-department-change.psd1 b/examples/workflows/ad-mover-department-change.psd1 index c6843099..63b5e318 100644 --- a/examples/workflows/ad-mover-department-change.psd1 +++ b/examples/workflows/ad-mover-department-change.psd1 @@ -3,9 +3,9 @@ LifecycleEvent = 'Mover' Steps = @( @{ - Name = 'Update Department' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ + Name = 'Update Department' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ IdentityKey = 'existinguser@contoso.local' Name = 'Department' Value = 'Sales' @@ -13,23 +13,21 @@ # Examples: 'Identity', 'SourceAD', 'TargetAD', 'SystemX', etc. Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Attribute.Ensure') }, @{ - Name = 'Update Title' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ + Name = 'Update Title' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ IdentityKey = 'existinguser@contoso.local' Name = 'Title' Value = 'Sales Manager' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Attribute.Ensure') }, @{ - Name = 'Revoke old IT department group' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ + Name = 'Revoke old IT department group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'existinguser@contoso.local' Entitlement = @{ Kind = 'Group' @@ -39,12 +37,11 @@ State = 'Absent' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Revoke') }, @{ - Name = 'Grant Sales department group' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ + Name = 'Grant Sales department group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'existinguser@contoso.local' Entitlement = @{ Kind = 'Group' @@ -54,17 +51,15 @@ State = 'Present' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') }, @{ - Name = 'Move to Sales OU' - Type = 'IdLE.Step.MoveIdentity' - With = @{ + Name = 'Move to Sales OU' + Type = 'IdLE.Step.MoveIdentity' + With = @{ IdentityKey = 'existinguser@contoso.local' TargetContainer = 'OU=Sales,OU=Users,DC=contoso,DC=local' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Identity.Move') } ) } diff --git a/examples/workflows/joiner-ensureentitlement.psd1 b/examples/workflows/joiner-ensureentitlement.psd1 index 14347ad8..c1c7f8e2 100644 --- a/examples/workflows/joiner-ensureentitlement.psd1 +++ b/examples/workflows/joiner-ensureentitlement.psd1 @@ -3,16 +3,14 @@ LifecycleEvent = 'Joiner' Steps = @( @{ - Name = 'Ensure Department' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ IdentityKey = 'user1'; Name = 'Department'; Value = 'IT'; Provider = 'Identity' } - RequiresCapabilities = 'IdLE.Identity.Attribute.Ensure' + Name = 'Ensure Department' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ IdentityKey = 'user1'; Name = 'Department'; Value = 'IT'; Provider = 'Identity' } }, @{ - Name = 'Assign demo group' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ IdentityKey = 'user1'; Entitlement = @{ Kind = 'Group'; Id = 'demo-group'; DisplayName = 'Demo Group' }; State = 'Present'; Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') + Name = 'Assign demo group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'user1'; Entitlement = @{ Kind = 'Group'; Id = 'demo-group'; DisplayName = 'Demo Group' }; State = 'Present'; Provider = 'Identity' } } ) } diff --git a/examples/workflows/joiner-with-onfailure.psd1 b/examples/workflows/joiner-with-onfailure.psd1 index 660602b9..4cf8c4a0 100644 --- a/examples/workflows/joiner-with-onfailure.psd1 +++ b/examples/workflows/joiner-with-onfailure.psd1 @@ -10,20 +10,19 @@ With = @{ Message = 'Starting Joiner workflow with OnFailure handling' } } @{ - Name = 'Ensure Department' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ + Name = 'Ensure Department' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ IdentityKey = 'user1' Name = 'Department' Value = 'IT' Provider = 'Identity' } - RequiresCapabilities = 'IdLE.Identity.Attribute.Ensure' } @{ - Name = 'Assign demo group' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ + Name = 'Assign demo group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'user1' Entitlement = @{ Kind = 'Group' @@ -33,7 +32,6 @@ State = 'Present' Provider = 'Identity' } - RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') } ) diff --git a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 new file mode 100644 index 00000000..37b20ffc --- /dev/null +++ b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 @@ -0,0 +1,267 @@ +function Resolve-IdleStepMetadataCatalog { + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + # Metadata catalog maps Step.Type -> metadata hashtable. + # + # Trust boundary: + # - The metadata catalog is a host-provided extension point, similar to StepRegistry. + # - It is not loaded from workflow configuration. + # - Workflows are data-only and must not contain executable code. + # + # Security / secure defaults: + # - Only data-only metadata (hashtables with scalar/array values) are supported. + # - ScriptBlock values are intentionally rejected to avoid arbitrary code execution. + + $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + + # Helper: Resolve a function from a module without requiring global command exports. + function Resolve-IdleModuleFunction { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $FunctionName, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ModuleName + ) + + # 1) Module-scoped discovery (prefer the specified module to avoid name shadowing) + $module = Get-Module -Name $ModuleName -All | Select-Object -First 1 + if ($null -ne $module -and + $null -ne $module.ExportedCommands -and + $module.ExportedCommands.ContainsKey($FunctionName)) { + return $module.ExportedCommands[$FunctionName] + } + + # 2) Global discovery (fallback; supports hosts that import modules globally) + $cmd = Get-Command -Name $FunctionName -ErrorAction SilentlyContinue + if ($null -ne $cmd) { + return $cmd + } + + return $null + } + + # Helper: Validate a single capability identifier format. + function Test-IdleCapabilityIdentifier { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Capability, + + [Parameter(Mandatory)] + [string] $StepType, + + [Parameter(Mandatory)] + [string] $SourceName + ) + + $cap = $Capability.Trim() + if ([string]::IsNullOrWhiteSpace($cap)) { + return + } + + if ($cap -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$StepType' declares invalid capability '$cap'. Expected dot-separated segments like 'IdLE.Identity.Read'.", + 'Providers' + ) + } + } + + # Helper: Validate RequiredCapabilities value. + function Test-IdleRequiredCapabilities { + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [string] $StepType, + + [Parameter(Mandatory)] + [string] $SourceName + ) + + if ($null -eq $Value) { + return + } + + if ($Value -is [string]) { + Test-IdleCapabilityIdentifier -Capability $Value -StepType $StepType -SourceName $SourceName + return + } + + # Explicitly reject dictionary/hashtable values; they are not valid capability lists. + if ($Value -is [System.Collections.IDictionary] -or $Value -is [hashtable]) { + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$StepType' has invalid RequiredCapabilities value. Expected string or string array.", + 'Providers' + ) + } + + if ($Value -is [System.Collections.IEnumerable]) { + foreach ($c in $Value) { + if ($null -eq $c) { + continue + } + + if ($c -isnot [string]) { + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$StepType' has invalid RequiredCapabilities value. Expected string or string array.", + 'Providers' + ) + } + + Test-IdleCapabilityIdentifier -Capability $c -StepType $StepType -SourceName $SourceName + } + return + } + + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$StepType' has invalid RequiredCapabilities value. Expected string or string array.", + 'Providers' + ) + } + + # Helper: Validate and merge metadata from a hashtable source. + function Merge-IdleStepMetadata { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable] $Target, + + [Parameter(Mandatory)] + [hashtable] $Source, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $SourceName + ) + + foreach ($key in $Source.Keys) { + if ($null -eq $key -or [string]::IsNullOrWhiteSpace([string]$key)) { + throw [System.ArgumentException]::new("$SourceName contains an empty step type key.", 'Providers') + } + + $value = $Source[$key] + + if ($value -isnot [hashtable]) { + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$key' must be a hashtable (metadata object).", + 'Providers' + ) + } + + # Validate metadata shape (data-only, no ScriptBlocks). + # Recursively check for ScriptBlocks in the entire metadata tree. + function Test-IdleMetadataForScriptBlocks { + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [string] $Path + ) + + if ($null -eq $Value) { + return + } + + if ($Value -is [scriptblock]) { + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$key' contains a ScriptBlock at '$Path'. ScriptBlocks are not allowed (data-only boundary).", + 'Providers' + ) + } + + if ($Value -is [hashtable] -or $Value -is [System.Collections.IDictionary]) { + foreach ($k in $Value.Keys) { + Test-IdleMetadataForScriptBlocks -Value $Value[$k] -Path "$Path.$k" + } + } + elseif ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + $index = 0 + foreach ($item in $Value) { + Test-IdleMetadataForScriptBlocks -Value $item -Path "$Path[$index]" + $index++ + } + } + } + + foreach ($metaKey in $value.Keys) { + $metaValue = $value[$metaKey] + + # Recursively validate no ScriptBlocks anywhere in metadata + Test-IdleMetadataForScriptBlocks -Value $metaValue -Path $metaKey + + if ($metaKey -eq 'RequiredCapabilities') { + Test-IdleRequiredCapabilities -Value $metaValue -StepType $key -SourceName $SourceName + } + } + + # Merge (host metadata overrides built-in). + $Target[[string]$key] = $value + } + } + + # Helper: Get host-provided StepMetadata if available. + function Get-IdleHostStepMetadata { + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + if ($null -eq $Providers) { + return $null + } + + if ($Providers -is [hashtable] -and $Providers.ContainsKey('StepMetadata')) { + return $Providers['StepMetadata'] + } + + if ($Providers.PSObject.Properties.Name -contains 'StepMetadata') { + return $Providers.StepMetadata + } + + return $null + } + + # 1) Register built-in step metadata if available. + $builtInFunction = Resolve-IdleModuleFunction -FunctionName 'Get-IdleStepMetadataCatalog' -ModuleName 'IdLE.Steps.Common' + + if ($null -ne $builtInFunction) { + $functionModule = $builtInFunction.ModuleName ?? $builtInFunction.Source + + if ($functionModule -eq 'IdLE.Steps.Common') { + $builtInMetadata = & $builtInFunction + if ($null -ne $builtInMetadata -and $builtInMetadata -is [hashtable]) { + Merge-IdleStepMetadata -Target $catalog -Source $builtInMetadata -SourceName 'Built-in StepMetadata' + } + } + } + + # 2) Merge host-provided StepMetadata (optional) - this overrides built-in. + $hostMetadata = Get-IdleHostStepMetadata -Providers $Providers + + if ($null -ne $hostMetadata) { + if ($hostMetadata -isnot [hashtable]) { + throw [System.ArgumentException]::new('Providers.StepMetadata must be a hashtable that maps Step.Type to a metadata object (hashtable).', 'Providers') + } + + Merge-IdleStepMetadata -Target $catalog -Source $hostMetadata -SourceName 'Providers.StepMetadata' + } + + return $catalog +} diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index a245bf93..2365cde5 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -8,6 +8,29 @@ function Test-IdleWorkflowSchema { # Strict validation: collect all schema violations and return them as a list. $errors = [System.Collections.Generic.List[string]]::new() + # Helper: Validate step keys and detect disallowed keys. + function Test-IdleWorkflowStepKeys { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable] $Step, + + [Parameter(Mandatory)] + [string] $StepPath, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[string]] $ErrorList + ) + + $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description') + foreach ($k in $Step.Keys) { + if ($allowedStepKeys -notcontains $k) { + $ErrorList.Add("Unknown key '$k' in $StepPath. Allowed keys: $($allowedStepKeys -join ', ').") + } + } + } + $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'OnFailureSteps', 'Description') foreach ($key in $Workflow.Keys) { if ($allowedRootKeys -notcontains $key) { @@ -42,12 +65,7 @@ function Test-IdleWorkflowSchema { continue } - $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RequiresCapabilities') - foreach ($k in $step.Keys) { - if ($allowedStepKeys -notcontains $k) { - $errors.Add("Unknown key '$k' in $stepPath. Allowed keys: $($allowedStepKeys -join ', ').") - } - } + Test-IdleWorkflowStepKeys -Step $step -StepPath $stepPath -ErrorList $errors if (-not $step.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$step.Name)) { $errors.Add("Missing or empty required key '$stepPath.Name'.") @@ -95,12 +113,7 @@ function Test-IdleWorkflowSchema { continue } - $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RequiresCapabilities') - foreach ($k in $step.Keys) { - if ($allowedStepKeys -notcontains $k) { - $errors.Add("Unknown key '$k' in $stepPath. Allowed keys: $($allowedStepKeys -join ', ').") - } - } + Test-IdleWorkflowStepKeys -Step $step -StepPath $stepPath -ErrorList $errors if (-not $step.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$step.Name)) { $errors.Add("Missing or empty required key '$stepPath.Name'.") diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 1f8f7a7b..67f72b86 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -425,7 +425,11 @@ function New-IdlePlanObject { [Parameter(Mandatory)] [ValidateNotNull()] - [object] $PlanningContext + [object] $PlanningContext, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $StepMetadataCatalog ) if ($null -eq $WorkflowSteps -or @($WorkflowSteps).Count -eq 0) { @@ -487,9 +491,21 @@ function New-IdlePlanObject { } } + # Derive RequiresCapabilities from StepMetadataCatalog instead of workflow. $requiresCaps = @() - if (Test-IdleWorkflowStepKey -Step $s -Key 'RequiresCapabilities') { - $requiresCaps = ConvertTo-IdleRequiredCapabilities -Value (Get-IdleWorkflowStepValue -Step $s -Key 'RequiresCapabilities') -StepName $stepName + if ($StepMetadataCatalog.ContainsKey($stepType)) { + $metadata = $StepMetadataCatalog[$stepType] + if ($null -ne $metadata -and $metadata -is [hashtable] -and $metadata.ContainsKey('RequiredCapabilities')) { + $requiresCaps = ConvertTo-IdleRequiredCapabilities -Value $metadata['RequiredCapabilities'] -StepName $stepName + } + } + else { + # Workflow references a Step.Type for which no StepMetadata entry is available - fail fast. + throw [System.ArgumentException]::new( + ("Workflow step '{0}' references Step.Type '{1}' which has no StepMetadata entry. " + + "Host must provide Providers.StepMetadata['{1}'] = @{{ RequiredCapabilities = ... }}." -f $stepName, $stepType), + 'Providers' + ) } $description = if (Test-IdleWorkflowStepKey -Step $s -Key 'Description') { @@ -572,13 +588,16 @@ function New-IdlePlanObject { Workflow = $workflow } + # Load StepMetadataCatalog (trusted extension point). + $stepMetadataCatalog = Resolve-IdleStepMetadataCatalog -Providers $Providers + $workflowOnFailureSteps = Get-IdleOptionalPropertyValue -Object $workflow -Name 'OnFailureSteps' # Normalize primary and OnFailure steps. # IMPORTANT: # ConvertTo-IdleWorkflowSteps may return an empty array that would otherwise collapse to $null on assignment. - $plan.Steps = @(ConvertTo-IdleWorkflowSteps -WorkflowSteps $workflow.Steps -PlanningContext $planningContext) - $plan.OnFailureSteps = @(ConvertTo-IdleWorkflowSteps -WorkflowSteps $workflowOnFailureSteps -PlanningContext $planningContext) + $plan.Steps = @(ConvertTo-IdleWorkflowSteps -WorkflowSteps $workflow.Steps -PlanningContext $planningContext -StepMetadataCatalog $stepMetadataCatalog) + $plan.OnFailureSteps = @(ConvertTo-IdleWorkflowSteps -WorkflowSteps $workflowOnFailureSteps -PlanningContext $planningContext -StepMetadataCatalog $stepMetadataCatalog) # Fail-fast capability validation (includes OnFailureSteps). $allStepsForCapabilities = @() diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 index 0001cbee..f527d2df 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 @@ -8,6 +8,7 @@ PowerShellVersion = '7.0' FunctionsToExport = @( + 'Get-IdleStepMetadataCatalog', 'Invoke-IdleStepEmitEvent', 'Invoke-IdleStepEnsureAttribute', 'Invoke-IdleStepEnsureEntitlement', diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 index 365d5dd9..5211dfbd 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 @@ -24,6 +24,7 @@ if (Test-Path -Path $PublicPath) { } Export-ModuleMember -Function @( + 'Get-IdleStepMetadataCatalog', 'Invoke-IdleStepEmitEvent', 'Invoke-IdleStepEnsureAttribute', 'Invoke-IdleStepEnsureEntitlement', diff --git a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 new file mode 100644 index 00000000..2f266676 --- /dev/null +++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 @@ -0,0 +1,67 @@ +function Get-IdleStepMetadataCatalog { + <# + .SYNOPSIS + Returns metadata for 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). + + The metadata is used during plan building to derive required provider capabilities + for each step, removing the need to declare RequiresCapabilities in workflow definitions. + + .OUTPUTS + Hashtable (case-insensitive) mapping Step.Type (string) to metadata (hashtable). + + .EXAMPLE + $metadata = Get-IdleStepMetadataCatalog + $metadata['IdLE.Step.DisableIdentity'].RequiredCapabilities + # Returns: @('IdLE.Identity.Disable', 'IdLE.Identity.Read') + #> + [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') + } + + # IdLE.Step.MoveIdentity - requires identity move capability + $catalog['IdLE.Step.MoveIdentity'] = @{ + RequiredCapabilities = @('IdLE.Identity.Move') + } + + # IdLE.Step.EnsureAttribute - requires identity attribute ensure capability + $catalog['IdLE.Step.EnsureAttribute'] = @{ + 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') + } + + return $catalog +} diff --git a/tests/Export-IdlePlan.Tests.ps1 b/tests/Export-IdlePlan.Tests.ps1 index 13f59240..d8bc5c52 100644 --- a/tests/Export-IdlePlan.Tests.ps1 +++ b/tests/Export-IdlePlan.Tests.ps1 @@ -38,7 +38,15 @@ Describe 'Export-IdlePlan' { -IdentityKeys ([ordered]@{ userId = 'jdoe' }) ` -DesiredState ([ordered]@{ department = 'IT' }) - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } + $providers = @{ + Dummy = $true + StepRegistry = @{ + 'EnsureMailbox' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('EnsureMailbox') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $expectedPath = Join-Path $PSScriptRoot 'fixtures/plan-export/expected/plan-export.json' $expectedJson = Get-Content -Path $expectedPath -Raw -Encoding utf8 diff --git a/tests/Invoke-IdlePlan.Retry.Tests.ps1 b/tests/Invoke-IdlePlan.Retry.Tests.ps1 index 46a92f2f..229e70e5 100644 --- a/tests/Invoke-IdlePlan.Retry.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Retry.Tests.ps1 @@ -4,6 +4,9 @@ BeforeDiscovery { } BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule + # Create a dedicated, ephemeral test module that exports the step handlers. # This avoids global scope pollution while ensuring the engine can resolve # handler names deterministically via module-qualified command names. @@ -108,13 +111,15 @@ Describe 'Invoke-IdlePlan - safe retries for transient failures (fail-fast)' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.Transient' = "$script:RetryTestModuleName\Invoke-IdleRetryTestTransientStep" } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Transient') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $result = Invoke-IdlePlan -Plan $plan -Providers $providers @@ -142,13 +147,15 @@ Describe 'Invoke-IdlePlan - safe retries for transient failures (fail-fast)' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.NonTransient' = "$script:RetryTestModuleName\Invoke-IdleRetryTestNonTransientStep" } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.NonTransient') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $result = Invoke-IdlePlan -Plan $plan -Providers $providers diff --git a/tests/Invoke-IdlePlan.Tests.ps1 b/tests/Invoke-IdlePlan.Tests.ps1 index 2402efc0..dab5cf41 100644 --- a/tests/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Tests.ps1 @@ -162,14 +162,16 @@ Describe 'Invoke-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' 'IdLE.Step.EnsureAttributes' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity', 'IdLE.Step.EnsureAttributes') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $result = Invoke-IdlePlan -Plan $plan -Providers $providers @@ -211,13 +213,15 @@ Describe 'Invoke-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $sinkEvents = [System.Collections.Generic.List[object]]::new() $sinkObject = [pscustomobject]@{} @@ -247,13 +251,15 @@ Describe 'Invoke-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $sink = { param($e) } { Invoke-IdlePlan -Plan $plan -Providers $providers -EventSink $sink } | Should -Throw @@ -272,13 +278,15 @@ Describe 'Invoke-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.ResolveIdentity' = { param($Context, $Step) } } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw } @@ -302,6 +310,7 @@ Describe 'Invoke-IdlePlan' { StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleTestEmitStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.EmitEvent') } $result = Invoke-IdlePlan -Plan $plan -Providers $providers @@ -328,7 +337,6 @@ Describe 'Invoke-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ @@ -336,7 +344,10 @@ Describe 'Invoke-IdlePlan' { 'IdLE.Step.NeverRuns' = 'Invoke-IdleTestNoopStep' 'IdLE.Step.OnFailure1' = 'Invoke-IdleTestEmitStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailPrimary', 'IdLE.Step.NeverRuns', 'IdLE.Step.OnFailure1') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $result = Invoke-IdlePlan -Plan $plan -Providers $providers @@ -376,7 +387,6 @@ Describe 'Invoke-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ @@ -384,7 +394,10 @@ Describe 'Invoke-IdlePlan' { 'IdLE.Step.OnFailureFail' = 'Invoke-IdleTestFailStep' 'IdLE.Step.OnFailureOk' = 'Invoke-IdleTestEmitStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailPrimary', 'IdLE.Step.OnFailureFail', 'IdLE.Step.OnFailureOk') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $result = Invoke-IdlePlan -Plan $plan -Providers $providers @@ -415,14 +428,16 @@ Describe 'Invoke-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.Ok' = 'Invoke-IdleTestNoopStep' 'IdLE.Step.OnFailure1' = 'Invoke-IdleTestEmitStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Ok', 'IdLE.Step.OnFailure1') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $result = Invoke-IdlePlan -Plan $plan -Providers $providers @@ -525,6 +540,7 @@ Describe 'Invoke-IdlePlan' { StepRegistry = @{ 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession') } { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*AuthSessionBroker*' @@ -557,6 +573,7 @@ Describe 'Invoke-IdlePlan' { 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' 'IdLE.Step.Noop' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession', 'IdLE.Step.Noop') } # Should not throw because the AcquireAuthSession step is NotApplicable @@ -604,6 +621,7 @@ Describe 'Invoke-IdlePlan' { StepRegistry = @{ 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession') AuthSessionBroker = $broker } @@ -654,6 +672,7 @@ Describe 'Invoke-IdlePlan' { StepRegistry = @{ 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession') AuthSessionBroker = $broker } @@ -703,6 +722,7 @@ Describe 'Invoke-IdlePlan' { StepRegistry = @{ 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.AcquireAuthSession') AuthSessionBroker = $broker } @@ -728,13 +748,15 @@ Describe 'Invoke-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.Legacy' = 'Invoke-IdleTestLegacyStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Legacy') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $result = Invoke-IdlePlan -Plan $plan -Providers $providers diff --git a/tests/ModuleSurface.Tests.ps1 b/tests/ModuleSurface.Tests.ps1 index 8c05b5be..0e0f7ee3 100644 --- a/tests/ModuleSurface.Tests.ps1 +++ b/tests/ModuleSurface.Tests.ps1 @@ -82,14 +82,16 @@ Describe 'Module manifests and public surface' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req $providers = @{ StepRegistry = @{ 'IdLE.Step.Primary' = 'Invoke-IdleSurfaceTestNoopStep' 'IdLE.Step.Containment' = 'Invoke-IdleSurfaceTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Primary', 'IdLE.Step.Containment') } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $result = Invoke-IdlePlan -Plan $plan -Providers $providers diff --git a/tests/New-IdlePlan.Capabilities.Tests.ps1 b/tests/New-IdlePlan.Capabilities.Tests.ps1 index da48845b..cfea0213 100644 --- a/tests/New-IdlePlan.Capabilities.Tests.ps1 +++ b/tests/New-IdlePlan.Capabilities.Tests.ps1 @@ -1,55 +1,35 @@ BeforeAll { . (Join-Path $PSScriptRoot '_testHelpers.ps1') Import-IdleTestModule + $fixturesPath = Join-Path $PSScriptRoot 'fixtures/workflows' } Describe 'New-IdlePlan - required provider capabilities' { - It 'fails fast when a step requires capabilities that no provider advertises' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-capabilities.psd1' - - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Capability Validation' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Disable identity' - Type = 'IdLE.Step.DisableIdentity' - RequiresCapabilities = @('IdLE.Identity.Disable') - } - ) -} -'@ + It 'fails fast when a step type has no metadata entry' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-no-metadata.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + # Provide a custom StepRegistry for the unknown step type + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Unknown' = 'Invoke-CustomStepUnknown' + } + } + try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null throw 'Expected an exception but none was thrown.' } catch { - $_.Exception.Message | Should -Match 'MissingCapabilities: IdLE\.Identity\.Disable' - $_.Exception.Message | Should -Match 'AffectedSteps: Disable identity' + $_.Exception.Message | Should -Match 'no StepMetadata entry' + $_.Exception.Message | Should -Match 'Custom.Step.Unknown' } } - It 'allows planning when a provider advertises the required capabilities' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-capabilities-ok.psd1' - - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Capability Validation OK' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Disable identity' - Type = 'IdLE.Step.DisableIdentity' - RequiresCapabilities = @('IdLE.Identity.Disable') - } - ) -} -'@ + It 'derives capabilities from built-in step metadata' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-builtin.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' @@ -66,31 +46,11 @@ Describe 'New-IdlePlan - required provider capabilities' { $plan | Should -Not -BeNullOrEmpty $plan.Steps.Count | Should -Be 1 - $plan.Steps[0].RequiresCapabilities | Should -Be @('IdLE.Identity.Disable') + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' } - It 'fails fast when an OnFailure step requires capabilities that no provider advertises' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-onfailure-capabilities.psd1' - - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - OnFailure Capability Validation' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Primary step' - Type = 'IdLE.Step.Primary' - } - ) - OnFailureSteps = @( - @{ - Name = 'Containment' - Type = 'IdLE.Step.Containment' - RequiresCapabilities = @('IdLE.Identity.Disable') - } - ) -} -'@ + It 'fails fast when required capabilities are missing' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-missing-caps.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' @@ -99,33 +59,40 @@ Describe 'New-IdlePlan - required provider capabilities' { throw 'Expected an exception but none was thrown.' } catch { - $_.Exception.Message | Should -Match 'MissingCapabilities: IdLE\.Identity\.Disable' - $_.Exception.Message | Should -Match 'AffectedSteps: Containment' + $_.Exception.Message | Should -Match 'MissingCapabilities' + $_.Exception.Message | Should -Match 'IdLE\.Identity\.Disable' + $_.Exception.Message | Should -Match 'AffectedSteps: Disable identity' } } - It 'includes OnFailureSteps capability requirements in successful planning' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-onfailure-capabilities-ok.psd1' - - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - OnFailure Capability Validation OK' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Primary step' - Type = 'IdLE.Step.Primary' - } - ) - OnFailureSteps = @( - @{ - Name = 'Containment' - Type = 'IdLE.Step.Containment' - RequiresCapabilities = @('IdLE.Identity.Disable') + It 'allows host metadata to override built-in metadata' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-override.psd1' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('Custom.Capability.Override') + } -Force + + $providers = @{ + IdentityProvider = $provider + StepMetadata = @{ + 'IdLE.Step.DisableIdentity' = @{ + RequiredCapabilities = @('Custom.Capability.Override') + } + } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.Steps.Count | Should -Be 1 + $plan.Steps[0].RequiresCapabilities | Should -Be @('Custom.Capability.Override') } - ) -} -'@ + + It 'validates OnFailureSteps capabilities from metadata' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-onfailure.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' @@ -142,26 +109,11 @@ Describe 'New-IdlePlan - required provider capabilities' { $plan | Should -Not -BeNullOrEmpty $plan.OnFailureSteps.Count | Should -Be 1 - $plan.OnFailureSteps[0].RequiresCapabilities | Should -Be @('IdLE.Identity.Disable') + $plan.OnFailureSteps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' } - It 'validates entitlement capabilities for EnsureEntitlement steps' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-entitlements.psd1' - - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Entitlement Capability Validation' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'Ensure group membership' - Type = 'IdLE.Step.EnsureEntitlement' - With = @{ IdentityKey = 'user1'; Entitlement = @{ Kind = 'Group'; Id = 'demo-group' }; State = 'Present' } - RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') - } - ) -} -'@ + It 'validates entitlement capabilities from metadata' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-entitlements.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' @@ -170,12 +122,13 @@ Describe 'New-IdlePlan - required provider capabilities' { throw 'Expected an exception but none was thrown.' } catch { - $_.Exception.Message | Should -Match 'MissingCapabilities: IdLE\.Entitlement\.Grant, IdLE\.Entitlement\.List' + $_.Exception.Message | Should -Match 'MissingCapabilities' + $_.Exception.Message | Should -Match 'IdLE\.Entitlement' } $provider = [pscustomobject]@{ Name = 'EntProvider' } $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') + return @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') } -Force $providers = @{ Entitlement = $provider } @@ -184,6 +137,81 @@ Describe 'New-IdlePlan - required provider capabilities' { $plan | Should -Not -BeNullOrEmpty $plan.Steps.Count | Should -Be 1 - $plan.Steps[0].RequiresCapabilities | Should -Be @('IdLE.Entitlement.Grant', 'IdLE.Entitlement.List') + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.List' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.Grant' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.Revoke' + } + + It 'rejects metadata with ScriptBlock values' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-scriptblock.psd1' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Test' = 'Invoke-CustomStep' + } + StepMetadata = @{ + 'Custom.Step.Test' = @{ + RequiredCapabilities = { 'Dynamic.Capability' } + } + } + } + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'ScriptBlock' + } + } + + It 'rejects invalid metadata shapes' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-invalid.psd1' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Test' = 'Invoke-CustomStep' + } + StepMetadata = @{ + 'Custom.Step.Test' = 'not-a-hashtable' + } + } + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'must be a hashtable' + } + } + + It 'rejects invalid capability identifiers' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-invalid-cap.psd1' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Test' = 'Invoke-CustomStep' + } + StepMetadata = @{ + 'Custom.Step.Test' = @{ + RequiredCapabilities = @('Invalid Capability With Spaces') + } + } + } + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'invalid capability' + } } } diff --git a/tests/New-IdlePlan.Tests.ps1 b/tests/New-IdlePlan.Tests.ps1 index ddcea388..66c27ef2 100644 --- a/tests/New-IdlePlan.Tests.ps1 +++ b/tests/New-IdlePlan.Tests.ps1 @@ -19,7 +19,15 @@ Describe 'New-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } + $providers = @{ + Dummy = $true + StepRegistry = @{ + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.EnsureAttributes' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity', 'IdLE.Step.EnsureAttributes') + } + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $plan | Should -Not -BeNullOrEmpty $plan.PSTypeNames | Should -Contain 'IdLE.Plan' @@ -69,7 +77,16 @@ Describe 'New-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } + $providers = @{ + Dummy = $true + StepRegistry = @{ + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.Containment' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.NeverApplicable' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity', 'IdLE.Step.Containment', 'IdLE.Step.NeverApplicable') + } + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $plan | Should -Not -BeNullOrEmpty $plan.PSObject.Properties.Name | Should -Contain 'OnFailureSteps' diff --git a/tests/_testHelpers.ps1 b/tests/_testHelpers.ps1 index cf9069d4..df12357d 100644 --- a/tests/_testHelpers.ps1 +++ b/tests/_testHelpers.ps1 @@ -44,3 +44,52 @@ function Get-ModuleManifestPaths { Select-Object -ExpandProperty FullName } +function New-IdleTestStepMetadata { + <# + .SYNOPSIS + Creates test step metadata for custom step types used in tests. + + .DESCRIPTION + Helper function to create StepMetadata entries for test-specific step types. + By default, creates metadata with no required capabilities. + + .PARAMETER StepTypes + Array of step type names to create metadata for. + + .PARAMETER RequiredCapabilities + Hashtable mapping step types to their required capabilities. + + .EXAMPLE + $metadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity', 'IdLE.Step.Primary') + + .EXAMPLE + $metadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Custom') -RequiredCapabilities @{ + 'IdLE.Step.Custom' = @('Custom.Capability') + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string[]] $StepTypes, + + [Parameter()] + [hashtable] $RequiredCapabilities = @{} + ) + + $metadata = @{} + foreach ($stepType in $StepTypes) { + $caps = if ($RequiredCapabilities.ContainsKey($stepType)) { + $RequiredCapabilities[$stepType] + } + else { + @() + } + + $metadata[$stepType] = @{ + RequiredCapabilities = $caps + } + } + + return $metadata +} + diff --git a/tests/fixtures/workflows/joiner-builtin.psd1 b/tests/fixtures/workflows/joiner-builtin.psd1 new file mode 100644 index 00000000..9bd614dd --- /dev/null +++ b/tests/fixtures/workflows/joiner-builtin.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'Joiner - Built-in Metadata' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Disable identity' + Type = 'IdLE.Step.DisableIdentity' + } + ) +} diff --git a/tests/fixtures/workflows/joiner-entitlements.psd1 b/tests/fixtures/workflows/joiner-entitlements.psd1 new file mode 100644 index 00000000..226f0457 --- /dev/null +++ b/tests/fixtures/workflows/joiner-entitlements.psd1 @@ -0,0 +1,11 @@ +@{ + Name = 'Joiner - Entitlement Metadata' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Ensure group membership' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ IdentityKey = 'user1'; Entitlement = @{ Kind = 'Group'; Id = 'demo-group' }; State = 'Present' } + } + ) +} diff --git a/tests/fixtures/workflows/joiner-invalid-cap.psd1 b/tests/fixtures/workflows/joiner-invalid-cap.psd1 new file mode 100644 index 00000000..cc353561 --- /dev/null +++ b/tests/fixtures/workflows/joiner-invalid-cap.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'Joiner - Invalid Capability' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Test step' + Type = 'Custom.Step.Test' + } + ) +} diff --git a/tests/fixtures/workflows/joiner-invalid.psd1 b/tests/fixtures/workflows/joiner-invalid.psd1 new file mode 100644 index 00000000..fefeefaf --- /dev/null +++ b/tests/fixtures/workflows/joiner-invalid.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'Joiner - Invalid Metadata' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Test step' + Type = 'Custom.Step.Test' + } + ) +} diff --git a/tests/fixtures/workflows/joiner-missing-caps.psd1 b/tests/fixtures/workflows/joiner-missing-caps.psd1 new file mode 100644 index 00000000..51a45f1c --- /dev/null +++ b/tests/fixtures/workflows/joiner-missing-caps.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'Joiner - Missing Capabilities' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Disable identity' + Type = 'IdLE.Step.DisableIdentity' + } + ) +} diff --git a/tests/fixtures/workflows/joiner-no-metadata.psd1 b/tests/fixtures/workflows/joiner-no-metadata.psd1 new file mode 100644 index 00000000..c80faa7f --- /dev/null +++ b/tests/fixtures/workflows/joiner-no-metadata.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'Joiner - Missing Metadata' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Unknown step' + Type = 'Custom.Step.Unknown' + } + ) +} diff --git a/tests/fixtures/workflows/joiner-onfailure.psd1 b/tests/fixtures/workflows/joiner-onfailure.psd1 new file mode 100644 index 00000000..65b17a8e --- /dev/null +++ b/tests/fixtures/workflows/joiner-onfailure.psd1 @@ -0,0 +1,17 @@ +@{ + Name = 'Joiner - OnFailure Metadata' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Primary step' + Type = 'IdLE.Step.EmitEvent' + With = @{ Message = 'Primary' } + } + ) + OnFailureSteps = @( + @{ + Name = 'Containment' + Type = 'IdLE.Step.DisableIdentity' + } + ) +} diff --git a/tests/fixtures/workflows/joiner-override.psd1 b/tests/fixtures/workflows/joiner-override.psd1 new file mode 100644 index 00000000..f6a1d240 --- /dev/null +++ b/tests/fixtures/workflows/joiner-override.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'Joiner - Override Metadata' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Disable identity' + Type = 'IdLE.Step.DisableIdentity' + } + ) +} diff --git a/tests/fixtures/workflows/joiner-scriptblock.psd1 b/tests/fixtures/workflows/joiner-scriptblock.psd1 new file mode 100644 index 00000000..2373222e --- /dev/null +++ b/tests/fixtures/workflows/joiner-scriptblock.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'Joiner - ScriptBlock Test' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Test step' + Type = 'Custom.Step.Test' + } + ) +}