Implement Stage A: rule engine, validation, audit, and safety gates

Completes 109 of 121 tasks. Every remaining task needs a tenant connection
(T055, T056, T101-T103) or an Azure Automation account (T115-T121).

  354 offline Pester tests      PASS
  Engine purity (Principle IV)  PASS
  Sanitization (SC-013)         PASS  (156 files)
  Graph module loaded in tests  none  (SC-008 holds)

What landed
  - Four-layer configuration validation with stable finding codes, covering
    every VR-002 and VR-003 condition, plus a 23-fixture invalid-config corpus
  - Run loop, audit records (NDJSON through a single sink), summaries,
    reconciliation, and exit codes 0-6
  - Persistence behind a single write-body builder whose result always has
    exactly one key
  - Invoke-PersonaEngine.ps1 and Edit-PersonaEngineConfig.ps1
  - Six docs, two pipelines, traceability matrix, V-5a and sanitization records

Three deviations from tasks.md, each recorded in its status block

  T033 is not in Resolve-UserPersona. evaluationErrorThreshold is run-level
  state and the rule engine is pure; a counter there would break Principle IV.
  It lives in New-PersonaRunCounter and is applied in the run loop.

  A new src/Engine/ layer holds Invoke-PersonaEngineRun. The entry script
  imports the manifest, which requires Microsoft.Graph.Authentication, so a
  loop living only inside it could not run on a machine without the Graph SDK
  and SC-004 could not be proven at all. The entry script is now a thin
  wrapper and what ships is what is tested.

  The invalid-config corpus is generated by a committed script, with the
  generated fixtures committed too, so a reviewer sees the fixture in the diff.

Defects found by running the code, not by reading it

  Group and role ID lists were double-wrapped: @(Get-PersonaGroupIdPage ...)
  around a comma-returned array collapsed every membership list into one
  bogus space-joined entry. That is a silent false non-match, exactly what
  FR-013 exists to prevent.

  A 403 whose status appears only in the exception message parsed as $null,
  which the retry policy treats as a transport error - five requests per
  account against a tenant already refusing. Status extraction now falls back
  to the message text, bounded to 400-599.

  The sanitization scan walked tracked files only, so it covered 34 of 156
  files and none of this phase's code. It now scans untracked non-ignored
  files too, and a negative control confirms it catches a planted leak.

  Test-Json reports one error per violating location, not first-failure-only
  as the V-5a draft claimed. Record and pin corrected.

Enforcement remains blocked on the V-4 security sign-off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 21:48:19 -04:00
parent c59c85dd55
commit cdc6bb33d3
124 changed files with 16638 additions and 199 deletions
+131
View File
@@ -0,0 +1,131 @@
function Resolve-UserPersona {
<#
.SYNOPSIS
Produces the authoritative persona decision for one user.
.DESCRIPTION
The engine's core. Pure: it takes a normalized record and a rule set and
returns a decision. No Graph, no authentication, no console, no filesystem,
no clock (constitution Principle IV, enforced by tests/Test-EnginePurity.ps1).
Evaluation order (FR-008, FR-009):
1. Discard disabled rules.
2. Sort by ascending priority — lower evaluates first (RE-002).
3. Evaluate in order and STOP at the first True.
Outcomes are mutually exclusive (SC-001):
Matched a rule returned True
Unclassified every enabled rule returned False (FR-010)
EvaluationError any rule returned Unknown before a match was found
The EvaluationError rule is subtle and deliberate: an Unknown encountered
at priority 30 stops evaluation even though a lower-priority rule might
have matched. Continuing would risk assigning a persona from priority 900
when the account may in truth have matched at 30 — precisely the
privilege-downgrade misclassification FR-013 exists to prevent. Preserving
the stored value is the only safe answer.
Timing uses a monotonic stopwatch rather than the wall clock, so no
wall-clock value can influence a decision.
.PARAMETER UserRecord
Normalized record from New-PersonaUserRecord.
.PARAMETER Rules
The business rule collection.
.PARAMETER MaxDepth
Maximum condition nesting depth (RE-004).
.PARAMETER DefaultMembershipMode
Membership mode for conditions that do not specify one (RE-007).
.PARAMETER IncludeTrace
Populates ConditionTrace with per-rule diagnostic results. Off by default;
the caller gates this behind -Debug (Principle V).
.OUTPUTS
PersonaEngine.PersonaDecisionResult
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[object] $UserRecord,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[object[]] $Rules,
[ValidateRange(1, 10)]
[int] $MaxDepth = 5,
[ValidateSet('Direct', 'Transitive')]
[string] $DefaultMembershipMode = 'Direct',
[switch] $IncludeTrace
)
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$outcome = 'Unclassified'
$matchedRuleId = $null
$calculatedPersona = 'Unclassified'
$errorReason = $null
$rulesEvaluated = 0
$trace = [System.Collections.Generic.List[object]]::new()
# Sort by priority, then by Id. The Id tiebreak matters: duplicate priorities
# are a validation error, but if one reaches the engine the result must still
# be the same on every run and every host (SC-003) rather than depending on
# collection order.
$ordered = @($Rules) |
Where-Object { $_.enabled } |
Sort-Object -Property @{ Expression = { [int]$_.priority } }, @{ Expression = { [string]$_.id } }
foreach ($rule in $ordered) {
$rulesEvaluated++
$result = Test-PersonaRule -Rule $rule -UserRecord $UserRecord `
-MaxDepth $MaxDepth -DefaultMembershipMode $DefaultMembershipMode
if ($IncludeTrace) {
$trace.Add([pscustomobject]@{
RuleId = [string]$rule.id
Priority = [int]$rule.priority
Result = $result
})
}
if ($result -eq 'True') {
$outcome = 'Matched'
$matchedRuleId = [string]$rule.id
$calculatedPersona = [string]$rule.persona
break
}
if ($result -eq 'Unknown') {
$outcome = 'EvaluationError'
$calculatedPersona = $null
$errorReason = "Rule '$([string]$rule.id)' could not be evaluated: required data was unavailable or the condition could not be interpreted."
break
}
}
$stopwatch.Stop()
[pscustomobject]@{
PSTypeName = 'PersonaEngine.PersonaDecisionResult'
AccountObjectId = $UserRecord.AccountObjectId
UserPrincipalName = $UserRecord.UserPrincipalName
Outcome = $outcome
MatchedRuleId = $matchedRuleId
CalculatedPersona = $calculatedPersona
StoredPersona = $UserRecord.StoredPersona
Action = 'Pending' # set by the comparison stage (Compare-PersonaValue)
EvaluationErrorReason = $errorReason
RulesEvaluated = $rulesEvaluated
DurationMs = [int]$stopwatch.ElapsedMilliseconds
ConditionTrace = $IncludeTrace ? $trace.ToArray() : $null
}
}
+235
View File
@@ -0,0 +1,235 @@
function Test-PersonaCondition {
<#
.SYNOPSIS
Evaluates one leaf condition against a normalized user record.
.DESCRIPTION
Returns 'True', 'False', or 'Unknown' — never a boolean.
The tri-state is the whole safety argument (FR-013). A boolean return has
no way to distinguish "the user is not in that group" from "we could not
find out", and collapsing the second into the first is exactly how an
unavailable data source misclassifies a privileged account as an ordinary
user. 'Unknown' propagates upward and ultimately produces EvaluationError,
which preserves the stored persona.
Null handling (FR-012): for ordinary string comparisons an absent or null
property is treated as an empty string and never fails evaluation. The
isNull / isNotNull operators exist for intentional null matching, and treat
both $null and the empty string as null.
Comparison is case-insensitive (RE-006).
.PARAMETER Condition
A leaf condition object with Type, Operator, and the operands its operator
requires.
.PARAMETER UserRecord
The normalized record produced by New-PersonaUserRecord.
.PARAMETER DefaultMembershipMode
Membership mode used when the condition does not specify one (RE-007).
.OUTPUTS
System.String — 'True', 'False', or 'Unknown'.
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory)]
[object] $Condition,
[Parameter(Mandatory)]
[object] $UserRecord,
[ValidateSet('Direct', 'Transitive')]
[string] $DefaultMembershipMode = 'Direct'
)
$operator = [string]$Condition.operator
$type = if ($Condition.type) { [string]$Condition.type } else { 'property' }
switch ($type) {
{ $_ -in @('membership', 'role') } {
return Test-PersonaMembershipCondition -Condition $Condition -UserRecord $UserRecord -DefaultMembershipMode $DefaultMembershipMode
}
'property' {
$name = [string]$Condition.property
$raw = $UserRecord.Properties[$name]
# Intentional null matching happens before the empty-string coercion,
# otherwise isNull could never be true.
switch ($operator) {
'isNull' { return (Test-PersonaValueIsNull $raw) ? 'True' : 'False' }
'isNotNull' { return (Test-PersonaValueIsNull $raw) ? 'False' : 'True' }
}
# FR-012: null and absent both compare as empty.
$value = if ($null -eq $raw) { '' } else { [string]$raw }
switch ($operator) {
'equals' { return (Test-PersonaStringEquals $value ([string]$Condition.value)) ? 'True' : 'False' }
'notEquals' { return (Test-PersonaStringEquals $value ([string]$Condition.value)) ? 'False' : 'True' }
'contains' { return ($value.ToLowerInvariant().Contains(([string]$Condition.value).ToLowerInvariant())) ? 'True' : 'False' }
'notContains' { return ($value.ToLowerInvariant().Contains(([string]$Condition.value).ToLowerInvariant())) ? 'False' : 'True' }
'startsWith' { return ($value.StartsWith([string]$Condition.value, [System.StringComparison]::OrdinalIgnoreCase)) ? 'True' : 'False' }
'endsWith' { return ($value.EndsWith([string]$Condition.value, [System.StringComparison]::OrdinalIgnoreCase)) ? 'True' : 'False' }
'matchesRegex' {
$pattern = [string]$Condition.value
# RE-006: validate before execution. An invalid pattern is a
# configuration defect, and treating it as a non-match would
# hide the defect behind a plausible-looking result.
try {
$regex = [regex]::new($pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
}
catch {
return 'Unknown'
}
return ($regex.IsMatch($value)) ? 'True' : 'False'
}
'in' {
foreach ($candidate in @($Condition.values)) {
if (Test-PersonaStringEquals $value ([string]$candidate)) { return 'True' }
}
return 'False'
}
'notIn' {
foreach ($candidate in @($Condition.values)) {
if (Test-PersonaStringEquals $value ([string]$candidate)) { return 'False' }
}
return 'True'
}
default {
# An unsupported operator reaching evaluation means validation
# let it through. Unknown preserves the stored value rather than
# inventing a decision from a condition nobody can interpret.
return 'Unknown'
}
}
}
default { return 'Unknown' }
}
}
function Test-PersonaMembershipCondition {
<#
.SYNOPSIS
Evaluates a membership or directory-role condition. Internal helper.
.DESCRIPTION
Selects the facet of the membership record that answers the question the
condition actually asks — direct groups, transitive groups, or directory
roles — and returns 'Unknown' if that specific facet was not retrieved
(FR-013).
Facet selection is deliberately exact. Transitive membership is a superset
of direct, so answering a direct question from transitive data would produce
false positives, and answering a transitive question from direct data would
produce false negatives. Neither is acceptable when the answer decides
whether an account is classified as an administrator.
Because each facet carries its own retrieval status, a failure in one does
not contaminate the others: a transitive lookup that times out leaves
direct-membership conditions fully evaluable.
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory)] [object] $Condition,
[Parameter(Mandatory)] [object] $UserRecord,
[string] $DefaultMembershipMode = 'Direct'
)
$membership = $UserRecord.Membership
if ($null -eq $membership) { return 'Unknown' }
$isRole = ([string]$Condition.type -eq 'role')
if ($isRole) {
if (-not $membership.RolesRetrieved) { return 'Unknown' }
$haystack = @($membership.DirectoryRoleIds)
$needles = @($Condition.roleIds)
}
else {
$requestedMode = if ($Condition.membershipMode) { [string]$Condition.membershipMode } else { $DefaultMembershipMode }
switch ($requestedMode.ToLowerInvariant()) {
'transitive' {
if (-not $membership.TransitiveRetrieved) { return 'Unknown' }
$haystack = @($membership.TransitiveGroupObjectIds)
}
'direct' {
if (-not $membership.DirectRetrieved) { return 'Unknown' }
$haystack = @($membership.DirectGroupObjectIds)
}
default { return 'Unknown' }
}
$needles = @($Condition.groupObjectIds)
}
$found = $false
foreach ($needle in $needles) {
foreach ($held in $haystack) {
if (Test-PersonaStringEquals ([string]$held) ([string]$needle)) {
$found = $true
break
}
}
if ($found) { break }
}
switch ([string]$Condition.operator) {
'memberOf' { return $found ? 'True' : 'False' }
'notMemberOf' { return $found ? 'False' : 'True' }
default { return 'Unknown' }
}
}
function Test-PersonaStringEquals {
<#
.SYNOPSIS
Case-insensitive ordinal string comparison (RE-006). Internal helper.
#>
[CmdletBinding()]
[OutputType([bool])]
param(
[AllowNull()] [string] $Left,
[AllowNull()] [string] $Right
)
[string]::Equals($Left, $Right, [System.StringComparison]::OrdinalIgnoreCase)
}
function Test-PersonaValueIsNull {
<#
.SYNOPSIS
Determines whether a property value counts as null. Internal helper.
.DESCRIPTION
Both $null and the empty string count. A directory routinely returns an
empty string for a cleared attribute, and a rule author asking "is this
unset" means the same thing in both cases.
#>
[CmdletBinding()]
[OutputType([bool])]
param(
[Parameter(Position = 0)]
[AllowNull()]
[object] $Value
)
if ($null -eq $Value) { return $true }
return [string]::IsNullOrEmpty([string]$Value)
}
@@ -0,0 +1,101 @@
function Test-PersonaConditionGroup {
<#
.SYNOPSIS
Evaluates an all/any condition group, propagating Unknown correctly.
.DESCRIPTION
The propagation table (data-model.md) is the safety argument in four rows:
all + any False -> False a definite non-match wins
all + only True and Unknown -> Unknown cannot confirm
any + any True -> True a definite match wins
any + only False and Unknown -> Unknown cannot rule out
The two "definite wins" rows matter as much as the two Unknown rows. If an
'all' group already contains a False, the result is False regardless of any
unknown sibling — the rule cannot match either way, so degrading to
EvaluationError there would produce spurious errors and mask real ones.
Depth is bounded (RE-004). The root group is depth 1.
.PARAMETER Group
A condition group with operator 'all' or 'any' and a conditions collection.
.PARAMETER UserRecord
The normalized record to evaluate against.
.PARAMETER MaxDepth
Maximum nesting depth. Default 5, hard ceiling 10.
.PARAMETER CurrentDepth
Internal recursion counter. Callers leave this at its default.
.OUTPUTS
System.String — 'True', 'False', or 'Unknown'.
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory)]
[object] $Group,
[Parameter(Mandatory)]
[object] $UserRecord,
[ValidateRange(1, 10)]
[int] $MaxDepth = 5,
[ValidateSet('Direct', 'Transitive')]
[string] $DefaultMembershipMode = 'Direct',
[int] $CurrentDepth = 1
)
# Exceeding the depth limit is a configuration defect that validation should
# have caught. Unknown rather than silent truncation: a truncated condition
# tree evaluates a rule the author did not write.
if ($CurrentDepth -gt $MaxDepth) { return 'Unknown' }
$operator = ([string]$Group.operator).ToLowerInvariant()
if ($operator -notin @('all', 'any')) { return 'Unknown' }
$children = @($Group.conditions)
if ($children.Count -eq 0) { return 'Unknown' }
$sawUnknown = $false
foreach ($child in $children) {
# A child is a group when it carries its own conditions collection.
$isGroup = $null -ne $child.PSObject.Properties['conditions'] -and $null -ne $child.conditions
$result = if ($isGroup) {
Test-PersonaConditionGroup -Group $child -UserRecord $UserRecord `
-MaxDepth $MaxDepth -DefaultMembershipMode $DefaultMembershipMode `
-CurrentDepth ($CurrentDepth + 1)
}
else {
Test-PersonaCondition -Condition $child -UserRecord $UserRecord `
-DefaultMembershipMode $DefaultMembershipMode
}
switch ($result) {
'Unknown' { $sawUnknown = $true }
'False' {
# Short-circuit only on the definite result that decides the group.
if ($operator -eq 'all') { return 'False' }
}
'True' {
if ($operator -eq 'any') { return 'True' }
}
}
}
# No definite result decided the group. If anything was unknown, the group is
# unknown; otherwise every child agreed with the group's identity element.
if ($sawUnknown) { return 'Unknown' }
return ($operator -eq 'all') ? 'True' : 'False'
}
+40
View File
@@ -0,0 +1,40 @@
function Test-PersonaRule {
<#
.SYNOPSIS
Evaluates a single business rule's root condition group against a user.
.DESCRIPTION
Returns 'True', 'False', or 'Unknown'. Disabled rules are not evaluated
here — Resolve-UserPersona filters them out before evaluation so they are
excluded from the enabled rule count as well as from the result.
.PARAMETER Rule
A business rule with a match condition group.
.PARAMETER UserRecord
The normalized record to evaluate against.
.OUTPUTS
System.String — 'True', 'False', or 'Unknown'.
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory)]
[object] $Rule,
[Parameter(Mandatory)]
[object] $UserRecord,
[ValidateRange(1, 10)]
[int] $MaxDepth = 5,
[ValidateSet('Direct', 'Transitive')]
[string] $DefaultMembershipMode = 'Direct'
)
if ($null -eq $Rule.match) { return 'Unknown' }
Test-PersonaConditionGroup -Group $Rule.match -UserRecord $UserRecord `
-MaxDepth $MaxDepth -DefaultMembershipMode $DefaultMembershipMode
}