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
43 changes: 40 additions & 3 deletions docs/reference/specs/plan-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,13 @@ The request object captures *why* a plan was created, independent of *how* it wi
"identityKeys": {
"userId": "jdoe"
},
"desiredState": {
"intent": {
"department": "IT"
},
"context": {
"Identity": {
"ObjectId": "abc-123"
}
}
}
}
Expand All @@ -108,6 +113,14 @@ The request object captures *why* a plan was created, independent of *how* it wi
| actor | Originator of the request (system or human), if available |
| input | Business intent payload (data-only) |

### input sub-fields (IdLE-native requests)

| Field | Description |
| ------ | ------------- |
| identityKeys | System-neutral identity lookup keys (e.g. EmployeeId, UPN) |
| intent | Caller-provided action inputs (attributes, entitlements, operator flags) |
| context | Read-only associated context provided by the host or resolvers (e.g. identity snapshots, device hints) |

### Rules

- The `request` object represents **business intent**, not execution details.
Expand All @@ -119,11 +132,35 @@ The request object captures *why* a plan was created, independent of *how* it wi
- no executable expressions
- no runtime handles
- For **IdLE-native lifecycle requests**, `input` SHOULD contain:
- `identityKeys` – identifiers of the target identity
- `desiredState` – intended target state
- `identityKeys` – system-neutral identity lookup keys
- `intent` – caller-provided action inputs
- `context` – read-only associated context
- The standard `New-IdleRequest` / `New-IdleRequestObject` factory guarantees all three fields
are present (normalised to empty objects when not provided by the caller).
- Hosts MAY include additional fields in `input`.
- The request payload is exported for **audit, approval, and traceability purposes** and MUST remain stable once the plan is created.

### Safety rules

The export pipeline enforces the following safety rules before writing `input` to the artifact:

**Secret prevention (redaction)**
Fields with names that match known secret keys (e.g. `password`, `token`, `secret`, `apiKey`,
`clientSecret`, `accessToken`, `refreshToken`, `privateKey`, `credential`) are replaced with
`[REDACTED]` before the artifact is written.
This applies at all nesting depths.
`PSCredential` and `SecureString` values are redacted regardless of key name.

**Executable / unsafe type prevention**
`ScriptBlock` objects are replaced with `[REDACTED]` at the export boundary.
Non-serializable objects are converted to their string representation.

**Size limits**
Each of `identityKeys`, `intent`, and `context` is individually bounded to **64 KB** of
serialized UTF-8 JSON. Fields exceeding this limit are replaced with the deterministic marker
`[TRUNCATED - N bytes]`, where N is the pre-truncation byte count.
This bound prevents unbounded snapshot artifacts while keeping the marker auditable.

## Plan Object

```json
Expand Down
47 changes: 47 additions & 0 deletions src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,33 @@ function ConvertTo-IdlePlanExportObject {
return $null
}

# Maximum UTF-8 byte count for a single snapshot field (identityKeys, intent, context).
# Fields serializing beyond this limit are replaced with a deterministic truncation marker
# to prevent unbounded snapshot artifacts. 64 KB is a conservative bound per field.
$snapshotFieldSizeLimit = 65536

function Limit-IdleSnapshotField {
# Returns the value unchanged if its serialized UTF-8 size is within $snapshotFieldSizeLimit.
# Returns a '[TRUNCATED - N bytes]' marker string when the limit is exceeded.
[CmdletBinding()]
param(
[Parameter()]
[AllowNull()]
[object] $Value
)

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

$serialized = $Value | ConvertTo-Json -Depth 20 -Compress
$byteCount = [System.Text.Encoding]::UTF8.GetByteCount($serialized)

if ($byteCount -gt $snapshotFieldSizeLimit) {
return "[TRUNCATED - $byteCount bytes]"
}

return $Value
}

# ---- Engine block --------------------------------------------------------
$engineMap = New-OrderedMap
$engineMap.name = 'IdLE'
Expand Down Expand Up @@ -119,6 +146,26 @@ function ConvertTo-IdlePlanExportObject {
$null
}

# Enforce per-field size limits on the redacted snapshot fields.
# identityKeys, intent and context are each bounded to $snapshotFieldSizeLimit bytes (serialized UTF-8).
# Fields that exceed the limit are replaced with a deterministic truncation marker.
# Handle both IDictionary (hashtable / ordered) and PSCustomObject shapes.
if ($null -ne $redactedRequestInput) {
foreach ($fieldName in @('identityKeys', 'intent', 'context')) {
if ($redactedRequestInput -is [System.Collections.IDictionary]) {
if ($redactedRequestInput.Contains($fieldName)) {
$redactedRequestInput[$fieldName] = Limit-IdleSnapshotField -Value $redactedRequestInput[$fieldName]
}
}
else {
$prop = $redactedRequestInput.PSObject.Properties[$fieldName]
if ($null -ne $prop) {
$prop.Value = Limit-IdleSnapshotField -Value $prop.Value
}
}
}
}

$requestMap = New-OrderedMap
$requestMap.type = $requestType
$requestMap.correlationId = $correlationId
Expand Down
198 changes: 198 additions & 0 deletions tests/Core/RequestSnapshot.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
Set-StrictMode -Version Latest

BeforeDiscovery {
. (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1')
Import-IdleTestModule
}

Describe 'RequestSnapshot - plan export contract' {

InModuleScope 'IdLE.Core' {

# ---------------------------------------------------------------------------
# Helper: build a minimal plan object shaped like the output of New-IdlePlanObject
# ---------------------------------------------------------------------------
BeforeAll {
function New-TestPlan {
param(
[hashtable] $Intent = @{},
[hashtable] $Context = @{},
[hashtable] $IdentityKeys = @{ userId = 'jdoe' }
)

[pscustomobject]@{
PSTypeName = 'IdLE.Plan'
Request = [pscustomobject]@{
PSTypeName = 'IdLE.LifecycleRequestSnapshot'
LifecycleEvent = 'Joiner'
CorrelationId = '11111111-1111-1111-1111-111111111111'
Actor = $null
IdentityKeys = $IdentityKeys
Intent = $Intent
Context = $Context
}
Steps = @()
OnFailureSteps = @()
}
}
}

Context 'Intent and Context are included in request.input' {
It 'exports Request.Intent into request.input.intent' {
$plan = New-TestPlan -Intent @{ department = 'IT'; title = 'Engineer' }

$json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json

$json.request.input.intent | Should -Not -BeNullOrEmpty
$json.request.input.intent.department | Should -Be 'IT'
$json.request.input.intent.title | Should -Be 'Engineer'
}

It 'exports Request.Context into request.input.context' {
$plan = New-TestPlan -Context @{ Identity = @{ ObjectId = 'abc-123' } }

$json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json

$json.request.input.context | Should -Not -BeNullOrEmpty
$json.request.input.context.Identity.ObjectId | Should -Be 'abc-123'
}

It 'exports an empty context as an empty object (not null)' {
$plan = New-TestPlan -Context @{}

$json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json

$json.request.input.context | Should -Not -Be $null
}

It 'exports IdentityKeys alongside Intent and Context' {
$plan = New-TestPlan `
-IdentityKeys @{ userId = 'jdoe'; employeeId = '42' } `
-Intent @{ department = 'IT' } `
-Context @{ source = 'HR' }

$json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json

$json.request.input.identityKeys.userId | Should -Be 'jdoe'
$json.request.input.identityKeys.employeeId | Should -Be '42'
$json.request.input.intent.department | Should -Be 'IT'
$json.request.input.context.source | Should -Be 'HR'
}
}

Context 'ScriptBlock safety' {
It 'redacts a ScriptBlock value inside Intent at the export boundary' {
# ScriptBlocks are rejected at request-creation time via Assert-IdleNoScriptBlock.
# Here we verify the export boundary independently also redacts them,
# guarding against any path that bypasses request-creation validation.
$plan = [pscustomobject]@{
PSTypeName = 'IdLE.Plan'
Request = [pscustomobject]@{
PSTypeName = 'IdLE.LifecycleRequestSnapshot'
LifecycleEvent = 'Joiner'
CorrelationId = 'corr-sb-01'
Actor = $null
IdentityKeys = @{ userId = 'test' }
Intent = @{ action = { Write-Output 'bad' } }
Context = @{}
}
Steps = @()
OnFailureSteps = @()
}

$json = Export-IdlePlanObject -Plan $plan

$json | Should -Not -Match 'bad'
$json | Should -Match '\[REDACTED\]'
}

It 'does not emit ScriptBlock text in the exported JSON for Context' {
$plan = [pscustomobject]@{
PSTypeName = 'IdLE.Plan'
Request = [pscustomobject]@{
PSTypeName = 'IdLE.LifecycleRequestSnapshot'
LifecycleEvent = 'Joiner'
CorrelationId = 'corr-sb-02'
Actor = $null
IdentityKeys = @{ userId = 'test' }
Intent = @{}
Context = @{ compute = { 1 + 1 } }
}
Steps = @()
OnFailureSteps = @()
}

$json = Export-IdlePlanObject -Plan $plan

$json | Should -Not -Match '1 \+ 1'
$json | Should -Match '\[REDACTED\]'
}
}

Context 'Size limits' {
It 'truncates an oversized Intent field with a deterministic marker' {
# Build an Intent whose serialized JSON exceeds 64 KB (65536 bytes).
$largeString = 'x' * 70000
$plan = New-TestPlan -Intent @{ bigField = $largeString }

$json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json

$json.request.input.intent | Should -BeLike '*TRUNCATED*bytes*'
}

It 'truncates an oversized Context field with a deterministic marker' {
$largeString = 'x' * 70000
$plan = New-TestPlan -Context @{ bigField = $largeString }

$json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json

$json.request.input.context | Should -BeLike '*TRUNCATED*bytes*'
}

It 'does not truncate Intent that is within the 64 KB limit' {
$plan = New-TestPlan -Intent @{ department = 'IT' }

$json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json

$json.request.input.intent | Should -Not -BeLike '*TRUNCATED*'
$json.request.input.intent.department | Should -Be 'IT'
}

It 'truncation marker includes the original byte count' {
$largeString = 'x' * 70000
$plan = New-TestPlan -Intent @{ bigField = $largeString }

$json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json

$marker = $json.request.input.intent
$marker | Should -Match '^\[TRUNCATED - \d+ bytes\]$'
}

It 'enforces size limits when request.input is a PSCustomObject (not a hashtable)' {
# Verifies the fix for the path where Copy-IdleRedactedObject returns a PSCustomObject
# (e.g. when the caller provides Request.Input as an already-built PSCustomObject).
$largeString = 'x' * 70000
$plan = [pscustomobject]@{
PSTypeName = 'IdLE.Plan'
Request = [pscustomobject]@{
PSTypeName = 'IdLE.LifecycleRequest'
Type = 'Joiner'
CorrelationId = 'corr-pscobj-01'
Actor = $null
Input = [pscustomobject]@{
identityKeys = [pscustomobject]@{ userId = 'jdoe' }
intent = [pscustomobject]@{ bigField = $largeString }
context = [pscustomobject]@{}
}
}
Steps = @()
OnFailureSteps = @()
}

$json = Export-IdlePlanObject -Plan $plan | ConvertFrom-Json

$json.request.input.intent | Should -BeLike '*TRUNCATED*bytes*'
}
}
}
}