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,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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user