Add elapsed run time, per-account results CSV, and a default config path
Summaries now show elapsed wall-clock time since the run started, and every summary (interim and final) overwrites a results.csv (Object ID, UPN, persona/status) next to the audit log, so an operator has a plain export without parsing NDJSON. ConfigPath also now defaults to ./config/persona-engine.json instead of requiring -ConfigPath every run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,8 @@
|
||||
|
||||
.PARAMETER ConfigPath
|
||||
Path to the JSON configuration. Validated through all four layers before any
|
||||
connection is attempted (FR-002).
|
||||
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
|
||||
@@ -73,9 +74,8 @@
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $ConfigPath,
|
||||
[string] $ConfigPath = (Join-Path (Get-Location).Path 'config' 'persona-engine.json'),
|
||||
|
||||
[guid] $UserObjectId,
|
||||
|
||||
@@ -162,6 +162,13 @@ try {
|
||||
$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
|
||||
@@ -222,6 +229,8 @@ try {
|
||||
IsEnforcing = $runConfirmed
|
||||
ShouldProcessGate = $gate
|
||||
Tracing = $tracing
|
||||
StartedUtc = $startedUtc
|
||||
ResultsPath = $resultsPath
|
||||
}
|
||||
if ($UserObjectId -and $UserObjectId -ne [guid]::Empty) { $runParams['UserObjectId'] = $UserObjectId.ToString() }
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
'Add-PersonaRunResult'
|
||||
'Test-PersonaReconciliation'
|
||||
'Get-PersonaReconciliationDetail'
|
||||
'Export-PersonaResultsCsv'
|
||||
# Audit
|
||||
'New-PersonaAuditContext'
|
||||
'New-PersonaAuditRecord'
|
||||
|
||||
@@ -324,7 +324,7 @@ its Tier 0 group, and one run would quietly demote the entire administrative pop
|
||||
|
||||
| Parameter | Notes |
|
||||
| --- | --- |
|
||||
| `-ConfigPath <string>` | Required. Validated through all four layers before any connection. |
|
||||
| `-ConfigPath <string>` | Defaults to `./config/persona-engine.json`. Validated through all four layers before any connection. |
|
||||
| `-WhatIf` | **The approved no-write control.** |
|
||||
| `-UserObjectId <GUID>` | Single-user execution. |
|
||||
| `-OutputPath <string>` | Overrides `logging.path`. Both default to `<current-directory>/logs/persona-engine-audit.ndjson`. |
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
},
|
||||
"logging": {
|
||||
"destination": "both",
|
||||
"resultsFileName": "results.csv",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"personas": [
|
||||
|
||||
@@ -105,6 +105,12 @@
|
||||
"type": "string",
|
||||
"description": "NDJSON output file for 'file'/'both' destinations. Defaults to <current-directory>/logs/persona-engine-audit.ndjson when unset."
|
||||
},
|
||||
"resultsFileName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"default": "results.csv",
|
||||
"description": "Per-account results CSV (AccountObjectId, UserPrincipalName, persona/status), written alongside the audit log and overwritten on every summary."
|
||||
},
|
||||
"traceConditionValues": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
|
||||
@@ -57,6 +57,7 @@ property-only rules.
|
||||
| --- | --- | --- | --- |
|
||||
| `destination` | no | `both` | `file`, `stream`, `both`, or `none`. `stream` writes records to the PowerShell Information stream. |
|
||||
| `path` | no | `<current-directory>/logs/persona-engine-audit.ndjson` | NDJSON output file. One record per line. |
|
||||
| `resultsFileName` | no | `results.csv` | Per-account results CSV, written next to `path`'s directory. Lists `AccountObjectId`, `UserPrincipalName`, `PersonaStatus` for every account processed so far. Overwritten on every summary, interim and final. |
|
||||
| `traceConditionValues` | no | `false` | Writes evaluated attribute values into audit records. |
|
||||
| `acknowledgeConditionTracing` | no | `false` | **Required whenever `traceConditionValues` is true** (VR-003). |
|
||||
|
||||
|
||||
@@ -38,6 +38,19 @@ warning, **once** per run, and processing continues.
|
||||
Once, not once per user: a run over five thousand accounts with a locked log file should warn once,
|
||||
or the warning that matters is buried in the noise it generates.
|
||||
|
||||
## Results CSV
|
||||
|
||||
Alongside the NDJSON audit log, every summary — interim and final — (re)writes a plain CSV listing
|
||||
every account processed so far: `AccountObjectId`, `UserPrincipalName`, `PersonaStatus` (the assigned
|
||||
persona for `Matched` accounts, otherwise `Unclassified` or `EvaluationError`). It is overwritten in
|
||||
full each time, not appended, so it always reflects the whole run to that point rather than only the
|
||||
accounts since the last summary.
|
||||
|
||||
It is written next to the audit log — same directory as `logging.path` — under `logging.resultsFileName`
|
||||
(default `results.csv`). Export failure never ends a run, for the same reason a sink failure doesn't:
|
||||
a locked file or full disk is an operational problem, not a reason to abandon a classification run
|
||||
mid-population.
|
||||
|
||||
## Record types
|
||||
|
||||
| Type | When | Carries |
|
||||
|
||||
@@ -44,7 +44,11 @@ the header line and `PE-SAF-001`.
|
||||
A summary appears every `summaryInterval` accounts and once at the end, listing **every** rule
|
||||
including disabled and zero-match ones. A rule that never fired and a rule that is not in the
|
||||
configuration look identical if zero-match rules are omitted, and that distinction is usually what
|
||||
you are looking for.
|
||||
you are looking for. Its header shows elapsed wall-clock time since the run started.
|
||||
|
||||
Each summary also (re)writes `logging.resultsFileName` (default `results.csv`, next to the audit log)
|
||||
with one row per account processed so far — Object ID, UPN, and assigned persona/status — for an
|
||||
operator who wants the current population breakdown without parsing NDJSON.
|
||||
|
||||
## Exit codes
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ The engine entry point. Retrieval, evaluation, reporting, and controlled persist
|
||||
```powershell
|
||||
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
|
||||
param(
|
||||
[Parameter(Mandatory)][string] $ConfigPath,
|
||||
[Parameter()][string] $ConfigPath = './config/persona-engine.json',
|
||||
[Parameter()][guid] $UserObjectId,
|
||||
[Parameter()][string] $OutputPath,
|
||||
[Parameter()][guid] $CorrelationId
|
||||
@@ -21,7 +21,7 @@ parameters and are **not** declared.
|
||||
|
||||
| Parameter | Required | Behaviour |
|
||||
| --- | --- | --- |
|
||||
| `-ConfigPath` | Yes | Path to the JSON configuration. Validated through all four layers before any connection is made (FR-002). |
|
||||
| `-ConfigPath` | No | Path to the JSON configuration. Defaults to `./config/persona-engine.json`, resolved against the current directory, when omitted. Validated through all four layers before any connection is made (FR-002). |
|
||||
| `-WhatIf` | No | **The approved no-write control.** Reads, evaluation, comparison, console output, summaries, and audit records all behave identically to enforcement; zero write requests are issued (FR-017, SC-004). |
|
||||
| `-UserObjectId` | No | Single-user execution for validation. Skips enumeration; retrieves one user. |
|
||||
| `-OutputPath` | No | Overrides the configured audit output path where permitted. |
|
||||
@@ -56,9 +56,12 @@ Failing any of these yields `Unchanged`, `WouldUpdate`, or `Skipped` — never a
|
||||
- **Per user, immediately after evaluation** (FR-018, SC-012): one console line carrying UPN,
|
||||
Account Object ID, outcome, matched rule ID, stored value, calculated value, and action.
|
||||
- **Every `summaryInterval` users** (FR-019): a table of all business rules with match counts, plus
|
||||
outcome totals and a reconciliation check.
|
||||
outcome totals, elapsed wall-clock time since the run started, and a reconciliation check.
|
||||
- **At completion**: a final summary regardless of interval, including when the interval is `0`
|
||||
(FR-020).
|
||||
- **Every summary, interim and final**: `logging.resultsFileName` (default `results.csv`, written
|
||||
next to the audit log) is overwritten with one row per account processed so far — Account Object
|
||||
ID, UPN, and the assigned persona or outcome status.
|
||||
- **Reconciliation** at every summary: `Processed = Matched + Unclassified + EvaluationError`
|
||||
(FR-021). A mismatch is logged as an engine defect, at `Error` severity.
|
||||
- **Audit records**: see [audit-record.md](audit-record.md).
|
||||
|
||||
@@ -105,6 +105,12 @@
|
||||
"type": "string",
|
||||
"description": "NDJSON output file for 'file'/'both' destinations. Defaults to <current-directory>/logs/persona-engine-audit.ndjson when unset."
|
||||
},
|
||||
"resultsFileName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"default": "results.csv",
|
||||
"description": "Per-account results CSV (AccountObjectId, UserPrincipalName, persona/status), written alongside the audit log and overwritten on every summary."
|
||||
},
|
||||
"traceConditionValues": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
|
||||
@@ -51,6 +51,16 @@ function Invoke-PersonaEngineRun {
|
||||
.PARAMETER Tracing
|
||||
Include ConditionTrace on results and audit records.
|
||||
|
||||
.PARAMETER StartedUtc
|
||||
Run start timestamp, threaded down to Write-PersonaSummary so each summary
|
||||
can show elapsed wall-clock time. Optional; a caller that omits it just gets
|
||||
summaries without an elapsed figure.
|
||||
|
||||
.PARAMETER ResultsPath
|
||||
Destination for the per-account results CSV. When supplied, it is
|
||||
(re)written after every summary - interim and final. Optional; a caller
|
||||
that omits it skips the CSV export entirely.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.RunOutcome carrying the counters and the exit code.
|
||||
#>
|
||||
@@ -74,7 +84,11 @@ function Invoke-PersonaEngineRun {
|
||||
|
||||
[string] $UserObjectId,
|
||||
|
||||
[switch] $Tracing
|
||||
[switch] $Tracing,
|
||||
|
||||
[Nullable[datetime]] $StartedUtc,
|
||||
|
||||
[string] $ResultsPath
|
||||
)
|
||||
|
||||
$EXIT_OK = 0
|
||||
@@ -190,7 +204,9 @@ function Invoke-PersonaEngineRun {
|
||||
|
||||
if ($Configuration.SummaryInterval -gt 0 -and ($counters.Processed % $Configuration.SummaryInterval) -eq 0) {
|
||||
|
||||
Write-PersonaSummary -Counters $counters -SummaryType 'Interim' -Mode $Context.Mode
|
||||
Write-PersonaSummary -Counters $counters -SummaryType 'Interim' -Mode $Context.Mode -StartedUtc $StartedUtc
|
||||
|
||||
if ($ResultsPath) { Export-PersonaResultsCsv -Counters $counters -Path $ResultsPath }
|
||||
|
||||
New-PersonaAuditRecord -Context $Context -RecordType 'Summary' -Counters $counters `
|
||||
-Properties @{ summaryType = 'Interim' } | Write-PersonaAuditRecord @AuditParameters
|
||||
@@ -206,7 +222,9 @@ function Invoke-PersonaEngineRun {
|
||||
}
|
||||
|
||||
# Always emitted, whatever the interval - including 0 (FR-020).
|
||||
Write-PersonaSummary -Counters $counters -SummaryType 'Final' -Mode $Context.Mode
|
||||
Write-PersonaSummary -Counters $counters -SummaryType 'Final' -Mode $Context.Mode -StartedUtc $StartedUtc
|
||||
|
||||
if ($ResultsPath) { Export-PersonaResultsCsv -Counters $counters -Path $ResultsPath }
|
||||
|
||||
New-PersonaAuditRecord -Context $Context -RecordType 'Summary' -Counters $counters `
|
||||
-Properties @{ summaryType = 'Final' } | Write-PersonaAuditRecord @AuditParameters
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
function Export-PersonaResultsCsv {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Writes the per-account results CSV (object ID, UPN, persona/status).
|
||||
|
||||
.DESCRIPTION
|
||||
Called after every summary, interim and final, and overwrites the file each
|
||||
time. Counters.Results accumulates for the whole run, so the file on disk
|
||||
always lists every account processed so far, not just those since the last
|
||||
summary.
|
||||
|
||||
Export failure never ends the run, matching Write-PersonaAuditRecord's
|
||||
failure handling for the same reason: a locked file or a full disk is an
|
||||
operational problem with the export, not a reason to abandon a
|
||||
classification run mid-population.
|
||||
|
||||
.PARAMETER Counters
|
||||
The run counter set.
|
||||
|
||||
.PARAMETER Path
|
||||
Destination CSV file. The parent directory is created if it does not exist.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Counters,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Path
|
||||
)
|
||||
|
||||
if ($Counters.Results.Count -eq 0) { return }
|
||||
|
||||
try {
|
||||
$directory = Split-Path -Parent $Path
|
||||
if ($directory -and -not (Test-Path -LiteralPath $directory)) {
|
||||
$null = New-Item -ItemType Directory -Path $directory -Force -WhatIf:$false -Confirm:$false
|
||||
}
|
||||
|
||||
# -WhatIf:$false / -Confirm:$false pin this write regardless of any ambient
|
||||
# $WhatIfPreference in the caller's session, the same reason
|
||||
# Write-PersonaAuditRecord pins its own sink writes: this is a report, not a
|
||||
# directory mutation, and must not silently no-op under -WhatIf.
|
||||
$Counters.Results | Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding utf8NoBOM -Force -WhatIf:$false -Confirm:$false
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Results CSV export failed; the run continues without it: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,11 @@ function New-PersonaRunCounter {
|
||||
makes a zero-match rule distinguishable from an absent one - an operator
|
||||
asking "did RULE-0030 fire?" gets "no, zero matches" rather than silence.
|
||||
|
||||
Results accumulates one row per processed account (AccountObjectId,
|
||||
UserPrincipalName, persona/status) for Export-PersonaResultsCsv. It grows
|
||||
for the life of the run, not just since the last summary, so the CSV a
|
||||
summary writes always reflects every account processed so far.
|
||||
|
||||
.PARAMETER Rules
|
||||
The business rule collection, used to seed RuleCounts.
|
||||
|
||||
@@ -67,6 +72,7 @@ function New-PersonaRunCounter {
|
||||
Skipped = 0
|
||||
|
||||
RuleCounts = $ruleCounts
|
||||
Results = [System.Collections.Generic.List[object]]::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,4 +123,12 @@ function Add-PersonaRunResult {
|
||||
'UpdateFailed' { $Counters.UpdateFailed++ }
|
||||
'Skipped' { $Counters.Skipped++ }
|
||||
}
|
||||
|
||||
# Matched carries the assigned persona; Unclassified/EvaluationError carry
|
||||
# their outcome name, since CalculatedPersona is 'Unclassified' or $null there.
|
||||
$Counters.Results.Add([pscustomobject]@{
|
||||
AccountObjectId = [string]$Result.AccountObjectId
|
||||
UserPrincipalName = [string]$Result.UserPrincipalName
|
||||
PersonaStatus = [string]$Result.Outcome -eq 'Matched' ? [string]$Result.CalculatedPersona : [string]$Result.Outcome
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ function Write-PersonaSummary {
|
||||
.PARAMETER Mode
|
||||
Preview or Enforce, shown in the header so a screenshot of a summary is
|
||||
self-describing.
|
||||
|
||||
.PARAMETER StartedUtc
|
||||
Run start timestamp. When supplied, the header shows elapsed wall-clock
|
||||
time since the run began. Optional so callers that only care about counts
|
||||
are not forced to thread a clock through.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
@@ -37,14 +42,25 @@ function Write-PersonaSummary {
|
||||
[string] $SummaryType = 'Interim',
|
||||
|
||||
[ValidateSet('Preview', 'Enforce')]
|
||||
[string] $Mode = 'Preview'
|
||||
[string] $Mode = 'Preview',
|
||||
|
||||
[Nullable[datetime]] $StartedUtc
|
||||
)
|
||||
|
||||
$reconciled = Test-PersonaReconciliation -Counters $Counters
|
||||
|
||||
# $StartedUtc arrives here already unwrapped to a plain [datetime] - PowerShell
|
||||
# collapses [Nullable[datetime]] to DateTime (or $null) at the call boundary, so
|
||||
# a null check is used rather than .Value / .HasValue.
|
||||
$elapsed = ($null -ne $StartedUtc) ? ('{0:hh\:mm\:ss}' -f ([DateTime]::UtcNow - $StartedUtc)) : $null
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ('=' * 100) -ForegroundColor DarkGray
|
||||
Write-Host ("{0} summary - mode: {1} - processed: {2}" -f $SummaryType, $Mode, $Counters.Processed) -ForegroundColor Cyan
|
||||
Write-Host (
|
||||
$elapsed `
|
||||
? ("{0} summary - mode: {1} - elapsed: {2} - processed: {3}" -f $SummaryType, $Mode, $elapsed, $Counters.Processed) `
|
||||
: ("{0} summary - mode: {1} - processed: {2}" -f $SummaryType, $Mode, $Counters.Processed)
|
||||
) -ForegroundColor Cyan
|
||||
Write-Host ('=' * 100) -ForegroundColor DarkGray
|
||||
|
||||
Write-Host ('{0,-28} {1,-40} {2,-9} {3,10} {4,8}' -f 'Rule ID', 'Name', 'Priority', 'Enabled', 'Matches') -ForegroundColor DarkGray
|
||||
|
||||
Reference in New Issue
Block a user