2026-08-20 21:48:19 -04:00
|
|
|
#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
|
2026-08-24 10:01:21 -04:00
|
|
|
Overrides logging.path for this run. When neither this nor logging.path is set,
|
|
|
|
|
audit records are written to '<current-directory>/logs/persona-engine-audit.ndjson'.
|
2026-08-20 21:48:19 -04:00
|
|
|
|
|
|
|
|
.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'
|
2026-08-24 10:01:21 -04:00
|
|
|
|
|
|
|
|
# No -OutputPath and no logging.path means "just log somewhere sane" rather than
|
|
|
|
|
# "log nowhere" - an operator running a plain -WhatIf shouldn't have to configure
|
|
|
|
|
# a file path just to see what the engine would have done.
|
|
|
|
|
$configuredPath = [string]$config.Logging.path
|
|
|
|
|
$defaultLogPath = Join-Path (Get-Location).Path 'logs' 'persona-engine-audit.ndjson'
|
|
|
|
|
$logPath = $OutputPath ? $OutputPath : ($configuredPath ? $configuredPath : $defaultLogPath)
|
2026-08-20 21:48:19 -04:00
|
|
|
$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
|