Files
personaEngine2/Invoke-PersonaEngine.ps1
T
dave cdc6bb33d3 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>
2026-08-20 21:48:19 -04:00

256 lines
10 KiB
PowerShell

#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