#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). Defaults to ./config/persona-engine.json, resolved against the current directory, when omitted. .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. When neither this nor logging.path is set, audit records are written to '/logs/persona-engine-audit.ndjson'. .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 -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( [ValidateNotNullOrEmpty()] [string] $ConfigPath = (Join-Path (Get-Location).Path 'config' 'persona-engine.json'), [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' # 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) $auditParams = @{ Destination = $destination; Path = $logPath; State = $sinkState } # Lives next to the audit log rather than under its own config key for path - # one directory to point an operator at, not two. The file name alone is # configurable because "results.csv" may collide with something else already # written there. $resultsFileName = $config.Logging.resultsFileName ? [string]$config.Logging.resultsFileName : 'results.csv' $resultsPath = Join-Path (Split-Path -Parent $logPath) $resultsFileName # 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 StartedUtc = $startedUtc ResultsPath = $resultsPath } 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