diff --git a/docs/use/workflows.md b/docs/use/workflows.md index e53ef74a..c32f96c0 100644 --- a/docs/use/workflows.md +++ b/docs/use/workflows.md @@ -67,6 +67,93 @@ Start with [Quick Start](quickstart.md). --- +## Template substitution + +Step configuration values (`With.*`) support `{{path}}` placeholders that are resolved against the +request during plan build (`New-IdlePlan`). Multiple placeholders may appear in a single value. + +```powershell +IdentityKey = '{{Request.IdentityKeys.sAMAccountName}}' +DisplayName = '{{Request.DesiredState.GivenName}}' +Message = 'User {{Request.DesiredState.DisplayName}} is joining.' +``` + +### Allowed roots + +For security, only these path roots are permitted: + +| Root | Description | +| ---- | ----------- | +| `Request.DesiredState.*` | Intended target state of the identity | +| `Request.IdentityKeys.*` | Identifiers of the target identity | +| `Request.Changes.*` | Explicit deltas (Mover events) | +| `Request.LifecycleEvent` | Lifecycle event type (e.g. `Joiner`) | +| `Request.CorrelationId` | Stable correlation identifier | +| `Request.Actor` | Originator of the request | +| `Request.Input.*` | Alias for `Request.DesiredState.*` when no `Input` property exists | + +### Pure vs. mixed placeholders + +A value containing **only** a single placeholder preserves the resolved type (bool, int, datetime, guid, string): + +```powershell +# Resolves to the actual [bool] value, not the string "True" +Enabled = '{{Request.DesiredState.IsEnabled}}' +``` + +A value with surrounding text always produces a **string**: + +```powershell +Message = 'Account for {{Request.DesiredState.DisplayName}} created.' +``` + +### Backslash and special characters + +Backslash (`\`) is a **literal character** in template strings and requires no escaping. +Windows-style paths and domain-qualified names work as-is: + +```powershell +# \ is kept as-is; only the placeholder is substituted +IdentityKey = 'DOMAIN\{{Request.IdentityKeys.sAMAccountName}}' +# → e.g. 'DOMAIN\jdoe' +``` + +### Escaping a literal `{{` + +To include a literal `{{` in the output, prefix it with `\`. The escape is applied whenever +`\{{` is **not** immediately followed by a valid allowed-root template path and `}}`: + +```powershell +# \{{ not followed by a valid path+}} → literal {{ in output +Value = 'Literal \{{ braces here' +# → 'Literal {{ braces here' + +# \{{ followed by an invalid/disallowed path → also escaped (literal {{ in output) +Value = '\{{Request.InvalidRoot}}' +# → '{{Request.InvalidRoot}}' +``` + +Summary of backslash behaviour: + +| Input | Result | +| ----- | ------ | +| `DOMAIN\{{Request.IdentityKeys.sAMAccountName}}` | `DOMAIN\jdoe` — `\` literal, valid template resolved | +| `Literal \{{ braces here` | `Literal {{ braces here` — escape applied | +| `\{{Request.InvalidRoot}}` | `{{Request.InvalidRoot}}` — invalid root, escape applied | +| `Literal \{{ and {{Request.Input.Name}}` | `Literal {{ and TestName` — escape + template | + +### Validation + +During plan build, IdLE validates every template value: + +- **Unbalanced braces** — mismatched `{{`/`}}` pairs throw a syntax error. +- **Invalid path** — paths must use dot-separated identifiers (letters, numbers, underscores). +- **Disallowed root** — paths outside the allowlist throw a security error. +- **Null or missing value** — if the resolved path does not exist, an error is thrown. +- **Non-scalar value** — resolving to a hashtable or array is not allowed. + +--- + ## Reference For full definitions and reference, see: diff --git a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 index 93a6e6c2..92ed6540 100644 --- a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 @@ -22,7 +22,10 @@ function Resolve-IdleTemplateString { - Request.Actor Escaping: - - \{{ → literal {{ (escape removed after resolution) + - \{{ → literal {{ (backslash escapes the opening braces when not followed by a valid allowed-root template path+}}) + - \{{path}} where path is a valid allowed-root path treats \ as a literal character and resolves the template normally + (e.g. DOMAIN\{{Request.IdentityKeys.sAMAccountName}} → DOMAIN\) + - \{{Request.InvalidRoot}} or \{{Request..Bad}} → escaped to literal {{...}} (invalid paths are never treated as templates) .PARAMETER Value The string value to resolve. If not a string, returns the value unchanged. @@ -64,10 +67,6 @@ function Resolve-IdleTemplateString { # Quick exit: no template markers present if ($stringValue -notlike '*{{*' -and $stringValue -notlike '*}}*') { - # Handle escaped braces with no actual templates - if ($stringValue -like '*\{{*') { - return $stringValue -replace '\\{{', '{{' - } return $stringValue } @@ -201,36 +200,54 @@ function Resolve-IdleTemplateString { } } + # Escape sequence normalization: \{{ (not followed by a valid allowed-root template path+}}) → literal {{. + # Use a Unicode Private Use Area character as placeholder so template matching does not see the + # escaped braces. This character is extremely unlikely to appear in real workflow configuration. + # The lookahead is intentionally restricted to the exact set of allowed roots so that sequences + # like \{{Request.Foo}} (invalid root) or \{{Request..Name}} (double dot) are still escaped to + # literal {{, rather than flowing into template parsing and failing with path/root errors. + $litOpenPlaceholder = [string][char]0xE001 + $backslashEscapePattern = '\\{{(?!Request\.(?:(?:Input|DesiredState|IdentityKeys|Changes)(?:\.[A-Za-z0-9_]+)*|LifecycleEvent|CorrelationId|Actor)}})' + $normalizedValue = if ($stringValue -match '\\{{') { + [regex]::Replace($stringValue, $backslashEscapePattern, $litOpenPlaceholder) + } else { + $stringValue + } + # Check if this is a pure placeholder (no prefix/suffix text, single placeholder) # If so, we can preserve the type instead of coercing to string $purePattern = '^\s*\{\{([^}]+)\}\}\s*$' - $pureMatch = [regex]::Match($stringValue, $purePattern) + $pureMatch = [regex]::Match($normalizedValue, $purePattern) $isPurePlaceholder = $pureMatch.Success # Check for unbalanced braces (typo safety) # Skip this validation for pure placeholders as we already validated them if (-not $isPurePlaceholder) { - # Count non-escaped opening braces - $openCount = ([regex]::Matches($stringValue, '(?