config editor

This commit is contained in:
2026-08-21 01:18:19 -04:00
parent c30ef6ec24
commit 01d08e635c
15 changed files with 1501 additions and 16 deletions
+389 -5
View File
@@ -306,17 +306,206 @@ function Save-PersonaConfiguration {
$true
}
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
}
}
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.
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.
#>
param(
[Parameter(Mandatory)] [object] $Document,
@@ -333,6 +522,7 @@ function Invoke-InteractiveEditor {
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 ' [A] add a rule [E] edit a rule [D] delete a rule'
Write-Host ' [V] re-validate [R] run rule test [S] save'
Write-Host ' [Q] quit'
Write-Host ''
@@ -378,6 +568,200 @@ function Invoke-InteractiveEditor {
Write-Host "Rule $id priority is now $value. Re-validate before saving - a reorder changes which rule wins." -ForegroundColor Green
}
'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
}
}
'V' {
$temp = [System.IO.Path]::GetTempFileName()
try {