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
105 changes: 105 additions & 0 deletions docs/usage/workflows.md
Comment thread
blindzero marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,109 @@ If the condition is not met, the step is marked as `Skipped` and a skip event is

## References and inputs

### Template substitution ({{...}})

IdLE supports **template substitution** for embedding request values into workflow step configurations using `{{...}}` placeholders. Templates are resolved during planning (plan build), producing a plan with resolved values.

**How it works:**

When you create a lifecycle request, you provide data in the request object (via `DesiredState`, `IdentityKeys`, etc.). Templates in workflow configurations reference these values using dot-notation paths. During plan building, IdLE resolves the templates by looking up the paths in the request object and substituting the actual values.

**Creating a request with values:**

```powershell
$req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{
UserPrincipalName = 'jdoe@example.com'
DisplayName = 'John Doe'
GivenName = 'John'
Surname = 'Doe'
Department = 'Engineering'
}
```

The values in `DesiredState` are accessible via `Request.Input.*` (or `Request.DesiredState.*`) in templates.

**Using templates in workflows:**

```powershell
@{
Name = 'CreateUser'
Type = 'IdLE.Step.CreateIdentity'
With = @{
Attributes = @{
UserPrincipalName = '{{Request.Input.UserPrincipalName}}'
DisplayName = '{{Request.Input.DisplayName}}'
}
}
}
@{
Name = 'EmitEvent'
Type = 'IdLE.Step.EmitEvent'
With = @{
Message = 'Creating user {{Request.Input.DisplayName}} ({{Request.Input.UserPrincipalName}})'
}
}
```

When the plan is built, templates are resolved to the actual values from the request:
- `{{Request.Input.UserPrincipalName}}` → `'jdoe@example.com'`
- `{{Request.Input.DisplayName}}` → `'John Doe'`

**Key features:**

- **Concise syntax**: Use `{{Path}}` instead of verbose `@{ ValueFrom = 'Path' }` objects
- **Multiple placeholders**: Place multiple templates in one string
- **Nested structures**: Templates work in nested hashtables and arrays
- **Planning-time resolution**: Templates are resolved during plan build, not execution
- **Security boundary**: Only allowlisted request roots are accessible

**Allowed roots:**

For security, template resolution only allows accessing these request properties:

- `Request.Input.*` (aliased to `Request.DesiredState.*` if Input does not exist)
- `Request.DesiredState.*`
- `Request.IdentityKeys.*`
- `Request.Changes.*`
- `Request.LifecycleEvent`
- `Request.CorrelationId`
- `Request.Actor`

Attempting to access other roots (like `Plan.*`, `Providers.*`, or `Workflow.*`) will fail during planning with an actionable error.

**Type handling:**

Templates resolve scalar values (string, numeric, bool, datetime, guid) to strings. Non-scalar values (hashtables, arrays, objects) are rejected with an error. If you need to map complex objects, use explicit mapping steps or host-side pre-flattening.

**Error handling:**

Template resolution fails fast during planning if:

- Path does not exist or resolves to `$null`
- Path uses invalid characters or patterns
- Braces are unbalanced (typo safety)
- Root is not in the allowlist
- Value is non-scalar

These deterministic errors prevent silent substitution bugs (like empty UPNs).

**Escaping:**

Use `\{{` to include literal `{{` in a string:

```powershell
With = @{
Message = 'Literal \{{ braces here and template {{Request.Input.Name}}'
}
# Resolves to: 'Literal {{ braces here and template <actual name>'
```

**Request.Input alias:**

Workflow authors can use `Request.Input.*` for consistency, even if the request object only provides `DesiredState`. IdLE automatically aliases `Request.Input.*` to `Request.DesiredState.*` when the `Input` property does not exist.

### Legacy reference syntax (ValueFrom)

Prefer explicit reference fields over implicit parsing:

- `Value` for literals
Expand All @@ -114,6 +217,8 @@ Prefer explicit reference fields over implicit parsing:

This makes configurations safe and statically validatable.

**Note:** Template substitution (`{{...}}`) is preferred for string fields. Use `ValueFrom` objects when you need non-string references or conditional defaults.

## Advanced Workflow Patterns

(Content for advanced patterns will be added in future updates)
Expand Down
205 changes: 205 additions & 0 deletions src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
function Resolve-IdleTemplateString {
<#
.SYNOPSIS
Resolves template placeholders in a string using request context.

.DESCRIPTION
Scans a string for {{...}} placeholders and resolves them against the request object.
Only allowlisted request roots are permitted for security.

Template syntax:
- Placeholder format: {{<Path>}}
- Path is a dot-separated property path
- Multiple placeholders are supported in one string

Allowed roots (security boundary):
- Request.Input.* (aliased to Request.DesiredState.* if Input does not exist)
- Request.DesiredState.*
- Request.IdentityKeys.*
- Request.Changes.*
- Request.LifecycleEvent
- Request.CorrelationId
- Request.Actor

Escaping:
- \{{ → literal {{ (escape removed after resolution)

.PARAMETER Value
The string value to resolve. If not a string, returns the value unchanged.

.PARAMETER Request
The request object providing context for template resolution.

.PARAMETER StepName
The name of the step being processed (for error messages).

.OUTPUTS
Resolved string with placeholders replaced by request values.
#>
[CmdletBinding()]
param(
[Parameter()]
[AllowNull()]
[object] $Value,

[Parameter(Mandatory)]
[ValidateNotNull()]
[object] $Request,

[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $StepName
)

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

if ($Value -isnot [string]) {
return $Value
}

$stringValue = [string]$Value

# 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
}

# Check for unbalanced braces (typo safety)
# 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)
# We can do this by counting matches of the full template pattern
$templatePattern = '(?<!\\)\{\{([^}]+)\}\}'
$templateCount = ([regex]::Matches($stringValue, $templatePattern)).Count
# Any }} that's part of a template is matched. Any other }} is unbalanced.
$allCloseCount = ([regex]::Matches($stringValue, '\}\}')).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),
'Workflow'
)
}

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

foreach ($match in $matches) {
$placeholder = $match.Groups[0].Value
$path = $match.Groups[1].Value.Trim()

# Validate path pattern (strict: alphanumeric + dots only)
if ($path -notmatch '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$') {
throw [System.ArgumentException]::new(
("Template path error in step '{0}': Invalid path pattern '{1}'. Paths must use dot-separated identifiers (letters, numbers, underscores) with no spaces or special characters." -f $StepName, $path),
'Workflow'
)
}

# Security: validate allowed roots
$allowedRoots = @('Request.Input', 'Request.DesiredState', 'Request.IdentityKeys', 'Request.Changes', 'Request.LifecycleEvent', 'Request.CorrelationId', 'Request.Actor')
$isAllowed = $false
foreach ($root in $allowedRoots) {
if ($path -eq $root -or $path.StartsWith("$root.")) {
$isAllowed = $true
break
}
}

if (-not $isAllowed) {
throw [System.ArgumentException]::new(
("Template security error in step '{0}': Path '{1}' is not allowed. Only these roots are permitted: {2}" -f $StepName, $path, ([string]::Join(', ', $allowedRoots))),
'Workflow'
)
}

# Handle Request.Input.* alias to Request.DesiredState.*
$resolvePath = $path
$hasInputProperty = $false
if ($Request.PSObject.Properties['Input']) {
$hasInputProperty = $true
}

if ($path.StartsWith('Request.Input.')) {
if (-not $hasInputProperty) {
# Alias to DesiredState
$resolvePath = $path -replace '^Request\.Input\.', 'Request.DesiredState.'
}
}
elseif ($path -eq 'Request.Input') {
if (-not $hasInputProperty) {
$resolvePath = 'Request.DesiredState'
}
}
Comment thread
blindzero marked this conversation as resolved.

# Resolve the value (using custom logic that handles hashtables)
$contextWrapper = [pscustomobject]@{ Request = $Request }
$current = $contextWrapper
foreach ($segment in ($resolvePath -split '\.')) {
if ($null -eq $current) {
$resolvedValue = $null
break
}

# Handle hashtables/dictionaries
if ($current -is [System.Collections.IDictionary]) {
if ($current.ContainsKey($segment)) {
$current = $current[$segment]
}
else {
$current = $null
}
}
# Handle PSCustomObjects and class instances
else {
$prop = $current.PSObject.Properties[$segment]
if ($null -eq $prop) {
$current = $null
}
else {
$current = $prop.Value
}
}
}
$resolvedValue = $current

# Fail fast on null/missing values
if ($null -eq $resolvedValue) {
throw [System.ArgumentException]::new(
("Template resolution error in step '{0}': Path '{1}' resolved to null or does not exist. Ensure the request contains all required values." -f $StepName, $path),
'Workflow'
)
}

# Type validation: only scalar-ish types allowed
if ($resolvedValue -is [hashtable] -or
$resolvedValue -is [System.Collections.IDictionary] -or
$resolvedValue -is [array] -or
($resolvedValue -is [System.Collections.IEnumerable] -and $resolvedValue -isnot [string])) {
throw [System.ArgumentException]::new(
("Template type error in step '{0}': Path '{1}' resolved to a non-scalar value (hashtable/array/object). Templates only support scalar values (string, number, bool, datetime, guid). Use an explicit mapping step or host-side pre-flattening." -f $StepName, $path),
'Workflow'
)
}

# Convert to string
$stringReplacement = [string]$resolvedValue

# Replace placeholder
$result = $result.Replace($placeholder, $stringReplacement)
}

# Process escape sequences (unescape \{{ to {{)
$result = $result -replace '\\{{', '{{'

return $result
}
Loading