config editor
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user