Files
personaEngine2/Invoke-PersonaEngine.ps1
T

256 lines
10 KiB
PowerShell
Raw Normal View History

#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