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:
2026-08-20 21:48:19 -04:00
parent c59c85dd55
commit cdc6bb33d3
124 changed files with 16638 additions and 199 deletions
+59
View File
@@ -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
}
}
+227
View File
@@ -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)
}
+108
View File
@@ -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()
}
+153
View File
@@ -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
}
+99
View File
@@ -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
}
+242
View File
@@ -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
}
}
+100
View File
@@ -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
}
}
+70
View File
@@ -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
}
+78
View File
@@ -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
}
}
+120
View File
@@ -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.'
}
}
+82
View File
@@ -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)"
}
}
+131
View File
@@ -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
}
}
+235
View File
@@ -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'
}
+40
View File
@@ -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
}