#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 } function Invoke-InteractiveEditor { <# .SYNOPSIS The interactive loop (FR-023). .DESCRIPTION Deliberately small. It toggles rules, adjusts priorities, re-validates, tests against fixtures, and saves. It does not attempt to be a JSON editor - rule authoring belongs in a text editor with schema completion, and a half-featured structural editor would only add ways to corrupt a file that is already under version control. #> 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' 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 } '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