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
+380
View File
@@ -0,0 +1,380 @@
<#
Shared test helpers.
Loads the module's source files by dot-sourcing each layer rather than importing
PersonaEngine.psd1. Two reasons, both load-bearing:
1. The manifest declares Microsoft.Graph.Authentication as a required module.
Importing it would pull that module into the session, and SC-008 requires
the offline suites to run with no Graph module loaded at all. Dot-sourcing
is the practical proof that the pure layers do not need it.
2. Pester's Mock replaces functions in the scope where they are defined.
Dot-sourced functions land in the test file's scope, so mocking
Get-PersonaUsers or Set-UserPersonaAttribute works without -ModuleName
plumbing on every call.
Nothing here fabricates engine behaviour. The functions under test are the
shipped ones; only the Graph boundary is replaced.
#>
if (-not (Get-Command 'Invoke-MgGraphRequest' -ErrorAction SilentlyContinue)) {
function Invoke-MgGraphRequest {
<#
.SYNOPSIS
Stand-in for the real cmdlet when Microsoft.Graph.Authentication is absent.
.DESCRIPTION
SC-008 requires the offline suites to run with no Graph module loaded, and
Pester cannot mock a command that does not exist. This stub gives Mock
something to replace.
It throws if it is ever actually called. A stub that returned plausible
data would let a test pass while silently exercising nothing, which is
worse than no test at all.
#>
[CmdletBinding()]
param(
[string] $Uri,
[string] $Method,
[object] $Body,
[string] $ContentType,
[hashtable] $Headers,
[string] $OutputType
)
throw 'Invoke-MgGraphRequest stub was called without being mocked. A test reached the real Graph boundary.'
}
}
function Get-PersonaSourceFile {
<#
.SYNOPSIS
Returns the module's source files in load order, for the caller to dot-source.
.DESCRIPTION
Returns paths rather than dot-sourcing them itself. Dot-sourcing inside a
function loads into that function's scope, which disappears when it returns -
the functions would be defined and immediately unreachable. The caller has to
do it:
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
The Authentication layer is excluded by default: it is the one layer that
calls Connect-MgGraph, and loading it is unnecessary for any offline suite.
#>
[CmdletBinding()]
[OutputType([string[]])]
param(
[Parameter(Mandatory)]
[string] $RepoRoot,
[string[]] $Layers = @(
'Normalization', 'Configuration', 'RuleEngine',
'DataProviders', 'Persistence', 'Presentation', 'Engine', 'Audit'
)
)
$files = [System.Collections.Generic.List[string]]::new()
foreach ($layer in $Layers) {
$path = Join-Path $RepoRoot "src/$layer"
if (-not (Test-Path $path)) { continue }
foreach ($file in (Get-ChildItem -Path $path -Filter '*.ps1' -File | Sort-Object Name)) {
$files.Add($file.FullName)
}
}
, $files.ToArray()
}
function New-TestConfigurationDocument {
<#
.SYNOPSIS
Builds a minimal valid configuration document as a hashtable.
.DESCRIPTION
Tests that need an INVALID configuration start from this and break exactly
one thing, so the finding under test is unambiguously caused by that one
change rather than by an unrelated defect in a hand-written fixture.
#>
[CmdletBinding()]
[OutputType([hashtable])]
param()
@{
configVersion = '1.0.0'
engine = @{
targetAttribute = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
approvedWritableAttributes = @('extension_<EXTENSION-APP-ID>_<PERSONA>')
maxConditionDepth = 5
summaryInterval = 25
defaultMembershipMode = 'direct'
}
dataSources = @{
groups = @{ enabled = $true }
roles = @{ enabled = $true }
}
logging = @{ destination = 'stream' }
personas = @('Employee', 'Guest', 'Tier0-Admin')
rules = @(
@{
id = 'RULE-0010-GUEST'
name = 'Guest accounts'
description = 'Accounts whose user type is Guest.'
enabled = $true
priority = 10
persona = 'Guest'
match = @{
operator = 'all'
conditions = @(
@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' }
)
}
}
@{
id = 'RULE-0900-EMPLOYEE'
name = 'Employees'
description = 'Default classification for member accounts with a department.'
enabled = $true
priority = 900
persona = 'Employee'
match = @{
operator = 'all'
conditions = @(
@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }
)
}
}
)
}
}
function Save-TestConfiguration {
<#
.SYNOPSIS
Writes a configuration document to a temporary file and returns its path.
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory)] [hashtable] $Document,
[string] $Directory = ([System.IO.Path]::GetTempPath())
)
$path = Join-Path $Directory ("pe-test-{0}.json" -f [guid]::NewGuid().ToString('N'))
Set-Content -LiteralPath $path -Value ($Document | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
$path
}
function New-TestGraphUser {
<#
.SYNOPSIS
Builds a raw Graph-shaped user hashtable.
.DESCRIPTION
A hashtable, because that is what Invoke-MgGraphRequest returns. Building
fixtures in the shape the real boundary produces is what makes the
normalization tests meaningful.
#>
[CmdletBinding()]
[OutputType([hashtable])]
param(
[Parameter(Mandatory)] [string] $Id,
[Parameter(Mandatory)] [string] $UserPrincipalName,
[string] $DisplayName = 'Test Account',
[string] $UserType = 'Member',
[bool] $AccountEnabled = $true,
[string] $Department = 'Finance',
[string] $CompanyName = '<ORGANIZATION-NAME>',
[string] $TargetAttribute = 'extension_<EXTENSION-APP-ID>_<PERSONA>',
[AllowEmptyString()] [string] $StoredPersona = ''
)
@{
id = $Id
userPrincipalName = $UserPrincipalName
displayName = $DisplayName
userType = $UserType
accountEnabled = $AccountEnabled
department = $Department
companyName = $CompanyName
$TargetAttribute = $StoredPersona
}
}
function New-TestPopulation {
<#
.SYNOPSIS
Builds a synthetic population spanning every outcome and action.
.DESCRIPTION
Deliberately includes accounts that produce Matched, Unclassified, and - via
the membership fixture wired by the caller - EvaluationError, plus accounts
whose stored value already matches and accounts whose value would change. A
zero-write assertion over a population where nothing would change proves
nothing.
#>
[CmdletBinding()]
[OutputType([object[]])]
param(
[int] $Count = 30,
[string] $TargetAttribute = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
)
$users = [System.Collections.Generic.List[object]]::new()
for ($i = 1; $i -le $Count; $i++) {
$id = '00000000-0000-0000-0000-{0:d12}' -f $i
# Every third account is a Guest whose stored value is stale, so a change is
# proposed. Every fifth has no department and matches nothing.
$isGuest = ($i % 3) -eq 0
$isUnclassifiable = -not $isGuest -and ($i % 5) -eq 0
$users.Add((New-TestGraphUser `
-Id $id `
-UserPrincipalName ("user{0:d4}@example.invalid" -f $i) `
-UserType ($isGuest ? 'Guest' : 'Member') `
-Department ($isUnclassifiable ? $null : 'Finance') `
-TargetAttribute $TargetAttribute `
-StoredPersona ($isGuest ? 'Employee' : ($isUnclassifiable ? '' : 'Employee'))))
}
# Emitted unwrapped, so a Mock body returning this call unrolls into the pipeline
# the way a real Get-PersonaUsers does. The `, $array` idiom would emit one
# object containing the array, and @() around the call would then produce a
# single-element population - a mistake that makes a 30-user test silently a
# 1-user test.
$users.ToArray()
}
function New-TestRuntimeConfiguration {
<#
.SYNOPSIS
Builds the configuration object shape Import-PersonaConfiguration produces.
.DESCRIPTION
The run loop consumes the imported object, not the raw JSON document. Tests
that exercise the loop build this directly so they are not also testing the
importer - a failure here should mean the loop is wrong, not that the parser
changed.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[string] $TargetAttribute = 'extension_<EXTENSION-APP-ID>_<PERSONA>',
[int] $SummaryInterval = 0,
[AllowNull()] [object] $EvaluationErrorThreshold = $null,
[object[]] $Rules,
[string] $DefaultMembershipMode = 'Direct'
)
if (-not $Rules) {
$Rules = @(
[pscustomobject]@{
id = 'RULE-0010-GUEST'; name = 'Guests'; enabled = $true; priority = 10; persona = 'Guest'
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' })
}
}
[pscustomobject]@{
id = 'RULE-0900-EMPLOYEE'; name = 'Employees'; enabled = $true; priority = 900; persona = 'Employee'
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
}
)
}
[pscustomobject]@{
ConfigVersion = '1.0.0'
ConfigurationHash = ('0' * 64)
SourcePath = '<CONFIG-PATH>'
TargetAttribute = $TargetAttribute
ApprovedWritableAttributes = @($TargetAttribute)
MaxConditionDepth = 5
SummaryInterval = $SummaryInterval
DefaultMembershipMode = $DefaultMembershipMode
EvaluationErrorThreshold = $EvaluationErrorThreshold
Rules = $Rules
}
}
function New-TestMembershipRule {
<#
.SYNOPSIS
A rule requiring group membership, so a failed lookup becomes EvaluationError.
#>
[CmdletBinding()]
param(
[string] $GroupObjectId = '00000000-0000-0000-0000-0000000000a0',
[int] $Priority = 30,
[string] $Persona = 'Tier0-Admin'
)
[pscustomobject]@{
id = 'RULE-0030-TIER0'; name = 'Tier 0 administrators'; enabled = $true
priority = $Priority; persona = $Persona
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'membership'; operator = 'memberOf'; groupObjectIds = @($GroupObjectId) })
}
}
}
function Get-CapturedAuditRecord {
<#
.SYNOPSIS
Extracts audit records from a captured Information stream.
.DESCRIPTION
Write-PersonaAuditRecord emits records on the Information stream so the
success stream stays free for the run outcome. Captured entries arrive as
InformationRecord wrappers; this unwraps them and optionally filters by type.
Usage:
$info = $null
$outcome = Invoke-PersonaEngineRun ... -InformationVariable info
$events = Get-CapturedAuditRecord -Captured $info -RecordType 'UserEvent'
#>
[CmdletBinding()]
param(
[AllowNull()] [object] $Captured,
[string] $RecordType
)
$records = foreach ($entry in @($Captured)) {
$record = ($entry -is [System.Management.Automation.InformationRecord]) ? $entry.MessageData : $entry
if ($record -is [System.Collections.IDictionary]) { $record }
}
# Both returns use the comma idiom. Without it a single matching record is
# unrolled onto the pipeline and the caller assigns the dictionary itself, so
# .Count reports the key count and [0] indexes a field rather than a record -
# which fails as a confusing type mismatch rather than as a missing record.
if ($RecordType) { return , @($records | Where-Object { $_['recordType'] -eq $RecordType }) }
, @($records)
}
function New-TestAuditContext {
<#
.SYNOPSIS
Builds an audit context without needing a real configuration file.
#>
[CmdletBinding()]
param(
[string] $Mode = 'Preview',
[string] $RunId = '00000000-0000-0000-0000-00000000f001'
)
New-PersonaAuditContext -RunId $RunId -EngineVersion '0.1.0' -Mode $Mode -Configuration ([pscustomobject]@{
ConfigVersion = '1.0.0'
ConfigurationHash = ('0' * 64)
})
}