Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
os: [ubuntu-latest, windows-latest]

steps:
- uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions docs/_sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@

### Advanced
- [Architecture](advanced/architecture.md)
- [Security](advanced/security.md)
- [Extensibility](advanced/extensibility.md)
- [Testing](advanced/testing.md)
6 changes: 6 additions & 0 deletions docs/advanced/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,9 @@ No deep merge: replace-at-path semantics only.
- Providers integrate target systems

See: [Extensibility](extensibility.md).

## Trust boundaries

IdLE treats workflow configuration and lifecycle requests as **untrusted data** and validates that they contain no ScriptBlocks.

Host-provided extension points (step registry, providers, external event sinks) are **trusted inputs** and are validated for safe shapes (object contracts). For details, see `advanced/security.md`.
9 changes: 9 additions & 0 deletions docs/advanced/extensibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,12 @@ Do not add:
- UI or web server dependencies

Those belong in a host application.

## Register step handlers

Steps are executed via a host-provided step registry.

- Workflows reference steps by `Type` (identifier).
- The host maps this identifier to a **function name** (string) in the step registry.

ScriptBlock handlers are intentionally not supported as a secure default.
50 changes: 50 additions & 0 deletions docs/advanced/security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Security and Trust Boundaries

IdLE is designed to execute *data-driven* lifecycle workflows in a deterministic way.

Because IdLE is an orchestration engine, it must be very explicit about **what is trusted** and **what is untrusted**.

## Trust boundaries

### Untrusted inputs (data-only)

These inputs may come from users, CI pipelines, or external systems and **must be treated as untrusted**:

- Workflow definitions (PSD1)
- Lifecycle requests (input objects)
- Step parameters (`With`, `When`)

**Rule:** Untrusted inputs must be *data-only*. They must not contain ScriptBlocks or other executable objects.

IdLE enforces this by rejecting ScriptBlocks when importing workflow definitions and by validating inputs at runtime.

### Trusted extension points (code)

These inputs are provided by the host and are **privileged** because they determine what code is executed:

- Step registry (maps `Step.Type` to a handler function name)
- Provider modules / provider objects (system-specific adapters)
- External event sinks (streaming events)

**Rule:** Only trusted code should populate these extension points.

## Secure defaults

IdLE applies secure defaults to reduce accidental code execution:

- Workflow configuration is loaded as data and ScriptBlocks are rejected.
- Event streaming uses an object-based contract (`WriteEvent(event)`); ScriptBlock event sinks are rejected.
- Step registry handlers must be function names (strings); ScriptBlock handlers are rejected.

## Guidance for hosts

- Keep workflow files in a protected location and review them like code (even though they are data-only).
- Load step and provider modules explicitly before execution.
- Treat the step registry as privileged configuration and do not let workflow authors change it.
- If you stream events, implement a small sink object with a `WriteEvent(event)` method and keep it side-effect free.

## Guidance for step authors

- Use providers for system operations; do not embed authentication logic inside steps.
- Emit events using `Context.EventSink.WriteEvent(Type, Message, StepName, Data)`.
- Avoid global state. Steps should be idempotent whenever possible.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ for identity and account processes (Joiner / Mover / Leaver) built for **PowerSh
## Advanced

- [Architecture](advanced/architecture.md)
- [Security](advanced/security.md)
- [Extensibility](advanced/extensibility.md)
- [Testing](advanced/testing.md)

Expand Down
7 changes: 7 additions & 0 deletions docs/usage/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,10 @@ Unit tests must not call live systems.

- [Testing](../advanced/testing.md)
- [Architecture](../advanced/architecture.md)

## Trust and security

Providers and the step registry are host-controlled extension points and should be treated as trusted code.
Workflows and lifecycle requests are data-only and must not contain executable objects.

For details, see `docs/advanced/security.md`.
6 changes: 6 additions & 0 deletions docs/usage/steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,9 @@ IdLE uses a fail-fast execution model in V1:
- [Workflows](workflows.md)
- [Providers](providers.md)
- [Architecture](../advanced/architecture.md)

## Security notes

- Steps emit events via `Context.EventSink.WriteEvent(...)`.
- Step handlers are referenced by function name (string) in the step registry.
- ScriptBlock handlers are not supported as a secure default.
3 changes: 2 additions & 1 deletion examples/run-demo.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ $providers = @{
$result = Invoke-IdlePlan -Plan $plan -Providers $providers

Write-DemoHeader "IdLE Demo – Plan Execution"
Write-ResultSummary -Result $result

Write-Host ""
Write-DemoHeader "Step Results"
Expand All @@ -113,3 +112,5 @@ Write-DemoHeader "Event Stream"
$result.Events |
ForEach-Object { Format-EventRow $_ } |
Format-Table Time, Type, Step, Message -AutoSize

Write-ResultSummary -Result $result
8 changes: 6 additions & 2 deletions src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ function Assert-IdleNoScriptBlock {
if ($null -eq $InputObject) { return }

if ($InputObject -is [scriptblock]) {
throw [System.ArgumentException]::new("ScriptBlocks are not allowed in request data. Found at: $Path", $Path)
throw [System.ArgumentException]::new(
"ScriptBlocks are not allowed in request data. Found at: $Path",
$Path
)
}

# Hashtable / Dictionary
Expand All @@ -41,7 +44,8 @@ function Assert-IdleNoScriptBlock {
if ($InputObject -is [pscustomobject]) {
foreach ($p in $InputObject.PSObject.Properties) {
if ($p.MemberType -eq 'NoteProperty') {
Assert-IdleNoScriptBlock -InputObject $p.InputObject -Path "$Path.$($p.Name)"
# PSPropertyInfo does not expose "InputObject" here; the value is in .Value.
Assert-IdleNoScriptBlock -InputObject $p.Value -Path "$Path.$($p.Name)"
}
}
}
Expand Down
73 changes: 45 additions & 28 deletions src/IdLE.Core/Private/Get-IdleStepRegistry.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -6,49 +6,66 @@ function Get-IdleStepRegistry {
[object] $Providers
)

# Registry maps workflow Step.Type -> handler
# Handler can be:
# - [string] : PowerShell function name
# - [scriptblock] : executable handler (useful for tests / hosts)
# Registry maps workflow Step.Type -> handler function name (string).
#
# Trust boundary:
# - The registry is a host-provided extension point. It is not loaded from workflow configuration.
# - Workflows are data-only and must not contain executable code.
#
# Security / secure defaults:
# - Only string handlers (function names) are supported.
# - ScriptBlock handlers are intentionally rejected to avoid arbitrary code execution.

$registry = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase)

# 1) Copy host-provided StepRegistry (optional)
# We support two shapes for compatibility:
# - StepRegistry['Type'] = 'FunctionName' | { scriptblock }
# - StepRegistry['Type'] = @{ Handler = 'FunctionName' } (legacy/demo style)
if ($null -ne $Providers) {

$source = $null
# - Providers.StepRegistry (hashtable)
# - Providers['StepRegistry'] (hashtable)
$hostRegistry = $null

if ($Providers -is [System.Collections.IDictionary]) {
if ($Providers.Contains('StepRegistry')) {
$source = $Providers['StepRegistry']
}
if ($null -ne $Providers) {
if ($Providers -is [hashtable] -and $Providers.ContainsKey('StepRegistry')) {
$hostRegistry = $Providers['StepRegistry']
}
else {
$prop = $Providers.PSObject.Properties['StepRegistry']
if ($null -ne $prop) {
$source = $prop.Value
}
elseif ($Providers.PSObject.Properties.Name -contains 'StepRegistry') {
$hostRegistry = $Providers.StepRegistry
}
}

if ($null -ne $hostRegistry) {
if ($hostRegistry -isnot [hashtable]) {
throw [System.ArgumentException]::new('Providers.StepRegistry must be a hashtable that maps Step.Type to a function name (string).', 'Providers')
}

if ($null -ne $source -and ($source -is [System.Collections.IDictionary])) {
foreach ($k in @($source.Keys)) {
foreach ($key in $hostRegistry.Keys) {
if ($null -eq $key -or [string]::IsNullOrWhiteSpace([string]$key)) {
throw [System.ArgumentException]::new('Providers.StepRegistry contains an empty step type key.', 'Providers')
}

$v = $source[$k]
$value = $hostRegistry[$key]

# Allow legacy shape: @{ Handler = 'Invoke-...' }
if ($v -is [hashtable] -and $v.ContainsKey('Handler')) {
$v = $v['Handler']
}
if ($value -is [scriptblock]) {
throw [System.ArgumentException]::new(
"Providers.StepRegistry entry for step type '$key' is a ScriptBlock. ScriptBlock handlers are not allowed. Provide a function name (string) instead.",
'Providers'
)
}

$registry[[string]$k] = $v
if ($value -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$value)) {
throw [System.ArgumentException]::new(
"Providers.StepRegistry entry for step type '$key' must be a non-empty string (function name).",
'Providers'
)
}

$registry[[string]$key] = ([string]$value).Trim()
}
}

# 2) Built-in defaults (only if commands are available)
# Do not overwrite host-provided entries.
# 2) Register built-in steps if available.
#
# These are optional modules (Steps.Common, etc.). If they are not loaded, the registry entry is not added.
if (-not $registry.ContainsKey('IdLE.Step.EmitEvent')) {
$cmd = Get-Command -Name 'Invoke-IdleStepEmitEvent' -ErrorAction SilentlyContinue
if ($null -ne $cmd) {
Expand Down
36 changes: 23 additions & 13 deletions src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,30 @@ function Resolve-IdleStepHandler {
[hashtable] $Registry
)

# Registry maps StepType -> handler
# Handler can be:
# - [string] : PowerShell function name
# - [scriptblock] : executable handler (useful for tests / hosts)
# 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]
if ($null -eq $handler) {
return $null
}

if ($handler -is [scriptblock]) {
return $handler
}

if ($handler -is [string]) {
$fn = [string]$handler
$fn = $handler.Trim()
if ([string]::IsNullOrWhiteSpace($fn)) {
return $null
}

# 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
Expand All @@ -42,6 +42,16 @@ function Resolve-IdleStepHandler {
return $cmd.Name
}

if ($handler -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'
)
}

# Any other type is invalid configuration.
throw [System.ArgumentException]::new("Invalid step handler type for '$StepType'. Allowed: string (function name) or scriptblock.", 'Registry')
throw [System.ArgumentException]::new(
"Invalid step handler for type '$StepType'. Allowed: string (function name).",
'Registry'
)
}
19 changes: 8 additions & 11 deletions src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ function Invoke-IdlePlanObject {
}
}

# Secure default: treat host-provided extension points as privileged inputs.
# The engine rejects ScriptBlocks in the plan and providers to avoid accidental code execution.
Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan'
Assert-IdleNoScriptBlock -InputObject $Providers -Path 'Providers'

$events = [System.Collections.Generic.List[object]]::new()

$corr = [string]$Plan.CorrelationId
Expand Down Expand Up @@ -121,22 +126,14 @@ function Invoke-IdlePlanObject {

try {
# Resolve implementation handler for this step type.
# Handler can be:
# - [scriptblock] : invoked as & $handler $context $step
# - [string] : function name invoked as & $handler -Context $context -Step $step
# Handler must be a function name (string).
$handler = Resolve-IdleStepHandler -StepType $stepType -Registry $registry
if ($null -eq $handler) {
throw [System.InvalidOperationException]::new("Step type '$stepType' is not registered.")
}

# Invoke the step plugin depending on handler type.
if ($handler -is [scriptblock]) {
$stepResult = & $handler $context $step
}
else {
# handler is a function name (string)
$stepResult = & $handler -Context $context -Step $step
}
# Invoke the step plugin.
$stepResult = & $handler -Context $context -Step $step

$stepResults += $stepResult

Expand Down
Loading