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
201 changes: 153 additions & 48 deletions src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ function Resolve-IdleTemplateString {
The name of the step being processed (for error messages).

.OUTPUTS
Resolved string with placeholders replaced by request values.
For pure placeholders (single placeholder with no surrounding text), returns the resolved value with its original type preserved (string, bool, int, datetime, guid, etc.).
For mixed strings (string interpolation with multiple placeholders or surrounding text), returns a string with placeholders replaced.
#>
[CmdletBinding()]
param(
Expand Down Expand Up @@ -70,81 +71,66 @@ function Resolve-IdleTemplateString {
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()
# Define validation constants used in multiple paths
$pathValidationPattern = '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$'
$allowedRoots = @('Request.Input', 'Request.DesiredState', 'Request.IdentityKeys', 'Request.Changes', 'Request.LifecycleEvent', 'Request.CorrelationId', 'Request.Actor')

# Validate path pattern (strict: alphanumeric + dots only)
if ($path -notmatch '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$') {
# Helper function to validate path pattern
$validatePath = {
param([string]$Path)
if ($Path -notmatch $pathValidationPattern) {
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),
("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')
# Helper function to validate allowed roots
$validateAllowedRoot = {
param([string]$Path)
$isAllowed = $false
foreach ($root in $allowedRoots) {
if ($path -eq $root -or $path.StartsWith("$root.")) {
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))),
("Template security error in step '{0}': Path '{1}' is not allowed. Only these roots are permitted: {2}" -f $StepName, $Path, ([string]::Join(', ', $allowedRoots))),
'Workflow'
)
}
}

# Helper function to resolve a template path to its value
$resolvePath = {
param([string]$Path)

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

if ($path.StartsWith('Request.Input.')) {
if ($Path.StartsWith('Request.Input.')) {
if (-not $hasInputProperty) {
# Alias to DesiredState
$resolvePath = $path -replace '^Request\.Input\.', 'Request.DesiredState.'
$targetPath = $Path -replace '^Request\.Input\.', 'Request.DesiredState.'
}
}
elseif ($path -eq 'Request.Input') {
elseif ($Path -eq 'Request.Input') {
if (-not $hasInputProperty) {
$resolvePath = 'Request.DesiredState'
$targetPath = 'Request.DesiredState'
}
}

# Resolve the value (using custom logic that handles hashtables)
$contextWrapper = [pscustomobject]@{ Request = $Request }
$current = $contextWrapper
Comment thread
blindzero marked this conversation as resolved.
foreach ($segment in ($resolvePath -split '\.')) {
foreach ($segment in ($targetPath -split '\.')) {
if ($null -eq $current) {
$resolvedValue = $null
break
Expand Down Expand Up @@ -175,21 +161,140 @@ function Resolve-IdleTemplateString {
# 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),
("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])) {
return $resolvedValue
}

# Helper function to validate resolved value is a scalar type
$validateScalarType = {
param([object]$Value, [string]$Path, [bool]$AllowComplexForInterpolation = $false)

# Security checks apply regardless of interpolation mode (defense in depth)
# Explicitly reject dangerous/executable types
if ($Value -is [scriptblock]) {
throw [System.ArgumentException]::new(
("Template security error in step '{0}': Path '{1}' resolved to a ScriptBlock. ScriptBlocks are not allowed in template resolution." -f $StepName, $Path),
'Workflow'
)
}

# Reject credential types (should be handled via AuthSessionBroker, not templates)
if ($Value -is [System.Management.Automation.PSCredential] -or
$Value -is [System.Security.SecureString]) {
throw [System.ArgumentException]::new(
("Template security error in step '{0}': Path '{1}' resolved to a credential type ('{2}'). Credentials must not be passed through templates. Use AuthSessionBroker for authentication." -f $StepName, $Path, ($Value.GetType().FullName)),
'Workflow'
)
}

# For mixed templates (string interpolation), use simpler validation
if ($AllowComplexForInterpolation) {
if ($Value -is [hashtable] -or
$Value -is [System.Collections.IDictionary] -or
$Value -is [array] -or
($Value -is [System.Collections.IEnumerable] -and $Value -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'
)
}
}
else {
# For pure placeholders, validate scalar types
# Accept: primitives (int, bool, etc.), string, datetime, guid, enums
# Reject: collections, PSCustomObject, complex reference types

$isScalar = $false

if ($Value -is [string]) {
# String is a fundamental data type
$isScalar = $true
}
elseif ($Value.GetType().IsValueType) {
# Value types: primitives (int, bool, etc.), structs (datetime, guid, timespan), enums
$isScalar = $true
}

Comment thread
blindzero marked this conversation as resolved.
if (-not $isScalar) {
throw [System.ArgumentException]::new(
("Template type error in step '{0}': Path '{1}' resolved to a non-scalar value (unsupported type: '{2}'). Templates only support scalar values (string, numeric primitives, bool, datetime, guid, enum). Use an explicit mapping step or host-side pre-flattening." -f $StepName, $Path, ($Value.GetType().FullName)),
'Workflow'
)
}
}
}

# 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)
$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)
# 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 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),
("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)

# For pure placeholders, we'll return the typed value directly
if ($isPurePlaceholder) {
# There should be exactly one match
$match = $matches[0]
$path = $match.Groups[1].Value.Trim()

# Validate path pattern and allowed roots using helper functions
& $validatePath $path
& $validateAllowedRoot $path

# Resolve the value using the shared helper
$resolvedValue = & $resolvePath $path

# Type validation: only allow scalar-ish types explicitly
& $validateScalarType $resolvedValue $path $false

# Return the typed value directly (no string conversion)
return $resolvedValue
}

# For mixed templates (string interpolation), process all placeholders and convert to string

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

# Validate path pattern and allowed roots using helper functions
& $validatePath $path
& $validateAllowedRoot $path

# Resolve the value using the shared helper
$resolvedValue = & $resolvePath $path

# Type validation: only scalar-ish types allowed
& $validateScalarType $resolvedValue $path $true

# Convert to string
$stringReplacement = [string]$resolvedValue
Expand Down
100 changes: 100 additions & 0 deletions tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -435,4 +435,104 @@ Describe 'Template Substitution' {
Should -Throw -ExpectedMessage '*non-scalar value*'
}
}

Context 'Type preservation for pure placeholders' {
It 'preserves boolean false type for pure placeholder' {
$wfPath = Get-TemplateTestFixture 'template-pure-boolean-false'

$req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Enabled = $false }
$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.Enabled | Should -BeOfType [bool]
$plan.Steps[0].With.Enabled | Should -BeFalse
# Verify it's not the string "False"
$plan.Steps[0].With.Enabled | Should -Not -BeOfType [string]
}

It 'preserves boolean true type for pure placeholder' {
$wfPath = Get-TemplateTestFixture 'template-pure-boolean-true'

$req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ IsActive = $true }
$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.IsActive | Should -BeOfType [bool]
$plan.Steps[0].With.IsActive | Should -BeTrue
}

It 'preserves integer type for pure placeholder' {
$wfPath = Get-TemplateTestFixture 'template-pure-integer'

$req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserId = 12345 }
$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.UserId | Should -BeOfType [int]
$plan.Steps[0].With.UserId | Should -Be 12345
}

It 'preserves datetime type for pure placeholder' {
$wfPath = Get-TemplateTestFixture 'template-pure-datetime'

$testDate = Get-Date '2026-01-15T10:00:00'
$req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ StartDate = $testDate }
$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.StartDate | Should -BeOfType [datetime]
$plan.Steps[0].With.StartDate | Should -Be $testDate
}

It 'preserves guid type for pure placeholder' {
$wfPath = Get-TemplateTestFixture 'template-pure-guid'

$testGuid = [guid]::NewGuid()
$req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ ObjectId = $testGuid }
$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.ObjectId | Should -BeOfType [guid]
$plan.Steps[0].With.ObjectId | Should -Be $testGuid
}

It 'converts to string for mixed template (string interpolation)' {
$wfPath = Get-TemplateTestFixture 'template-mixed-boolean'

$req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Enabled = $false }
$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.Message | Should -BeOfType [string]
$plan.Steps[0].With.Message | Should -Be 'Account enabled: False'
}
}

# Note: Security validation tests for ScriptBlock/PSCredential/SecureString are validated
# through direct unit testing due to test harness limitations. The security checks
# are applied regardless of pure/mixed template mode as verified by manual testing.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@{
Name = 'Template Test - Mixed Boolean (String Interpolation)'
LifecycleEvent = 'Joiner'
Steps = @(
@{
Name = 'TestStep'
Type = 'IdLE.Step.Test'
With = @{
Message = 'Account enabled: {{Request.Input.Enabled}}'
}
}
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@{
Name = 'Template Test - Pure Boolean False'
LifecycleEvent = 'Joiner'
Steps = @(
@{
Name = 'TestStep'
Type = 'IdLE.Step.Test'
With = @{
Enabled = '{{Request.Input.Enabled}}'
}
}
)
}
Loading
Loading