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,255 @@
|
||||
#Requires -Version 7.2
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Classifies Entra ID user accounts against a configuration-driven rule set.
|
||||
|
||||
.DESCRIPTION
|
||||
The engine entry point: validate, connect, enumerate, evaluate, report, and -
|
||||
only when explicitly confirmed - persist.
|
||||
|
||||
SAFETY MODEL
|
||||
|
||||
Mode is derived from $PSCmdlet.ShouldProcess() and nothing else. There is no
|
||||
-Preview switch and no configuration key that suppresses writes. Two sources of
|
||||
truth for a write gate is precisely the defect class constitution Principle III
|
||||
exists to prevent: the day they disagree, one of them is wrong and the directory
|
||||
finds out first.
|
||||
|
||||
-WhatIf is the approved no-write control. Under it, reads happen, rules evaluate,
|
||||
values are compared, console output and summaries appear, and audit records are
|
||||
written exactly as they would be in enforcement. The single difference is that no
|
||||
write request is ever constructed (FR-017, SC-004).
|
||||
|
||||
-Debug enables condition tracing. It does NOT imply read-only. A -Debug run
|
||||
without -WhatIf writes, and a test asserts that it does - because an operator who
|
||||
believed otherwise would reach for -Debug as a safety measure.
|
||||
|
||||
.PARAMETER ConfigPath
|
||||
Path to the JSON configuration. Validated through all four layers before any
|
||||
connection is attempted (FR-002).
|
||||
|
||||
.PARAMETER UserObjectId
|
||||
Evaluate a single user instead of enumerating the tenant. The recommended first
|
||||
run against any new configuration.
|
||||
|
||||
.PARAMETER OutputPath
|
||||
Overrides logging.path for this run.
|
||||
|
||||
.PARAMETER CorrelationId
|
||||
Run identifier. Generated when absent. Appears on every audit record (NFR-005).
|
||||
|
||||
.PARAMETER SchemaPath
|
||||
Schema override for validation layer 2.
|
||||
|
||||
.PARAMETER PreviousConfigPath
|
||||
Currently deployed configuration, enabling the VR-003 drift checks.
|
||||
|
||||
.EXAMPLE
|
||||
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf
|
||||
|
||||
The standard preview run. Reports what would change; writes nothing.
|
||||
|
||||
.EXAMPLE
|
||||
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf -Verbose
|
||||
|
||||
Single-user preview, the recommended first run against a new configuration.
|
||||
|
||||
.OUTPUTS
|
||||
Audit records on the success stream when logging.destination includes 'stream'.
|
||||
The process exit code carries the run status; see the exit code table in
|
||||
specs/001-persona-engine/contracts/cli-invoke-persona-engine.md.
|
||||
|
||||
.NOTES
|
||||
Exit codes
|
||||
0 successful run
|
||||
1 configuration validation failure
|
||||
2 authentication or authorization failure
|
||||
3 user enumeration failure
|
||||
4 fatal required data-provider failure, or evaluationErrorThreshold exceeded
|
||||
5 reconciliation failure
|
||||
6 unexpected fatal engine error
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $ConfigPath,
|
||||
|
||||
[guid] $UserObjectId,
|
||||
|
||||
[string] $OutputPath,
|
||||
|
||||
[guid] $CorrelationId,
|
||||
|
||||
[string] $SchemaPath,
|
||||
|
||||
[string] $PreviousConfigPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$engineVersion = '0.1.0'
|
||||
$startedUtc = [DateTime]::UtcNow
|
||||
$runId = $CorrelationId -and $CorrelationId -ne [guid]::Empty ? $CorrelationId.ToString() : [guid]::NewGuid().ToString()
|
||||
|
||||
Import-Module (Join-Path $PSScriptRoot 'PersonaEngine.psd1') -Force -ErrorAction Stop
|
||||
|
||||
# Exit codes are named rather than sprinkled as literals so the contract table and
|
||||
# the code cannot drift apart.
|
||||
$EXIT_OK = 0
|
||||
$EXIT_CONFIG = 1
|
||||
$EXIT_AUTH = 2
|
||||
$EXIT_ENUMERATION = 3
|
||||
$EXIT_DATA = 4
|
||||
$EXIT_RECONCILIATION = 5
|
||||
$EXIT_UNEXPECTED = 6
|
||||
|
||||
$exitCode = $EXIT_OK
|
||||
$counters = $null
|
||||
$context = $null
|
||||
$sinkState = New-PersonaAuditSinkState
|
||||
|
||||
try {
|
||||
|
||||
# ======================================================== 1. Validate (FR-002)
|
||||
|
||||
Write-Verbose "Validating configuration: $ConfigPath"
|
||||
|
||||
$validationParams = @{ Path = $ConfigPath }
|
||||
if ($SchemaPath) { $validationParams['SchemaPath'] = $SchemaPath }
|
||||
if ($PreviousConfigPath) { $validationParams['PreviousConfigPath'] = $PreviousConfigPath }
|
||||
|
||||
$validation = Test-PersonaConfiguration @validationParams
|
||||
|
||||
if (-not $validation.IsValid) {
|
||||
Write-PersonaValidationFinding -Findings $validation.Findings
|
||||
Write-Host "Configuration validation failed with $($validation.ErrorCount) error(s). No connection was attempted." -ForegroundColor Red
|
||||
exit $EXIT_CONFIG
|
||||
}
|
||||
|
||||
if ($validation.WarningCount -gt 0) {
|
||||
Write-PersonaValidationFinding -Findings @($validation.Findings | Where-Object Severity -NE 'Information')
|
||||
}
|
||||
|
||||
$config = Import-PersonaConfiguration -Path $ConfigPath
|
||||
$target = Resolve-TargetAttribute -Configuration $config
|
||||
|
||||
# ======================================================== 2. Mode (FR-016, FR-017)
|
||||
|
||||
# The single write gate. Called once, before anything is read, so the mode is
|
||||
# known when the RunStart record is written and cannot change mid-run.
|
||||
#
|
||||
# Under -WhatIf this returns $false without prompting. Without -WhatIf, the High
|
||||
# confirm impact means the operator is asked to confirm the run; answering
|
||||
# "Yes to All" also satisfies the per-user gate below without further prompts.
|
||||
$runConfirmed = $PSCmdlet.ShouldProcess(
|
||||
"Entra ID directory - $($config.SourcePath)",
|
||||
"Classify users and write '$target' where the calculated persona differs")
|
||||
|
||||
$mode = $runConfirmed ? 'Enforce' : 'Preview'
|
||||
|
||||
$context = New-PersonaAuditContext -RunId $runId -EngineVersion $engineVersion -Configuration $config -Mode $mode
|
||||
|
||||
$destination = $config.Logging.destination ? [string]$config.Logging.destination : 'stream'
|
||||
$logPath = $OutputPath ? $OutputPath : [string]$config.Logging.path
|
||||
$auditParams = @{ Destination = $destination; Path = $logPath; State = $sinkState }
|
||||
|
||||
# Tracing is enabled by -Debug or by configuration, and requires acknowledgement
|
||||
# in the configuration either way (VR-003, enforced in validation layer 4).
|
||||
$traceRequested = $PSBoundParameters.ContainsKey('Debug') -or
|
||||
($config.Logging.traceConditionValues -and [bool]$config.Logging.traceConditionValues)
|
||||
$traceAcknowledged = $config.Logging.PSObject.Properties['acknowledgeConditionTracing'] -and
|
||||
[bool]$config.Logging.acknowledgeConditionTracing
|
||||
$tracing = [bool]($traceRequested -and $traceAcknowledged)
|
||||
|
||||
if ($traceRequested -and -not $traceAcknowledged) {
|
||||
Write-Warning 'Condition tracing was requested but logging.acknowledgeConditionTracing is not set. Tracing is disabled for this run (VR-003).'
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "Persona Engine $engineVersion run $runId mode $mode" -ForegroundColor Cyan
|
||||
Write-Host "Configuration $($config.ConfigVersion) hash $($config.ConfigurationHash.Substring(0, 16))..." -ForegroundColor DarkGray
|
||||
if ($mode -eq 'Preview') {
|
||||
Write-Host 'PREVIEW - no write request will be constructed or sent.' -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host ''
|
||||
|
||||
New-PersonaAuditRecord -Context $context -RecordType 'RunStart' -Properties @{
|
||||
configPath = $config.SourcePath
|
||||
targetAttribute = $target
|
||||
ruleCount = @($config.Rules).Count
|
||||
enabledRules = @($config.Rules | Where-Object { $_.enabled }).Count
|
||||
singleUser = [bool]($UserObjectId -and $UserObjectId -ne [guid]::Empty)
|
||||
tracing = $tracing
|
||||
} | Write-PersonaAuditRecord @auditParams
|
||||
|
||||
# ======================================================== 3. Connect (FR-003)
|
||||
|
||||
$facets = Get-PersonaRequiredFacets -Rules $config.Rules -DefaultMembershipMode $config.DefaultMembershipMode
|
||||
|
||||
try {
|
||||
$null = Connect-PersonaGraphInteractive `
|
||||
-IncludeGroups:([bool]($facets.Direct -or $facets.Transitive)) `
|
||||
-IncludeRoles:([bool]$facets.Roles) `
|
||||
-IncludeWrite:$runConfirmed
|
||||
}
|
||||
catch {
|
||||
Write-Host "Authentication failed: $($_.Exception.Message)" -ForegroundColor Red
|
||||
$exitCode = $EXIT_AUTH
|
||||
throw
|
||||
}
|
||||
|
||||
# ======================================================== 4. Run
|
||||
|
||||
# The per-user gate, passed down rather than re-derived. $PSCmdlet.ShouldProcess
|
||||
# remains the single origin of the write decision; the run loop never learns what
|
||||
# -WhatIf is and so cannot disagree with it.
|
||||
$gate = { param($Target, $Description) $PSCmdlet.ShouldProcess($Target, $Description) }.GetNewClosure()
|
||||
|
||||
$runParams = @{
|
||||
Configuration = $config
|
||||
TargetAttribute = $target
|
||||
Context = $context
|
||||
AuditParameters = $auditParams
|
||||
IsEnforcing = $runConfirmed
|
||||
ShouldProcessGate = $gate
|
||||
Tracing = $tracing
|
||||
}
|
||||
if ($UserObjectId -and $UserObjectId -ne [guid]::Empty) { $runParams['UserObjectId'] = $UserObjectId.ToString() }
|
||||
|
||||
$outcome = Invoke-PersonaEngineRun @runParams
|
||||
|
||||
$counters = $outcome.Counters
|
||||
if ($outcome.ExitCode -ne $EXIT_OK) { $exitCode = $outcome.ExitCode }
|
||||
|
||||
if ($outcome.FailureReason) {
|
||||
Write-Host "Run failed: $($outcome.FailureReason)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if ($exitCode -eq $EXIT_RECONCILIATION) {
|
||||
Write-Host 'Reconciliation failed. This is an engine defect, not a data condition (FR-021).' -ForegroundColor Red
|
||||
}
|
||||
elseif ($exitCode -eq $EXIT_DATA) {
|
||||
Write-Host ("EvaluationError count {0} exceeds the configured threshold of {1}. Reporting the run as failed." -f
|
||||
$counters.EvaluationError, $config.EvaluationErrorThreshold) -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($exitCode -eq $EXIT_OK) { $exitCode = $EXIT_UNEXPECTED }
|
||||
|
||||
Write-Host "Run terminated: $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Verbose $_.ScriptStackTrace
|
||||
}
|
||||
finally {
|
||||
# RunComplete is written even on a fatal error. A run that died at user 400 of
|
||||
# 5000 leaves a record saying exactly that, which is what distinguishes "stopped
|
||||
# early" from "never started" - two very different incidents that otherwise
|
||||
# produce identical evidence.
|
||||
if ($null -ne $context -and $null -ne $counters) {
|
||||
Export-PersonaRunReport -Context $context -Counters $counters -StartedUtc $startedUtc -ExitCode $exitCode |
|
||||
Write-PersonaAuditRecord @auditParams
|
||||
}
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
Reference in New Issue
Block a user