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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user