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>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
function Export-PersonaRunReport {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds the RunComplete record closing out a run (FR-022, NFR-005).
|
||||
|
||||
.DESCRIPTION
|
||||
The last record of every run, successful or not. It carries the final
|
||||
counters, the wall-clock span, and the exit code the process returned.
|
||||
|
||||
Emitted even on a fatal error. A run that died at user 400 of 5000 leaves a
|
||||
RunComplete saying exactly that, which is what lets an operator tell "the
|
||||
engine stopped early" from "the engine never started" - two very different
|
||||
incidents that produce identical evidence if the record is written only on
|
||||
success.
|
||||
|
||||
startedUtc and completedUtc are wall-clock, unlike per-user durations, which
|
||||
use a monotonic stopwatch. They are here for correlation with other systems'
|
||||
logs, never as an input to a decision.
|
||||
|
||||
.PARAMETER Context
|
||||
The audit context.
|
||||
|
||||
.PARAMETER Counters
|
||||
The final run counters.
|
||||
|
||||
.PARAMETER StartedUtc
|
||||
Run start timestamp.
|
||||
|
||||
.PARAMETER ExitCode
|
||||
The exit code the run will return (0 - 6).
|
||||
|
||||
.OUTPUTS
|
||||
An ordered dictionary ready for Write-PersonaAuditRecord.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Context,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Counters,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[datetime] $StartedUtc,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateRange(0, 6)]
|
||||
[int] $ExitCode
|
||||
)
|
||||
|
||||
$completed = [DateTime]::UtcNow
|
||||
|
||||
New-PersonaAuditRecord -Context $Context -RecordType 'RunComplete' -Counters $Counters -Properties @{
|
||||
startedUtc = $StartedUtc.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
completedUtc = $completed.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
durationMs = [int]($completed - $StartedUtc).TotalMilliseconds
|
||||
exitCode = $ExitCode
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
function New-PersonaAuditContext {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates the constant envelope shared by every audit record in a run.
|
||||
|
||||
.DESCRIPTION
|
||||
Run ID, engine version, configuration version, configuration hash, and mode
|
||||
are identical on every record (NFR-005). Building them once and carrying the
|
||||
context means no call site can emit a record missing them, and no call site
|
||||
can disagree about the mode.
|
||||
|
||||
.PARAMETER RunId
|
||||
The run identifier, from -CorrelationId or generated.
|
||||
|
||||
.PARAMETER EngineVersion
|
||||
Module version.
|
||||
|
||||
.PARAMETER Configuration
|
||||
The loaded configuration, source of configVersion and configurationHash.
|
||||
|
||||
.PARAMETER Mode
|
||||
Preview or Enforce, derived by the caller from ShouldProcess alone.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.AuditContext
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $RunId,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $EngineVersion,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Configuration,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('Preview', 'Enforce')]
|
||||
[string] $Mode
|
||||
)
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.AuditContext'
|
||||
RunId = $RunId
|
||||
EngineVersion = $EngineVersion
|
||||
ConfigVersion = [string]$Configuration.ConfigVersion
|
||||
ConfigurationHash = [string]$Configuration.ConfigurationHash
|
||||
Mode = $Mode
|
||||
}
|
||||
}
|
||||
|
||||
function New-PersonaAuditRecord {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds a structured audit record of the requested type (FR-022, NFR-005).
|
||||
|
||||
.DESCRIPTION
|
||||
Every record type shares the common envelope from contracts/audit-record.md
|
||||
and adds its own fields. One builder rather than five keeps the envelope in
|
||||
a single place, so a field added to it appears on every record type without
|
||||
five separate edits.
|
||||
|
||||
Prohibited content - tokens, Authorization headers, secrets, raw Graph
|
||||
responses - is not merely undocumented here, it is unreachable: this
|
||||
function accepts only named, typed values from the decision result and the
|
||||
counters. There is no pass-through of an arbitrary object, so there is
|
||||
nothing for a secret to ride in on (Principle V).
|
||||
|
||||
.PARAMETER Context
|
||||
The audit context from New-PersonaAuditContext.
|
||||
|
||||
.PARAMETER RecordType
|
||||
RunStart, UserEvent, Summary, RunComplete, or EngineDefect.
|
||||
|
||||
.PARAMETER Result
|
||||
For UserEvent: the PersonaDecisionResult.
|
||||
|
||||
.PARAMETER PreviousValue
|
||||
For UserEvent: the value captured at write time on an Updated record.
|
||||
|
||||
.PARAMETER Counters
|
||||
For Summary and RunComplete: the run counter object.
|
||||
|
||||
.PARAMETER IncludeTrace
|
||||
Emits conditionTrace on a UserEvent. Gated by the caller on -Debug or
|
||||
logging.traceConditionValues, never enabled by default (VR-003).
|
||||
|
||||
.PARAMETER Properties
|
||||
Additional fields for RunStart, RunComplete, and EngineDefect.
|
||||
|
||||
.OUTPUTS
|
||||
System.Collections.Specialized.OrderedDictionary - ordered so serialized
|
||||
records list their fields in the documented sequence on every run.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Context,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('RunStart', 'UserEvent', 'Summary', 'RunComplete', 'EngineDefect')]
|
||||
[string] $RecordType,
|
||||
|
||||
[object] $Result,
|
||||
|
||||
[AllowNull()]
|
||||
[AllowEmptyString()]
|
||||
[string] $PreviousValue,
|
||||
|
||||
[object] $Counters,
|
||||
|
||||
[switch] $IncludeTrace,
|
||||
|
||||
[hashtable] $Properties = @{}
|
||||
)
|
||||
|
||||
$record = [ordered]@{
|
||||
timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
recordType = $RecordType
|
||||
runId = $Context.RunId
|
||||
engineVersion = $Context.EngineVersion
|
||||
configVersion = $Context.ConfigVersion
|
||||
configurationHash = $Context.ConfigurationHash
|
||||
mode = $Context.Mode
|
||||
}
|
||||
|
||||
switch ($RecordType) {
|
||||
|
||||
'UserEvent' {
|
||||
if ($null -eq $Result) { throw 'A UserEvent record requires -Result.' }
|
||||
|
||||
$record['accountObjectId'] = [string]$Result.AccountObjectId
|
||||
$record['userPrincipalName'] = [string]$Result.UserPrincipalName
|
||||
$record['outcome'] = [string]$Result.Outcome
|
||||
$record['matchedRuleId'] = $Result.MatchedRuleId
|
||||
$record['storedPersona'] = $Result.StoredPersona
|
||||
$record['calculatedPersona'] = $Result.CalculatedPersona
|
||||
|
||||
# Present only on Updated. On any other action there is nothing that was
|
||||
# replaced, and a populated previousValue would imply otherwise to a
|
||||
# rollback tool reading these records later.
|
||||
$record['previousValue'] = ($Result.Action -eq 'Updated') ? $PreviousValue : $null
|
||||
|
||||
$record['action'] = [string]$Result.Action
|
||||
$record['rulesEvaluated'] = [int]$Result.RulesEvaluated
|
||||
$record['durationMs'] = [int]$Result.DurationMs
|
||||
$record['evaluationErrorReason'] = $Result.EvaluationErrorReason
|
||||
|
||||
if ($IncludeTrace -and $Result.ConditionTrace) {
|
||||
$record['conditionTrace'] = @(
|
||||
foreach ($entry in $Result.ConditionTrace) {
|
||||
[ordered]@{
|
||||
ruleId = [string]$entry.RuleId
|
||||
priority = [int]$entry.Priority
|
||||
result = [string]$entry.Result
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
'Summary' {
|
||||
if ($null -eq $Counters) { throw 'A Summary record requires -Counters.' }
|
||||
|
||||
$record['summaryType'] = $Properties.ContainsKey('summaryType') ? [string]$Properties['summaryType'] : 'Interim'
|
||||
Add-PersonaCounterField -Record $record -Counters $Counters
|
||||
$record['ruleCounts'] = @(
|
||||
foreach ($entry in $Counters.RuleCounts) {
|
||||
[ordered]@{
|
||||
ruleId = [string]$entry.RuleId
|
||||
name = [string]$entry.Name
|
||||
enabled = [bool]$entry.Enabled
|
||||
matches = [int]$entry.Matches
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
'RunComplete' {
|
||||
if ($null -eq $Counters) { throw 'A RunComplete record requires -Counters.' }
|
||||
|
||||
$record['startedUtc'] = $Properties['startedUtc']
|
||||
$record['completedUtc'] = $Properties['completedUtc']
|
||||
$record['durationMs'] = [int]$Properties['durationMs']
|
||||
Add-PersonaCounterField -Record $record -Counters $Counters
|
||||
$record['exitCode'] = [int]$Properties['exitCode']
|
||||
}
|
||||
|
||||
default {
|
||||
# RunStart and EngineDefect carry only the envelope plus whatever the
|
||||
# caller names explicitly.
|
||||
foreach ($key in $Properties.Keys) { $record[$key] = $Properties[$key] }
|
||||
}
|
||||
}
|
||||
|
||||
$record
|
||||
}
|
||||
|
||||
function Add-PersonaCounterField {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Adds the shared counter block to a Summary or RunComplete record.
|
||||
|
||||
.DESCRIPTION
|
||||
Summary and RunComplete carry the same counters. Sharing the block means the
|
||||
two record types cannot drift apart, which matters because reconciliation
|
||||
tooling reads both.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Record,
|
||||
[Parameter(Mandatory)] [object] $Counters
|
||||
)
|
||||
|
||||
$Record['processed'] = [int]$Counters.Processed
|
||||
$Record['matched'] = [int]$Counters.Matched
|
||||
$Record['unclassified'] = [int]$Counters.Unclassified
|
||||
$Record['evaluationError'] = [int]$Counters.EvaluationError
|
||||
$Record['unchanged'] = [int]$Counters.Unchanged
|
||||
$Record['wouldUpdate'] = [int]$Counters.WouldUpdate
|
||||
$Record['updated'] = [int]$Counters.Updated
|
||||
$Record['updateFailed'] = [int]$Counters.UpdateFailed
|
||||
$Record['skipped'] = [int]$Counters.Skipped
|
||||
$Record['reconciliationPassed'] = [bool](Test-PersonaReconciliation -Counters $Counters)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
function Write-PersonaAuditRecord {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
The single emission point for audit records (FR-022, OTD-006).
|
||||
|
||||
.DESCRIPTION
|
||||
Serializes one record as newline-delimited JSON and emits it to file, to the
|
||||
object stream, or both, per logging.destination.
|
||||
|
||||
Every audit record in the engine passes through here. That is the whole
|
||||
design: adding a transport - an approved logging platform, an event hub, a
|
||||
different file layout - is a change to this function and nothing else. If
|
||||
call sites wrote their own output, each new transport would mean auditing
|
||||
every call site again, and the one that got missed would be silent.
|
||||
|
||||
Emission failure never ends the run. A full disk or a locked file is an
|
||||
operational problem with the audit sink, not a reason to abandon a
|
||||
classification run mid-population and leave the directory in a half-reconciled
|
||||
state. The failure is surfaced as a warning, once, and processing continues.
|
||||
|
||||
.PARAMETER Record
|
||||
An ordered dictionary from New-PersonaAuditRecord.
|
||||
|
||||
.PARAMETER Destination
|
||||
file, stream, both, or none.
|
||||
|
||||
.PARAMETER Path
|
||||
Output file for the file and both destinations.
|
||||
|
||||
.PARAMETER State
|
||||
Optional sink state carrying the one-warning latch, so a failing sink warns
|
||||
once per run rather than once per user.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory, ValueFromPipeline)]
|
||||
[object] $Record,
|
||||
|
||||
[ValidateSet('file', 'stream', 'both', 'none')]
|
||||
[string] $Destination = 'stream',
|
||||
|
||||
[string] $Path,
|
||||
|
||||
[object] $State
|
||||
)
|
||||
|
||||
process {
|
||||
if ($Destination -eq 'none') { return }
|
||||
|
||||
if ($Destination -in @('stream', 'both')) {
|
||||
# The Information stream, not the success stream. Audit records emitted
|
||||
# onto the success stream would be indistinguishable from a function's
|
||||
# return value: the run loop returns its outcome there, and mixing the two
|
||||
# would turn one object into an array of several thousand.
|
||||
#
|
||||
# The record object is emitted, not a string, so a caller capturing it
|
||||
# with -InformationVariable can assert on fields without reparsing.
|
||||
Write-Information -MessageData $Record -Tags 'PersonaEngine.Audit'
|
||||
}
|
||||
|
||||
if ($Destination -in @('file', 'both')) {
|
||||
if (-not $Path) {
|
||||
Write-Warning 'logging.destination requests file output but no path is configured. No audit file was written.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
$line = $Record | ConvertTo-Json -Depth 16 -Compress
|
||||
|
||||
$directory = Split-Path -Parent $Path
|
||||
if ($directory -and -not (Test-Path -LiteralPath $directory)) {
|
||||
$null = New-Item -ItemType Directory -Path $directory -Force
|
||||
}
|
||||
|
||||
# Append, one record per line. UTF-8 without BOM so the file is
|
||||
# machine-readable by any NDJSON consumer.
|
||||
Add-Content -LiteralPath $Path -Value $line -Encoding utf8NoBOM -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
if ($null -ne $State -and $State.FileSinkFailed) { return }
|
||||
if ($null -ne $State) { $State.FileSinkFailed = $true }
|
||||
|
||||
Write-Warning "Audit file sink failed; the run continues without file output: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function New-PersonaAuditSinkState {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates the per-run sink state for Write-PersonaAuditRecord.
|
||||
|
||||
.DESCRIPTION
|
||||
Holds the latch that keeps a failing file sink from emitting one warning per
|
||||
user. A run over five thousand accounts with a locked log file should warn
|
||||
once, not five thousand times, or the warning that matters is buried in the
|
||||
noise it generates.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param()
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.AuditSinkState'
|
||||
FileSinkFailed = $false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
function Connect-PersonaGraphInteractive {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Establishes a delegated Microsoft Graph connection for local development.
|
||||
|
||||
.DESCRIPTION
|
||||
Stage A2 authentication (plan.md). Requests only the scopes the enabled
|
||||
rules require (OTD-002) — never Directory.Read.All, which is materially
|
||||
broader than the three read scopes combined.
|
||||
|
||||
Returns an opaque handle. No token, header, or secret is ever returned to a
|
||||
caller, logged, or written to an audit record (Principle V).
|
||||
|
||||
Note on V-3: effective access is the intersection of the requested scope and
|
||||
the signed-in account's directory roles. Signing in as a Global Administrator
|
||||
makes the least-privilege test meaningless, because the account's roles grant
|
||||
broad read regardless of the scope requested.
|
||||
|
||||
.PARAMETER IncludeWrite
|
||||
Adds User.ReadWrite.All. Enforcement only, and only after the V-4 security
|
||||
sign-off is recorded.
|
||||
|
||||
.PARAMETER TenantId
|
||||
Optional tenant hint.
|
||||
|
||||
.EXAMPLE
|
||||
Connect-PersonaGraphInteractive
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[switch] $IncludeWrite,
|
||||
[string] $TenantId,
|
||||
[switch] $IncludeGroups,
|
||||
[switch] $IncludeRoles
|
||||
)
|
||||
|
||||
$scopes = [System.Collections.Generic.List[string]]::new()
|
||||
$scopes.Add($IncludeWrite ? 'User.ReadWrite.All' : 'User.Read.All')
|
||||
if ($IncludeGroups) { $scopes.Add('GroupMember.Read.All') }
|
||||
if ($IncludeRoles) { $scopes.Add('RoleManagement.Read.Directory') }
|
||||
|
||||
$connectArgs = @{ Scopes = $scopes.ToArray(); NoWelcome = $true; ErrorAction = 'Stop' }
|
||||
if ($TenantId) { $connectArgs['TenantId'] = $TenantId }
|
||||
|
||||
Write-Verbose "Connecting to Microsoft Graph with scopes: $($scopes -join ', ')"
|
||||
Connect-MgGraph @connectArgs | Out-Null
|
||||
|
||||
$context = Get-MgContext
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.GraphConnection'
|
||||
AuthType = 'Delegated'
|
||||
Account = $context.Account
|
||||
TenantId = $context.TenantId
|
||||
Scopes = @($context.Scopes)
|
||||
WriteCapable = [bool]$IncludeWrite
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
function Get-PersonaRequiredFacets {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Determines which membership facets the enabled rules actually need.
|
||||
|
||||
.DESCRIPTION
|
||||
Least privilege applied to data retrieval: a configuration with no role
|
||||
conditions never calls the role endpoint, so a tenant where role reads are
|
||||
unavailable can still run property-only rules.
|
||||
|
||||
Walks every enabled rule's condition tree. A membership condition resolves
|
||||
to the direct or transitive facet by its own membershipMode, falling back to
|
||||
the configured default (RE-007).
|
||||
|
||||
.PARAMETER Rules
|
||||
The business rule collection.
|
||||
|
||||
.PARAMETER DefaultMembershipMode
|
||||
Mode for conditions that do not specify one.
|
||||
|
||||
.OUTPUTS
|
||||
A hashtable with Direct, Transitive, and Roles boolean keys.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([hashtable])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
[object[]] $Rules,
|
||||
|
||||
[ValidateSet('Direct', 'Transitive')]
|
||||
[string] $DefaultMembershipMode = 'Direct'
|
||||
)
|
||||
|
||||
$need = @{ Direct = $false; Transitive = $false; Roles = $false }
|
||||
|
||||
function Test-Node {
|
||||
param($Node)
|
||||
if ($null -eq $Node) { return }
|
||||
|
||||
if ($Node.PSObject.Properties['conditions'] -and $Node.conditions) {
|
||||
foreach ($child in $Node.conditions) { Test-Node $child }
|
||||
return
|
||||
}
|
||||
|
||||
$type = [string]$Node.type
|
||||
|
||||
if ($type -eq 'role') {
|
||||
$need.Roles = $true
|
||||
return
|
||||
}
|
||||
|
||||
if ($type -eq 'membership') {
|
||||
$mode = $Node.PSObject.Properties['membershipMode'] -and $Node.membershipMode `
|
||||
? [string]$Node.membershipMode `
|
||||
: $DefaultMembershipMode
|
||||
|
||||
if ($mode -ieq 'transitive') { $need.Transitive = $true } else { $need.Direct = $true }
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rule in @($Rules)) {
|
||||
if (-not $rule.enabled) { continue }
|
||||
Test-Node $rule.match
|
||||
}
|
||||
|
||||
$need
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
function Import-PersonaConfiguration {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Loads a JSON configuration file into the object shape the engine consumes.
|
||||
|
||||
.DESCRIPTION
|
||||
Layer 1 of validation (syntax) happens implicitly here: malformed JSON
|
||||
throws. Layers 2-4 are Test-PersonaConfiguration's responsibility, and the
|
||||
caller runs them before using the result (FR-002).
|
||||
|
||||
Computes ConfigurationHash as the SHA-256 of the file bytes, which is
|
||||
recorded on every audit record (NFR-005) so a run can be tied to the exact
|
||||
configuration that produced it.
|
||||
|
||||
.PARAMETER Path
|
||||
Path to the JSON configuration file.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.Configuration
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $Path
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||
throw "Configuration file not found: '$Path'."
|
||||
}
|
||||
|
||||
$raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
|
||||
|
||||
try {
|
||||
$document = $raw | ConvertFrom-Json -Depth 32 -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
throw "Configuration is not valid JSON: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Hash the file bytes rather than the parsed object: two files that differ only
|
||||
# in whitespace are different configurations for audit purposes, and the hash
|
||||
# must be reproducible from the artifact on disk.
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$bytes = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $Path).ProviderPath)
|
||||
$hash = [System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
finally {
|
||||
$sha.Dispose()
|
||||
}
|
||||
|
||||
$engine = $document.engine
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.Configuration'
|
||||
ConfigVersion = [string]$document.configVersion
|
||||
ConfigurationHash = $hash
|
||||
SourcePath = (Resolve-Path -LiteralPath $Path).ProviderPath
|
||||
|
||||
TargetAttribute = [string]$engine.targetAttribute
|
||||
ApprovedWritableAttributes = @($engine.approvedWritableAttributes)
|
||||
MaxConditionDepth = ($null -ne $engine.maxConditionDepth) ? [int]$engine.maxConditionDepth : 5
|
||||
SummaryInterval = ($null -ne $engine.summaryInterval) ? [int]$engine.summaryInterval : 25
|
||||
DefaultMembershipMode = $engine.defaultMembershipMode ? (Get-Culture).TextInfo.ToTitleCase([string]$engine.defaultMembershipMode) : 'Direct'
|
||||
EvaluationErrorThreshold = ($null -ne $engine.evaluationErrorThreshold) ? [int]$engine.evaluationErrorThreshold : $null
|
||||
|
||||
DataSources = $document.dataSources
|
||||
Logging = $document.logging
|
||||
Personas = @($document.personas)
|
||||
Rules = @($document.rules)
|
||||
|
||||
Raw = $document
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
function New-PersonaValidationFinding {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a structured validation finding (VR-004).
|
||||
|
||||
.DESCRIPTION
|
||||
Every one of the four validation layers emits this shape, so a caller —
|
||||
console, pipeline, or editor — handles findings uniformly regardless of
|
||||
which layer produced them.
|
||||
|
||||
Finding codes are stable and namespaced by layer, because pipelines and
|
||||
runbooks will match on them:
|
||||
|
||||
PE-SYN-nnn syntax
|
||||
PE-SCH-nnn schema
|
||||
PE-SEM-nnn semantic
|
||||
PE-SAF-nnn safety
|
||||
|
||||
.PARAMETER Severity
|
||||
Error blocks execution and saving. Warning blocks only under
|
||||
-TreatWarningsAsErrors. Information never blocks (VR-005).
|
||||
|
||||
.PARAMETER Code
|
||||
Stable finding code, e.g. PE-SEM-012.
|
||||
|
||||
.PARAMETER Location
|
||||
JSON path or rule ID identifying where the problem is.
|
||||
|
||||
.PARAMETER SuggestedResolution
|
||||
What the author should do. Required — a finding without a resolution just
|
||||
tells someone they are wrong.
|
||||
|
||||
.EXAMPLE
|
||||
New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-002' -Location 'rules[3].priority' -Description 'Duplicate priority 30.' -SuggestedResolution 'Assign a unique priority.'
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('Error', 'Warning', 'Information')]
|
||||
[string] $Severity,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^PE-(SYN|SCH|SEM|SAF)-\d{3}$')]
|
||||
[string] $Code,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $Location,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $Description,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $SuggestedResolution
|
||||
)
|
||||
|
||||
# The layer is derivable from the code, so it cannot drift out of agreement
|
||||
# with it.
|
||||
$layer = switch -Regex ($Code) {
|
||||
'^PE-SYN-' { 'Syntax' }
|
||||
'^PE-SCH-' { 'Schema' }
|
||||
'^PE-SEM-' { 'Semantic' }
|
||||
'^PE-SAF-' { 'Safety' }
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.ValidationFinding'
|
||||
Severity = $Severity
|
||||
Code = $Code
|
||||
Location = $Location
|
||||
Description = $Description
|
||||
SuggestedResolution = $SuggestedResolution
|
||||
Layer = $layer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
function Resolve-TargetAttribute {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Resolves the attribute the engine is permitted to write (NFR-006).
|
||||
|
||||
.DESCRIPTION
|
||||
Returns the configured target attribute only when it is non-blank and
|
||||
present in approvedWritableAttributes. Anything else throws.
|
||||
|
||||
Throwing rather than returning $null is the point. A caller that treated a
|
||||
null return as "no writes this run" would be indistinguishable from a caller
|
||||
that forgot to check, and the second one writes to whatever name it was
|
||||
holding. There is no safe fallback value for an attribute name, so there is
|
||||
no fallback.
|
||||
|
||||
Comparison against the approved list is ORDINAL. Extension property names
|
||||
are case-sensitive in Graph: extension_<id>_Persona and
|
||||
extension_<id>_persona are two different attributes, and approving one does
|
||||
not approve the other. Rule matching is case-insensitive (RE-006); attribute
|
||||
approval is not, and the difference is deliberate.
|
||||
|
||||
.PARAMETER Configuration
|
||||
The loaded configuration.
|
||||
|
||||
.EXAMPLE
|
||||
$target = Resolve-TargetAttribute -Configuration $config
|
||||
|
||||
.OUTPUTS
|
||||
System.String
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Configuration
|
||||
)
|
||||
|
||||
$target = [string]$Configuration.TargetAttribute
|
||||
$approved = @($Configuration.ApprovedWritableAttributes)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($target)) {
|
||||
throw 'engine.targetAttribute is blank. There is no attribute to compare against or write.'
|
||||
}
|
||||
|
||||
if ($target -cnotin $approved) {
|
||||
throw "engine.targetAttribute '$target' is not present in engine.approvedWritableAttributes. Comparison is ordinal: extension property names are case-sensitive, so a casing difference is a different attribute."
|
||||
}
|
||||
|
||||
$target
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
function Test-PersonaConfiguration {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs all four validation layers over a configuration file (VR-001, FR-002).
|
||||
|
||||
.DESCRIPTION
|
||||
Layers run in order and fail fast between them:
|
||||
|
||||
1. Syntax ConvertFrom-Json PE-SYN-nnn
|
||||
2. Schema Test-Json -SchemaFile PE-SCH-nnn
|
||||
3. Semantic Test-PersonaConfigurationSemantic PE-SEM-nnn
|
||||
4. Safety Test-PersonaConfigurationSafety PE-SAF-nnn
|
||||
|
||||
A layer that produces Error findings stops the sequence. Running semantic
|
||||
checks over a structurally invalid document produces noise, not signal: every
|
||||
missing field yields a cascade of consequent errors, and the author has to
|
||||
guess which one is the actual cause.
|
||||
|
||||
Layer 2 error handling is driven by the V-5a observation
|
||||
(specs/001-persona-engine/verification/V-5a.md), which matters more than it
|
||||
looks. On PowerShell 7.6.5, Test-Json returns $true when the SCHEMA ITSELF is
|
||||
unparseable, writing the failure to the error stream instead. A wrapper that
|
||||
trusted the return value would report a configuration as schema-valid when
|
||||
the schema never ran. So this function treats a non-empty error variable as
|
||||
failure regardless of what was returned, and separates the "schema is broken"
|
||||
case from the "configuration is invalid" case, because they need different
|
||||
exit codes.
|
||||
|
||||
.PARAMETER Path
|
||||
Configuration file to validate.
|
||||
|
||||
.PARAMETER SchemaPath
|
||||
Schema override. Defaults to the shipped config/persona-engine.schema.json.
|
||||
|
||||
.PARAMETER PreviousConfigPath
|
||||
A previously deployed configuration, enabling the VR-003 comparison checks
|
||||
(version downgrade, undeclared rule deletion or reorder).
|
||||
|
||||
.PARAMETER EnforcementEnabled
|
||||
Whether this configuration will be used for an enforcing run. Raises the
|
||||
severity of several safety findings.
|
||||
|
||||
.PARAMETER SkipSafety
|
||||
Runs layers 1-3 only. Used by the editor while a document is mid-edit.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.ValidationResult
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $Path,
|
||||
|
||||
[string] $SchemaPath,
|
||||
|
||||
[string] $PreviousConfigPath,
|
||||
|
||||
[switch] $EnforcementEnabled,
|
||||
|
||||
[switch] $SkipSafety
|
||||
)
|
||||
|
||||
$findings = [System.Collections.Generic.List[object]]::new()
|
||||
$document = $null
|
||||
$stoppedAtLayer = $null
|
||||
$schemaUnusable = $false
|
||||
|
||||
# ---------------------------------------------------------------- Layer 1
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SYN-001' `
|
||||
-Location $Path `
|
||||
-Description "Configuration file not found or is not a file: '$Path'." `
|
||||
-SuggestedResolution 'Check the path. In a pipeline, confirm the file was checked out and the working directory is what you expect.'))
|
||||
|
||||
return New-PersonaValidationResult -Findings $findings -Document $null -StoppedAtLayer 'Syntax' -SchemaUnusable $false
|
||||
}
|
||||
|
||||
$raw = $null
|
||||
try {
|
||||
$raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SYN-002' `
|
||||
-Location $Path `
|
||||
-Description "Configuration file could not be read: $($_.Exception.Message)" `
|
||||
-SuggestedResolution 'Check file permissions and that no other process holds an exclusive lock.'))
|
||||
|
||||
return New-PersonaValidationResult -Findings $findings -Document $null -StoppedAtLayer 'Syntax' -SchemaUnusable $false
|
||||
}
|
||||
|
||||
try {
|
||||
$document = $raw | ConvertFrom-Json -Depth 32 -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SYN-003' `
|
||||
-Location $Path `
|
||||
-Description "Configuration is not valid JSON: $($_.Exception.Message)" `
|
||||
-SuggestedResolution 'Fix the JSON syntax. A trailing comma or an unquoted key is the usual cause.'))
|
||||
|
||||
return New-PersonaValidationResult -Findings $findings -Document $null -StoppedAtLayer 'Syntax' -SchemaUnusable $false
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- Layer 2
|
||||
|
||||
if (-not $SchemaPath) {
|
||||
$moduleRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
$SchemaPath = Join-Path $moduleRoot 'config/persona-engine.schema.json'
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $SchemaPath -PathType Leaf)) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SCH-002' `
|
||||
-Location $SchemaPath `
|
||||
-Description "Schema file not found: '$SchemaPath'." `
|
||||
-SuggestedResolution 'Supply -SchemaPath, or restore config/persona-engine.schema.json.'))
|
||||
|
||||
return New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer 'Schema' -SchemaUnusable $true
|
||||
}
|
||||
|
||||
$schemaErrors = $null
|
||||
$schemaOk = $raw | Test-Json -SchemaFile $SchemaPath -ErrorAction SilentlyContinue -ErrorVariable schemaErrors
|
||||
|
||||
foreach ($schemaError in @($schemaErrors)) {
|
||||
$message = [string]$schemaError.Exception.Message
|
||||
|
||||
# V-5a: this message arrives with a $true return value. Treating it as a
|
||||
# pass would validate every configuration against a schema that never ran.
|
||||
if ($message -match 'Cannot parse the JSON schema') {
|
||||
$schemaUnusable = $true
|
||||
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SCH-003' `
|
||||
-Location $SchemaPath `
|
||||
-Description "The schema file itself is not valid JSON Schema and could not be used: $message" `
|
||||
-SuggestedResolution 'Repair the schema file. Until it parses, no configuration can be schema-validated, and a passing result would be meaningless.'))
|
||||
continue
|
||||
}
|
||||
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SCH-001' `
|
||||
-Location (Get-PersonaSchemaErrorLocation -Message $message) `
|
||||
-Description $message `
|
||||
-SuggestedResolution 'Correct the document to match config/persona-engine.schema.json. Test-Json reports only the first violation per run, so re-validate after each fix.'))
|
||||
}
|
||||
|
||||
if ($schemaUnusable -or -not $schemaOk -or @($schemaErrors).Count -gt 0) {
|
||||
return New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer 'Schema' -SchemaUnusable $schemaUnusable
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- Layer 3
|
||||
|
||||
foreach ($finding in (Test-PersonaConfigurationSemantic -Document $document)) { $findings.Add($finding) }
|
||||
|
||||
if (@($findings | Where-Object Severity -EQ 'Error').Count -gt 0) {
|
||||
return New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer 'Semantic' -SchemaUnusable $false
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- Layer 4
|
||||
|
||||
if (-not $SkipSafety) {
|
||||
$safetyParams = @{ Document = $document; EnforcementEnabled = $EnforcementEnabled }
|
||||
if ($PreviousConfigPath) { $safetyParams['PreviousConfigPath'] = $PreviousConfigPath }
|
||||
|
||||
foreach ($finding in (Test-PersonaConfigurationSafety @safetyParams)) { $findings.Add($finding) }
|
||||
}
|
||||
|
||||
New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer $null -SchemaUnusable $false
|
||||
}
|
||||
|
||||
function New-PersonaValidationResult {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Wraps a finding collection into the shape every caller consumes.
|
||||
|
||||
.DESCRIPTION
|
||||
IsValid is computed here rather than by each caller, so console, pipeline,
|
||||
and editor cannot disagree about what "valid" means. Warnings never affect
|
||||
IsValid; escalation under -TreatWarningsAsErrors is the caller's decision
|
||||
(VR-005) and belongs where the exit code is chosen.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)] [AllowEmptyCollection()] [object] $Findings,
|
||||
[AllowNull()] [object] $Document,
|
||||
[AllowNull()] [string] $StoppedAtLayer,
|
||||
[bool] $SchemaUnusable
|
||||
)
|
||||
|
||||
$all = @($Findings)
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.ValidationResult'
|
||||
IsValid = (@($all | Where-Object Severity -EQ 'Error').Count -eq 0)
|
||||
Findings = $all
|
||||
ErrorCount = @($all | Where-Object Severity -EQ 'Error').Count
|
||||
WarningCount = @($all | Where-Object Severity -EQ 'Warning').Count
|
||||
Document = $Document
|
||||
StoppedAtLayer = $StoppedAtLayer
|
||||
SchemaUnusable = $SchemaUnusable
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PersonaSchemaErrorLocation {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extracts the JSON pointer from a Test-Json error message.
|
||||
|
||||
.DESCRIPTION
|
||||
Test-Json embeds the failing location in prose - "... at '/rules/3/priority'".
|
||||
VR-004 requires a location on every finding, so it is lifted out here rather
|
||||
than leaving the caller to read it out of the description. When no pointer is
|
||||
present the document root is reported, which is honest: the violation is
|
||||
somewhere in the document and the message says where in words.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Message)
|
||||
|
||||
if ($Message -match "at '([^']*)'") {
|
||||
$pointer = $Matches[1]
|
||||
return $pointer ? $pointer : '/'
|
||||
}
|
||||
|
||||
'/'
|
||||
}
|
||||
|
||||
function Write-PersonaValidationFinding {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Renders validation findings for a human reader.
|
||||
|
||||
.DESCRIPTION
|
||||
Grouped by severity, most serious first, with the suggested resolution on
|
||||
its own line. A finding without a visible resolution just tells someone they
|
||||
are wrong, which is why VR-004 makes the field mandatory and why it is
|
||||
printed rather than hidden behind a verbose switch.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Findings
|
||||
)
|
||||
|
||||
if (@($Findings).Count -eq 0) {
|
||||
Write-Host 'Validation passed with no findings.' -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
|
||||
foreach ($severity in @('Error', 'Warning', 'Information')) {
|
||||
$set = @($Findings | Where-Object Severity -EQ $severity)
|
||||
if ($set.Count -eq 0) { continue }
|
||||
|
||||
$colour = switch ($severity) { 'Error' { 'Red' } 'Warning' { 'Yellow' } default { 'Gray' } }
|
||||
|
||||
foreach ($finding in $set) {
|
||||
Write-Host ("[{0}] {1} {2}" -f $finding.Severity.ToUpperInvariant(), $finding.Code, $finding.Location) -ForegroundColor $colour
|
||||
Write-Host (" {0}" -f $finding.Description)
|
||||
Write-Host (" -> {0}" -f $finding.SuggestedResolution) -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
function Test-PersonaConfigurationSafety {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validation layer 4: safety checks (VR-003).
|
||||
|
||||
.DESCRIPTION
|
||||
Layer 3 asks "does this configuration make sense?". Layer 4 asks "what
|
||||
happens to the directory if we run it?" - a configuration can be perfectly
|
||||
coherent and still be dangerous.
|
||||
|
||||
PE-SAF-001 production-capable configuration with a blank target attribute
|
||||
PE-SAF-002 unsupported writable attribute in the approved list
|
||||
PE-SAF-003 enabled group rules while group retrieval is disabled
|
||||
PE-SAF-004 prohibited configuration version downgrade
|
||||
PE-SAF-005 rule deletion or reorder without a version change, enforcing
|
||||
PE-SAF-006 condition tracing enabled without explicit acknowledgement
|
||||
PE-SAF-007 save path overwrites the only valid configuration with no backup
|
||||
|
||||
PE-SAF-004 and PE-SAF-005 need a baseline to compare against and are skipped
|
||||
when -PreviousConfigPath is absent. Skipped, not passed: a check that cannot
|
||||
run has not been satisfied, and an Information finding says so rather than
|
||||
leaving silence to be read as approval.
|
||||
|
||||
.PARAMETER Document
|
||||
The parsed configuration document.
|
||||
|
||||
.PARAMETER PreviousConfigPath
|
||||
The currently deployed configuration, for the comparison checks.
|
||||
|
||||
.PARAMETER EnforcementEnabled
|
||||
Whether this configuration will drive an enforcing run. Several findings are
|
||||
Errors under enforcement and Warnings in preview, because the same
|
||||
configuration carries very different risk in the two modes.
|
||||
|
||||
.PARAMETER SavePath
|
||||
Intended save target, for PE-SAF-007.
|
||||
|
||||
.PARAMETER BackupPlanned
|
||||
A timestamped backup or Save-As will be taken, satisfying PE-SAF-007.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.ValidationFinding objects.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Document,
|
||||
|
||||
[string] $PreviousConfigPath,
|
||||
|
||||
[switch] $EnforcementEnabled,
|
||||
|
||||
[string] $SavePath,
|
||||
|
||||
[switch] $BackupPlanned
|
||||
)
|
||||
|
||||
$findings = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
$engine = $Document.engine
|
||||
$target = [string]$engine.targetAttribute
|
||||
$approved = @($engine.approvedWritableAttributes)
|
||||
$rules = @($Document.rules)
|
||||
|
||||
# ------------------------------------------------- PE-SAF-001
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($target)) {
|
||||
$severity = $EnforcementEnabled ? 'Error' : 'Warning'
|
||||
|
||||
$findings.Add((New-PersonaValidationFinding -Severity $severity -Code 'PE-SAF-001' `
|
||||
-Location 'engine.targetAttribute' `
|
||||
-Description 'The target attribute is blank. In enforce mode there is no attribute to write and every changed result becomes Skipped, so the run reports success while changing nothing.' `
|
||||
-SuggestedResolution 'Set engine.targetAttribute before running with enforcement.'))
|
||||
}
|
||||
|
||||
# ------------------------------------------------- PE-SAF-002
|
||||
|
||||
foreach ($attribute in $approved) {
|
||||
if (Test-PersonaWritableAttributeShape -Name $attribute) { continue }
|
||||
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-002' `
|
||||
-Location 'engine.approvedWritableAttributes' `
|
||||
-Description "'$attribute' is not a supported writable attribute. Only directory (schema) extension properties may be written by this engine (OTD-001); built-in attributes such as department or jobTitle are authoritative elsewhere and writing them would overwrite another system's data." `
|
||||
-SuggestedResolution 'Remove the entry, or replace it with a directory extension property named extension_<APP-ID>_<NAME>.'))
|
||||
}
|
||||
|
||||
# ------------------------------------------------- PE-SAF-003
|
||||
|
||||
$groupsEnabled = [bool]$Document.dataSources.groups.enabled
|
||||
$rolesEnabled = [bool]$Document.dataSources.roles.enabled
|
||||
|
||||
$needs = Get-PersonaRequiredFacets -Rules $rules -DefaultMembershipMode (
|
||||
$engine.defaultMembershipMode ? (Get-Culture).TextInfo.ToTitleCase([string]$engine.defaultMembershipMode) : 'Direct')
|
||||
|
||||
if (($needs.Direct -or $needs.Transitive) -and -not $groupsEnabled) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-003' `
|
||||
-Location 'dataSources.groups.enabled' `
|
||||
-Description 'Enabled rules contain group membership conditions while group retrieval is disabled. Every account those rules reach becomes EvaluationError, so the run preserves stored values and classifies nobody - a silent no-op that still reports success.' `
|
||||
-SuggestedResolution 'Enable dataSources.groups, or disable the rules that depend on membership.'))
|
||||
}
|
||||
|
||||
if ($needs.Roles -and -not $rolesEnabled) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-003' `
|
||||
-Location 'dataSources.roles.enabled' `
|
||||
-Description 'Enabled rules contain directory role conditions while role retrieval is disabled. Tier 0 rules are the usual casualty, and an account that should have matched a privileged rule falls through to a lower-privilege persona or to EvaluationError.' `
|
||||
-SuggestedResolution 'Enable dataSources.roles, or disable the rules that depend on role assignments.'))
|
||||
}
|
||||
|
||||
# ------------------------------------------------- PE-SAF-006
|
||||
|
||||
$logging = $Document.logging
|
||||
$tracing = $logging -and $logging.PSObject.Properties['traceConditionValues'] -and [bool]$logging.traceConditionValues
|
||||
$acknowledged = $logging -and $logging.PSObject.Properties['acknowledgeConditionTracing'] -and [bool]$logging.acknowledgeConditionTracing
|
||||
|
||||
if ($tracing -and -not $acknowledged) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-006' `
|
||||
-Location 'logging.traceConditionValues' `
|
||||
-Description 'Condition-value tracing is enabled without acknowledgement. Tracing writes evaluated attribute values into audit records, widening what the log contains beyond the UPN and Object ID that are approved by default (Principle V).' `
|
||||
-SuggestedResolution 'Set logging.acknowledgeConditionTracing to true in the same change that enables tracing, so the decision is visible in review, or disable tracing.'))
|
||||
}
|
||||
|
||||
# ------------------------------------------------- PE-SAF-004 / PE-SAF-005
|
||||
|
||||
if (-not $PreviousConfigPath) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Information -Code 'PE-SAF-004' `
|
||||
-Location 'configVersion' `
|
||||
-Description 'No previous configuration was supplied, so the version-downgrade and rule-drift checks did not run. This is not a pass - the checks were skipped.' `
|
||||
-SuggestedResolution 'Pass -PreviousConfigPath pointing at the currently deployed configuration to enable the comparison checks. In CI, this is the copy from the deployed branch.'))
|
||||
}
|
||||
elseif (-not (Test-Path -LiteralPath $PreviousConfigPath -PathType Leaf)) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Warning -Code 'PE-SAF-004' `
|
||||
-Location $PreviousConfigPath `
|
||||
-Description 'The previous configuration path was supplied but does not exist. The comparison checks did not run.' `
|
||||
-SuggestedResolution 'Correct the path, or omit it deliberately if this is the first deployment.'))
|
||||
}
|
||||
else {
|
||||
foreach ($finding in (Compare-PersonaConfigurationVersion -Document $Document -PreviousConfigPath $PreviousConfigPath -EnforcementEnabled:$EnforcementEnabled)) {
|
||||
$findings.Add($finding)
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------- PE-SAF-007
|
||||
|
||||
if ($SavePath -and -not $BackupPlanned -and (Test-Path -LiteralPath $SavePath -PathType Leaf)) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-007' `
|
||||
-Location $SavePath `
|
||||
-Description 'The save would overwrite an existing configuration with no backup. If the replacement turns out to be wrong, the only known-good copy is gone and there is nothing to roll back to.' `
|
||||
-SuggestedResolution 'Allow the timestamped backup, or supply -OutputPath to save alongside the original (FR-026).'))
|
||||
}
|
||||
|
||||
$findings
|
||||
}
|
||||
|
||||
function Compare-PersonaConfigurationVersion {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Compares a candidate configuration against the deployed one (PE-SAF-004, PE-SAF-005).
|
||||
|
||||
.DESCRIPTION
|
||||
Two questions, both about change control rather than correctness:
|
||||
|
||||
Did the version go backwards? A downgrade means an older rule set is about
|
||||
to replace a newer one, and audit records would carry a configVersion that
|
||||
has already been superseded - so two different rule sets share one version
|
||||
number and no record can tell them apart.
|
||||
|
||||
Did rules disappear or change order without the version changing? Deleting
|
||||
a rule silently reclassifies everyone it used to match; reordering does the
|
||||
same for anyone matched by an overtaken rule. Neither is wrong in itself,
|
||||
but doing it under an unchanged version number makes the change invisible
|
||||
in the audit trail.
|
||||
|
||||
Under enforcement these are Errors, because the consequence is a directory
|
||||
write. In preview they are Warnings: the same drift is worth seeing but costs
|
||||
nothing yet.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Document,
|
||||
[Parameter(Mandatory)] [string] $PreviousConfigPath,
|
||||
[switch] $EnforcementEnabled
|
||||
)
|
||||
|
||||
$findings = [System.Collections.Generic.List[object]]::new()
|
||||
$severity = $EnforcementEnabled ? 'Error' : 'Warning'
|
||||
|
||||
$previous = $null
|
||||
try {
|
||||
$previous = Get-Content -LiteralPath $PreviousConfigPath -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 32 -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Warning -Code 'PE-SAF-004' `
|
||||
-Location $PreviousConfigPath `
|
||||
-Description "The previous configuration could not be parsed, so the comparison checks did not run: $($_.Exception.Message)" `
|
||||
-SuggestedResolution 'Point -PreviousConfigPath at a valid configuration, or omit it.'))
|
||||
return $findings
|
||||
}
|
||||
|
||||
$currentVersion = $null
|
||||
$previousVersion = $null
|
||||
$parsedBoth = [version]::TryParse([string]$Document.configVersion, [ref] $currentVersion) -and
|
||||
[version]::TryParse([string]$previous.configVersion, [ref] $previousVersion)
|
||||
|
||||
if ($parsedBoth -and $currentVersion -lt $previousVersion) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity $severity -Code 'PE-SAF-004' `
|
||||
-Location 'configVersion' `
|
||||
-Description "configVersion $currentVersion is lower than the deployed version $previousVersion. Audit records would report a version that has already been superseded, making two different rule sets indistinguishable in the log." `
|
||||
-SuggestedResolution 'Raise configVersion above the deployed version. If a rollback is genuinely intended, publish it as a new higher version rather than reusing the old number.'))
|
||||
}
|
||||
|
||||
if ($parsedBoth -and $currentVersion -ne $previousVersion) {
|
||||
# The version moved, so drift is declared. Nothing further to report.
|
||||
return $findings
|
||||
}
|
||||
|
||||
$currentIds = @($Document.rules | ForEach-Object { [string]$_.id })
|
||||
$previousIds = @($previous.rules | ForEach-Object { [string]$_.id })
|
||||
|
||||
$removed = @($previousIds | Where-Object { $_ -notin $currentIds })
|
||||
if ($removed.Count -gt 0) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity $severity -Code 'PE-SAF-005' `
|
||||
-Location 'rules' `
|
||||
-Description "Rules removed without a configVersion change: $($removed -join ', '). Every account these rules matched will be reclassified by a later rule, or become Unclassified, with nothing in the audit trail marking the change." `
|
||||
-SuggestedResolution 'Raise configVersion so the change is declared, or disable the rules instead of deleting them so the audit trail keeps reporting zero matches against them.'))
|
||||
}
|
||||
|
||||
$currentOrder = @($Document.rules | Sort-Object -Property @{ Expression = { [int]$_.priority } } | ForEach-Object { [string]$_.id })
|
||||
$previousOrder = @($previous.rules | Sort-Object -Property @{ Expression = { [int]$_.priority } } | ForEach-Object { [string]$_.id })
|
||||
$shared = @($currentOrder | Where-Object { $_ -in $previousIds })
|
||||
$sharedPrevious = @($previousOrder | Where-Object { $_ -in $currentIds })
|
||||
|
||||
if (($shared -join '>') -ne ($sharedPrevious -join '>')) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity $severity -Code 'PE-SAF-005' `
|
||||
-Location 'rules[*].priority' `
|
||||
-Description 'Rule evaluation order changed without a configVersion change. First match wins (FR-009), so a reorder silently reassigns every account matched by more than one rule.' `
|
||||
-SuggestedResolution 'Raise configVersion so the reorder is declared and traceable in audit records.'))
|
||||
}
|
||||
|
||||
$findings
|
||||
}
|
||||
|
||||
function Test-PersonaWritableAttributeShape {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Reports whether an attribute name is shaped like a writable extension property.
|
||||
|
||||
.DESCRIPTION
|
||||
OTD-001 selected directory (schema) extension properties as the persona
|
||||
store. Only those may be written. Built-in attributes are deliberately
|
||||
excluded even when the operator holds permission to write them: they are
|
||||
authoritative in HR or in the sync source, and this engine is not their owner.
|
||||
|
||||
The placeholder form is accepted so the committed example configuration
|
||||
passes its own validator without carrying a real application ID (SC-013).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([bool])]
|
||||
param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Name)
|
||||
|
||||
if ($Name -match '^extension_[0-9a-fA-F]{32}_[A-Za-z0-9]+$') { return $true }
|
||||
if ($Name -match '^extension_<[^>]+>_<?[^>]+>?$') { return $true }
|
||||
|
||||
$false
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
function Test-PersonaConfigurationSemantic {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validation layer 3: semantic checks (VR-002).
|
||||
|
||||
.DESCRIPTION
|
||||
Every condition VR-002 names, one stable code each. Codes are part of the
|
||||
contract - pipelines and runbooks match on them - so a code is never reused
|
||||
for a different condition and never renumbered.
|
||||
|
||||
PE-SEM-001 duplicate rule IDs
|
||||
PE-SEM-002 duplicate priorities
|
||||
PE-SEM-003 no enabled rules
|
||||
PE-SEM-004 invalid or blank target attribute
|
||||
PE-SEM-005 target attribute absent from the approved writable list
|
||||
PE-SEM-006 reference to an unavailable data source
|
||||
PE-SEM-007 memberOf/notMemberOf without group Object IDs
|
||||
PE-SEM-008 in/notIn without values
|
||||
PE-SEM-009 isNull/isNotNull carrying a comparison value
|
||||
PE-SEM-010 undefined or prohibited persona value
|
||||
PE-SEM-011 Unclassified used as an ordinary rule persona
|
||||
PE-SEM-012 condition depth over the configured maximum
|
||||
PE-SEM-013 configured maximum over the hard ceiling of 10
|
||||
PE-SEM-014 membership mode not enabled globally
|
||||
PE-SEM-015 unsupported property name
|
||||
PE-SEM-016 invalid regular expression
|
||||
|
||||
Several of these are also expressible in JSON Schema and some are already
|
||||
caught there. They are repeated here deliberately: layer 2 can be bypassed
|
||||
with -SchemaPath, and V-5a showed that an unparseable schema silently passes
|
||||
on this build. A rule that can misclassify a privileged account should not
|
||||
depend on one layer alone.
|
||||
|
||||
.PARAMETER Document
|
||||
The parsed configuration document.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.ValidationFinding objects. Empty when the document is sound.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Document
|
||||
)
|
||||
|
||||
$findings = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
$engine = $Document.engine
|
||||
$rules = @($Document.rules)
|
||||
$personas = @($Document.personas)
|
||||
|
||||
# ---------------------------------------------------- target attribute
|
||||
|
||||
$target = [string]$engine.targetAttribute
|
||||
$approved = @($engine.approvedWritableAttributes)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($target)) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-004' `
|
||||
-Location 'engine.targetAttribute' `
|
||||
-Description 'The target attribute is blank. The engine has no attribute to compare against or write.' `
|
||||
-SuggestedResolution 'Set engine.targetAttribute to the approved persona extension property name.'))
|
||||
}
|
||||
elseif ($target -cnotin $approved) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-005' `
|
||||
-Location 'engine.targetAttribute' `
|
||||
-Description "The target attribute '$target' does not appear in engine.approvedWritableAttributes. Comparison is ordinal, so a casing difference counts as absent." `
|
||||
-SuggestedResolution 'Add the exact attribute name to approvedWritableAttributes, or correct the target attribute. Extension property names are case-sensitive in Graph.'))
|
||||
}
|
||||
|
||||
# ---------------------------------------------------- depth ceiling
|
||||
|
||||
$maxDepth = ($null -ne $engine.maxConditionDepth) ? [int]$engine.maxConditionDepth : 5
|
||||
|
||||
if ($maxDepth -gt 10) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-013' `
|
||||
-Location 'engine.maxConditionDepth' `
|
||||
-Description "maxConditionDepth is $maxDepth, above the hard ceiling of 10 (RE-004)." `
|
||||
-SuggestedResolution 'Lower maxConditionDepth to 10 or less. A rule needing deeper nesting is better split into two rules with distinct priorities.'))
|
||||
}
|
||||
|
||||
if ($maxDepth -lt 1) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-013' `
|
||||
-Location 'engine.maxConditionDepth' `
|
||||
-Description "maxConditionDepth is $maxDepth, below the minimum of 1 (RE-004). No rule could be evaluated." `
|
||||
-SuggestedResolution 'Set maxConditionDepth to at least 1.'))
|
||||
}
|
||||
|
||||
# ---------------------------------------------------- data sources
|
||||
|
||||
$groupsEnabled = [bool]$Document.dataSources.groups.enabled
|
||||
$rolesEnabled = [bool]$Document.dataSources.roles.enabled
|
||||
# Left null when the configuration does not pin a mode. Absent means "any mode is
|
||||
# acceptable", which is the normal case: RE-007 makes mode a per-condition
|
||||
# choice, and the facets are retrieved independently. Pinning it globally is a
|
||||
# deliberate restriction, and only then is a per-condition override worth
|
||||
# flagging.
|
||||
$globalMode = $Document.dataSources.groups.PSObject.Properties['membershipMode'] `
|
||||
? [string]$Document.dataSources.groups.membershipMode : $null
|
||||
$defaultMode = $engine.defaultMembershipMode ? [string]$engine.defaultMembershipMode : 'direct'
|
||||
|
||||
# ---------------------------------------------------- rules
|
||||
|
||||
if ($rules.Count -eq 0) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-003' `
|
||||
-Location 'rules' `
|
||||
-Description 'The configuration contains no rules.' `
|
||||
-SuggestedResolution 'Add at least one enabled rule. A run with no rules classifies every account as Unclassified.'))
|
||||
}
|
||||
elseif (@($rules | Where-Object { $_.enabled }).Count -eq 0) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-003' `
|
||||
-Location 'rules' `
|
||||
-Description 'Every rule in the configuration is disabled. The run would classify every account as Unclassified and, in enforce mode, propose clearing every stored persona.' `
|
||||
-SuggestedResolution 'Enable at least one rule, or do not deploy this configuration.'))
|
||||
}
|
||||
|
||||
foreach ($group in ($rules | Group-Object -Property { [string]$_.id } | Where-Object Count -GT 1)) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-001' `
|
||||
-Location "rules[id=$($group.Name)]" `
|
||||
-Description "Rule ID '$($group.Name)' is used by $($group.Count) rules. Rule IDs appear in audit records and are how a decision is traced back to its rule." `
|
||||
-SuggestedResolution 'Give each rule a unique ID.'))
|
||||
}
|
||||
|
||||
foreach ($group in ($rules | Where-Object { $_.enabled } | Group-Object -Property { [int]$_.priority } | Where-Object Count -GT 1)) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-002' `
|
||||
-Location "rules[priority=$($group.Name)]" `
|
||||
-Description "Priority $($group.Name) is shared by $($group.Count) enabled rules: $(($group.Group | ForEach-Object { [string]$_.id }) -join ', '). Evaluation order between them is not defined by the configuration (RE-002)." `
|
||||
-SuggestedResolution 'Assign a unique priority to each enabled rule. The engine breaks ties by rule ID so results stay deterministic, but the resulting order is an accident rather than a decision.'))
|
||||
}
|
||||
|
||||
foreach ($rule in $rules) {
|
||||
$ruleId = [string]$rule.id
|
||||
$persona = [string]$rule.persona
|
||||
|
||||
if ($persona -ieq 'Unclassified') {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-011' `
|
||||
-Location "rules[$ruleId].persona" `
|
||||
-Description 'Unclassified is a processing result, not a rule outcome (FR-010). A rule that assigns it makes "no rule matched" indistinguishable from "this rule matched".' `
|
||||
-SuggestedResolution 'Remove the rule, or give it a real persona. Accounts matching no rule already receive Unclassified.'))
|
||||
}
|
||||
elseif ($persona -ieq 'EvaluationError') {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-010' `
|
||||
-Location "rules[$ruleId].persona" `
|
||||
-Description 'EvaluationError is an execution result and must never be assigned by a rule.' `
|
||||
-SuggestedResolution 'Give the rule a persona from the personas catalogue.'))
|
||||
}
|
||||
elseif ($persona -and $persona -notin $personas) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-010' `
|
||||
-Location "rules[$ruleId].persona" `
|
||||
-Description "Persona '$persona' is not declared in the personas catalogue. The catalogue is what stops a typo from writing a new persona value into the directory." `
|
||||
-SuggestedResolution "Add '$persona' to the personas array, or correct the spelling."))
|
||||
}
|
||||
|
||||
if ($null -eq $rule.match) { continue }
|
||||
|
||||
$context = @{
|
||||
RuleId = $ruleId
|
||||
MaxDepth = $maxDepth
|
||||
GroupsEnabled = $groupsEnabled
|
||||
RolesEnabled = $rolesEnabled
|
||||
GlobalMode = $globalMode
|
||||
DefaultMode = $defaultMode
|
||||
Findings = $findings
|
||||
}
|
||||
|
||||
Test-PersonaSemanticNode -Node $rule.match -Depth 1 -PathText "rules[$ruleId].match" -Context $context
|
||||
}
|
||||
|
||||
$findings
|
||||
}
|
||||
|
||||
function Test-PersonaSemanticNode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Recursively validates one condition group or condition.
|
||||
|
||||
.DESCRIPTION
|
||||
Depth is counted the same way the engine counts it, so the validator and the
|
||||
runtime agree about what "too deep" means. A validator with its own depth
|
||||
arithmetic would eventually pass a configuration the engine rejects at run
|
||||
time, against a live tenant, which is the worst place to discover it.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Node,
|
||||
[Parameter(Mandatory)] [int] $Depth,
|
||||
[Parameter(Mandatory)] [string] $PathText,
|
||||
[Parameter(Mandatory)] [hashtable] $Context
|
||||
)
|
||||
|
||||
$findings = $Context.Findings
|
||||
|
||||
if ($Depth -gt $Context.MaxDepth) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-012' `
|
||||
-Location $PathText `
|
||||
-Description "Condition nesting reaches depth $Depth, above the configured maximum of $($Context.MaxDepth) (RE-004). The engine returns Unknown beyond the limit, which becomes EvaluationError for every account this rule reaches." `
|
||||
-SuggestedResolution 'Flatten the condition tree, or raise engine.maxConditionDepth up to the ceiling of 10.'))
|
||||
return
|
||||
}
|
||||
|
||||
# A group: recurse and stop. Groups carry no operator-level semantics of their own.
|
||||
if ($Node.PSObject.Properties['conditions'] -and $Node.conditions) {
|
||||
$index = 0
|
||||
foreach ($child in $Node.conditions) {
|
||||
Test-PersonaSemanticNode -Node $child -Depth ($Depth + 1) -PathText "$PathText.conditions[$index]" -Context $Context
|
||||
$index++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
$type = [string]$Node.type
|
||||
$operator = [string]$Node.operator
|
||||
$hasValue = $Node.PSObject.Properties['value'] -and $null -ne $Node.value
|
||||
|
||||
# @($Node.values) on an absent property yields @($null) - a one-element array -
|
||||
# which would make an empty list look populated and a populated one look no
|
||||
# different. Every array read here goes through the null filter for that reason.
|
||||
$values = @($Node.values | Where-Object { $null -ne $_ })
|
||||
$groupIds = @($Node.groupObjectIds | Where-Object { $null -ne $_ })
|
||||
$roleIds = @($Node.roleIds | Where-Object { $null -ne $_ })
|
||||
|
||||
switch ($operator) {
|
||||
|
||||
{ $_ -in @('memberOf', 'notMemberOf') } {
|
||||
if ($type -eq 'role') {
|
||||
if ($roleIds.Count -eq 0) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-007' `
|
||||
-Location $PathText `
|
||||
-Description "A role condition using '$operator' carries no roleIds. It can never evaluate to a meaningful result." `
|
||||
-SuggestedResolution 'Add at least one role template ID to roleIds.'))
|
||||
}
|
||||
|
||||
if (-not $Context.RolesEnabled) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-006' `
|
||||
-Location $PathText `
|
||||
-Description 'This rule requires directory role data, but dataSources.roles.enabled is false. The role facet is never retrieved, so every account reaching this rule becomes EvaluationError (FR-013).' `
|
||||
-SuggestedResolution 'Enable dataSources.roles, or remove the role conditions.'))
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($groupIds.Count -eq 0) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-007' `
|
||||
-Location $PathText `
|
||||
-Description "A membership condition using '$operator' carries no groupObjectIds." `
|
||||
-SuggestedResolution 'Add at least one group Object ID to groupObjectIds. Object IDs are used rather than names because names are mutable (RE-009).'))
|
||||
}
|
||||
|
||||
if (-not $Context.GroupsEnabled) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-006' `
|
||||
-Location $PathText `
|
||||
-Description 'This rule requires group membership data, but dataSources.groups.enabled is false. Every account reaching this rule becomes EvaluationError (FR-013).' `
|
||||
-SuggestedResolution 'Enable dataSources.groups, or remove the membership conditions.'))
|
||||
}
|
||||
|
||||
$mode = ($Node.PSObject.Properties['membershipMode'] -and $Node.membershipMode) ? [string]$Node.membershipMode : $Context.DefaultMode
|
||||
|
||||
if ($Context.GroupsEnabled -and $mode -ine $Context.GlobalMode -and $Context.GlobalMode) {
|
||||
# Not an error. The three facets are retrieved independently, so a
|
||||
# per-condition mode differing from the global one is served
|
||||
# correctly - but it is worth flagging, because it usually means
|
||||
# the author did not realise the global setting was there.
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Warning -Code 'PE-SEM-014' `
|
||||
-Location $PathText `
|
||||
-Description "This condition requests '$mode' membership while dataSources.groups.membershipMode is '$($Context.GlobalMode)'. Both facets will be retrieved, at the cost of an extra call per account." `
|
||||
-SuggestedResolution "Confirm '$mode' is intended here. If every rule wants the same mode, set it globally and drop the per-condition override."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{ $_ -in @('in', 'notIn') } {
|
||||
if ($values.Count -eq 0) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-008' `
|
||||
-Location $PathText `
|
||||
-Description "Operator '$operator' requires a values array, which is absent or empty. An empty set matches nothing and would silently never fire." `
|
||||
-SuggestedResolution 'Populate values, or use equals/notEquals for a single comparison.'))
|
||||
}
|
||||
}
|
||||
|
||||
{ $_ -in @('isNull', 'isNotNull') } {
|
||||
if ($hasValue -or $values.Count -gt 0) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-009' `
|
||||
-Location $PathText `
|
||||
-Description "Operator '$operator' tests for presence and ignores any comparison value. A value here is silently discarded, so the rule does not do what it appears to do." `
|
||||
-SuggestedResolution 'Remove value/values, or switch to equals if a comparison was intended.'))
|
||||
}
|
||||
}
|
||||
|
||||
'matchesRegex' {
|
||||
if (-not $hasValue) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-016' `
|
||||
-Location $PathText `
|
||||
-Description 'matchesRegex requires a pattern in value.' `
|
||||
-SuggestedResolution 'Supply a regular expression in the value field.'))
|
||||
}
|
||||
else {
|
||||
try {
|
||||
# Compiling proves the pattern parses. Validating here rather than
|
||||
# at run time means a bad pattern fails a pipeline, not a
|
||||
# production run in which every account becomes EvaluationError.
|
||||
$null = [System.Text.RegularExpressions.Regex]::new([string]$Node.value)
|
||||
}
|
||||
catch {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-016' `
|
||||
-Location $PathText `
|
||||
-Description "Invalid regular expression: $($_.Exception.Message)" `
|
||||
-SuggestedResolution 'Correct the pattern. Remember that JSON requires backslashes to be escaped, so \d is written \\d.'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($type -eq 'property') {
|
||||
$property = [string]$Node.property
|
||||
|
||||
if (-not (Test-PersonaSupportedProperty -Name $property)) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-015' `
|
||||
-Location $PathText `
|
||||
-Description "Property '$property' is not a supported property name. Unsupported properties are never retrieved, so the condition would compare against a permanently absent value." `
|
||||
-SuggestedResolution 'Use one of AccountObjectId, UserPrincipalName, DisplayName, UserType, AccountEnabled, CompanyName, JobTitle, Department, or an extension property named extension_<APP-ID>_<NAME>.'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Test-PersonaSupportedProperty {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Reports whether a property name can actually be retrieved and evaluated.
|
||||
|
||||
.DESCRIPTION
|
||||
The FR-005 baseline plus directory extension properties. Extension names are
|
||||
accepted on shape alone - extension_<32 hex>_<name> - because the set of
|
||||
registered extensions is tenant-specific and cannot be known offline, and
|
||||
SC-008 requires validation to run with no tenant.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([bool])]
|
||||
param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Name)
|
||||
|
||||
$supported = @(
|
||||
'AccountObjectId', 'UserPrincipalName', 'DisplayName', 'UserType',
|
||||
'AccountEnabled', 'CompanyName', 'JobTitle', 'Department'
|
||||
)
|
||||
|
||||
if ($Name -in $supported) { return $true }
|
||||
if ($Name -match '^extension_[0-9a-fA-F]{32}_[A-Za-z0-9]+$') { return $true }
|
||||
|
||||
# The placeholder form used in committed examples (SC-013) must validate, or the
|
||||
# shipped example configuration could never pass its own validator.
|
||||
if ($Name -match '^extension_<[^>]+>_<?[^>]+>?$') { return $true }
|
||||
|
||||
$false
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
function Get-PersonaDirectoryRoles {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Retrieves active directory role assignments for one user.
|
||||
|
||||
.DESCRIPTION
|
||||
Uses the unified role management endpoint, which returns roleDefinitionId
|
||||
values (role template IDs) — stable across tenants, unlike role instance IDs,
|
||||
so a configuration written against them is portable.
|
||||
|
||||
Eligible (PIM) assignments are out of scope for v1 unless authorization is
|
||||
confirmed and a provider is implemented (spec Out of Scope).
|
||||
|
||||
Throws on failure. The caller (Get-PersonaGroupMembership) contains the
|
||||
failure into the Roles facet so it never becomes a false non-match.
|
||||
|
||||
.PARAMETER UserObjectId
|
||||
The principal to query.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string[]])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $UserObjectId
|
||||
)
|
||||
|
||||
$uri = "/v1.0/roleManagement/directory/roleAssignments?`$filter=principalId eq '$UserObjectId'"
|
||||
$ids = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
while ($uri) {
|
||||
$response = Invoke-PersonaGraphRequest -Uri $uri
|
||||
|
||||
if ($null -eq $response -or -not $response.ContainsKey('value')) {
|
||||
throw 'Role assignment endpoint returned an unexpected response shape.'
|
||||
}
|
||||
|
||||
foreach ($assignment in $response['value']) {
|
||||
if ($assignment['roleDefinitionId']) { $ids.Add([string]$assignment['roleDefinitionId']) }
|
||||
}
|
||||
|
||||
$uri = $response.ContainsKey('@odata.nextLink') ? $response['@odata.nextLink'] : $null
|
||||
}
|
||||
|
||||
, $ids.ToArray()
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
function Get-PersonaGroupMembership {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Retrieves group membership and directory roles for one user.
|
||||
|
||||
.DESCRIPTION
|
||||
Returns a MembershipRecord — ALWAYS. On failure it returns a record with the
|
||||
affected facet unretrieved and a FailureReason. It never returns an empty
|
||||
list on failure and never throws past the per-user boundary.
|
||||
|
||||
That single behaviour is what makes FR-013 work: unknown membership becomes
|
||||
EvaluationError, never a false non-match. If this function ever throws or
|
||||
returns empty on error, a transient Graph outage silently reclassifies
|
||||
privileged accounts.
|
||||
|
||||
Fetches only the facets the configuration actually needs, and each one
|
||||
independently, so a failure in one does not make the others unevaluable.
|
||||
|
||||
.PARAMETER UserObjectId
|
||||
The user to query.
|
||||
|
||||
.PARAMETER NeedDirect
|
||||
Fetch direct group membership.
|
||||
|
||||
.PARAMETER NeedTransitive
|
||||
Fetch transitive group membership.
|
||||
|
||||
.PARAMETER NeedRoles
|
||||
Fetch directory role assignments.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $UserObjectId,
|
||||
|
||||
[switch] $NeedDirect,
|
||||
[switch] $NeedTransitive,
|
||||
[switch] $NeedRoles
|
||||
)
|
||||
|
||||
$direct = @()
|
||||
$transitive = @()
|
||||
$roles = @()
|
||||
$directOk = $false
|
||||
$transitiveOk = $false
|
||||
$rolesOk = $false
|
||||
$directErr = $null
|
||||
$transitiveErr = $null
|
||||
$rolesErr = $null
|
||||
|
||||
if ($NeedDirect) {
|
||||
try {
|
||||
$direct = Get-PersonaGroupIdPage -Uri "/v1.0/users/$UserObjectId/memberOf?`$select=id&`$top=999"
|
||||
$directOk = $true
|
||||
}
|
||||
catch {
|
||||
$directErr = $_.Exception.Message
|
||||
Write-Verbose "Direct membership lookup failed for $UserObjectId : $directErr"
|
||||
}
|
||||
}
|
||||
|
||||
if ($NeedTransitive) {
|
||||
try {
|
||||
$transitive = Get-PersonaGroupIdPage -Uri "/v1.0/users/$UserObjectId/transitiveMemberOf?`$select=id&`$top=999"
|
||||
$transitiveOk = $true
|
||||
}
|
||||
catch {
|
||||
$transitiveErr = $_.Exception.Message
|
||||
Write-Verbose "Transitive membership lookup failed for $UserObjectId : $transitiveErr"
|
||||
}
|
||||
}
|
||||
|
||||
if ($NeedRoles) {
|
||||
try {
|
||||
$roles = Get-PersonaDirectoryRoles -UserObjectId $UserObjectId
|
||||
$rolesOk = $true
|
||||
}
|
||||
catch {
|
||||
$rolesErr = $_.Exception.Message
|
||||
Write-Verbose "Role lookup failed for $UserObjectId : $rolesErr"
|
||||
}
|
||||
}
|
||||
|
||||
$params = @{
|
||||
DirectGroupObjectIds = $direct
|
||||
TransitiveGroupObjectIds = $transitive
|
||||
DirectoryRoleIds = $roles
|
||||
}
|
||||
if ($directOk) { $params['DirectRetrieved'] = $true } elseif ($NeedDirect) { $params['DirectFailureReason'] = $directErr }
|
||||
if ($transitiveOk) { $params['TransitiveRetrieved'] = $true } elseif ($NeedTransitive) { $params['TransitiveFailureReason'] = $transitiveErr }
|
||||
if ($rolesOk) { $params['RolesRetrieved'] = $true } elseif ($NeedRoles) { $params['RolesFailureReason'] = $rolesErr }
|
||||
|
||||
New-PersonaMembershipRecord @params
|
||||
}
|
||||
|
||||
function Get-PersonaGroupIdPage {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Collects group Object IDs across all pages of a membership endpoint.
|
||||
|
||||
.DESCRIPTION
|
||||
memberOf returns directory objects of mixed type. Only group IDs are
|
||||
collected; administrative units and other object types are ignored.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string[]])]
|
||||
param([Parameter(Mandatory)] [string] $Uri)
|
||||
|
||||
$ids = [System.Collections.Generic.List[string]]::new()
|
||||
$next = $Uri
|
||||
|
||||
while ($next) {
|
||||
$response = Invoke-PersonaGraphRequest -Uri $next
|
||||
|
||||
if ($null -eq $response -or -not $response.ContainsKey('value')) {
|
||||
throw 'Membership endpoint returned an unexpected response shape.'
|
||||
}
|
||||
|
||||
foreach ($item in $response['value']) {
|
||||
$type = $item['@odata.type']
|
||||
if ($type -and $type -ne '#microsoft.graph.group') { continue }
|
||||
if ($item['id']) { $ids.Add([string]$item['id']) }
|
||||
}
|
||||
|
||||
$next = $response.ContainsKey('@odata.nextLink') ? $response['@odata.nextLink'] : $null
|
||||
}
|
||||
|
||||
, $ids.ToArray()
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
function Get-PersonaUsers {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Retrieves in-scope user objects, following pagination to exhaustion.
|
||||
|
||||
.DESCRIPTION
|
||||
Requests only the properties enabled rules actually need (FR-005) plus the
|
||||
configured target attribute and the operational fields required for logging.
|
||||
|
||||
Pagination follows @odata.nextLink until absent (FR-004). A truncated
|
||||
enumeration raises rather than returning a partial population — silently
|
||||
classifying half a tenant is worse than failing.
|
||||
|
||||
Emits raw Graph objects. Normalization is ConvertTo-PersonaUserRecord's job;
|
||||
the rule engine never sees what this returns.
|
||||
|
||||
.PARAMETER SelectProperties
|
||||
Property names for $select.
|
||||
|
||||
.PARAMETER UserObjectId
|
||||
Retrieves a single user instead of enumerating (the -UserObjectId path).
|
||||
|
||||
.PARAMETER PageSize
|
||||
$top value. Graph caps user enumeration at 999.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string[]] $SelectProperties,
|
||||
|
||||
[string] $UserObjectId,
|
||||
|
||||
[ValidateRange(1, 999)]
|
||||
[int] $PageSize = 999
|
||||
)
|
||||
|
||||
$select = ($SelectProperties | Select-Object -Unique) -join ','
|
||||
|
||||
if ($UserObjectId) {
|
||||
Write-Verbose "Retrieving single user $UserObjectId"
|
||||
return @(Invoke-PersonaGraphRequest -Uri "/v1.0/users/$UserObjectId`?`$select=$select")
|
||||
}
|
||||
|
||||
$uri = "/v1.0/users?`$select=$select&`$top=$PageSize"
|
||||
$page = 0
|
||||
|
||||
while ($uri) {
|
||||
$page++
|
||||
Write-Verbose "Retrieving user page $page"
|
||||
|
||||
$response = Invoke-PersonaGraphRequest -Uri $uri
|
||||
|
||||
if ($null -eq $response -or -not $response.ContainsKey('value')) {
|
||||
throw "User enumeration returned an unexpected response shape on page $page. Refusing to continue with a partial population."
|
||||
}
|
||||
|
||||
foreach ($user in $response['value']) { $user }
|
||||
|
||||
$uri = $response.ContainsKey('@odata.nextLink') ? $response['@odata.nextLink'] : $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PersonaRequiredProperties {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds the $select list from the configuration and its enabled rules.
|
||||
|
||||
.DESCRIPTION
|
||||
The FR-005 baseline plus the target attribute plus every property an enabled
|
||||
rule references. Properties nothing references are not requested — least
|
||||
privilege applies to data as well as permissions.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string[]])]
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Configuration
|
||||
)
|
||||
|
||||
# Graph property names, which differ in casing from the normalized record.
|
||||
$baseline = @(
|
||||
'id', 'userPrincipalName', 'displayName', 'userType',
|
||||
'accountEnabled', 'companyName', 'jobTitle', 'department'
|
||||
)
|
||||
|
||||
$properties = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($p in $baseline) { $null = $properties.Add($p) }
|
||||
|
||||
if ($Configuration.TargetAttribute) { $null = $properties.Add($Configuration.TargetAttribute) }
|
||||
|
||||
foreach ($name in (Get-PersonaReferencedProperties -Rules $Configuration.Rules)) {
|
||||
# Intrinsics are already covered by the baseline under their Graph names.
|
||||
if ($name -in @('AccountObjectId', 'UserPrincipalName', 'DisplayName', 'UserType', 'AccountEnabled')) { continue }
|
||||
$null = $properties.Add((Get-PersonaGraphPropertyName $name))
|
||||
}
|
||||
|
||||
, @($properties)
|
||||
}
|
||||
|
||||
function Get-PersonaReferencedProperties {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Walks enabled rules and collects every referenced property name.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string[]])]
|
||||
param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Rules)
|
||||
|
||||
$found = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
|
||||
function Walk {
|
||||
param($Node)
|
||||
if ($null -eq $Node) { return }
|
||||
|
||||
if ($Node.PSObject.Properties['conditions'] -and $Node.conditions) {
|
||||
foreach ($child in $Node.conditions) { Walk $child }
|
||||
return
|
||||
}
|
||||
|
||||
if ($Node.PSObject.Properties['property'] -and $Node.property) {
|
||||
$null = $found.Add([string]$Node.property)
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rule in @($Rules)) {
|
||||
if (-not $rule.enabled) { continue }
|
||||
Walk $rule.match
|
||||
}
|
||||
|
||||
, @($found)
|
||||
}
|
||||
|
||||
function Get-PersonaGraphPropertyName {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Maps a normalized property name to its Graph equivalent.
|
||||
|
||||
.DESCRIPTION
|
||||
Extension property names pass through unchanged — they are already in Graph
|
||||
form and are case-sensitive, unlike the built-in properties.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param([Parameter(Mandatory, Position = 0)] [string] $Name)
|
||||
|
||||
if ($Name -like 'extension_*') { return $Name }
|
||||
|
||||
# camelCase the first letter; Graph built-ins are camelCase.
|
||||
if ($Name.Length -gt 0) {
|
||||
return $Name.Substring(0, 1).ToLowerInvariant() + $Name.Substring(1)
|
||||
}
|
||||
|
||||
$Name
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
function Invoke-PersonaGraphRequest {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Issues a Microsoft Graph request with the retry policy from OTD-007.
|
||||
|
||||
.DESCRIPTION
|
||||
The single point at which the engine touches Graph. Direct REST via
|
||||
Invoke-MgGraphRequest (OTD-004), so request bodies are explicit values that
|
||||
tests can assert on — which is what makes SC-005 provable.
|
||||
|
||||
Retry policy:
|
||||
Retryable 429, 500, 502, 503, 504, transport timeout
|
||||
Never 400, 401, 403, 404, 409 (configuration, authorization, or
|
||||
logic defects — retrying masks them)
|
||||
Retry-After honoured when present; overrides computed backoff
|
||||
Attempts max 5, exponential from 1s, full jitter, per-delay cap 60s
|
||||
|
||||
Never logs tokens, Authorization headers, or full response bodies.
|
||||
|
||||
.PARAMETER Uri
|
||||
Absolute or Graph-relative URI.
|
||||
|
||||
.PARAMETER Method
|
||||
HTTP method. Defaults to GET.
|
||||
|
||||
.PARAMETER Body
|
||||
Request body. Passed through unchanged so the caller controls exactly what
|
||||
is sent.
|
||||
|
||||
.PARAMETER MaxAttempts
|
||||
Maximum attempts including the first. Default 5.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Uri,
|
||||
|
||||
[ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')]
|
||||
[string] $Method = 'GET',
|
||||
|
||||
[object] $Body,
|
||||
|
||||
[ValidateRange(1, 10)]
|
||||
[int] $MaxAttempts = 5,
|
||||
|
||||
[int] $BaseDelayMs = 1000,
|
||||
|
||||
[int] $MaxDelayMs = 60000
|
||||
)
|
||||
|
||||
$retryableStatus = @(429, 500, 502, 503, 504)
|
||||
$attempt = 0
|
||||
|
||||
while ($true) {
|
||||
$attempt++
|
||||
|
||||
try {
|
||||
$params = @{ Uri = $Uri; Method = $Method; ErrorAction = 'Stop' }
|
||||
if ($null -ne $Body) {
|
||||
$params['Body'] = ($Body -is [string]) ? $Body : ($Body | ConvertTo-Json -Depth 16 -Compress)
|
||||
$params['ContentType'] = 'application/json'
|
||||
}
|
||||
|
||||
return Invoke-MgGraphRequest @params
|
||||
}
|
||||
catch {
|
||||
$status = Get-PersonaGraphStatusCode -ErrorRecord $_
|
||||
$isRetryable = ($null -eq $status) -or ($status -in $retryableStatus)
|
||||
|
||||
if (-not $isRetryable) {
|
||||
# A definite client-side failure. Retrying would hide a
|
||||
# configuration or authorization defect behind a timeout.
|
||||
throw
|
||||
}
|
||||
|
||||
if ($attempt -ge $MaxAttempts) {
|
||||
throw "Graph request failed after $attempt attempt(s) (last status: $status): $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$retryAfter = Get-PersonaRetryAfterMs -ErrorRecord $_
|
||||
|
||||
if ($null -ne $retryAfter) {
|
||||
$delay = [Math]::Min($retryAfter, $MaxDelayMs)
|
||||
}
|
||||
else {
|
||||
# Exponential with full jitter: a uniform draw from [0, backoff]
|
||||
# rather than backoff itself, so concurrent callers do not retry in
|
||||
# lockstep and re-create the throttling they are backing off from.
|
||||
$backoff = [Math]::Min($BaseDelayMs * [Math]::Pow(2, $attempt - 1), $MaxDelayMs)
|
||||
$delay = Get-Random -Minimum 0 -Maximum ([int]$backoff)
|
||||
}
|
||||
|
||||
Write-Verbose "Graph request attempt $attempt failed with status $status; retrying in $delay ms."
|
||||
Start-Sleep -Milliseconds $delay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PersonaGraphStatusCode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extracts an HTTP status code from a Graph error record. Internal helper.
|
||||
|
||||
.DESCRIPTION
|
||||
Tries the structured properties first, then falls back to the message text.
|
||||
|
||||
The message fallback is not cosmetic. Invoke-MgGraphRequest does not always
|
||||
surface a Response object, and several of its failure paths put the status
|
||||
only in prose: "Response status code does not indicate success: 403
|
||||
(Forbidden)." Without the fallback those failures return $null, which the
|
||||
retry policy treats as a transport error and retries - so a single 403 becomes
|
||||
five requests per account, hammering a tenant that is already refusing and
|
||||
turning an instant authorization failure into a long, expensive one.
|
||||
|
||||
A genuine transport failure still returns $null and is still retried. The
|
||||
distinction being drawn is "no status exists" versus "the status was not in
|
||||
the property I looked at first".
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)] $ErrorRecord)
|
||||
|
||||
$response = $ErrorRecord.Exception.PSObject.Properties['Response']
|
||||
if ($response -and $response.Value) {
|
||||
$code = $response.Value.PSObject.Properties['StatusCode']
|
||||
if ($code -and $code.Value) { return [int]$code.Value }
|
||||
}
|
||||
|
||||
if ($ErrorRecord.Exception.PSObject.Properties['StatusCode']) {
|
||||
return [int]$ErrorRecord.Exception.StatusCode
|
||||
}
|
||||
|
||||
$message = [string]$ErrorRecord.Exception.Message
|
||||
|
||||
foreach ($pattern in @(
|
||||
'status code does not indicate success:\s*(\d{3})'
|
||||
'status(?:\s*code)?[\s:=]+(\d{3})'
|
||||
'HTTP\s+(\d{3})'
|
||||
'\((\d{3})\)'
|
||||
)) {
|
||||
if ($message -match $pattern) {
|
||||
$parsed = [int]$Matches[1]
|
||||
# Only real HTTP error codes. A three-digit number elsewhere in a message
|
||||
# is not a status, and guessing one would suppress a legitimate retry.
|
||||
if ($parsed -ge 400 -and $parsed -le 599) { return $parsed }
|
||||
}
|
||||
}
|
||||
|
||||
# No status anywhere: a transport failure. Retryable.
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-PersonaRetryAfterMs {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Reads a Retry-After header, in milliseconds, if present. Internal helper.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)] $ErrorRecord)
|
||||
|
||||
try {
|
||||
$headers = $ErrorRecord.Exception.Response.Headers
|
||||
if (-not $headers) { return $null }
|
||||
|
||||
$value = $null
|
||||
if ($headers.PSObject.Properties['RetryAfter'] -and $headers.RetryAfter.Delta) {
|
||||
$value = [int]$headers.RetryAfter.Delta.TotalSeconds
|
||||
}
|
||||
|
||||
if ($null -ne $value -and $value -gt 0) { return $value * 1000 }
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
function New-PersonaDataCache {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a run-scoped cache for group and role lookups (NFR-002, FR-006).
|
||||
|
||||
.DESCRIPTION
|
||||
The cache exists for exactly one run and is discarded with it. It is never
|
||||
written to disk and never reused across runs — a stale membership record
|
||||
surviving into a later run would reclassify accounts from data nobody
|
||||
checked, which is the same failure mode FR-013 guards against, arriving by a
|
||||
different route.
|
||||
|
||||
Scope is deliberately narrow. Only lookups that are stable for the duration
|
||||
of a single run are cached: a user's membership facets, keyed by Object ID.
|
||||
Nothing derived from a rule evaluation is cached, so the cache can never
|
||||
change a decision — only how many times the same question is asked.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.DataCache
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param()
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.DataCache'
|
||||
Membership = [System.Collections.Generic.Dictionary[string, object]]::new(
|
||||
[System.StringComparer]::OrdinalIgnoreCase)
|
||||
Hits = 0
|
||||
Misses = 0
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PersonaCachedMembership {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns a cached MembershipRecord, or retrieves and caches one.
|
||||
|
||||
.DESCRIPTION
|
||||
A failed lookup is cached alongside a successful one. That is intentional:
|
||||
retrying the same failing endpoint once per rule would multiply load on an
|
||||
endpoint already in trouble, and a user's classification must not depend on
|
||||
which attempt happened to succeed. One answer per user per run, whatever it
|
||||
was.
|
||||
|
||||
.PARAMETER Cache
|
||||
The run-scoped cache from New-PersonaDataCache. When omitted, the retrieval
|
||||
runs uncached — the offline test path uses this.
|
||||
|
||||
.PARAMETER UserObjectId
|
||||
The user to resolve.
|
||||
|
||||
.PARAMETER NeedDirect
|
||||
Direct group membership is required by at least one enabled rule.
|
||||
|
||||
.PARAMETER NeedTransitive
|
||||
Transitive group membership is required by at least one enabled rule.
|
||||
|
||||
.PARAMETER NeedRoles
|
||||
Directory role assignments are required by at least one enabled rule.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[AllowNull()]
|
||||
[object] $Cache,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $UserObjectId,
|
||||
|
||||
[switch] $NeedDirect,
|
||||
[switch] $NeedTransitive,
|
||||
[switch] $NeedRoles
|
||||
)
|
||||
|
||||
if ($null -eq $Cache) {
|
||||
return Get-PersonaGroupMembership -UserObjectId $UserObjectId `
|
||||
-NeedDirect:$NeedDirect -NeedTransitive:$NeedTransitive -NeedRoles:$NeedRoles
|
||||
}
|
||||
|
||||
# The key includes the requested facets. A record fetched for direct membership
|
||||
# only cannot answer a transitive question, and returning it would present an
|
||||
# unretrieved facet as though it had been checked.
|
||||
$key = '{0}|{1}{2}{3}' -f $UserObjectId, [int]$NeedDirect.IsPresent, [int]$NeedTransitive.IsPresent, [int]$NeedRoles.IsPresent
|
||||
|
||||
$existing = $null
|
||||
if ($Cache.Membership.TryGetValue($key, [ref] $existing)) {
|
||||
$Cache.Hits++
|
||||
return $existing
|
||||
}
|
||||
|
||||
$Cache.Misses++
|
||||
$record = Get-PersonaGroupMembership -UserObjectId $UserObjectId `
|
||||
-NeedDirect:$NeedDirect -NeedTransitive:$NeedTransitive -NeedRoles:$NeedRoles
|
||||
|
||||
$Cache.Membership[$key] = $record
|
||||
$record
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
function Invoke-PersonaEngineRun {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
The classification run loop: retrieve, evaluate, compare, report, persist.
|
||||
|
||||
.DESCRIPTION
|
||||
Lives in the module rather than in Invoke-PersonaEngine.ps1 so it can be
|
||||
exercised offline with mocked data providers. That is not a testing
|
||||
convenience - SC-004 requires proof that a -WhatIf run issues zero writes
|
||||
across a full population, and a loop that only exists inside an entry script
|
||||
needing a live tenant cannot be proven at all. What ships and what is tested
|
||||
are the same code.
|
||||
|
||||
The write gate is supplied by the caller as a scriptblock, not re-derived
|
||||
here. Invoke-PersonaEngine.ps1 passes one that closes over
|
||||
$PSCmdlet.ShouldProcess, so there is still exactly one origin for the
|
||||
decision (Principle III); this function does not know what -WhatIf is and
|
||||
cannot accidentally disagree with it.
|
||||
|
||||
Per-user failures are contained. A membership lookup that fails yields
|
||||
EvaluationError for that account and the run continues; a write that fails
|
||||
yields UpdateFailed and the run continues. Only enumeration and
|
||||
authentication failures end a run, because those affect the whole population
|
||||
rather than one account.
|
||||
|
||||
.PARAMETER Configuration
|
||||
The loaded configuration.
|
||||
|
||||
.PARAMETER TargetAttribute
|
||||
The resolved target attribute.
|
||||
|
||||
.PARAMETER Context
|
||||
The audit context.
|
||||
|
||||
.PARAMETER AuditParameters
|
||||
Splat for Write-PersonaAuditRecord.
|
||||
|
||||
.PARAMETER IsEnforcing
|
||||
Whether the run-level gate returned true. Controls the Action assigned by
|
||||
Compare-PersonaValue; the per-user gate below still applies.
|
||||
|
||||
.PARAMETER ShouldProcessGate
|
||||
Scriptblock taking (upn, description) and returning a boolean. Called once
|
||||
per user that would otherwise be written. Defaults to a gate that always
|
||||
refuses - the safe default, so a caller that forgets to supply one previews
|
||||
rather than writes.
|
||||
|
||||
.PARAMETER UserObjectId
|
||||
Single-user run.
|
||||
|
||||
.PARAMETER Tracing
|
||||
Include ConditionTrace on results and audit records.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.RunOutcome carrying the counters and the exit code.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Configuration,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $TargetAttribute,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Context,
|
||||
|
||||
[hashtable] $AuditParameters = @{ Destination = 'none' },
|
||||
|
||||
[switch] $IsEnforcing,
|
||||
|
||||
[scriptblock] $ShouldProcessGate = { param($Target, $Description) $false },
|
||||
|
||||
[string] $UserObjectId,
|
||||
|
||||
[switch] $Tracing
|
||||
)
|
||||
|
||||
$EXIT_OK = 0
|
||||
$EXIT_ENUMERATION = 3
|
||||
$EXIT_DATA = 4
|
||||
$EXIT_RECONCILIATION = 5
|
||||
|
||||
$exitCode = $EXIT_OK
|
||||
|
||||
$facets = Get-PersonaRequiredFacets -Rules $Configuration.Rules -DefaultMembershipMode $Configuration.DefaultMembershipMode
|
||||
$needMembership = [bool]($facets.Direct -or $facets.Transitive -or $facets.Roles)
|
||||
|
||||
$selectProperties = Get-PersonaRequiredProperties -Configuration $Configuration
|
||||
$cache = New-PersonaDataCache
|
||||
$counters = New-PersonaRunCounter -Rules $Configuration.Rules
|
||||
|
||||
$users = $null
|
||||
try {
|
||||
$users = $UserObjectId `
|
||||
? @(Get-PersonaUsers -SelectProperties $selectProperties -UserObjectId $UserObjectId) `
|
||||
: @(Get-PersonaUsers -SelectProperties $selectProperties)
|
||||
}
|
||||
catch {
|
||||
# A partial population is worse than none: half a tenant classified looks
|
||||
# like a successful run to everything downstream.
|
||||
return New-PersonaRunOutcome -Counters $counters -ExitCode $EXIT_ENUMERATION -FailureReason $_.Exception.Message
|
||||
}
|
||||
|
||||
foreach ($graphUser in $users) {
|
||||
|
||||
$membership = $null
|
||||
if ($needMembership) {
|
||||
$membership = Get-PersonaCachedMembership -Cache $cache `
|
||||
-UserObjectId ([string](Get-PersonaMemberValue -Item $graphUser -Key 'id')) `
|
||||
-NeedDirect:([bool]$facets.Direct) `
|
||||
-NeedTransitive:([bool]$facets.Transitive) `
|
||||
-NeedRoles:([bool]$facets.Roles)
|
||||
}
|
||||
|
||||
$record = ConvertTo-PersonaUserRecord -GraphUser $graphUser -TargetAttribute $TargetAttribute -Membership $membership
|
||||
|
||||
$result = Resolve-UserPersona -UserRecord $record -Rules $Configuration.Rules `
|
||||
-MaxDepth $Configuration.MaxConditionDepth `
|
||||
-DefaultMembershipMode $Configuration.DefaultMembershipMode `
|
||||
-IncludeTrace:$Tracing
|
||||
|
||||
$result = Compare-PersonaValue -Result $result -IsEnforcing:$IsEnforcing `
|
||||
-TargetAttribute $TargetAttribute -ApprovedWritableAttributes $Configuration.ApprovedWritableAttributes
|
||||
|
||||
$previousValue = $null
|
||||
|
||||
# The only branch from which a write is reachable. Compare-PersonaValue has
|
||||
# already applied FR-016 conditions 1-3; the gate below is condition 4.
|
||||
if ($result.Action -eq 'Updated') {
|
||||
|
||||
$description = "Set '$TargetAttribute' to '$($result.CalculatedPersona)'"
|
||||
|
||||
if (& $ShouldProcessGate $result.UserPrincipalName $description) {
|
||||
|
||||
# Captured before the PATCH, never read back afterwards - a read-back
|
||||
# returns the new value, and OTD-010 rollback needs the old one.
|
||||
$previousValue = [string]$result.StoredPersona
|
||||
|
||||
$write = Set-UserPersonaAttribute `
|
||||
-UserObjectId $result.AccountObjectId `
|
||||
-AttributeName $TargetAttribute `
|
||||
-Value ([string]$result.CalculatedPersona) `
|
||||
-PreviousValue $previousValue `
|
||||
-TargetAttribute $TargetAttribute `
|
||||
-ApprovedWritableAttributes $Configuration.ApprovedWritableAttributes `
|
||||
-Confirmed
|
||||
|
||||
if (-not $write.Succeeded) {
|
||||
$result.Action = 'UpdateFailed'
|
||||
$previousValue = $null
|
||||
}
|
||||
}
|
||||
else {
|
||||
$result.Action = 'WouldUpdate'
|
||||
}
|
||||
}
|
||||
|
||||
Write-UserPersonaResult -Result $result
|
||||
|
||||
Add-PersonaRunResult -Counters $counters -Result $result
|
||||
|
||||
New-PersonaAuditRecord -Context $Context -RecordType 'UserEvent' -Result $result `
|
||||
-PreviousValue $previousValue -IncludeTrace:$Tracing |
|
||||
Write-PersonaAuditRecord @AuditParameters
|
||||
|
||||
if ($Configuration.SummaryInterval -gt 0 -and ($counters.Processed % $Configuration.SummaryInterval) -eq 0) {
|
||||
|
||||
Write-PersonaSummary -Counters $counters -SummaryType 'Interim' -Mode $Context.Mode
|
||||
|
||||
New-PersonaAuditRecord -Context $Context -RecordType 'Summary' -Counters $counters `
|
||||
-Properties @{ summaryType = 'Interim' } | Write-PersonaAuditRecord @AuditParameters
|
||||
|
||||
if (-not (Test-PersonaReconciliation -Counters $counters)) {
|
||||
$exitCode = $EXIT_RECONCILIATION
|
||||
|
||||
New-PersonaAuditRecord -Context $Context -RecordType 'EngineDefect' `
|
||||
-Properties (Get-PersonaReconciliationDetail -Counters $counters) |
|
||||
Write-PersonaAuditRecord @AuditParameters
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Always emitted, whatever the interval - including 0 (FR-020).
|
||||
Write-PersonaSummary -Counters $counters -SummaryType 'Final' -Mode $Context.Mode
|
||||
|
||||
New-PersonaAuditRecord -Context $Context -RecordType 'Summary' -Counters $counters `
|
||||
-Properties @{ summaryType = 'Final' } | Write-PersonaAuditRecord @AuditParameters
|
||||
|
||||
if (-not (Test-PersonaReconciliation -Counters $counters)) {
|
||||
$exitCode = $EXIT_RECONCILIATION
|
||||
|
||||
New-PersonaAuditRecord -Context $Context -RecordType 'EngineDefect' `
|
||||
-Properties (Get-PersonaReconciliationDetail -Counters $counters) |
|
||||
Write-PersonaAuditRecord @AuditParameters
|
||||
}
|
||||
elseif ($null -ne $Configuration.EvaluationErrorThreshold -and
|
||||
$counters.EvaluationError -gt $Configuration.EvaluationErrorThreshold) {
|
||||
|
||||
# Past this count the population was classified from data that could not be
|
||||
# trusted. Stored values were preserved (FR-014), so nothing is damaged - but
|
||||
# reporting success would invite someone to draw conclusions from the run.
|
||||
$exitCode = $EXIT_DATA
|
||||
|
||||
New-PersonaAuditRecord -Context $Context -RecordType 'EngineDefect' -Properties @{
|
||||
severity = 'Error'
|
||||
defect = 'EvaluationErrorThresholdExceeded'
|
||||
evaluationError = [int]$counters.EvaluationError
|
||||
threshold = [int]$Configuration.EvaluationErrorThreshold
|
||||
processed = [int]$counters.Processed
|
||||
description = 'Too many accounts could not be evaluated from trusted data. Stored personas were preserved; no classification conclusion should be drawn from this run.'
|
||||
} | Write-PersonaAuditRecord @AuditParameters
|
||||
}
|
||||
|
||||
New-PersonaRunOutcome -Counters $counters -ExitCode $exitCode
|
||||
}
|
||||
|
||||
function New-PersonaRunOutcome {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Wraps the run result for the entry script.
|
||||
|
||||
.DESCRIPTION
|
||||
Carries the counters and the exit code together, so the caller cannot report
|
||||
an exit code that disagrees with the numbers it prints.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Counters,
|
||||
[Parameter(Mandatory)] [int] $ExitCode,
|
||||
[string] $FailureReason
|
||||
)
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.RunOutcome'
|
||||
Counters = $Counters
|
||||
ExitCode = $ExitCode
|
||||
FailureReason = $FailureReason
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
function ConvertTo-PersonaMembershipRecord {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts raw membership responses into a normalized MembershipRecord.
|
||||
|
||||
.DESCRIPTION
|
||||
The membership half of the normalization boundary (Principle IV). Callers
|
||||
that already hold raw Graph collections — the offline replay path, and any
|
||||
future provider that fetches membership in bulk rather than per user — use
|
||||
this instead of reaching for New-PersonaMembershipRecord directly, so the
|
||||
filtering rules live in one place.
|
||||
|
||||
Two filtering rules are applied and are the reason this function exists
|
||||
rather than a straight constructor call:
|
||||
|
||||
1. memberOf and transitiveMemberOf return directory objects of mixed type.
|
||||
Only `#microsoft.graph.group` entries become group Object IDs;
|
||||
administrative units and directory roles arriving on that endpoint are
|
||||
discarded. An administrative unit ID treated as a group ID would never
|
||||
match, which reads as "not a member" — a false non-match, the exact
|
||||
outcome FR-013 exists to prevent.
|
||||
|
||||
2. Role assignments are reduced to their roleDefinitionId (the role template
|
||||
ID), which is stable across tenants. Assignment instance IDs are not, so
|
||||
a configuration written against them would not survive a tenant move.
|
||||
|
||||
Retrieval status is supplied by the caller, never inferred from an empty
|
||||
collection. An empty list means "checked, member of nothing"; only an unset
|
||||
flag means "unknown".
|
||||
|
||||
.PARAMETER DirectMemberOf
|
||||
Raw objects from the memberOf endpoint. Omit when not retrieved.
|
||||
|
||||
.PARAMETER TransitiveMemberOf
|
||||
Raw objects from the transitiveMemberOf endpoint. Omit when not retrieved.
|
||||
|
||||
.PARAMETER RoleAssignments
|
||||
Raw objects from the roleAssignments endpoint. Omit when not retrieved.
|
||||
|
||||
.PARAMETER DirectRetrieved
|
||||
The direct lookup completed.
|
||||
|
||||
.PARAMETER TransitiveRetrieved
|
||||
The transitive lookup completed.
|
||||
|
||||
.PARAMETER RolesRetrieved
|
||||
The role lookup completed.
|
||||
|
||||
.PARAMETER DirectFailureReason
|
||||
Sanitized reason the direct lookup failed.
|
||||
|
||||
.PARAMETER TransitiveFailureReason
|
||||
Sanitized reason the transitive lookup failed.
|
||||
|
||||
.PARAMETER RolesFailureReason
|
||||
Sanitized reason the role lookup failed.
|
||||
|
||||
.EXAMPLE
|
||||
ConvertTo-PersonaMembershipRecord -DirectMemberOf $raw -DirectRetrieved
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.MembershipRecord
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[AllowNull()]
|
||||
[object[]] $DirectMemberOf,
|
||||
|
||||
[AllowNull()]
|
||||
[object[]] $TransitiveMemberOf,
|
||||
|
||||
[AllowNull()]
|
||||
[object[]] $RoleAssignments,
|
||||
|
||||
[switch] $DirectRetrieved,
|
||||
[switch] $TransitiveRetrieved,
|
||||
[switch] $RolesRetrieved,
|
||||
|
||||
[string] $DirectFailureReason,
|
||||
[string] $TransitiveFailureReason,
|
||||
[string] $RolesFailureReason
|
||||
)
|
||||
|
||||
$params = @{
|
||||
DirectGroupObjectIds = ConvertTo-PersonaGroupIdList -Objects $DirectMemberOf
|
||||
TransitiveGroupObjectIds = ConvertTo-PersonaGroupIdList -Objects $TransitiveMemberOf
|
||||
DirectoryRoleIds = ConvertTo-PersonaRoleIdList -Objects $RoleAssignments
|
||||
}
|
||||
|
||||
if ($DirectRetrieved) { $params['DirectRetrieved'] = $true }
|
||||
if ($TransitiveRetrieved) { $params['TransitiveRetrieved'] = $true }
|
||||
if ($RolesRetrieved) { $params['RolesRetrieved'] = $true }
|
||||
|
||||
if ($DirectFailureReason) { $params['DirectFailureReason'] = $DirectFailureReason }
|
||||
if ($TransitiveFailureReason) { $params['TransitiveFailureReason'] = $TransitiveFailureReason }
|
||||
if ($RolesFailureReason) { $params['RolesFailureReason'] = $RolesFailureReason }
|
||||
|
||||
New-PersonaMembershipRecord @params
|
||||
}
|
||||
|
||||
function ConvertTo-PersonaGroupIdList {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extracts group Object IDs from a mixed directory-object collection.
|
||||
|
||||
.DESCRIPTION
|
||||
Entries carrying an @odata.type other than #microsoft.graph.group are
|
||||
discarded. An entry with no @odata.type is kept: the membership endpoints
|
||||
omit the annotation when the collection is homogeneous, and discarding those
|
||||
would silently empty the list.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string[]])]
|
||||
param([AllowNull()] [object[]] $Objects)
|
||||
|
||||
$ids = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
foreach ($item in @($Objects)) {
|
||||
if ($null -eq $item) { continue }
|
||||
|
||||
$type = Get-PersonaMemberValue -Item $item -Key '@odata.type'
|
||||
if ($type -and $type -ne '#microsoft.graph.group') { continue }
|
||||
|
||||
$id = Get-PersonaMemberValue -Item $item -Key 'id'
|
||||
if ($id) { $ids.Add([string]$id) }
|
||||
}
|
||||
|
||||
, $ids.ToArray()
|
||||
}
|
||||
|
||||
function ConvertTo-PersonaRoleIdList {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extracts role template IDs from a role-assignment collection.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string[]])]
|
||||
param([AllowNull()] [object[]] $Objects)
|
||||
|
||||
$ids = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
foreach ($item in @($Objects)) {
|
||||
if ($null -eq $item) { continue }
|
||||
|
||||
$id = Get-PersonaMemberValue -Item $item -Key 'roleDefinitionId'
|
||||
if ($id) { $ids.Add([string]$id) }
|
||||
}
|
||||
|
||||
, $ids.ToArray()
|
||||
}
|
||||
|
||||
function Get-PersonaMemberValue {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Reads a key from either a hashtable or an object.
|
||||
|
||||
.DESCRIPTION
|
||||
Invoke-MgGraphRequest returns hashtables; fixtures loaded from JSON arrive as
|
||||
PSCustomObjects. Both shapes reach normalization, so both are handled here
|
||||
rather than forcing every call site to know which it has.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Item,
|
||||
[Parameter(Mandatory)] [string] $Key
|
||||
)
|
||||
|
||||
if ($Item -is [System.Collections.IDictionary]) {
|
||||
return $Item.Contains($Key) ? $Item[$Key] : $null
|
||||
}
|
||||
|
||||
$prop = $Item.PSObject.Properties[$Key]
|
||||
$prop ? $prop.Value : $null
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
function ConvertTo-PersonaUserRecord {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts a raw Graph user object into a normalized UserRecord.
|
||||
|
||||
.DESCRIPTION
|
||||
The normalization boundary (Principle IV). Everything downstream of this
|
||||
function is testable offline with synthetic data, because nothing downstream
|
||||
knows Graph exists.
|
||||
|
||||
Graph returns hashtables from Invoke-MgGraphRequest, with camelCase keys and
|
||||
the persona value under its full extension property name.
|
||||
|
||||
.PARAMETER GraphUser
|
||||
The raw object from Get-PersonaUsers.
|
||||
|
||||
.PARAMETER TargetAttribute
|
||||
Name of the persona attribute, read into StoredPersona.
|
||||
|
||||
.PARAMETER Membership
|
||||
Optional MembershipRecord. Omitted when no rule needs membership data.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory, ValueFromPipeline)]
|
||||
[object] $GraphUser,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $TargetAttribute,
|
||||
|
||||
[object] $Membership
|
||||
)
|
||||
|
||||
process {
|
||||
$get = {
|
||||
param($key)
|
||||
if ($GraphUser -is [System.Collections.IDictionary]) {
|
||||
return $GraphUser.Contains($key) ? $GraphUser[$key] : $null
|
||||
}
|
||||
$prop = $GraphUser.PSObject.Properties[$key]
|
||||
return $prop ? $prop.Value : $null
|
||||
}
|
||||
|
||||
$id = & $get 'id'
|
||||
$upn = & $get 'userPrincipalName'
|
||||
|
||||
if (-not $id -or -not $upn) {
|
||||
# An identity-less record is an upstream defect, not a user to skip.
|
||||
# Skipping would silently shrink the population and still reconcile.
|
||||
throw "Graph user object is missing 'id' or 'userPrincipalName'; cannot normalize."
|
||||
}
|
||||
|
||||
$properties = @{
|
||||
CompanyName = & $get 'companyName'
|
||||
JobTitle = & $get 'jobTitle'
|
||||
Department = & $get 'department'
|
||||
}
|
||||
|
||||
# Any additional selected property, including extension attributes, is
|
||||
# carried through so a rule can reference it without a code change.
|
||||
$keys = ($GraphUser -is [System.Collections.IDictionary]) ? $GraphUser.Keys : $GraphUser.PSObject.Properties.Name
|
||||
foreach ($key in $keys) {
|
||||
if ($key -like '@odata*') { continue }
|
||||
if (-not $properties.ContainsKey($key)) { $properties[$key] = & $get $key }
|
||||
}
|
||||
|
||||
$enabled = & $get 'accountEnabled'
|
||||
|
||||
New-PersonaUserRecord `
|
||||
-AccountObjectId ([string]$id) `
|
||||
-UserPrincipalName ([string]$upn) `
|
||||
-DisplayName ([string](& $get 'displayName')) `
|
||||
-UserType ([string](& $get 'userType')) `
|
||||
-AccountEnabled ($null -eq $enabled ? $true : [bool]$enabled) `
|
||||
-Properties $properties `
|
||||
-StoredPersona ([string](& $get $TargetAttribute)) `
|
||||
-Membership $Membership
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
function New-PersonaMembershipRecord {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a normalized MembershipRecord.
|
||||
|
||||
.DESCRIPTION
|
||||
Holds three independently-retrieved facets, each with its own retrieval
|
||||
status: direct group membership, transitive group membership, and directory
|
||||
role assignments.
|
||||
|
||||
Three facets rather than one "mode" because RE-007 makes membership mode a
|
||||
per-condition choice. A single rule set may legitimately ask for transitive
|
||||
membership in one rule and direct membership in another, so a record
|
||||
carrying only one mode cannot answer both — every user would become an
|
||||
EvaluationError on whichever question the record could not serve.
|
||||
|
||||
Independent statuses also mean a failure is contained: if the transitive
|
||||
lookup times out but the direct lookup succeeded, only conditions that need
|
||||
transitive data become Unknown. Collapsing them into one flag would turn one
|
||||
slow endpoint into a tenant-wide outage.
|
||||
|
||||
Every Retrieved flag defaults to $false. An unset flag means "unknown",
|
||||
never "not a member" — so a forgotten flag degrades to EvaluationError
|
||||
(FR-013) instead of silently misclassifying a privileged account.
|
||||
|
||||
.PARAMETER DirectGroupObjectIds
|
||||
Groups the user is a direct member of.
|
||||
|
||||
.PARAMETER TransitiveGroupObjectIds
|
||||
Groups the user is a transitive member of.
|
||||
|
||||
.PARAMETER DirectoryRoleIds
|
||||
Directory roles assigned to the user.
|
||||
|
||||
.PARAMETER DirectRetrieved
|
||||
Set only when the direct membership lookup genuinely completed.
|
||||
|
||||
.PARAMETER TransitiveRetrieved
|
||||
Set only when the transitive membership lookup genuinely completed.
|
||||
|
||||
.PARAMETER RolesRetrieved
|
||||
Set only when the role lookup genuinely completed.
|
||||
|
||||
.EXAMPLE
|
||||
New-PersonaMembershipRecord -DirectGroupObjectIds $ids -DirectRetrieved
|
||||
|
||||
.EXAMPLE
|
||||
New-PersonaMembershipRecord -TransitiveFailureReason 'Graph 503 after 5 attempts'
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[string[]] $DirectGroupObjectIds = @(),
|
||||
[string[]] $TransitiveGroupObjectIds = @(),
|
||||
[string[]] $DirectoryRoleIds = @(),
|
||||
|
||||
[switch] $DirectRetrieved,
|
||||
[switch] $TransitiveRetrieved,
|
||||
[switch] $RolesRetrieved,
|
||||
|
||||
[string] $DirectFailureReason,
|
||||
[string] $TransitiveFailureReason,
|
||||
[string] $RolesFailureReason,
|
||||
|
||||
# Marks every facet as retrieved. Convenience for tests and for the common
|
||||
# production case where all required lookups succeeded.
|
||||
[switch] $AllRetrieved
|
||||
)
|
||||
|
||||
foreach ($facet in @(
|
||||
@{ Name = 'Direct'; Retrieved = $DirectRetrieved; Reason = $DirectFailureReason }
|
||||
@{ Name = 'Transitive'; Retrieved = $TransitiveRetrieved; Reason = $TransitiveFailureReason }
|
||||
@{ Name = 'Roles'; Retrieved = $RolesRetrieved; Reason = $RolesFailureReason }
|
||||
)) {
|
||||
# A success claim alongside a failure reason is a caller bug, not a state
|
||||
# to interpret. Fail loudly rather than guess which was meant.
|
||||
if (($facet.Retrieved -or $AllRetrieved) -and $facet.Reason) {
|
||||
throw "Membership facet '$($facet.Name)' cannot be both retrieved and carry a failure reason."
|
||||
}
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.MembershipRecord'
|
||||
|
||||
DirectGroupObjectIds = @($DirectGroupObjectIds)
|
||||
DirectRetrieved = [bool]($DirectRetrieved -or $AllRetrieved)
|
||||
DirectFailureReason = $DirectFailureReason
|
||||
|
||||
TransitiveGroupObjectIds = @($TransitiveGroupObjectIds)
|
||||
TransitiveRetrieved = [bool]($TransitiveRetrieved -or $AllRetrieved)
|
||||
TransitiveFailureReason = $TransitiveFailureReason
|
||||
|
||||
DirectoryRoleIds = @($DirectoryRoleIds)
|
||||
RolesRetrieved = [bool]($RolesRetrieved -or $AllRetrieved)
|
||||
RolesFailureReason = $RolesFailureReason
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
function New-PersonaUserRecord {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a normalized UserRecord — the only user shape the rule engine sees.
|
||||
|
||||
.DESCRIPTION
|
||||
Constitution Principle IV: the rule engine must never receive a raw directory
|
||||
response. This function is that boundary. Everything downstream of it is
|
||||
testable offline with synthetic data.
|
||||
|
||||
Properties are stored in a case-insensitive dictionary so rule authors need
|
||||
not match directory casing (RE-006). An absent property returns $null, which
|
||||
ordinary string comparisons treat as empty (FR-012).
|
||||
|
||||
.PARAMETER AccountObjectId
|
||||
Immutable directory Object ID. Required; approved for logs.
|
||||
|
||||
.PARAMETER UserPrincipalName
|
||||
Required; approved for logs.
|
||||
|
||||
.PARAMETER Properties
|
||||
Evaluable property values. Copied into a case-insensitive dictionary.
|
||||
|
||||
.PARAMETER Membership
|
||||
A MembershipRecord. When omitted, an empty record with every facet marked
|
||||
unretrieved is used, so any membership condition evaluated against it yields
|
||||
Unknown rather than a false non-match. A rule set with no membership
|
||||
conditions never consults it.
|
||||
|
||||
.EXAMPLE
|
||||
New-PersonaUserRecord -AccountObjectId $id -UserPrincipalName $upn -Properties @{ Department = 'Finance' }
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $AccountObjectId,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $UserPrincipalName,
|
||||
|
||||
[string] $DisplayName,
|
||||
|
||||
[string] $UserType,
|
||||
|
||||
[bool] $AccountEnabled = $true,
|
||||
|
||||
[hashtable] $Properties = @{},
|
||||
|
||||
[AllowNull()]
|
||||
[string] $StoredPersona,
|
||||
|
||||
[AllowNull()]
|
||||
[pscustomobject] $Membership
|
||||
)
|
||||
|
||||
# StringComparer::OrdinalIgnoreCase gives case-insensitive lookup without the
|
||||
# cost of normalizing every key on every read.
|
||||
$bag = [System.Collections.Generic.Dictionary[string, object]]::new(
|
||||
[System.StringComparer]::OrdinalIgnoreCase)
|
||||
|
||||
foreach ($key in $Properties.Keys) {
|
||||
$bag[[string]$key] = $Properties[$key]
|
||||
}
|
||||
|
||||
# Intrinsic fields are also addressable as properties so a rule can target
|
||||
# UserPrincipalName or AccountEnabled without a separate condition type.
|
||||
# Explicit entries in -Properties win, so a caller can override for testing.
|
||||
foreach ($intrinsic in @(
|
||||
@{ Name = 'AccountObjectId'; Value = $AccountObjectId }
|
||||
@{ Name = 'UserPrincipalName'; Value = $UserPrincipalName }
|
||||
@{ Name = 'DisplayName'; Value = $DisplayName }
|
||||
@{ Name = 'UserType'; Value = $UserType }
|
||||
@{ Name = 'AccountEnabled'; Value = $AccountEnabled }
|
||||
)) {
|
||||
if (-not $bag.ContainsKey($intrinsic.Name)) {
|
||||
$bag[$intrinsic.Name] = $intrinsic.Value
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $Membership) {
|
||||
# Unretrieved, not empty: an absent lookup is unknown, never "member of
|
||||
# nothing". The safe default is the one that produces EvaluationError.
|
||||
$Membership = New-PersonaMembershipRecord
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.UserRecord'
|
||||
AccountObjectId = $AccountObjectId
|
||||
UserPrincipalName = $UserPrincipalName
|
||||
DisplayName = $DisplayName
|
||||
UserType = $UserType
|
||||
AccountEnabled = $AccountEnabled
|
||||
Properties = $bag
|
||||
StoredPersona = $StoredPersona
|
||||
Membership = $Membership
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
function Compare-PersonaValue {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Decides the action for a decision result by comparing stored and calculated
|
||||
values (FR-015, FR-016).
|
||||
|
||||
.DESCRIPTION
|
||||
Sets Action on the result and returns it. The state machine (data-model.md):
|
||||
|
||||
EvaluationError -> Skipped no write, ever
|
||||
Calculated == Stored -> Unchanged
|
||||
Calculated != Stored, preview mode -> WouldUpdate no request built
|
||||
Calculated != Stored, enforce mode -> Updated / UpdateFailed
|
||||
|
||||
Comparison is ORDINAL and case-sensitive, unlike rule evaluation. A stored
|
||||
value of 'employee' against a calculated 'Employee' is a real difference
|
||||
worth correcting, and treating it as equal would leave the directory
|
||||
permanently inconsistent with the rule set. Rule matching stays
|
||||
case-insensitive (RE-006); only change detection is exact.
|
||||
|
||||
.PARAMETER Result
|
||||
A PersonaDecisionResult.
|
||||
|
||||
.PARAMETER IsEnforcing
|
||||
Whether the caller's ShouldProcess gate returned true.
|
||||
|
||||
.PARAMETER TargetAttribute
|
||||
The configured target attribute.
|
||||
|
||||
.PARAMETER ApprovedWritableAttributes
|
||||
The approved list. A target absent from it can never be written.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Result,
|
||||
|
||||
[switch] $IsEnforcing,
|
||||
|
||||
[string] $TargetAttribute,
|
||||
|
||||
[string[]] $ApprovedWritableAttributes = @()
|
||||
)
|
||||
|
||||
if ($Result.Outcome -eq 'EvaluationError') {
|
||||
# FR-014: preserve the stored value. No write is attempted, and this is the
|
||||
# only branch that must never be reachable by any later condition.
|
||||
$Result.Action = 'Skipped'
|
||||
return $Result
|
||||
}
|
||||
|
||||
$stored = [string]$Result.StoredPersona
|
||||
$calculated = [string]$Result.CalculatedPersona
|
||||
|
||||
if ([string]::Equals($stored, $calculated, [System.StringComparison]::Ordinal)) {
|
||||
$Result.Action = 'Unchanged'
|
||||
return $Result
|
||||
}
|
||||
|
||||
# A target that is blank or unapproved can never be written, whatever the mode.
|
||||
$targetValid = $TargetAttribute -and ($TargetAttribute -in $ApprovedWritableAttributes)
|
||||
if (-not $targetValid) {
|
||||
$Result.Action = 'Skipped'
|
||||
return $Result
|
||||
}
|
||||
|
||||
$Result.Action = $IsEnforcing ? 'Updated' : 'WouldUpdate'
|
||||
return $Result
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
function New-PersonaWriteBody {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds the PATCH body for a persona write — the only function permitted to
|
||||
do so (SC-005, NFR-006).
|
||||
|
||||
.DESCRIPTION
|
||||
Returns a hashtable whose Count is exactly 1. Nothing else in the codebase
|
||||
constructs a directory write body, so the single-attribute guarantee is a
|
||||
property of one testable function rather than a convention every call site
|
||||
must remember.
|
||||
|
||||
Two rejections are enforced here, both throwing rather than returning a
|
||||
corrected body. A caller that asked to write the wrong attribute has a
|
||||
defect; quietly substituting the right one would hide it until the day the
|
||||
substitution was also wrong.
|
||||
|
||||
1. AttributeName must equal the configured target attribute.
|
||||
2. The target attribute must appear in approvedWritableAttributes.
|
||||
|
||||
The second check is deliberately redundant with configuration validation.
|
||||
Validation runs once at startup against the file; this runs on every write
|
||||
against the values actually in hand, so a configuration object mutated
|
||||
mid-run still cannot widen the blast radius.
|
||||
|
||||
.PARAMETER AttributeName
|
||||
The attribute to write. Must equal TargetAttribute.
|
||||
|
||||
.PARAMETER Value
|
||||
The calculated persona. May be an empty string to clear the attribute; may
|
||||
not be $null, which Graph would interpret as a removal the engine never
|
||||
intends to request implicitly.
|
||||
|
||||
.PARAMETER TargetAttribute
|
||||
The configured target attribute.
|
||||
|
||||
.PARAMETER ApprovedWritableAttributes
|
||||
The approved list from configuration.
|
||||
|
||||
.EXAMPLE
|
||||
New-PersonaWriteBody -AttributeName $t -Value 'Employee' -TargetAttribute $t -ApprovedWritableAttributes @($t)
|
||||
|
||||
.OUTPUTS
|
||||
System.Collections.Hashtable with exactly one key.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([hashtable])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $AttributeName,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyString()]
|
||||
[string] $Value,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $TargetAttribute,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
[string[]] $ApprovedWritableAttributes
|
||||
)
|
||||
|
||||
# Ordinal comparison: extension property names are case-sensitive in Graph, and
|
||||
# a case difference here means the caller is not writing the attribute the
|
||||
# configuration approved.
|
||||
if (-not [string]::Equals($AttributeName, $TargetAttribute, [System.StringComparison]::Ordinal)) {
|
||||
throw "Refusing to build a write body for '$AttributeName': only the configured target attribute may be written."
|
||||
}
|
||||
|
||||
if ($TargetAttribute -cnotin $ApprovedWritableAttributes) {
|
||||
throw "Refusing to build a write body for '$TargetAttribute': the attribute is not present in approvedWritableAttributes."
|
||||
}
|
||||
|
||||
@{ $AttributeName = $Value }
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
function Set-UserPersonaAttribute {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Writes the calculated persona to one user (FR-016).
|
||||
|
||||
.DESCRIPTION
|
||||
Issues PATCH /v1.0/users/{id} with a body built by New-PersonaWriteBody.
|
||||
|
||||
This function is reachable only when the caller's ShouldProcess gate has
|
||||
already returned true. It does not re-derive the mode and does not own a
|
||||
preview flag of its own — a second source of truth for the write gate is
|
||||
the defect class Principle III exists to prevent. What it does own is the
|
||||
refusal to proceed without an explicit -Confirmed switch, so a call that
|
||||
skipped the gate entirely fails loudly instead of writing.
|
||||
|
||||
previousValue is captured here, at write time, from the value the engine
|
||||
actually observed before the PATCH. Reading it back afterwards would return
|
||||
the new value; deriving it from the decision result would record what the
|
||||
engine believed rather than what it replaced. Without it, OTD-010 rollback
|
||||
is impossible retroactively — no later run can reconstruct what a value used
|
||||
to be.
|
||||
|
||||
.PARAMETER UserObjectId
|
||||
The user to update.
|
||||
|
||||
.PARAMETER AttributeName
|
||||
The attribute to write. Validated against the target by New-PersonaWriteBody.
|
||||
|
||||
.PARAMETER Value
|
||||
The calculated persona.
|
||||
|
||||
.PARAMETER PreviousValue
|
||||
The stored value observed before the write, recorded for rollback.
|
||||
|
||||
.PARAMETER TargetAttribute
|
||||
The configured target attribute.
|
||||
|
||||
.PARAMETER ApprovedWritableAttributes
|
||||
The approved list from configuration.
|
||||
|
||||
.PARAMETER Confirmed
|
||||
Asserts that the caller's ShouldProcess gate returned true. Required.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.WriteResult
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $UserObjectId,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $AttributeName,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyString()]
|
||||
[string] $Value,
|
||||
|
||||
[AllowNull()]
|
||||
[AllowEmptyString()]
|
||||
[string] $PreviousValue,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $TargetAttribute,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
[string[]] $ApprovedWritableAttributes,
|
||||
|
||||
[switch] $Confirmed
|
||||
)
|
||||
|
||||
if (-not $Confirmed) {
|
||||
# Not a guess about intent. A caller that reached here without the gate has
|
||||
# a control-flow defect, and the only safe response is to refuse.
|
||||
throw 'Set-UserPersonaAttribute was called without a confirmed ShouldProcess gate. No write was attempted.'
|
||||
}
|
||||
|
||||
$body = New-PersonaWriteBody -AttributeName $AttributeName -Value $Value `
|
||||
-TargetAttribute $TargetAttribute -ApprovedWritableAttributes $ApprovedWritableAttributes
|
||||
|
||||
$succeeded = $false
|
||||
$failureReason = $null
|
||||
|
||||
try {
|
||||
$null = Invoke-PersonaGraphRequest -Uri "/v1.0/users/$UserObjectId" -Method 'PATCH' -Body $body
|
||||
$succeeded = $true
|
||||
}
|
||||
catch {
|
||||
# A failed write is a per-user outcome, not a run-ending one. The run
|
||||
# continues and the count surfaces in the summary; the stored value is
|
||||
# untouched because the PATCH did not land.
|
||||
$failureReason = $_.Exception.Message
|
||||
Write-Verbose "Write failed for $UserObjectId : $failureReason"
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.WriteResult'
|
||||
AccountObjectId = $UserObjectId
|
||||
AttributeName = $AttributeName
|
||||
Value = $Value
|
||||
PreviousValue = $PreviousValue
|
||||
Succeeded = $succeeded
|
||||
FailureReason = $failureReason
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
function New-PersonaRunCounter {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates the run counter set used by summaries and reconciliation (FR-019 - FR-021).
|
||||
|
||||
.DESCRIPTION
|
||||
Holds two independent tallies that must never be conflated:
|
||||
|
||||
Outcome buckets Matched, Unclassified, EvaluationError - what the engine
|
||||
decided. Mutually exclusive, and their sum must equal
|
||||
Processed (SC-001, FR-021).
|
||||
|
||||
Action buckets Unchanged, WouldUpdate, Updated, UpdateFailed, Skipped -
|
||||
what happened to the directory. Also mutually exclusive,
|
||||
but they do NOT reconcile against Processed, because a
|
||||
user can be Matched and Unchanged at the same time.
|
||||
|
||||
Reconciliation checks the outcome buckets only. Checking the action buckets
|
||||
instead would pass on a run that lost users, because Skipped absorbs
|
||||
anything unexplained.
|
||||
|
||||
RuleCounts is seeded from the full rule set, including disabled rules, at
|
||||
construction. Seeding at construction rather than on first match is what
|
||||
makes a zero-match rule distinguishable from an absent one - an operator
|
||||
asking "did RULE-0030 fire?" gets "no, zero matches" rather than silence.
|
||||
|
||||
.PARAMETER Rules
|
||||
The business rule collection, used to seed RuleCounts.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.RunCounter
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
[object[]] $Rules
|
||||
)
|
||||
|
||||
$ruleCounts = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
foreach ($rule in (@($Rules) | Sort-Object -Property @{ Expression = { [int]$_.priority } }, @{ Expression = { [string]$_.id } })) {
|
||||
$ruleCounts.Add([pscustomobject]@{
|
||||
RuleId = [string]$rule.id
|
||||
Name = [string]$rule.name
|
||||
Priority = [int]$rule.priority
|
||||
Enabled = [bool]$rule.enabled
|
||||
Persona = [string]$rule.persona
|
||||
Matches = 0
|
||||
})
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.RunCounter'
|
||||
|
||||
Processed = 0
|
||||
|
||||
Matched = 0
|
||||
Unclassified = 0
|
||||
EvaluationError = 0
|
||||
|
||||
Unchanged = 0
|
||||
WouldUpdate = 0
|
||||
Updated = 0
|
||||
UpdateFailed = 0
|
||||
Skipped = 0
|
||||
|
||||
RuleCounts = $ruleCounts
|
||||
}
|
||||
}
|
||||
|
||||
function Add-PersonaRunResult {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Records one decision result into the run counters.
|
||||
|
||||
.DESCRIPTION
|
||||
The only function that increments counters. A single entry point is what
|
||||
makes reconciliation meaningful: if call sites incremented directly, a
|
||||
missed increment would look identical to a lost user, and the reconciliation
|
||||
check would be reporting on its own bookkeeping rather than on the run.
|
||||
|
||||
Processed increments exactly once per result, before the outcome switch, so
|
||||
an unrecognized outcome shows up as a reconciliation failure rather than
|
||||
being quietly dropped.
|
||||
|
||||
.PARAMETER Counters
|
||||
The run counter set.
|
||||
|
||||
.PARAMETER Result
|
||||
A PersonaDecisionResult with both Outcome and Action populated.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Counters,
|
||||
[Parameter(Mandatory)] [object] $Result
|
||||
)
|
||||
|
||||
$Counters.Processed++
|
||||
|
||||
switch ([string]$Result.Outcome) {
|
||||
'Matched' {
|
||||
$Counters.Matched++
|
||||
|
||||
$entry = $Counters.RuleCounts | Where-Object { $_.RuleId -eq [string]$Result.MatchedRuleId } | Select-Object -First 1
|
||||
if ($entry) { $entry.Matches++ }
|
||||
}
|
||||
'Unclassified' { $Counters.Unclassified++ }
|
||||
'EvaluationError' { $Counters.EvaluationError++ }
|
||||
}
|
||||
|
||||
switch ([string]$Result.Action) {
|
||||
'Unchanged' { $Counters.Unchanged++ }
|
||||
'WouldUpdate' { $Counters.WouldUpdate++ }
|
||||
'Updated' { $Counters.Updated++ }
|
||||
'UpdateFailed' { $Counters.UpdateFailed++ }
|
||||
'Skipped' { $Counters.Skipped++ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
function Test-PersonaReconciliation {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Verifies Processed = Matched + Unclassified + EvaluationError (FR-021, SC-007).
|
||||
|
||||
.DESCRIPTION
|
||||
Run at every summary and once at completion. Returns $true when the outcome
|
||||
buckets account for every processed user.
|
||||
|
||||
A mismatch is not a data condition and is never reported as one. Outcomes are
|
||||
assigned by the engine, exactly one per user (SC-001), so if the totals do
|
||||
not add up the engine lost a user or double-counted one. That is a defect in
|
||||
this codebase, and the caller emits an EngineDefect record and exit code 5
|
||||
rather than folding the discrepancy into an ordinary counter where it would
|
||||
be invisible.
|
||||
|
||||
Deliberately checks only the outcome buckets. The action buckets - Unchanged,
|
||||
WouldUpdate, Updated, UpdateFailed, Skipped - also sum to Processed in a
|
||||
correct run, but Skipped is a catch-all that would absorb a lost user and let
|
||||
the check pass on a broken run.
|
||||
|
||||
.PARAMETER Counters
|
||||
The run counter set.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([bool])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Counters
|
||||
)
|
||||
|
||||
$sum = [int]$Counters.Matched + [int]$Counters.Unclassified + [int]$Counters.EvaluationError
|
||||
[int]$Counters.Processed -eq $sum
|
||||
}
|
||||
|
||||
function Get-PersonaReconciliationDetail {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Describes a reconciliation failure precisely enough to debug it.
|
||||
|
||||
.DESCRIPTION
|
||||
Emitted onto the EngineDefect record. Carries the expected total, the actual
|
||||
total, and the difference, because "reconciliation failed" alone does not
|
||||
tell a maintainer whether users were lost or double-counted - and the sign of
|
||||
the difference does.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([hashtable])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Counters
|
||||
)
|
||||
|
||||
$sum = [int]$Counters.Matched + [int]$Counters.Unclassified + [int]$Counters.EvaluationError
|
||||
|
||||
@{
|
||||
severity = 'Error'
|
||||
defect = 'ReconciliationFailure'
|
||||
processed = [int]$Counters.Processed
|
||||
outcomeTotal = $sum
|
||||
difference = [int]$Counters.Processed - $sum
|
||||
matched = [int]$Counters.Matched
|
||||
unclassified = [int]$Counters.Unclassified
|
||||
evaluationError = [int]$Counters.EvaluationError
|
||||
description = 'Processed does not equal Matched + Unclassified + EvaluationError. Every processed user must land in exactly one outcome bucket (SC-001); a mismatch is an engine defect, not a property of the data.'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
function Write-PersonaSummary {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Renders the rule-match table, outcome totals, and reconciliation result
|
||||
(FR-019, FR-020, FR-021).
|
||||
|
||||
.DESCRIPTION
|
||||
Emitted every summaryInterval users and once at completion.
|
||||
|
||||
Every business rule appears, including disabled rules and rules with zero
|
||||
matches. A rule that never fired and a rule that is not in the configuration
|
||||
look identical if zero-match rules are omitted, and the difference is exactly
|
||||
what an operator investigating "why did nobody get classified as Tier0" needs
|
||||
to see.
|
||||
|
||||
Reconciliation is displayed on every summary, not only when it fails. A check
|
||||
that is only visible when broken gives an operator no reason to believe it
|
||||
ran at all.
|
||||
|
||||
.PARAMETER Counters
|
||||
The run counter set.
|
||||
|
||||
.PARAMETER SummaryType
|
||||
Interim or Final. Final is emitted regardless of interval, including when
|
||||
the interval is 0 (FR-020).
|
||||
|
||||
.PARAMETER Mode
|
||||
Preview or Enforce, shown in the header so a screenshot of a summary is
|
||||
self-describing.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Counters,
|
||||
|
||||
[ValidateSet('Interim', 'Final')]
|
||||
[string] $SummaryType = 'Interim',
|
||||
|
||||
[ValidateSet('Preview', 'Enforce')]
|
||||
[string] $Mode = 'Preview'
|
||||
)
|
||||
|
||||
$reconciled = Test-PersonaReconciliation -Counters $Counters
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ('=' * 100) -ForegroundColor DarkGray
|
||||
Write-Host ("{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
|
||||
|
||||
foreach ($entry in $Counters.RuleCounts) {
|
||||
# A disabled rule is dimmed rather than hidden: it is part of the
|
||||
# configuration and its absence from the output would read as a deletion.
|
||||
$colour = if (-not $entry.Enabled) { 'DarkGray' } elseif ($entry.Matches -gt 0) { 'Green' } else { 'Gray' }
|
||||
|
||||
Write-Host ('{0,-28} {1,-40} {2,-9} {3,10} {4,8}' -f
|
||||
$entry.RuleId,
|
||||
($entry.Name.Length -gt 40 ? $entry.Name.Substring(0, 37) + '...' : $entry.Name),
|
||||
$entry.Priority,
|
||||
$entry.Enabled,
|
||||
$entry.Matches) -ForegroundColor $colour
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ('Outcomes Matched: {0} Unclassified: {1} EvaluationError: {2}' -f
|
||||
$Counters.Matched, $Counters.Unclassified, $Counters.EvaluationError)
|
||||
Write-Host ('Actions Unchanged: {0} WouldUpdate: {1} Updated: {2} UpdateFailed: {3} Skipped: {4}' -f
|
||||
$Counters.Unchanged, $Counters.WouldUpdate, $Counters.Updated, $Counters.UpdateFailed, $Counters.Skipped)
|
||||
|
||||
if ($reconciled) {
|
||||
Write-Host ('Reconciliation PASS {0} = {1} + {2} + {3}' -f
|
||||
$Counters.Processed, $Counters.Matched, $Counters.Unclassified, $Counters.EvaluationError) -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host ('Reconciliation FAIL {0} != {1} + {2} + {3} - this is an engine defect (FR-021)' -f
|
||||
$Counters.Processed, $Counters.Matched, $Counters.Unclassified, $Counters.EvaluationError) -ForegroundColor Red
|
||||
}
|
||||
|
||||
Write-Host ('=' * 100) -ForegroundColor DarkGray
|
||||
Write-Host ''
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
function Write-UserPersonaResult {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Displays one user's result immediately after evaluation (FR-018, SC-012).
|
||||
|
||||
.DESCRIPTION
|
||||
Emitted per user as it is processed, not batched at the end, so an operator
|
||||
watching a long run sees progress and can stop early if the impact looks
|
||||
wrong. That per-user visibility is the whole point of a preview run.
|
||||
|
||||
Carries UPN and Account Object ID, which are approved for logs. Never emits
|
||||
tokens, headers, or raw responses (Principle V).
|
||||
|
||||
.PARAMETER Result
|
||||
A PersonaDecisionResult with Action already set by Compare-PersonaValue.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory, ValueFromPipeline)]
|
||||
[object] $Result
|
||||
)
|
||||
|
||||
process {
|
||||
$colour = switch ($Result.Action) {
|
||||
'Updated' { 'Green' }
|
||||
'WouldUpdate' { 'Yellow' }
|
||||
'UpdateFailed' { 'Red' }
|
||||
'Skipped' { 'Red' }
|
||||
default { 'Gray' }
|
||||
}
|
||||
|
||||
$detail = switch ($Result.Outcome) {
|
||||
'Matched' { "$($Result.CalculatedPersona) [$($Result.MatchedRuleId)]" }
|
||||
'Unclassified' { 'Unclassified' }
|
||||
'EvaluationError' { "EvaluationError - $($Result.EvaluationErrorReason)" }
|
||||
}
|
||||
|
||||
$change = switch ($Result.Action) {
|
||||
'Unchanged' { '=' }
|
||||
'WouldUpdate' { "'$($Result.StoredPersona)' -> '$($Result.CalculatedPersona)'" }
|
||||
'Updated' { "'$($Result.StoredPersona)' -> '$($Result.CalculatedPersona)'" }
|
||||
'UpdateFailed' { "write failed; '$($Result.StoredPersona)' retained" }
|
||||
'Skipped' { "'$($Result.StoredPersona)' retained" }
|
||||
default { '' }
|
||||
}
|
||||
|
||||
$line = '{0,-14} {1,-45} {2,-40} {3}' -f $Result.Action, $Result.UserPrincipalName, $detail, $change
|
||||
|
||||
Write-Host $line -ForegroundColor $colour
|
||||
Write-Verbose " ObjectId=$($Result.AccountObjectId) RulesEvaluated=$($Result.RulesEvaluated) DurationMs=$($Result.DurationMs)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
function Resolve-UserPersona {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Produces the authoritative persona decision for one user.
|
||||
|
||||
.DESCRIPTION
|
||||
The engine's core. Pure: it takes a normalized record and a rule set and
|
||||
returns a decision. No Graph, no authentication, no console, no filesystem,
|
||||
no clock (constitution Principle IV, enforced by tests/Test-EnginePurity.ps1).
|
||||
|
||||
Evaluation order (FR-008, FR-009):
|
||||
1. Discard disabled rules.
|
||||
2. Sort by ascending priority — lower evaluates first (RE-002).
|
||||
3. Evaluate in order and STOP at the first True.
|
||||
|
||||
Outcomes are mutually exclusive (SC-001):
|
||||
Matched a rule returned True
|
||||
Unclassified every enabled rule returned False (FR-010)
|
||||
EvaluationError any rule returned Unknown before a match was found
|
||||
|
||||
The EvaluationError rule is subtle and deliberate: an Unknown encountered
|
||||
at priority 30 stops evaluation even though a lower-priority rule might
|
||||
have matched. Continuing would risk assigning a persona from priority 900
|
||||
when the account may in truth have matched at 30 — precisely the
|
||||
privilege-downgrade misclassification FR-013 exists to prevent. Preserving
|
||||
the stored value is the only safe answer.
|
||||
|
||||
Timing uses a monotonic stopwatch rather than the wall clock, so no
|
||||
wall-clock value can influence a decision.
|
||||
|
||||
.PARAMETER UserRecord
|
||||
Normalized record from New-PersonaUserRecord.
|
||||
|
||||
.PARAMETER Rules
|
||||
The business rule collection.
|
||||
|
||||
.PARAMETER MaxDepth
|
||||
Maximum condition nesting depth (RE-004).
|
||||
|
||||
.PARAMETER DefaultMembershipMode
|
||||
Membership mode for conditions that do not specify one (RE-007).
|
||||
|
||||
.PARAMETER IncludeTrace
|
||||
Populates ConditionTrace with per-rule diagnostic results. Off by default;
|
||||
the caller gates this behind -Debug (Principle V).
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.PersonaDecisionResult
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $UserRecord,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
[object[]] $Rules,
|
||||
|
||||
[ValidateRange(1, 10)]
|
||||
[int] $MaxDepth = 5,
|
||||
|
||||
[ValidateSet('Direct', 'Transitive')]
|
||||
[string] $DefaultMembershipMode = 'Direct',
|
||||
|
||||
[switch] $IncludeTrace
|
||||
)
|
||||
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
$outcome = 'Unclassified'
|
||||
$matchedRuleId = $null
|
||||
$calculatedPersona = 'Unclassified'
|
||||
$errorReason = $null
|
||||
$rulesEvaluated = 0
|
||||
$trace = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
# Sort by priority, then by Id. The Id tiebreak matters: duplicate priorities
|
||||
# are a validation error, but if one reaches the engine the result must still
|
||||
# be the same on every run and every host (SC-003) rather than depending on
|
||||
# collection order.
|
||||
$ordered = @($Rules) |
|
||||
Where-Object { $_.enabled } |
|
||||
Sort-Object -Property @{ Expression = { [int]$_.priority } }, @{ Expression = { [string]$_.id } }
|
||||
|
||||
foreach ($rule in $ordered) {
|
||||
$rulesEvaluated++
|
||||
|
||||
$result = Test-PersonaRule -Rule $rule -UserRecord $UserRecord `
|
||||
-MaxDepth $MaxDepth -DefaultMembershipMode $DefaultMembershipMode
|
||||
|
||||
if ($IncludeTrace) {
|
||||
$trace.Add([pscustomobject]@{
|
||||
RuleId = [string]$rule.id
|
||||
Priority = [int]$rule.priority
|
||||
Result = $result
|
||||
})
|
||||
}
|
||||
|
||||
if ($result -eq 'True') {
|
||||
$outcome = 'Matched'
|
||||
$matchedRuleId = [string]$rule.id
|
||||
$calculatedPersona = [string]$rule.persona
|
||||
break
|
||||
}
|
||||
|
||||
if ($result -eq 'Unknown') {
|
||||
$outcome = 'EvaluationError'
|
||||
$calculatedPersona = $null
|
||||
$errorReason = "Rule '$([string]$rule.id)' could not be evaluated: required data was unavailable or the condition could not be interpreted."
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
$stopwatch.Stop()
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.PersonaDecisionResult'
|
||||
AccountObjectId = $UserRecord.AccountObjectId
|
||||
UserPrincipalName = $UserRecord.UserPrincipalName
|
||||
Outcome = $outcome
|
||||
MatchedRuleId = $matchedRuleId
|
||||
CalculatedPersona = $calculatedPersona
|
||||
StoredPersona = $UserRecord.StoredPersona
|
||||
Action = 'Pending' # set by the comparison stage (Compare-PersonaValue)
|
||||
EvaluationErrorReason = $errorReason
|
||||
RulesEvaluated = $rulesEvaluated
|
||||
DurationMs = [int]$stopwatch.ElapsedMilliseconds
|
||||
ConditionTrace = $IncludeTrace ? $trace.ToArray() : $null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
function Test-PersonaCondition {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Evaluates one leaf condition against a normalized user record.
|
||||
|
||||
.DESCRIPTION
|
||||
Returns 'True', 'False', or 'Unknown' — never a boolean.
|
||||
|
||||
The tri-state is the whole safety argument (FR-013). A boolean return has
|
||||
no way to distinguish "the user is not in that group" from "we could not
|
||||
find out", and collapsing the second into the first is exactly how an
|
||||
unavailable data source misclassifies a privileged account as an ordinary
|
||||
user. 'Unknown' propagates upward and ultimately produces EvaluationError,
|
||||
which preserves the stored persona.
|
||||
|
||||
Null handling (FR-012): for ordinary string comparisons an absent or null
|
||||
property is treated as an empty string and never fails evaluation. The
|
||||
isNull / isNotNull operators exist for intentional null matching, and treat
|
||||
both $null and the empty string as null.
|
||||
|
||||
Comparison is case-insensitive (RE-006).
|
||||
|
||||
.PARAMETER Condition
|
||||
A leaf condition object with Type, Operator, and the operands its operator
|
||||
requires.
|
||||
|
||||
.PARAMETER UserRecord
|
||||
The normalized record produced by New-PersonaUserRecord.
|
||||
|
||||
.PARAMETER DefaultMembershipMode
|
||||
Membership mode used when the condition does not specify one (RE-007).
|
||||
|
||||
.OUTPUTS
|
||||
System.String — 'True', 'False', or 'Unknown'.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Condition,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[object] $UserRecord,
|
||||
|
||||
[ValidateSet('Direct', 'Transitive')]
|
||||
[string] $DefaultMembershipMode = 'Direct'
|
||||
)
|
||||
|
||||
$operator = [string]$Condition.operator
|
||||
$type = if ($Condition.type) { [string]$Condition.type } else { 'property' }
|
||||
|
||||
switch ($type) {
|
||||
|
||||
{ $_ -in @('membership', 'role') } {
|
||||
return Test-PersonaMembershipCondition -Condition $Condition -UserRecord $UserRecord -DefaultMembershipMode $DefaultMembershipMode
|
||||
}
|
||||
|
||||
'property' {
|
||||
$name = [string]$Condition.property
|
||||
$raw = $UserRecord.Properties[$name]
|
||||
|
||||
# Intentional null matching happens before the empty-string coercion,
|
||||
# otherwise isNull could never be true.
|
||||
switch ($operator) {
|
||||
'isNull' { return (Test-PersonaValueIsNull $raw) ? 'True' : 'False' }
|
||||
'isNotNull' { return (Test-PersonaValueIsNull $raw) ? 'False' : 'True' }
|
||||
}
|
||||
|
||||
# FR-012: null and absent both compare as empty.
|
||||
$value = if ($null -eq $raw) { '' } else { [string]$raw }
|
||||
|
||||
switch ($operator) {
|
||||
'equals' { return (Test-PersonaStringEquals $value ([string]$Condition.value)) ? 'True' : 'False' }
|
||||
'notEquals' { return (Test-PersonaStringEquals $value ([string]$Condition.value)) ? 'False' : 'True' }
|
||||
|
||||
'contains' { return ($value.ToLowerInvariant().Contains(([string]$Condition.value).ToLowerInvariant())) ? 'True' : 'False' }
|
||||
'notContains' { return ($value.ToLowerInvariant().Contains(([string]$Condition.value).ToLowerInvariant())) ? 'False' : 'True' }
|
||||
|
||||
'startsWith' { return ($value.StartsWith([string]$Condition.value, [System.StringComparison]::OrdinalIgnoreCase)) ? 'True' : 'False' }
|
||||
'endsWith' { return ($value.EndsWith([string]$Condition.value, [System.StringComparison]::OrdinalIgnoreCase)) ? 'True' : 'False' }
|
||||
|
||||
'matchesRegex' {
|
||||
$pattern = [string]$Condition.value
|
||||
|
||||
# RE-006: validate before execution. An invalid pattern is a
|
||||
# configuration defect, and treating it as a non-match would
|
||||
# hide the defect behind a plausible-looking result.
|
||||
try {
|
||||
$regex = [regex]::new($pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||
}
|
||||
catch {
|
||||
return 'Unknown'
|
||||
}
|
||||
|
||||
return ($regex.IsMatch($value)) ? 'True' : 'False'
|
||||
}
|
||||
|
||||
'in' {
|
||||
foreach ($candidate in @($Condition.values)) {
|
||||
if (Test-PersonaStringEquals $value ([string]$candidate)) { return 'True' }
|
||||
}
|
||||
return 'False'
|
||||
}
|
||||
|
||||
'notIn' {
|
||||
foreach ($candidate in @($Condition.values)) {
|
||||
if (Test-PersonaStringEquals $value ([string]$candidate)) { return 'False' }
|
||||
}
|
||||
return 'True'
|
||||
}
|
||||
|
||||
default {
|
||||
# An unsupported operator reaching evaluation means validation
|
||||
# let it through. Unknown preserves the stored value rather than
|
||||
# inventing a decision from a condition nobody can interpret.
|
||||
return 'Unknown'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default { return 'Unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
function Test-PersonaMembershipCondition {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Evaluates a membership or directory-role condition. Internal helper.
|
||||
|
||||
.DESCRIPTION
|
||||
Selects the facet of the membership record that answers the question the
|
||||
condition actually asks — direct groups, transitive groups, or directory
|
||||
roles — and returns 'Unknown' if that specific facet was not retrieved
|
||||
(FR-013).
|
||||
|
||||
Facet selection is deliberately exact. Transitive membership is a superset
|
||||
of direct, so answering a direct question from transitive data would produce
|
||||
false positives, and answering a transitive question from direct data would
|
||||
produce false negatives. Neither is acceptable when the answer decides
|
||||
whether an account is classified as an administrator.
|
||||
|
||||
Because each facet carries its own retrieval status, a failure in one does
|
||||
not contaminate the others: a transitive lookup that times out leaves
|
||||
direct-membership conditions fully evaluable.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Condition,
|
||||
[Parameter(Mandatory)] [object] $UserRecord,
|
||||
[string] $DefaultMembershipMode = 'Direct'
|
||||
)
|
||||
|
||||
$membership = $UserRecord.Membership
|
||||
if ($null -eq $membership) { return 'Unknown' }
|
||||
|
||||
$isRole = ([string]$Condition.type -eq 'role')
|
||||
|
||||
if ($isRole) {
|
||||
if (-not $membership.RolesRetrieved) { return 'Unknown' }
|
||||
$haystack = @($membership.DirectoryRoleIds)
|
||||
$needles = @($Condition.roleIds)
|
||||
}
|
||||
else {
|
||||
$requestedMode = if ($Condition.membershipMode) { [string]$Condition.membershipMode } else { $DefaultMembershipMode }
|
||||
|
||||
switch ($requestedMode.ToLowerInvariant()) {
|
||||
'transitive' {
|
||||
if (-not $membership.TransitiveRetrieved) { return 'Unknown' }
|
||||
$haystack = @($membership.TransitiveGroupObjectIds)
|
||||
}
|
||||
'direct' {
|
||||
if (-not $membership.DirectRetrieved) { return 'Unknown' }
|
||||
$haystack = @($membership.DirectGroupObjectIds)
|
||||
}
|
||||
default { return 'Unknown' }
|
||||
}
|
||||
|
||||
$needles = @($Condition.groupObjectIds)
|
||||
}
|
||||
|
||||
$found = $false
|
||||
foreach ($needle in $needles) {
|
||||
foreach ($held in $haystack) {
|
||||
if (Test-PersonaStringEquals ([string]$held) ([string]$needle)) {
|
||||
$found = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($found) { break }
|
||||
}
|
||||
|
||||
switch ([string]$Condition.operator) {
|
||||
'memberOf' { return $found ? 'True' : 'False' }
|
||||
'notMemberOf' { return $found ? 'False' : 'True' }
|
||||
default { return 'Unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
function Test-PersonaStringEquals {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Case-insensitive ordinal string comparison (RE-006). Internal helper.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([bool])]
|
||||
param(
|
||||
[AllowNull()] [string] $Left,
|
||||
[AllowNull()] [string] $Right
|
||||
)
|
||||
|
||||
[string]::Equals($Left, $Right, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
function Test-PersonaValueIsNull {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Determines whether a property value counts as null. Internal helper.
|
||||
|
||||
.DESCRIPTION
|
||||
Both $null and the empty string count. A directory routinely returns an
|
||||
empty string for a cleared attribute, and a rule author asking "is this
|
||||
unset" means the same thing in both cases.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([bool])]
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[AllowNull()]
|
||||
[object] $Value
|
||||
)
|
||||
|
||||
if ($null -eq $Value) { return $true }
|
||||
return [string]::IsNullOrEmpty([string]$Value)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
function Test-PersonaConditionGroup {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Evaluates an all/any condition group, propagating Unknown correctly.
|
||||
|
||||
.DESCRIPTION
|
||||
The propagation table (data-model.md) is the safety argument in four rows:
|
||||
|
||||
all + any False -> False a definite non-match wins
|
||||
all + only True and Unknown -> Unknown cannot confirm
|
||||
any + any True -> True a definite match wins
|
||||
any + only False and Unknown -> Unknown cannot rule out
|
||||
|
||||
The two "definite wins" rows matter as much as the two Unknown rows. If an
|
||||
'all' group already contains a False, the result is False regardless of any
|
||||
unknown sibling — the rule cannot match either way, so degrading to
|
||||
EvaluationError there would produce spurious errors and mask real ones.
|
||||
|
||||
Depth is bounded (RE-004). The root group is depth 1.
|
||||
|
||||
.PARAMETER Group
|
||||
A condition group with operator 'all' or 'any' and a conditions collection.
|
||||
|
||||
.PARAMETER UserRecord
|
||||
The normalized record to evaluate against.
|
||||
|
||||
.PARAMETER MaxDepth
|
||||
Maximum nesting depth. Default 5, hard ceiling 10.
|
||||
|
||||
.PARAMETER CurrentDepth
|
||||
Internal recursion counter. Callers leave this at its default.
|
||||
|
||||
.OUTPUTS
|
||||
System.String — 'True', 'False', or 'Unknown'.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Group,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[object] $UserRecord,
|
||||
|
||||
[ValidateRange(1, 10)]
|
||||
[int] $MaxDepth = 5,
|
||||
|
||||
[ValidateSet('Direct', 'Transitive')]
|
||||
[string] $DefaultMembershipMode = 'Direct',
|
||||
|
||||
[int] $CurrentDepth = 1
|
||||
)
|
||||
|
||||
# Exceeding the depth limit is a configuration defect that validation should
|
||||
# have caught. Unknown rather than silent truncation: a truncated condition
|
||||
# tree evaluates a rule the author did not write.
|
||||
if ($CurrentDepth -gt $MaxDepth) { return 'Unknown' }
|
||||
|
||||
$operator = ([string]$Group.operator).ToLowerInvariant()
|
||||
if ($operator -notin @('all', 'any')) { return 'Unknown' }
|
||||
|
||||
$children = @($Group.conditions)
|
||||
if ($children.Count -eq 0) { return 'Unknown' }
|
||||
|
||||
$sawUnknown = $false
|
||||
|
||||
foreach ($child in $children) {
|
||||
|
||||
# A child is a group when it carries its own conditions collection.
|
||||
$isGroup = $null -ne $child.PSObject.Properties['conditions'] -and $null -ne $child.conditions
|
||||
|
||||
$result = if ($isGroup) {
|
||||
Test-PersonaConditionGroup -Group $child -UserRecord $UserRecord `
|
||||
-MaxDepth $MaxDepth -DefaultMembershipMode $DefaultMembershipMode `
|
||||
-CurrentDepth ($CurrentDepth + 1)
|
||||
}
|
||||
else {
|
||||
Test-PersonaCondition -Condition $child -UserRecord $UserRecord `
|
||||
-DefaultMembershipMode $DefaultMembershipMode
|
||||
}
|
||||
|
||||
switch ($result) {
|
||||
'Unknown' { $sawUnknown = $true }
|
||||
|
||||
'False' {
|
||||
# Short-circuit only on the definite result that decides the group.
|
||||
if ($operator -eq 'all') { return 'False' }
|
||||
}
|
||||
|
||||
'True' {
|
||||
if ($operator -eq 'any') { return 'True' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# No definite result decided the group. If anything was unknown, the group is
|
||||
# unknown; otherwise every child agreed with the group's identity element.
|
||||
if ($sawUnknown) { return 'Unknown' }
|
||||
|
||||
return ($operator -eq 'all') ? 'True' : 'False'
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
function Test-PersonaRule {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Evaluates a single business rule's root condition group against a user.
|
||||
|
||||
.DESCRIPTION
|
||||
Returns 'True', 'False', or 'Unknown'. Disabled rules are not evaluated
|
||||
here — Resolve-UserPersona filters them out before evaluation so they are
|
||||
excluded from the enabled rule count as well as from the result.
|
||||
|
||||
.PARAMETER Rule
|
||||
A business rule with a match condition group.
|
||||
|
||||
.PARAMETER UserRecord
|
||||
The normalized record to evaluate against.
|
||||
|
||||
.OUTPUTS
|
||||
System.String — 'True', 'False', or 'Unknown'.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Rule,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[object] $UserRecord,
|
||||
|
||||
[ValidateRange(1, 10)]
|
||||
[int] $MaxDepth = 5,
|
||||
|
||||
[ValidateSet('Direct', 'Transitive')]
|
||||
[string] $DefaultMembershipMode = 'Direct'
|
||||
)
|
||||
|
||||
if ($null -eq $Rule.match) { return 'Unknown' }
|
||||
|
||||
Test-PersonaConditionGroup -Group $Rule.match -UserRecord $UserRecord `
|
||||
-MaxDepth $MaxDepth -DefaultMembershipMode $DefaultMembershipMode
|
||||
}
|
||||
Reference in New Issue
Block a user