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
87 changes: 87 additions & 0 deletions docs/use/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Comment thread
blindzero marked this conversation as resolved.

### 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:
Expand Down
49 changes: 33 additions & 16 deletions src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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\<resolved value>)
- \{{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.
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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, '(?<!\\)\{\{')).Count
# For closing braces, only count those that belong to templates (have a corresponding non-escaped opening)
# Count opening and closing braces on the normalized string so that \{{ escape sequences
# are correctly excluded from the counts (each \{{ escape is replaced by the placeholder).
$openCount = ([regex]::Matches($normalizedValue, '\{\{')).Count
# For closing braces, only count those that belong to templates (have a corresponding opening)
# We can do this by counting matches of the full template pattern
$templatePattern = '(?<!\\)\{\{([^}]+)\}\}'
$templateCount = ([regex]::Matches($stringValue, $templatePattern)).Count
$templatePattern = '\{\{([^}]+)\}\}'
$templateCount = ([regex]::Matches($normalizedValue, $templatePattern)).Count
# Any }} that's part of a template is matched. Any other }} is unbalanced.
$allCloseCount = ([regex]::Matches($stringValue, '\}\}')).Count
$allCloseCount = ([regex]::Matches($normalizedValue, '\}\}')).Count

# The expected close count should equal template count (each template has one closing)
if ($openCount -ne $templateCount -or $allCloseCount -ne $templateCount) {
throw [System.ArgumentException]::new(
("Template syntax error in step '{0}': Unbalanced braces in value '{1}'. Found {2} opening '{{{{' and {3} closing '}}}}'. Check for typos or missing braces." -f $StepName, $stringValue, $openCount, $allCloseCount),
("Template syntax error in step '{0}': Unbalanced braces in value '{1}'. Found {2} opening '{{{{' and {3} closing '}}}}'. Check for typos or missing braces." -f $StepName, ([string]$Value), $openCount, $allCloseCount),
'Workflow'
)
}
}

# Commit the normalized (escape-processed) string for template matching
$stringValue = $normalizedValue

# Parse and resolve placeholders
$result = $stringValue
$pattern = '(?<!\\)\{\{([^}]+)\}\}'
$pattern = '\{\{([^}]+)\}\}'
$matches = [regex]::Matches($stringValue, $pattern)

# For pure placeholders, we'll return the typed value directly
Expand Down Expand Up @@ -276,8 +293,8 @@ function Resolve-IdleTemplateString {
$result = $result.Replace($placeholder, $stringReplacement)
}

# Process escape sequences (unescape \{{ to {{)
$result = $result -replace '\\{{', '{{'
# Post-process: restore \{{ escape sequences → literal {{
$result = $result.Replace($litOpenPlaceholder, '{{')

return $result
}
30 changes: 30 additions & 0 deletions tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,36 @@ Describe 'Template Substitution' {

$plan.Steps[0].With.Value | Should -Be 'Literal {{ and template TestName'
}

It 'treats backslash before {{ as a literal character (not an escape)' {
$wfPath = Get-TemplateTestFixture 'template-backslash'

$req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ sAMAccountName = 'jdoe' }
$providers = @{
StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' }
StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test')
}

$plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers

$plan.Steps[0].With.IdentityKey | Should -Be 'DOMAIN\jdoe'
}

It 'escapes \{{ followed by an invalid (non-allowed) root — throws unbalanced braces, not path error' {
# With the tight allowed-root lookahead, \{{InvalidRoot}} is escaped (placeholder replaces \{{)
# leaving }} orphaned → "unbalanced braces" error, same as original code.
# A loose lookahead would let this through to template parsing → wrong "path not allowed" error.
$wfPath = Get-TemplateTestFixture 'template-escaped-invalid-root'

$req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' }
$providers = @{
StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' }
StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test')
}

{ New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } |
Should -Throw -ExpectedMessage '*Unbalanced braces*'
}
}

Context 'OnFailureSteps template resolution' {
Expand Down
13 changes: 13 additions & 0 deletions tests/fixtures/workflows/template-tests/template-backslash.psd1
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@{
Name = 'Template Test - Backslash Before Template'
LifecycleEvent = 'Joiner'
Steps = @(
@{
Name = 'TestStep'
Type = 'IdLE.Step.Test'
With = @{
IdentityKey = 'DOMAIN\{{Request.IdentityKeys.sAMAccountName}}'
}
}
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@{
Name = 'Template Test - Escaped Invalid Root'
LifecycleEvent = 'Joiner'
Steps = @(
@{
Name = 'TestStep'
Type = 'IdLE.Step.Test'
With = @{
Value = '\{{Request.InvalidRoot}}'
}
}
)
}
Loading