diff --git a/docs/reference/specs/plan-export.md b/docs/reference/specs/plan-export.md index d7f11107..be4cde1b 100644 --- a/docs/reference/specs/plan-export.md +++ b/docs/reference/specs/plan-export.md @@ -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" + } } } } @@ -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. @@ -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 diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index f5b3639b..dc83bd33 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -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' @@ -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 diff --git a/tests/Core/RequestSnapshot.Tests.ps1 b/tests/Core/RequestSnapshot.Tests.ps1 new file mode 100644 index 00000000..618974ad --- /dev/null +++ b/tests/Core/RequestSnapshot.Tests.ps1 @@ -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*' + } + } + } +}