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
22 changes: 22 additions & 0 deletions docs/extend/steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,28 @@ so hosts do not need `Providers.StepRegistry` for step pack steps:
Modules named `IdLE.Steps.*` that export `Get-IdleStepMetadataCatalog` but carry no marker are still
discovered. This fallback is deprecated.

### Provenance

Plans and execution results record which module provided each step handler and each provider
(e.g. for audits: "which code disabled this account?"):

```powershell
$plan.Steps[0].Source # @{ Module = 'IdLE.Steps.Common'; Version = '1.0.0' }
$plan.ProviderSources['Identity']
$result.Steps[0].Source # handler that actually ran
$result.ProviderSources['Identity']
```

- Handlers without a module (e.g. host functions registered via `Providers.StepRegistry`) and handlers that
are not resolvable at planning time are recorded with `Module = $null` and `Version = $null`.
- `Source` records names and versions as loaded; it is not an integrity check of the module files.
- In execution results, `Source` is `$null` for steps whose handler did not run (`NotApplicable`,
`Blocked`, `PreconditionSkipped`).
- `ProviderSources` lists only provider objects (entries exposing `GetCapabilities()`). The module of a
provider is the module that defines its `GetCapabilities` method.
- The plan export contains the same information (`steps[].source`, `plan.providers`); see the
[plan export specification](../reference/specs/plan-export.md).

Metadata exists to make steps:

- understandable
Expand Down
34 changes: 30 additions & 4 deletions docs/reference/specs/plan-export.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
# IdLE Plan Export Specification

Schema-Version: 1.0
Schema-Version: 1.1

## Overview

This document specifies the canonical, machine-readable representation of an IdLE plan export.
It defines structure, required fields, and normative rules for producers and consumers.

The exported plan is a **contract artifact**, not an internal object dump.

## Scope

This specification focuses on format and semantics.
Operational guidance is documented separately in the User Guide.

## Format

The export is a JSON document encoded in UTF-8.

---
Expand Down Expand Up @@ -47,7 +50,7 @@ The plan export MUST NOT contain:

```json
{
"schemaVersion": "1.0",
"schemaVersion": "1.1",
"engine": {
"name": "IdLE"
},
Expand Down Expand Up @@ -167,7 +170,14 @@ This bound prevents unbounded snapshot artifacts while keeping the marker audita
"plan": {
"id": "plan-001",
"mode": null,
"steps": []
"steps": [],
"providers": [
{
"alias": "Identity",
"module": "IdLE.Provider.AD",
"version": "1.0.0"
}
]
}
```

Expand All @@ -179,12 +189,20 @@ This bound prevents unbounded snapshot artifacts while keeping the marker audita
| createdAt | (Optional) ISO-8601 UTC timestamp |
| mode | Plan lifecycle state (`null` when not explicitly set on the plan object) |
| steps | Ordered list of step objects |
| providers | (Since 1.1) Provenance of each provider used for planning, ordered by alias. Only provider objects are listed; provider configuration is never exported. |

### providers entries

| Field | Description |
| ------ | ------------ |
| alias | Provider alias in the host's `Providers` map |
| module | Module that defines the provider, or `null` if unknown (e.g. a provider created in a host script) |
| version | Version of that module, or `null` |

---

## Step Object


Each step is an **atomic, independent unit**.

```json
Expand All @@ -202,6 +220,10 @@ Each step is an **atomic, independent unit**.
},
"expectedState": {
"MailboxExists": true
},
"source": {
"module": "IdLE.Steps.Mailbox",
"version": "1.0.0"
}
}
```
Expand All @@ -212,6 +234,10 @@ Each step is an **atomic, independent unit**.
- Steps are ordered and deterministic
- `stepType` is a logical identifier, not a module path
- `provider` is declarative, not an implementation reference
- `source` (since 1.1) records the module that provides the step handler at planning time
(`module`, `version`). `module` and `version` are `null` if the handler has no module or is
only registered at execution time. `source` is informational for audits and MUST NOT be used to select
the handler at execution time.

---

Expand Down
14 changes: 13 additions & 1 deletion docs/use/plan-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ A plan export typically includes:
- the planned step list in execution order
- step configuration after template resolution (as applicable)
- provider alias references (but **not** live provider objects)
- provenance: which module (and version) provides each step handler and provider
- capability information (if available)

:::info
Expand All @@ -56,7 +57,7 @@ Providers and authentication are always supplied by the host at execution time.

```json
{
"schemaVersion": "1.0",
"schemaVersion": "1.1",
"engine": {
"name": "IdLE"
},
Expand Down Expand Up @@ -91,9 +92,20 @@ Providers and authentication are always supplied by the host at execution time.
"mailboxType": "User"
},
"expectedState": null,
"source": {
"module": "IdLE.Steps.Mailbox",
"version": "1.0.0"
},
"warnings": []
}
],
"providers": [
{
"alias": "ExchangeOnline",
"module": "IdLE.Provider.ExchangeOnline",
"version": "1.0.0"
}
],
"warnings": []
},
"metadata": {
Expand Down
39 changes: 38 additions & 1 deletion src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,26 @@ function ConvertTo-IdlePlanExportObject {
return [ordered] @{}
}

# Maps an internal source record (@{ Module; Version }) to the export shape.
# Returns $null when no source was recorded (e.g. plans built by older engine versions).
function ConvertTo-IdleSourceExportMap {
[CmdletBinding()]
param(
[Parameter()]
[AllowNull()]
[object] $Source
)

if ($null -eq $Source) {
return $null
}

$map = [ordered] @{}
$map.module = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $Source -Names @('Module'))
$map.version = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $Source -Names @('Version'))
return $map
}

function Get-FirstPropertyValue {
[CmdletBinding()]
param(
Expand Down Expand Up @@ -275,6 +295,7 @@ function ConvertTo-IdlePlanExportObject {
$stepMap.condition = $conditionMap
$stepMap.inputs = $redactedInputs
$stepMap.expectedState = $redactedExpectedState
$stepMap.source = ConvertTo-IdleSourceExportMap -Source (Get-FirstPropertyValue -Object $step -Names @('Source'))

# Per-step planning warnings (e.g. unresolved precondition context paths).
$rawStepWarnings = Get-FirstPropertyValue -Object $step -Names @('Warnings', 'PlanningWarnings', 'StepWarnings')
Expand Down Expand Up @@ -314,10 +335,26 @@ function ConvertTo-IdlePlanExportObject {
$warningList += $warningMap
}

# ---- Provider sources (provenance) --------------------------------------
# Ordered by alias (as recorded at planning time); provider configuration is never exported.
$rawProviderSources = Get-FirstPropertyValue -Object $Plan -Names @('ProviderSources')
$providerList = @()
if ($rawProviderSources -is [System.Collections.IDictionary]) {
foreach ($alias in $rawProviderSources.Keys) {
$providerMap = New-OrderedMap
$providerMap.alias = [string]$alias
$sourceMap = ConvertTo-IdleSourceExportMap -Source $rawProviderSources[$alias]
$providerMap.module = $sourceMap.module
$providerMap.version = $sourceMap.version
$providerList += $providerMap
}
}

$planMap = New-OrderedMap
$planMap.id = $planId
$planMap.mode = $mode
$planMap.steps = $stepList
$planMap.providers = $providerList
$planMap.warnings = $warningList

# ---- Metadata block ------------------------------------------------------
Expand All @@ -328,7 +365,7 @@ function ConvertTo-IdlePlanExportObject {

# ---- Root ---------------------------------------------------------------
$root = New-OrderedMap
$root.schemaVersion = '1.0'
$root.schemaVersion = '1.1'
$root.engine = $engineMap
$root.request = $requestMap
$root.plan = $planMap
Expand Down
34 changes: 34 additions & 0 deletions src/IdLE.Core/Private/ConvertTo-IdleSourceInfo.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
function ConvertTo-IdleSourceInfo {
<#
.SYNOPSIS
Creates the data-only provenance record for a step handler or provider.

.DESCRIPTION
Returns @{ Module; Version } for the given module. If the module is unknown (e.g. a host-defined
function or provider object without a module), Module and Version are $null.

.PARAMETER Module
The module that defines the handler or provider, or $null if unknown.

.OUTPUTS
Ordered hashtable with keys Module, Version.
#>
[CmdletBinding()]
param(
[Parameter()]
[AllowNull()]
[System.Management.Automation.PSModuleInfo] $Module
)

if ($null -eq $Module) {
return [ordered]@{
Module = $null
Version = $null
}
}

return [ordered]@{
Module = $Module.Name
Version = $Module.Version.ToString()
}
}
60 changes: 60 additions & 0 deletions src/IdLE.Core/Private/Get-IdleProviderSources.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
function Get-IdleProviderSources {
<#
.SYNOPSIS
Returns the provenance of every provider in the Providers map (alias -> source record).

.DESCRIPTION
Providers are map entries that expose GetCapabilities(). Other entries (StepRegistry, StepMetadata,
AuthSessionBroker, placeholders) are not providers and are skipped.

The module of a provider is the module that defines its GetCapabilities script method. Provider
objects created outside a module (e.g. in a host script) have no module.

.PARAMETER Providers
The Providers map passed to planning or execution.

.OUTPUTS
Ordered hashtable mapping provider alias (sorted) to a source record (see ConvertTo-IdleSourceInfo).
#>
[CmdletBinding()]
param(
[Parameter()]
[AllowNull()]
[object] $Providers
)

$sources = [ordered]@{}

if ($null -eq $Providers) {
return $sources
}

$entries = @{}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve case-distinct provider aliases

If a host supplies a case-sensitive IDictionary with aliases that differ only by case, this @{} scratch map (and the ordered map above) merges them because PowerShell hashtable literals are case-insensitive (PowerShell case-sensitivity docs). ProviderSources can then omit an alias or report the wrong provider's module and version, making the audit provenance diverge from the providers available at execution; preserve keys in a case-sensitive representation throughout.

Useful? React with 👍 / 👎.

if ($Providers -is [System.Collections.IDictionary]) {
foreach ($key in $Providers.Keys) {
$entries[[string]$key] = $Providers[$key]
}
}
else {
foreach ($prop in $Providers.PSObject.Properties) {
$entries[$prop.Name] = $prop.Value
}
}

foreach ($alias in @($entries.Keys | Sort-Object)) {
$provider = $entries[$alias]
if ($null -eq $provider) {
continue
}

$method = $provider.PSObject.Methods['GetCapabilities']
if ($null -eq $method) {
continue
}

$module = if ($method -is [System.Management.Automation.PSScriptMethod]) { $method.Script.Module } else { $null }
$sources[$alias] = ConvertTo-IdleSourceInfo -Module $module
}

return $sources
}
Loading
Loading