diff --git a/src/IdLE.Core/Private/Assert-IdlePlanCapabilitiesSatisfied.ps1 b/src/IdLE.Core/Private/Assert-IdlePlanCapabilitiesSatisfied.ps1 new file mode 100644 index 00000000..2dd1a91e --- /dev/null +++ b/src/IdLE.Core/Private/Assert-IdlePlanCapabilitiesSatisfied.ps1 @@ -0,0 +1,89 @@ +Set-StrictMode -Version Latest + +function Assert-IdlePlanCapabilitiesSatisfied { + <# + .SYNOPSIS + Validates that all required step capabilities are available. + + .DESCRIPTION + Fail-fast validation executed during planning. + If one or more capabilities are missing, an ArgumentException is thrown with a + deterministic error message listing missing capabilities and affected steps. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object[]] $Steps, + + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + if ($null -eq $Steps -or @($Steps).Count -eq 0) { + return + } + + $required = @() + $requiredByStep = [ordered]@{} + + foreach ($s in @($Steps)) { + if ($null -eq $s) { + continue + } + + $stepName = Get-IdleOptionalPropertyValue -Object $s -Name 'Name' + if ($null -eq $stepName -or [string]::IsNullOrWhiteSpace([string]$stepName)) { + $stepName = '' + } + + $capsRaw = Get-IdleOptionalPropertyValue -Object $s -Name 'RequiresCapabilities' + $caps = if ($null -eq $capsRaw) { @() } else { @($capsRaw) } + + if (@($caps).Count -gt 0) { + $required += $caps + $requiredByStep[$stepName] = @($caps) + } + } + + $required = @($required | Sort-Object -Unique) + if (@($required).Count -eq 0) { + return + } + + $available = @(Get-IdleAvailableCapabilities -Providers $Providers) + + $missing = @() + foreach ($c in $required) { + if ($available -notcontains $c) { + $missing += $c + } + } + + $missing = @($missing | Sort-Object -Unique) + if (@($missing).Count -eq 0) { + return + } + + $affectedSteps = @() + foreach ($k in $requiredByStep.Keys) { + $capsForStep = @($requiredByStep[$k]) + foreach ($m in $missing) { + if ($capsForStep -contains $m) { + $affectedSteps += $k + break + } + } + } + + $affectedSteps = @($affectedSteps | Sort-Object -Unique) + + $msg = @() + $msg += "Plan cannot be built because required provider capabilities are missing." + $msg += ("MissingCapabilities: {0}" -f ([string]::Join(', ', @($missing)))) + $msg += ("AffectedSteps: {0}" -f ([string]::Join(', ', @($affectedSteps)))) + $msg += ("AvailableCapabilities: {0}" -f ([string]::Join(', ', @($available)))) + + throw [System.ArgumentException]::new(([string]::Join(' ', $msg)), 'Providers') +} diff --git a/src/IdLE.Core/Private/ConvertTo-IdleNormalizedCapability.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleNormalizedCapability.ps1 new file mode 100644 index 00000000..1d6f9619 --- /dev/null +++ b/src/IdLE.Core/Private/ConvertTo-IdleNormalizedCapability.ps1 @@ -0,0 +1,40 @@ +Set-StrictMode -Version Latest + +function ConvertTo-IdleNormalizedCapability { + <# + .SYNOPSIS + Normalizes capability identifiers and maps deprecated IDs to current ones. + + .DESCRIPTION + Handles capability ID migrations and deprecation warnings during planning. + Pre-1.0 deprecated capability IDs are mapped to their replacements and emit a warning. + + .PARAMETER Capability + The raw capability identifier to normalize. + + .OUTPUTS + Normalized capability identifier (string). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Capability + ) + + # Deprecated capability ID mappings (pre-1.0) + # Format: @{ 'OldID' = 'NewID' } + $deprecatedMappings = @{ + 'IdLE.Mailbox.Read' = 'IdLE.Mailbox.Info.Read' + } + + $normalized = $Capability.Trim() + + if ($deprecatedMappings.ContainsKey($normalized)) { + $newId = $deprecatedMappings[$normalized] + Write-Warning "DEPRECATED: Capability '$normalized' is deprecated in v1.0 and will be removed in v2.0. Use '$newId' instead." + return $newId + } + + return $normalized +} diff --git a/src/IdLE.Core/Private/ConvertTo-IdleRequiredCapabilities.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleRequiredCapabilities.ps1 new file mode 100644 index 00000000..30d260bc --- /dev/null +++ b/src/IdLE.Core/Private/ConvertTo-IdleRequiredCapabilities.ps1 @@ -0,0 +1,75 @@ +Set-StrictMode -Version Latest + +function ConvertTo-IdleRequiredCapabilities { + <# + .SYNOPSIS + Normalizes the optional RequiresCapabilities key from a workflow step. + + .DESCRIPTION + Supported shapes: + - missing / $null -> empty list + - string -> single capability + - array/enumerable of strings -> list of capabilities + + The output is a stable, sorted, unique string array. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $StepName + ) + + if ($null -eq $Value) { + return @() + } + + $items = @() + + if ($Value -is [string]) { + $items = @($Value) + } + elseif ($Value -is [System.Collections.IEnumerable]) { + foreach ($v in $Value) { + $items += $v + } + } + else { + throw [System.ArgumentException]::new( + ("Workflow step '{0}' has invalid RequiresCapabilities value. Expected string or string array." -f $StepName), + 'Workflow' + ) + } + + $normalized = @() + foreach ($c in $items) { + if ($null -eq $c) { + continue + } + + $s = ([string]$c).Trim() + if ([string]::IsNullOrWhiteSpace($s)) { + continue + } + + # Keep convention aligned with Get-IdleProviderCapabilities: + # - dot-separated segments + # - no whitespace + # - starts with a letter + if ($s -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { + throw [System.ArgumentException]::new( + ("Workflow step '{0}' declares invalid capability '{1}'. Expected dot-separated segments like 'IdLE.Identity.Read'." -f $StepName, $s), + 'Workflow' + ) + } + + # Normalize deprecated capabilities + $normalized += ConvertTo-IdleNormalizedCapability -Capability $s + } + + return @($normalized | Sort-Object -Unique) +} diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 new file mode 100644 index 00000000..61d8b612 --- /dev/null +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -0,0 +1,146 @@ +Set-StrictMode -Version Latest + +function ConvertTo-IdleWorkflowSteps { + <# + .SYNOPSIS + Normalizes workflow steps into IdLE.PlanStep objects. + + .DESCRIPTION + Evaluates Condition during planning and sets Status = Planned / NotApplicable. + + IMPORTANT: + WorkflowSteps is optional and may be null or empty. A workflow is allowed to omit + OnFailureSteps entirely. Therefore we must not mark this parameter as Mandatory. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object[]] $WorkflowSteps, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $PlanningContext, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $StepMetadataCatalog + ) + + if ($null -eq $WorkflowSteps -or @($WorkflowSteps).Count -eq 0) { + return @() + } + + $normalizedSteps = @() + + foreach ($s in @($WorkflowSteps)) { + $stepName = if (Test-IdleWorkflowStepKey -Step $s -Key 'Name') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'Name') + } + else { + '' + } + + if ([string]::IsNullOrWhiteSpace($stepName)) { + throw [System.ArgumentException]::new('Workflow step is missing required key "Name".', 'Workflow') + } + + $stepType = if (Test-IdleWorkflowStepKey -Step $s -Key 'Type') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'Type') + } + else { + '' + } + + if ([string]::IsNullOrWhiteSpace($stepType)) { + throw [System.ArgumentException]::new(("Workflow step '{0}' is missing required key 'Type'." -f $stepName), 'Workflow') + } + + if (Test-IdleWorkflowStepKey -Step $s -Key 'When') { + throw [System.ArgumentException]::new( + ("Workflow step '{0}' uses key 'When'. 'When' has been renamed to 'Condition'. Please update the workflow definition." -f $stepName), + 'Workflow' + ) + } + + $condition = if (Test-IdleWorkflowStepKey -Step $s -Key 'Condition') { + Get-IdleWorkflowStepValue -Step $s -Key 'Condition' + } + else { + $null + } + + $status = 'Planned' + if ($null -ne $condition) { + $schemaErrors = Test-IdleConditionSchema -Condition $condition -StepName $stepName + if (@($schemaErrors).Count -gt 0) { + throw [System.ArgumentException]::new( + ("Invalid Condition on step '{0}': {1}" -f $stepName, ([string]::Join(' ', @($schemaErrors)))), + 'Workflow' + ) + } + + $isApplicable = Test-IdleCondition -Condition $condition -Context $PlanningContext + if (-not $isApplicable) { + $status = 'NotApplicable' + } + } + + # Derive RequiresCapabilities from StepMetadataCatalog instead of workflow. + $requiresCaps = @() + 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. + $errorMessage = "MissingStepTypeMetadata: Workflow step '$stepName' references step type '$stepType' which has no metadata entry. " + ` + "To resolve this: (1) Import/load the step pack module (IdLE.Steps.*) that provides metadata for '$stepType' via Get-IdleStepMetadataCatalog, OR " + ` + "(2) For host-defined/custom step types only, provide Providers.StepMetadata['$stepType'] = @{ RequiredCapabilities = @(...) }." + throw [System.InvalidOperationException]::new($errorMessage) + } + + $description = if (Test-IdleWorkflowStepKey -Step $s -Key 'Description') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'Description') + } + else { + '' + } + + $with = if (Test-IdleWorkflowStepKey -Step $s -Key 'With') { + Copy-IdleDataObject -Value (Get-IdleWorkflowStepValue -Step $s -Key 'With') + } + else { + @{} + } + + # Resolve template placeholders in With (planning-time resolution) + $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName + + $retryProfile = if (Test-IdleWorkflowStepKey -Step $s -Key 'RetryProfile') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'RetryProfile') + } + else { + $null + } + + $normalizedSteps += [pscustomobject]@{ + PSTypeName = 'IdLE.PlanStep' + Name = $stepName + Type = $stepType + Description = $description + Condition = Copy-IdleDataObject -Value $condition + With = $with + RequiresCapabilities = $requiresCaps + Status = $status + RetryProfile = $retryProfile + } + } + + # IMPORTANT: + # Returning an empty array variable can produce no pipeline output, resulting in $null on assignment. + # Force a stable array output shape. + return @($normalizedSteps) +} diff --git a/src/IdLE.Core/Private/ConvertTo-NullIfEmptyString.ps1 b/src/IdLE.Core/Private/ConvertTo-NullIfEmptyString.ps1 new file mode 100644 index 00000000..82a500c0 --- /dev/null +++ b/src/IdLE.Core/Private/ConvertTo-NullIfEmptyString.ps1 @@ -0,0 +1,20 @@ +Set-StrictMode -Version Latest + +function ConvertTo-NullIfEmptyString { + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [string] $Value + ) + + if ($null -eq $Value) { + return $null + } + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $null + } + + return $Value +} diff --git a/src/IdLE.Core/Private/Get-IdleAvailableCapabilities.ps1 b/src/IdLE.Core/Private/Get-IdleAvailableCapabilities.ps1 new file mode 100644 index 00000000..3f74a9b7 --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleAvailableCapabilities.ps1 @@ -0,0 +1,71 @@ +Set-StrictMode -Version Latest + +function Get-IdleAvailableCapabilities { + <# + .SYNOPSIS + Aggregates capabilities from all providers. + + .DESCRIPTION + Collects capabilities from all provider instances and returns a unique sorted list. + Uses the simpler nested helper version of Get-IdleProviderCapabilities that directly + calls GetCapabilities() without inference logic. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + function Get-ProviderCapabilitiesSimple { + <# + .SYNOPSIS + Gets the capability list advertised by a provider (simplified version). + + .DESCRIPTION + Providers are expected to expose a GetCapabilities() method. + If not present, the provider is treated as advertising no capabilities. + This is the planning-time version without inference logic. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Provider + ) + + if ($null -eq $Provider) { + return @() + } + + if ($Provider.PSObject.Methods.Name -contains 'GetCapabilities') { + $caps = $Provider.GetCapabilities() + if ($null -eq $caps) { + return @() + } + return @( + $caps | + Where-Object { $null -ne $_ } | + ForEach-Object { + $rawCap = ([string]$_).Trim() + if (-not [string]::IsNullOrWhiteSpace($rawCap)) { + ConvertTo-IdleNormalizedCapability -Capability $rawCap + } + } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Sort-Object -Unique + ) + } + + return @() + } + + $providerInstances = @(Get-IdleProvidersFromMap -Providers $Providers) + + $caps = @() + foreach ($p in $providerInstances) { + $caps += @(Get-ProviderCapabilitiesSimple -Provider $p) + } + + return @($caps | Sort-Object -Unique) +} diff --git a/src/IdLE.Core/Private/Get-IdleCommandParameterNames.ps1 b/src/IdLE.Core/Private/Get-IdleCommandParameterNames.ps1 new file mode 100644 index 00000000..d6054bfb --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleCommandParameterNames.ps1 @@ -0,0 +1,38 @@ +Set-StrictMode -Version Latest + +function Get-IdleCommandParameterNames { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Handler + ) + + # Returns a HashSet[string] of parameter names supported by the handler. + $set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + if ($Handler -is [scriptblock]) { + + $paramBlock = $Handler.Ast.ParamBlock + if ($null -eq $paramBlock) { + return $set + } + + foreach ($p in $paramBlock.Parameters) { + # Parameter name is stored without the leading '$' + $null = $set.Add([string]$p.Name.VariablePath.UserPath) + } + + return $set + } + + if ($Handler -is [System.Management.Automation.CommandInfo]) { + foreach ($n in $Handler.Parameters.Keys) { + $null = $set.Add([string]$n) + } + return $set + } + + # Unknown handler shape: return an empty set. + return $set +} diff --git a/src/IdLE.Core/Private/Get-IdleOptionalPropertyValue.ps1 b/src/IdLE.Core/Private/Get-IdleOptionalPropertyValue.ps1 new file mode 100644 index 00000000..3e7c5a9f --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleOptionalPropertyValue.ps1 @@ -0,0 +1,44 @@ +Set-StrictMode -Version Latest + +function Get-IdleOptionalPropertyValue { + <# + .SYNOPSIS + Safely reads an optional property from an object. + + .DESCRIPTION + Works with: + - IDictionary (hashtables / ordered dictionaries) + - PSCustomObject / objects with note properties + + Returns $null when the property does not exist. + Uses Get-Member to avoid PropertyNotFoundException in strict mode. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Object, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Name + ) + + if ($null -eq $Object) { + return $null + } + + if ($Object -is [System.Collections.IDictionary]) { + if ($Object.ContainsKey($Name)) { + return $Object[$Name] + } + return $null + } + + $m = $Object | Get-Member -Name $Name -MemberType NoteProperty, Property -ErrorAction SilentlyContinue + if ($null -eq $m) { + return $null + } + + return $Object.$Name +} diff --git a/src/IdLE.Core/Private/Get-IdleProvidersFromMap.ps1 b/src/IdLE.Core/Private/Get-IdleProvidersFromMap.ps1 new file mode 100644 index 00000000..44295786 --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleProvidersFromMap.ps1 @@ -0,0 +1,43 @@ +Set-StrictMode -Version Latest + +function Get-IdleProvidersFromMap { + <# + .SYNOPSIS + Extracts provider instances from the -Providers argument. + + .DESCRIPTION + Supports both: + - hashtable map: @{ Name = ; ... } + - array/list: @( , ... ) + + Returns an array of provider objects. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + if ($null -eq $Providers) { + return @() + } + + if ($Providers -is [System.Collections.IDictionary]) { + $items = @() + foreach ($k in $Providers.Keys) { + $items += $Providers[$k] + } + return @($items) + } + + if ($Providers -is [System.Collections.IEnumerable] -and $Providers -isnot [string]) { + $items = @() + foreach ($p in $Providers) { + $items += $p + } + return @($items) + } + + return @($Providers) +} diff --git a/src/IdLE.Core/Private/Get-IdleStepField.ps1 b/src/IdLE.Core/Private/Get-IdleStepField.ps1 new file mode 100644 index 00000000..6789e8e9 --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleStepField.ps1 @@ -0,0 +1,30 @@ +Set-StrictMode -Version Latest + +function Get-IdleStepField { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Step, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Name + ) + + if ($null -eq $Step) { return $null } + + if ($Step -is [System.Collections.IDictionary]) { + if ($Step.Contains($Name)) { + return $Step[$Name] + } + return $null + } + + $propNames = @($Step.PSObject.Properties.Name) + if ($propNames -contains $Name) { + return $Step.$Name + } + + return $null +} diff --git a/src/IdLE.Core/Private/Get-IdleWorkflowStepValue.ps1 b/src/IdLE.Core/Private/Get-IdleWorkflowStepValue.ps1 new file mode 100644 index 00000000..ba23bcf7 --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleWorkflowStepValue.ps1 @@ -0,0 +1,24 @@ +Set-StrictMode -Version Latest + +function Get-IdleWorkflowStepValue { + <# + .SYNOPSIS + Gets a value from a workflow step by key. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Key + ) + + if ($Step -is [System.Collections.IDictionary]) { + return $Step[$Key] + } + + return $Step.$Key +} diff --git a/src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1 index b57986a0..eb08dcb4 100644 --- a/src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1 @@ -7,51 +7,69 @@ function Resolve-IdleStepHandler { [Parameter(Mandatory)] [ValidateNotNull()] - [hashtable] $Registry + [object] $StepRegistry ) - # Registry maps StepType -> handler. - # - # Trust boundary: - # - The registry is a host-controlled extension point and must be treated as trusted input. - # - Workflows must never be able to provide code (ScriptBlocks) that is executed by the engine. - # - # Security / secure defaults: - # - Only string handlers (function names) are supported. - # - ScriptBlock handlers are intentionally rejected to avoid arbitrary code execution. - - if (-not $Registry.ContainsKey($StepType)) { - return $null - } - - $handler = $Registry[$StepType] + $handlerName = $null - if ($handler -is [string]) { - $fn = $handler.Trim() - if ([string]::IsNullOrWhiteSpace($fn)) { - return $null + if ($StepRegistry -is [System.Collections.IDictionary]) { + if ($StepRegistry.Contains($StepType)) { + $handlerName = $StepRegistry[$StepType] } - - # Ensure the function exists in the current session. - # The host is responsible for importing the module that provides the handler. - $cmd = Get-Command -Name $fn -ErrorAction SilentlyContinue - if ($null -eq $cmd) { - return $null + } + else { + if ($StepRegistry.PSObject.Properties.Name -contains $StepType) { + $handlerName = $StepRegistry.$StepType } + } - return $cmd.Name + if ($null -eq $handlerName -or [string]::IsNullOrWhiteSpace([string]$handlerName)) { + throw [System.ArgumentException]::new("No step handler registered for step type '$StepType'.", 'Providers') } - if ($handler -is [scriptblock]) { + # Reject ScriptBlock handlers (secure default). + if ($handlerName -is [scriptblock]) { throw [System.ArgumentException]::new( - "Invalid step handler for type '$StepType'. ScriptBlock handlers are not allowed. Provide a string with a function name instead.", - 'Registry' + "Step registry handler for '$StepType' must be a function name (string), not a ScriptBlock.", + 'Providers' ) } - # Any other type is invalid configuration. - throw [System.ArgumentException]::new( - "Invalid step handler for type '$StepType'. Allowed: string (function name).", - 'Registry' - ) + # Resolve the handler command. + # The handler name can be: + # 1) A simple function name (e.g. "Invoke-IdleStepEmitEvent") - globally available + # 2) A module-qualified name (e.g. "IdLE.Steps.Common\Invoke-IdleStepEmitEvent") - from a nested module + # + # Module-qualified names are used for built-in steps that are loaded as nested modules + # and not exported globally to keep the session clean. + + $cmd = $null + + # Try simple lookup first (globally available commands) + $cmd = Get-Command -Name ([string]$handlerName) -CommandType Function -ErrorAction SilentlyContinue + + # If not found and name contains backslash, try module-qualified lookup + if ($null -eq $cmd -and ([string]$handlerName).Contains('\')) { + $parts = ([string]$handlerName).Split('\', 2) + if ($parts.Count -eq 2) { + $moduleName = $parts[0] + $commandName = $parts[1] + + # Get-Module -All returns loaded modules (including nested/hidden modules) + # We use -All to find modules that are loaded but not in the global session state + $modules = @(Get-Module -Name $moduleName -All) + if ($modules.Count -gt 0) { + $module = $modules[0] + if ($null -ne $module.ExportedCommands -and $module.ExportedCommands.ContainsKey($commandName)) { + $cmd = $module.ExportedCommands[$commandName] + } + } + } + } + + if ($null -eq $cmd) { + throw [System.ArgumentException]::new("Step handler '$handlerName' for step type '$StepType' could not be resolved to a valid command.", 'Providers') + } + + return $cmd } diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowStepKey.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowStepKey.ps1 new file mode 100644 index 00000000..29c6ff5f --- /dev/null +++ b/src/IdLE.Core/Private/Test-IdleWorkflowStepKey.ps1 @@ -0,0 +1,25 @@ +Set-StrictMode -Version Latest + +function Test-IdleWorkflowStepKey { + <# + .SYNOPSIS + Checks whether a workflow step contains a given key. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Key + ) + + if ($Step -is [System.Collections.IDictionary]) { + return $Step.ContainsKey($Key) + } + + $m = $Step | Get-Member -Name $Key -MemberType NoteProperty, Property -ErrorAction SilentlyContinue + return ($null -ne $m) +} diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index cca51fec..c37a0045 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -45,148 +45,6 @@ function Invoke-IdlePlanObject { [hashtable] $ExecutionOptions ) - function Get-IdleCommandParameterNames { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Handler - ) - - # Returns a HashSet[string] of parameter names supported by the handler. - $set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - - if ($Handler -is [scriptblock]) { - - $paramBlock = $Handler.Ast.ParamBlock - if ($null -eq $paramBlock) { - return $set - } - - foreach ($p in $paramBlock.Parameters) { - # Parameter name is stored without the leading '$' - $null = $set.Add([string]$p.Name.VariablePath.UserPath) - } - - return $set - } - - if ($Handler -is [System.Management.Automation.CommandInfo]) { - foreach ($n in $Handler.Parameters.Keys) { - $null = $set.Add([string]$n) - } - return $set - } - - # Unknown handler shape: return an empty set. - return $set - } - - function Resolve-IdleStepHandler { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $StepType, - - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $StepRegistry - ) - - $handlerName = $null - - if ($StepRegistry -is [System.Collections.IDictionary]) { - if ($StepRegistry.Contains($StepType)) { - $handlerName = $StepRegistry[$StepType] - } - } - else { - if ($StepRegistry.PSObject.Properties.Name -contains $StepType) { - $handlerName = $StepRegistry.$StepType - } - } - - if ($null -eq $handlerName -or [string]::IsNullOrWhiteSpace([string]$handlerName)) { - throw [System.ArgumentException]::new("No step handler registered for step type '$StepType'.", 'Providers') - } - - # Reject ScriptBlock handlers (secure default). - if ($handlerName -is [scriptblock]) { - throw [System.ArgumentException]::new( - "Step registry handler for '$StepType' must be a function name (string), not a ScriptBlock.", - 'Providers' - ) - } - - # Resolve the handler command. - # The handler name can be: - # 1) A simple function name (e.g. "Invoke-IdleStepEmitEvent") - globally available - # 2) A module-qualified name (e.g. "IdLE.Steps.Common\Invoke-IdleStepEmitEvent") - from a nested module - # - # Module-qualified names are used for built-in steps that are loaded as nested modules - # and not exported globally to keep the session clean. - - $cmd = $null - - # Try simple lookup first (globally available commands) - $cmd = Get-Command -Name ([string]$handlerName) -CommandType Function -ErrorAction SilentlyContinue - - # If not found and name contains backslash, try module-qualified lookup - if ($null -eq $cmd -and ([string]$handlerName).Contains('\')) { - $parts = ([string]$handlerName).Split('\', 2) - if ($parts.Count -eq 2) { - $moduleName = $parts[0] - $commandName = $parts[1] - - # Get-Module -All returns loaded modules (including nested/hidden modules) - # We use -All to find modules that are loaded but not in the global session state - $modules = @(Get-Module -Name $moduleName -All) - if ($modules.Count -gt 0) { - $module = $modules[0] - if ($null -ne $module.ExportedCommands -and $module.ExportedCommands.ContainsKey($commandName)) { - $cmd = $module.ExportedCommands[$commandName] - } - } - } - } - - if ($null -eq $cmd) { - throw [System.ArgumentException]::new("Step handler '$handlerName' for step type '$StepType' could not be resolved to a valid command.", 'Providers') - } - - return $cmd - } - - function Get-IdleStepField { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [AllowNull()] - [object] $Step, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $Name - ) - - if ($null -eq $Step) { return $null } - - if ($Step -is [System.Collections.IDictionary]) { - if ($Step.Contains($Name)) { - return $Step[$Name] - } - return $null - } - - $propNames = @($Step.PSObject.Properties.Name) - if ($propNames -contains $Name) { - return $Step.$Name - } - - return $null - } - $planPropNames = @($Plan.PSObject.Properties.Name) $request = if ($planPropNames -contains 'Request') { $Plan.Request } else { $null } diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index bd2ae187..b1ade006 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -40,567 +40,6 @@ function New-IdlePlanObject { [object] $Providers ) - function ConvertTo-NullIfEmptyString { - [CmdletBinding()] - param( - [Parameter()] - [AllowNull()] - [string] $Value - ) - - if ($null -eq $Value) { - return $null - } - - if ([string]::IsNullOrWhiteSpace($Value)) { - return $null - } - - return $Value - } - - function Get-IdleOptionalPropertyValue { - <# - .SYNOPSIS - Safely reads an optional property from an object. - - .DESCRIPTION - Works with: - - IDictionary (hashtables / ordered dictionaries) - - PSCustomObject / objects with note properties - - Returns $null when the property does not exist. - Uses Get-Member to avoid PropertyNotFoundException in strict mode. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [AllowNull()] - [object] $Object, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $Name - ) - - if ($null -eq $Object) { - return $null - } - - if ($Object -is [System.Collections.IDictionary]) { - if ($Object.ContainsKey($Name)) { - return $Object[$Name] - } - return $null - } - - $m = $Object | Get-Member -Name $Name -MemberType NoteProperty, Property -ErrorAction SilentlyContinue - if ($null -eq $m) { - return $null - } - - return $Object.$Name - } - - function ConvertTo-IdleRequiredCapabilities { - <# - .SYNOPSIS - Normalizes the optional RequiresCapabilities key from a workflow step. - - .DESCRIPTION - Supported shapes: - - missing / $null -> empty list - - string -> single capability - - array/enumerable of strings -> list of capabilities - - The output is a stable, sorted, unique string array. - #> - [CmdletBinding()] - param( - [Parameter()] - [AllowNull()] - [object] $Value, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $StepName - ) - - if ($null -eq $Value) { - return @() - } - - $items = @() - - if ($Value -is [string]) { - $items = @($Value) - } - elseif ($Value -is [System.Collections.IEnumerable]) { - foreach ($v in $Value) { - $items += $v - } - } - else { - throw [System.ArgumentException]::new( - ("Workflow step '{0}' has invalid RequiresCapabilities value. Expected string or string array." -f $StepName), - 'Workflow' - ) - } - - $normalized = @() - foreach ($c in $items) { - if ($null -eq $c) { - continue - } - - $s = ([string]$c).Trim() - if ([string]::IsNullOrWhiteSpace($s)) { - continue - } - - # Keep convention aligned with Get-IdleProviderCapabilities: - # - dot-separated segments - # - no whitespace - # - starts with a letter - if ($s -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { - throw [System.ArgumentException]::new( - ("Workflow step '{0}' declares invalid capability '{1}'. Expected dot-separated segments like 'IdLE.Identity.Read'." -f $StepName, $s), - 'Workflow' - ) - } - - # Normalize deprecated capabilities - $normalized += ConvertTo-IdleNormalizedCapability -Capability $s - } - - return @($normalized | Sort-Object -Unique) - } - - function Get-IdleProvidersFromMap { - <# - .SYNOPSIS - Extracts provider instances from the -Providers argument. - - .DESCRIPTION - Supports both: - - hashtable map: @{ Name = ; ... } - - array/list: @( , ... ) - - Returns an array of provider objects. - #> - [CmdletBinding()] - param( - [Parameter()] - [AllowNull()] - [object] $Providers - ) - - if ($null -eq $Providers) { - return @() - } - - if ($Providers -is [System.Collections.IDictionary]) { - $items = @() - foreach ($k in $Providers.Keys) { - $items += $Providers[$k] - } - return @($items) - } - - if ($Providers -is [System.Collections.IEnumerable] -and $Providers -isnot [string]) { - $items = @() - foreach ($p in $Providers) { - $items += $p - } - return @($items) - } - - return @($Providers) - } - - function ConvertTo-IdleNormalizedCapability { - <# - .SYNOPSIS - Normalizes capability identifiers and maps deprecated IDs to current ones. - - .DESCRIPTION - Handles capability ID migrations and deprecation warnings during planning. - Pre-1.0 deprecated capability IDs are mapped to their replacements and emit a warning. - - .PARAMETER Capability - The raw capability identifier to normalize. - - .OUTPUTS - Normalized capability identifier (string). - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $Capability - ) - - # Deprecated capability ID mappings (pre-1.0) - # Format: @{ 'OldID' = 'NewID' } - $deprecatedMappings = @{ - 'IdLE.Mailbox.Read' = 'IdLE.Mailbox.Info.Read' - } - - $normalized = $Capability.Trim() - - if ($deprecatedMappings.ContainsKey($normalized)) { - $newId = $deprecatedMappings[$normalized] - Write-Warning "DEPRECATED: Capability '$normalized' is deprecated in v1.0 and will be removed in v2.0. Use '$newId' instead." - return $newId - } - - return $normalized - } - - function Get-IdleProviderCapabilities { - <# - .SYNOPSIS - Gets the capability list advertised by a provider. - - .DESCRIPTION - Providers are expected to expose a GetCapabilities() method. - If not present, the provider is treated as advertising no capabilities. - #> - [CmdletBinding()] - param( - [Parameter()] - [AllowNull()] - [object] $Provider - ) - - if ($null -eq $Provider) { - return @() - } - - if ($Provider.PSObject.Methods.Name -contains 'GetCapabilities') { - $caps = $Provider.GetCapabilities() - if ($null -eq $caps) { - return @() - } - return @( - $caps | - Where-Object { $null -ne $_ } | - ForEach-Object { - $rawCap = ([string]$_).Trim() - if (-not [string]::IsNullOrWhiteSpace($rawCap)) { - ConvertTo-IdleNormalizedCapability -Capability $rawCap - } - } | - Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | - Sort-Object -Unique - ) - } - - return @() - } - - function Get-IdleAvailableCapabilities { - <# - .SYNOPSIS - Aggregates capabilities from all providers. - #> - [CmdletBinding()] - param( - [Parameter()] - [AllowNull()] - [object] $Providers - ) - - $providerInstances = @(Get-IdleProvidersFromMap -Providers $Providers) - - $caps = @() - foreach ($p in $providerInstances) { - $caps += @(Get-IdleProviderCapabilities -Provider $p) - } - - return @($caps | Sort-Object -Unique) - } - - function Assert-IdlePlanCapabilitiesSatisfied { - <# - .SYNOPSIS - Validates that all required step capabilities are available. - - .DESCRIPTION - Fail-fast validation executed during planning. - If one or more capabilities are missing, an ArgumentException is thrown with a - deterministic error message listing missing capabilities and affected steps. - #> - [CmdletBinding()] - param( - [Parameter()] - [AllowNull()] - [object[]] $Steps, - - [Parameter()] - [AllowNull()] - [object] $Providers - ) - - if ($null -eq $Steps -or @($Steps).Count -eq 0) { - return - } - - $required = @() - $requiredByStep = [ordered]@{} - - foreach ($s in @($Steps)) { - if ($null -eq $s) { - continue - } - - $stepName = Get-IdleOptionalPropertyValue -Object $s -Name 'Name' - if ($null -eq $stepName -or [string]::IsNullOrWhiteSpace([string]$stepName)) { - $stepName = '' - } - - $capsRaw = Get-IdleOptionalPropertyValue -Object $s -Name 'RequiresCapabilities' - $caps = if ($null -eq $capsRaw) { @() } else { @($capsRaw) } - - if (@($caps).Count -gt 0) { - $required += $caps - $requiredByStep[$stepName] = @($caps) - } - } - - $required = @($required | Sort-Object -Unique) - if (@($required).Count -eq 0) { - return - } - - $available = @(Get-IdleAvailableCapabilities -Providers $Providers) - - $missing = @() - foreach ($c in $required) { - if ($available -notcontains $c) { - $missing += $c - } - } - - $missing = @($missing | Sort-Object -Unique) - if (@($missing).Count -eq 0) { - return - } - - $affectedSteps = @() - foreach ($k in $requiredByStep.Keys) { - $capsForStep = @($requiredByStep[$k]) - foreach ($m in $missing) { - if ($capsForStep -contains $m) { - $affectedSteps += $k - break - } - } - } - - $affectedSteps = @($affectedSteps | Sort-Object -Unique) - - $msg = @() - $msg += "Plan cannot be built because required provider capabilities are missing." - $msg += ("MissingCapabilities: {0}" -f ([string]::Join(', ', @($missing)))) - $msg += ("AffectedSteps: {0}" -f ([string]::Join(', ', @($affectedSteps)))) - $msg += ("AvailableCapabilities: {0}" -f ([string]::Join(', ', @($available)))) - - throw [System.ArgumentException]::new(([string]::Join(' ', $msg)), 'Providers') - } - - function Test-IdleWorkflowStepKey { - <# - .SYNOPSIS - Checks whether a workflow step contains a given key. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Step, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $Key - ) - - if ($Step -is [System.Collections.IDictionary]) { - return $Step.ContainsKey($Key) - } - - $m = $Step | Get-Member -Name $Key -MemberType NoteProperty, Property -ErrorAction SilentlyContinue - return ($null -ne $m) - } - - function Get-IdleWorkflowStepValue { - <# - .SYNOPSIS - Gets a value from a workflow step by key. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Step, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $Key - ) - - if ($Step -is [System.Collections.IDictionary]) { - return $Step[$Key] - } - - return $Step.$Key - } - - function ConvertTo-IdleWorkflowSteps { - <# - .SYNOPSIS - Normalizes workflow steps into IdLE.PlanStep objects. - - .DESCRIPTION - Evaluates Condition during planning and sets Status = Planned / NotApplicable. - - IMPORTANT: - WorkflowSteps is optional and may be null or empty. A workflow is allowed to omit - OnFailureSteps entirely. Therefore we must not mark this parameter as Mandatory. - #> - [CmdletBinding()] - param( - [Parameter()] - [AllowNull()] - [object[]] $WorkflowSteps, - - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $PlanningContext, - - [Parameter(Mandatory)] - [ValidateNotNull()] - [hashtable] $StepMetadataCatalog - ) - - if ($null -eq $WorkflowSteps -or @($WorkflowSteps).Count -eq 0) { - return @() - } - - $normalizedSteps = @() - - foreach ($s in @($WorkflowSteps)) { - $stepName = if (Test-IdleWorkflowStepKey -Step $s -Key 'Name') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'Name') - } - else { - '' - } - - if ([string]::IsNullOrWhiteSpace($stepName)) { - throw [System.ArgumentException]::new('Workflow step is missing required key "Name".', 'Workflow') - } - - $stepType = if (Test-IdleWorkflowStepKey -Step $s -Key 'Type') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'Type') - } - else { - '' - } - - if ([string]::IsNullOrWhiteSpace($stepType)) { - throw [System.ArgumentException]::new(("Workflow step '{0}' is missing required key 'Type'." -f $stepName), 'Workflow') - } - - if (Test-IdleWorkflowStepKey -Step $s -Key 'When') { - throw [System.ArgumentException]::new( - ("Workflow step '{0}' uses key 'When'. 'When' has been renamed to 'Condition'. Please update the workflow definition." -f $stepName), - 'Workflow' - ) - } - - $condition = if (Test-IdleWorkflowStepKey -Step $s -Key 'Condition') { - Get-IdleWorkflowStepValue -Step $s -Key 'Condition' - } - else { - $null - } - - $status = 'Planned' - if ($null -ne $condition) { - $schemaErrors = Test-IdleConditionSchema -Condition $condition -StepName $stepName - if (@($schemaErrors).Count -gt 0) { - throw [System.ArgumentException]::new( - ("Invalid Condition on step '{0}': {1}" -f $stepName, ([string]::Join(' ', @($schemaErrors)))), - 'Workflow' - ) - } - - $isApplicable = Test-IdleCondition -Condition $condition -Context $PlanningContext - if (-not $isApplicable) { - $status = 'NotApplicable' - } - } - - # Derive RequiresCapabilities from StepMetadataCatalog instead of workflow. - $requiresCaps = @() - 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. - $errorMessage = "MissingStepTypeMetadata: Workflow step '$stepName' references step type '$stepType' which has no metadata entry. " + ` - "To resolve this: (1) Import/load the step pack module (IdLE.Steps.*) that provides metadata for '$stepType' via Get-IdleStepMetadataCatalog, OR " + ` - "(2) For host-defined/custom step types only, provide Providers.StepMetadata['$stepType'] = @{ RequiredCapabilities = @(...) }." - throw [System.InvalidOperationException]::new($errorMessage) - } - - $description = if (Test-IdleWorkflowStepKey -Step $s -Key 'Description') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'Description') - } - else { - '' - } - - $with = if (Test-IdleWorkflowStepKey -Step $s -Key 'With') { - Copy-IdleDataObject -Value (Get-IdleWorkflowStepValue -Step $s -Key 'With') - } - else { - @{} - } - - # Resolve template placeholders in With (planning-time resolution) - $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName - - $retryProfile = if (Test-IdleWorkflowStepKey -Step $s -Key 'RetryProfile') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'RetryProfile') - } - else { - $null - } - - $normalizedSteps += [pscustomobject]@{ - PSTypeName = 'IdLE.PlanStep' - Name = $stepName - Type = $stepType - Description = $description - Condition = Copy-IdleDataObject -Value $condition - With = $with - RequiresCapabilities = $requiresCaps - Status = $status - RetryProfile = $retryProfile - } - } - - # IMPORTANT: - # Returning an empty array variable can produce no pipeline output, resulting in $null on assignment. - # Force a stable array output shape. - return @($normalizedSteps) - } - # Ensure required request properties exist without hard-typing the request class. $reqProps = $Request.PSObject.Properties.Name if ($reqProps -notcontains 'LifecycleEvent') { diff --git a/src/IdLE.Provider.AD/Private/ConvertTo-IdleADEntitlement.ps1 b/src/IdLE.Provider.AD/Private/ConvertTo-IdleADEntitlement.ps1 new file mode 100644 index 00000000..1c09644c --- /dev/null +++ b/src/IdLE.Provider.AD/Private/ConvertTo-IdleADEntitlement.ps1 @@ -0,0 +1,69 @@ +Set-StrictMode -Version Latest + +function ConvertTo-IdleADEntitlement { + <# + .SYNOPSIS + Converts a value to an IdLE.Entitlement object for AD provider. + + .DESCRIPTION + Normalizes and validates entitlement values from various input formats + (hashtable, PSCustomObject) into a standard IdLE.Entitlement object. + + The function validates that required fields (Kind, Id) are present and not empty. + + .PARAMETER Value + The input value to convert. Can be a hashtable or PSCustomObject with + Kind, Id, and optionally DisplayName properties. + + .OUTPUTS + PSCustomObject with PSTypeName 'IdLE.Entitlement' + - PSTypeName: 'IdLE.Entitlement' + - Kind: Entitlement kind (e.g., 'Group') + - Id: Entitlement identifier (e.g., Group DN) + - DisplayName: Optional display name (null if not provided or empty) + + .EXAMPLE + $ent = ConvertTo-IdleADEntitlement -Value @{ Kind = 'Group'; Id = 'CN=MyGroup,OU=Groups,DC=contoso,DC=com' } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Value + ) + + $kind = $null + $id = $null + $displayName = $null + + if ($Value -is [System.Collections.IDictionary]) { + $kind = $Value['Kind'] + $id = $Value['Id'] + if ($Value.Contains('DisplayName')) { $displayName = $Value['DisplayName'] } + } + else { + $props = $Value.PSObject.Properties + if ($props.Name -contains 'Kind') { $kind = $Value.Kind } + if ($props.Name -contains 'Id') { $id = $Value.Id } + if ($props.Name -contains 'DisplayName') { $displayName = $Value.DisplayName } + } + + if ([string]::IsNullOrWhiteSpace([string]$kind)) { + throw "Entitlement.Kind must not be empty." + } + if ([string]::IsNullOrWhiteSpace([string]$id)) { + throw "Entitlement.Id must not be empty." + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.Entitlement' + Kind = [string]$kind + Id = [string]$id + DisplayName = if ($null -eq $displayName -or [string]::IsNullOrWhiteSpace([string]$displayName)) { + $null + } + else { + [string]$displayName + } + } +} diff --git a/src/IdLE.Provider.AD/Private/Test-IdleADPrerequisites.ps1 b/src/IdLE.Provider.AD/Private/Test-IdleADPrerequisites.ps1 new file mode 100644 index 00000000..b82420d1 --- /dev/null +++ b/src/IdLE.Provider.AD/Private/Test-IdleADPrerequisites.ps1 @@ -0,0 +1,58 @@ +Set-StrictMode -Version Latest + +function Test-IdleADPrerequisites { + <# + .SYNOPSIS + Checks if the Active Directory prerequisites are available. + + .DESCRIPTION + Validates that the ActiveDirectory PowerShell module (RSAT) is available. + This module is required for all AD provider operations. + + This function does not throw and returns a structured result object + that can be used by the provider to emit warnings or by provider methods + to throw actionable errors when prerequisites are missing. + + .OUTPUTS + PSCustomObject with PSTypeName 'IdLE.PrerequisitesResult' + - PSTypeName: 'IdLE.PrerequisitesResult' + - ProviderName: 'ADIdentityProvider' + - IsHealthy: $true if all required prerequisites are met + - MissingRequired: array of missing required modules/components + - MissingOptional: array of missing optional modules/components + - Notes: array of additional notes or recommendations + - CheckedAt: datetime when the check was performed + + .EXAMPLE + $prereqs = Test-IdleADPrerequisites + if (-not $prereqs.IsHealthy) { + Write-Warning "AD prerequisites check failed: $($prereqs.MissingRequired -join ', ')" + } + #> + [CmdletBinding()] + param() + + $missingRequired = @() + $missingOptional = @() + $notes = @() + + # Check for ActiveDirectory module (required) + $adModule = Get-Module -Name 'ActiveDirectory' -ListAvailable -ErrorAction SilentlyContinue + if ($null -eq $adModule) { + $missingRequired += 'ActiveDirectory' + $notes += 'The ActiveDirectory PowerShell module (RSAT-AD-PowerShell) is required for all AD provider operations.' + $notes += 'Install via: Add-WindowsCapability -Online -Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0' + } + + $isHealthy = ($missingRequired.Count -eq 0) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.PrerequisitesResult' + ProviderName = 'ADIdentityProvider' + IsHealthy = $isHealthy + MissingRequired = $missingRequired + MissingOptional = $missingOptional + Notes = $notes + CheckedAt = [datetime]::UtcNow + } +} diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index 1b80caae..69a5659b 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -87,6 +87,17 @@ function New-IdleADIdentityProvider { [object] $Adapter ) + # Check prerequisites and emit warnings if required components are missing + $prereqs = Test-IdleADPrerequisites + if (-not $prereqs.IsHealthy) { + foreach ($missing in $prereqs.MissingRequired) { + Write-Warning "AD provider prerequisite check: Required component '$missing' is not available." + } + foreach ($note in $prereqs.Notes) { + Write-Warning "AD provider prerequisite check: $note" + } + } + if ($null -eq $Adapter) { $Adapter = New-IdleADAdapter } @@ -99,40 +110,7 @@ function New-IdleADIdentityProvider { [object] $Value ) - $kind = $null - $id = $null - $displayName = $null - - if ($Value -is [System.Collections.IDictionary]) { - $kind = $Value['Kind'] - $id = $Value['Id'] - if ($Value.Contains('DisplayName')) { $displayName = $Value['DisplayName'] } - } - else { - $props = $Value.PSObject.Properties - if ($props.Name -contains 'Kind') { $kind = $Value.Kind } - if ($props.Name -contains 'Id') { $id = $Value.Id } - if ($props.Name -contains 'DisplayName') { $displayName = $Value.DisplayName } - } - - if ([string]::IsNullOrWhiteSpace([string]$kind)) { - throw "Entitlement.Kind must not be empty." - } - if ([string]::IsNullOrWhiteSpace([string]$id)) { - throw "Entitlement.Id must not be empty." - } - - return [pscustomobject]@{ - PSTypeName = 'IdLE.Entitlement' - Kind = [string]$kind - Id = [string]$id - DisplayName = if ($null -eq $displayName -or [string]::IsNullOrWhiteSpace([string]$displayName)) { - $null - } - else { - [string]$displayName - } - } + return ConvertTo-IdleADEntitlement -Value $Value } $testEntitlementEquals = { @@ -240,7 +218,23 @@ function New-IdleADIdentityProvider { [object] $AuthSession ) + # If no AuthSession, return the default adapter + # Only validate prerequisites for the default adapter if it's the real one (not injected for tests) + # Check TypeNames collection (PSTypeName in hashtable adds to TypeNames, not as a property) if ($null -eq $AuthSession) { + $isRealAdapter = ($this.Adapter.PSObject.TypeNames -contains 'IdLE.ADAdapter') + + if ($isRealAdapter) { + $prereqCheck = Test-IdleADPrerequisites + if (-not $prereqCheck.IsHealthy) { + $missingList = $prereqCheck.MissingRequired -join ', ' + $errorMsg = "AD provider operation cannot proceed. Required prerequisite(s) missing: $missingList" + if ($prereqCheck.Notes.Count -gt 0) { + $errorMsg += "`n" + ($prereqCheck.Notes -join "`n") + } + throw $errorMsg + } + } return $this.Adapter } @@ -253,6 +247,16 @@ function New-IdleADIdentityProvider { } if ($null -ne $credential) { + # Creating new adapter with credential - validate prerequisites + $prereqCheck = Test-IdleADPrerequisites + if (-not $prereqCheck.IsHealthy) { + $missingList = $prereqCheck.MissingRequired -join ', ' + $errorMsg = "AD provider operation cannot proceed. Required prerequisite(s) missing: $missingList" + if ($prereqCheck.Notes.Count -gt 0) { + $errorMsg += "`n" + ($prereqCheck.Notes -join "`n") + } + throw $errorMsg + } return New-IdleADAdapter -Credential $credential } diff --git a/src/IdLE.Provider.EntraID/Private/ConvertTo-IdleEntraIDEntitlement.ps1 b/src/IdLE.Provider.EntraID/Private/ConvertTo-IdleEntraIDEntitlement.ps1 new file mode 100644 index 00000000..dffe314a --- /dev/null +++ b/src/IdLE.Provider.EntraID/Private/ConvertTo-IdleEntraIDEntitlement.ps1 @@ -0,0 +1,80 @@ +Set-StrictMode -Version Latest + +function ConvertTo-IdleEntraIDEntitlement { + <# + .SYNOPSIS + Converts a value to an IdLE.Entitlement object for Entra ID provider. + + .DESCRIPTION + Normalizes and validates entitlement values from various input formats + (hashtable, PSCustomObject) into a standard IdLE.Entitlement object. + + The function validates that required fields (Kind, Id) are present and not empty. + Supports optional fields: DisplayName, Mail. + + .PARAMETER Value + The input value to convert. Can be a hashtable or PSCustomObject with + Kind, Id, and optionally DisplayName and Mail properties. + + .OUTPUTS + PSCustomObject with PSTypeName 'IdLE.Entitlement' + - PSTypeName: 'IdLE.Entitlement' + - Kind: Entitlement kind (e.g., 'Group') + - Id: Entitlement identifier (e.g., Group objectId) + - DisplayName: Optional display name (null if not provided or empty) + - Mail: Optional mail address (null if not provided or empty) + + .EXAMPLE + $ent = ConvertTo-IdleEntraIDEntitlement -Value @{ Kind = 'Group'; Id = '12345678-1234-1234-1234-123456789012' } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Value + ) + + $kind = $null + $id = $null + $displayName = $null + $mail = $null + + if ($Value -is [System.Collections.IDictionary]) { + $kind = $Value['Kind'] + $id = $Value['Id'] + if ($Value.Contains('DisplayName')) { $displayName = $Value['DisplayName'] } + if ($Value.Contains('Mail')) { $mail = $Value['Mail'] } + } + else { + $props = $Value.PSObject.Properties + if ($props.Name -contains 'Kind') { $kind = $Value.Kind } + if ($props.Name -contains 'Id') { $id = $Value.Id } + if ($props.Name -contains 'DisplayName') { $displayName = $Value.DisplayName } + if ($props.Name -contains 'Mail') { $mail = $Value.Mail } + } + + if ([string]::IsNullOrWhiteSpace([string]$kind)) { + throw "Entitlement.Kind must not be empty." + } + if ([string]::IsNullOrWhiteSpace([string]$id)) { + throw "Entitlement.Id must not be empty." + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.Entitlement' + Kind = [string]$kind + Id = [string]$id + DisplayName = if ($null -eq $displayName -or [string]::IsNullOrWhiteSpace([string]$displayName)) { + $null + } + else { + [string]$displayName + } + Mail = if ($null -eq $mail -or [string]::IsNullOrWhiteSpace([string]$mail)) { + $null + } + else { + [string]$mail + } + } +} diff --git a/src/IdLE.Provider.EntraID/Private/Test-IdleEntraIDPrerequisites.ps1 b/src/IdLE.Provider.EntraID/Private/Test-IdleEntraIDPrerequisites.ps1 new file mode 100644 index 00000000..2f4b58be --- /dev/null +++ b/src/IdLE.Provider.EntraID/Private/Test-IdleEntraIDPrerequisites.ps1 @@ -0,0 +1,71 @@ +Set-StrictMode -Version Latest + +function Test-IdleEntraIDPrerequisites { + <# + .SYNOPSIS + Checks if the Microsoft Entra ID prerequisites are available. + + .DESCRIPTION + Validates prerequisites for the Entra ID provider. The default adapter uses + Invoke-RestMethod (built into PowerShell) to call Microsoft Graph API, so there + are no external module dependencies for the adapter itself. + + However, the host must provide valid Graph API authentication (access tokens) + via the AuthSessionBroker pattern for operations to succeed. + + This function does not throw and returns a structured result object + that can be used by the provider to emit warnings or by provider methods + to validate operational readiness. + + .OUTPUTS + PSCustomObject with PSTypeName 'IdLE.PrerequisitesResult' + - PSTypeName: 'IdLE.PrerequisitesResult' + - ProviderName: 'EntraIDIdentityProvider' + - IsHealthy: $true if all required prerequisites are met + - MissingRequired: array of missing required modules/components + - MissingOptional: array of missing optional modules/components + - Notes: array of additional notes or recommendations + - CheckedAt: datetime when the check was performed + + .EXAMPLE + $prereqs = Test-IdleEntraIDPrerequisites + if (-not $prereqs.IsHealthy) { + Write-Warning "EntraID prerequisites check failed: $($prereqs.MissingRequired -join ', ')" + } + #> + [CmdletBinding()] + param() + + $missingRequired = @() + $missingOptional = @() + $notes = @() + + # The default Entra ID adapter uses Invoke-RestMethod (built-in) to call Graph API. + # No external module dependencies are required by the adapter itself. + # + # Authentication is provided by the host via AuthSessionBroker pattern at runtime. + # If auth fails at runtime, the Graph API calls will fail with actionable errors. + + # Check if Invoke-RestMethod is available (should always be available in PS 7+) + if (-not (Get-Command -Name 'Invoke-RestMethod' -ErrorAction SilentlyContinue)) { + $missingRequired += 'Invoke-RestMethod' + $notes += 'Invoke-RestMethod cmdlet is required but not available in this PowerShell session.' + } + + $isHealthy = ($missingRequired.Count -eq 0) + + if (-not $isHealthy) { + $notes += 'The Entra ID provider requires valid Graph API authentication at runtime via AuthSessionBroker.' + $notes += 'Ensure the host provides access tokens with required permissions: User.Read.All, User.ReadWrite.All, Group.Read.All, GroupMember.ReadWrite.All' + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.PrerequisitesResult' + ProviderName = 'EntraIDIdentityProvider' + IsHealthy = $isHealthy + MissingRequired = $missingRequired + MissingOptional = $missingOptional + Notes = $notes + CheckedAt = [datetime]::UtcNow + } +} diff --git a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 index c89dc566..2b89d284 100644 --- a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 +++ b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 @@ -96,6 +96,17 @@ function New-IdleEntraIDIdentityProvider { [object] $Adapter ) + # Check prerequisites and emit warnings if required components are missing + $prereqs = Test-IdleEntraIDPrerequisites + if (-not $prereqs.IsHealthy) { + foreach ($missing in $prereqs.MissingRequired) { + Write-Warning "EntraID provider prerequisite check: Required component '$missing' is not available." + } + foreach ($note in $prereqs.Notes) { + Write-Warning "EntraID provider prerequisite check: $note" + } + } + if ($null -eq $Adapter) { $Adapter = New-IdleEntraIDAdapter } @@ -108,49 +119,7 @@ function New-IdleEntraIDIdentityProvider { [object] $Value ) - $kind = $null - $id = $null - $displayName = $null - $mail = $null - - if ($Value -is [System.Collections.IDictionary]) { - $kind = $Value['Kind'] - $id = $Value['Id'] - if ($Value.Contains('DisplayName')) { $displayName = $Value['DisplayName'] } - if ($Value.Contains('Mail')) { $mail = $Value['Mail'] } - } - else { - $props = $Value.PSObject.Properties - if ($props.Name -contains 'Kind') { $kind = $Value.Kind } - if ($props.Name -contains 'Id') { $id = $Value.Id } - if ($props.Name -contains 'DisplayName') { $displayName = $Value.DisplayName } - if ($props.Name -contains 'Mail') { $mail = $Value.Mail } - } - - if ([string]::IsNullOrWhiteSpace([string]$kind)) { - throw "Entitlement.Kind must not be empty." - } - if ([string]::IsNullOrWhiteSpace([string]$id)) { - throw "Entitlement.Id must not be empty." - } - - return [pscustomobject]@{ - PSTypeName = 'IdLE.Entitlement' - Kind = [string]$kind - Id = [string]$id - DisplayName = if ($null -eq $displayName -or [string]::IsNullOrWhiteSpace([string]$displayName)) { - $null - } - else { - [string]$displayName - } - Mail = if ($null -eq $mail -or [string]::IsNullOrWhiteSpace([string]$mail)) { - $null - } - else { - [string]$mail - } - } + return ConvertTo-IdleEntraIDEntitlement -Value $Value } $testEntitlementEquals = { @@ -182,6 +151,23 @@ function New-IdleEntraIDIdentityProvider { [object] $AuthSession ) + # Validate prerequisites only when using the real (default) adapter + # Skip validation if a fake adapter is injected for tests + # Check TypeNames collection (PSTypeName in hashtable adds to TypeNames, not as a property) + $isRealAdapter = ($this.Adapter.PSObject.TypeNames -contains 'IdLE.EntraIDAdapter') + + if ($isRealAdapter) { + $prereqCheck = Test-IdleEntraIDPrerequisites + if (-not $prereqCheck.IsHealthy) { + $missingList = $prereqCheck.MissingRequired -join ', ' + $errorMsg = "EntraID provider operation cannot proceed. Required prerequisite(s) missing: $missingList" + if ($prereqCheck.Notes.Count -gt 0) { + $errorMsg += "`n" + ($prereqCheck.Notes -join "`n") + } + throw $errorMsg + } + } + if ($null -eq $AuthSession) { # For tests/development, allow null but it will fail when hitting real Graph API # Real usage will fail with proper error from Graph API @@ -193,16 +179,18 @@ function New-IdleEntraIDIdentityProvider { return $AuthSession } - # Object with GetAccessToken() method - if ($AuthSession.PSObject.Methods.Name -contains 'GetAccessToken') { - return $AuthSession.GetAccessToken() - } - # Object with AccessToken property - if ($AuthSession.PSObject.Properties.Name -contains 'AccessToken') { + $hasAccessTokenProperty = $null -ne ($AuthSession.PSObject.Properties | Where-Object { $_.Name -eq 'AccessToken' }) + if ($hasAccessTokenProperty) { return $AuthSession.AccessToken } + # Object with GetAccessToken() method + $hasGetAccessTokenMethod = $null -ne ($AuthSession.PSObject.Methods | Where-Object { $_.Name -eq 'GetAccessToken' }) + if ($hasGetAccessTokenMethod) { + return $AuthSession.GetAccessToken() + } + # PSCredential with token in password field if ($AuthSession -is [PSCredential]) { return $AuthSession.GetNetworkCredential().Password diff --git a/src/IdLE.Provider.ExchangeOnline/Private/Test-IdleExchangeOnlinePrerequisites.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/Test-IdleExchangeOnlinePrerequisites.ps1 new file mode 100644 index 00000000..0d44fb8b --- /dev/null +++ b/src/IdLE.Provider.ExchangeOnline/Private/Test-IdleExchangeOnlinePrerequisites.ps1 @@ -0,0 +1,58 @@ +Set-StrictMode -Version Latest + +function Test-IdleExchangeOnlinePrerequisites { + <# + .SYNOPSIS + Checks if the Exchange Online prerequisites are available. + + .DESCRIPTION + Validates that the ExchangeOnlineManagement PowerShell module is available. + This module is required for all Exchange Online provider operations. + + This function does not throw and returns a structured result object + that can be used by the provider to emit warnings or by provider methods + to throw actionable errors when prerequisites are missing. + + .OUTPUTS + PSCustomObject with PSTypeName 'IdLE.PrerequisitesResult' + - PSTypeName: 'IdLE.PrerequisitesResult' + - ProviderName: 'ExchangeOnlineProvider' + - IsHealthy: $true if all required prerequisites are met + - MissingRequired: array of missing required modules/components + - MissingOptional: array of missing optional modules/components + - Notes: array of additional notes or recommendations + - CheckedAt: datetime when the check was performed + + .EXAMPLE + $prereqs = Test-IdleExchangeOnlinePrerequisites + if (-not $prereqs.IsHealthy) { + Write-Warning "ExchangeOnline prerequisites check failed: $($prereqs.MissingRequired -join ', ')" + } + #> + [CmdletBinding()] + param() + + $missingRequired = @() + $missingOptional = @() + $notes = @() + + # Check for ExchangeOnlineManagement module (required) + $exoModule = Get-Module -Name 'ExchangeOnlineManagement' -ListAvailable -ErrorAction SilentlyContinue + if ($null -eq $exoModule) { + $missingRequired += 'ExchangeOnlineManagement' + $notes += 'The ExchangeOnlineManagement PowerShell module is required for all Exchange Online provider operations.' + $notes += 'Install via: Install-Module -Name ExchangeOnlineManagement -Scope CurrentUser' + } + + $isHealthy = ($missingRequired.Count -eq 0) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.PrerequisitesResult' + ProviderName = 'ExchangeOnlineProvider' + IsHealthy = $isHealthy + MissingRequired = $missingRequired + MissingOptional = $missingOptional + Notes = $notes + CheckedAt = [datetime]::UtcNow + } +} diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 index bf57fae4..63256837 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -75,13 +75,18 @@ function New-IdleExchangeOnlineProvider { [object] $Adapter ) - if ($null -eq $Adapter) { - # Verify ExchangeOnlineManagement module is available - $module = Get-Module -Name 'ExchangeOnlineManagement' -ListAvailable -ErrorAction SilentlyContinue - if ($null -eq $module) { - throw "ExchangeOnlineManagement module is not installed. Install it with: Install-Module -Name ExchangeOnlineManagement -Scope CurrentUser" + # Check prerequisites and emit warnings if required components are missing + $prereqs = Test-IdleExchangeOnlinePrerequisites + if (-not $prereqs.IsHealthy) { + foreach ($missing in $prereqs.MissingRequired) { + Write-Warning "ExchangeOnline provider prerequisite check: Required component '$missing' is not available." + } + foreach ($note in $prereqs.Notes) { + Write-Warning "ExchangeOnline provider prerequisite check: $note" } + } + if ($null -eq $Adapter) { $Adapter = New-IdleExchangeOnlineAdapter } @@ -92,6 +97,23 @@ function New-IdleExchangeOnlineProvider { [object] $AuthSession ) + # Validate prerequisites only when using the real (default) adapter + # Skip validation if a fake adapter is injected for tests + # Check TypeNames collection (PSTypeName in hashtable adds to TypeNames, not as a property) + $isRealAdapter = ($this.Adapter.PSObject.TypeNames -contains 'IdLE.ExchangeOnlineAdapter') + + if ($isRealAdapter) { + $prereqCheck = Test-IdleExchangeOnlinePrerequisites + if (-not $prereqCheck.IsHealthy) { + $missingList = $prereqCheck.MissingRequired -join ', ' + $errorMsg = "ExchangeOnline provider operation cannot proceed. Required prerequisite(s) missing: $missingList" + if ($prereqCheck.Notes.Count -gt 0) { + $errorMsg += "`n" + ($prereqCheck.Notes -join "`n") + } + throw $errorMsg + } + } + if ($null -eq $AuthSession) { # For tests/development, allow null but commands will use existing session return $null @@ -102,16 +124,18 @@ function New-IdleExchangeOnlineProvider { return $AuthSession } - # Object with GetAccessToken() method - if ($AuthSession.PSObject.Methods.Name -contains 'GetAccessToken') { - return $AuthSession.GetAccessToken() - } - # Object with AccessToken property - if ($AuthSession.PSObject.Properties.Name -contains 'AccessToken') { + $hasAccessTokenProperty = $null -ne ($AuthSession.PSObject.Properties | Where-Object { $_.Name -eq 'AccessToken' }) + if ($hasAccessTokenProperty) { return $AuthSession.AccessToken } + # Object with GetAccessToken() method + $hasGetAccessTokenMethod = $null -ne ($AuthSession.PSObject.Methods | Where-Object { $_.Name -eq 'GetAccessToken' }) + if ($hasGetAccessTokenMethod) { + return $AuthSession.GetAccessToken() + } + # PSCredential (for certificate-based auth) if ($AuthSession -is [PSCredential]) { # Certificate thumbprint might be in password field