2026-08-20 21:48:19 -04:00
|
|
|
#Requires -Version 7.2
|
|
|
|
|
|
|
|
|
|
<#
|
|
|
|
|
.SYNOPSIS
|
|
|
|
|
Validates, tests, and interactively edits a Persona Engine configuration.
|
|
|
|
|
|
|
|
|
|
.DESCRIPTION
|
|
|
|
|
Two jobs in one tool, deliberately: the thing that checks a configuration and the
|
|
|
|
|
thing that edits it must agree about what "valid" means, and the surest way to
|
|
|
|
|
guarantee that is to make them the same code path.
|
|
|
|
|
|
|
|
|
|
-ValidateOnly report findings and exit. Never enters the editor.
|
|
|
|
|
-NonInteractive pipeline mode. Never prompts, never hangs (SC-010).
|
|
|
|
|
(neither) interactive editor, re-validating before every save.
|
|
|
|
|
|
|
|
|
|
-NonInteractive is not a convenience flag. A build agent runs with stdin closed;
|
|
|
|
|
a tool that prompts there does not fail, it hangs until the job times out, and
|
|
|
|
|
the pipeline reports an infrastructure problem instead of a bad configuration.
|
|
|
|
|
Every prompting call in this script sits behind a check for it.
|
|
|
|
|
|
|
|
|
|
Rule testing against synthetic users (-TestDataPath) runs the real rule engine
|
|
|
|
|
with no tenant connectivity (FR-025, SC-008). The engine is pure, so the answer
|
|
|
|
|
it gives offline is the answer it gives in production.
|
|
|
|
|
|
|
|
|
|
.PARAMETER ConfigPath
|
|
|
|
|
Configuration to validate or edit.
|
|
|
|
|
|
|
|
|
|
.PARAMETER ValidateOnly
|
|
|
|
|
Validate and report; do not enter the editor.
|
|
|
|
|
|
|
|
|
|
.PARAMETER NonInteractive
|
|
|
|
|
Pipeline mode: no prompts, exit code only.
|
|
|
|
|
|
|
|
|
|
.PARAMETER SchemaPath
|
|
|
|
|
Override the shipped schema.
|
|
|
|
|
|
|
|
|
|
.PARAMETER OutputPath
|
|
|
|
|
Save-As target. Leaves the input file untouched.
|
|
|
|
|
|
|
|
|
|
.PARAMETER TreatWarningsAsErrors
|
|
|
|
|
Escalate Warning findings to blocking (VR-005).
|
|
|
|
|
|
|
|
|
|
.PARAMETER TestDataPath
|
|
|
|
|
Synthetic sample users for offline rule testing (FR-025).
|
|
|
|
|
|
|
|
|
|
.PARAMETER PreviousConfigPath
|
|
|
|
|
Currently deployed configuration, enabling the VR-003 drift checks.
|
|
|
|
|
|
|
|
|
|
.PARAMETER EnforcementEnabled
|
|
|
|
|
Validate as though this configuration will drive an enforcing run, which raises
|
|
|
|
|
the severity of several safety findings.
|
|
|
|
|
|
|
|
|
|
.EXAMPLE
|
|
|
|
|
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
|
|
|
|
|
|
|
|
|
|
The pipeline invocation. Returns 0 when the configuration is clean.
|
|
|
|
|
|
|
|
|
|
.EXAMPLE
|
|
|
|
|
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData
|
|
|
|
|
|
|
|
|
|
Validate, then evaluate the rules against synthetic users with no tenant.
|
|
|
|
|
|
|
|
|
|
.NOTES
|
|
|
|
|
Exit codes
|
|
|
|
|
0 valid; no blocking findings
|
|
|
|
|
1 one or more Error findings
|
|
|
|
|
2 Warning findings present with -TreatWarningsAsErrors
|
|
|
|
|
3 configuration file not found or unreadable
|
|
|
|
|
4 schema file not found or itself invalid
|
|
|
|
|
#>
|
|
|
|
|
[CmdletBinding(SupportsShouldProcess = $true)]
|
|
|
|
|
param(
|
|
|
|
|
[Parameter(Mandatory)]
|
|
|
|
|
[ValidateNotNullOrEmpty()]
|
|
|
|
|
[string] $ConfigPath,
|
|
|
|
|
|
|
|
|
|
[switch] $ValidateOnly,
|
|
|
|
|
|
|
|
|
|
[switch] $NonInteractive,
|
|
|
|
|
|
|
|
|
|
[string] $SchemaPath,
|
|
|
|
|
|
|
|
|
|
[string] $OutputPath,
|
|
|
|
|
|
|
|
|
|
[switch] $TreatWarningsAsErrors,
|
|
|
|
|
|
|
|
|
|
[string] $TestDataPath,
|
|
|
|
|
|
|
|
|
|
[string] $PreviousConfigPath,
|
|
|
|
|
|
|
|
|
|
[switch] $EnforcementEnabled
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
|
|
|
|
|
|
# The .psm1 directly, not the manifest. The manifest declares
|
|
|
|
|
# Microsoft.Graph.Authentication as a required module, and this tool never touches
|
|
|
|
|
# Graph - SC-008 requires full validation and synthetic rule testing to complete with
|
|
|
|
|
# no network access. Importing the manifest would make a build agent that only
|
|
|
|
|
# validates configuration install a Graph SDK it will never call.
|
|
|
|
|
Import-Module (Join-Path $PSScriptRoot 'PersonaEngine.psm1') -Force -ErrorAction Stop
|
|
|
|
|
|
|
|
|
|
$EXIT_OK = 0
|
|
|
|
|
$EXIT_FINDINGS = 1
|
|
|
|
|
$EXIT_WARNINGS_AS_ERRORS = 2
|
|
|
|
|
$EXIT_CONFIG_UNREADABLE = 3
|
|
|
|
|
$EXIT_SCHEMA_UNUSABLE = 4
|
|
|
|
|
|
|
|
|
|
function Invoke-ConfigurationValidation {
|
|
|
|
|
<#
|
|
|
|
|
.SYNOPSIS
|
|
|
|
|
Runs all four layers and maps the result onto an exit code.
|
|
|
|
|
|
|
|
|
|
.DESCRIPTION
|
|
|
|
|
One function so the interactive save path and the pipeline path cannot
|
|
|
|
|
disagree. The exit-code mapping lives here rather than at the call sites for
|
|
|
|
|
the same reason: an exit code is the pipeline's only view of what happened.
|
|
|
|
|
#>
|
|
|
|
|
param([string] $Path)
|
|
|
|
|
|
|
|
|
|
$params = @{ Path = $Path; EnforcementEnabled = $EnforcementEnabled }
|
|
|
|
|
if ($SchemaPath) { $params['SchemaPath'] = $SchemaPath }
|
|
|
|
|
if ($PreviousConfigPath) { $params['PreviousConfigPath'] = $PreviousConfigPath }
|
|
|
|
|
|
|
|
|
|
$result = Test-PersonaConfiguration @params
|
|
|
|
|
|
|
|
|
|
$code = $EXIT_OK
|
|
|
|
|
|
|
|
|
|
# Order matters. An unreadable file and an unusable schema are environment
|
|
|
|
|
# faults, not authoring faults, and a pipeline needs to tell them apart from a
|
|
|
|
|
# genuinely invalid configuration - otherwise a missing schema file is reported
|
|
|
|
|
# to the author as "your rules are wrong".
|
|
|
|
|
if (@($result.Findings | Where-Object Code -In @('PE-SYN-001', 'PE-SYN-002')).Count -gt 0) {
|
|
|
|
|
$code = $EXIT_CONFIG_UNREADABLE
|
|
|
|
|
}
|
|
|
|
|
elseif ($result.SchemaUnusable -or @($result.Findings | Where-Object Code -EQ 'PE-SCH-002').Count -gt 0) {
|
|
|
|
|
$code = $EXIT_SCHEMA_UNUSABLE
|
|
|
|
|
}
|
|
|
|
|
elseif (-not $result.IsValid) {
|
|
|
|
|
$code = $EXIT_FINDINGS
|
|
|
|
|
}
|
|
|
|
|
elseif ($TreatWarningsAsErrors -and $result.WarningCount -gt 0) {
|
|
|
|
|
# VR-005: warnings block only here. The finding severity is unchanged - the
|
|
|
|
|
# configuration is not retroactively more broken because a switch was passed;
|
|
|
|
|
# what changed is the caller's tolerance for it.
|
|
|
|
|
$code = $EXIT_WARNINGS_AS_ERRORS
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[pscustomobject]@{ Result = $result; ExitCode = $code }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Invoke-SyntheticRuleTest {
|
|
|
|
|
<#
|
|
|
|
|
.SYNOPSIS
|
|
|
|
|
Evaluates the configuration's rules against synthetic users (FR-025).
|
|
|
|
|
|
|
|
|
|
.DESCRIPTION
|
|
|
|
|
No tenant, no credentials, no network. Reads the fixture files, builds
|
|
|
|
|
normalized records, and runs the real engine over them.
|
|
|
|
|
|
|
|
|
|
Fixtures declare their membership facets explicitly, including failed ones.
|
|
|
|
|
A fixture with an unretrieved facet is the only way to see EvaluationError
|
|
|
|
|
behaviour before it happens against a live directory.
|
|
|
|
|
#>
|
|
|
|
|
param(
|
|
|
|
|
[Parameter(Mandatory)] [object] $Configuration,
|
|
|
|
|
[Parameter(Mandatory)] [string] $DataPath
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
$usersFile = (Test-Path -LiteralPath $DataPath -PathType Container) `
|
|
|
|
|
? (Join-Path $DataPath 'Users/users.json') `
|
|
|
|
|
: $DataPath
|
|
|
|
|
|
|
|
|
|
if (-not (Test-Path -LiteralPath $usersFile -PathType Leaf)) {
|
|
|
|
|
Write-Host "No synthetic user fixtures found at '$usersFile'." -ForegroundColor Yellow
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$fixtures = (Get-Content -LiteralPath $usersFile -Raw | ConvertFrom-Json -Depth 32).users
|
|
|
|
|
|
|
|
|
|
$membershipFile = Join-Path (Split-Path (Split-Path $usersFile -Parent) -Parent) 'Memberships/memberships.json'
|
|
|
|
|
$membershipByFixture = @{}
|
|
|
|
|
|
|
|
|
|
if (Test-Path -LiteralPath $membershipFile -PathType Leaf) {
|
|
|
|
|
foreach ($entry in (Get-Content -LiteralPath $membershipFile -Raw | ConvertFrom-Json -Depth 32).memberships) {
|
|
|
|
|
$params = @{
|
|
|
|
|
DirectGroupObjectIds = @($entry.directGroupObjectIds)
|
|
|
|
|
TransitiveGroupObjectIds = @($entry.transitiveGroupObjectIds)
|
|
|
|
|
DirectoryRoleIds = @($entry.directoryRoleIds)
|
|
|
|
|
}
|
|
|
|
|
foreach ($facet in @('allRetrieved', 'directRetrieved', 'transitiveRetrieved', 'rolesRetrieved')) {
|
|
|
|
|
if ($entry.PSObject.Properties[$facet] -and $entry.$facet) {
|
|
|
|
|
$params[(Get-Culture).TextInfo.ToTitleCase($facet)] = $true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
foreach ($facet in @('directFailureReason', 'transitiveFailureReason', 'rolesFailureReason')) {
|
|
|
|
|
if ($entry.PSObject.Properties[$facet] -and $entry.$facet) {
|
|
|
|
|
$params[(Get-Culture).TextInfo.ToTitleCase($facet)] = [string]$entry.$facet
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
$membershipByFixture[[string]$entry.fixtureId] = New-PersonaMembershipRecord @params
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Write-Host ''
|
|
|
|
|
Write-Host 'Synthetic rule test - no tenant connectivity (FR-025, SC-008)' -ForegroundColor Cyan
|
|
|
|
|
Write-Host ('{0,-24} {1,-45} {2,-20} {3}' -f 'Fixture', 'UPN', 'Outcome', 'Persona / rule') -ForegroundColor DarkGray
|
|
|
|
|
|
|
|
|
|
foreach ($fixture in $fixtures) {
|
|
|
|
|
$properties = @{}
|
|
|
|
|
if ($fixture.properties) {
|
|
|
|
|
foreach ($p in $fixture.properties.PSObject.Properties) { $properties[$p.Name] = $p.Value }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$record = New-PersonaUserRecord `
|
|
|
|
|
-AccountObjectId ([string]$fixture.accountObjectId) `
|
|
|
|
|
-UserPrincipalName ([string]$fixture.userPrincipalName) `
|
|
|
|
|
-DisplayName ([string]$fixture.displayName) `
|
|
|
|
|
-UserType ([string]$fixture.userType) `
|
|
|
|
|
-AccountEnabled ([bool]$fixture.accountEnabled) `
|
|
|
|
|
-Properties $properties `
|
|
|
|
|
-StoredPersona ([string]$fixture.storedPersona) `
|
|
|
|
|
-Membership $membershipByFixture[[string]$fixture.fixtureId]
|
|
|
|
|
|
|
|
|
|
$result = Resolve-UserPersona -UserRecord $record -Rules $Configuration.Rules `
|
|
|
|
|
-MaxDepth $Configuration.MaxConditionDepth `
|
|
|
|
|
-DefaultMembershipMode $Configuration.DefaultMembershipMode
|
|
|
|
|
|
|
|
|
|
$colour = switch ($result.Outcome) {
|
|
|
|
|
'Matched' { 'Green' }
|
|
|
|
|
'Unclassified' { 'Gray' }
|
|
|
|
|
'EvaluationError' { 'Yellow' }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$detail = $result.Outcome -eq 'Matched' `
|
|
|
|
|
? "$($result.CalculatedPersona) [$($result.MatchedRuleId)]" `
|
|
|
|
|
: [string]$result.EvaluationErrorReason
|
|
|
|
|
|
|
|
|
|
Write-Host ('{0,-24} {1,-45} {2,-20} {3}' -f
|
|
|
|
|
$fixture.fixtureId, $result.UserPrincipalName, $result.Outcome, $detail) -ForegroundColor $colour
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Write-Host ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Save-PersonaConfiguration {
|
|
|
|
|
<#
|
|
|
|
|
.SYNOPSIS
|
|
|
|
|
Saves an edited configuration, backing up first (FR-026).
|
|
|
|
|
|
|
|
|
|
.DESCRIPTION
|
|
|
|
|
The save order is the contract, and it is deliberate:
|
|
|
|
|
|
|
|
|
|
1. Re-validate in full.
|
|
|
|
|
2. Refuse on any Error finding.
|
|
|
|
|
3. Back up the existing file, or write to -OutputPath instead.
|
|
|
|
|
4. Only then replace.
|
|
|
|
|
|
|
|
|
|
Backing up before replacing rather than after is what makes step 4 safe to
|
|
|
|
|
interrupt. A crash between backup and write leaves the original and a copy;
|
|
|
|
|
a crash the other way round leaves neither.
|
|
|
|
|
#>
|
|
|
|
|
param(
|
|
|
|
|
[Parameter(Mandatory)] [object] $Document,
|
|
|
|
|
[Parameter(Mandatory)] [string] $Path,
|
|
|
|
|
[string] $SaveAs
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
$json = $Document | ConvertTo-Json -Depth 32
|
|
|
|
|
|
|
|
|
|
$temp = [System.IO.Path]::GetTempFileName()
|
|
|
|
|
try {
|
|
|
|
|
Set-Content -LiteralPath $temp -Value $json -Encoding utf8NoBOM
|
|
|
|
|
|
|
|
|
|
$check = Invoke-ConfigurationValidation -Path $temp
|
|
|
|
|
if (-not $check.Result.IsValid) {
|
|
|
|
|
Write-Host 'Save blocked: the edited configuration has Error findings.' -ForegroundColor Red
|
|
|
|
|
Write-PersonaValidationFinding -Findings $check.Result.Findings
|
|
|
|
|
return $false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
finally {
|
|
|
|
|
Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$destination = $SaveAs ? $SaveAs : $Path
|
|
|
|
|
|
|
|
|
|
if ((Test-Path -LiteralPath $destination -PathType Leaf) -and -not $SaveAs) {
|
|
|
|
|
$backup = '{0}.{1}.bak' -f $destination, [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ')
|
|
|
|
|
|
|
|
|
|
if (-not $PSCmdlet.ShouldProcess($destination, "Back up to '$backup' and replace")) {
|
|
|
|
|
Write-Host 'Save cancelled.' -ForegroundColor Yellow
|
|
|
|
|
return $false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Copy-Item -LiteralPath $destination -Destination $backup -Force
|
|
|
|
|
Write-Host "Backup written: $backup" -ForegroundColor DarkGray
|
|
|
|
|
}
|
|
|
|
|
elseif (-not $PSCmdlet.ShouldProcess($destination, 'Write configuration')) {
|
|
|
|
|
Write-Host 'Save cancelled.' -ForegroundColor Yellow
|
|
|
|
|
return $false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Set-Content -LiteralPath $destination -Value $json -Encoding utf8NoBOM
|
|
|
|
|
Write-Host "Saved: $destination" -ForegroundColor Green
|
|
|
|
|
$true
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-21 01:18:19 -04:00
|
|
|
function Test-PersonaCandidateEdit {
|
|
|
|
|
<#
|
|
|
|
|
.SYNOPSIS
|
|
|
|
|
Applies a structural edit to a cloned document and re-validates it (FR-030).
|
|
|
|
|
|
|
|
|
|
.DESCRIPTION
|
|
|
|
|
Deep-clones Document via a JSON round trip (nested objects are reference
|
|
|
|
|
types, so mutating Document directly would leave a half-applied edit in
|
|
|
|
|
place if validation then rejected it), runs Apply against the clone, and
|
|
|
|
|
validates the clone in full - the identical check the `[V]` command and
|
|
|
|
|
`Save-PersonaConfiguration` use, so add/edit/delete can never be held to a
|
|
|
|
|
looser standard than a hand edit.
|
|
|
|
|
|
|
|
|
|
The document entering the interactive editor is already Error-free, so a
|
|
|
|
|
clone that fails here failed because of Apply, not because of pre-existing
|
|
|
|
|
state.
|
|
|
|
|
|
|
|
|
|
.PARAMETER Document
|
|
|
|
|
The current in-memory configuration document.
|
|
|
|
|
|
|
|
|
|
.PARAMETER Apply
|
|
|
|
|
A scriptblock taking the clone as its first parameter, followed by whatever
|
|
|
|
|
ApplyArgs supplies, and mutating the clone in place. Values are passed as
|
|
|
|
|
explicit arguments rather than closed over from the caller's scope: `&`
|
|
|
|
|
invocation runs a scriptblock in a new child scope of wherever it is invoked
|
|
|
|
|
(here, inside this function) rather than of the scope where the scriptblock
|
|
|
|
|
literal was written, so a bare `{ $doc.rules = ... $id ... }` would see $id as
|
|
|
|
|
unbound. Explicit parameters sidestep that entirely.
|
|
|
|
|
|
|
|
|
|
.PARAMETER ApplyArgs
|
|
|
|
|
Positional arguments passed to Apply after the clone.
|
|
|
|
|
|
|
|
|
|
.OUTPUTS
|
|
|
|
|
pscustomobject with Applied (bool), Document (the clone if Applied, otherwise
|
|
|
|
|
the original Document, unchanged), and Result (the validation result).
|
|
|
|
|
#>
|
|
|
|
|
param(
|
|
|
|
|
[Parameter(Mandatory)] [object] $Document,
|
|
|
|
|
[Parameter(Mandatory)] [scriptblock] $Apply,
|
|
|
|
|
[object[]] $ApplyArgs = @()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
$clone = $Document | ConvertTo-Json -Depth 32 | ConvertFrom-Json -Depth 32
|
|
|
|
|
& $Apply $clone @ApplyArgs
|
|
|
|
|
|
|
|
|
|
$temp = [System.IO.Path]::GetTempFileName()
|
|
|
|
|
try {
|
|
|
|
|
Set-Content -LiteralPath $temp -Value ($clone | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
|
|
|
|
|
$check = Invoke-ConfigurationValidation -Path $temp
|
|
|
|
|
}
|
|
|
|
|
finally {
|
|
|
|
|
Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[pscustomobject]@{
|
|
|
|
|
Applied = $check.Result.IsValid
|
|
|
|
|
Document = $check.Result.IsValid ? $clone : $Document
|
|
|
|
|
Result = $check.Result
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ConvertTo-PersonaConditionPath {
|
|
|
|
|
<#
|
|
|
|
|
.SYNOPSIS
|
|
|
|
|
Parses an operator-typed path like "1.0" into the int[] Get-PersonaConditionNode expects.
|
|
|
|
|
|
|
|
|
|
.DESCRIPTION
|
|
|
|
|
Blank input, or the literal word "root", addresses the rule's match group
|
|
|
|
|
itself.
|
|
|
|
|
#>
|
|
|
|
|
param([string] $Text)
|
|
|
|
|
|
|
|
|
|
$trimmed = ([string]$Text).Trim()
|
|
|
|
|
if ([string]::IsNullOrEmpty($trimmed) -or $trimmed -eq 'root') { return , @() }
|
|
|
|
|
|
|
|
|
|
$indices = foreach ($part in ($trimmed -split '[.\s]+' | Where-Object { $_ -ne '' })) {
|
|
|
|
|
$value = 0
|
|
|
|
|
if (-not [int]::TryParse($part, [ref] $value)) {
|
|
|
|
|
throw "'$part' is not a valid path segment - use dot-separated indices, for example '1.0'."
|
|
|
|
|
}
|
|
|
|
|
$value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
, @($indices)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Read-PersonaConditionLeafFields {
|
|
|
|
|
<#
|
|
|
|
|
.SYNOPSIS
|
|
|
|
|
Prompts for one leaf condition's fields, in the shape Set-PersonaConditionLeaf expects.
|
|
|
|
|
#>
|
|
|
|
|
Write-Host ' Condition type: [P]roperty [M]embership [R]ole' -ForegroundColor DarkGray
|
|
|
|
|
$typeChoice = (Read-Host ' Type').Trim().ToUpperInvariant()
|
|
|
|
|
$type = switch ($typeChoice) { 'P' { 'property' } 'M' { 'membership' } 'R' { 'role' } default { $null } }
|
|
|
|
|
if (-not $type) { throw "Unrecognized condition type '$typeChoice'." }
|
|
|
|
|
|
|
|
|
|
Write-Host ' Operators: equals notEquals contains notContains startsWith endsWith matchesRegex in notIn isNull isNotNull memberOf notMemberOf' -ForegroundColor DarkGray
|
|
|
|
|
$operator = (Read-Host ' Operator').Trim()
|
|
|
|
|
|
|
|
|
|
$fields = @{ type = $type; operator = $operator }
|
|
|
|
|
|
|
|
|
|
switch ($type) {
|
|
|
|
|
'property' { $fields['property'] = (Read-Host ' Property name').Trim() }
|
|
|
|
|
'membership' {
|
|
|
|
|
$ids = (Read-Host ' Group Object IDs (comma-separated)')
|
|
|
|
|
$fields['groupObjectIds'] = @($ids -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
|
|
|
|
$mode = (Read-Host ' Membership mode (direct/transitive, blank = configuration default)').Trim()
|
|
|
|
|
if ($mode) { $fields['membershipMode'] = $mode }
|
|
|
|
|
}
|
|
|
|
|
'role' {
|
|
|
|
|
$ids = (Read-Host ' Role IDs (comma-separated)')
|
|
|
|
|
$fields['roleIds'] = @($ids -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($operator -in @('in', 'notIn')) {
|
|
|
|
|
$values = (Read-Host ' Values (comma-separated)')
|
|
|
|
|
$fields['values'] = @($values -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
|
|
|
|
}
|
|
|
|
|
elseif ($operator -notin @('isNull', 'isNotNull', 'memberOf', 'notMemberOf')) {
|
|
|
|
|
$fields['value'] = Read-Host ' Comparison value'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$fields
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Read-PersonaConditionNode {
|
|
|
|
|
<#
|
|
|
|
|
.SYNOPSIS
|
|
|
|
|
Interactively builds one condition or nested group (FR-027, FR-028).
|
|
|
|
|
|
|
|
|
|
.DESCRIPTION
|
|
|
|
|
Recurses for a group's children. Nesting depth is not limited here - see
|
|
|
|
|
Test-PersonaCandidateEdit and Add-PersonaConditionNode's doc comment for why
|
|
|
|
|
depth is enforced by the real validator instead of a second, local count.
|
|
|
|
|
#>
|
|
|
|
|
param([int] $Depth = 1)
|
|
|
|
|
|
|
|
|
|
$indent = ' ' * $Depth
|
|
|
|
|
Write-Host ("{0}[C]ondition or [G]roup?" -f $indent) -ForegroundColor DarkGray
|
|
|
|
|
$kind = (Read-Host "$indent Kind").Trim().ToUpperInvariant()
|
|
|
|
|
|
|
|
|
|
if ($kind -eq 'G') {
|
|
|
|
|
$operator = (Read-Host "$indent Group operator (all/any)").Trim().ToLowerInvariant()
|
|
|
|
|
if ($operator -notin @('all', 'any')) { throw "Group operator must be 'all' or 'any'." }
|
|
|
|
|
|
|
|
|
|
$children = [System.Collections.Generic.List[object]]::new()
|
|
|
|
|
do {
|
|
|
|
|
$children.Add((Read-PersonaConditionNode -Depth ($Depth + 1)))
|
|
|
|
|
$more = (Read-Host "$indent Add another condition to this group? (y/N)").Trim()
|
|
|
|
|
} while ($more.ToLowerInvariant() -eq 'y')
|
|
|
|
|
|
|
|
|
|
return [pscustomobject]@{ operator = $operator; conditions = $children.ToArray() }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[pscustomobject](Read-PersonaConditionLeafFields)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Read-PersonaRuleFields {
|
|
|
|
|
<#
|
|
|
|
|
.SYNOPSIS
|
|
|
|
|
Prompts for a new rule's top-level fields (RE-001), excluding its match tree.
|
|
|
|
|
#>
|
|
|
|
|
$id = (Read-Host 'Rule id').Trim()
|
|
|
|
|
$name = (Read-Host 'Rule name').Trim()
|
|
|
|
|
$description = (Read-Host 'Description').Trim()
|
|
|
|
|
|
|
|
|
|
$priority = 0
|
|
|
|
|
if (-not [int]::TryParse((Read-Host 'Priority (integer)'), [ref] $priority)) {
|
|
|
|
|
throw 'Priority must be an integer.'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$persona = (Read-Host 'Persona').Trim()
|
|
|
|
|
$enabled = (Read-Host 'Enabled? (Y/n)').Trim().ToLowerInvariant() -ne 'n'
|
|
|
|
|
|
|
|
|
|
[pscustomobject]@{
|
|
|
|
|
id = $id; name = $name; description = $description
|
|
|
|
|
enabled = $enabled; priority = $priority; persona = $persona
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 21:48:19 -04:00
|
|
|
function Invoke-InteractiveEditor {
|
|
|
|
|
<#
|
|
|
|
|
.SYNOPSIS
|
|
|
|
|
The interactive loop (FR-023).
|
|
|
|
|
|
|
|
|
|
.DESCRIPTION
|
2026-08-21 01:18:19 -04:00
|
|
|
Toggles rules, adjusts priorities, adds/edits/deletes whole rules including
|
|
|
|
|
their condition trees (FR-027 - FR-030), re-validates, tests against
|
|
|
|
|
fixtures, and saves.
|
|
|
|
|
|
|
|
|
|
Every structural edit (add/edit/delete) goes through Test-PersonaCandidateEdit,
|
|
|
|
|
which applies it to a clone and re-runs full validation before it is allowed
|
|
|
|
|
to reach $Document. The live document entering this loop is already
|
|
|
|
|
Error-free - the main script exits before this function is ever called
|
|
|
|
|
otherwise - so a clone that fails validation failed because of the edit just
|
|
|
|
|
applied, and is discarded rather than committed. This is what makes
|
|
|
|
|
structural editing safe to add at all: the editor can never produce a worse
|
|
|
|
|
document than a careful hand edit would, because it runs the same four
|
|
|
|
|
validation layers a hand edit is checked against at save time.
|
2026-08-20 21:48:19 -04:00
|
|
|
#>
|
|
|
|
|
param(
|
|
|
|
|
[Parameter(Mandatory)] [object] $Document,
|
|
|
|
|
[Parameter(Mandatory)] [string] $Path
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
$dirty = $false
|
|
|
|
|
|
|
|
|
|
while ($true) {
|
|
|
|
|
Write-Host ''
|
|
|
|
|
Write-Host 'Persona Engine configuration editor' -ForegroundColor Cyan
|
|
|
|
|
Write-Host (" file {0}{1}" -f $Path, ($dirty ? ' [modified]' : ''))
|
|
|
|
|
Write-Host (" version {0}" -f $Document.configVersion)
|
|
|
|
|
Write-Host (" rules {0} ({1} enabled)" -f @($Document.rules).Count, @($Document.rules | Where-Object { $_.enabled }).Count)
|
|
|
|
|
Write-Host ''
|
|
|
|
|
Write-Host ' [L] list rules [T] toggle a rule [P] change a priority'
|
2026-08-21 01:18:19 -04:00
|
|
|
Write-Host ' [A] add a rule [E] edit a rule [D] delete a rule'
|
2026-08-20 21:48:19 -04:00
|
|
|
Write-Host ' [V] re-validate [R] run rule test [S] save'
|
|
|
|
|
Write-Host ' [Q] quit'
|
|
|
|
|
Write-Host ''
|
|
|
|
|
|
|
|
|
|
$choice = (Read-Host 'Choice').Trim().ToUpperInvariant()
|
|
|
|
|
|
|
|
|
|
switch ($choice) {
|
|
|
|
|
|
|
|
|
|
'L' {
|
|
|
|
|
Write-Host ''
|
|
|
|
|
foreach ($rule in ($Document.rules | Sort-Object { [int]$_.priority })) {
|
|
|
|
|
Write-Host (' {0,-6} {1,-28} {2,-30} {3}' -f
|
|
|
|
|
$rule.priority, $rule.id, $rule.persona,
|
|
|
|
|
($rule.enabled ? 'enabled' : 'DISABLED')) -ForegroundColor ($rule.enabled ? 'Gray' : 'DarkGray')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
'T' {
|
|
|
|
|
$id = (Read-Host 'Rule ID to toggle').Trim()
|
|
|
|
|
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
|
|
|
|
|
|
|
|
|
|
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
|
|
|
|
|
|
|
|
|
|
$rule.enabled = -not $rule.enabled
|
|
|
|
|
$dirty = $true
|
|
|
|
|
Write-Host ("Rule {0} is now {1}." -f $id, ($rule.enabled ? 'enabled' : 'disabled')) -ForegroundColor Green
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
'P' {
|
|
|
|
|
$id = (Read-Host 'Rule ID').Trim()
|
|
|
|
|
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
|
|
|
|
|
|
|
|
|
|
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
|
|
|
|
|
|
|
|
|
|
$value = 0
|
|
|
|
|
if (-not [int]::TryParse((Read-Host 'New priority'), [ref] $value)) {
|
|
|
|
|
Write-Host 'Priority must be an integer.' -ForegroundColor Yellow
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$rule.priority = $value
|
|
|
|
|
$dirty = $true
|
|
|
|
|
Write-Host "Rule $id priority is now $value. Re-validate before saving - a reorder changes which rule wins." -ForegroundColor Green
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-21 01:18:19 -04:00
|
|
|
'A' {
|
|
|
|
|
# FR-027. The candidate rule is built entirely from prompts before
|
|
|
|
|
# Test-PersonaCandidateEdit ever sees it, so a collision or a
|
|
|
|
|
# validation failure is reported once, against the whole rule, rather
|
|
|
|
|
# than mid-build against a rule that only half exists yet.
|
|
|
|
|
try {
|
|
|
|
|
$fields = Read-PersonaRuleFields
|
|
|
|
|
|
|
|
|
|
Write-Host 'Build the condition tree for this rule (the root group):' -ForegroundColor Cyan
|
|
|
|
|
$rootOperator = (Read-Host 'Root operator (all/any)').Trim().ToLowerInvariant()
|
|
|
|
|
if ($rootOperator -notin @('all', 'any')) { throw "Root operator must be 'all' or 'any'." }
|
|
|
|
|
|
|
|
|
|
$rootChildren = [System.Collections.Generic.List[object]]::new()
|
|
|
|
|
do {
|
|
|
|
|
$rootChildren.Add((Read-PersonaConditionNode -Depth 1))
|
|
|
|
|
$more = (Read-Host 'Add another top-level condition? (y/N)').Trim()
|
|
|
|
|
} while ($more.ToLowerInvariant() -eq 'y')
|
|
|
|
|
|
|
|
|
|
$newRule = [pscustomobject]@{
|
|
|
|
|
id = $fields.id
|
|
|
|
|
name = $fields.name
|
|
|
|
|
description = $fields.description
|
|
|
|
|
enabled = $fields.enabled
|
|
|
|
|
priority = $fields.priority
|
|
|
|
|
persona = $fields.persona
|
|
|
|
|
match = [pscustomobject]@{ operator = $rootOperator; conditions = $rootChildren.ToArray() }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$attempt = Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($newRule) -Apply {
|
|
|
|
|
param($doc, $rule)
|
|
|
|
|
$doc.rules = Add-PersonaConfigRule -Rules $doc.rules -Rule $rule
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($attempt.Applied) {
|
|
|
|
|
$Document = $attempt.Document
|
|
|
|
|
$dirty = $true
|
|
|
|
|
Write-Host "Rule $($newRule.id) added." -ForegroundColor Green
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
Write-Host 'Add rejected: the resulting configuration would be invalid.' -ForegroundColor Red
|
|
|
|
|
Write-PersonaValidationFinding -Findings $attempt.Result.Findings
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch {
|
|
|
|
|
Write-Host "Add cancelled: $($_.Exception.Message)" -ForegroundColor Yellow
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
'E' {
|
|
|
|
|
# FR-028. One structural operation per pass through this case - the
|
|
|
|
|
# operator repeats [E] to make further changes, which keeps each
|
|
|
|
|
# Test-PersonaCandidateEdit call scoped to one edit and one finding
|
|
|
|
|
# set, rather than a batch where a rejection leaves it unclear which
|
|
|
|
|
# of several changes caused it.
|
|
|
|
|
try {
|
|
|
|
|
$id = (Read-Host 'Rule id to edit').Trim()
|
|
|
|
|
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
|
|
|
|
|
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
|
|
|
|
|
|
|
|
|
|
Write-Host ("Editing rule {0} ({1})." -f $rule.id, $rule.name) -ForegroundColor Cyan
|
|
|
|
|
Write-Host ' [F] change a top-level field [C] add a condition/group'
|
|
|
|
|
Write-Host ' [E] edit a leaf condition [X] remove a condition/group'
|
|
|
|
|
Write-Host ' [B] back to the main menu, no change'
|
|
|
|
|
|
|
|
|
|
$subChoice = (Read-Host ' Edit action').Trim().ToUpperInvariant()
|
|
|
|
|
|
|
|
|
|
$attempt = switch ($subChoice) {
|
|
|
|
|
|
|
|
|
|
'F' {
|
|
|
|
|
$field = (Read-Host ' Field (name/description/priority/persona/enabled)').Trim().ToLowerInvariant()
|
|
|
|
|
$value = Read-Host ' New value'
|
|
|
|
|
|
|
|
|
|
Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($id, $field, $value) -Apply {
|
|
|
|
|
param($doc, $ruleId, $fieldName, $newValue)
|
|
|
|
|
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
|
|
|
|
|
switch ($fieldName) {
|
|
|
|
|
'priority' { $target.priority = [int]$newValue }
|
|
|
|
|
'enabled' { $target.enabled = $newValue.Trim().ToLowerInvariant() -in @('y', 'yes', 'true', '1') }
|
|
|
|
|
'name' { $target.name = $newValue }
|
|
|
|
|
'description' { $target.description = $newValue }
|
|
|
|
|
'persona' { $target.persona = $newValue }
|
|
|
|
|
default { throw "Unrecognized field '$fieldName'." }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
'C' {
|
|
|
|
|
$parentPath = ConvertTo-PersonaConditionPath -Text (Read-Host ' Parent path (blank = root, e.g. "1.0")')
|
|
|
|
|
$newNode = Read-PersonaConditionNode -Depth ($parentPath.Count + 1)
|
|
|
|
|
|
|
|
|
|
# ApplyArgs is built with .Add() rather than @(...), because an
|
|
|
|
|
# int[] placed inside a `@()` array literal is unrolled into the
|
|
|
|
|
# outer array (the same gotcha Get-CapturedAuditRecord's comma
|
|
|
|
|
# idiom guards against elsewhere) - @($id, $parentPath, $newNode)
|
|
|
|
|
# would silently flatten a two-segment path into two extra
|
|
|
|
|
# positional arguments instead of passing it as one array.
|
|
|
|
|
$args = [System.Collections.Generic.List[object]]::new()
|
|
|
|
|
$args.Add($id); $args.Add($parentPath); $args.Add($newNode)
|
|
|
|
|
|
|
|
|
|
Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply {
|
|
|
|
|
param($doc, $ruleId, $parentPathArg, $node)
|
|
|
|
|
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
|
|
|
|
|
Add-PersonaConditionNode -Group $target.match -ParentPath $parentPathArg -Node $node | Out-Null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
'E' {
|
|
|
|
|
$path = ConvertTo-PersonaConditionPath -Text (Read-Host ' Leaf condition path (e.g. "1.0")')
|
|
|
|
|
if ($path.Count -eq 0) { throw 'The root is a group, not a leaf condition - navigate to a leaf path.' }
|
|
|
|
|
$fields = Read-PersonaConditionLeafFields
|
|
|
|
|
|
|
|
|
|
$args = [System.Collections.Generic.List[object]]::new()
|
|
|
|
|
$args.Add($id); $args.Add($path); $args.Add($fields)
|
|
|
|
|
|
|
|
|
|
Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply {
|
|
|
|
|
param($doc, $ruleId, $pathArg, $fieldsArg)
|
|
|
|
|
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
|
|
|
|
|
$leaf = Get-PersonaConditionNode -Group $target.match -Path $pathArg
|
|
|
|
|
Set-PersonaConditionLeaf -Node $leaf -Fields $fieldsArg | Out-Null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
'X' {
|
|
|
|
|
$path = ConvertTo-PersonaConditionPath -Text (Read-Host ' Condition/group path to remove (e.g. "1.0")')
|
|
|
|
|
|
|
|
|
|
$args = [System.Collections.Generic.List[object]]::new()
|
|
|
|
|
$args.Add($id); $args.Add($path)
|
|
|
|
|
|
|
|
|
|
Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply {
|
|
|
|
|
param($doc, $ruleId, $pathArg)
|
|
|
|
|
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
|
|
|
|
|
Remove-PersonaConditionNode -Group $target.match -Path $pathArg | Out-Null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
'B' { $null }
|
|
|
|
|
|
|
|
|
|
default { Write-Host 'Unrecognized edit action.' -ForegroundColor Yellow; $null }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($null -ne $attempt) {
|
|
|
|
|
if ($attempt.Applied) {
|
|
|
|
|
$Document = $attempt.Document
|
|
|
|
|
$dirty = $true
|
|
|
|
|
Write-Host "Rule $id updated." -ForegroundColor Green
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
Write-Host 'Edit rejected: the resulting configuration would be invalid.' -ForegroundColor Red
|
|
|
|
|
Write-PersonaValidationFinding -Findings $attempt.Result.Findings
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch {
|
|
|
|
|
Write-Host "Edit cancelled: $($_.Exception.Message)" -ForegroundColor Yellow
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
'D' {
|
|
|
|
|
# FR-029. Confirmation names id, name, and priority before anything is
|
|
|
|
|
# touched, matching the acceptance criterion literally rather than a
|
|
|
|
|
# generic "are you sure?".
|
|
|
|
|
try {
|
|
|
|
|
$id = (Read-Host 'Rule id to delete').Trim()
|
|
|
|
|
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
|
|
|
|
|
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
|
|
|
|
|
|
|
|
|
|
Write-Host ("About to delete rule {0} '{1}' priority {2}." -f $rule.id, $rule.name, $rule.priority) -ForegroundColor Yellow
|
|
|
|
|
$confirm = (Read-Host 'Delete this rule? (y/N)').Trim().ToLowerInvariant()
|
|
|
|
|
|
|
|
|
|
if ($confirm -ne 'y') {
|
|
|
|
|
Write-Host 'Delete cancelled.' -ForegroundColor Yellow
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$attempt = Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($id) -Apply {
|
|
|
|
|
param($doc, $ruleId)
|
|
|
|
|
$doc.rules = Remove-PersonaConfigRule -Rules $doc.rules -RuleId $ruleId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($attempt.Applied) {
|
|
|
|
|
$Document = $attempt.Document
|
|
|
|
|
$dirty = $true
|
|
|
|
|
Write-Host "Rule $id deleted." -ForegroundColor Green
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
Write-Host 'Delete rejected: the resulting configuration would be invalid.' -ForegroundColor Red
|
|
|
|
|
Write-PersonaValidationFinding -Findings $attempt.Result.Findings
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch {
|
|
|
|
|
Write-Host "Delete cancelled: $($_.Exception.Message)" -ForegroundColor Yellow
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 21:48:19 -04:00
|
|
|
'V' {
|
|
|
|
|
$temp = [System.IO.Path]::GetTempFileName()
|
|
|
|
|
try {
|
|
|
|
|
Set-Content -LiteralPath $temp -Value ($Document | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
|
|
|
|
|
$check = Invoke-ConfigurationValidation -Path $temp
|
|
|
|
|
Write-PersonaValidationFinding -Findings $check.Result.Findings
|
|
|
|
|
}
|
|
|
|
|
finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
'R' {
|
|
|
|
|
$dataPath = $TestDataPath ? $TestDataPath : (Join-Path $PSScriptRoot 'tests/TestData')
|
|
|
|
|
$temp = [System.IO.Path]::GetTempFileName()
|
|
|
|
|
try {
|
|
|
|
|
Set-Content -LiteralPath $temp -Value ($Document | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
|
|
|
|
|
Invoke-SyntheticRuleTest -Configuration (Import-PersonaConfiguration -Path $temp) -DataPath $dataPath
|
|
|
|
|
}
|
|
|
|
|
finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
'S' {
|
|
|
|
|
if (Save-PersonaConfiguration -Document $Document -Path $Path -SaveAs $OutputPath) { $dirty = $false }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
'Q' {
|
|
|
|
|
if ($dirty) {
|
|
|
|
|
$confirm = (Read-Host 'Unsaved changes will be lost. Quit anyway? (y/N)').Trim()
|
|
|
|
|
if ($confirm -ne 'y') { break }
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
default { Write-Host 'Unrecognized choice.' -ForegroundColor Yellow }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ============================================================ main
|
|
|
|
|
|
|
|
|
|
$validation = Invoke-ConfigurationValidation -Path $ConfigPath
|
|
|
|
|
|
|
|
|
|
Write-PersonaValidationFinding -Findings $validation.Result.Findings
|
|
|
|
|
|
|
|
|
|
if ($validation.ExitCode -ne $EXIT_OK) {
|
|
|
|
|
Write-Host ("Validation failed with exit code {0}." -f $validation.ExitCode) -ForegroundColor Red
|
|
|
|
|
exit $validation.ExitCode
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($TestDataPath) {
|
|
|
|
|
Invoke-SyntheticRuleTest -Configuration (Import-PersonaConfiguration -Path $ConfigPath) -DataPath $TestDataPath
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# -ValidateOnly and -NonInteractive both short-circuit before any prompting call.
|
|
|
|
|
# The check is here, once, at the single point where the editor could be entered.
|
|
|
|
|
if ($ValidateOnly -or $NonInteractive) {
|
|
|
|
|
Write-Host 'Configuration is valid.' -ForegroundColor Green
|
|
|
|
|
exit $EXIT_OK
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Invoke-InteractiveEditor -Document $validation.Result.Document -Path $ConfigPath
|
|
|
|
|
|
|
|
|
|
exit $EXIT_OK
|