diff --git a/.gitattributes b/.gitattributes index 543f158e..1a02cf32 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,4 +4,5 @@ *.yaml text eol=lf *.ps1 text eol=lf *.psm1 text eol=lf -*.psd1 text eol=lf \ No newline at end of file +*.psd1 text eol=lf +*.html text eol=lf \ No newline at end of file diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 index d6385eeb..4c6973e4 100644 --- a/PSScriptAnalyzerSettings.psd1 +++ b/PSScriptAnalyzerSettings.psd1 @@ -36,13 +36,13 @@ Rules = @{ PSUseConsistentIndentation = @{ - Enable = $true - IndentationSize = 4 + Enable = $true + IndentationSize = 4 PipelineIndentation = 'IncreaseIndentationForFirstPipeline' - Kind = 'space' + Kind = 'space' } - PSUseConsistentWhitespace = @{ + PSUseConsistentWhitespace = @{ Enable = $true CheckInnerBrace = $true CheckOpenBrace = $true @@ -52,7 +52,7 @@ CheckPipeForRedundantWhitespace = $false CheckSeparator = $true CheckParameter = $false - IgnoreAssignmentOperatorInsideHashTable = $false + IgnoreAssignmentOperatorInsideHashTable = $true } } } diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index efb8a2f0..2b2b946e 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -28,6 +28,7 @@ It follows widely accepted **GitHub and PowerShell community conventions**. - Verb-Noun cmdlet naming - Singular nouns - Avoid abbreviations +- Only approved Verbs --- @@ -87,7 +88,7 @@ Steps must: Providers: - handle authentication -- use `ExecutionContext.AcquireSession()` +- use `ExecutionContext.AcquireAuthSession()` - must be mockable - must not assume global state @@ -129,6 +130,7 @@ This style guide focuses on **in-code documentation rules** (comment-based help, - validate early - write tests - document decisions +- keep surface small, no private function exports ### Don't diff --git a/docs/advanced/extensibility.md b/docs/advanced/extensibility.md index 23c2e973..266299f2 100644 --- a/docs/advanced/extensibility.md +++ b/docs/advanced/extensibility.md @@ -26,9 +26,35 @@ A new provider typically involves: 1. A contract interface (if not already present) 2. A provider implementation module -3. Session acquisition via host execution context +3. Auth session acquisition via host execution context (AuthSessionBroker) 4. Contract tests and unit tests +### Auth session acquisition (AuthSessionBroker) + +IdLE keeps authentication out of the core engine. Hosts provide an auth session broker +that is responsible for obtaining and caching authenticated runtime handles (tokens, +Graph clients, Exchange Online sessions, LDAP binds, etc.). + +- Hosts MUST pass the broker via `Providers.AuthSessionBroker`. +- Providers SHOULD acquire sessions through the execution context: + - `Context.AcquireAuthSession(Name, Options)` + +Broker contract: + +- The broker MUST expose an `AcquireAuthSession(Name, Options)` method. +- `Name` is a routing key (for example: `MicrosoftGraph`, `ExchangeOnline`, `ActiveDirectory`). +- `Options` is optional (`$null` is treated as an empty hashtable) and must be data-only: + - ScriptBlock values are rejected, including nested values. +- The engine enriches options with `CorrelationId` and `Actor` when available. +- The engine deep-copies `Options` before invoking the broker; brokers MUST treat + options as immutable and MUST NOT mutate nested values. + +Security notes: + +- Do not embed credentials directly in `Options`. +- Treat `Options` as configuration input, not a secret store. +- Use host secret management and keep secrets out of plans, events, and exports. + ### Capability Advertisement Providers must explicitly advertise their supported capabilities via a @@ -56,6 +82,7 @@ Do not add: - interactive prompts - authentication code inside steps +- authentication flows inside providers (use AuthSessionBroker) - UI or web server dependencies Those belong in a host application. @@ -68,3 +95,7 @@ Steps are executed via a host-provided step registry. - The host maps this identifier to a **function name** (string) in the step registry. ScriptBlock handlers are intentionally not supported as a secure default. + +Step handlers may optionally declare a `Context` parameter. +For backwards compatibility, the engine passes `-Context` only when the handler +supports it. diff --git a/docs/reference/events-and-observability.md b/docs/reference/events-and-observability.md index 7d36cd4b..af42d2ab 100644 --- a/docs/reference/events-and-observability.md +++ b/docs/reference/events-and-observability.md @@ -92,6 +92,24 @@ Buffering is the default because it keeps the core: --- +### Optional streaming to a host sink + +Hosts may provide an **event sink** to receive events as they happen. + +The engine expects an object with a `WriteEvent(event)` method. +If present, the engine forwards each event to that sink **in addition to** buffering +events in the execution result. + +This enables patterns such as: + +- forward events to a central logging system +- push events to a message bus +- render progress updates in an interactive host + +The engine still does not assume anything about formatting, persistence, or transport. + +--- + ## Usage ### Typical consumption patterns @@ -142,6 +160,24 @@ This separation ensures that IdLE remains portable and reusable across environme --- +## Security and sensitive data + +Events are a primary observability surface and therefore a common place where sensitive +values can accidentally leak. + +Guidelines: + +- **Do not emit secrets** in `Event.Data` (tokens, passwords, client secrets, API keys, certificates). +- Prefer **references** (for example: identity IDs, step names, correlation IDs) over raw payloads. +- The engine applies **redaction** at output boundaries (buffered events and host sinks) for + common sensitive key names (for example: `password`, `token`, `secret`, `apiKey`). + Redaction is a safety net, not a design strategy. +- When acquiring authentication sessions, use `Providers.AuthSessionBroker` via + `Context.AcquireAuthSession(Name, Options)`. Auth session options are a data-only boundary + and reject ScriptBlocks. + +--- + ## Common pitfalls ### “Why don’t I see any events?” diff --git a/docs/reference/providers-and-contracts.md b/docs/reference/providers-and-contracts.md index 9ec97c84..c6d4c0ef 100644 --- a/docs/reference/providers-and-contracts.md +++ b/docs/reference/providers-and-contracts.md @@ -160,6 +160,72 @@ their implementation beyond contract usage. --- +## Auth session acquisition (AuthSessionBroker) + +Many providers require authenticated connections (tokens, API clients, remote sessions). +IdLE keeps authentication out of the engine and out of individual providers by using a +host-supplied broker. + +### Contract + +The host injects an **AuthSessionBroker** into the providers map: + +- `Providers.AuthSessionBroker` + +During execution, steps and providers may acquire sessions via the execution context: + +- `Context.AcquireAuthSession(Name, Options)` + +Where: + +- `Name` identifies the requested session (e.g. `Graph`, `ExchangeOnline`, `Ldap`, ...). +- `Options` is an optional **data-only** hashtable. + - `$null` is treated as an empty hashtable. + - ScriptBlocks are rejected, including nested values. + +The broker must expose a method: + +- `AcquireAuthSession(Name, Options)` + +### Responsibility boundaries + +- **Engine** + - Provides `Context.AcquireAuthSession()` as a stable API. + - Enforces the data-only boundary for `Options`. + - Does not implement authentication. + +- **Host** + - Implements and configures the AuthSessionBroker. + - Decides how to authenticate (interactive, managed identity, certificate, secrets, ...). + - Must ensure secrets are not leaked into plans, events, or exports. + +- **Providers / Steps** + - Request sessions through the execution context. + - Must not perform their own authentication flows. + +### Enrichment + +The execution context may enrich the broker request with common run metadata, such as: + +- `CorrelationId` +- `Actor` + +Providers and steps should treat these values as optional. + +--- + +## Execution context injection (backwards compatibility) + +IdLE step handlers can optionally accept a `Context` parameter. + +To remain backwards compatible, the engine passes `-Context $Context` **only if** the +handler supports a `Context` parameter. + +Guidance: + +- New step handlers should accept `Context` to access providers, event sink, and auth session acquisition. +- Existing handlers without `Context` continue to work unchanged. + ## Common pitfalls ### Treating providers as part of the engine diff --git a/src/IdLE.Core/IdLE.Core.psm1 b/src/IdLE.Core/IdLE.Core.psm1 index ba22c46d..466cf73a 100644 --- a/src/IdLE.Core/IdLE.Core.psm1 +++ b/src/IdLE.Core/IdLE.Core.psm1 @@ -2,7 +2,7 @@ Set-StrictMode -Version Latest -$PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' +$PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' $PrivatePath = Join-Path -Path $PSScriptRoot -ChildPath 'Private' foreach ($path in @($PrivatePath, $PublicPath)) { diff --git a/src/IdLE.Core/Private/Assert-IdleNoScriptBlockInAuthSessionOptions.ps1 b/src/IdLE.Core/Private/Assert-IdleNoScriptBlockInAuthSessionOptions.ps1 new file mode 100644 index 00000000..a6f7d03a --- /dev/null +++ b/src/IdLE.Core/Private/Assert-IdleNoScriptBlockInAuthSessionOptions.ps1 @@ -0,0 +1,49 @@ +# Validates that auth session options do not contain ScriptBlock objects. +# Recursively walks hashtables, enumerables, and PSCustomObjects. +# Enforces the security boundary: auth session options must be data-only. + +function Assert-IdleNoScriptBlockInAuthSessionOptions { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $InputObject, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + if ($null -eq $InputObject) { return } + + if ($InputObject -is [scriptblock]) { + throw [System.ArgumentException]::new( + "ScriptBlocks are not allowed in auth session options. Found at: $Path", + $Path + ) + } + + if ($InputObject -is [System.Collections.IDictionary]) { + foreach ($key in $InputObject.Keys) { + Assert-IdleNoScriptBlockInAuthSessionOptions -InputObject $InputObject[$key] -Path "$Path.$key" + } + return + } + + if (($InputObject -is [System.Collections.IEnumerable]) -and ($InputObject -isnot [string])) { + $i = 0 + foreach ($item in $InputObject) { + Assert-IdleNoScriptBlockInAuthSessionOptions -InputObject $item -Path "$Path[$i]" + $i++ + } + return + } + + if ($InputObject -is [pscustomobject]) { + foreach ($p in $InputObject.PSObject.Properties) { + if ($p.MemberType -eq 'NoteProperty') { + Assert-IdleNoScriptBlockInAuthSessionOptions -InputObject $p.Value -Path "$Path.$($p.Name)" + } + } + } +} diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index 928e48ae..fd507ca4 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -102,13 +102,13 @@ function ConvertTo-IdlePlanExportObject { # When present, export these as the canonical request.input payload. $identityKeys = Get-FirstPropertyValue -Object $request -Names @('IdentityKeys', 'IdentityKey', 'Keys') $desiredState = Get-FirstPropertyValue -Object $request -Names @('DesiredState', 'TargetState') - $changes = Get-FirstPropertyValue -Object $request -Names @('Changes', 'Delta') + $changes = Get-FirstPropertyValue -Object $request -Names @('Changes', 'Delta') if ($null -ne $identityKeys -or $null -ne $desiredState -or $null -ne $changes) { $requestInput = New-OrderedMap $requestInput.identityKeys = $identityKeys $requestInput.desiredState = $desiredState - $requestInput.changes = $changes + $requestInput.changes = $changes } } } @@ -138,10 +138,10 @@ function ConvertTo-IdlePlanExportObject { } $requestMap = New-OrderedMap - $requestMap.type = $requestType + $requestMap.type = $requestType $requestMap.correlationId = $correlationId - $requestMap.actor = $actor - $requestMap.input = $redactedRequestInput + $requestMap.actor = $actor + $requestMap.input = $redactedRequestInput # ---- Plan block ---------------------------------------------------------- $planId = ConvertTo-NullIfEmptyString -Value ( diff --git a/src/IdLE.Core/Private/Copy-IdleDataObject.ps1 b/src/IdLE.Core/Private/Copy-IdleDataObject.ps1 new file mode 100644 index 00000000..bfefe540 --- /dev/null +++ b/src/IdLE.Core/Private/Copy-IdleDataObject.ps1 @@ -0,0 +1,67 @@ +function Copy-IdleDataObject { + <# + .SYNOPSIS + Creates a deep-ish, data-only copy of an object. + + .DESCRIPTION + This helper is used to snapshot data-like objects so that exported or executed + artifacts do not retain references to caller-owned objects. + + NOTE: + This is intentionally conservative and only supports data-like objects: + - Hashtable / OrderedDictionary + - PSCustomObject / NoteProperties + - Arrays/lists + - Primitive types + + ScriptBlocks and other executable objects are rejected by upstream validation. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { return $null } + + # Primitive / immutable types should be returned as-is before property inspection. + # This prevents strings from being converted to PSCustomObject with Length property. + if ($Value -is [string] -or + $Value -is [int] -or + $Value -is [long] -or + $Value -is [double] -or + $Value -is [decimal] -or + $Value -is [bool] -or + $Value -is [datetime] -or + $Value -is [guid]) { + return $Value + } + + if ($Value -is [System.Collections.IDictionary]) { + $copy = @{} + foreach ($k in $Value.Keys) { + $copy[$k] = Copy-IdleDataObject -Value $Value[$k] + } + return $copy + } + + if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + $arr = @() + foreach ($item in $Value) { + $arr += Copy-IdleDataObject -Value $item + } + return $arr + } + + $props = @($Value.PSObject.Properties | Where-Object MemberType -in @('NoteProperty', 'Property')) + if ($null -ne $props -and @($props).Count -gt 0) { + $o = [ordered]@{} + foreach ($p in $props) { + $o[$p.Name] = Copy-IdleDataObject -Value $p.Value + } + return [pscustomobject]$o + } + + return $Value +} diff --git a/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 b/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 index 40870fa3..0bdb65cc 100644 --- a/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 +++ b/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 @@ -19,11 +19,11 @@ class IdleLifecycleRequest { [string] $actor ) { $this.LifecycleEvent = $lifecycleEvent - $this.IdentityKeys = $identityKeys - $this.DesiredState = $desiredState - $this.Changes = $changes - $this.CorrelationId = $correlationId - $this.Actor = $actor + $this.IdentityKeys = $identityKeys + $this.DesiredState = $desiredState + $this.Changes = $changes + $this.CorrelationId = $correlationId + $this.Actor = $actor $this.Normalize() } diff --git a/src/IdLE.Core/Private/Invoke-IdleWithRetry.ps1 b/src/IdLE.Core/Private/Invoke-IdleWithRetry.ps1 index ccbc08bf..5d99745b 100644 --- a/src/IdLE.Core/Private/Invoke-IdleWithRetry.ps1 +++ b/src/IdLE.Core/Private/Invoke-IdleWithRetry.ps1 @@ -66,9 +66,9 @@ function Get-IdleDeterministicJitter { } $bytes = [System.Text.Encoding]::UTF8.GetBytes($Seed) - $hash = [System.Security.Cryptography.SHA256]::HashData($bytes) + $hash = [System.Security.Cryptography.SHA256]::HashData($bytes) - $u64 = [System.BitConverter]::ToUInt64($hash, 0) + $u64 = [System.BitConverter]::ToUInt64($hash, 0) $unit = $u64 / [double][UInt64]::MaxValue return (($unit * 2.0) - 1.0) * $JitterRatio diff --git a/src/IdLE.Core/Private/New-IdleEventSink.ps1 b/src/IdLE.Core/Private/New-IdleEventSink.ps1 index dd7adb62..532133c6 100644 --- a/src/IdLE.Core/Private/New-IdleEventSink.ps1 +++ b/src/IdLE.Core/Private/New-IdleEventSink.ps1 @@ -37,7 +37,7 @@ function New-IdleEventSink { } # Capture command references once to avoid scope/name resolution issues inside script methods. - $newIdleEventCmd = Get-Command -Name 'New-IdleEvent' -CommandType Function -ErrorAction Stop + $newIdleEventCmd = Get-Command -Name 'New-IdleEvent' -CommandType Function -ErrorAction Stop $writeIdleEventCmd = Get-Command -Name 'Write-IdleEvent' -CommandType Function -ErrorAction Stop $sink = [pscustomobject]@{ diff --git a/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 b/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 index fdfb5448..a943a416 100644 --- a/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 @@ -67,32 +67,32 @@ function Test-IdleConditionSchema { if (-not ($Node -is [System.Collections.IDictionary])) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must be a hashtable/dictionary." -f $NodePath) - return ,$nodeErrors + return , $nodeErrors } $allowedGroupKeys = @('All', 'Any', 'None') - $allowedOpKeys = @('Equals', 'NotEquals', 'Exists', 'In') - $allowedKeys = @($allowedGroupKeys + $allowedOpKeys) + $allowedOpKeys = @('Equals', 'NotEquals', 'Exists', 'In') + $allowedKeys = @($allowedGroupKeys + $allowedOpKeys) $presentGroupKeys = @($allowedGroupKeys | Where-Object { $Node.Contains($_) }) - $presentOpKeys = @($allowedOpKeys | Where-Object { $Node.Contains($_) }) + $presentOpKeys = @($allowedOpKeys | Where-Object { $Node.Contains($_) }) # Enforce: either group OR operator, never both. if ($presentGroupKeys.Count -gt 0 -and $presentOpKeys.Count -gt 0) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must be either a group (All/Any/None) or an operator (Equals/NotEquals/Exists/In), not both." -f $NodePath) - return ,$nodeErrors + return , $nodeErrors } # Enforce: at least one recognized key. if ($presentGroupKeys.Count -eq 0 -and $presentOpKeys.Count -eq 0) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must specify one group (All/Any/None) or one operator (Equals/NotEquals/Exists/In)." -f $NodePath) - return ,$nodeErrors + return , $nodeErrors } # Enforce: exactly one key at this level (avoids ambiguous evaluation). if (($presentGroupKeys.Count + $presentOpKeys.Count) -ne 1) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must specify exactly one group/operator key." -f $NodePath) - return ,$nodeErrors + return , $nodeErrors } # Unknown keys are errors. @@ -103,7 +103,7 @@ function Test-IdleConditionSchema { } if ($nodeErrors.Count -gt 0) { - return ,$nodeErrors + return , $nodeErrors } # GROUP: All/Any/None must be a non-empty array/list of condition nodes. @@ -114,12 +114,12 @@ function Test-IdleConditionSchema { if ($null -eq $children) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group value must not be null and must contain at least one condition." -f $groupPath) - return ,$nodeErrors + return , $nodeErrors } if (-not ($children -is [System.Collections.IEnumerable]) -or ($children -is [string])) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group value must be an array/list of condition nodes." -f $groupPath) - return ,$nodeErrors + return , $nodeErrors } $i = 0 @@ -136,19 +136,19 @@ function Test-IdleConditionSchema { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group must contain at least one condition node." -f $groupPath) } - return ,$nodeErrors + return , $nodeErrors } # OPERATOR: Exactly one of Equals/NotEquals/Exists/In. - $opKey = [string]$presentOpKeys[0] - $opVal = $Node[$opKey] + $opKey = [string]$presentOpKeys[0] + $opVal = $Node[$opKey] $opPath = ("{0}.{1}" -f $NodePath, $opKey) switch ($opKey) { 'Equals' { if (-not ($opVal -is [System.Collections.IDictionary])) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Equals must be a hashtable with keys Path and Value." -f $opPath) - return ,$nodeErrors + return , $nodeErrors } foreach ($k in @($opVal.Keys)) { @@ -165,13 +165,13 @@ function Test-IdleConditionSchema { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Value." -f $opPath) } - return ,$nodeErrors + return , $nodeErrors } 'NotEquals' { if (-not ($opVal -is [System.Collections.IDictionary])) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: NotEquals must be a hashtable with keys Path and Value." -f $opPath) - return ,$nodeErrors + return , $nodeErrors } foreach ($k in @($opVal.Keys)) { @@ -188,7 +188,7 @@ function Test-IdleConditionSchema { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Value." -f $opPath) } - return ,$nodeErrors + return , $nodeErrors } 'Exists' { @@ -199,12 +199,12 @@ function Test-IdleConditionSchema { if ([string]::IsNullOrWhiteSpace([string]$opVal)) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Exists path must be a non-empty string." -f $opPath) } - return ,$nodeErrors + return , $nodeErrors } if (-not ($opVal -is [System.Collections.IDictionary])) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Exists must be a string path or a hashtable with key Path." -f $opPath) - return ,$nodeErrors + return , $nodeErrors } foreach ($k in @($opVal.Keys)) { @@ -217,7 +217,7 @@ function Test-IdleConditionSchema { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) } - return ,$nodeErrors + return , $nodeErrors } 'In' { @@ -225,7 +225,7 @@ function Test-IdleConditionSchema { # In = @{ Path = 'context.Identity.Type'; Values = @('Joiner','Mover') } if (-not ($opVal -is [System.Collections.IDictionary])) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: In must be a hashtable with keys Path and Values." -f $opPath) - return ,$nodeErrors + return , $nodeErrors } foreach ($k in @($opVal.Keys)) { @@ -240,13 +240,13 @@ function Test-IdleConditionSchema { if (-not $opVal.Contains('Values')) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Values." -f $opPath) - return ,$nodeErrors + return , $nodeErrors } $values = $opVal.Values if ($null -eq $values) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Values must not be null." -f $opPath) - return ,$nodeErrors + return , $nodeErrors } # Values should be list/array (or scalar) but must not be a dictionary (ambiguous). @@ -254,17 +254,17 @@ function Test-IdleConditionSchema { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Values must be a list/array (or scalar), not a dictionary." -f $opPath) } - return ,$nodeErrors + return , $nodeErrors } } Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unsupported operator '{1}'." -f $NodePath, $opKey) - return ,$nodeErrors + return , $nodeErrors } foreach ($e in (Test-IdleConditionNodeSchema -Node $Condition -NodePath ("{0}: Condition" -f $prefix))) { Add-IdleConditionError -List $errors -Message $e } - return ,$errors + return , $errors } diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 5bb0feac..7ef5b630 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -115,6 +115,35 @@ function Invoke-IdlePlanObject { 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 } @@ -140,6 +169,31 @@ function Invoke-IdlePlanObject { $engineEventSink = New-IdleEventSink -CorrelationId $corr -Actor $actor -ExternalEventSink $EventSink -EventBuffer $events # Enforce data-only boundary: reject ScriptBlocks in untrusted inputs. + # Special-case: for auth session acquisition options, throw a contextualized error message. + $planSteps = if ($planPropNames -contains 'Steps') { $Plan.Steps } else { $null } + if ($null -ne $planSteps -and ($planSteps -is [System.Collections.IEnumerable]) -and ($planSteps -isnot [string])) { + $i = 0 + foreach ($step in $planSteps) { + $stepType = [string](Get-IdleStepField -Step $step -Name 'Type') + if ($stepType -eq 'IdLE.Step.AcquireAuthSession') { + $with = Get-IdleStepField -Step $step -Name 'With' + $options = $null + if ($null -ne $with) { + if ($with -is [System.Collections.IDictionary]) { + if ($with.Contains('Options')) { $options = $with['Options'] } + } + else { + if ($with.PSObject.Properties.Name -contains 'Options') { $options = $with.Options } + } + } + + Assert-IdleNoScriptBlockInAuthSessionOptions -InputObject $options -Path "Plan.Steps[$i].With.Options" + } + + $i++ + } + } + Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan' Assert-IdleNoScriptBlock -InputObject $Providers -Path 'Providers' @@ -153,6 +207,115 @@ function Invoke-IdlePlanObject { EventSink = $engineEventSink } + # Expose common run metadata on the execution context so providers can enrich session acquisition requests + # without having to parse the plan structure themselves. + $null = $context | Add-Member -MemberType NoteProperty -Name CorrelationId -Value $corr -Force + $null = $context | Add-Member -MemberType NoteProperty -Name Actor -Value $actor -Force + + # Session acquisition boundary: + # - Providers MUST NOT implement their own authentication flows. + # - The host supplies an AuthSessionBroker in Providers.AuthSessionBroker. + # - Options must be data-only (no ScriptBlocks). + $null = $context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Name, + + [Parameter()] + [AllowNull()] + [hashtable] $Options + ) + + $providers = $this.Providers + $broker = $null + + if ($providers -is [System.Collections.IDictionary]) { + if ($providers.Contains('AuthSessionBroker')) { + $broker = $providers['AuthSessionBroker'] + } + } + else { + if ($null -ne $providers -and $providers.PSObject.Properties.Name -contains 'AuthSessionBroker') { + $broker = $providers.AuthSessionBroker + } + } + + if ($null -eq $broker) { + throw [System.InvalidOperationException]::new( + 'No AuthSessionBroker configured. Provide Providers.AuthSessionBroker to acquire auth sessions during execution.' + ) + } + + if ($broker.PSObject.Methods.Name -notcontains 'AcquireAuthSession') { + throw [System.InvalidOperationException]::new( + 'AuthSessionBroker must provide an AcquireAuthSession(Name, Options) method.' + ) + } + + $normalizedOptions = if ($null -eq $Options) { @{} } else { $Options } + Assert-IdleNoScriptBlockInAuthSessionOptions -InputObject $normalizedOptions -Path 'AuthSessionOptions' + + # Copy options to avoid mutating caller-owned hashtables. + $optionsCopy = Copy-IdleDataObject -Value $normalizedOptions + + if ($null -ne $this.CorrelationId) { $optionsCopy['CorrelationId'] = $this.CorrelationId } + if ($null -ne $this.Actor) { $optionsCopy['Actor'] = $this.Actor } + + return $broker.AcquireAuthSession($Name, $optionsCopy) + } -Force + + # Fail-fast security validation: Check if AuthSessionBroker is required but missing. + # AcquireAuthSession steps require an AuthSessionBroker to be present in Providers. + # Skip NotApplicable steps, as they won't be executed and don't require the broker. + $requiresAuthBroker = $false + $steps = if ($planPropNames -contains 'Steps') { $Plan.Steps } else { @() } + foreach ($step in $steps) { + if ($null -eq $step) { continue } + + $stepType = $null + $stepStatus = $null + if ($step -is [System.Collections.IDictionary]) { + if ($step.Contains('Type')) { + $stepType = $step['Type'] + } + if ($step.Contains('Status')) { + $stepStatus = $step['Status'] + } + } + else { + $stepPropNames = @($step.PSObject.Properties.Name) + $stepType = if ($stepPropNames -contains 'Type') { $step.Type } else { $null } + $stepStatus = if ($stepPropNames -contains 'Status') { $step.Status } else { $null } + } + + if ($stepType -eq 'IdLE.Step.AcquireAuthSession' -and $stepStatus -ne 'NotApplicable') { + $requiresAuthBroker = $true + break + } + } + + if ($requiresAuthBroker) { + $broker = $null + if ($Providers -is [System.Collections.IDictionary]) { + if ($Providers.Contains('AuthSessionBroker')) { + $broker = $Providers['AuthSessionBroker'] + } + } + else { + if ($null -ne $Providers -and $Providers.PSObject.Properties.Name -contains 'AuthSessionBroker') { + $broker = $Providers.AuthSessionBroker + } + } + + if ($null -eq $broker) { + throw [System.InvalidOperationException]::new( + 'AuthSessionBroker is required but not configured. One or more steps require auth session acquisition. Provide Providers.AuthSessionBroker to proceed.' + ) + } + } + $context.EventSink.WriteEvent('RunStarted', 'Plan execution started.', $null, @{ CorrelationId = $corr Actor = $actor @@ -169,12 +332,13 @@ function Invoke-IdlePlanObject { continue } - $stepPropNames = @($step.PSObject.Properties.Name) + $stepName = [string](Get-IdleStepField -Step $step -Name 'Name') + if ($null -eq $stepName) { $stepName = '' } - $stepName = if ($stepPropNames -contains 'Name') { [string]$step.Name } else { '' } - $stepType = if ($stepPropNames -contains 'Type') { $step.Type } else { $null } - $stepWith = if ($stepPropNames -contains 'With') { $step.With } else { $null } - $stepStatus = if ($stepPropNames -contains 'Status') { [string]$step.Status } else { '' } + $stepType = Get-IdleStepField -Step $step -Name 'Type' + $stepWith = Get-IdleStepField -Step $step -Name 'With' + $stepStatus = [string](Get-IdleStepField -Step $step -Name 'Status') + if ($null -eq $stepStatus) { $stepStatus = '' } # Conditions are evaluated during planning and represented as Step.Status. if ($stepStatus -eq 'NotApplicable') { @@ -206,8 +370,11 @@ function Invoke-IdlePlanObject { $supportedParams = Get-IdleCommandParameterNames -Handler $impl - $invokeParams = @{ - Context = $context + $invokeParams = @{} + + # Backwards compatibility: pass -Context only when the handler supports it. + if ($supportedParams.Contains('Context')) { + $invokeParams.Context = $context } if ($null -ne $stepWith -and $supportedParams.Contains('With')) { @@ -354,8 +521,11 @@ function Invoke-IdlePlanObject { $supportedParams = Get-IdleCommandParameterNames -Handler $impl - $invokeParams = @{ - Context = $context + $invokeParams = @{} + + # Backwards compatibility: pass -Context only when the handler supports it. + if ($supportedParams.Contains('Context')) { + $invokeParams.Context = $context } if ($null -ne $ofWith -and $supportedParams.Contains('With')) { diff --git a/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 b/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 index 0be407c7..06441b8b 100644 --- a/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 +++ b/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 @@ -30,7 +30,7 @@ function New-IdleLifecycleRequestObject { # shallow clone is sufficient as we have already validated no ScriptBlocks are present $IdentityKeys = if ($null -eq $IdentityKeys) { @{} } else { $IdentityKeys.Clone() } $DesiredState = if ($null -eq $DesiredState) { @{} } else { $DesiredState.Clone() } - $Changes = if ($null -eq $Changes) { $null } else { $Changes.Clone() } + $Changes = if ($null -eq $Changes) { $null } else { $Changes.Clone() } # Construct and return the core domain object defined in Private/IdleLifecycleRequest.ps1 return [IdleLifecycleRequest]::new( diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 05aa98cd..73b56bf4 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -59,74 +59,6 @@ function New-IdlePlanObject { return $Value } - function Copy-IdleDataObject { - <# - .SYNOPSIS - Creates a deep-ish, data-only copy of an object. - - .DESCRIPTION - This helper is used to snapshot the request input so that the plan can be exported - deterministically, without retaining references to the original live object. - - NOTE: - This is intentionally conservative and only supports data-like objects: - - Hashtable / OrderedDictionary - - PSCustomObject / NoteProperties - - Arrays/lists - - Primitive types - - ScriptBlocks and other executable objects are rejected by upstream validation. - #> - [CmdletBinding()] - param( - [Parameter()] - [AllowNull()] - [object] $Value - ) - - if ($null -eq $Value) { return $null } - - # Primitive / immutable types should be returned as-is before property inspection. - # This prevents strings from being converted to PSCustomObject with Length property. - if ($Value -is [string] -or - $Value -is [int] -or - $Value -is [long] -or - $Value -is [double] -or - $Value -is [decimal] -or - $Value -is [bool] -or - $Value -is [datetime] -or - $Value -is [guid]) { - return $Value - } - - if ($Value -is [System.Collections.IDictionary]) { - $copy = @{} - foreach ($k in $Value.Keys) { - $copy[$k] = Copy-IdleDataObject -Value $Value[$k] - } - return $copy - } - - if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { - $arr = @() - foreach ($item in $Value) { - $arr += Copy-IdleDataObject -Value $item - } - return $arr - } - - $props = @($Value.PSObject.Properties | Where-Object MemberType -in @('NoteProperty', 'Property')) - if ($null -ne $props -and @($props).Count -gt 0) { - $o = [ordered]@{} - foreach ($p in $props) { - $o[$p.Name] = Copy-IdleDataObject -Value $p.Value - } - return [pscustomobject]$o - } - - return $Value - } - function Get-IdleOptionalPropertyValue { <# .SYNOPSIS diff --git a/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 index 8f61bfce..60fc3be2 100644 --- a/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 +++ b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 @@ -49,7 +49,7 @@ function Test-IdleWorkflowDefinitionObject { } else { $reqEvent = [string]$Request.LifecycleEvent - $wfEvent = [string]$workflow.LifecycleEvent + $wfEvent = [string]$workflow.LifecycleEvent if (-not [string]::IsNullOrWhiteSpace($reqEvent) -and -not $reqEvent.Equals($wfEvent, [System.StringComparison]::OrdinalIgnoreCase)) { diff --git a/tests/Invoke-IdlePlan.Tests.ps1 b/tests/Invoke-IdlePlan.Tests.ps1 index 485b6160..2402efc0 100644 --- a/tests/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Tests.ps1 @@ -68,6 +68,74 @@ BeforeAll { Error = 'Boom' } } + + function global:Invoke-IdleTestAcquireAuthSessionStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $with = $Step.With + $name = $null + $options = $null + + if ($with -is [System.Collections.IDictionary]) { + if ($with.Contains('Name')) { + $name = [string]$with['Name'] + } + + if ($with.Contains('Options')) { + $options = $with['Options'] + } + } + else { + if ($null -ne $with -and $with.PSObject.Properties.Name -contains 'Name') { + $name = [string]$with.Name + } + + if ($null -ne $with -and $with.PSObject.Properties.Name -contains 'Options') { + $options = $with.Options + } + } + + $session = $Context.AcquireAuthSession($name, $options) + + if ($null -eq $session) { + throw [System.InvalidOperationException]::new('AcquireAuthSession returned null.') + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } + + # Legacy step handler without Context parameter for backwards compatibility testing + function global:Invoke-IdleTestLegacyStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } } AfterAll { @@ -75,6 +143,8 @@ AfterAll { Remove-Item -Path 'Function:\Invoke-IdleTestNoopStep' -ErrorAction SilentlyContinue Remove-Item -Path 'Function:\Invoke-IdleTestEmitStep' -ErrorAction SilentlyContinue Remove-Item -Path 'Function:\Invoke-IdleTestFailStep' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\Invoke-IdleTestAcquireAuthSessionStep' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\Invoke-IdleTestLegacyStep' -ErrorAction SilentlyContinue } Describe 'Invoke-IdlePlan' { @@ -433,4 +503,243 @@ Describe 'Invoke-IdlePlan' { } { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*ScriptBlocks are not allowed*' - }} \ No newline at end of file + } + + It 'throws when AuthSessionBroker is missing (AuthSession acquisition)' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + CorrelationId = 'test-corr' + Steps = @( + @{ + Name = 'Acquire' + Type = 'IdLE.Step.AcquireAuthSession' + With = @{ + Name = 'Demo' + Options = @{} + } + } + ) + } + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' + } + } + + { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*AuthSessionBroker*' + } + + It 'does not require AuthSessionBroker when AcquireAuthSession steps are NotApplicable' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + CorrelationId = 'test-corr' + Steps = @( + @{ + Name = 'ConditionalAcquire' + Type = 'IdLE.Step.AcquireAuthSession' + Status = 'NotApplicable' + With = @{ + Name = 'Demo' + Options = @{} + } + } + @{ + Name = 'SomeOtherStep' + Type = 'IdLE.Step.Noop' + With = @{} + } + ) + } + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' + 'IdLE.Step.Noop' = 'Invoke-IdleTestNoopStep' + } + } + + # Should not throw because the AcquireAuthSession step is NotApplicable + { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Not -Throw + } + + It 'normalizes null options and enriches CorrelationId when acquiring an auth session' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + CorrelationId = 'test-corr' + Actor = 'test-actor' + Steps = @( + @{ + Name = 'Acquire' + Type = 'IdLE.Step.AcquireAuthSession' + With = @{ + Name = 'Demo' + Options = $null + } + } + ) + } + + $callLog = [pscustomobject]@{ + CallCount = 0 + Name = $null + Options = $null + } + + $broker = [pscustomobject]@{} + $acquireMethod = { + param($Name, $Options) + $callLog.CallCount++ + $callLog.Name = $Name + $callLog.Options = $Options + + return [pscustomobject]@{ + PSTypeName = 'IdLE.AuthSession' + Kind = 'Test' + } + }.GetNewClosure() + $null = Add-Member -InputObject $broker -MemberType ScriptMethod -Name 'AcquireAuthSession' -Value $acquireMethod -Force + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' + } + AuthSessionBroker = $broker + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + $callLog.CallCount | Should -Be 1 + $callLog.Name | Should -Be 'Demo' + + $callLog.Options | Should -BeOfType 'hashtable' + $callLog.Options.ContainsKey('CorrelationId') | Should -BeTrue + $callLog.Options['CorrelationId'] | Should -Be 'test-corr' + $callLog.Options.ContainsKey('Actor') | Should -BeTrue + $callLog.Options['Actor'] | Should -Be 'test-actor' + } + + It 'rejects ScriptBlocks in auth session options (security)' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + CorrelationId = 'test-corr' + Steps = @( + @{ + Name = 'Acquire' + Type = 'IdLE.Step.AcquireAuthSession' + With = @{ + Name = 'Demo' + Options = @{ + Nested = @{ + Bad = { 'do-not-allow' } + } + } + } + } + ) + } + + $broker = [pscustomobject]@{} + $acquireMethod = { + param($Name, $Options) + return [pscustomobject]@{ + PSTypeName = 'IdLE.AuthSession' + Kind = 'Test' + } + } + $null = Add-Member -InputObject $broker -MemberType ScriptMethod -Name 'AcquireAuthSession' -Value $acquireMethod -Force + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' + } + AuthSessionBroker = $broker + } + + { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*auth session options*' + } + + It 'calls the AuthSessionBroker and returns a completed step result (AuthSession acquisition)' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + CorrelationId = 'test-corr' + Steps = @( + @{ + Name = 'Acquire' + Type = 'IdLE.Step.AcquireAuthSession' + With = @{ + Name = 'Demo' + Options = @{ + Mode = 'Auto' + CacheKey = 'unit-test' + } + } + } + ) + } + + $callLog = [pscustomobject]@{ + CallCount = 0 + Name = $null + Options = $null + } + + $broker = [pscustomobject]@{} + $acquireMethod = { + param($Name, $Options) + $callLog.CallCount++ + $callLog.Name = $Name + $callLog.Options = $Options + + return [pscustomobject]@{ + PSTypeName = 'IdLE.AuthSession' + Kind = 'Test' + } + }.GetNewClosure() + $null = Add-Member -InputObject $broker -MemberType ScriptMethod -Name 'AcquireAuthSession' -Value $acquireMethod -Force + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.AcquireAuthSession' = 'Invoke-IdleTestAcquireAuthSessionStep' + } + AuthSessionBroker = $broker + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + $callLog.CallCount | Should -Be 1 + $callLog.Name | Should -Be 'Demo' + $callLog.Options['Mode'] | Should -Be 'Auto' + $callLog.Options['CacheKey'] | Should -Be 'unit-test' + } + + It 'supports step handlers without Context parameter (backwards compatibility)' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'legacy.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Legacy' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'LegacyStep'; Type = 'IdLE.Step.Legacy' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Legacy' = 'Invoke-IdleTestLegacyStep' + } + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' + $result.Steps[0].Name | Should -Be 'LegacyStep' + } +} diff --git a/tests/New-IdleReleaseArtifact.Tests.ps1 b/tests/New-IdleReleaseArtifact.Tests.ps1 index fe1462af..c7c40e8f 100644 --- a/tests/New-IdleReleaseArtifact.Tests.ps1 +++ b/tests/New-IdleReleaseArtifact.Tests.ps1 @@ -51,7 +51,7 @@ Describe 'New-IdleReleaseArtifact.ps1' { Where-Object { $_ -like ' - *' } | ForEach-Object { $_.Substring(3) } - return ,$paths + return , $paths } } diff --git a/tests/Redaction.Boundaries.Tests.ps1 b/tests/Redaction.Boundaries.Tests.ps1 index cec956c4..45018d47 100644 --- a/tests/Redaction.Boundaries.Tests.ps1 +++ b/tests/Redaction.Boundaries.Tests.ps1 @@ -134,5 +134,52 @@ Describe 'Redaction at output boundaries (events, exports, execution results)' { $result.Providers.Directory.token | Should -Be '[REDACTED]' $result.Providers.Mail.apiKey | Should -Be '[REDACTED]' } + + It 'redacts AuthSessionBroker secrets and does not leak broker methods in the returned execution result (Providers surface)' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + Request = [pscustomobject]@{ + PSTypeName = 'IdLE.LifecycleRequest' + Type = 'Joiner' + CorrelationId = 'corr-003' + Actor = 'tester' + } + Steps = @() + } + + $broker = [pscustomobject]@{ + PSTypeName = 'Tests.AuthSessionBroker' + token = 'abc123' + note = 'ok' + } + + $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param([Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][hashtable] $Options) + return [pscustomobject]@{ + PSTypeName = 'IdLE.AuthSession' + Kind = 'Test' + Name = $Name + } + } -Force + + $providers = @{ + Directory = @{ + clientSecret = 'TopSecret' + } + AuthSessionBroker = $broker + } + + $result = Invoke-IdlePlanObject -Plan $plan -Providers $providers -EventSink $null + + # Original broker must remain unchanged + $providers.AuthSessionBroker.token | Should -Be 'abc123' + $providers.AuthSessionBroker.note | Should -Be 'ok' + $providers.AuthSessionBroker.PSObject.Methods.Name | Should -Contain 'AcquireAuthSession' + + # Result must be redacted (token) and must not include broker methods + $result.Providers.AuthSessionBroker.token | Should -Be '[REDACTED]' + $result.Providers.AuthSessionBroker.note | Should -Be 'ok' + $result.Providers.AuthSessionBroker.PSObject.Methods.Name | Should -Not -Contain 'AcquireAuthSession' + } } } diff --git a/tools/Generate-IdleCmdletReference.ps1 b/tools/Generate-IdleCmdletReference.ps1 index 2a463e36..e1d8a0c7 100644 --- a/tools/Generate-IdleCmdletReference.ps1 +++ b/tools/Generate-IdleCmdletReference.ps1 @@ -168,8 +168,6 @@ function New-IdleCmdletIndexMarkdown { New-Item -Path $indexDir -ItemType Directory -Force | Out-Null } - $timestampUtc = [DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss "UTC"') - $lines = New-Object System.Collections.Generic.List[string] $lines.Add('# Cmdlet Reference') $lines.Add('') diff --git a/tools/Generate-IdleStepReference.ps1 b/tools/Generate-IdleStepReference.ps1 index b8d28443..a58ccbfb 100644 --- a/tools/Generate-IdleStepReference.ps1 +++ b/tools/Generate-IdleStepReference.ps1 @@ -310,8 +310,6 @@ if (-not $stepCommands) { throw "No step commands found. Ensure step modules are included in -StepModules (currently: $($StepModules -join ', '))." } -$timestampUtc = [DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss "UTC"') - $header = @( '# Step Catalog' '' diff --git a/tools/New-IdleModulePackage.ps1 b/tools/New-IdleModulePackage.ps1 index 6f8a4383..73f7d661 100644 --- a/tools/New-IdleModulePackage.ps1 +++ b/tools/New-IdleModulePackage.ps1 @@ -127,7 +127,7 @@ function Get-IdleNestedModuleEntryPaths { ".\Modules\$n\$n.psd1" } - return ,$paths + return , $paths } function Set-IdleNestedModulesInManifest { diff --git a/tools/New-IdleReleaseArtifact.ps1 b/tools/New-IdleReleaseArtifact.ps1 index dc5d90a9..952b610b 100644 --- a/tools/New-IdleReleaseArtifact.ps1 +++ b/tools/New-IdleReleaseArtifact.ps1 @@ -163,7 +163,7 @@ function Get-IdleReleaseFileList { Where-Object { -not (Test-IdlePathExcluded -RelativePath $_.RelativePath) } | Sort-Object SortKey - return ,($sorted.FileInfo) + return , ($sorted.FileInfo) } function ConvertTo-IdleSafeFileName {