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
+442
View File
@@ -0,0 +1,442 @@
#Requires -Version 7.2
<#
.SYNOPSIS
Validates, tests, and interactively edits a Persona Engine configuration.
.DESCRIPTION
Two jobs in one tool, deliberately: the thing that checks a configuration and the
thing that edits it must agree about what "valid" means, and the surest way to
guarantee that is to make them the same code path.
-ValidateOnly report findings and exit. Never enters the editor.
-NonInteractive pipeline mode. Never prompts, never hangs (SC-010).
(neither) interactive editor, re-validating before every save.
-NonInteractive is not a convenience flag. A build agent runs with stdin closed;
a tool that prompts there does not fail, it hangs until the job times out, and
the pipeline reports an infrastructure problem instead of a bad configuration.
Every prompting call in this script sits behind a check for it.
Rule testing against synthetic users (-TestDataPath) runs the real rule engine
with no tenant connectivity (FR-025, SC-008). The engine is pure, so the answer
it gives offline is the answer it gives in production.
.PARAMETER ConfigPath
Configuration to validate or edit.
.PARAMETER ValidateOnly
Validate and report; do not enter the editor.
.PARAMETER NonInteractive
Pipeline mode: no prompts, exit code only.
.PARAMETER SchemaPath
Override the shipped schema.
.PARAMETER OutputPath
Save-As target. Leaves the input file untouched.
.PARAMETER TreatWarningsAsErrors
Escalate Warning findings to blocking (VR-005).
.PARAMETER TestDataPath
Synthetic sample users for offline rule testing (FR-025).
.PARAMETER PreviousConfigPath
Currently deployed configuration, enabling the VR-003 drift checks.
.PARAMETER EnforcementEnabled
Validate as though this configuration will drive an enforcing run, which raises
the severity of several safety findings.
.EXAMPLE
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
The pipeline invocation. Returns 0 when the configuration is clean.
.EXAMPLE
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData
Validate, then evaluate the rules against synthetic users with no tenant.
.NOTES
Exit codes
0 valid; no blocking findings
1 one or more Error findings
2 Warning findings present with -TreatWarningsAsErrors
3 configuration file not found or unreadable
4 schema file not found or itself invalid
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $ConfigPath,
[switch] $ValidateOnly,
[switch] $NonInteractive,
[string] $SchemaPath,
[string] $OutputPath,
[switch] $TreatWarningsAsErrors,
[string] $TestDataPath,
[string] $PreviousConfigPath,
[switch] $EnforcementEnabled
)
$ErrorActionPreference = 'Stop'
# The .psm1 directly, not the manifest. The manifest declares
# Microsoft.Graph.Authentication as a required module, and this tool never touches
# Graph - SC-008 requires full validation and synthetic rule testing to complete with
# no network access. Importing the manifest would make a build agent that only
# validates configuration install a Graph SDK it will never call.
Import-Module (Join-Path $PSScriptRoot 'PersonaEngine.psm1') -Force -ErrorAction Stop
$EXIT_OK = 0
$EXIT_FINDINGS = 1
$EXIT_WARNINGS_AS_ERRORS = 2
$EXIT_CONFIG_UNREADABLE = 3
$EXIT_SCHEMA_UNUSABLE = 4
function Invoke-ConfigurationValidation {
<#
.SYNOPSIS
Runs all four layers and maps the result onto an exit code.
.DESCRIPTION
One function so the interactive save path and the pipeline path cannot
disagree. The exit-code mapping lives here rather than at the call sites for
the same reason: an exit code is the pipeline's only view of what happened.
#>
param([string] $Path)
$params = @{ Path = $Path; EnforcementEnabled = $EnforcementEnabled }
if ($SchemaPath) { $params['SchemaPath'] = $SchemaPath }
if ($PreviousConfigPath) { $params['PreviousConfigPath'] = $PreviousConfigPath }
$result = Test-PersonaConfiguration @params
$code = $EXIT_OK
# Order matters. An unreadable file and an unusable schema are environment
# faults, not authoring faults, and a pipeline needs to tell them apart from a
# genuinely invalid configuration - otherwise a missing schema file is reported
# to the author as "your rules are wrong".
if (@($result.Findings | Where-Object Code -In @('PE-SYN-001', 'PE-SYN-002')).Count -gt 0) {
$code = $EXIT_CONFIG_UNREADABLE
}
elseif ($result.SchemaUnusable -or @($result.Findings | Where-Object Code -EQ 'PE-SCH-002').Count -gt 0) {
$code = $EXIT_SCHEMA_UNUSABLE
}
elseif (-not $result.IsValid) {
$code = $EXIT_FINDINGS
}
elseif ($TreatWarningsAsErrors -and $result.WarningCount -gt 0) {
# VR-005: warnings block only here. The finding severity is unchanged - the
# configuration is not retroactively more broken because a switch was passed;
# what changed is the caller's tolerance for it.
$code = $EXIT_WARNINGS_AS_ERRORS
}
[pscustomobject]@{ Result = $result; ExitCode = $code }
}
function Invoke-SyntheticRuleTest {
<#
.SYNOPSIS
Evaluates the configuration's rules against synthetic users (FR-025).
.DESCRIPTION
No tenant, no credentials, no network. Reads the fixture files, builds
normalized records, and runs the real engine over them.
Fixtures declare their membership facets explicitly, including failed ones.
A fixture with an unretrieved facet is the only way to see EvaluationError
behaviour before it happens against a live directory.
#>
param(
[Parameter(Mandatory)] [object] $Configuration,
[Parameter(Mandatory)] [string] $DataPath
)
$usersFile = (Test-Path -LiteralPath $DataPath -PathType Container) `
? (Join-Path $DataPath 'Users/users.json') `
: $DataPath
if (-not (Test-Path -LiteralPath $usersFile -PathType Leaf)) {
Write-Host "No synthetic user fixtures found at '$usersFile'." -ForegroundColor Yellow
return
}
$fixtures = (Get-Content -LiteralPath $usersFile -Raw | ConvertFrom-Json -Depth 32).users
$membershipFile = Join-Path (Split-Path (Split-Path $usersFile -Parent) -Parent) 'Memberships/memberships.json'
$membershipByFixture = @{}
if (Test-Path -LiteralPath $membershipFile -PathType Leaf) {
foreach ($entry in (Get-Content -LiteralPath $membershipFile -Raw | ConvertFrom-Json -Depth 32).memberships) {
$params = @{
DirectGroupObjectIds = @($entry.directGroupObjectIds)
TransitiveGroupObjectIds = @($entry.transitiveGroupObjectIds)
DirectoryRoleIds = @($entry.directoryRoleIds)
}
foreach ($facet in @('allRetrieved', 'directRetrieved', 'transitiveRetrieved', 'rolesRetrieved')) {
if ($entry.PSObject.Properties[$facet] -and $entry.$facet) {
$params[(Get-Culture).TextInfo.ToTitleCase($facet)] = $true
}
}
foreach ($facet in @('directFailureReason', 'transitiveFailureReason', 'rolesFailureReason')) {
if ($entry.PSObject.Properties[$facet] -and $entry.$facet) {
$params[(Get-Culture).TextInfo.ToTitleCase($facet)] = [string]$entry.$facet
}
}
$membershipByFixture[[string]$entry.fixtureId] = New-PersonaMembershipRecord @params
}
}
Write-Host ''
Write-Host 'Synthetic rule test - no tenant connectivity (FR-025, SC-008)' -ForegroundColor Cyan
Write-Host ('{0,-24} {1,-45} {2,-20} {3}' -f 'Fixture', 'UPN', 'Outcome', 'Persona / rule') -ForegroundColor DarkGray
foreach ($fixture in $fixtures) {
$properties = @{}
if ($fixture.properties) {
foreach ($p in $fixture.properties.PSObject.Properties) { $properties[$p.Name] = $p.Value }
}
$record = New-PersonaUserRecord `
-AccountObjectId ([string]$fixture.accountObjectId) `
-UserPrincipalName ([string]$fixture.userPrincipalName) `
-DisplayName ([string]$fixture.displayName) `
-UserType ([string]$fixture.userType) `
-AccountEnabled ([bool]$fixture.accountEnabled) `
-Properties $properties `
-StoredPersona ([string]$fixture.storedPersona) `
-Membership $membershipByFixture[[string]$fixture.fixtureId]
$result = Resolve-UserPersona -UserRecord $record -Rules $Configuration.Rules `
-MaxDepth $Configuration.MaxConditionDepth `
-DefaultMembershipMode $Configuration.DefaultMembershipMode
$colour = switch ($result.Outcome) {
'Matched' { 'Green' }
'Unclassified' { 'Gray' }
'EvaluationError' { 'Yellow' }
}
$detail = $result.Outcome -eq 'Matched' `
? "$($result.CalculatedPersona) [$($result.MatchedRuleId)]" `
: [string]$result.EvaluationErrorReason
Write-Host ('{0,-24} {1,-45} {2,-20} {3}' -f
$fixture.fixtureId, $result.UserPrincipalName, $result.Outcome, $detail) -ForegroundColor $colour
}
Write-Host ''
}
function Save-PersonaConfiguration {
<#
.SYNOPSIS
Saves an edited configuration, backing up first (FR-026).
.DESCRIPTION
The save order is the contract, and it is deliberate:
1. Re-validate in full.
2. Refuse on any Error finding.
3. Back up the existing file, or write to -OutputPath instead.
4. Only then replace.
Backing up before replacing rather than after is what makes step 4 safe to
interrupt. A crash between backup and write leaves the original and a copy;
a crash the other way round leaves neither.
#>
param(
[Parameter(Mandatory)] [object] $Document,
[Parameter(Mandatory)] [string] $Path,
[string] $SaveAs
)
$json = $Document | ConvertTo-Json -Depth 32
$temp = [System.IO.Path]::GetTempFileName()
try {
Set-Content -LiteralPath $temp -Value $json -Encoding utf8NoBOM
$check = Invoke-ConfigurationValidation -Path $temp
if (-not $check.Result.IsValid) {
Write-Host 'Save blocked: the edited configuration has Error findings.' -ForegroundColor Red
Write-PersonaValidationFinding -Findings $check.Result.Findings
return $false
}
}
finally {
Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue
}
$destination = $SaveAs ? $SaveAs : $Path
if ((Test-Path -LiteralPath $destination -PathType Leaf) -and -not $SaveAs) {
$backup = '{0}.{1}.bak' -f $destination, [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ')
if (-not $PSCmdlet.ShouldProcess($destination, "Back up to '$backup' and replace")) {
Write-Host 'Save cancelled.' -ForegroundColor Yellow
return $false
}
Copy-Item -LiteralPath $destination -Destination $backup -Force
Write-Host "Backup written: $backup" -ForegroundColor DarkGray
}
elseif (-not $PSCmdlet.ShouldProcess($destination, 'Write configuration')) {
Write-Host 'Save cancelled.' -ForegroundColor Yellow
return $false
}
Set-Content -LiteralPath $destination -Value $json -Encoding utf8NoBOM
Write-Host "Saved: $destination" -ForegroundColor Green
$true
}
function Invoke-InteractiveEditor {
<#
.SYNOPSIS
The interactive loop (FR-023).
.DESCRIPTION
Deliberately small. It toggles rules, adjusts priorities, re-validates, tests
against fixtures, and saves. It does not attempt to be a JSON editor - rule
authoring belongs in a text editor with schema completion, and a
half-featured structural editor would only add ways to corrupt a file that
is already under version control.
#>
param(
[Parameter(Mandatory)] [object] $Document,
[Parameter(Mandatory)] [string] $Path
)
$dirty = $false
while ($true) {
Write-Host ''
Write-Host 'Persona Engine configuration editor' -ForegroundColor Cyan
Write-Host (" file {0}{1}" -f $Path, ($dirty ? ' [modified]' : ''))
Write-Host (" version {0}" -f $Document.configVersion)
Write-Host (" rules {0} ({1} enabled)" -f @($Document.rules).Count, @($Document.rules | Where-Object { $_.enabled }).Count)
Write-Host ''
Write-Host ' [L] list rules [T] toggle a rule [P] change a priority'
Write-Host ' [V] re-validate [R] run rule test [S] save'
Write-Host ' [Q] quit'
Write-Host ''
$choice = (Read-Host 'Choice').Trim().ToUpperInvariant()
switch ($choice) {
'L' {
Write-Host ''
foreach ($rule in ($Document.rules | Sort-Object { [int]$_.priority })) {
Write-Host (' {0,-6} {1,-28} {2,-30} {3}' -f
$rule.priority, $rule.id, $rule.persona,
($rule.enabled ? 'enabled' : 'DISABLED')) -ForegroundColor ($rule.enabled ? 'Gray' : 'DarkGray')
}
}
'T' {
$id = (Read-Host 'Rule ID to toggle').Trim()
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
$rule.enabled = -not $rule.enabled
$dirty = $true
Write-Host ("Rule {0} is now {1}." -f $id, ($rule.enabled ? 'enabled' : 'disabled')) -ForegroundColor Green
}
'P' {
$id = (Read-Host 'Rule ID').Trim()
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
$value = 0
if (-not [int]::TryParse((Read-Host 'New priority'), [ref] $value)) {
Write-Host 'Priority must be an integer.' -ForegroundColor Yellow
break
}
$rule.priority = $value
$dirty = $true
Write-Host "Rule $id priority is now $value. Re-validate before saving - a reorder changes which rule wins." -ForegroundColor Green
}
'V' {
$temp = [System.IO.Path]::GetTempFileName()
try {
Set-Content -LiteralPath $temp -Value ($Document | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
$check = Invoke-ConfigurationValidation -Path $temp
Write-PersonaValidationFinding -Findings $check.Result.Findings
}
finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue }
}
'R' {
$dataPath = $TestDataPath ? $TestDataPath : (Join-Path $PSScriptRoot 'tests/TestData')
$temp = [System.IO.Path]::GetTempFileName()
try {
Set-Content -LiteralPath $temp -Value ($Document | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
Invoke-SyntheticRuleTest -Configuration (Import-PersonaConfiguration -Path $temp) -DataPath $dataPath
}
finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue }
}
'S' {
if (Save-PersonaConfiguration -Document $Document -Path $Path -SaveAs $OutputPath) { $dirty = $false }
}
'Q' {
if ($dirty) {
$confirm = (Read-Host 'Unsaved changes will be lost. Quit anyway? (y/N)').Trim()
if ($confirm -ne 'y') { break }
}
return
}
default { Write-Host 'Unrecognized choice.' -ForegroundColor Yellow }
}
}
}
# ============================================================ main
$validation = Invoke-ConfigurationValidation -Path $ConfigPath
Write-PersonaValidationFinding -Findings $validation.Result.Findings
if ($validation.ExitCode -ne $EXIT_OK) {
Write-Host ("Validation failed with exit code {0}." -f $validation.ExitCode) -ForegroundColor Red
exit $validation.ExitCode
}
if ($TestDataPath) {
Invoke-SyntheticRuleTest -Configuration (Import-PersonaConfiguration -Path $ConfigPath) -DataPath $TestDataPath
}
# -ValidateOnly and -NonInteractive both short-circuit before any prompting call.
# The check is here, once, at the single point where the editor could be entered.
if ($ValidateOnly -or $NonInteractive) {
Write-Host 'Configuration is valid.' -ForegroundColor Green
exit $EXIT_OK
}
Invoke-InteractiveEditor -Document $validation.Result.Document -Path $ConfigPath
exit $EXIT_OK
+255
View File
@@ -0,0 +1,255 @@
#Requires -Version 7.2
<#
.SYNOPSIS
Classifies Entra ID user accounts against a configuration-driven rule set.
.DESCRIPTION
The engine entry point: validate, connect, enumerate, evaluate, report, and -
only when explicitly confirmed - persist.
SAFETY MODEL
Mode is derived from $PSCmdlet.ShouldProcess() and nothing else. There is no
-Preview switch and no configuration key that suppresses writes. Two sources of
truth for a write gate is precisely the defect class constitution Principle III
exists to prevent: the day they disagree, one of them is wrong and the directory
finds out first.
-WhatIf is the approved no-write control. Under it, reads happen, rules evaluate,
values are compared, console output and summaries appear, and audit records are
written exactly as they would be in enforcement. The single difference is that no
write request is ever constructed (FR-017, SC-004).
-Debug enables condition tracing. It does NOT imply read-only. A -Debug run
without -WhatIf writes, and a test asserts that it does - because an operator who
believed otherwise would reach for -Debug as a safety measure.
.PARAMETER ConfigPath
Path to the JSON configuration. Validated through all four layers before any
connection is attempted (FR-002).
.PARAMETER UserObjectId
Evaluate a single user instead of enumerating the tenant. The recommended first
run against any new configuration.
.PARAMETER OutputPath
Overrides logging.path for this run.
.PARAMETER CorrelationId
Run identifier. Generated when absent. Appears on every audit record (NFR-005).
.PARAMETER SchemaPath
Schema override for validation layer 2.
.PARAMETER PreviousConfigPath
Currently deployed configuration, enabling the VR-003 drift checks.
.EXAMPLE
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf
The standard preview run. Reports what would change; writes nothing.
.EXAMPLE
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf -Verbose
Single-user preview, the recommended first run against a new configuration.
.OUTPUTS
Audit records on the success stream when logging.destination includes 'stream'.
The process exit code carries the run status; see the exit code table in
specs/001-persona-engine/contracts/cli-invoke-persona-engine.md.
.NOTES
Exit codes
0 successful run
1 configuration validation failure
2 authentication or authorization failure
3 user enumeration failure
4 fatal required data-provider failure, or evaluationErrorThreshold exceeded
5 reconciliation failure
6 unexpected fatal engine error
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $ConfigPath,
[guid] $UserObjectId,
[string] $OutputPath,
[guid] $CorrelationId,
[string] $SchemaPath,
[string] $PreviousConfigPath
)
$ErrorActionPreference = 'Stop'
$engineVersion = '0.1.0'
$startedUtc = [DateTime]::UtcNow
$runId = $CorrelationId -and $CorrelationId -ne [guid]::Empty ? $CorrelationId.ToString() : [guid]::NewGuid().ToString()
Import-Module (Join-Path $PSScriptRoot 'PersonaEngine.psd1') -Force -ErrorAction Stop
# Exit codes are named rather than sprinkled as literals so the contract table and
# the code cannot drift apart.
$EXIT_OK = 0
$EXIT_CONFIG = 1
$EXIT_AUTH = 2
$EXIT_ENUMERATION = 3
$EXIT_DATA = 4
$EXIT_RECONCILIATION = 5
$EXIT_UNEXPECTED = 6
$exitCode = $EXIT_OK
$counters = $null
$context = $null
$sinkState = New-PersonaAuditSinkState
try {
# ======================================================== 1. Validate (FR-002)
Write-Verbose "Validating configuration: $ConfigPath"
$validationParams = @{ Path = $ConfigPath }
if ($SchemaPath) { $validationParams['SchemaPath'] = $SchemaPath }
if ($PreviousConfigPath) { $validationParams['PreviousConfigPath'] = $PreviousConfigPath }
$validation = Test-PersonaConfiguration @validationParams
if (-not $validation.IsValid) {
Write-PersonaValidationFinding -Findings $validation.Findings
Write-Host "Configuration validation failed with $($validation.ErrorCount) error(s). No connection was attempted." -ForegroundColor Red
exit $EXIT_CONFIG
}
if ($validation.WarningCount -gt 0) {
Write-PersonaValidationFinding -Findings @($validation.Findings | Where-Object Severity -NE 'Information')
}
$config = Import-PersonaConfiguration -Path $ConfigPath
$target = Resolve-TargetAttribute -Configuration $config
# ======================================================== 2. Mode (FR-016, FR-017)
# The single write gate. Called once, before anything is read, so the mode is
# known when the RunStart record is written and cannot change mid-run.
#
# Under -WhatIf this returns $false without prompting. Without -WhatIf, the High
# confirm impact means the operator is asked to confirm the run; answering
# "Yes to All" also satisfies the per-user gate below without further prompts.
$runConfirmed = $PSCmdlet.ShouldProcess(
"Entra ID directory - $($config.SourcePath)",
"Classify users and write '$target' where the calculated persona differs")
$mode = $runConfirmed ? 'Enforce' : 'Preview'
$context = New-PersonaAuditContext -RunId $runId -EngineVersion $engineVersion -Configuration $config -Mode $mode
$destination = $config.Logging.destination ? [string]$config.Logging.destination : 'stream'
$logPath = $OutputPath ? $OutputPath : [string]$config.Logging.path
$auditParams = @{ Destination = $destination; Path = $logPath; State = $sinkState }
# Tracing is enabled by -Debug or by configuration, and requires acknowledgement
# in the configuration either way (VR-003, enforced in validation layer 4).
$traceRequested = $PSBoundParameters.ContainsKey('Debug') -or
($config.Logging.traceConditionValues -and [bool]$config.Logging.traceConditionValues)
$traceAcknowledged = $config.Logging.PSObject.Properties['acknowledgeConditionTracing'] -and
[bool]$config.Logging.acknowledgeConditionTracing
$tracing = [bool]($traceRequested -and $traceAcknowledged)
if ($traceRequested -and -not $traceAcknowledged) {
Write-Warning 'Condition tracing was requested but logging.acknowledgeConditionTracing is not set. Tracing is disabled for this run (VR-003).'
}
Write-Host ''
Write-Host "Persona Engine $engineVersion run $runId mode $mode" -ForegroundColor Cyan
Write-Host "Configuration $($config.ConfigVersion) hash $($config.ConfigurationHash.Substring(0, 16))..." -ForegroundColor DarkGray
if ($mode -eq 'Preview') {
Write-Host 'PREVIEW - no write request will be constructed or sent.' -ForegroundColor Yellow
}
Write-Host ''
New-PersonaAuditRecord -Context $context -RecordType 'RunStart' -Properties @{
configPath = $config.SourcePath
targetAttribute = $target
ruleCount = @($config.Rules).Count
enabledRules = @($config.Rules | Where-Object { $_.enabled }).Count
singleUser = [bool]($UserObjectId -and $UserObjectId -ne [guid]::Empty)
tracing = $tracing
} | Write-PersonaAuditRecord @auditParams
# ======================================================== 3. Connect (FR-003)
$facets = Get-PersonaRequiredFacets -Rules $config.Rules -DefaultMembershipMode $config.DefaultMembershipMode
try {
$null = Connect-PersonaGraphInteractive `
-IncludeGroups:([bool]($facets.Direct -or $facets.Transitive)) `
-IncludeRoles:([bool]$facets.Roles) `
-IncludeWrite:$runConfirmed
}
catch {
Write-Host "Authentication failed: $($_.Exception.Message)" -ForegroundColor Red
$exitCode = $EXIT_AUTH
throw
}
# ======================================================== 4. Run
# The per-user gate, passed down rather than re-derived. $PSCmdlet.ShouldProcess
# remains the single origin of the write decision; the run loop never learns what
# -WhatIf is and so cannot disagree with it.
$gate = { param($Target, $Description) $PSCmdlet.ShouldProcess($Target, $Description) }.GetNewClosure()
$runParams = @{
Configuration = $config
TargetAttribute = $target
Context = $context
AuditParameters = $auditParams
IsEnforcing = $runConfirmed
ShouldProcessGate = $gate
Tracing = $tracing
}
if ($UserObjectId -and $UserObjectId -ne [guid]::Empty) { $runParams['UserObjectId'] = $UserObjectId.ToString() }
$outcome = Invoke-PersonaEngineRun @runParams
$counters = $outcome.Counters
if ($outcome.ExitCode -ne $EXIT_OK) { $exitCode = $outcome.ExitCode }
if ($outcome.FailureReason) {
Write-Host "Run failed: $($outcome.FailureReason)" -ForegroundColor Red
}
if ($exitCode -eq $EXIT_RECONCILIATION) {
Write-Host 'Reconciliation failed. This is an engine defect, not a data condition (FR-021).' -ForegroundColor Red
}
elseif ($exitCode -eq $EXIT_DATA) {
Write-Host ("EvaluationError count {0} exceeds the configured threshold of {1}. Reporting the run as failed." -f
$counters.EvaluationError, $config.EvaluationErrorThreshold) -ForegroundColor Red
}
}
catch {
if ($exitCode -eq $EXIT_OK) { $exitCode = $EXIT_UNEXPECTED }
Write-Host "Run terminated: $($_.Exception.Message)" -ForegroundColor Red
Write-Verbose $_.ScriptStackTrace
}
finally {
# RunComplete is written even on a fatal error. A run that died at user 400 of
# 5000 leaves a record saying exactly that, which is what distinguishes "stopped
# early" from "never started" - two very different incidents that otherwise
# produce identical evidence.
if ($null -ne $context -and $null -ne $counters) {
Export-PersonaRunReport -Context $context -Counters $counters -StartedUtc $startedUtc -ExitCode $exitCode |
Write-PersonaAuditRecord @auditParams
}
}
exit $exitCode
+34
View File
@@ -0,0 +1,34 @@
@{
Severity = @('Error', 'Warning')
IncludeRules = @(
# Constitution Principle III: every state-changing function must support
# ShouldProcess, so -WhatIf reaches all of them.
'PSUseShouldProcessForStateChangingFunctions'
'PSShouldProcess'
# Principle V: no credential material in source.
'PSAvoidUsingPlainTextForPassword'
'PSAvoidUsingConvertToSecureStringWithPlainText'
'PSUsePSCredentialType'
# NFR-004 maintainability.
'PSUseApprovedVerbs'
'PSUseSingularNouns'
'PSAvoidUsingCmdletAliases'
'PSUseDeclaredVarsMoreThanAssignments'
'PSAvoidUsingPositionalParameters'
# NFR-008 portability: no Windows PowerShell-only constructs.
'PSUseCompatibleSyntax'
)
Rules = @{
PSUseCompatibleSyntax = @{
Enable = $true
# 7.2 is the floor because the Automation runtime version is unverified
# until Stage B (research.md V-5b).
TargetVersions = @('7.2')
}
}
}
+75
View File
@@ -0,0 +1,75 @@
@{
RootModule = 'PersonaEngine.psm1'
ModuleVersion = '0.1.0'
GUID = 'b3f1c2d4-5e6a-47b8-9c0d-1e2f3a4b5c6d'
Author = 'Persona Engine maintainers'
Description = 'Deterministic, configuration-driven identity classification for Microsoft Entra ID user objects.'
# PS 7.2 floor rather than 7.4: the Azure Automation runtime version is
# unverified until Stage B (research.md V-5b). Do not raise without evidence.
PowerShellVersion = '7.2'
# OTD-004: authentication and Invoke-MgGraphRequest only. No resource-specific
# Graph SDK modules — keeping the Automation import surface to one module.
RequiredModules = @('Microsoft.Graph.Authentication')
FunctionsToExport = @(
# Configuration
'Import-PersonaConfiguration'
'Test-PersonaConfiguration'
'Test-PersonaConfigurationSemantic'
'Test-PersonaConfigurationSafety'
'Write-PersonaValidationFinding'
'Resolve-TargetAttribute'
'Get-PersonaRequiredFacets'
'New-PersonaValidationFinding'
# Normalization
'New-PersonaUserRecord'
'New-PersonaMembershipRecord'
'ConvertTo-PersonaUserRecord'
'ConvertTo-PersonaMembershipRecord'
# Rule engine
'Test-PersonaCondition'
'Test-PersonaConditionGroup'
'Test-PersonaRule'
'Resolve-UserPersona'
# Authentication and data providers
'Connect-PersonaGraphInteractive'
'Invoke-PersonaGraphRequest'
'Get-PersonaUsers'
'Get-PersonaRequiredProperties'
'Get-PersonaGroupMembership'
'Get-PersonaDirectoryRoles'
'New-PersonaDataCache'
'Get-PersonaCachedMembership'
# Persistence
'Compare-PersonaValue'
'New-PersonaWriteBody'
'Set-UserPersonaAttribute'
# Presentation
'Write-UserPersonaResult'
'Write-PersonaSummary'
'New-PersonaRunCounter'
'Add-PersonaRunResult'
'Test-PersonaReconciliation'
'Get-PersonaReconciliationDetail'
# Audit
'New-PersonaAuditContext'
'New-PersonaAuditRecord'
'Write-PersonaAuditRecord'
'New-PersonaAuditSinkState'
'Export-PersonaRunReport'
# Run loop
'Invoke-PersonaEngineRun'
)
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
PrivateData = @{
PSData = @{
Tags = @('Entra', 'Identity', 'Classification')
ProjectUri = ''
}
}
}
+37
View File
@@ -0,0 +1,37 @@
#Requires -Version 7.2
<#
Module loader.
Dot-sources every function file under src/. Load order is layer-by-layer so a
file may rely on functions from a layer loaded before it.
Note for tests: offline suites (rule engine, normalization, configuration)
deliberately dot-source individual layer folders rather than importing this
module, because importing the manifest pulls in Microsoft.Graph.Authentication.
Keeping the pure layers loadable without that module is the practical proof of
constitution Principle IV.
#>
$ErrorActionPreference = 'Stop'
$layerOrder = @(
'Normalization'
'Configuration'
'RuleEngine'
'Authentication'
'DataProviders'
'Persistence'
'Presentation'
'Engine'
'Audit'
)
foreach ($layer in $layerOrder) {
$layerPath = Join-Path $PSScriptRoot "src/$layer"
if (-not (Test-Path $layerPath)) { continue }
foreach ($file in Get-ChildItem -Path $layerPath -Filter '*.ps1' -File | Sort-Object Name) {
. $file.FullName
}
}
+429 -188
View File
@@ -4,12 +4,295 @@ A modular, configuration-driven **PowerShell 7** identity-classification service
The engine enumerates Entra user accounts, evaluates each one against an ordered, JSON-defined rule set, deterministically assigns **exactly one persona**, and updates a single approved persona attribute — and only when the calculated value differs from the current value.
> **Project status: Phase 1 complete — specification drafted.**
> No implementation code exists yet. The approved requirements baseline is
> [`Persona-Engine-Developer-Handoff.txt`](Persona-Engine-Developer-Handoff.txt), now converted
> into [`specs/001-persona-engine/spec.md`](specs/001-persona-engine/spec.md). Project governance
> is ratified in [`.specify/memory/constitution.md`](.specify/memory/constitution.md) (v1.0.0).
> The next step is Phase 2: produce `plan.md` and `research.md`, closing OTD-001 through OTD-005.
> **Project status: Stage A implementation complete — ready for tenant validation.**
>
> 109 of 121 tasks are done. **354 offline tests pass**, along with the engine-purity and
> sanitization gates. Every remaining task needs something a developer workstation does not have:
> a tenant connection (T055, T056, T101T103) or an Azure Automation account (T115T121).
>
> **Nothing has ever been run against a real directory.** The next step is [Stage A2](#stage-a2--tenant-preview-read-only)
> — a delegated, read-only `-WhatIf` run. Follow the [testing checklist](#testing-checklist) in order.
---
## Quick start
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -TestDataPath ./tests/TestData -ValidateOnly -NonInteractive
```
That validates the configuration through all four layers and runs the real rule engine against
synthetic fixtures. No tenant, no credentials, no network. It is the fastest way to see what the
engine does.
---
## Deployment
### Prerequisites
| Requirement | Notes |
| --- | --- |
| PowerShell 7.2 or later | Developed on 7.6.5. `pwsh -v` to check. |
| `Microsoft.Graph.Authentication` | **Runtime only.** Not needed for the offline suites or the config editor. |
| Pester 5.0+ | Only to run the tests. Developed against 6.1.0. |
| PSScriptAnalyzer | Only for the lint gate. |
| An Entra app registration | For the persona extension property and delegated scopes. |
```powershell
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser -SkipPublisherCheck
Install-Module PSScriptAnalyzer -Scope CurrentUser
```
> Only `Microsoft.Graph.Authentication` is a runtime dependency (OTD-004). The engine calls Graph
> through `Invoke-MgGraphRequest` rather than resource-specific SDK modules, which keeps the import
> surface to one module and makes request bodies explicit values that tests can assert on — that is
> what makes the single-attribute guarantee provable.
### Step 1 — Get the code onto the target machine
```bash
git clone <REPO-URL> persona-engine
cd persona-engine
```
### Step 2 — Prove the machine can run it, before touching a tenant
```bash
pwsh -NoProfile -Command "& { $c = & ./tests/PesterConfiguration.ps1 -Suite Offline; Invoke-Pester -Configuration $c }"
```
Expect **354 passed, 0 failed**. This needs no credentials and no network. If it does not pass, stop
— nothing downstream is trustworthy.
### Step 3 — Register the persona extension property
The persona is stored in a **directory (schema) extension property** on an app registration (OTD-001),
addressable in dynamic group rules as `user.extension_<appId>_<name>`.
```powershell
Connect-MgGraph -Scopes 'Application.ReadWrite.All'
$app = Get-MgApplication -Filter "displayName eq '<APP-REGISTRATION-NAME>'"
New-MgApplicationExtensionProperty -ApplicationId $app.Id -BodyParameter @{
name = '<APPROVED-PERSONA-ATTRIBUTE-NAME>'
dataType = 'String'
targetObjects = @('User')
}
```
Record the returned `name` — it is the full `extension_<32-hex-app-id>_<name>` string, and it is what
goes into `engine.targetAttribute`.
> `extensionAttribute1..15` were **rejected**: they cannot be written from the cloud on objects that
> are, or ever were, synchronized from on-premises, or on Exchange-originated objects. Custom
> security attributes were **rejected**: they are not exposed to the dynamic group engine, which
> defeats the purpose.
### Step 4 — Grant delegated scopes
Read-only, and enough for every preview run:
```powershell
Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'
```
The engine requests only what the enabled rules need — a configuration with no role conditions never
asks for `RoleManagement.Read.Directory`.
`User.ReadWrite.All` is added only for Stage A3, and only after V-4 sign-off.
> **Never sign in with a standing privileged account.** A Global Administrator run invalidates V-3
> as evidence and removes every practical limit on what a defect could reach.
### Step 5 — Build the configuration
```bash
cp ./config/persona-engine.example.json ./config/persona-engine.json
```
Then replace every placeholder:
| Placeholder | Replace with |
| --- | --- |
| `extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>` | The full extension property name from step 3 |
| `<TEAM-NAME>`, `<CHANGE-REFERENCE>` | Ownership metadata |
| `<TIER0-ROLE-TEMPLATE-ID>` | Role **template** IDs (stable across tenants) |
| `00000000-0000-...` group IDs | Real group Object IDs |
| `<ORGANIZATION-NAME>` | Your company name as it appears in `companyName` |
| `<LOG-OUTPUT-PATH>` | Audit log path |
> **`config/persona-engine.json` must never be committed.** It contains real group Object IDs and
> your tenant's attribute name. Keep it in a protected configuration store, and confirm `.gitignore`
> covers it. The sanitization gate scans untracked files too, so it will catch this — but do not rely
> on that as your only control.
### Step 6 — Validate, before connecting to anything
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
```
Exit code `0` required. Codes: `1` findings · `2` warnings with `-TreatWarningsAsErrors` ·
`3` file unreadable · `4` schema unusable.
### Step 7 — Preview
```bash
pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf -Verbose
```
One account first. Then the tenant:
```bash
pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf
```
`-WhatIf` is the **only** approved no-write control. `-Debug` does not imply read-only.
### Step 8 — CI
Two pipelines are included. `pipelines/validate.yml` gates every pull request and runs entirely
offline; `pipelines/test.yml` publishes test results and coverage.
### Step 9 — Enforcement 🔒
**Blocked on the V-4 security sign-off** (T101). Do not run without `-WhatIf` against anything other
than purpose-created test accounts until that is recorded. See [docs/SecurityModel.md](docs/SecurityModel.md).
---
## Testing checklist
Work through these in order. Each stage assumes the previous one passed. **Do not skip ahead** — the
whole point of the staging is that a failure is cheap at stage A1 and expensive at stage A3.
### Stage A1 — offline (no tenant, no credentials, no network)
Everything here runs on any machine with PowerShell 7.
- [ ] **Module manifest loads**
`pwsh -NoProfile -Command "Test-ModuleManifest ./PersonaEngine.psd1"`
Fails without `Microsoft.Graph.Authentication` installed. Expected on a bare machine.
- [ ] **Offline suite: 354 passed, 0 failed**
`pwsh -NoProfile -Command "& { $c = & ./tests/PesterConfiguration.ps1 -Suite Offline; Invoke-Pester -Configuration $c }"`
- [ ] **Safety suite passes and is not empty**
`pwsh -NoProfile -Command "& { $c = & ./tests/PesterConfiguration.ps1 -Suite Safety; Invoke-Pester -Configuration $c }"`
A zero-test green run is the most dangerous possible result — it is what a mis-tagged file looks
like, and the assertions it silently drops are the zero-write and single-attribute ones.
- [ ] **Engine purity (Principle IV)**
`pwsh ./tests/Test-EnginePurity.ps1`
- [ ] **Sanitization (SC-013)**
`pwsh ./tests/Test-Sanitization.ps1`
Scans tracked **and** untracked non-ignored files, so it catches a leak before the commit.
- [ ] **Lint**
`pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1"`
- [ ] **No Graph module was loaded during the offline suite**
`pwsh -NoProfile -Command "& { $c = & ./tests/PesterConfiguration.ps1 -Suite Offline; $c.Output.Verbosity='None'; $null = Invoke-Pester -Configuration $c; Get-Module Microsoft.Graph* }"`
Must print nothing. This is the proof that SC-008 holds.
- [ ] **Example configuration passes all four layers**
`pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive`
- [ ] **Your real configuration passes all four layers**
`pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive`
- [ ] **Synthetic rule test produces the personas you expect**
`pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData -ValidateOnly -NonInteractive`
The fixtures include two accounts whose membership lookups failed. Both must show
`EvaluationError`, not a persona. If they show a persona, stop — FR-013 is broken.
- [ ] **Drift check against the deployed configuration** (once one exists)
`pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -PreviousConfigPath ./deployed/persona-engine.json -ValidateOnly -NonInteractive`
### Stage A2 — tenant preview, read-only
Requires delegated read scopes. **Sign in as a non-privileged account.**
- [ ] **Connect with read-only scopes and confirm no write scope was granted**
`Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'`
then `(Get-MgContext).Scopes`
- [ ] **Single-user preview**
`pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf -Verbose`
Expect: one result line, `WouldUpdate` or `Unchanged`, exit `0`.
- [ ] **Single-user preview against each origin type — V-1 read half (T056)**
A cloud-only account, a currently-synced account, and a formerly-synced account. Record in
`specs/001-persona-engine/verification/V-1.md`. This is the check that proves the OTD-001
attribute choice actually works where `extensionAttributeN` would not.
- [ ] **Full tenant preview**
`pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf`
Expect exit `0` and reconciliation `PASS` at every summary.
- [ ] **Record the V-3 evidence (T055)**
Confirm the run completed as a **non-privileged** account. A Global Administrator run
invalidates this item. Record in `specs/001-persona-engine/verification/V-3.md`.
- [ ] **Review the impact before going further**
From the final summary: how many `WouldUpdate`? Which rules fired, and which fired zero times?
Is the `EvaluationError` count near zero? A high count means the rule set is asking for data
the tenant will not reliably give it.
- [ ] **Idempotence against the real tenant**
Run the full preview twice. The counters must be identical.
- [ ] **Performance baseline (NFR-002)**
Record `durationMs` from the `RunComplete` record and the account count. No target exists yet;
this run is how one gets set.
### Stage A3 — delegated write, test accounts only 🔒
**Gated on V-4** — the written security sign-off on the six OTD-003 compensating controls
(T101, `specs/001-persona-engine/verification/V-4.md`).
- [ ] **V-4 sign-off recorded** — nothing below may start before this
- [ ] **Purpose-created test accounts exist**, one per origin type, and no other account is in scope
- [ ] **Write scope added**: `User.ReadWrite.All`, still as a non-privileged account
- [ ] **Preview the test accounts first**, one at a time, with `-WhatIf`
- [ ] **Enforce one test account**
`pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <TEST-ACCOUNT-OBJECT-ID>`
Confirm the confirmation prompt appears — `ConfirmImpact` is `High`.
- [ ] **Verify the write landed and touched nothing else**
Re-read the account and diff every property against a copy taken beforehand. Only the persona
attribute may have changed.
- [ ] **Confirm `previousValue` is on the audit record** — without it, rollback is impossible retroactively
- [ ] **Second run proposes zero changes** (SC-002, against a real directory this time)
- [ ] **V-1 write half (T102)** — one write per origin type; append to `V-1.md`
- [ ] **V-2 (T103)** — build a dynamic group on `user.extension_<appId>_<name>`, assign a Conditional
Access policy to it in **report-only** mode, and confirm it applies. This is what proves the
persona is actually useful rather than merely stored.
### Stage B — Azure Automation ⛔
Deferred; no Automation account available. T115T121. Run **T116 first** (`Test-Json` behaviour in
the Automation runtime) — it is the cheapest item most likely to surprise, and V-5a already showed
this cmdlet behaves in a way nobody would guess.
### If something fails
| Symptom | Look at |
| --- | --- |
| Exit `1` | The findings printed above it. No connection was attempted. |
| Exit `2` | Scopes, consent, whether the account can sign in. |
| Exit `3` | Graph availability. **No accounts were processed** — a partial population is never used. |
| Exit `4` | Group/role endpoint health. Nothing was changed. |
| Exit `5` | **An engine defect.** File an issue with the `EngineDefect` record. |
| Every account `Skipped` | Target attribute blank or unapproved. |
| Every account `EvaluationError` | A required data source is disabled or unreachable. |
| Every account `Unclassified` | Every rule disabled, or no rule matches. The summary distinguishes these. |
[docs/OperationsRunbook.md](docs/OperationsRunbook.md) has the full table, the kill switch, and the
rollback procedure.
---
@@ -21,17 +304,15 @@ This project follows the **GitHub Spec Kit** workflow. Nothing is implemented be
Specify -> Plan -> Tasks -> Implement
```
| Stage | Artifact | Phase | State |
| --- | --- | --- | --- |
| Baseline | `Persona-Engine-Developer-Handoff.txt` | 0 | Approved |
| Constitution | `.specify/memory/constitution.md` | 0 | Ratified v1.0.0 |
| Specify | `specs/001-persona-engine/spec.md` | 1 | Draft complete |
| Plan | `specs/001-persona-engine/plan.md`, `research.md` | 2 | In progress |
| Contracts | `data-model.md`, `contracts/`, `persona-engine.schema.json` | 3 | Not started |
| Tasks | `specs/001-persona-engine/tasks.md` | 4 | Not started |
| Implement | `src/`, `tests/`, `pipelines/` | 512 | Not started |
Any item that is unresolved must be captured as an explicit **assumption, risk, or architecture decision**. It must never be silently implemented.
| Stage | Artifact | State |
| --- | --- | --- |
| Baseline | `Persona-Engine-Developer-Handoff.txt` | Approved |
| Constitution | `.specify/memory/constitution.md` | Ratified v1.0.0 |
| Specify | `specs/001-persona-engine/spec.md` | Complete |
| Plan | `plan.md`, `research.md` | Complete — OTD-001…007, 010 resolved |
| Contracts | `data-model.md`, `contracts/`, `persona-engine.schema.json` | Complete |
| Tasks | `specs/001-persona-engine/tasks.md` | Complete — 121 tasks |
| Implement | `src/`, `tests/`, `pipelines/`, `docs/` | **109 / 121** — remainder needs a tenant or Automation |
---
@@ -48,232 +329,192 @@ Any item that is unresolved must be captured as an explicit **assumption, risk,
| **Explainable** | Every result identifies the matched rule, run ID, UPN, and Account Object ID. |
| **Modular** | The pure rule engine has no dependency on Graph, Azure Automation, or the console. |
---
### The one that matters most
## Scope (version 1)
**Unknown is not false.** If a membership lookup fails, the condition is `Unknown`, not `False` — so a
`notMemberOf` condition does not become satisfied when the lookup fails.
**In scope**
- Microsoft Entra **user objects only**
- Ordered, first-match business rules defined in **JSON** (the only supported configuration format)
- Property, group-membership, and role-based conditions with nested `All` / `Any` composition
- Native PowerShell `-WhatIf` as the approved no-write control
- Structured, audit-friendly logging plus immediate per-user output and periodic summaries
- Local PowerShell 7 execution and Azure Automation PowerShell 7 runbook execution
**Out of scope for v1** — the architecture must not assume these share user-object properties:
- Service principals, managed identities, workload identities, agentic identities
### Candidate persona catalogue
These are candidate business classifications, **not** hard-coded engine behaviour:
`Guest` · `BreakGlass-Admin` · `Tier0-Admin` · `Tier1-Admin` · `Tier2-Admin` · `Restricted-User` · `Test-Account` · `Service-Account` · `Shared-Functional-Account` · `Meeting-Room-Device` · `Employee` · `Contractor` · `Student`
Two values are processing results rather than rules:
- **`Unclassified`** — evaluation succeeded, but no rule matched.
- **`EvaluationError`** — evaluation could not complete; the existing persona is preserved.
Without that, a transient Graph outage would make every privileged account look like a non-member of
its Tier 0 group, and one run would quietly demote the entire administrative population.
`tests/RuleEngine/UnknownNotFalse.Tests.ps1` exists solely to prevent that regression.
---
## Components
### 1. `Invoke-PersonaEngine.ps1`
Retrieval, evaluation, reporting, and controlled persistence.
### `Invoke-PersonaEngine.ps1`
| Parameter | Notes |
| --- | --- |
| `-ConfigPath <string>` | Required |
| `-WhatIf` | Native risk-mitigation parameter; the approved no-write control |
| `-Verbose` / `-Debug` | Native common parameters; `-Debug` must **not** mean read-only |
| `-UserObjectId <GUID>` | Optional single-user test execution |
| `-OutputPath <string>` | Optional override, if permitted |
| `-CorrelationId <GUID>` | Optional supplied run identifier |
| `-ConfigPath <string>` | Required. Validated through all four layers before any connection. |
| `-WhatIf` | **The approved no-write control.** |
| `-UserObjectId <GUID>` | Single-user execution. |
| `-OutputPath <string>` | Overrides `logging.path`. |
| `-CorrelationId <GUID>` | Run identifier; generated when absent. |
| `-SchemaPath <string>` | Schema override. |
| `-PreviousConfigPath <string>` | Enables the VR-003 drift checks. |
| `-Verbose` / `-Debug` | Common parameters. **Neither implies read-only.** |
The script uses `CmdletBinding` with `SupportsShouldProcess`.
**Exit codes**: `0` success · `1` config invalid · `2` auth · `3` enumeration · `4` data / threshold ·
`5` reconciliation · `6` unexpected.
```powershell
# Read-only evaluation
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf
# Read-only with operational detail
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf -Verbose
# Single-user validation
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf
# Production, changed-values-only processing
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json
```
**Exit codes**
| Code | Meaning |
| --- | --- |
| `0` | Successful run; no fatal processing errors |
| `1` | Configuration validation failure |
| `2` | Authentication / authorization failure |
| `3` | User enumeration failure |
| `4` | Fatal required data-provider failure |
| `5` | Reconciliation failure |
| `6` | Unexpected fatal engine error |
A per-user `EvaluationError` does not necessarily terminate the run, but the final status must report the number of affected accounts and may apply a configurable warning/failure threshold.
### 2. `Edit-PersonaEngineConfig.ps1`
Configuration validation, interactive editing, synthetic rule testing, and pipeline enforcement.
### `Edit-PersonaEngineConfig.ps1`
| Parameter | Notes |
| --- | --- |
| `-ConfigPath <string>` | Required |
| `-ValidateOnly` | Validate without entering the editor |
| `-NonInteractive` | Pipeline mode; returns codes instead of prompting |
| `-SchemaPath <string>` | Optional schema override |
| `-OutputPath <string>` | Optional Save-As target |
| `-TreatWarningsAsErrors` | Escalate warnings |
| `-TestDataPath <string>` | Optional synthetic sample input |
| `-ConfigPath <string>` | Required. |
| `-ValidateOnly` | Validate; never enter the editor. |
| `-NonInteractive` | Pipeline mode. Never prompts, never hangs. |
| `-SchemaPath` / `-OutputPath` | Schema override; Save-As target. |
| `-TreatWarningsAsErrors` | Escalate warnings (VR-005). |
| `-TestDataPath <string>` | Synthetic rule testing, no tenant. |
| `-PreviousConfigPath` / `-EnforcementEnabled` | Drift checks; raise safety severities. |
```powershell
# Validate only
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly
**Exit codes**: `0` valid · `1` errors · `2` warnings escalated · `3` file unreadable · `4` schema unusable.
# Pipeline validation
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
# Interactive editor
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json
```
Validation runs in four layers: **JSON syntax → JSON Schema → semantic → safety**.
Validation runs in four layers: **JSON syntax → JSON Schema → semantic → safety**, stopping at the
first that produces errors.
---
## Architecture
```
Invoke-PersonaEngine.ps1
|
+-- Configuration Import-PersonaConfiguration, Test-PersonaConfiguration, Resolve-TargetAttribute
+-- Authentication Connect-PersonaGraphInteractive, Connect-PersonaGraphManagedIdentity
+-- Data Providers Get-PersonaUsers, Get-PersonaGroupMembership, Get-PersonaDirectoryRoles
+-- Normalization ConvertTo-PersonaUserRecord, ConvertTo-PersonaMembershipRecord
+-- Rule Engine Test-PersonaCondition, Test-PersonaConditionGroup, Test-PersonaRule, Resolve-UserPersona
+-- Persistence Compare-PersonaValue, Set-UserPersonaAttribute
+-- Presentation Write-UserPersonaResult, Write-PersonaSummary
+-- Audit New-PersonaAuditRecord, Export-PersonaRunReport
Invoke-PersonaEngine.ps1 thin wrapper: parameters, ShouldProcess, exit code
└── src/Engine/ Invoke-PersonaEngineRun (the run loop, testable offline)
├── Configuration Import, Test (4 layers), Resolve-TargetAttribute
├── Authentication Connect-PersonaGraphInteractive
├── DataProviders Get-PersonaUsers, GroupMembership, DirectoryRoles, retry, cache
├── Normalization ConvertTo-PersonaUserRecord, ConvertTo-PersonaMembershipRecord
├── RuleEngine Test-PersonaCondition/ConditionGroup/Rule, Resolve-UserPersona
├── Persistence Compare-PersonaValue, New-PersonaWriteBody, Set-UserPersonaAttribute
├── Presentation Write-UserPersonaResult, Write-PersonaSummary, reconciliation
└── Audit New-PersonaAuditRecord, Write-PersonaAuditRecord, Export-PersonaRunReport
```
**Critical flow**
```
Graph acquisition -> normalized identity record -> pure rule engine
-> persona decision result -> comparison -> optional persistence adapter
-> console and structured audit output
```
The **pure rule engine must not depend** on Graph authentication, Azure Automation, or console rendering. It has to be testable offline with synthetic data.
The **rule engine is pure** — no Graph, no auth, no console, no filesystem, no clock. Enforced on
every build by `tests/Test-EnginePurity.ps1`, which parses each file with the PowerShell AST parser
and inspects only code tokens. That purity is why 354 tests run with no tenant.
---
## Planned repository structure
## Repository layout
```
PersonaEngine/
|-- README.md
|-- Invoke-PersonaEngine.ps1
|-- Edit-PersonaEngineConfig.ps1
|-- PersonaEngine.psd1
|-- PersonaEngine.psm1
|
|-- config/ persona-engine.example.json, persona-engine.schema.json
|-- src/ Authentication/ Configuration/ DataProviders/ Normalization/
| RuleEngine/ Persistence/ Presentation/ Audit/
|-- tests/ Unit/ Integration/ Configuration/ Safety/ TestData/
|-- docs/ Architecture.md BusinessRules.md ConfigurationReference.md
| Logging.md SecurityModel.md OperationsRunbook.md
|-- pipelines/ validate.yml test.yml release.yml
|-- specs/
|-- 001-persona-engine/
|-- spec.md plan.md tasks.md research.md data-model.md quickstart.md
|-- contracts/ checklists/
Invoke-PersonaEngine.ps1 Edit-PersonaEngineConfig.ps1
PersonaEngine.psd1 PersonaEngine.psm1
config/ persona-engine.example.json, persona-engine.schema.json
src/ Configuration/ Authentication/ DataProviders/ Normalization/
RuleEngine/ Persistence/ Presentation/ Engine/ Audit/
tests/ Unit/ RuleEngine/ Configuration/ Safety/ TestData/
Test-EnginePurity.ps1 Test-Sanitization.ps1 TestHelpers.ps1
docs/ Architecture.md SecurityModel.md ConfigurationReference.md
OperationsRunbook.md BusinessRules.md Logging.md
pipelines/ validate.yml test.yml
specs/001-persona-engine/
spec.md plan.md tasks.md research.md data-model.md
quickstart.md traceability.md contracts/ verification/
```
---
## Security model
- **Authentication** — Azure Automation uses a **managed identity**. Local development uses an approved interactive or read-only application identity. **No client secret in source control.**
- **Least privilege** — the execution identity gets only what the enabled rules require: in-scope user properties, configured group membership, configured role data, and the existing persona value.
- **Write permissions** — the production identity is granted the minimum permission needed to update the configured target attribute. Whether Entra can enforce write scope at the **individual attribute level must be verified, never assumed** (see OTD-003).
- **Compensating controls**, if the Graph permission proves broader than the single attribute: the persistence module accepts only the approved target attribute; that attribute must appear in `approvedWritableAttributes`; validation rejects all others; a dedicated function builds a request body containing only that attribute; unit and integration tests inspect the request body; code owners and branch policies gate persistence changes; directory audit logs are monitored for unexpected property writes.
- **Data handling** — UPN and Account Object ID are approved for logs. Never log access tokens, authorization headers, secrets, or full Graph responses. Detailed condition values are diagnostic-only, behind `-Debug`.
- **Kill switch** — disable the Automation schedule, run with `-WhatIf`, revoke production write permission, or disable write deployment stages.
The short version; the full one is [docs/SecurityModel.md](docs/SecurityModel.md).
**Graph application permissions have no per-property write scope** (OTD-003). An identity that can
write the persona attribute can write any writable user property. The directory will not stop a
malformed request on our behalf, so six compensating controls hold that line, and each is tested:
1. `New-PersonaWriteBody` throws for any attribute other than the configured target.
2. The target must appear in `approvedWritableAttributes` — checked at validation **and** at write time.
3. Validation rejects any approved attribute that is not a directory extension property (`PE-SAF-002`).
4. One function builds the request body, and it returns a hashtable whose `Count` is exactly 1.
5. Tests inspect every body issued during a full enforcing run.
6. Code owners and branch policies gate changes to `src/Persistence/`.
**V-4 — written security sign-off on these controls — gates all enforcement.**
Never logged: tokens, `Authorization` headers, secrets, certificates, raw Graph responses. The
guarantee is structural: the record builder accepts only named, typed values, so there is nothing for
a secret to ride in on.
---
## Sanitization requirements
## Sanitization (SC-013)
**Every** artifact in this repository — docs, examples, tests, configuration samples — must be free of organization names, real domains, tenant or subscription IDs, automation account names, real UPNs or Object IDs, real group/role identifiers, environment-specific attribute names, Log Analytics details, and any secret, token, certificate, or credential.
No organization name, real domain, tenant or subscription ID, real UPN or Object ID, real group or
role identifier, environment-specific attribute name, or secret may appear in any file this
repository would commit.
Use placeholders only:
`tests/Test-Sanitization.ps1` scans tracked **and** untracked non-ignored files. Reserved domains
(`example.com`, `.invalid`, `.test`) and the module manifest's own identity GUID are exempt; nothing
else is.
`<ORGANIZATION-NAME>` · `<PRIMARY-DOMAIN>` · `<TENANT-ID>` · `<ACCOUNT-OBJECT-ID>` · `<GROUP-OBJECT-ID>` · `<APPROVED-PERSONA-ATTRIBUTE-NAME>` · `<AUTOMATION-ACCOUNT-NAME>` · `<LOG-OUTPUT-PATH>`
Synthetic test data must be obviously fictional and must never reproduce real employee records. Tenant-specific configuration belongs in a protected repository or configuration store, not here.
Placeholders: `<ORGANIZATION-NAME>` · `<PRIMARY-DOMAIN>` · `<TENANT-ID>` · `<ACCOUNT-OBJECT-ID>` ·
`<GROUP-OBJECT-ID>` · `<APPROVED-PERSONA-ATTRIBUTE-NAME>` · `<AUTOMATION-ACCOUNT-NAME>` · `<LOG-OUTPUT-PATH>`
---
## Open technical decisions
## Open decisions and verification
Research items to be resolved in `research.md` or an ADR. **OTD-001 through OTD-005 must be closed before persistence implementation.**
| ID | Decision |
| ID | Status |
| --- | --- |
| OTD-001 | Select the exact Entra persona attribute mechanism — data type, Graph read/update method, discoverability, Conditional Access compatibility |
| OTD-002 | Confirm exact least-privilege Microsoft Graph permissions |
| OTD-003 | Confirm whether write authorization can be restricted to the individual target attribute |
| OTD-004 | Select the Graph access approach — SDK cmdlets, direct REST, or a controlled combination |
| OTD-005 | Select a JSON Schema validation approach compatible with PS7 locally and in Azure Automation |
| OTD-006 | Select structured-log destination and transport |
| OTD-007 | Define retry policy — retryable status codes, max attempts, backoff, jitter, logging |
| OTD-008 | Define full versus incremental processing roadmap |
| OTD-009 | Define production schedule and concurrency lock |
| OTD-010 | Define rollback implementation |
| OTD-001 persona attribute | **Resolved** — directory extension property |
| OTD-002 least-privilege permissions | **Resolved** |
| OTD-003 per-attribute write scope | **Resolved: not possible.** Six compensating controls; V-4 outstanding |
| OTD-004 Graph access approach | **Resolved**`Invoke-MgGraphRequest` |
| OTD-005 schema validation | **Resolved locally**`Test-Json`; V-5b open for Automation |
| OTD-006 log transport | **Resolved** — NDJSON via a single sink |
| OTD-007 retry policy | **Resolved** |
| OTD-008 incremental processing | Open — full enumeration only in v1 |
| OTD-009 concurrency lock | Open — deferred with Stage B |
| OTD-010 rollback | **Data captured**; tool out of scope for v1 |
| Verification | Status |
| --- | --- |
| V-1 read / write | Open — needs a tenant / test accounts |
| V-2 dynamic group + CA | Open |
| V-3 non-privileged preview | Open |
| **V-4 security sign-off** | **Open — gates enforcement** |
| V-5a `Test-Json` behaviour | **Closed** — [V-5a.md](specs/001-persona-engine/verification/V-5a.md) |
| V-5b `Test-Json` in Automation | Deferred |
Full requirement-to-test mapping, including the gaps: [traceability.md](specs/001-persona-engine/traceability.md).
---
## Getting started
## Documentation
Implementation has not begun. The current work item is Phase 2 — planning.
1. ~~Initialize the Spec Kit project structure.~~ Done.
2. ~~Convert the handoff baseline into `specs/001-persona-engine/spec.md`.~~ Done.
3. Build a requirements traceability list using FR/NFR identifiers.
4. Close OTD-001 through OTD-005 before any persistence work.
5. Create `persona-engine.schema.json` and a placeholder-only `persona-engine.example.json`.
6. Define normalized PowerShell object contracts.
7. **Build the pure rule engine first**, with offline Pester tests, before any Graph integration.
8. Implement configuration validation and non-interactive pipeline mode.
9. Implement Graph read adapters, then console and structured logging.
10. Implement the persistence adapter **last**, with `ShouldProcess` and tests proving zero writes under `-WhatIf`.
Requirements: **PowerShell 7**. Offline unit testing must not require tenant connectivity.
| Document | For |
| --- | --- |
| [Architecture.md](docs/Architecture.md) | Boundaries, and why the rule engine is pure |
| [SecurityModel.md](docs/SecurityModel.md) | OTD-003, the six controls, V-4 |
| [ConfigurationReference.md](docs/ConfigurationReference.md) | Every field and every finding code |
| [BusinessRules.md](docs/BusinessRules.md) | Writing and changing rules |
| [OperationsRunbook.md](docs/OperationsRunbook.md) | Kill switch, rollback, incidents |
| [Logging.md](docs/Logging.md) | Record types and querying |
---
## Delivery
## Scope (version 1)
- Hosted in **Azure DevOps Git** — feature branches, pull requests, protected release branch, code owners on persistence, security configuration, and production rules.
- **Validation pipeline** — repository hygiene checks, PowerShell static analysis, JSON Schema validation, semantic/safety configuration validation, Pester unit tests, Pester safety tests, test result publication, artifact packaging.
- **Release pipeline** — validate approved branch/tag, repeat validation and tests, package, deploy to Azure Automation, import modules, publish runbook, **keep the schedule disabled**, execute `-WhatIf` validation, approval gate, then enable enforcement.
- Changes to the target attribute, `approvedWritableAttributes`, rule priority, rule enablement, rule conditions, persona outputs, authentication permissions, persistence functions, logging destination, or `WhatIf`/`ShouldProcess` behaviour **all require review**.
**In scope** — Entra **user objects only**; ordered first-match rules in JSON; property,
group-membership, and role conditions with nested `all`/`any`; `-WhatIf` as the no-write control;
structured audit logging; local PowerShell 7 and (deferred) Azure Automation.
---
**Out of scope** — service principals, managed identities, workload identities, agentic identities;
non-JSON configuration; delta processing; condition-level case-sensitivity; PIM-eligible role
assignments; a rollback tool.
## Definition of done (v1)
### Candidate persona catalogue
Version 1 is complete when both PowerShell scripts are implemented; the JSON Schema exists and is documented; validation covers syntax, schema, semantic, and safety layers; ordered first-match evaluation and nested `All`/`Any` work within the configured depth; the initial property and membership operators are tested; null behaviour matches the approved decision; required group-lookup failures produce `EvaluationError` and preserve the existing persona; `Unclassified` users are reported distinctly; each user result displays immediately; interim and final summaries work — including interval `0` — and reconciliation checks pass; logs include UPN and Account Object ID; **`-WhatIf` produces zero Graph writes**; only changed valid values are written in enforcement mode; the write payload contains only the approved target attribute; local offline Pester, read-only production-tenant, Azure Automation PowerShell 7, and Azure DevOps pipeline runs all pass; security review confirms permissions and compensating controls; operational documentation, kill switch, and rollback procedure are complete; and `-WhatIf` impact evidence is reviewed before enforcement is enabled.
`Guest` · `BreakGlass-Admin` · `Tier0-Admin` · `Tier1-Admin` · `Tier2-Admin` · `Restricted-User` ·
`Test-Account` · `Service-Account` · `Shared-Functional-Account` · `Meeting-Room-Device` · `Employee` ·
`Contractor` · `Student`
Two values are processing results, not rule outcomes:
- **`Unclassified`** — evaluation succeeded, no rule matched.
- **`EvaluationError`** — evaluation could not complete; the existing persona is preserved.
+230
View File
@@ -0,0 +1,230 @@
{
"configVersion": "1.0.0",
"metadata": {
"owner": "<TEAM-NAME>",
"changeReference": "<CHANGE-REFERENCE>",
"description": "Placeholder-only example. Every identifier below is fictional. Real group Object IDs, attribute names, and domains belong in a protected configuration store, never in this repository."
},
"engine": {
"targetAttribute": "extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>",
"approvedWritableAttributes": [
"extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>"
],
"maxConditionDepth": 5,
"summaryInterval": 25,
"defaultMembershipMode": "direct",
"evaluationErrorThreshold": 50
},
"dataSources": {
"groups": {
"enabled": true
},
"roles": {
"enabled": true,
"includeEligible": false
}
},
"logging": {
"destination": "both",
"path": "<LOG-OUTPUT-PATH>",
"traceConditionValues": false
},
"personas": [
"Guest",
"BreakGlass-Admin",
"Tier0-Admin",
"Tier1-Admin",
"Tier2-Admin",
"Restricted-User",
"Test-Account",
"Service-Account",
"Shared-Functional-Account",
"Meeting-Room-Device",
"Employee",
"Contractor",
"Student"
],
"rules": [
{
"id": "RULE-0010-BREAKGLASS",
"name": "Emergency access accounts",
"description": "Emergency access accounts identified by immutable Object ID (RE-009). Evaluated first so no later rule can reclassify them.",
"enabled": true,
"priority": 10,
"persona": "BreakGlass-Admin",
"owner": "<TEAM-NAME>",
"match": {
"operator": "any",
"conditions": [
{
"type": "property",
"property": "AccountObjectId",
"operator": "in",
"values": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002"
]
}
]
}
},
{
"id": "RULE-0020-GUEST",
"name": "Guest accounts",
"description": "Any account whose directory user type is Guest.",
"enabled": true,
"priority": 20,
"persona": "Guest",
"match": {
"operator": "all",
"conditions": [
{
"type": "property",
"property": "UserType",
"operator": "equals",
"value": "Guest"
}
]
}
},
{
"id": "RULE-0030-TIER0",
"name": "Tier 0 administrators",
"description": "Members of the Tier 0 administrative group, or holders of a Tier 0 directory role.",
"enabled": true,
"priority": 30,
"persona": "Tier0-Admin",
"match": {
"operator": "any",
"conditions": [
{
"type": "membership",
"operator": "memberOf",
"membershipMode": "transitive",
"groupObjectIds": [
"00000000-0000-0000-0000-0000000000a0"
]
},
{
"type": "role",
"operator": "memberOf",
"roleIds": [
"<TIER0-ROLE-TEMPLATE-ID>"
]
}
]
}
},
{
"id": "RULE-0040-SERVICE",
"name": "Service accounts",
"description": "Non-human accounts identified by naming convention and the service account group. Both must hold, so a naming-convention collision alone cannot classify a person as a service account.",
"enabled": true,
"priority": 40,
"persona": "Service-Account",
"match": {
"operator": "all",
"conditions": [
{
"type": "property",
"property": "UserPrincipalName",
"operator": "startsWith",
"value": "svc-"
},
{
"type": "membership",
"operator": "memberOf",
"groupObjectIds": [
"00000000-0000-0000-0000-0000000000b0"
]
}
]
}
},
{
"id": "RULE-0050-TEST",
"name": "Test accounts",
"description": "Accounts in the test account group, or matching the test naming convention while disabled.",
"enabled": true,
"priority": 50,
"persona": "Test-Account",
"match": {
"operator": "any",
"conditions": [
{
"type": "membership",
"operator": "memberOf",
"groupObjectIds": [
"00000000-0000-0000-0000-0000000000c0"
]
},
{
"operator": "all",
"conditions": [
{
"type": "property",
"property": "UserPrincipalName",
"operator": "startsWith",
"value": "test-"
},
{
"type": "property",
"property": "AccountEnabled",
"operator": "equals",
"value": "False"
}
]
}
]
}
},
{
"id": "RULE-0060-CONTRACTOR",
"name": "Contractors",
"description": "Accounts whose company name marks them as external, excluding those already classified by an earlier rule.",
"enabled": true,
"priority": 60,
"persona": "Contractor",
"match": {
"operator": "all",
"conditions": [
{
"type": "property",
"property": "CompanyName",
"operator": "isNotNull"
},
{
"type": "property",
"property": "CompanyName",
"operator": "notEquals",
"value": "<ORGANIZATION-NAME>"
}
]
}
},
{
"id": "RULE-0900-EMPLOYEE",
"name": "Employees",
"description": "Default classification for enabled member accounts with a department. Lowest priority so every more specific rule wins first.",
"enabled": true,
"priority": 900,
"persona": "Employee",
"match": {
"operator": "all",
"conditions": [
{
"type": "property",
"property": "UserType",
"operator": "equals",
"value": "Member"
},
{
"type": "property",
"property": "Department",
"operator": "isNotNull"
}
]
}
}
]
}
+278
View File
@@ -0,0 +1,278 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://example.invalid/persona-engine.schema.json",
"title": "Persona Engine Configuration",
"description": "Draft-07 by decision OTD-005: validated with the built-in Test-Json -SchemaFile cmdlet, whose validator reliably supports draft-04/06/07 only. Do not introduce 2019-09 or 2020-12 constructs. This schema is validation layer 2 of 4; semantic rules (VR-002) and safety rules (VR-003) are enforced in PowerShell, not here.",
"type": "object",
"required": ["configVersion", "engine", "dataSources", "personas", "rules"],
"additionalProperties": false,
"properties": {
"configVersion": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$",
"description": "Semantic version of this configuration. A downgrade is a safety violation (VR-003)."
},
"metadata": {
"type": "object",
"additionalProperties": true,
"properties": {
"owner": { "type": "string" },
"changeReference": { "type": "string" },
"description": { "type": "string" }
}
},
"engine": {
"type": "object",
"required": ["targetAttribute", "approvedWritableAttributes"],
"additionalProperties": false,
"properties": {
"targetAttribute": {
"type": "string",
"minLength": 1,
"description": "The single attribute the engine may write. Must also appear in approvedWritableAttributes (semantic layer). Example placeholder: extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>"
},
"approvedWritableAttributes": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"maxConditionDepth": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 5,
"description": "RE-004. The ceiling of 10 is a hard limit; the configured value may be lower."
},
"summaryInterval": {
"type": "integer",
"minimum": 0,
"default": 25,
"description": "FR-020. Zero suppresses interim summaries; a final summary is always produced."
},
"defaultMembershipMode": {
"type": "string",
"enum": ["direct", "transitive"],
"default": "direct"
},
"evaluationErrorThreshold": {
"type": "integer",
"minimum": 0,
"description": "Optional. Count of EvaluationError results above which the run reports failure."
}
}
},
"dataSources": {
"type": "object",
"required": ["groups", "roles"],
"additionalProperties": false,
"properties": {
"groups": {
"type": "object",
"required": ["enabled"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"membershipMode": { "type": "string", "enum": ["direct", "transitive"] }
}
},
"roles": {
"type": "object",
"required": ["enabled"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"includeEligible": {
"type": "boolean",
"default": false,
"description": "Out of scope for v1 unless authorization is confirmed and the provider is implemented."
}
}
}
}
},
"logging": {
"type": "object",
"additionalProperties": false,
"properties": {
"destination": {
"type": "string",
"enum": ["file", "stream", "both"],
"default": "both",
"description": "OTD-006. Additional transports are added behind the sink function, not by widening this enum without a version change."
},
"path": {
"type": "string",
"description": "Placeholder in committed artifacts: <LOG-OUTPUT-PATH>"
},
"traceConditionValues": {
"type": "boolean",
"default": false,
"description": "Diagnostic only. Enabling this without explicit acknowledgement is a safety finding (VR-003)."
},
"acknowledgeConditionTracing": {
"type": "boolean",
"default": false,
"description": "Explicit acknowledgement that condition-value tracing writes evaluated attribute values into audit records. Required by VR-003 whenever traceConditionValues is true. Kept in the configuration rather than passed as a command-line flag so the acknowledgement is reviewable in the change that enables tracing."
}
}
},
"personas": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"not": { "enum": ["EvaluationError"] }
},
"description": "Defined persona catalogue. EvaluationError is an execution result and must never be declared as a persona."
},
"rules": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/rule" }
}
},
"definitions": {
"rule": {
"type": "object",
"required": ["id", "name", "description", "enabled", "priority", "persona", "match"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1 },
"description": { "type": "string", "minLength": 1 },
"enabled": { "type": "boolean" },
"priority": { "type": "integer", "minimum": 0 },
"persona": {
"type": "string",
"minLength": 1,
"not": { "enum": ["Unclassified", "EvaluationError"] },
"description": "Unclassified is a processing result, not a rule outcome (VR-002)."
},
"match": { "$ref": "#/definitions/conditionGroup" },
"tags": { "type": "array", "items": { "type": "string" } },
"owner": { "type": "string" },
"changeReference": { "type": "string" },
"effectiveDate": {
"type": "string",
"format": "date",
"description": "Metadata only in v1. It must not gate evaluation — a date-dependent decision would break determinism (Principle I)."
},
"notes": { "type": "string" },
"testCases": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "expectedMatch"],
"additionalProperties": true,
"properties": {
"name": { "type": "string" },
"expectedMatch": { "type": "boolean" },
"user": { "type": "object" }
}
}
}
}
},
"conditionGroup": {
"type": "object",
"required": ["operator", "conditions"],
"additionalProperties": false,
"properties": {
"operator": { "type": "string", "enum": ["all", "any"] },
"conditions": {
"type": "array",
"minItems": 1,
"items": {
"anyOf": [
{ "$ref": "#/definitions/conditionGroup" },
{ "$ref": "#/definitions/condition" }
]
}
}
}
},
"condition": {
"type": "object",
"required": ["type", "operator"],
"additionalProperties": false,
"properties": {
"type": { "type": "string", "enum": ["property", "membership", "role"] },
"property": { "type": "string", "minLength": 1 },
"operator": {
"type": "string",
"enum": [
"equals", "notEquals", "contains", "notContains",
"startsWith", "endsWith", "matchesRegex",
"in", "notIn", "isNull", "isNotNull",
"memberOf", "notMemberOf"
]
},
"value": { "type": "string" },
"values": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string" }
},
"groupObjectIds": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/definitions/guid" }
},
"roleIds": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"membershipMode": { "type": "string", "enum": ["direct", "transitive"] },
"caseSensitive": {
"type": "boolean",
"default": false,
"description": "Reserved. Condition-level case sensitivity is out of scope for v1; the schema accepts the key so a later version does not require a breaking change."
}
},
"allOf": [
{
"if": { "properties": { "type": { "const": "property" } }, "required": ["type"] },
"then": { "required": ["property"] }
},
{
"if": { "properties": { "type": { "const": "membership" } }, "required": ["type"] },
"then": { "required": ["groupObjectIds"] }
},
{
"if": { "properties": { "type": { "const": "role" } }, "required": ["type"] },
"then": { "required": ["roleIds"] }
},
{
"if": {
"properties": { "operator": { "enum": ["in", "notIn"] } },
"required": ["operator"]
},
"then": { "required": ["values"] }
},
{
"if": {
"properties": { "operator": { "enum": ["isNull", "isNotNull"] } },
"required": ["operator"]
},
"then": {
"allOf": [
{ "not": { "required": ["value"] } },
{ "not": { "required": ["values"] } }
]
}
}
]
},
"guid": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
}
}
}
+138
View File
@@ -0,0 +1,138 @@
# Architecture
How Persona Engine is put together, and why the boundaries sit where they do.
## The one structural rule
The rule engine is pure. It takes a normalized record and a rule set, and returns a decision. It has
no knowledge of Microsoft Graph, no authentication, no console, no filesystem, and no clock.
Everything else follows from that. It is constitution Principle IV, it is enforced by
[`tests/Test-EnginePurity.ps1`](../tests/Test-EnginePurity.ps1) on every build, and it is the reason
354 tests can run on a laptop with no tenant, no credentials, and no network.
The purity check is a tokenizer pass, not a text search: it parses each file under `src/RuleEngine/`
with the PowerShell AST parser and inspects only the code tokens. An earlier text-matching version
flagged a comment that merely *mentioned* a persistence function, which is the kind of false positive
that gets a check disabled.
## Layers
Loaded in this order by [`PersonaEngine.psm1`](../PersonaEngine.psm1). Order matters only for
readability — PowerShell resolves function names at call time — but the dependency direction is real
and one-way.
| Layer | Responsibility | May depend on |
| --- | --- | --- |
| `Normalization` | Convert raw directory objects into `UserRecord` and `MembershipRecord` | nothing |
| `Configuration` | Load, validate, and resolve configuration | Normalization |
| `RuleEngine` | Evaluate conditions, groups, rules; produce a decision | Normalization only |
| `Authentication` | Acquire a Graph connection | nothing in this module |
| `DataProviders` | Retrieve users, membership, roles; retry policy | Normalization |
| `Persistence` | Compare values; build and issue the write | DataProviders |
| `Presentation` | Per-user output, summaries, reconciliation | nothing |
| `Engine` | The run loop that composes all of the above | everything |
| `Audit` | Build and emit structured records | Presentation |
The arrow never points into `RuleEngine`. Nothing in `RuleEngine` may reference anything from
`Authentication`, `DataProviders`, `Persistence`, or `Presentation`.
## The normalization boundary
`ConvertTo-PersonaUserRecord` and `ConvertTo-PersonaMembershipRecord` are the only places where a raw
Graph shape becomes an engine shape. Downstream of them, nothing knows Graph exists.
Two consequences worth stating plainly:
**Fixtures are real inputs.** A synthetic `UserRecord` built in a test is indistinguishable to the
engine from one built from a live tenant response. That is what makes the offline suite evidence
rather than a rehearsal.
**Filtering rules live in one place.** `memberOf` returns directory objects of mixed type.
Administrative units arriving on that endpoint are discarded during normalization, not by each
caller. 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.
## The MembershipRecord shape
Three independently-retrieved facets — direct groups, transitive groups, directory roles — each with
its own retrieval flag and failure reason.
This started as a single record with one `Mode` field, and running the code against the example
configuration is what proved it wrong: RE-007 makes membership mode a **per-condition** choice, so a
rule set may legitimately ask for transitive membership in one rule and direct in another. A record
carrying only one mode cannot answer both, and seven of nine fixtures came back as `EvaluationError`.
The engine was right; the contract was wrong.
Independent facets also contain failure. If the transitive lookup times out but the direct lookup
succeeded, only conditions needing transitive data become `Unknown`. One collapsed 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` rather than silently misclassifying a privileged
account.
## Tri-state evaluation
Conditions return `'True'`, `'False'`, or `'Unknown'` — not a boolean. `Unknown` propagates through
condition groups by the table in [data-model.md](../specs/001-persona-engine/data-model.md), and an
`Unknown` reaching a rule's root becomes `EvaluationError` for that account.
An `Unknown` 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 — a privilege downgrade drawn from data nobody could read. Preserving the stored value
is the only safe answer.
## The write gate has one origin
Mode is derived from `$PSCmdlet.ShouldProcess()` and nothing else. There is no `-Preview` switch, no
configuration key that suppresses writes, and no reading of `$WhatIfPreference`. Two sources of truth
for a write gate is the defect class Principle III exists to prevent: the day they disagree, one of
them is wrong and the directory finds out first.
`Invoke-PersonaEngine.ps1` owns the `ShouldProcess` call and passes the result down to
`Invoke-PersonaEngineRun` as a scriptblock. The run loop never learns what `-WhatIf` is, so it cannot
disagree with it — and it defaults to a gate that refuses, so a caller that forgets to supply one
previews rather than writes.
## Why the run loop is a module function
`Invoke-PersonaEngine.ps1` is a thin wrapper: parameter binding, module import, `ShouldProcess`,
exit code. The loop itself is `Invoke-PersonaEngineRun` in `src/Engine/`.
That split exists because SC-004 requires proof that a `-WhatIf` run issues zero writes across a full
population. A loop that only exists inside an entry script — one that imports a manifest requiring
the Graph SDK — cannot be exercised without a tenant, so the claim could not be tested. What ships
and what is tested are now the same code.
## Single emission point for audit
Every audit record passes through `Write-PersonaAuditRecord`. Adding a transport is a change to that
one function. 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.
Records go to the **Information** stream, not the success stream. Audit records on the success stream
would be indistinguishable from a function's return value — the run loop returns its outcome there,
and mixing the two turns one object into an array of several thousand.
## Failure containment
| Failure | Scope | Outcome |
| --- | --- | --- |
| Configuration invalid | Run | Exit 1, before any connection is attempted |
| Authentication fails | Run | Exit 2 |
| Enumeration truncated | Run | Exit 3 — a partial population is never processed |
| One membership lookup fails | One facet, one user | `EvaluationError`, stored value preserved |
| Too many `EvaluationError` | Run status only | Exit 4; no value was changed |
| Counters disagree | Run | Exit 5, `EngineDefect` record |
| One write fails | One user | `UpdateFailed`, run continues |
The dividing line: anything affecting the whole population ends the run; anything affecting one
account is contained and reported.
## Related
- [SecurityModel.md](SecurityModel.md) — the OTD-003 trade-off and its compensating controls
- [ConfigurationReference.md](ConfigurationReference.md) — every schema field and finding code
- [OperationsRunbook.md](OperationsRunbook.md) — kill switch and rollback
- [Logging.md](Logging.md) — record types and what they may contain
+137
View File
@@ -0,0 +1,137 @@
# Business rules
How to write, order, and change the rules that decide what an account is.
Field-by-field syntax is in [ConfigurationReference.md](ConfigurationReference.md). This document is
about judgement.
## The model
Rules are evaluated in ascending `priority`. The first rule that returns `True` wins, and evaluation
stops. An account matching no enabled rule is `Unclassified`. An account whose evaluation hits data
that could not be retrieved is `EvaluationError`, and its stored value is preserved.
Three consequences worth internalising before writing a rule:
**Order is meaning.** A rule at priority 900 only ever sees accounts that failed every rule above it.
Changing a priority silently reclassifies every account matched by more than one rule, which is why
`PE-SAF-005` blocks a reorder without a `configVersion` change.
**A rule cannot express "and not the previous ones".** It does not need to. First-match already
excludes them. Adding explicit exclusions duplicates the ordering in two places, and the day they
disagree the ordering wins silently.
**Determinism is absolute.** The same account and the same configuration always produce the same
persona. Nothing time-dependent, random, or order-dependent may enter a decision — `effectiveDate` is
metadata for exactly this reason, and the engine's per-user timing uses a monotonic stopwatch rather
than the wall clock so no clock value can reach a decision.
## Priority bands
A convention, not enforced, but it makes the intent of a rule set legible at a glance:
| Band | Purpose | Examples |
| --- | --- | --- |
| 199 | Accounts that must never be reclassified by anything | Emergency access, Tier 0 |
| 100199 | Directory facts that are definitional | Guest, external |
| 200499 | Non-human accounts | Service, shared functional, room devices |
| 500799 | Population subsets | Contractor, student, restricted |
| 800999 | Defaults | Employee |
Leave gaps. Renumbering to insert a rule is a reorder, and a reorder is a `PE-SAF-005` finding.
## Writing a rule that holds up
**Identify special accounts by Object ID, never by name.** Display names and UPNs change; Object IDs
do not. RE-009 exists because a break-glass account renamed during an incident must not silently stop
being a break-glass account.
**Require two independent signals for a consequential classification.** The example configuration's
service-account rule requires both a naming convention *and* group membership, so a person whose UPN
happens to start with `svc-` is not classified as a service account.
**Prefer group membership to string matching for anything privileged.** A group is administered,
auditable, and has an owner. A naming convention is a habit.
**Give every rule a description that says why it exists**, not what it does — the conditions already
say what it does. A rule nobody can explain cannot be safely changed, which is why `description` is
required.
## Membership mode
`direct` asks whether the account is a member of the named group itself. `transitive` asks whether it
is a member through any chain of nesting.
Mode is a per-condition choice (RE-007). The three facets — direct, transitive, roles — are retrieved
independently, so mixing modes in one rule set is fully supported. It costs one extra request per
account for each additional facet.
Use `transitive` when the group is a role-holding group that other groups nest into — which is most
Tier 0 groups. Use `direct` when membership is explicitly managed and nesting would be a mistake.
**Do not pin `dataSources.groups.membershipMode` unless you mean to restrict.** Absent means "any mode
is acceptable". Pinning it turns every per-condition override into a `PE-SEM-014` warning, which
trains people to ignore warnings.
## Unknown is not false
If a membership lookup fails, the condition is `Unknown`, not `False`. A `notMemberOf` condition
therefore does **not** become satisfied when the lookup fails.
This is the single most important behaviour in the engine. Without it, a transient Graph outage would
make every privileged account look like a non-member of its Tier 0 group, and a single run would
quietly demote the entire administrative population. `UnknownNotFalse.Tests.ps1` exists solely to
prevent that regression.
The cost is that a failed lookup produces `EvaluationError` rather than a classification. That is the
correct trade: preserving a possibly-stale value is recoverable, and writing a confidently wrong one
is not.
## Nesting
`all` and `any` groups nest to `maxConditionDepth` (default 5, ceiling 10). Beyond the limit the
engine returns `Unknown`, which becomes `EvaluationError` for every account the rule reaches — so a
too-deep rule fails safe rather than silently.
A rule needing more than three levels is usually two rules with different priorities. Depth is
expensive to read and the ordering you would express with nesting is already available for free.
## Changing a rule set
1. Edit the configuration.
2. Validate against the deployed copy so the drift checks actually run:
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -PreviousConfigPath ./deployed/persona-engine.json -ValidateOnly -NonInteractive
```
3. Run the rules against synthetic fixtures.
4. Preview against one account, then against the tenant with `-WhatIf`.
5. Compare the summary's per-rule match counts against the previous run. A rule whose count moved
sharply is either the change you made or a change you did not intend.
6. Raise `configVersion`.
Step 5 is the one people skip. The summary lists every rule including zero-match ones precisely so
that a rule which *stopped* firing is visible, and a rule that stopped firing is the usual signature
of an accidental reorder.
## Disabling versus deleting
Disable rather than delete. A disabled rule still appears in every summary with a zero count, so the
audit trail keeps reporting on it and an operator can see it was deliberately turned off. A deleted
rule is indistinguishable from one that never existed, which is why `PE-SAF-005` flags a deletion
without a version change.
## Testing a rule set without a tenant
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData -ValidateOnly -NonInteractive
```
This runs the real engine against the synthetic fixtures in `tests/TestData/`. The fixtures include
accounts with null and absent properties, mixed casing, a guest, a disabled account, and — most
usefully — two accounts whose membership lookups failed, so `EvaluationError` behaviour is visible
before it happens against a real directory.
Add fixtures for the cases your rule set actually cares about. A fixture that reproduces a real edge
case, sanitized, is worth more than any amount of reasoning about what the engine will probably do.
+217
View File
@@ -0,0 +1,217 @@
# Configuration reference
Every field in `persona-engine.json`, and every finding code the validator can produce.
The authoritative schema is [`config/persona-engine.schema.json`](../config/persona-engine.schema.json)
(JSON Schema draft-07). A working example is
[`config/persona-engine.example.json`](../config/persona-engine.example.json), which is validated by
CI against its own schema — if the example the documentation points at could not pass, every reader's
first run would fail.
## Top level
| Field | Required | Notes |
| --- | --- | --- |
| `configVersion` | yes | Semantic version, `major.minor.patch`. A downgrade is a safety finding. |
| `metadata` | no | `owner`, `changeReference`, `description`. Free-form; not read by the engine. |
| `engine` | yes | Engine behaviour. |
| `dataSources` | yes | Which directory data may be retrieved. |
| `logging` | no | Audit output. |
| `personas` | yes | The declared persona catalogue. |
| `rules` | yes | Ordered business rules. |
## `engine`
| Field | Required | Default | Notes |
| --- | --- | --- | --- |
| `targetAttribute` | yes | — | The single attribute the engine may write. Must be a directory extension property and must appear in `approvedWritableAttributes`. |
| `approvedWritableAttributes` | yes | — | The allow-list. Comparison is **ordinal** — extension property names are case-sensitive in Graph. |
| `maxConditionDepth` | no | `5` | RE-004. Minimum 1, hard ceiling 10. |
| `summaryInterval` | no | `25` | Interim summary frequency. `0` suppresses interim summaries; a final summary always appears. |
| `defaultMembershipMode` | no | `direct` | Mode for membership conditions that do not specify one. |
| `evaluationErrorThreshold` | no | unset | Count of `EvaluationError` results above which the run reports exit code 4. Unset means report, do not fail. |
Setting `evaluationErrorThreshold` to `0` makes a single transient lookup failure fail the run. That
is occasionally what you want; it is rarely what you meant.
## `dataSources`
| Field | Required | Notes |
| --- | --- | --- |
| `groups.enabled` | yes | When false, no membership facet is retrieved. Rules needing it become `EvaluationError`. |
| `groups.membershipMode` | no | Pins a mode globally. **Leave it out unless you mean to restrict** — absent means "any mode is acceptable", and RE-007 makes mode a per-condition choice. Pinning it makes every per-condition override a `PE-SEM-014` warning. |
| `roles.enabled` | yes | Directory role assignments. |
| `roles.includeEligible` | no | PIM-eligible assignments. **Out of scope for v1**; no provider is implemented. |
The engine retrieves only the facets enabled rules actually reference. 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.
## `logging`
| Field | Required | Default | Notes |
| --- | --- | --- | --- |
| `destination` | no | `both` | `file`, `stream`, `both`, or `none`. `stream` writes records to the PowerShell Information stream. |
| `path` | no | — | NDJSON output file. One record per line. |
| `traceConditionValues` | no | `false` | Writes evaluated attribute values into audit records. |
| `acknowledgeConditionTracing` | no | `false` | **Required whenever `traceConditionValues` is true** (VR-003). |
## `personas`
The declared catalogue. A rule assigning a persona absent from this list is a `PE-SEM-010` error — the
catalogue is what stops a typo from writing a new persona value into the directory.
`Unclassified` and `EvaluationError` are processing results and may never be declared or assigned.
## `rules`
| Field | Required | Notes |
| --- | --- | --- |
| `id` | yes | Unique. Appears in every audit record; this is how a decision is traced to its rule. |
| `name` | yes | Human-readable. |
| `description` | yes | Why the rule exists. Required, because a rule nobody can explain cannot be safely changed. |
| `enabled` | yes | Disabled rules are excluded from evaluation but still appear in summaries with zero matches. |
| `priority` | yes | Unique integer. **Lower evaluates first.** |
| `persona` | yes | Must appear in `personas`. |
| `match` | yes | The root condition group. |
| `tags`, `owner`, `changeReference`, `effectiveDate`, `notes`, `testCases` | no | Metadata. `effectiveDate` is **not** evaluated — a date-dependent decision would break determinism. |
Priorities must be unique among enabled rules. The engine breaks ties by rule ID so results stay
deterministic, but the resulting order is an accident rather than a decision, so `PE-SEM-002` blocks it.
## Condition groups and conditions
A group has `operator` (`all` or `any`) and a `conditions` array. Each entry is either another group
or a condition.
| Field | Applies to | Notes |
| --- | --- | --- |
| `type` | all | `property`, `membership`, or `role`. |
| `property` | `property` | One of the supported names below, or an extension property. |
| `operator` | all | See the operator table. |
| `value` | most | Single comparison value. |
| `values` | `in`, `notIn` | Comparison set. |
| `groupObjectIds` | `membership` | Group Object IDs. Names are mutable; IDs are not (RE-009). |
| `roleIds` | `role` | Role **template** IDs, which are stable across tenants. |
| `membershipMode` | `membership` | `direct` or `transitive`, per condition. |
| `caseSensitive` | — | Reserved. Not implemented in v1; the schema accepts the key so a later version needs no breaking change. |
### Supported properties
`AccountObjectId` · `UserPrincipalName` · `DisplayName` · `UserType` · `AccountEnabled` ·
`CompanyName` · `JobTitle` · `Department`
Plus any directory extension property named `extension_<32-hex-app-id>_<name>`. Anything else is
`PE-SEM-015`: unsupported properties are never retrieved, so the condition would compare against a
permanently absent value and quietly never match.
### Operators (RE-005)
| Operator | Applies to | Notes |
| --- | --- | --- |
| `equals`, `notEquals` | property | Case-insensitive (RE-006). |
| `contains`, `notContains` | property | Case-insensitive substring. |
| `startsWith`, `endsWith` | property | Case-insensitive. |
| `matchesRegex` | property | Pattern compiled at validation time. An invalid pattern is `PE-SEM-016`, not a runtime failure. |
| `in`, `notIn` | property | Requires `values`. |
| `isNull`, `isNotNull` | property | Tests presence. **Must not carry a value** — it would be silently ignored (`PE-SEM-009`). |
| `memberOf`, `notMemberOf` | membership, role | Requires `groupObjectIds` or `roleIds`. |
Null and absent properties are treated as empty for ordinary comparisons and never cause an
evaluation failure (FR-012). Intentional null matching uses `isNull` / `isNotNull`.
## Tri-state evaluation
Conditions return `True`, `False`, or `Unknown`. `Unknown` means required data could not be
retrieved, and it propagates:
| Group | Contains | Result |
| --- | --- | --- |
| `all` | any `False` | `False` |
| `all` | only `True` plus at least one `Unknown` | `Unknown` |
| `any` | any `True` | `True` |
| `any` | only `False` plus at least one `Unknown` | `Unknown` |
An `Unknown` at a rule's root makes the account `EvaluationError`: the stored persona is preserved
and no write is attempted (FR-013, FR-014).
## Validation layers
Run in order, stopping at the first that produces `Error` findings. Running semantic checks over a
structurally invalid document yields noise, not signal.
| Layer | Mechanism | Codes |
| --- | --- | --- |
| 1 Syntax | `ConvertFrom-Json` | `PE-SYN-nnn` |
| 2 Schema | `Test-Json -SchemaFile` | `PE-SCH-nnn` |
| 3 Semantic | PowerShell checks | `PE-SEM-nnn` |
| 4 Safety | PowerShell checks | `PE-SAF-nnn` |
Codes are stable. Pipelines and runbooks match on them, so a code is never reused for a different
condition and never renumbered.
### Syntax — `PE-SYN`
| Code | Condition |
| --- | --- |
| `PE-SYN-001` | Configuration file not found, or is not a file |
| `PE-SYN-002` | File exists but could not be read |
| `PE-SYN-003` | File is not valid JSON |
### Schema — `PE-SCH`
| Code | Condition |
| --- | --- |
| `PE-SCH-001` | Document violates the schema |
| `PE-SCH-002` | Schema file not found |
| `PE-SCH-003` | Schema file exists but is not valid JSON Schema |
`PE-SCH-003` exists because of V-5a: `Test-Json` returns `$true` when the schema itself cannot be
parsed. A wrapper trusting the return value would report every configuration as schema-valid against
a schema that never ran.
### Semantic — `PE-SEM` (VR-002)
| Code | Condition | Severity |
| --- | --- | --- |
| `PE-SEM-001` | Duplicate rule ID | Error |
| `PE-SEM-002` | Duplicate priority among enabled rules | Error |
| `PE-SEM-003` | No rules, or no enabled rules | Error |
| `PE-SEM-004` | Blank target attribute | Error |
| `PE-SEM-005` | Target attribute absent from the approved list | Error |
| `PE-SEM-006` | Rule references a disabled data source | Error |
| `PE-SEM-007` | `memberOf` / `notMemberOf` with no group or role IDs | Error |
| `PE-SEM-008` | `in` / `notIn` with no `values` | Error |
| `PE-SEM-009` | `isNull` / `isNotNull` carrying a comparison value | Error |
| `PE-SEM-010` | Persona not in the declared catalogue | Error |
| `PE-SEM-011` | `Unclassified` used as a rule persona | Error |
| `PE-SEM-012` | Nesting deeper than `maxConditionDepth` | Error |
| `PE-SEM-013` | `maxConditionDepth` outside 110 | Error |
| `PE-SEM-014` | Condition mode differs from an explicitly pinned global mode | Warning |
| `PE-SEM-015` | Unsupported property name | Error |
| `PE-SEM-016` | Invalid regular expression | Error |
Several of these are also enforced by the schema. The overlap is deliberate: layer 2 can be bypassed
with `-SchemaPath`, and V-5a showed an unparseable schema passes silently. Anything that can
misclassify a privileged account is checked twice.
### Safety — `PE-SAF` (VR-003)
| Code | Condition | Severity |
| --- | --- | --- |
| `PE-SAF-001` | Blank target attribute | Error enforcing, Warning in preview |
| `PE-SAF-002` | Approved list contains a non-extension attribute | Error |
| `PE-SAF-003` | Enabled rules need data the data sources do not provide | Error |
| `PE-SAF-004` | `configVersion` lower than the deployed version | Error enforcing, Warning in preview |
| `PE-SAF-005` | Rules removed or reordered with no version change | Error enforcing, Warning in preview |
| `PE-SAF-006` | Tracing enabled without acknowledgement | Error |
| `PE-SAF-007` | Save would overwrite an existing configuration with no backup | Error |
`PE-SAF-004` and `PE-SAF-005` need `-PreviousConfigPath`. Without it they are **skipped**, and an
`Information` finding says so — silence would be read as approval.
## Escalation (VR-005)
`Error` blocks execution and saving. `Warning` blocks only under `-TreatWarningsAsErrors`.
`Information` never blocks. Passing `-TreatWarningsAsErrors` does not change a finding's severity;
it changes the caller's tolerance for it.
+136
View File
@@ -0,0 +1,136 @@
# Logging
Structured audit output: what is emitted, where it goes, and what may never appear in it.
The serialized contract is [audit-record.md](../specs/001-persona-engine/contracts/audit-record.md).
This document covers the operational side.
## Format and transport
Newline-delimited JSON (OTD-006). One record per line, UTF-8 without BOM, appended.
Every record passes through a single sink, `Write-PersonaAuditRecord`. Adding a transport — an
approved logging platform, an event hub, a different file layout — is a change to that one function.
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.
| `logging.destination` | Behaviour |
| --- | --- |
| `file` | Appends NDJSON to `logging.path` |
| `stream` | Emits the record object on the PowerShell **Information** stream |
| `both` | Both |
| `none` | Nothing |
`stream` uses the Information stream rather than the success stream deliberately. Audit records on
the success stream would be indistinguishable from a function's return value — the run loop returns
its outcome there — and mixing the two turns one object into an array of several thousand. Capture
them with `-InformationVariable`, or redirect with `6>`.
### Sink failure never ends a run
A full disk or a locked file is an operational problem with the sink, not a reason to abandon a
classification run mid-population and leave the directory half-reconciled. The failure surfaces as a
warning, **once** per run, and processing continues.
Once, not once per user: a run over five thousand accounts with a locked log file should warn once,
or the warning that matters is buried in the noise it generates.
## Record types
| Type | When | Carries |
| --- | --- | --- |
| `RunStart` | Once, after mode is determined | Config path, target attribute, rule counts, whether tracing is on |
| `UserEvent` | Once per processed account | The full decision |
| `Summary` | Every `summaryInterval` accounts, and once at the end | Counters, per-rule match counts, reconciliation result |
| `RunComplete` | Once, in a `finally` block | Final counters, timing, exit code |
| `EngineDefect` | Reconciliation failure, or threshold breach | What went wrong and by how much |
`RunComplete` is written even on a fatal error. A run that died at account 400 of 5,000 leaves a
record 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.
## The common envelope
Every record, every type:
`timestamp` · `recordType` · `runId` · `engineVersion` · `configVersion` · `configurationHash` · `mode`
These appear **first** in each record, so a truncated line still identifies the run that produced it.
`runId` comes from `-CorrelationId` or is generated, and is constant for the run (NFR-005).
`configurationHash` is the SHA-256 of the configuration file bytes — two files differing only in
whitespace are different configurations for audit purposes, and the hash must be reproducible from
the artifact on disk.
## `UserEvent`
100% carry `runId`, `userPrincipalName`, and `accountObjectId` (SC-006). 100% of `Matched` records
carry `matchedRuleId`. `evaluationErrorReason` is non-null exactly when `outcome` is
`EvaluationError`.
`previousValue` is present **only** on `Updated` records, captured at write time. 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. Without it, OTD-010 rollback is impossible retroactively —
no future run can reconstruct what a value used to be.
## What may never appear
Access tokens, `Authorization` headers, client secrets, certificates, credentials, and full Graph
responses.
The guarantee is structural rather than filtered. `New-PersonaAuditRecord` 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.
[`AuditRedaction.Tests.ps1`](../tests/Unit/AuditRedaction.Tests.ps1) asserts this holds even when a
caller attaches a token to the decision result, and scans every serialized record for JWT and Bearer
shapes.
## Approved for logs
User principal name, account object ID, matched rule ID, stored and calculated persona values, run
ID, configuration version and hash, per-rule match counts, timing.
Runtime records naturally contain real UPNs and Object IDs. **No such value may ever be committed to
this repository** (SC-013) — the sanitization scan enforces that on every build.
## Condition tracing
`conditionTrace` is added to a `UserEvent` only when **both** gates are open: the decision result was
built with tracing, and the record was asked to include it. It carries the per-rule result
(`True` / `False` / `Unknown`) and priority.
Tracing widens what the log contains beyond the approved set, so it requires
`logging.acknowledgeConditionTracing` in the same configuration (`PE-SAF-006`). Tracing never changes
a decision — if it could, a debug run would stop being evidence about the real one, and
[`ConditionTrace.Tests.ps1`](../tests/Unit/ConditionTrace.Tests.ps1) asserts the outcome is identical
with and without it.
## Querying
```powershell
# Everything from one run
Get-Content <LOG-OUTPUT-PATH> | ForEach-Object { $_ | ConvertFrom-Json } |
Where-Object runId -eq '<RUN-ID>'
# Accounts a run changed, with what it replaced
Get-Content <LOG-OUTPUT-PATH> | ForEach-Object { $_ | ConvertFrom-Json } |
Where-Object { $_.recordType -eq 'UserEvent' -and $_.action -eq 'Updated' } |
Select-Object userPrincipalName, previousValue, calculatedPersona, matchedRuleId
# Accounts that could not be evaluated, and why
Get-Content <LOG-OUTPUT-PATH> | ForEach-Object { $_ | ConvertFrom-Json } |
Where-Object outcome -eq 'EvaluationError' |
Select-Object userPrincipalName, evaluationErrorReason
# Which rule set produced a given decision
Get-Content <LOG-OUTPUT-PATH> | ForEach-Object { $_ | ConvertFrom-Json } |
Where-Object recordType -eq 'RunStart' |
Select-Object runId, configVersion, configurationHash, mode
```
## Retention
Not set by this engine. Records contain UPNs and Object IDs, so retention is governed by the
organization's identity-data policy rather than by anything in this repository. Decide it before the
first enforcing run, not after.
+168
View File
@@ -0,0 +1,168 @@
# Operations runbook
What to do when the engine is running, and what to do when it should not be.
## Before any run
1. Validate the configuration. It costs seconds and needs no tenant.
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
```
2. Run the rules against synthetic fixtures. This shows what the rule set *does* before it sees a
real account.
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData -ValidateOnly -NonInteractive
```
3. Preview a single user before previewing the tenant.
```bash
pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf -Verbose
```
Never skip step 3. A rule set that behaves correctly against fixtures can still request a property
your tenant does not populate, and finding that out on one account is cheaper than on fifty thousand.
## Reading a run
Per-user lines appear immediately, one per account, colour-coded by action:
| Action | Meaning |
| --- | --- |
| `Unchanged` | Calculated value already matches the stored value. Nothing to do. |
| `WouldUpdate` | A change is proposed. Preview mode, or the per-user gate refused. |
| `Updated` | The attribute was written. |
| `UpdateFailed` | The write was attempted and rejected. Stored value is untouched. |
| `Skipped` | No write is possible: `EvaluationError`, or a blank or unapproved target. |
`Skipped` on every account almost always means the target attribute is blank or unapproved — check
the header line and `PE-SAF-001`.
A summary appears every `summaryInterval` accounts and once at the end, listing **every** rule
including disabled and zero-match ones. A rule that never fired and a rule that is not in the
configuration look identical if zero-match rules are omitted, and that distinction is usually what
you are looking for.
## Exit codes
| Code | Meaning | First thing to check |
| --- | --- | --- |
| 0 | Success | — |
| 1 | Configuration validation failed | The findings printed above it; no connection was attempted |
| 2 | Authentication or authorization failed | Scopes, consent, and whether the account can sign in |
| 3 | User enumeration failed | Graph availability. **No accounts were processed** — a partial population is never used |
| 4 | `evaluationErrorThreshold` exceeded | Group or role endpoint health. Nothing was changed |
| 5 | Reconciliation failed | **An engine defect.** Open an issue with the `EngineDefect` record |
| 6 | Unexpected fatal error | The message and `-Verbose` stack trace |
Exit 5 is never a data condition. Outcomes are assigned by the engine, exactly one per account, so if
`Processed` does not equal `Matched + Unclassified + EvaluationError` the engine lost a user or
double-counted one. Report it rather than re-running.
Exit 4 means the population was classified from data that could not be trusted. Stored values were
preserved, so nothing is damaged — but do not draw conclusions from the run.
## Kill switch
In increasing order of severity. Pick the lowest one that addresses the problem.
### 1. Stop writing — immediate, no deployment
Add `-WhatIf` to the invocation. Reads, evaluation, output, and audit records continue unchanged;
zero write requests are constructed.
### 2. Stop classifying — one configuration change
Set `enabled: false` on every rule and deploy.
> Every account becomes `Unclassified`. **In an enforcing run that proposes clearing every stored
> persona.** Combine with `-WhatIf`, or use option 1 instead, unless clearing is what you want.
### 3. Stop running — Stage B only
Disable the Automation schedule. Nothing is in flight; the next run simply does not start.
### 4. Remove the capability — the one that holds if the code is the problem
Revoke `User.ReadWrite.All` from the execution identity. The engine keeps running and every write
becomes `UpdateFailed`, which is loud, logged, and harmless.
### 5. Remove the path — for a suspected defect in the write path
Remove the write deployment stage from the release pipeline so no build can restore write capability
by accident.
Options 1 through 3 rely on the engine behaving correctly. Options 4 and 5 do not, which is why they
exist.
## Rollback (OTD-010)
Every `Updated` audit record carries `previousValue`, captured **before** the PATCH. That is what
makes rollback possible; reading the value back afterwards would return the new one.
To roll back a run:
1. Find the run's records by `runId`.
2. Select records where `recordType` is `UserEvent` and `action` is `Updated`.
3. For each, write `previousValue` back to `accountObjectId`.
```powershell
# Reads the NDJSON audit file and lists what a rollback would restore.
# Review this output before writing anything back.
Get-Content <LOG-OUTPUT-PATH> |
ForEach-Object { $_ | ConvertFrom-Json } |
Where-Object { $_.runId -eq '<RUN-ID>' -and $_.recordType -eq 'UserEvent' -and $_.action -eq 'Updated' } |
Select-Object accountObjectId, userPrincipalName, previousValue, calculatedPersona
```
> A rollback is itself a directory write and is subject to the same V-4 gate and the same test-account
> restriction as any enforcement run. A rollback tool is **not implemented in v1** — the data needed
> to build one is captured, deliberately, because it cannot be reconstructed retroactively.
If a rollback is genuinely intended at the configuration level, publish it as a **new higher
version** rather than reusing the old number. Two different rule sets sharing one `configVersion`
makes the audit trail unable to tell them apart (`PE-SAF-004`).
## Common situations
**Every account is `EvaluationError`.** A required data source is disabled or unreachable. Check
`dataSources.groups.enabled` and `dataSources.roles.enabled` against what the rules need — `PE-SAF-003`
catches this at validation time, so a run reaching this state usually means validation was bypassed.
**Every account is `Unclassified`.** Either every rule is disabled, or no rule matches. The summary
table distinguishes these: disabled rules are dimmed, zero-match enabled rules show `0`.
**The run is slow.** Check the cache hit ratio via `-Verbose`. Membership lookups dominate: one
account needing both direct and transitive facets plus roles is three requests. Narrowing rules to a
single membership mode roughly halves that.
**A write failed with 403.** Non-retryable by design — retrying would hide a configuration or
authorization defect behind a timeout. Check that the identity holds `User.ReadWrite.All` and that
the target attribute exists on the application registration.
**Audit file sink warnings.** The sink warns once per run and processing continues. A locked or full
log file is an operational problem with the sink, not a reason to abandon a run mid-population and
leave the directory half-reconciled.
## Concurrency
Two concurrent enforcing runs against the same tenant would race on the same attributes. Until the
OTD-009 run-start concurrency check is implemented (T120, Stage B), **the schedule is the lock**: do
not start a manual enforcing run while a scheduled one may be in flight.
Preview runs are read-only and safe to run concurrently.
## What to attach to a bug report
- The exit code
- The `RunComplete` record for the run — it carries the counters, timing, and exit code even when the
run died early
- Any `EngineDefect` record
- The `configVersion` and `configurationHash` from any record
- The rule set, sanitized
Do **not** attach raw audit records containing real UPNs or Object IDs to anything that leaves the
organization. They are approved for internal logs, not for public issue trackers.
+169
View File
@@ -0,0 +1,169 @@
# Security model
What this engine is permitted to do, what it is not, and where the gap between those two is held
open by testing rather than by the platform.
## The central trade-off (OTD-003)
**Microsoft Graph application permissions have no per-property write scope.** An identity granted
`User.ReadWrite.All` can write *any* writable property on *any* user object. It cannot be narrowed to
one extension attribute.
This is not a limitation to be worked around. It is a fact about the platform, recorded here so that
nobody later assumes the directory is enforcing something it is not.
The consequence: **the only thing standing between this engine and every writable user property is
the code in this repository, and the tests that hold it to that.** Every control below exists because
the directory will not refuse a malformed request on our behalf.
## The six compensating controls
All six are mandatory. Each is testable, and each is tested.
| # | Control | Where it lives | Proof |
| --- | --- | --- | --- |
| 1 | The persistence layer accepts only the configured target attribute | `New-PersonaWriteBody` throws for any other name | `WriteBodyRejection.Tests.ps1` |
| 2 | The target must appear in `approvedWritableAttributes` | `Resolve-TargetAttribute` and `New-PersonaWriteBody`, checked twice | `WriteBodyRejection.Tests.ps1` |
| 3 | Validation rejects every other attribute | `PE-SAF-002`, layer 4 | `Safety.Tests.ps1` |
| 4 | One dedicated function builds the request body, and it is the only one | `New-PersonaWriteBody` returns a hashtable whose `Count` is exactly 1 | `WriteBody.Tests.ps1` |
| 5 | Tests inspect the captured request body | Every body issued during a full enforcing run is asserted to have one key | `WriteBody.Tests.ps1` |
| 6 | Code owners and branch policies gate persistence changes | Repository configuration, outside this codebase | Branch protection on `src/Persistence/` |
Control 4 is the load-bearing one. A single construction site makes SC-005 a property of one testable
function rather than a convention every future call site has to remember. `WriteBody.Tests.ps1`
includes a scan asserting that no other file under `src/` builds a PATCH body.
Control 2 is deliberately redundant. Validation runs once at startup against the file; the write
builder checks again on every write against the values actually in hand — so a configuration object
mutated mid-run still cannot widen the blast radius.
### Why comparison is ordinal here and case-insensitive elsewhere
Rule matching is case-insensitive (RE-006), because a rule author should not have to match directory
casing. Attribute approval is **ordinal and case-sensitive**, because 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.
Change detection is also ordinal (FR-015). A stored `employee` against a calculated `Employee` is a
real difference worth correcting, not a formatting quirk.
## Permissions
### Stage A — local, delegated (current)
```powershell
Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'
```
Read-only. Sufficient for every preview run and for closing V-1 (read) and V-3.
The engine requests only the scopes the enabled rules actually need: a configuration with no role
conditions never asks for `RoleManagement.Read.Directory`, and a configuration with no membership
conditions never asks for `GroupMember.Read.All`. Least privilege applies to data as well as to
permissions — properties nothing references are not even added to `$select`.
**Stage A3 (delegated write) adds `User.ReadWrite.All` and targets purpose-created test accounts
only.** Under delegated authentication the write runs *as the operator*, which makes the compensating
controls more important rather than less: the directory sees the operator's own permissions, not a
narrowed service identity.
> **Never sign in with a standing privileged account for a write run.** A Global Administrator
> session invalidates V-3 as evidence and removes every practical limit on what a defect could reach.
### Stage B — Azure Automation, application permissions (deferred)
`User.Read.All`, `GroupMember.Read.All`, `RoleManagement.Read.Directory`, and — for enforcement —
`User.ReadWrite.All`, granted to a **managed identity**. No client secret, ever, in source control or
in a runbook parameter.
Deferred, not waived: no Automation account is available. V-3b and V-5b remain open.
## The persona attribute (OTD-001)
A **directory (schema) extension property**, registered on an application registration and addressable
as `extension_<appId>_<name>`.
Two alternatives were rejected for concrete reasons:
- **`extensionAttribute1..15`** — unavailable for cloud writes on objects that are, or ever were,
synchronized from on-premises, and on Exchange-originated objects. A classification engine that
silently cannot write to a subset of the population is worse than one that cannot write at all.
- **Custom security attributes** — not exposed to the dynamic group membership engine, which defeats
the purpose: the persona exists so that Conditional Access can be targeted through dynamic groups.
`PE-SAF-002` rejects any approved attribute that is not shaped like a directory extension property.
Built-in attributes such as `department` or `jobTitle` are excluded **even when the operator holds
permission to write them** — they are authoritative in the sync source or in HR, and this engine does
not own them.
## Data handling
**Approved for logs**: user principal name, account object ID, matched rule ID, stored and calculated
persona values, run ID, configuration version and hash.
**Never logged, under any setting**: access tokens, `Authorization` headers, client secrets,
certificates, credentials, or full Graph responses.
The guarantee is structural rather than filtered. `New-PersonaAuditRecord` 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. `AuditRedaction.Tests.ps1` asserts this holds even
when a caller actively attaches a token to the decision result.
### Condition tracing
`logging.traceConditionValues` writes evaluated attribute values into audit records, widening what
the log contains beyond the approved set. It requires `logging.acknowledgeConditionTracing` in the
same configuration, or validation fails with `PE-SAF-006`.
The acknowledgement lives in the configuration rather than in a command-line switch on purpose: a
flag passed at a console is invisible to review, while a field in the configuration appears in the
diff of the change that enables tracing, next to the person who approved it.
## The no-write control
`-WhatIf` is the only approved no-write control.
`-Debug` does **not** imply read-only. A `-Debug` run without `-WhatIf` writes, and
`ShouldProcessGate.Tests.ps1` asserts that it does — because an operator who believed otherwise would
reach for `-Debug` as a safety measure and get an enforcing run. The same holds for `-Verbose`.
`ShouldProcessGate.Tests.ps1` also asserts that the entry script declares no `-Preview`, `-NoWrite`,
`-ReadOnly`, or `-DryRun` parameter, and never reads `$WhatIfPreference`.
## Verification gates
| Item | Status | Blocks |
| --- | --- | --- |
| V-1 read half | Open — needs a tenant | Confidence in the read path across origin types |
| V-1 write half | Open — needs test accounts | Enforcement |
| V-2 dynamic group + CA | Open | Declaring the persona useful |
| V-3 non-privileged `-WhatIf` run | Open — needs a tenant | Stage A2 sign-off |
| V-3b managed-identity scopes | Deferred | Stage B |
| **V-4 security sign-off on these controls** | **Open** | **All enforcement (T101)** |
| V-5a `Test-Json` behaviour | **Closed** — see [V-5a.md](../specs/001-persona-engine/verification/V-5a.md) | Layer 2 implementation |
| V-5b `Test-Json` in Automation | Deferred | Stage B |
**V-4 gates enforcement.** No write run against anything other than purpose-created test accounts
until it is recorded in `specs/001-persona-engine/verification/V-4.md`.
## Kill switch
In increasing order of severity — see [OperationsRunbook.md](OperationsRunbook.md) for the procedure:
1. Run with `-WhatIf`.
2. Set every rule to `enabled: false` and deploy.
3. Disable the Automation schedule (Stage B).
4. Revoke `User.ReadWrite.All` from the execution identity.
5. Remove the write deployment stage.
Steps 4 and 5 are the ones that hold if the code itself is the problem.
## Sanitization (SC-013)
No organization name, real domain, tenant or subscription ID, real UPN or Object ID, real group or
role identifier, environment-specific attribute name, or any secret may appear in any tracked file.
Placeholders only.
[`tests/Test-Sanitization.ps1`](../tests/Test-Sanitization.ps1) scans every tracked file on every
build. Runtime records naturally contain real UPNs and Object IDs — approved for logs — but no such
value is ever committed.
+181
View File
@@ -0,0 +1,181 @@
# Persona Engine - test stage
#
# Separate from validate.yml because the two answer different questions. Validation
# asks "is this repository well-formed?" and gates every pull request. This asks "does
# the engine behave correctly?" and publishes evidence.
#
# The Integration suite is present but disabled by default. It needs a delegated
# read-only connection to a real tenant (Stage A2), which a shared build agent cannot
# hold without storing credentials - and NFR-006 puts no secrets in source control.
# Enable it only on an agent with an interactive or workload-identity connection,
# never by adding a secret to this file.
trigger: none
pr: none
schedules:
- cron: '0 6 * * 1-5'
displayName: 'Weekday morning regression'
branches:
include:
- main
always: false
pool:
vmImage: windows-latest
parameters:
- name: runIntegration
displayName: 'Run the Integration suite (requires a tenant connection)'
type: boolean
default: false
variables:
pesterVersion: '5.6.1'
stages:
- stage: OfflineSuites
displayName: 'Offline and safety suites'
jobs:
- job: Offline
displayName: 'Offline suite'
steps:
- checkout: self
- task: PowerShell@2
displayName: 'Install pinned Pester'
inputs:
pwsh: true
targetType: inline
script: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module Pester -RequiredVersion $(pesterVersion) -Force -SkipPublisherCheck -Scope CurrentUser
- task: PowerShell@2
displayName: 'Offline suite'
inputs:
pwsh: true
targetType: inline
script: |
$ErrorActionPreference = 'Stop'
$config = & ./tests/PesterConfiguration.ps1 -Suite Offline
$config.Run.Exit = $false
$config.Run.PassThru = $true
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = './testResults.offline.xml'
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = @('./src')
$config.CodeCoverage.OutputPath = './coverage.offline.xml'
$config.Output.Verbosity = 'Normal'
$result = Invoke-Pester -Configuration $config
if ($result.TotalCount -eq 0) { throw 'The offline suite ran no tests.' }
if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) offline test(s) failed." }
- task: PublishTestResults@2
displayName: 'Publish offline results'
condition: succeededOrFailed()
inputs:
testResultsFormat: NUnit
testResultsFiles: './testResults.offline.xml'
testRunTitle: 'Persona Engine - offline suite'
- task: PublishCodeCoverageResults@2
displayName: 'Publish coverage'
condition: succeededOrFailed()
inputs:
summaryFileLocation: './coverage.offline.xml'
- job: Safety
displayName: 'Safety suite'
dependsOn: Offline
steps:
- checkout: self
- task: PowerShell@2
displayName: 'Install pinned Pester'
inputs:
pwsh: true
targetType: inline
script: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module Pester -RequiredVersion $(pesterVersion) -Force -SkipPublisherCheck -Scope CurrentUser
- task: PowerShell@2
displayName: 'Safety suite (SC-002, SC-004, SC-005)'
inputs:
pwsh: true
targetType: inline
script: |
$ErrorActionPreference = 'Stop'
$config = & ./tests/PesterConfiguration.ps1 -Suite Safety
$config.Run.Exit = $false
$config.Run.PassThru = $true
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = './testResults.safety.xml'
$config.Output.Verbosity = 'Detailed'
$result = Invoke-Pester -Configuration $config
# The safety suite running zero tests is the most dangerous possible
# green build: it is exactly what a mis-tagged file looks like, and the
# assertions it drops are the zero-write and single-attribute ones.
if ($result.TotalCount -eq 0) { throw 'The safety suite ran no tests. Check the Safety tag.' }
if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) safety test(s) failed. Do not deploy." }
Write-Host "Safety suite: $($result.PassedCount) passed."
- task: PublishTestResults@2
displayName: 'Publish safety results'
condition: succeededOrFailed()
inputs:
testResultsFormat: NUnit
testResultsFiles: './testResults.safety.xml'
testRunTitle: 'Persona Engine - safety suite'
- stage: IntegrationSuite
displayName: 'Integration suite (tenant required)'
dependsOn: OfflineSuites
condition: and(succeeded(), eq('${{ parameters.runIntegration }}', true))
jobs:
- job: Integration
displayName: 'Integration suite'
steps:
- checkout: self
- task: PowerShell@2
displayName: 'Integration suite'
inputs:
pwsh: true
targetType: inline
script: |
$ErrorActionPreference = 'Stop'
# No credential handling here by design. The agent must already hold a
# delegated read-only connection; if it does not, this fails loudly
# rather than prompting or falling back to a stored secret.
if (-not (Get-Module -ListAvailable Microsoft.Graph.Authentication)) {
throw 'Microsoft.Graph.Authentication is not available on this agent.'
}
$config = & ./tests/PesterConfiguration.ps1 -Suite Integration
$config.Run.Exit = $false
$config.Run.PassThru = $true
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = './testResults.integration.xml'
$result = Invoke-Pester -Configuration $config
if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) integration test(s) failed." }
- task: PublishTestResults@2
displayName: 'Publish integration results'
condition: succeededOrFailed()
inputs:
testResultsFormat: NUnit
testResultsFiles: './testResults.integration.xml'
testRunTitle: 'Persona Engine - integration suite'
+175
View File
@@ -0,0 +1,175 @@
# Persona Engine - validation stage
#
# Everything here runs with no tenant, no credentials, and no network (SC-008). That
# is the point: a pipeline that needs a directory connection to tell you a rule file
# is wrong cannot run on every pull request, and the check that only runs sometimes is
# the one that stops catching things.
#
# Gate order is deliberate, cheapest and most categorical first. Sanitization runs
# before anything else because a leaked identifier in a branch is a problem whether or
# not the code compiles, and every later stage prints file contents into build logs.
trigger:
branches:
include:
- main
paths:
include:
- src/*
- tests/*
- config/*
- pipelines/*
- Invoke-PersonaEngine.ps1
- Edit-PersonaEngineConfig.ps1
- PersonaEngine.psd1
- PersonaEngine.psm1
pr:
branches:
include:
- main
pool:
vmImage: windows-latest
variables:
# Pinned rather than latest. A validation stage that changes behaviour when an
# upstream module publishes is not a gate, it is a coin flip.
pesterVersion: '5.6.1'
analyzerVersion: '1.22.0'
steps:
- checkout: self
fetchDepth: 0
- task: PowerShell@2
displayName: 'Gate 1 - Sanitization (SC-013)'
inputs:
pwsh: true
filePath: tests/Test-Sanitization.ps1
failOnStderr: false
- task: PowerShell@2
displayName: 'Install pinned analysis modules'
inputs:
pwsh: true
targetType: inline
script: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module Pester -RequiredVersion $(pesterVersion) -Force -SkipPublisherCheck -Scope CurrentUser
Install-Module PSScriptAnalyzer -RequiredVersion $(analyzerVersion) -Force -Scope CurrentUser
- task: PowerShell@2
displayName: 'Gate 2 - PSScriptAnalyzer'
inputs:
pwsh: true
targetType: inline
script: |
$ErrorActionPreference = 'Stop'
$findings = Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1
if ($findings) {
$findings | Format-Table -AutoSize | Out-String | Write-Host
}
$blocking = @($findings | Where-Object Severity -in 'Error', 'Warning')
if ($blocking.Count -gt 0) {
throw "PSScriptAnalyzer reported $($blocking.Count) blocking finding(s)."
}
- task: PowerShell@2
displayName: 'Gate 3 - Engine purity (Principle IV)'
inputs:
pwsh: true
filePath: tests/Test-EnginePurity.ps1
- task: PowerShell@2
displayName: 'Gate 4 - Shipped schema is valid JSON Schema'
inputs:
pwsh: true
targetType: inline
script: |
$ErrorActionPreference = 'Stop'
# V-5a: Test-Json returns $true when the schema itself cannot be parsed, so a
# broken schema would let every later check pass while validating nothing.
# This stage exists solely to catch that.
$errors = $null
$null = '{}' | Test-Json -SchemaFile ./config/persona-engine.schema.json -ErrorAction SilentlyContinue -ErrorVariable errors
$unusable = @($errors | Where-Object { $_.Exception.Message -match 'Cannot parse the JSON schema' })
if ($unusable.Count -gt 0) {
throw 'config/persona-engine.schema.json is not valid JSON Schema. No configuration can be schema-validated until it is repaired.'
}
# The contract copy and the shipped copy must stay identical, or a rule author
# reading the contract validates against a different schema than the engine.
$shipped = (Get-FileHash ./config/persona-engine.schema.json -Algorithm SHA256).Hash
$contract = (Get-FileHash ./specs/001-persona-engine/contracts/persona-engine.schema.json -Algorithm SHA256).Hash
if ($shipped -ne $contract) {
throw 'config/persona-engine.schema.json and the contract copy have diverged.'
}
- task: PowerShell@2
displayName: 'Gate 5 - Example configuration passes all four layers'
inputs:
pwsh: true
targetType: inline
script: |
$ErrorActionPreference = 'Stop'
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive
if ($LASTEXITCODE -ne 0) { throw "The shipped example configuration failed validation with exit code $LASTEXITCODE." }
- task: PowerShell@2
displayName: 'Gate 6 - Offline Pester suite (SC-008)'
inputs:
pwsh: true
targetType: inline
script: |
$ErrorActionPreference = 'Stop'
$config = & ./tests/PesterConfiguration.ps1 -Suite Offline
$config.Run.Exit = $false
$config.Run.PassThru = $true
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = './testResults.offline.xml'
$config.Output.Verbosity = 'Normal'
$result = Invoke-Pester -Configuration $config
# Asserted, not assumed. A configuration change that silently filtered every
# test out would otherwise report a green pipeline over zero coverage.
if ($result.TotalCount -eq 0) { throw 'The offline suite ran no tests.' }
if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) offline test(s) failed." }
Write-Host "Offline suite: $($result.PassedCount) passed, $($result.FailedCount) failed."
- task: PublishTestResults@2
displayName: 'Publish offline test results'
condition: succeededOrFailed()
inputs:
testResultsFormat: NUnit
testResultsFiles: './testResults.offline.xml'
testRunTitle: 'Persona Engine - offline suite'
- task: PowerShell@2
displayName: 'Gate 7 - No Graph module was loaded (SC-008)'
inputs:
pwsh: true
targetType: inline
script: |
# The proof that the offline suite is genuinely offline. If a test ever
# imports the Graph SDK, the "runs with no tenant" claim quietly stops being
# true and nobody notices until an air-gapped build fails.
$config = & ./tests/PesterConfiguration.ps1 -Suite Offline
$config.Run.PassThru = $true
$config.Output.Verbosity = 'None'
$null = Invoke-Pester -Configuration $config
$graph = Get-Module | Where-Object Name -like 'Microsoft.Graph*'
if ($graph) {
throw "A Graph module was loaded during the offline suite: $($graph.Name -join ', ')"
}
Write-Host 'No Graph module was loaded. SC-008 holds.'
@@ -0,0 +1,127 @@
# Contract: Structured Audit Records
Serialized form of the audit trail (FR-022, NFR-005, Principle V). Format is newline-delimited JSON,
one record per line (OTD-006). All emission goes through a single `Write-PersonaAuditRecord` sink so
a future transport can be added without touching call sites.
## Common envelope
Every record carries:
| Field | Type | Notes |
| --- | --- | --- |
| `timestamp` | string (ISO 8601 UTC) | |
| `recordType` | string | `RunStart`, `UserEvent`, `Summary`, `RunComplete`, `EngineDefect` |
| `runId` | string (GUID) | Constant for the run (NFR-005) |
| `engineVersion` | string | |
| `configVersion` | string | |
| `configurationHash` | string | SHA-256 of the configuration file |
| `mode` | string | `Preview` or `Enforce` |
## `UserEvent`
Emitted once per processed user. 100% of these records carry `runId`, `userPrincipalName`, and
`accountObjectId` (SC-006).
```json
{
"timestamp": "2026-08-20T09:14:02.187Z",
"recordType": "UserEvent",
"runId": "<RUN-ID>",
"engineVersion": "1.0.0",
"configVersion": "1.4.0",
"configurationHash": "<SHA256>",
"mode": "Preview",
"accountObjectId": "<ACCOUNT-OBJECT-ID>",
"userPrincipalName": "<USER>@<PRIMARY-DOMAIN>",
"outcome": "Matched",
"matchedRuleId": "RULE-0100-TIER0",
"storedPersona": "Employee",
"calculatedPersona": "Tier0-Admin",
"previousValue": "Employee",
"action": "WouldUpdate",
"rulesEvaluated": 4,
"durationMs": 38,
"evaluationErrorReason": null
}
```
**Field requirements**
| Field | Requirement |
| --- | --- |
| `outcome` | Exactly one of `Matched`, `Unclassified`, `EvaluationError` (SC-001) |
| `matchedRuleId` | Non-null on every `Matched` record (SC-006) |
| `previousValue` | **Captured at write time on every `Updated` record.** This is what makes OTD-010 rollback possible; omitting it in v1 makes rollback impossible retroactively |
| `evaluationErrorReason` | Non-null exactly when `outcome` is `EvaluationError` |
**Prohibited fields**: access tokens, `Authorization` headers, secrets, and full Graph responses
MUST NEVER appear in any record.
**Condition tracing**: a `conditionTrace` array may be added **only** under `-Debug`
(`logging.traceConditionValues`). It contains diagnostic condition-level values and is therefore
gated by acknowledgement (VR-003).
## `Summary`
Emitted every `summaryInterval` users and once at completion.
```json
{
"recordType": "Summary",
"runId": "<RUN-ID>",
"summaryType": "Interim",
"processed": 250,
"matched": 231,
"unclassified": 14,
"evaluationError": 5,
"unchanged": 220,
"wouldUpdate": 11,
"updated": 0,
"updateFailed": 0,
"reconciliationPassed": true,
"ruleCounts": [
{ "ruleId": "RULE-0100-TIER0", "name": "Tier 0 administrators", "enabled": true, "matches": 3 }
]
}
```
`reconciliationPassed` is `processed == matched + unclassified + evaluationError` (FR-021, SC-007).
`ruleCounts` lists **all** business rules, including disabled and zero-match rules — an absent rule
is indistinguishable from a rule that never fired, and operators need that distinction.
## `RunComplete`
```json
{
"recordType": "RunComplete",
"runId": "<RUN-ID>",
"startedUtc": "2026-08-20T09:00:00.000Z",
"completedUtc": "2026-08-20T09:12:44.913Z",
"durationMs": 764913,
"processed": 4820,
"matched": 4611,
"unclassified": 190,
"evaluationError": 19,
"unchanged": 4400,
"wouldUpdate": 211,
"updated": 0,
"updateFailed": 0,
"reconciliationPassed": true,
"exitCode": 0
}
```
## `EngineDefect`
Emitted when reconciliation fails (FR-021) or an internal invariant is violated. Severity is always
`Error`. A failed reconciliation is a defect in the engine, not a property of the data, and is
reported as such rather than being folded into ordinary counters.
## Sanitization (SC-013)
Committed artifacts — this contract, examples, fixtures, tests, and documentation — use placeholders
only: `<ORGANIZATION-NAME>`, `<PRIMARY-DOMAIN>`, `<TENANT-ID>`, `<ACCOUNT-OBJECT-ID>`,
`<GROUP-OBJECT-ID>`, `<APPROVED-PERSONA-ATTRIBUTE-NAME>`, `<AUTOMATION-ACCOUNT-NAME>`,
`<LOG-OUTPUT-PATH>`, `<RUN-ID>`. Runtime records naturally contain real UPNs and Object IDs — which
are approved for logs — but no such value may ever be committed to this repository.
@@ -0,0 +1,90 @@
# Contract: `Edit-PersonaEngineConfig.ps1`
Configuration validation, interactive editing, synthetic rule testing, and pipeline enforcement
(FR-023 FR-026).
## Signature
```powershell
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory)][string] $ConfigPath,
[Parameter()][switch] $ValidateOnly,
[Parameter()][switch] $NonInteractive,
[Parameter()][string] $SchemaPath,
[Parameter()][string] $OutputPath,
[Parameter()][switch] $TreatWarningsAsErrors,
[Parameter()][string] $TestDataPath
)
```
## Parameter contract
| Parameter | Behaviour |
| --- | --- |
| `-ConfigPath` | Required. Configuration to validate or edit. |
| `-ValidateOnly` | Validate and report; never enter the editor. |
| `-NonInteractive` | Pipeline mode. **MUST NOT prompt and MUST NOT hang** (SC-010). Returns an exit code. |
| `-SchemaPath` | Override the shipped schema. |
| `-OutputPath` | Save-As target; leaves the input file untouched. |
| `-TreatWarningsAsErrors` | Escalates `Warning` findings to blocking (VR-005). |
| `-TestDataPath` | Synthetic sample users for offline rule testing (FR-025). No tenant connectivity. |
## Validation layers (VR-001, ordered, fail-fast between layers)
| Layer | Mechanism | Example findings |
| --- | --- | --- |
| 1. Syntax | `ConvertFrom-Json` | Malformed JSON |
| 2. Schema | `Test-Json -SchemaFile` (draft-07, OTD-005) | Missing required field, wrong type, bad enum |
| 3. Semantic | PowerShell checks | Every condition in VR-002 |
| 4. Safety | PowerShell checks | Every condition in VR-003 |
A layer that produces `Error` findings stops the sequence — running semantic checks over a
structurally invalid document yields noise, not signal.
### Layer 2 error-handling requirement
`Test-Json` reports schema failure by writing errors rather than returning `$false` in several
PowerShell versions. The wrapper MUST invoke it with `-ErrorAction SilentlyContinue -ErrorVariable`
and convert collected errors into `ValidationFinding` objects, so layer 2 emits the same structured
shape as every other layer (VR-004).
## Finding contract
Every finding carries `Severity`, `Code`, `Location` (JSON path or rule ID), `Description`,
`SuggestedResolution`, and `Layer`. Finding codes are stable and namespaced by layer:
```text
PE-SYN-nnn syntax
PE-SCH-nnn schema
PE-SEM-nnn semantic (one code per VR-002 condition)
PE-SAF-nnn safety (one code per VR-003 condition)
```
Stability matters: pipelines and runbooks will match on these codes.
## Exit codes
| Code | Condition |
| --- | --- |
| `0` | Valid; no blocking findings |
| `1` | One or more `Error` findings |
| `2` | `Warning` findings present with `-TreatWarningsAsErrors` |
| `3` | Configuration file not found or unreadable |
| `4` | Schema file not found or itself invalid |
## Save contract (FR-026)
1. Re-validate the edited document in full.
2. Block the save on any `Error` finding.
3. Write a timestamped backup — or require `-OutputPath` — before replacing an existing file.
4. Overwriting the only valid configuration without a backup is a safety finding (VR-003), not
merely a warning.
## Invariants (test-asserted)
| Invariant | Assertion |
| --- | --- |
| Non-interactive never prompts | Runs to completion with stdin closed; no prompt, no hang (SC-010) |
| Every VR-002/VR-003 condition detected | One test per condition, each asserting code, severity, and location (SC-009) |
| Offline | Full validation and synthetic rule testing complete with no network access (SC-008) |
@@ -0,0 +1,90 @@
# Contract: `Invoke-PersonaEngine.ps1`
The engine entry point. Retrieval, evaluation, reporting, and controlled persistence.
## Signature
```powershell
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory)][string] $ConfigPath,
[Parameter()][guid] $UserObjectId,
[Parameter()][string] $OutputPath,
[Parameter()][guid] $CorrelationId
)
```
`SupportsShouldProcess` supplies `-WhatIf` and `-Confirm`. `-Verbose` and `-Debug` are common
parameters and are **not** declared.
## Parameter contract
| Parameter | Required | Behaviour |
| --- | --- | --- |
| `-ConfigPath` | Yes | Path to the JSON configuration. Validated through all four layers before any connection is made (FR-002). |
| `-WhatIf` | No | **The approved no-write control.** Reads, evaluation, comparison, console output, summaries, and audit records all behave identically to enforcement; zero write requests are issued (FR-017, SC-004). |
| `-UserObjectId` | No | Single-user execution for validation. Skips enumeration; retrieves one user. |
| `-OutputPath` | No | Overrides the configured audit output path where permitted. |
| `-CorrelationId` | No | Supplied run identifier. Generated when absent. Appears on every audit record. |
| `-Verbose` | No | Operational detail. **MUST NOT** alter write behaviour. |
| `-Debug` | No | Enables condition-value tracing (Principle V). **MUST NOT** imply read-only — a `-Debug` run without `-WhatIf` writes. |
## Mode determination
```text
$PSCmdlet.ShouldProcess() returns $false -> Preview mode -> no write request constructed or sent
$PSCmdlet.ShouldProcess() returns $true -> Enforce mode -> write permitted, subject to FR-016
```
Mode MUST be derived from `ShouldProcess` alone. A separate boolean "preview" flag is prohibited —
two sources of truth for the write gate is precisely the defect class Principle III exists to
prevent.
## Write gate (FR-016)
A write is issued only when **all** hold:
1. Evaluation completed successfully (`Outcome != EvaluationError`).
2. `CalculatedPersona != StoredPersona` (ordinal comparison, case-sensitive for change detection).
3. The target attribute is non-blank and present in `approvedWritableAttributes`.
4. `ShouldProcess` returned `$true` for this user.
Failing any of these yields `Unchanged`, `WouldUpdate`, or `Skipped` — never a silent write.
## Output contract
- **Per user, immediately after evaluation** (FR-018, SC-012): one console line carrying UPN,
Account Object ID, outcome, matched rule ID, stored value, calculated value, and action.
- **Every `summaryInterval` users** (FR-019): a table of all business rules with match counts, plus
outcome totals and a reconciliation check.
- **At completion**: a final summary regardless of interval, including when the interval is `0`
(FR-020).
- **Reconciliation** at every summary: `Processed = Matched + Unclassified + EvaluationError`
(FR-021). A mismatch is logged as an engine defect, at `Error` severity.
- **Audit records**: see [audit-record.md](audit-record.md).
## Exit codes
| Code | Condition |
| --- | --- |
| `0` | Successful run; no fatal processing errors |
| `1` | Configuration validation failure |
| `2` | Authentication / authorization failure |
| `3` | User enumeration failure |
| `4` | Fatal required data-provider failure |
| `5` | Reconciliation failure |
| `6` | Unexpected fatal engine error |
Every code MUST be reachable and returned for its documented condition (SC-011). A per-user
`EvaluationError` does **not** by itself terminate the run; the final status reports the affected
count and applies `evaluationErrorThreshold` when configured.
## Invariants (test-asserted)
| Invariant | Assertion |
| --- | --- |
| Zero writes under `-WhatIf` | The write adapter is mocked; call count is `0` over a full synthetic population (SC-004) |
| Single-attribute body | Every captured request body has exactly one key, equal to `engine.targetAttribute` (SC-005) |
| Idempotence | Second consecutive run over unchanged input issues zero writes (SC-002) |
| Determinism | Same fixture set, shuffled input order, identical results (SC-003) |
| Exactly one outcome | Every processed user appears in exactly one outcome bucket (SC-001) |
@@ -0,0 +1,124 @@
# Contract: Directory Data Provider
The only component permitted to talk to Microsoft Graph. Implements OTD-002, OTD-004, and OTD-007.
Everything below the normalization boundary is invisible to the rule engine (Principle IV).
**Transport**: `Invoke-MgGraphRequest` from `Microsoft.Graph.Authentication` (OTD-004). No
resource-specific SDK modules.
## Authentication (FR-003)
| Function | Environment | Mechanism |
| --- | --- | --- |
| `Connect-PersonaGraphManagedIdentity` | Azure Automation | `Connect-MgGraph -Identity` |
| `Connect-PersonaGraphInteractive` | Local development | `Connect-MgGraph -Scopes <read scopes>` |
Both return an opaque connection handle. No token, header, or secret is ever returned to a caller,
logged, or written to an audit record.
## Permissions (OTD-002)
| Function | Application permission |
| --- | --- |
| `Get-PersonaUsers` | `User.Read.All` (or `User.ReadWrite.All` for the enforcement identity) |
| `Get-PersonaGroupMembership` | `GroupMember.Read.All` |
| `Get-PersonaDirectoryRoles` | `RoleManagement.Read.Directory` |
| `Set-UserPersonaAttribute` | `User.ReadWrite.All` |
`Directory.Read.All` is prohibited — materially broader than the three read permissions combined.
## Read operations
### `Get-PersonaUsers`
```text
GET /v1.0/users?$select=<fields>&$top=999
```
- `$select` carries the FR-005 baseline (`id`, `userPrincipalName`, `displayName`, `userType`,
`accountEnabled`, `companyName`, `jobTitle`, `department`) plus the configured target attribute and
any property referenced by an enabled rule. Unused properties are not requested.
- The persona directory extension is selected by its full name,
`extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>`.
- Pagination follows `@odata.nextLink` until absent (FR-004). A truncated enumeration MUST raise —
never return a partial population as if complete.
- `-UserObjectId` switches to `GET /v1.0/users/{id}?$select=...`.
### `Get-PersonaGroupMembership`
```text
GET /v1.0/users/{id}/memberOf # direct
GET /v1.0/users/{id}/transitiveMemberOf # transitive
```
Mode comes from the condition, falling back to `engine.defaultMembershipMode` (RE-007).
**Return contract**: always a `MembershipRecord`. On failure it returns a record with
`RetrievalSucceeded = $false` and a `FailureReason` — it MUST NOT return an empty list, and MUST NOT
throw past the per-user boundary. This single behaviour is what makes FR-013 work: unknown membership
becomes `EvaluationError`, never a false non-match.
### `Get-PersonaDirectoryRoles`
```text
GET /v1.0/roleManagement/directory/roleAssignments?$filter=principalId eq '<id>'
```
Eligible (PIM) assignments are out of scope for v1 unless authorization is confirmed.
### Caching
Group and role data reusable across users is cached for the run's lifetime (NFR-002). The cache is
keyed by group or role Object ID and is **never** persisted between runs — a stale cache would make
results depend on run history, breaking Principle I.
## Write operation
### `New-PersonaWriteBody`
The **only** function permitted to construct a write body (OTD-003 control 3).
```powershell
# Returns exactly one key.
@{ "<engine.targetAttribute>" = "<CalculatedPersona>" }
```
Contract:
- Throws if the attribute name is not `engine.targetAttribute`.
- Throws if the attribute is absent from `approvedWritableAttributes`.
- Returns a hashtable whose `Count` is exactly `1`. Tests assert on this directly (SC-005).
### `Set-UserPersonaAttribute`
```text
PATCH /v1.0/users/{id}
Content-Type: application/json
<body from New-PersonaWriteBody>
```
- Callable **only** when `ShouldProcess` returned `$true`. Under `-WhatIf` this function is not
reached — the caller does not construct a request at all (SC-004). Preview mode is an absence of a
call, not a suppressed call.
- A failure returns `UpdateFailed` for that user and does not terminate the run.
## Retry policy (OTD-007)
| Aspect | Value |
| --- | --- |
| Retryable | 429, 500, 502, 503, 504, transport timeout |
| Never retried | 400, 401, 403, 404, 409 |
| `Retry-After` | Honoured when present; overrides computed backoff |
| Attempts | Max 5 |
| Backoff | Exponential from 1s, full jitter, per-delay cap 60s |
| Logging | Attempt number, status code, and delay on every retry |
Exhausted retries on **required** data produce `EvaluationError` for the affected user (FR-013).
Exhausted retries during enumeration are fatal (exit code `3`).
## Prohibited in this layer
- Logging tokens, `Authorization` headers, or full response bodies (Principle V).
- Returning raw Graph objects past `ConvertTo-Persona*Record`.
- Any reference to rule, persona, or condition concepts — this layer moves data, it does not decide.
@@ -0,0 +1,278 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://example.invalid/persona-engine.schema.json",
"title": "Persona Engine Configuration",
"description": "Draft-07 by decision OTD-005: validated with the built-in Test-Json -SchemaFile cmdlet, whose validator reliably supports draft-04/06/07 only. Do not introduce 2019-09 or 2020-12 constructs. This schema is validation layer 2 of 4; semantic rules (VR-002) and safety rules (VR-003) are enforced in PowerShell, not here.",
"type": "object",
"required": ["configVersion", "engine", "dataSources", "personas", "rules"],
"additionalProperties": false,
"properties": {
"configVersion": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$",
"description": "Semantic version of this configuration. A downgrade is a safety violation (VR-003)."
},
"metadata": {
"type": "object",
"additionalProperties": true,
"properties": {
"owner": { "type": "string" },
"changeReference": { "type": "string" },
"description": { "type": "string" }
}
},
"engine": {
"type": "object",
"required": ["targetAttribute", "approvedWritableAttributes"],
"additionalProperties": false,
"properties": {
"targetAttribute": {
"type": "string",
"minLength": 1,
"description": "The single attribute the engine may write. Must also appear in approvedWritableAttributes (semantic layer). Example placeholder: extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>"
},
"approvedWritableAttributes": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"maxConditionDepth": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 5,
"description": "RE-004. The ceiling of 10 is a hard limit; the configured value may be lower."
},
"summaryInterval": {
"type": "integer",
"minimum": 0,
"default": 25,
"description": "FR-020. Zero suppresses interim summaries; a final summary is always produced."
},
"defaultMembershipMode": {
"type": "string",
"enum": ["direct", "transitive"],
"default": "direct"
},
"evaluationErrorThreshold": {
"type": "integer",
"minimum": 0,
"description": "Optional. Count of EvaluationError results above which the run reports failure."
}
}
},
"dataSources": {
"type": "object",
"required": ["groups", "roles"],
"additionalProperties": false,
"properties": {
"groups": {
"type": "object",
"required": ["enabled"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"membershipMode": { "type": "string", "enum": ["direct", "transitive"] }
}
},
"roles": {
"type": "object",
"required": ["enabled"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"includeEligible": {
"type": "boolean",
"default": false,
"description": "Out of scope for v1 unless authorization is confirmed and the provider is implemented."
}
}
}
}
},
"logging": {
"type": "object",
"additionalProperties": false,
"properties": {
"destination": {
"type": "string",
"enum": ["file", "stream", "both"],
"default": "both",
"description": "OTD-006. Additional transports are added behind the sink function, not by widening this enum without a version change."
},
"path": {
"type": "string",
"description": "Placeholder in committed artifacts: <LOG-OUTPUT-PATH>"
},
"traceConditionValues": {
"type": "boolean",
"default": false,
"description": "Diagnostic only. Enabling this without explicit acknowledgement is a safety finding (VR-003)."
},
"acknowledgeConditionTracing": {
"type": "boolean",
"default": false,
"description": "Explicit acknowledgement that condition-value tracing writes evaluated attribute values into audit records. Required by VR-003 whenever traceConditionValues is true. Kept in the configuration rather than passed as a command-line flag so the acknowledgement is reviewable in the change that enables tracing."
}
}
},
"personas": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"not": { "enum": ["EvaluationError"] }
},
"description": "Defined persona catalogue. EvaluationError is an execution result and must never be declared as a persona."
},
"rules": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/rule" }
}
},
"definitions": {
"rule": {
"type": "object",
"required": ["id", "name", "description", "enabled", "priority", "persona", "match"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1 },
"description": { "type": "string", "minLength": 1 },
"enabled": { "type": "boolean" },
"priority": { "type": "integer", "minimum": 0 },
"persona": {
"type": "string",
"minLength": 1,
"not": { "enum": ["Unclassified", "EvaluationError"] },
"description": "Unclassified is a processing result, not a rule outcome (VR-002)."
},
"match": { "$ref": "#/definitions/conditionGroup" },
"tags": { "type": "array", "items": { "type": "string" } },
"owner": { "type": "string" },
"changeReference": { "type": "string" },
"effectiveDate": {
"type": "string",
"format": "date",
"description": "Metadata only in v1. It must not gate evaluation — a date-dependent decision would break determinism (Principle I)."
},
"notes": { "type": "string" },
"testCases": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "expectedMatch"],
"additionalProperties": true,
"properties": {
"name": { "type": "string" },
"expectedMatch": { "type": "boolean" },
"user": { "type": "object" }
}
}
}
}
},
"conditionGroup": {
"type": "object",
"required": ["operator", "conditions"],
"additionalProperties": false,
"properties": {
"operator": { "type": "string", "enum": ["all", "any"] },
"conditions": {
"type": "array",
"minItems": 1,
"items": {
"anyOf": [
{ "$ref": "#/definitions/conditionGroup" },
{ "$ref": "#/definitions/condition" }
]
}
}
}
},
"condition": {
"type": "object",
"required": ["type", "operator"],
"additionalProperties": false,
"properties": {
"type": { "type": "string", "enum": ["property", "membership", "role"] },
"property": { "type": "string", "minLength": 1 },
"operator": {
"type": "string",
"enum": [
"equals", "notEquals", "contains", "notContains",
"startsWith", "endsWith", "matchesRegex",
"in", "notIn", "isNull", "isNotNull",
"memberOf", "notMemberOf"
]
},
"value": { "type": "string" },
"values": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string" }
},
"groupObjectIds": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/definitions/guid" }
},
"roleIds": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"membershipMode": { "type": "string", "enum": ["direct", "transitive"] },
"caseSensitive": {
"type": "boolean",
"default": false,
"description": "Reserved. Condition-level case sensitivity is out of scope for v1; the schema accepts the key so a later version does not require a breaking change."
}
},
"allOf": [
{
"if": { "properties": { "type": { "const": "property" } }, "required": ["type"] },
"then": { "required": ["property"] }
},
{
"if": { "properties": { "type": { "const": "membership" } }, "required": ["type"] },
"then": { "required": ["groupObjectIds"] }
},
{
"if": { "properties": { "type": { "const": "role" } }, "required": ["type"] },
"then": { "required": ["roleIds"] }
},
{
"if": {
"properties": { "operator": { "enum": ["in", "notIn"] } },
"required": ["operator"]
},
"then": { "required": ["values"] }
},
{
"if": {
"properties": { "operator": { "enum": ["isNull", "isNotNull"] } },
"required": ["operator"]
},
"then": {
"allOf": [
{ "not": { "required": ["value"] } },
{ "not": { "required": ["values"] } }
]
}
}
]
},
"guid": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
}
}
}
+228
View File
@@ -0,0 +1,228 @@
# Data Model: Persona Engine
**Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md)
Normalized in-memory contracts. These are the objects the rule engine sees. Per Principle IV the
rule engine MUST NOT receive raw directory responses — normalization is the boundary.
All types are plain `PSCustomObject` shapes. Field types are PowerShell types.
---
## UserRecord
Produced by `ConvertTo-PersonaUserRecord`. Consumed by the rule engine, presentation, and audit.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `AccountObjectId` | `string` (GUID) | Yes | Immutable identity key. Approved for logs. |
| `UserPrincipalName` | `string` | Yes | Approved for logs. |
| `DisplayName` | `string` | No | Diagnostics only. |
| `UserType` | `string` | No | `Member` / `Guest`. |
| `AccountEnabled` | `bool` | Yes | Disabled accounts remain in scope (FR-011). |
| `Properties` | `hashtable` | Yes | Case-insensitive map of evaluable property name → value. Populated from FR-005 selection plus any property a rule references. Absent property returns `$null`. |
| `StoredPersona` | `string` | No | Current value of the target attribute; `$null` when unset. |
| `Membership` | `MembershipRecord` | Yes | Never `$null`; an unattempted lookup is represented by an empty record with `RetrievalSucceeded = $true` and `Mode = 'None'`. |
**Validation rules**
- `AccountObjectId` and `UserPrincipalName` MUST be non-empty; a record failing this is an
upstream defect and MUST raise, not silently skip.
- `Properties` lookups are case-insensitive (RE-006).
- A `$null` or absent value in `Properties` is treated as empty for ordinary string comparisons and
MUST NOT fail evaluation (FR-012).
---
## MembershipRecord
Produced by `ConvertTo-PersonaMembershipRecord`. This type carries the most safety-critical fields
in the model.
**Revised 2026-08-20 during implementation.** The original design held a single `Mode` field
(`Direct` / `Transitive` / `None`) alongside one `GroupObjectIds` set. That cannot satisfy RE-007,
which makes membership mode a **per-condition** choice: a rule set may legitimately ask for
transitive membership in one rule and direct membership in another, and a single-mode record can
only answer one of them — every user became an `EvaluationError` on the other. The defect was
caught by running the shipped example configuration, which mixes both modes, against the fixtures.
The record now holds three independently-retrieved facets.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `DirectGroupObjectIds` | `string[]` | Yes | May be empty. |
| `DirectRetrieved` | `bool` | Yes | **`$false` means "unknown", never "not a member".** |
| `DirectFailureReason` | `string` | No | Populated only when `DirectRetrieved` is `$false`. |
| `TransitiveGroupObjectIds` | `string[]` | Yes | May be empty. |
| `TransitiveRetrieved` | `bool` | Yes | Same semantics as `DirectRetrieved`. |
| `TransitiveFailureReason` | `string` | No | |
| `DirectoryRoleIds` | `string[]` | Yes | May be empty. |
| `RolesRetrieved` | `bool` | Yes | Same semantics. |
| `RolesFailureReason` | `string` | No | |
**Validation rules**
- Every `*Retrieved` flag defaults to `$false`. A condition MUST read the flag for the facet it
actually queries, and a `$false` MUST yield `Unknown`, propagating to `EvaluationError` (FR-013).
- Facets are independent: a failed transitive lookup MUST NOT make direct-membership conditions
unevaluable. Collapsing them would turn one slow endpoint into a tenant-wide outage.
- An empty identifier collection with its facet `Retrieved = $true` is a legitimate "member of
nothing" and evaluates normally.
- A facet MUST NOT be both retrieved and carry a failure reason; the constructor throws.
- Mode selection is exact. A condition asking for transitive membership MUST NOT be answered from
direct data (false negatives on nested groups) and vice versa (false positives).
---
## BusinessRule
Deserialized from configuration. Never constructed in source (Principle II).
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `Id` | `string` | Yes | Unique across the rule set (VR-002). |
| `Name` | `string` | Yes | |
| `Description` | `string` | Yes | |
| `Enabled` | `bool` | Yes | Disabled rules are skipped and excluded from the enabled count. |
| `Priority` | `int` | Yes | Unique; lower evaluates first (RE-002). |
| `Persona` | `string` | Yes | MUST be a defined persona; MUST NOT be `Unclassified` or `EvaluationError` (VR-002). |
| `Match` | `ConditionGroup` | Yes | Root condition group. |
| `Tags` | `string[]` | No | |
| `Owner` | `string` | No | |
| `ChangeReference` | `string` | No | |
| `EffectiveDate` | `string` | No | Metadata only in v1 — MUST NOT gate evaluation, as a date-dependent decision would break Principle I. |
| `Notes` | `string` | No | |
| `TestCases` | `object[]` | No | Consumed by the editor's synthetic testing (FR-025). |
---
## ConditionGroup / Condition
Recursive structure bounded by the configured depth (RE-004: default 5, min 1, ceiling 10).
**ConditionGroup**
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `Operator` | `string` | Yes | `all` or `any`. |
| `Conditions` | `(Condition\|ConditionGroup)[]` | Yes | MUST be non-empty. |
**Condition (leaf)**
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `Type` | `string` | Yes | `property`, `membership`, or `role`. |
| `Property` | `string` | For `property` | MUST be a supported property name (VR-002). |
| `Operator` | `string` | Yes | One of RE-005. |
| `Value` | `string` | Conditional | Required for comparison operators; MUST be absent for `isNull` / `isNotNull` (VR-002). |
| `Values` | `string[]` | Conditional | Required for `in` / `notIn`. |
| `GroupObjectIds` | `string[]` | For `membership` | MUST be non-empty (VR-002). |
| `RoleIds` | `string[]` | For `role` | MUST be non-empty. |
| `MembershipMode` | `string` | No | `direct` or `transitive`; defaults to the engine setting (RE-007). |
**Evaluation result values**: every condition evaluates to `True`, `False`, or **`Unknown`**.
`Unknown` is what makes FR-013 expressible.
**Propagation rules** (these are the whole safety argument — implement exactly):
| Group | Contains `Unknown` | Result |
| --- | --- | --- |
| `all` | plus any `False` | `False` — a definite non-match wins; the unknown cannot rescue it |
| `all` | plus only `True` | `Unknown` |
| `any` | plus any `True` | `True` — a definite match wins |
| `any` | plus only `False` | `Unknown` |
An `Unknown` at the rule root yields `EvaluationError` for that user.
---
## PersonaDecisionResult
Produced by `Resolve-UserPersona`. The engine's authoritative per-user output.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `AccountObjectId` | `string` | Yes | |
| `UserPrincipalName` | `string` | Yes | |
| `Outcome` | `string` | Yes | `Matched`, `Unclassified`, or `EvaluationError` — exactly one (SC-001). |
| `MatchedRuleId` | `string` | When `Matched` | `$null` otherwise. |
| `CalculatedPersona` | `string` | Yes | The persona, `Unclassified`, or `$null` when `EvaluationError`. |
| `StoredPersona` | `string` | No | Copied from the `UserRecord`. |
| `Action` | `string` | Yes | `Unchanged`, `WouldUpdate`, `Updated`, `UpdateFailed`, or `Skipped`. |
| `EvaluationErrorReason` | `string` | When `EvaluationError` | |
| `RulesEvaluated` | `int` | Yes | Count until first match or exhaustion. |
| `DurationMs` | `int` | Yes | Per-user timing (NFR-002). |
| `ConditionTrace` | `object[]` | No | Populated only under `-Debug` (Principle V). |
**State transitions for `Action`**
```text
EvaluationError ─────────────────────────────► Skipped (FR-014, no write ever)
Calculated == Stored ────────────────────────► Unchanged
Calculated != Stored, preview mode ──────────► WouldUpdate (FR-017, no request issued)
Calculated != Stored, enforce, write ok ─────► Updated
Calculated != Stored, enforce, write fails ──► UpdateFailed
```
`Unclassified` follows the same comparison path as any other calculated value — it is a legitimate
value to write if the configuration approves it, and is reported distinctly either way.
---
## Configuration
The complete ordered decision process. Structure is normative in
[contracts/persona-engine.schema.json](contracts/persona-engine.schema.json).
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `configVersion` | `string` | Yes | Downgrade is a safety violation (VR-003). |
| `engine.targetAttribute` | `string` | Yes | MUST appear in `approvedWritableAttributes`. |
| `engine.approvedWritableAttributes` | `string[]` | Yes | MUST be non-empty. |
| `engine.maxConditionDepth` | `int` | No | Default 5, min 1, max 10 (RE-004). |
| `engine.summaryInterval` | `int` | No | Default 25; `0` suppresses interim summaries (FR-020). |
| `engine.defaultMembershipMode` | `string` | No | `direct` or `transitive`. |
| `dataSources.groups.enabled` | `bool` | Yes | Enabled group rules with this `false` is a safety violation (VR-003). |
| `dataSources.roles.enabled` | `bool` | Yes | |
| `personas` | `string[]` | Yes | Defined persona catalogue. `EvaluationError` MUST NOT appear. |
| `logging.*` | `object` | No | Destination and path (OTD-006). |
| `rules` | `BusinessRule[]` | Yes | MUST contain at least one enabled rule (VR-002). |
**Derived at load**: `ConfigurationHash` (SHA-256 of the canonical file bytes) — recorded on every
run record (NFR-005).
---
## ValidationFinding
Produced by all four validation layers (VR-004).
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `Severity` | `string` | Yes | `Error`, `Warning`, `Information`. |
| `Code` | `string` | Yes | Stable finding code, e.g. `PE-SEM-012`. |
| `Location` | `string` | Yes | JSON path or rule ID. |
| `Description` | `string` | Yes | |
| `SuggestedResolution` | `string` | Yes | |
| `Layer` | `string` | Yes | `Syntax`, `Schema`, `Semantic`, `Safety`. |
`Error` blocks execution and saving; `Warning` blocks only under `-TreatWarningsAsErrors` (VR-005).
---
## RunRecord
One per execution. See [contracts/audit-record.md](contracts/audit-record.md) for the serialized
form.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `RunId` | `string` (GUID) | Yes | Supplied via `-CorrelationId` or generated. |
| `StartedUtc` / `CompletedUtc` | `datetime` | Yes | |
| `Mode` | `string` | Yes | `Preview` or `Enforce`. |
| `EngineVersion` | `string` | Yes | |
| `ConfigVersion` / `ConfigurationHash` | `string` | Yes | |
| `Processed` / `Matched` / `Unclassified` / `EvaluationError` | `int` | Yes | Reconciliation: `Processed = Matched + Unclassified + EvaluationError` (FR-021). |
| `Unchanged` / `WouldUpdate` / `Updated` / `UpdateFailed` | `int` | Yes | |
| `ExitCode` | `int` | Yes | Per the CLI contract. |
A failed reconciliation MUST be logged as an engine defect, not merely reported.
+202
View File
@@ -0,0 +1,202 @@
# Implementation Plan: Persona Engine
**Branch**: `main` (feature directory `001-persona-engine`) | **Date**: 2026-08-20 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/001-persona-engine/spec.md`
## Summary
Deterministic, configuration-driven persona classification for Microsoft Entra ID user objects. The
engine enumerates in-scope users, evaluates each against an ordered JSON rule set, assigns exactly
one persona, and updates a single approved directory attribute only when the calculated value
differs from the stored value.
**Technical approach**: a PowerShell 7 module (`PersonaEngine`) whose rule engine is a pure function
over normalized records, with Graph access, persistence, and presentation isolated behind adapters.
Directory access uses `Invoke-MgGraphRequest` (direct REST over the `Microsoft.Graph.Authentication`
module) so the write body is explicitly constructed and test-assertable. The persona value is stored
in a **directory (schema) extension** on the user object, consumed downstream by dynamic membership
groups. Configuration is validated with the built-in `Test-Json -SchemaFile` against a draft-07
schema. See [research.md](research.md) for the decisions and their rationale.
## Delivery Staging
**Constraint (2026-08-20)**: no Azure Automation account is available. All development and testing
proceeds on a local PowerShell 7 workstation using **user accounts and delegated authentication**.
This changes sequencing, not architecture. The adapter boundaries that make the engine testable
offline (Principle IV) are the same boundaries that make the Automation runtime a late, additive
step — so the deferral costs nothing structurally.
| Stage | Environment | Auth | Status |
| --- | --- | --- | --- |
| **A1** — offline | Local PS7, synthetic fixtures | None | Available now. Covers the rule engine, all four validation layers, and the safety suites. No tenant, no network. |
| **A2** — connected read-only | Local PS7, tenant | Delegated (`Connect-MgGraph -Scopes`) | Available now. Covers enumeration, membership, roles, normalization, presentation, reconciliation, and `-WhatIf`. |
| **A3** — connected write | Local PS7, **test accounts only** | Delegated | Gated on V-4. Test accounts only — the baseline's read-only-during-early-development assumption still stands for the general population. |
| **B** — Automation | Azure Automation PS7 | Managed identity | **Deferred.** Additive: a second authentication adapter, a runbook wrapper, and a schedule. |
**Consequences, stated plainly:**
1. **v1 cannot be declared complete while Stage B is deferred.** The Definition of Done requires an
Azure Automation PowerShell 7 run to pass. Deferring it does not violate the constitution — it
defers *completion*. The correct milestone to claim in the meantime is "Stage A complete", not
"v1 done". Do not quietly redefine done.
2. **`Connect-PersonaGraphManagedIdentity` will ship unexercised.** `Connect-MgGraph -Identity`
cannot run on a workstation. The mitigation is to keep the authentication adapter's surface
minimal — one function, returning the same handle shape as the interactive path, with no
engine-visible difference — so that the untested code is a few lines rather than a subsystem.
3. **Delegated authorization behaves differently from application permissions.** Effective access is
the intersection of the requested scope and the signed-in user's directory roles. This makes V-3
*more* meaningful when run as an ordinary user account, and meaningless when run as a Global
Administrator. See research.md V-3.
4. **Automation-specific risk stays open**: runtime PowerShell version, module availability, and
sandbox behaviour are unverified until Stage B. The one-module dependency decision (OTD-004) is
what keeps that risk small.
## Technical Context
**Language/Version**: PowerShell 7.4 locally. The Automation runtime version is unverified and
remains so until Stage B (verification item V-5b in research.md). Avoid any construct newer than
PS 7.2 so the eventual Automation runtime is not a constraint discovered late.
**Primary Dependencies**: `Microsoft.Graph.Authentication` (token acquisition and
`Invoke-MgGraphRequest`) is the only runtime dependency. `Pester` 5.x and `PSScriptAnalyzer` are
development/CI-only. No full Microsoft Graph SDK dependency — see OTD-004. Module availability in
the Automation sandbox is unverified until Stage B.
**Storage**: JSON configuration file on disk; no database. Persona values live in the directory
itself. Audit output is newline-delimited JSON to a file plus the Automation output stream.
**Testing**: Pester 5.x. Unit and rule-engine suites run fully offline against synthetic fixtures
(SC-008); integration suites require a read-only tenant identity, satisfied in Stage A2 by a
delegated connection; safety suites assert zero writes under `-WhatIf` (SC-004) and single-attribute
write bodies (SC-005). The safety suites mock the write adapter, so they are fully available now and
are **not** gated on Stage A3 or B — the zero-write guarantee is proven against the adapter contract,
not against a tenant.
**Target Platform**: PowerShell 7 on a local workstation (Stages A1A3). The Azure Automation
PowerShell 7 runtime remains the eventual production target but is out of the current stage.
**Project Type**: PowerShell module plus two CLI entry-point scripts.
**Performance Goals**: None fixed. NFR-002 explicitly defers a hard target until representative
tenant testing. The plan requires per-user and total duration to be recorded from the first
connected run so a baseline exists before any target is set.
**Constraints**: Rule engine must be free of Graph, authentication, Automation, and console
dependencies (Principle IV). `-WhatIf` must issue zero writes (Principle III). Write payloads carry
exactly one attribute (Principle III). All artifacts sanitized to placeholders (Principle V,
SC-013).
**Scale/Scope**: In-scope population size is tenant-specific and unknown at planning time. Full
enumeration with pagination is the v1 processing model (OTD-008); delta processing is deferred. The
read-only pilot establishes the population size and run duration baseline.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.*
Evaluated against [constitution.md](../../.specify/memory/constitution.md) v1.0.0.
| Gate | Principle | Pre-research | Post-design | Notes |
| --- | --- | --- | --- | --- |
| Deterministic, single-persona result | I (NON-NEGOTIABLE) | PASS | PASS | Ordered priority evaluation, first-match stop, no clock/random/unordered inputs in the engine. Rejecting `extensionAttributeN` (research OTD-001) removes a population-dependent failure mode that would have broken determinism across a hybrid population. |
| Configuration-driven rules | II | PASS | PASS | No persona, priority, group ID, role ID, or attribute name in source. Four-layer validation ordering preserved in the config contract. |
| Fail-safe, idempotent persistence | III (NON-NEGOTIABLE) | PASS | PASS | `EvaluationError` preserves stored value; `SupportsShouldProcess` on both write paths; changed-values-only comparison; single-attribute body construction isolated in one function. |
| Pure rule engine, offline-tested first | IV | PASS | PASS | Rule engine depends only on normalized records. Build order enforced in the task sequencing below; persistence adapter is last. |
| Explainable, sanitized observability | V | PASS | PASS | Run ID, UPN, Account Object ID, and matched rule ID on every user event; condition-value tracing gated behind `-Debug`; placeholders only in all artifacts. |
**Security and least-privilege constraints**: PASS with a mandatory condition. Research OTD-003
establishes that Microsoft Graph application permissions **cannot** be scoped to an individual user
attribute for the selected mechanism. The constitution anticipates exactly this outcome and makes
the compensating controls mandatory rather than optional; they are carried into the design as
testable requirements (see the persistence contract). This is a documented and approved-by-design
condition, not a constitution violation. Security approval of the compensating controls is a gate
before enforcement, per the Development Workflow section.
**Automation deferral (Stage B)**: PASS. Every principle is satisfiable on a local workstation —
determinism, configuration-driven rules, fail-safe persistence, engine purity, and observability are
all properties of the code, not of the hosting environment. Two constitution items are *deferred, not
waived*: the Definition of Done's Azure Automation PowerShell 7 run, and the release-pipeline stages
that deploy to it. Both are recorded in the Delivery Staging table and gate the v1 completion claim.
**Result**: no unjustified violations. Complexity Tracking is empty.
## Project Structure
### Documentation (this feature)
```text
specs/001-persona-engine/
├── plan.md # This file
├── research.md # Phase 0 output — OTD-001..010 decisions
├── data-model.md # Phase 1 output — entity contracts
├── quickstart.md # Phase 1 output — validation scenarios
├── contracts/ # Phase 1 output
│ ├── persona-engine.schema.json # Configuration JSON Schema (draft-07)
│ ├── cli-invoke-persona-engine.md # Engine CLI contract
│ ├── cli-edit-persona-engine-config.md # Editor CLI contract
│ ├── graph-data-provider.md # Directory read/write contract
│ └── audit-record.md # Structured log record contracts
└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here)
```
### Source Code (repository root)
```text
PersonaEngine.psd1 # Module manifest
PersonaEngine.psm1 # Module loader
Invoke-PersonaEngine.ps1 # Engine entry point (CmdletBinding, SupportsShouldProcess)
Edit-PersonaEngineConfig.ps1 # Configuration validation / editor entry point
config/
├── persona-engine.example.json # Placeholder-only example
└── persona-engine.schema.json # Shipped schema (from contracts/)
src/
├── Configuration/ # Import-PersonaConfiguration, Test-PersonaConfiguration, Resolve-TargetAttribute
├── Authentication/ # Connect-PersonaGraphInteractive, Connect-PersonaGraphManagedIdentity
├── DataProviders/ # Get-PersonaUsers, Get-PersonaGroupMembership, Get-PersonaDirectoryRoles
├── Normalization/ # ConvertTo-PersonaUserRecord, ConvertTo-PersonaMembershipRecord
├── RuleEngine/ # Test-PersonaCondition, Test-PersonaConditionGroup, Test-PersonaRule,
│ # Resolve-UserPersona <-- no Graph/auth/console dependency
├── Persistence/ # Compare-PersonaValue, New-PersonaWriteBody, Set-UserPersonaAttribute
├── Presentation/ # Write-UserPersonaResult, Write-PersonaSummary
└── Audit/ # New-PersonaAuditRecord, Export-PersonaRunReport
tests/
├── Unit/ # Per-function offline tests
├── RuleEngine/ # Rule evaluation matrix against synthetic fixtures
├── Configuration/ # Schema, semantic (VR-002), and safety (VR-003) validation
├── Integration/ # Read-only tenant tests
├── Safety/ # SC-004 zero-write, SC-005 single-attribute-body assertions
└── TestData/ # Obviously fictional synthetic users, memberships, configs
docs/ # Architecture, BusinessRules, ConfigurationReference, Logging,
# SecurityModel, OperationsRunbook
pipelines/ # validate.yml, test.yml, release.yml
```
**Structure Decision**: single PowerShell module with two CLI entry points, matching the layout
already published in [README.md](../../README.md). The directory split is the enforcement mechanism
for Principle IV — `src/RuleEngine/` may import nothing from `src/Authentication/`,
`src/DataProviders/`, `src/Persistence/`, or `src/Presentation/`, and a CI check asserts this.
### Build order (Principle IV, non-negotiable sequencing)
1. Normalized record contracts and synthetic fixtures.
2. Pure rule engine + offline Pester suite (no tenant connectivity).
3. Configuration import, four-layer validation, and non-interactive pipeline mode.
4. `Edit-PersonaEngineConfig.ps1` interactive editor and synthetic rule testing.
5. Graph authentication and **read** adapters; normalization wiring.
6. Presentation, summaries, reconciliation, and structured audit output.
7. Persistence adapter **last**, with `ShouldProcess` and the zero-write/single-attribute suites.
Steps 14 are Stage A1 (offline). Steps 56 are Stage A2 (delegated read-only). Step 7 is built and
fully unit-tested in Stage A1/A2 against a mocked adapter, and only *exercised against the tenant* in
Stage A3, behind V-4. Adding the managed-identity adapter and runbook wrapper is Stage B and touches
nothing in steps 17 — that is the test of whether the boundaries were drawn correctly.
## Complexity Tracking
> No Constitution Check violations. This section is intentionally empty.
+213
View File
@@ -0,0 +1,213 @@
# Quickstart: Persona Engine Validation
**Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md)
Runnable scenarios that prove the feature works. Ordered by the build sequence in
[plan.md](plan.md) — each stage is validatable before the next exists. Scenarios 13 require no
tenant, no credentials, and no network.
Structural details live in [data-model.md](data-model.md) and [contracts/](contracts/); this guide
does not repeat them.
## Prerequisites
**Current constraint**: no Azure Automation account. Everything runs locally on PowerShell 7 with
user accounts and delegated authentication. Scenario 5 is deferred; Scenarios 14 and 6 are all
available now.
| Scenario | Stage | Requirement |
| --- | --- | --- |
| 13 (offline) | A1 | PowerShell 7.4, Pester 5.x, PSScriptAnalyzer. No tenant, no network. |
| 4 (read-only) | A2 | An app registration with admin consent for the three delegated scopes, and a **non-privileged** user account to sign in with. Closes V-1 (read), V-3. |
| 5 (automation) | B | **Deferred** — no Automation account available. Closes V-3b, V-5b when it lands. |
| 6 (enforcement) | A3 | Delegated `User.ReadWrite.All`, **written security sign-off (V-4)**, reviewed `-WhatIf` evidence, and **purpose-created test accounts** as the write targets. |
### Local connection
```powershell
Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'
```
Sign in as an ordinary user account, not a Global Administrator — see the caution in Scenario 4.
---
## Scenario 1 — Rule engine determinism, offline
Proves SC-001, SC-003, SC-008 and the `Unknown` propagation table in
[data-model.md](data-model.md).
```bash
pwsh -NoProfile -Command "Invoke-Pester ./tests/RuleEngine -Output Detailed"
```
**Expected**: all pass with no network access. Specifically:
- Every synthetic user yields exactly one outcome.
- Shuffling fixture order changes nothing.
- A rule matching at priority 10 wins over one matching at 20, and evaluation stops.
- A `MembershipRecord` with `RetrievalSucceeded = $false` yields `EvaluationError`, never a
non-match.
- Depth beyond `maxConditionDepth` is rejected rather than silently truncated.
**Disconnect the network and re-run.** Identical results, or SC-008 is not met.
---
## Scenario 2 — Configuration validation
Proves SC-009, SC-010 and the four-layer ordering.
```bash
pwsh -NoProfile -File ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive
```
**Expected**: exit code `0`, no findings.
Then run the invalid-configuration corpus in `tests/TestData/InvalidConfigs/` — one file per VR-002
and VR-003 condition:
```bash
pwsh -NoProfile -Command "Invoke-Pester ./tests/Configuration -Output Detailed"
```
**Expected**: each file produces its documented finding code, severity, and location; blocking
findings return a non-zero exit code with **no prompt and no hang** (SC-010).
---
## Scenario 3 — Safety invariants
Proves SC-004 and SC-005 with the write adapter mocked. This suite is the reason the persistence
adapter is built last.
```bash
pwsh -NoProfile -Command "Invoke-Pester ./tests/Safety -Output Detailed"
```
**Expected**:
- Full synthetic population under `-WhatIf`: write adapter call count is exactly `0` — asserted, not
inspected (SC-004).
- Every captured request body has exactly one key, equal to `engine.targetAttribute` (SC-005).
- `New-PersonaWriteBody` throws for any other attribute name and for an attribute absent from
`approvedWritableAttributes`.
- A `-Debug` run **without** `-WhatIf` still reaches the write path — `-Debug` is not a safety
control.
---
## Scenario 4 — Read-only tenant preview (User Story 1)
The first connected run. Uses a read-only identity, so it is safe by construction rather than by
correct behaviour.
> **Sign in as a non-privileged account.** V-3 asks whether the three scopes are *sufficient*.
> A Global Administrator answers yes regardless — the scope narrows the token, but the account's
> directory roles still grant broad read access, so the run succeeds whether or not the permission
> set is correct. Running this as GA produces a green result that means nothing.
```bash
pwsh -NoProfile -File ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf -Verbose
```
**Expected**:
- A result line appears for every in-scope user, visible before the next user is processed (SC-012).
- Differences report as `WouldUpdate` with stored value, calculated value, and matched rule ID.
- Interim summaries at the configured interval; a final summary always; reconciliation passes at
every summary (SC-007).
- Zero write requests — confirm independently in the Entra sign-in and audit logs, not only from
console output.
Single-user check first, before the full population:
```bash
pwsh -NoProfile -File ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf
```
**Also close V-1 here**: run the single-user check against a cloud-only user, a currently-synced
user, and a formerly-synced user. Confirm the persona extension **reads** on all three. The write
half of V-1 is closed in Scenario 6.
**Idempotence check** (SC-002): run twice unchanged. The second run reports the same counts and zero
additional proposed changes.
---
## Scenario 5 — Azure Automation PowerShell 7 *(deferred — Stage B)*
**Not runnable in the current stage.** No Automation account is available. Recorded here so it is
not lost, and so the Stage B entry cost stays visible.
Proves NFR-001, NFR-008 and closes V-3b and V-5b.
1. Import the module and publish the runbook with the schedule **disabled**.
2. Run the runbook with `-WhatIf` using the managed identity.
3. Record the runtime's exact PowerShell version.
4. Confirm `Test-Json -SchemaFile` behaves as observed locally in V-5a — its error-reporting
behaviour varies by version, and the layer-2 wrapper depends on it (OTD-005). **Run this first**;
it is the cheapest item with the highest chance of surprising you.
5. Confirm the three read scopes work as *application* permissions on the managed identity (V-3b).
**Expected**: the run completes with only `Microsoft.Graph.Authentication` imported, and output
matches the equivalent local `-WhatIf` run.
**Until this scenario passes, v1 is not complete.** The Definition of Done requires an Automation
PowerShell 7 run. Stage A completion is a real milestone and worth claiming — but it is not v1.
---
## Scenario 6 — Enforcement (User Story 8)
**Gated.** Do not run until all of these hold:
- [ ] V-4 security sign-off on the OTD-003 compensating controls, in writing
- [ ] `-WhatIf` impact evidence from Scenario 4 reviewed and approved
- [ ] Scenarios 15 passing
- [ ] Kill switch and rollback procedure documented
- [ ] OTD-001 OTD-005 closed
```bash
pwsh -NoProfile -File ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json
```
**Expected**:
- Only changed values are written; unchanged users produce no request (SC-002).
- Every write body contains exactly one attribute (SC-005).
- Every `Updated` audit record carries `previousValue` — without it, rollback is impossible
retroactively (OTD-010).
- `EvaluationError` users are skipped with their stored persona intact (FR-014).
**Closes V-1 (write half)** and **V-2**: confirm the dynamic membership group built on the persona
extension populates, and that a Conditional Access policy assigned to that group applies.
---
## Verification item coverage
| Item | Closed by | Available now? |
| --- | --- | --- |
| V-1 | Scenario 4 (read) + Scenario 6 (write) | Yes |
| V-2 | Scenario 6 | Yes |
| V-3 | Scenario 4, as a non-privileged account | Yes |
| V-3b | Scenario 5 | **No — Stage B** |
| V-4 | Out-of-band security review — **gate on Scenario 6** | Yes (a conversation, not a tenant) |
| V-4a | Investigation; no scenario | Yes |
| V-5a | Scenario 2, behaviour pinned in a unit test | Yes |
| V-5b | Scenario 5 | **No — Stage B** |
## Exit code check (SC-011)
Every documented exit code must be reachable. Cover them deliberately rather than incidentally:
| Code | How to trigger |
| --- | --- |
| `0` | Scenario 4 |
| `1` | Any invalid configuration from Scenario 2 |
| `2` | Run with an unauthorized or expired identity |
| `3` | Fault injection on enumeration |
| `4` | Fault injection on a required data provider |
| `5` | Fault injection on the counter path (reconciliation defect) |
| `6` | Fault injection on an unhandled engine path |
+322
View File
@@ -0,0 +1,322 @@
# Phase 0 Research: Persona Engine
**Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md)
Resolves the Clarification Register in [spec.md](spec.md). OTD-001 through OTD-005 are
**persistence-blocking** and are decided here. OTD-006 through OTD-010 receive provisional decisions
sufficient to plan implementation.
Every decision below that depends on a tenant-specific fact carries a **verification item (V-n)**.
Per the constitution, attribute-level write authorization "MUST be verified, never assumed" — the
decisions state what the product documentation says, and the V-items state what the team must prove
in its own tenant before enforcement is enabled.
---
## OTD-001 — Persona attribute mechanism
**Decision**: Store the persona in a **directory (schema) extension** single-valued string property
on the `user` resource, registered against a dedicated application registration in the tenant. The
property is referenced as `extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>`.
**Rationale**:
- **Writable via Graph for cloud-mastered users** with an ordinary `PATCH /users/{id}`, and readable
via `$select` on the extension property name.
- **Consumable by Conditional Access.** CA assigns policy by user and group, not by user attribute,
so the consumption path is: persona attribute → dynamic membership group rule → CA assignment.
Dynamic membership rules support custom extension properties in the form
`user.extension_<appId>_<propertyName>`, provided the property is **single-valued** and the
extension belongs to an application in the tenant. Both conditions hold here.
- **Not population-dependent.** Unlike `extensionAttributeN`, it does not fail on accounts with an
external origin (see rejected alternative A).
**Alternatives considered**:
| Alternative | Verdict | Reason |
| --- | --- | --- |
| **A. `onPremisesExtensionAttributes.extensionAttributeN`** (extensionAttribute115) | **Rejected** | Updates via Graph succeed only for objects that have always been mastered in Entra. Accounts that were ever synced from on-premises AD — or that originated in Exchange Online — fail with *"Unable to update the specified properties for objects that have originated within an external service."* In a hybrid or formerly-hybrid tenant this produces write failures determined by an account's history rather than by its rule match, which is a direct hazard to Principle I (deterministic) and Principle III (fail-safe). Remediation would require an Exchange Online PowerShell write path — a second persistence mechanism and a second permission surface. |
| **B. Directory (schema) extension** | **Selected** | See rationale above. |
| **C. Custom security attribute** | **Rejected as primary; retained as the security-first alternative** | This is the *only* mechanism offering genuine attribute-scoped authorization (see OTD-003), which makes it attractive. But custom security attributes are **not exposed to the dynamic group evaluation engine** and cannot be referenced in dynamic membership rules, so they cannot drive the CA consumption path that motivates the persona value. They are also not returned by default and require a separate permission and role. Choosing C trades the feature's primary downstream use for a stronger write boundary. |
**Consequences**: the compensating controls in OTD-003 become mandatory, because alternative B has
no attribute-scoped authorization.
**Verification items**:
- **V-1** — Register the extension application and property in a non-production tenant or an
isolated attribute name; confirm read via `$select` and write via `PATCH` for: a cloud-only user, a
currently-synced user, and a formerly-synced user. Directory extensions are not on-premises-mastered
properties, so all three are expected to succeed — but this must be proven, not assumed, because the
whole reason A was rejected is an origin-dependent write restriction.
- **V-2** — Confirm a dynamic membership group rule referencing the extension property evaluates and
populates as expected, and that a CA policy assigned to that group applies.
---
## OTD-002 — Least-privilege Microsoft Graph permissions
**Decision**: application (managed identity) permissions, granted only as each capability is enabled:
| Capability | Permission | Notes |
| --- | --- | --- |
| Read users and the persona extension | `User.Read.All` | Extension property returned via `$select`. |
| Read group membership (`memberOf` conditions) | `GroupMember.Read.All` | Sufficient for `/users/{id}/memberOf` and `/transitiveMemberOf`. Narrower than `Group.Read.All`. |
| Read directory role assignments | `RoleManagement.Read.Directory` | For role-based conditions. |
| Write the persona attribute (enforcement only) | `User.ReadWrite.All` | **Supersedes** `User.Read.All`; grant only to the enforcement identity, and only after security approval of the OTD-003 controls. |
Application permissions are the Stage B (Automation, managed identity) form. Stage A uses the same
four as **delegated** scopes on an interactive connection.
**Rationale**: each permission maps to exactly one enabled capability, so a tenant that disables
group or role conditions grants strictly less. `Directory.Read.All` is deliberately **rejected** — it
is materially broader than the three read permissions combined and would grant visibility well
outside the enumerated data sources.
**Local (Stage A) equivalent — delegated scopes.** With no Automation account, the same three
capabilities are requested as delegated scopes on an interactive connection:
```powershell
Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'
```
These delegated scopes require one-time admin consent for the app registration used locally; after
that, an ordinary user account can hold them. **Effective access is the intersection of the granted
scope and the signed-in user's directory roles** — which is precisely why V-3 must be run as a
non-privileged account (see below).
**Alternatives considered**: `Directory.ReadWrite.All` (rejected — grossly over-broad);
`User.ManageIdentities.All` (not applicable); delegated-only operation (rejected — unattended
Automation requires application permissions; delegated remains the local development path per FR-003).
**Verification items**:
- **V-3** — During the read-only pilot, grant only the three read permissions and confirm every
enabled rule evaluates without a permission error. Any `EvaluationError` attributable to
authorization identifies a missing-but-required permission and must be resolved before enforcement.
**Stage A method**: connect with the three delegated scopes above while signed in as an **ordinary,
non-privileged user account**. Running this as a Global Administrator invalidates the test — the
scope narrows the token, but the account's directory roles still grant broad read access, so the
run would succeed regardless of whether the three permissions are actually sufficient. This item is
closeable now and does not need Automation.
- **V-3b** *(Stage B, deferred)* — Repeat as **application** permissions on the managed identity.
Delegated and application authorization are evaluated differently, so a passing V-3 is strong
evidence but not proof for the unattended path.
---
## OTD-003 — Can write authorization be restricted to the single target attribute?
**Decision**: **No — not for the mechanism selected in OTD-001.** Microsoft Graph application
permissions have no per-property scope: `User.ReadWrite.All` authorizes writes to every writable
property of every user in the tenant. There is no supported way to grant "write only
`extension_<app>_<persona>`".
Therefore the compensating controls are **mandatory and testable**, not advisory:
1. `Set-UserPersonaAttribute` accepts only the configured target attribute; any other name is a
terminating error.
2. The target attribute MUST appear in `approvedWritableAttributes`; validation rejects all others
(VR-002).
3. A single dedicated function, `New-PersonaWriteBody`, constructs the request body, and it emits a
hashtable containing exactly one key.
4. Unit and integration tests assert on the **request body**, not on observed behaviour (SC-005).
5. Code owners gate every change to persistence, `approvedWritableAttributes`, and the target
attribute.
6. Directory audit logs are monitored for property writes by the engine's service principal other
than the target attribute.
**The one mechanism that *would* satisfy attribute-level authorization**: custom security attributes
(OTD-001 alternative C). Their assignment is governed by attribute sets: a principal is granted
`Attribute Assignment Administrator` **scoped to a specific attribute set**, plus the separate
`CustomSecAttributeAssignment.ReadWrite.All` permission — and notably, Global Administrator does not
hold this access by default. That is a real, enforced boundary rather than a compensating control.
It was rejected only because custom security attributes cannot feed dynamic groups (OTD-001).
**Stage A makes these controls *more* important, not less.** Local delegated writes run as the
signed-in operator, whose directory roles are typically far broader than the eventual service
principal's. During Stage A3 the compensating controls are the **only** thing standing between the
engine and an unintended property write, because the authorization boundary is effectively "whatever
the operator can do." Two additional Stage A rules follow:
- Stage A3 writes target **purpose-created test accounts only**. The baseline's read-only-during-
early-development assumption continues to hold for the general population.
- Never sign in with a standing privileged account for a write run. Elevate for the session, and
expect the directory audit log to attribute the write to the operator rather than to a service
principal — which is exactly why Stage A3 evidence does not substitute for Stage B evidence.
**This trade-off requires explicit security sign-off.** The decision record for security review is:
*accept tenant-wide user-write permission plus six compensating controls, in exchange for a persona
value that Conditional Access can actually consume.*
**Verification items**:
- **V-4** — Confirm with the security owner, in writing, that the compensating-control set is
accepted in place of attribute-scoped authorization. This is a **gate before enforcement**, per the
constitution's Definition of Done.
- **V-4a** — Investigate whether an Administrative Unit-scoped role assignment can narrow the
enforcement identity's write scope to a subset of the user population. This narrows *which users*,
never *which attribute*, so it is a partial mitigation at best; do not present it as closing OTD-003.
---
## OTD-004 — Directory access approach
**Decision**: **Direct REST via `Invoke-MgGraphRequest`**, with `Microsoft.Graph.Authentication` as
the only runtime module. No resource-specific SDK modules (`Microsoft.Graph.Users`,
`Microsoft.Graph.Groups`, etc.).
**Rationale**:
- **Explicit request bodies.** The constitution requires the write payload to contain exactly one
attribute and requires tests to inspect that body. `Invoke-MgGraphRequest -Method PATCH -Body` makes
the body a first-class, assertable value. SDK cmdlets construct bodies internally from parameter
binding, which makes SC-005 far harder to prove.
- **Dynamic extension properties.** The persona property name is configuration-driven and unknown at
authoring time. Passing an arbitrary `extension_<appId>_<name>` key is natural in a hashtable body
and awkward through typed cmdlet parameters.
- **Automation footprint.** One small module to import instead of the SDK's large module set, which
reduces cold-start time, import failures, and version drift in the Automation PS7 environment
(NFR-008).
- Managed-identity and interactive token acquisition are still handled by `Connect-MgGraph`, so
nothing is reimplemented.
**Alternatives considered**: full Graph SDK cmdlets (rejected — heavy, opaque bodies, version drift);
raw `Invoke-RestMethod` with hand-rolled token acquisition (rejected — reimplements managed-identity
token handling and refresh for no benefit).
**Consequence**: pagination (`@odata.nextLink`), throttling, and error shaping are the engine's
responsibility. They are handled once, in the data-provider layer — see OTD-007 and
[contracts/graph-data-provider.md](contracts/graph-data-provider.md).
---
## OTD-005 — JSON Schema validation approach
**Decision**: the built-in **`Test-Json -SchemaFile`** cmdlet, with the schema authored to
**JSON Schema draft-07**.
**Rationale**: `Test-Json` ships with PowerShell 6.1+ and therefore needs no module import in either
the local or Automation PS7 environment — the strongest possible answer to NFR-008 and OTD-005's
"compatible locally and in automation" requirement. Its underlying validator is the Newtonsoft JSON
Schema implementation, whose reliable coverage is draft-04/06/07; **draft 2019-09 and 2020-12
constructs must not be used** in the schema.
**Implementation notes**:
- `Test-Json` signals failure by writing errors rather than simply returning `$false` in several
PowerShell versions. `Test-PersonaConfiguration` MUST wrap it with
`-ErrorAction SilentlyContinue -ErrorVariable` and translate the collected errors into
`Validation Finding` objects (VR-004), so that layer 2 produces structured findings like every other
layer.
- Schema validation is layer 2 of four. It cannot express the semantic rules in VR-002 (duplicate
priorities, depth limits, cross-field constraints), which is why layers 3 and 4 exist as PowerShell
checks. Do not attempt to push semantic rules into the schema.
**Alternatives considered**: bundling a third-party schema library (rejected — an extra Automation
dependency for capability the platform already provides); hand-written structural validation only
(rejected — VR-001 mandates a schema layer, and a schema is also the editor's contract).
**Verification items**:
- **V-5a** *(Stage A, closeable now)* — Execute `Test-Json -SchemaFile` against the draft-07 schema
on the local PowerShell 7.4 workstation. Record the exact behaviour on failure: whether it returns
`$false`, writes a non-terminating error, or throws. The layer-2 wrapper is built against **this
observed behaviour**, and the observation is pinned in a unit test so a runtime change is caught
rather than discovered.
- **V-5b** *(Stage B, deferred)* — Repeat inside the Azure Automation PowerShell 7 runtime and record
its exact PowerShell version. If the behaviour differs from V-5a, the wrapper handles both — do not
assume parity. This is the single highest-value item to run on day one of Stage B.
---
## Non-blocking decisions (OTD-006 OTD-010)
These do not block persistence. They are decided far enough to implement v1 without rework.
### OTD-006 — Structured log destination and transport
**Decision**: newline-delimited JSON (one audit record per line) written to a configurable file path,
plus the Automation output stream. Emission goes through a single `Write-PersonaAuditRecord` sink
function so a Log Analytics or Event Hub transport can be added later without touching call sites.
Log Analytics ingestion is **deferred**, not designed out.
**Rationale**: NDJSON is append-safe, streamable, trivially ingestible later, and needs no
dependency. The sink indirection is what keeps the deferral cheap.
### OTD-007 — Retry policy
**Decision**: bounded exponential backoff with full jitter in the data-provider layer.
- **Retryable**: HTTP 429, 500, 502, 503, 504, and transport-level timeouts.
- **Never retried**: 400, 401, 403, 404, 409 — these are configuration, authorization, or logic
defects and retrying masks them.
- **`Retry-After` honoured** whenever present; it overrides the computed backoff.
- **Max 5 attempts**, base delay 1s, exponential with full jitter, per-delay cap 60s.
- Every retry logs attempt number, status code, and delay. Exhausting retries on **required** data
yields `EvaluationError` for the affected user (FR-013) — never a silent non-match.
**Rationale**: satisfies NFR-003 while keeping Principle III intact: the failure mode of exhausted
retries is preserve-and-report, not assume-false.
### OTD-008 — Full versus incremental processing
**Decision**: v1 performs **full enumeration** with pagination. Delta query is deferred and out of
scope for v1 (already recorded in spec Out of Scope). Revisit only when pilot timings justify it.
### OTD-009 — Schedule and concurrency lock
**Decision**: hourly is the candidate cadence, deployment-configurable, and the schedule ships
**disabled** (per the release pipeline). Concurrency control for v1 is the Automation account's own
job behaviour plus a run-start check that fails fast if another job for the same runbook is running.
A durable distributed lock is deferred.
**Stage A status**: not applicable. With no Automation account there is no schedule and no
concurrency surface — runs are manual and serial by construction. Nothing here needs building until
Stage B, and nothing in Stages A1A3 depends on it.
**Rationale**: overlapping runs are idempotent by construction (Principle III) — the harm is wasted
Graph quota and confusing audit output, not incorrect data — so a lightweight check is proportionate
for v1.
### OTD-010 — Rollback
**Decision**: rollback is driven by the audit trail. Every write record carries the **previous
value**, the calculated value, the matched rule ID, and the run ID (NFR-005), which makes a run
reversible by replaying prior values from its audit output. A replay utility is deferred to v1.1; the
**record shape that makes it possible is v1** and is fixed in
[contracts/audit-record.md](contracts/audit-record.md).
**Rationale**: the cheap, decisive part is capturing the previous value at write time. Miss that in
v1 and rollback becomes impossible retroactively.
---
## Verification checklist
| ID | Item | Stage | Blocks |
| --- | --- | --- | --- |
| V-1 | Extension property read/write proven for cloud-only, synced, and formerly-synced users | A2 (read) / A3 (write) | Persistence implementation |
| V-2 | Dynamic group rule on the extension property populates; CA assignment applies | A3 | Downstream value of the feature |
| V-3 | Delegated pilot completes with only the three scopes, signed in as a **non-privileged** account | A2 | Enforcement |
| V-3b | Same, as application permissions on the managed identity | **B — deferred** | Unattended enforcement |
| V-4 | Written security sign-off on compensating controls in place of attribute-scoped write | Out-of-band | **Enforcement (constitution gate)** |
| V-4a | Administrative Unit scoping investigated as partial mitigation | Any | Nothing (informational) |
| V-5a | `Test-Json -SchemaFile` failure behaviour observed and pinned locally | A1 | Layer-2 wrapper implementation |
| V-5b | Same, confirmed in the Automation runtime, with PS version recorded | **B — deferred** | Configuration validation sign-off |
**Closeable in the current stage**: V-1 (read half), V-3, V-4a, V-5a — and V-4, which needs a
conversation rather than a tenant. **Deferred with Automation**: V-3b, V-5b.
## Sources
- [Manage rules for dynamic membership groups in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity/users/groups-dynamic-membership)
- [Creating dynamic groups using custom security attributes](https://learn.microsoft.com/en-us/answers/questions/5763638/creating-dynamic-groups-using-custom-security-attr)
- [Conditional Access: Users, Groups, Agents, and Workload Identities](https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-users-groups)
- [onPremisesExtensionAttributes resource type](https://learn.microsoft.com/en-us/graph/api/resources/onpremisesextensionattributes?view=graph-rest-1.0)
- [Update user — Microsoft Graph v1.0](https://learn.microsoft.com/en-us/graph/api/user-update?view=graph-rest-1.0)
- [Why is it not possible to update extension attributes of former hybrid users via Graph API?](https://learn.microsoft.com/en-us/answers/questions/1850101/why-is-it-not-possible-to-update-extension-attribu)
- [Add custom data to resources using extensions](https://learn.microsoft.com/en-us/graph/extensibility-overview)
- [What are custom security attributes in Microsoft Entra ID?](https://learn.microsoft.com/en-us/entra/fundamentals/custom-security-attributes-overview)
- [Manage access to custom security attributes in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/fundamentals/custom-security-attributes-manage)
- [Assign, update, list, or remove custom security attributes for a user](https://learn.microsoft.com/en-us/entra/identity/users/users-custom-security-attributes)
+13 -11
View File
@@ -310,20 +310,22 @@ Candidate business classifications — **not** hard-coded engine behaviour: `Gue
Open items carried from the baseline (§24). These are implementation research items to be resolved in `plan.md` / `research.md` or an ADR — **not** unanswered business requirements. OTD-001 through OTD-005 must be closed before persistence implementation.
**Updated 2026-08-20 (T114): OTD-001 through OTD-007 and OTD-010 are resolved in [research.md](research.md). The persistence gate is lifted for Stage A3 test accounts, and remains closed for the general population until V-4 security sign-off is recorded.**
| ID | Item | Status |
| --- | --- | --- |
| OTD-001 | Exact persona attribute mechanism — data type, read/update method, discoverability, Conditional Access compatibility | [NEEDS CLARIFICATION] Blocks persistence |
| OTD-002 | Exact least-privilege directory permissions for users, groups, roles, and the selected attribute | [NEEDS CLARIFICATION] Blocks persistence |
| OTD-003 | Whether write authorization can be restricted to the individual target attribute; if not, compensating controls plus security approval | [NEEDS CLARIFICATION] Blocks persistence |
| OTD-004 | Directory access approach — SDK cmdlets, direct REST, or a controlled combination | [NEEDS CLARIFICATION] Blocks persistence |
| OTD-005 | JSON Schema validation approach compatible with PowerShell 7 locally and in automation | [NEEDS CLARIFICATION] Blocks persistence |
| OTD-006 | Structured-log destination and transport | Open |
| OTD-007 | Retry policy — retryable status codes, max attempts, backoff, jitter, logging | Open |
| OTD-008 | Full versus incremental processing roadmap | Open |
| OTD-009 | Production schedule and concurrency lock to prevent overlapping runs | Open |
| OTD-010 | Rollback implementation — pre-change audit values or another approved mechanism | Open |
| OTD-001 | Exact persona attribute mechanism — data type, read/update method, discoverability, Conditional Access compatibility | **Resolved** — directory (schema) extension property on an app registration, addressable as `user.extension_<appId>_<name>` in dynamic group rules. `extensionAttributeN` rejected (unavailable for cloud writes on ever-synced and Exchange-originated objects); custom security attributes rejected (not exposed to the dynamic group engine). See research.md OTD-001. |
| OTD-002 | Exact least-privilege directory permissions for users, groups, roles, and the selected attribute | **Resolved**`User.Read.All`, `GroupMember.Read.All`, `RoleManagement.Read.Directory`; `User.ReadWrite.All` for enforcement only. Scopes are requested per enabled-rule need, not unconditionally. See research.md OTD-002. |
| OTD-003 | Whether write authorization can be restricted to the individual target attribute; if not, compensating controls plus security approval | **Resolved: it cannot.** Graph application permissions have no per-property write scope. Six compensating controls are mandatory and implemented; see [docs/SecurityModel.md](../../docs/SecurityModel.md). **Security approval (V-4) is still outstanding and gates enforcement.** |
| OTD-004 | Directory access approach — SDK cmdlets, direct REST, or a controlled combination | **Resolved** — direct REST via `Invoke-MgGraphRequest`, so request bodies are explicit values that tests can assert on. This is what makes SC-005 provable. Only `Microsoft.Graph.Authentication` is a runtime dependency. |
| OTD-005 | JSON Schema validation approach compatible with PowerShell 7 locally and in automation | **Resolved locally**`Test-Json -SchemaFile`, draft-07 only. Failure behaviour observed and pinned in [V-5a](verification/V-5a.md); note that an unparseable schema returns `$true`. **V-5b (Automation runtime) remains open.** |
| OTD-006 | Structured-log destination and transport | **Resolved** — NDJSON through a single sink, `Write-PersonaAuditRecord`. Additional transports are added behind that function, not by widening call sites. See [docs/Logging.md](../../docs/Logging.md). |
| OTD-007 | Retry policy — retryable status codes, max attempts, backoff, jitter, logging | **Resolved** — retry 429/500/502/503/504 and status-less transport failures; never 400/401/403/404/409; honour `Retry-After`; max 5 attempts; exponential backoff with full jitter capped at 60s. See research.md OTD-007. |
| OTD-008 | Full versus incremental processing roadmap | Open — full enumeration only in v1. |
| OTD-009 | Production schedule and concurrency lock to prevent overlapping runs | Open — deferred with Stage B (T120). Until then the schedule is the lock; see [docs/OperationsRunbook.md](../../docs/OperationsRunbook.md). |
| OTD-010 | Rollback implementation — pre-change audit values or another approved mechanism | **Data captured; tool not built.** `previousValue` is recorded at write time on every `Updated` record, which is the part that cannot be reconstructed retroactively. The rollback tool itself is out of scope for v1. |
**Mandatory research item**: determine the selected persona attribute mechanism and document the exact authorization boundary for updating it. If individual-attribute authorization is unavailable, document compensating controls before implementation approval.
**Mandatory research item**: determine the selected persona attribute mechanism and document the exact authorization boundary for updating it. If individual-attribute authorization is unavailable, document compensating controls before implementation approval.**Done.** The mechanism is a directory extension property; the authorization boundary is *the whole user object*, because Graph offers nothing narrower; the compensating controls are documented in [docs/SecurityModel.md](../../docs/SecurityModel.md) and each one is test-enforced. Implementation approval for enforcement still requires the V-4 sign-off.
---
+481
View File
@@ -0,0 +1,481 @@
---
description: "Task list for Persona Engine implementation"
---
# Tasks: Persona Engine
**Input**: Design documents from `/specs/001-persona-engine/`
**Prerequisites**: [plan.md](plan.md), [spec.md](spec.md), [research.md](research.md),
[data-model.md](data-model.md), [contracts/](contracts/), [quickstart.md](quickstart.md)
**Tests**: **Included and mandatory.** Not an optional TDD preference here — SC-004, SC-005, SC-008,
and SC-009 are written as test assertions, and constitution Principle IV requires the rule engine to
pass offline Pester tests before any Graph integration exists.
**Organization**: Grouped by user story. Story phases are ordered by the constitution's
**non-negotiable build order**, not strictly by priority — see the note below.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
- **[Story]**: US1US9, mapping to the user stories in spec.md
- Every task names an exact file path
## Path Conventions
Single PowerShell module at repository root: `src/`, `tests/`, `config/`, `docs/`, `pipelines/`,
per the Project Structure section of [plan.md](plan.md).
## Implementation status — 2026-08-20
**109 of 121 tasks complete.** All twelve remaining tasks require something this workstation does not
have: a tenant connection (T055, T056, T101T103) or an Azure Automation account (Phase 13,
T115T121). Nothing offline-implementable is outstanding.
```
354 offline Pester tests PASS
Engine purity (Principle IV) PASS
Sanitization (SC-013) PASS 156 files
Graph module loaded during tests False (SC-008 holds)
```
Three deviations from the task list as written, each made while implementing and each for a reason
worth recording:
**T033 does not live in `Resolve-UserPersona`.** The task named that file, but `evaluationErrorThreshold`
is run-level state and the rule engine is pure — a counter there would break Principle IV. It is
implemented in `New-PersonaRunCounter` and applied in the run loop, which is what "and the run summary
path" in the task description points at.
**A new `src/Engine/` layer exists** holding `Invoke-PersonaEngineRun`, which was not in the planned
structure. `Invoke-PersonaEngine.ps1` imports the manifest, which requires `Microsoft.Graph.Authentication`;
a run loop living only inside that script could not be executed on a machine without the Graph SDK,
so SC-004 — zero writes under `-WhatIf` across a full population — could not be proven at all. The
entry script is now a thin wrapper and the loop is testable. What ships and what is tested are the
same code.
**T057's corpus is generated, not hand-written.** `tests/TestData/InvalidConfigs/New-InvalidConfigCorpus.ps1`
produces 23 fixtures, each a valid configuration with exactly one defect. The generated files are
committed so a reviewer sees the fixture in the diff rather than a script that produces it.
## Ordering note (read before starting)
Constitution Principle IV fixes the build order: pure rule engine first, persistence last. This
**overrides** strict priority ordering, so the P1 stories are sequenced US2 → US3 → US1 rather than
US1 first. US1 (preview) is the headline MVP story but cannot be built before the engine it previews.
Stage labels refer to the Delivery Staging table in [plan.md](plan.md): **A1** offline, **A2**
delegated read-only, **A3** delegated write to test accounts, **B** Azure Automation (deferred — no
Automation account available).
---
## Phase 1: Setup (Shared Infrastructure) — Stage A1
**Purpose**: Repository scaffolding and tooling. No behaviour.
- [X] T001 Create the directory tree (`src/{Configuration,Authentication,DataProviders,Normalization,RuleEngine,Persistence,Presentation,Audit}`, `tests/{Unit,RuleEngine,Configuration,Integration,Safety,TestData}`, `config/`, `docs/`, `pipelines/`) per the Project Structure section of specs/001-persona-engine/plan.md
- [X] T002 Create the module manifest PersonaEngine.psd1 with `PowerShellVersion = '7.2'` and `RequiredModules = @('Microsoft.Graph.Authentication')`
- [X] T003 Create the module loader PersonaEngine.psm1 that dot-sources every `src/**/*.ps1` and exports only public functions
- [X] T004 [P] Add PSScriptAnalyzer settings in PSScriptAnalyzerSettings.psd1 enabling PSUseShouldProcessForStateChangingFunctions and PSAvoidUsingPlainTextForPassword
- [X] T005 [P] Add Pester configuration in tests/PesterConfiguration.ps1 with separate tags for Offline, Integration, and Safety suites
- [X] T006 [P] Copy specs/001-persona-engine/contracts/persona-engine.schema.json to config/persona-engine.schema.json as the shipped schema
- [X] T007 [P] Create the placeholder-only sample configuration config/persona-engine.example.json using `<APPROVED-PERSONA-ATTRIBUTE-NAME>` and `<GROUP-OBJECT-ID>` tokens only
- [X] T008 [P] Write the sanitization scan script tests/Test-Sanitization.ps1 that fails on real-looking GUIDs, domains, UPNs, or secrets in any tracked file (SC-013)
---
## Phase 2: Foundational (Blocking Prerequisites) — Stage A1
**Purpose**: The normalized contracts and guardrails every story depends on.
**⚠️ CRITICAL**: No user story work begins until this phase is complete.
- [X] T009 Implement `New-PersonaUserRecord` in src/Normalization/New-PersonaUserRecord.ps1 producing the UserRecord shape in data-model.md, with case-insensitive `Properties` lookup and mandatory `AccountObjectId`/`UserPrincipalName`
- [X] T010 Implement `New-PersonaMembershipRecord` in src/Normalization/New-PersonaMembershipRecord.ps1 with three independently-retrieved facets (direct, transitive, roles), every `*Retrieved` flag defaulting to `$false` so an unset record is never mistaken for "not a member" (revised during implementation — see data-model.md)
- [X] T011 [P] Implement `New-PersonaValidationFinding` in src/Configuration/New-PersonaValidationFinding.ps1 emitting Severity, Code, Location, Description, SuggestedResolution, Layer (VR-004)
- [X] T012 [P] Create synthetic fixtures in tests/TestData/Users/ covering enabled, disabled, guest, null-property, and missing-property accounts — obviously fictional, placeholders only
- [X] T013 [P] Create synthetic membership fixtures in tests/TestData/Memberships/ including a partial-failure record (one facet failed) and a total-failure record
- [X] T014 [P] Write Pester contract tests for both record types in tests/Unit/RecordContracts.Tests.ps1
- [X] T015 Write the engine-purity CI check tests/Test-EnginePurity.ps1 asserting no file under src/RuleEngine/ references Graph, `Connect-`, `Invoke-MgGraphRequest`, `Write-Host`, or any function from src/Authentication|DataProviders|Persistence|Presentation (Principle IV)
**Checkpoint**: normalized contracts exist and purity is enforced by CI. Story work can begin.
---
## Phase 3: User Story 2 — Define classification rules without changing code (Priority: P1) 🎯 Engine core
**Goal**: Ordered, JSON-defined rules evaluate against normalized records with no code changes and no
tenant.
**Independent Test**: Author a multi-rule configuration, evaluate it against synthetic fixtures
offline with the network disconnected, and confirm the expected persona for each fixture.
### Tests for User Story 2
> Write these first and confirm they fail before implementing.
- [X] T016 [P] [US2] Operator matrix tests for all thirteen RE-005 operators in tests/RuleEngine/Operators.Tests.ps1, including case-insensitivity (RE-006) and null-as-empty (FR-012)
- [X] T017 [P] [US2] Ordering tests in tests/RuleEngine/Ordering.Tests.ps1 asserting ascending priority evaluation, first-match stop, and that a priority-10 match beats a priority-20 match
- [X] T018 [P] [US2] Composition tests in tests/RuleEngine/Composition.Tests.ps1 for nested `all`/`any` within the depth limit, and rejection beyond `maxConditionDepth` and the hard ceiling of 10 (RE-004)
- [X] T019 [P] [US2] Determinism tests in tests/RuleEngine/Determinism.Tests.ps1 asserting identical results across shuffled fixture order and repeated runs (SC-003)
- [X] T020 [P] [US2] Unclassified test in tests/RuleEngine/Unclassified.Tests.ps1 asserting that zero matches with all rules evaluating successfully yields `Unclassified` (FR-010)
### Implementation for User Story 2
- [X] T021 [US2] Implement `Import-PersonaConfiguration` in src/Configuration/Import-PersonaConfiguration.ps1 converting JSON to BusinessRule/ConditionGroup/Condition objects and computing the SHA-256 `ConfigurationHash`
- [X] T022 [US2] Implement `Test-PersonaCondition` in src/RuleEngine/Test-PersonaCondition.ps1 covering all RE-005 operators, with regex patterns validated before execution (RE-006)
- [X] T023 [US2] Implement `Test-PersonaConditionGroup` in src/RuleEngine/Test-PersonaConditionGroup.ps1 with `all`/`any` composition and depth enforcement
- [X] T024 [US2] Implement `Test-PersonaRule` in src/RuleEngine/Test-PersonaRule.ps1 evaluating a rule's root condition group
- [X] T025 [US2] Implement `Resolve-UserPersona` in src/RuleEngine/Resolve-UserPersona.ps1 with priority sort, first-match stop, `Unclassified` fallback, `RulesEvaluated`, and `DurationMs`
- [X] T026 [US2] Implement membership and role condition types in src/RuleEngine/Test-PersonaCondition.ps1 reading only from the MembershipRecord, honouring per-condition `direct`/`transitive` mode (RE-007)
**Checkpoint**: the rule engine classifies synthetic users offline. Disconnect the network and re-run
tests/RuleEngine — SC-008 holds or the phase is not done.
---
## Phase 4: User Story 3 — Preserve existing values when evaluation cannot be trusted (Priority: P1)
**Goal**: Unretrievable required data yields `EvaluationError`, preserves the stored persona, and
never becomes a silent non-match.
**Independent Test**: Inject a group-lookup failure for one synthetic user; that user receives
`EvaluationError`, no write is attempted, and the run continues.
### Tests for User Story 3
- [X] T027 [P] [US3] Tri-state propagation tests in tests/RuleEngine/UnknownPropagation.Tests.ps1 covering all four rows of the propagation table in data-model.md
- [X] T028 [P] [US3] Preservation tests in tests/RuleEngine/EvaluationError.Tests.ps1 asserting stored persona is retained, `Action` is `Skipped`, and processing continues to the next user (FR-014)
- [X] T029 [P] [US3] Regression test in tests/RuleEngine/UnknownNotFalse.Tests.ps1 asserting a failed membership lookup never satisfies `notMemberOf` — the specific misclassification hazard FR-013 exists to prevent
### Implementation for User Story 3
- [X] T030 [US3] Extend condition evaluation in src/RuleEngine/Test-PersonaCondition.ps1 to return `True`/`False`/`Unknown` instead of a boolean
- [X] T031 [US3] Implement the propagation rules in src/RuleEngine/Test-PersonaConditionGroup.ps1: `all` with any `False` is `False`; `all` with only `True` plus `Unknown` is `Unknown`; `any` with any `True` is `True`; `any` with only `False` plus `Unknown` is `Unknown`
- [X] T032 [US3] Map a root-level `Unknown` to the `EvaluationError` outcome with `EvaluationErrorReason` in src/RuleEngine/Resolve-UserPersona.ps1
- [X] T033 [US3] Add the `evaluationErrorThreshold` counter and final-status effect in src/RuleEngine/Resolve-UserPersona.ps1 and the run summary path
**Checkpoint**: unknown data degrades to preserve-and-report. Principle III is satisfied in the pure
engine, before any tenant exists.
---
## Phase 5: User Story 1 — Preview classification without changing the directory (Priority: P1) 🎯 MVP
**Goal**: Every in-scope user is retrieved, evaluated, and reported with stored value, calculated
value, and matched rule — with zero directory writes.
**Independent Test**: Run with `-WhatIf` against the tenant using a delegated read-only connection;
verify a result appears for every account and the write adapter receives zero calls.
**Stage**: A2. Requires an app registration with admin consent for the three delegated scopes.
### Tests for User Story 1
- [X] T034 [P] [US1] Zero-write safety test in tests/Safety/WhatIfZeroWrites.Tests.ps1 mocking the write adapter and asserting call count is exactly 0 across the full synthetic population (SC-004)
- [X] T035 [P] [US1] Mode-derivation test in tests/Safety/ShouldProcessGate.Tests.ps1 asserting `-Debug` without `-WhatIf` still reaches the write path — `-Debug` is not a safety control
- [X] T036 [P] [US1] Pagination test in tests/Unit/Pagination.Tests.ps1 asserting `@odata.nextLink` is followed to exhaustion and a truncated enumeration raises rather than returning a partial population
- [X] T037 [P] [US1] Idempotence test in tests/Safety/Idempotence.Tests.ps1 asserting a second consecutive run over unchanged input proposes zero changes (SC-002)
- [X] T038 [P] [US1] Exactly-one-outcome test in tests/Unit/OutcomeExclusivity.Tests.ps1 asserting every processed user lands in exactly one bucket (SC-001)
### Implementation for User Story 1
- [X] T039 [P] [US1] Implement `Connect-PersonaGraphInteractive` in src/Authentication/Connect-PersonaGraphInteractive.ps1 requesting only `User.Read.All`, `GroupMember.Read.All`, `RoleManagement.Read.Directory`, returning an opaque handle that never exposes a token
- [X] T040 [P] [US1] Implement the retry helper `Invoke-PersonaGraphRequest` in src/DataProviders/Invoke-PersonaGraphRequest.ps1 per the OTD-007 table: retry 429/500/502/503/504/timeout, never 400/401/403/404/409, honour `Retry-After`, max 5 attempts, exponential backoff with full jitter capped at 60s
- [X] T041 [US1] Implement `Get-PersonaUsers` in src/DataProviders/Get-PersonaUsers.ps1 with `$select` built from FR-005 plus rule-referenced properties plus the target attribute, `$top=999`, and full `@odata.nextLink` pagination
- [X] T042 [US1] Add single-user retrieval to src/DataProviders/Get-PersonaUsers.ps1 for the `-UserObjectId` path
- [X] T043 [US1] Implement `Get-PersonaGroupMembership` in src/DataProviders/Get-PersonaGroupMembership.ps1 selecting `memberOf` or `transitiveMemberOf` by mode, returning a MembershipRecord with `RetrievalSucceeded = $false` on failure rather than throwing or returning an empty list
- [X] T044 [US1] Implement `Get-PersonaDirectoryRoles` in src/DataProviders/Get-PersonaDirectoryRoles.ps1 via `/roleManagement/directory/roleAssignments`
- [X] T045 [US1] Implement the run-scoped group/role cache in src/DataProviders/PersonaDataCache.ps1, never persisted between runs
- [X] T046 [US1] Implement `ConvertTo-PersonaUserRecord` in src/Normalization/ConvertTo-PersonaUserRecord.ps1 mapping raw Graph responses to UserRecord, including the persona extension property
- [X] T047 [US1] Implement `ConvertTo-PersonaMembershipRecord` in src/Normalization/ConvertTo-PersonaMembershipRecord.ps1
- [X] T048 [US1] Implement `Compare-PersonaValue` in src/Persistence/Compare-PersonaValue.ps1 performing ordinal comparison of stored versus calculated (FR-015)
- [X] T049 [US1] Implement `Write-UserPersonaResult` in src/Presentation/Write-UserPersonaResult.ps1 emitting one line per user immediately with UPN, Account Object ID, outcome, matched rule ID, stored value, calculated value, and action (FR-018, SC-012)
- [X] T050 [US1] Create Invoke-PersonaEngine.ps1 with `CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')` and the parameter set from contracts/cli-invoke-persona-engine.md
- [X] T051 [US1] Derive execution mode solely from `$PSCmdlet.ShouldProcess()` in Invoke-PersonaEngine.ps1 — no separate preview boolean, per the contract's prohibition on two sources of truth for the write gate
- [X] T052 [US1] Implement the `WouldUpdate` reporting path in Invoke-PersonaEngine.ps1 so preview reports intended changes without constructing a request (FR-017)
- [X] T053 [US1] Implement exit codes 06 in Invoke-PersonaEngine.ps1 per the contract table
- [X] T054 [US1] Record per-user and total duration in Invoke-PersonaEngine.ps1 to establish the NFR-002 baseline no target yet exists for
- [ ] T055 [US1] Run quickstart Scenario 4 as a **non-privileged** account and record results in specs/001-persona-engine/verification/V-3.md — a Global Administrator run invalidates this item
- [ ] T056 [US1] Close the V-1 read half against a cloud-only, a currently-synced, and a formerly-synced account; record in specs/001-persona-engine/verification/V-1.md
**Checkpoint**: 🎯 **MVP complete.** Full classification visibility against the live tenant with zero
write risk. Everything after this point adds observability, safety tooling, or enforcement.
---
## Phase 6: User Story 5 — Validate and edit configuration safely (Priority: P2)
**Goal**: Four-layer validation with structured findings, plus an interactive editor that cannot save
an invalid configuration.
**Independent Test**: Run the invalid-configuration corpus and confirm each file produces its
documented finding code, severity, and location.
### Tests for User Story 5
- [X] T057 [P] [US5] Build the invalid-configuration corpus in tests/TestData/InvalidConfigs/ — one file per VR-002 and VR-003 condition
- [X] T058 [P] [US5] Semantic validation tests in tests/Configuration/Semantic.Tests.ps1 asserting code, severity, and location for every VR-002 condition (SC-009)
- [X] T059 [P] [US5] Safety validation tests in tests/Configuration/Safety.Tests.ps1 asserting every VR-003 condition
- [X] T060 [P] [US5] Layer-ordering test in tests/Configuration/LayerOrdering.Tests.ps1 asserting a structurally invalid document stops before semantic checks run
### Implementation for User Story 5
- [X] T061 [US5] Close V-5a: observe and record whether `Test-Json -SchemaFile` returns `$false`, writes a non-terminating error, or throws on this PowerShell build; record in specs/001-persona-engine/verification/V-5a.md and pin the observation in tests/Configuration/TestJsonBehaviour.Tests.ps1
- [X] T062 [US5] Implement validation layer 1 (syntax) in src/Configuration/Test-PersonaConfiguration.ps1
- [X] T063 [US5] Implement validation layer 2 (schema) in src/Configuration/Test-PersonaConfiguration.ps1 wrapping `Test-Json -SchemaFile` with `-ErrorAction SilentlyContinue -ErrorVariable` and converting collected errors into ValidationFinding objects per the V-5a observation
- [X] T064 [US5] Implement validation layer 3 (semantic) in src/Configuration/Test-PersonaConfigurationSemantic.ps1 covering every VR-002 condition with stable `PE-SEM-nnn` codes
- [X] T065 [US5] Implement validation layer 4 (safety) in src/Configuration/Test-PersonaConfigurationSafety.ps1 covering every VR-003 condition with stable `PE-SAF-nnn` codes
- [X] T066 [US5] Implement `Resolve-TargetAttribute` in src/Configuration/Resolve-TargetAttribute.ps1 rejecting any attribute absent from `approvedWritableAttributes`
- [X] T067 [US5] Create Edit-PersonaEngineConfig.ps1 with the parameter set from contracts/cli-edit-persona-engine-config.md
- [X] T068 [US5] Implement the interactive editor loop in Edit-PersonaEngineConfig.ps1 with re-validation before save
- [X] T069 [US5] Implement the timestamped backup and Save-As path in Edit-PersonaEngineConfig.ps1 (FR-026)
- [X] T070 [US5] Implement synthetic rule testing via `-TestDataPath` in Edit-PersonaEngineConfig.ps1, reusing the rule engine with no tenant connectivity (FR-025)
**Checkpoint**: invalid configurations cannot reach the engine or overwrite a good file.
---
## Phase 7: User Story 6 — Block invalid configuration in a pipeline (Priority: P2)
**Goal**: Non-interactive validation that returns exit codes and never prompts.
**Independent Test**: Run `-ValidateOnly -NonInteractive` against an invalid file with stdin closed;
confirm a non-zero exit code, no prompt, and no hang.
**Depends on**: US5's validator (T062T065). If pipeline gating is needed sooner than the interactive
editor, build T062T065 and T071T073 first and defer T067T070.
### Tests for User Story 6
- [X] T071 [P] [US6] Non-interactive test in tests/Configuration/NonInteractive.Tests.ps1 running with stdin closed and asserting no prompt and no hang (SC-010)
- [X] T072 [P] [US6] Exit-code test in tests/Configuration/ExitCodes.Tests.ps1 covering codes 04 from the editor contract
### Implementation for User Story 6
- [X] T073 [US6] Implement `-NonInteractive` and `-ValidateOnly` short-circuits in Edit-PersonaEngineConfig.ps1 that never call a prompting cmdlet
- [X] T074 [US6] Implement `-TreatWarningsAsErrors` escalation in Edit-PersonaEngineConfig.ps1 (VR-005)
- [X] T075 [US6] Implement the editor exit codes 04 in Edit-PersonaEngineConfig.ps1
- [X] T076 [US6] Add the validation stage to pipelines/validate.yml invoking sanitization, PSScriptAnalyzer, engine purity, schema validation, and the offline Pester suites
**Checkpoint**: CI blocks a bad configuration before it can reach a tenant.
---
## Phase 8: User Story 4 — Observe progress and reconcile results (Priority: P2)
**Goal**: Interim and final summaries with a reconciliation check that treats a mismatch as an engine
defect.
**Independent Test**: Run over a fixture population with `summaryInterval` set to 5, then to 0;
confirm interim summaries appear at the interval, are suppressed at 0, and a final summary appears in
both cases.
### Tests for User Story 4
- [X] T077 [P] [US4] Interval-semantics tests in tests/Unit/SummaryInterval.Tests.ps1 covering default 25, a custom interval, and the `0` case that still produces a final summary (FR-020)
- [X] T078 [P] [US4] Reconciliation tests in tests/Unit/Reconciliation.Tests.ps1 asserting `Processed = Matched + Unclassified + EvaluationError` at every summary and that a forced mismatch raises an engine defect (SC-007)
### Implementation for User Story 4
- [X] T079 [US4] Implement `Write-PersonaSummary` in src/Presentation/Write-PersonaSummary.ps1 rendering all business rules with match counts — including disabled and zero-match rules, so an absent rule is distinguishable from one that never fired
- [X] T080 [US4] Implement interval-triggered interim summaries in Invoke-PersonaEngine.ps1 (FR-019)
- [X] T081 [US4] Implement `Test-PersonaReconciliation` in src/Presentation/Test-PersonaReconciliation.ps1 and invoke it at every summary (FR-021)
- [X] T082 [US4] Emit an `EngineDefect` record and exit code 5 on reconciliation failure in Invoke-PersonaEngine.ps1
**Checkpoint**: operators can watch a long run and trust the counters.
---
## Phase 9: User Story 7 — Audit any classification decision (Priority: P2)
**Goal**: Structured, audit-friendly records that explain every decision and make rollback possible.
**Independent Test**: Run over fixtures and confirm 100% of user events carry run ID, UPN, and
Account Object ID, and 100% of matched results carry a rule ID.
### Tests for User Story 7
- [X] T083 [P] [US7] Completeness tests in tests/Unit/AuditCompleteness.Tests.ps1 asserting SC-006 across every record
- [X] T084 [P] [US7] Redaction tests in tests/Unit/AuditRedaction.Tests.ps1 asserting no token, `Authorization` header, secret, or raw Graph response can appear in any record
- [X] T085 [P] [US7] Schema tests in tests/Unit/AuditRecordShape.Tests.ps1 validating each record type against contracts/audit-record.md
### Implementation for User Story 7
- [X] T086 [US7] Implement `New-PersonaAuditRecord` in src/Audit/New-PersonaAuditRecord.ps1 building the common envelope plus each `recordType`
- [X] T087 [US7] Implement the single sink `Write-PersonaAuditRecord` in src/Audit/Write-PersonaAuditRecord.ps1 emitting NDJSON to file and/or stream per `logging.destination`, as the only emission point so a future transport needs no call-site changes
- [X] T088 [US7] Implement `Export-PersonaRunReport` in src/Audit/Export-PersonaRunReport.ps1 producing the `RunComplete` record with all counters and the exit code
- [X] T089 [US7] Wire the run ID from `-CorrelationId` or a generated GUID through every record in Invoke-PersonaEngine.ps1 (NFR-005)
- [X] T090 [US7] Record `configVersion` and `configurationHash` on every record in src/Audit/New-PersonaAuditRecord.ps1
**Checkpoint**: every decision is explainable after the fact.
---
## Phase 10: User Story 9 — Trace the values behind every rule decision (Priority: P3)
**Goal**: Condition-level diagnostic tracing, available only when explicitly enabled.
**Independent Test**: Run one user with `-Debug` and confirm a condition trace appears; run without
`-Debug` and confirm no condition values are emitted anywhere.
### Tests for User Story 9
- [X] T091 [P] [US9] Gating tests in tests/Unit/ConditionTrace.Tests.ps1 asserting `conditionTrace` is absent without `-Debug` and present with it
- [X] T092 [P] [US9] Acknowledgement test in tests/Configuration/TraceAcknowledgement.Tests.ps1 asserting `traceConditionValues` without explicit acknowledgement produces a VR-003 safety finding
### Implementation for User Story 9
- [X] T093 [US9] Populate `ConditionTrace` on the decision result in src/RuleEngine/Resolve-UserPersona.ps1, built only when tracing is active
- [X] T094 [US9] Add the `conditionTrace` array to user events in src/Audit/New-PersonaAuditRecord.ps1, gated on `-Debug` or `logging.traceConditionValues`
**Checkpoint**: rule authors can debug a decision without loosening default logging.
---
## Phase 11: User Story 8 — Enforce changes in production (Priority: P3) 🔒 Gated
**Goal**: Write the calculated persona, only when changed, only the approved attribute.
**Independent Test**: Against test accounts, confirm only changed values are written, every request
body has exactly one key, and every `Updated` record carries `previousValue`.
**⚠️ Do not begin the tenant-facing tasks (T101T103) until**: V-4 security sign-off is recorded,
`-WhatIf` impact evidence from Scenario 4 is reviewed, and Phases 110 pass. Tasks T095T100 are
offline and may proceed at any time.
### Tests for User Story 8
- [X] T095 [P] [US8] Single-attribute body test in tests/Safety/WriteBody.Tests.ps1 asserting every captured body has exactly one key equal to `engine.targetAttribute` (SC-005)
- [X] T096 [P] [US8] Rejection tests in tests/Safety/WriteBodyRejection.Tests.ps1 asserting `New-PersonaWriteBody` throws for any other attribute name and for an attribute absent from `approvedWritableAttributes`
- [X] T097 [P] [US8] Write-gate tests in tests/Safety/WriteGate.Tests.ps1 covering all four FR-016 conditions, including that an `EvaluationError` user is never written
### Implementation for User Story 8
- [X] T098 [US8] Implement `New-PersonaWriteBody` in src/Persistence/New-PersonaWriteBody.ps1 as the only function permitted to construct a write body, returning a hashtable whose `Count` is exactly 1
- [X] T099 [US8] Implement `Set-UserPersonaAttribute` in src/Persistence/Set-UserPersonaAttribute.ps1 issuing `PATCH /v1.0/users/{id}`, reachable only when `ShouldProcess` returned true
- [X] T100 [US8] Capture `previousValue` at write time into the audit record in src/Persistence/Set-UserPersonaAttribute.ps1 — missing this in v1 makes OTD-010 rollback impossible retroactively
- [ ] T101 [US8] Record the V-4 security sign-off in specs/001-persona-engine/verification/V-4.md before any enforcement run
- [ ] T102 [US8] Close the V-1 write half against purpose-created test accounts of each origin type; append to specs/001-persona-engine/verification/V-1.md
- [ ] T103 [US8] Close V-2 by building a dynamic membership group on the persona extension and confirming a CA policy assigned to it applies; record in specs/001-persona-engine/verification/V-2.md
**Checkpoint**: enforcement works against test accounts with every safety invariant proven.
---
## Phase 12: Polish & Cross-Cutting Concerns
- [X] T104 [P] Add comment-based help to every public function across src/ (NFR-004)
- [X] T105 [P] Write docs/Architecture.md describing the adapter boundaries and why the rule engine is pure
- [X] T106 [P] Write docs/ConfigurationReference.md documenting every schema field and finding code
- [X] T107 [P] Write docs/SecurityModel.md recording the OTD-003 trade-off, the six compensating controls, and the V-4 sign-off
- [X] T108 [P] Write docs/OperationsRunbook.md including the kill switch and the rollback procedure
- [X] T109 [P] Write docs/BusinessRules.md and docs/Logging.md
- [X] T110 [P] Add the test stage to pipelines/test.yml publishing Pester results
- [X] T111 Build the FR/NFR traceability matrix in specs/001-persona-engine/traceability.md mapping every requirement ID to its implementing task and test
- [X] T112 Verify every exit code 06 is reachable via fault injection in tests/Unit/ExitCodes.Tests.ps1 (SC-011)
- [X] T113 Run tests/Test-Sanitization.ps1 across all tracked files and record the result in specs/001-persona-engine/verification/sanitization.md (SC-013)
- [X] T114 Update specs/001-persona-engine/spec.md Clarification Register to mark OTD-001 through OTD-005 resolved, citing research.md
---
## Phase 13: Stage B — Azure Automation (DEFERRED)
**Blocked**: no Automation account is available. Listed so the remaining entry cost stays visible and
nothing is lost. Nothing in Phases 112 depends on these.
- [ ] T115 Implement `Connect-PersonaGraphManagedIdentity` in src/Authentication/Connect-PersonaGraphManagedIdentity.ps1 returning the same handle shape as the interactive path
- [ ] T116 Close V-5b: confirm `Test-Json -SchemaFile` behaviour in the Automation runtime and record its exact PowerShell version in specs/001-persona-engine/verification/V-5b.md — run this first, it is the cheapest item most likely to surprise
- [ ] T117 Close V-3b: confirm the three read scopes work as application permissions on the managed identity; record in specs/001-persona-engine/verification/V-3b.md
- [ ] T118 Create the runbook wrapper pipelines/runbook/Invoke-PersonaEngineRunbook.ps1
- [ ] T119 Build pipelines/release.yml deploying to Automation with the schedule shipped **disabled**, a `-WhatIf` validation stage, and an approval gate before enforcement
- [ ] T120 Implement the run-start concurrency check in Invoke-PersonaEngine.ps1 per OTD-009
- [ ] T121 Run quickstart Scenario 5 and record results in specs/001-persona-engine/verification/Scenario5.md — **this is what allows v1 to be declared complete**
---
## Dependencies
```text
Phase 1 (Setup)
└─> Phase 2 (Foundational) ← blocks everything
└─> Phase 3 US2 Rule engine ← blocks US3, US1
└─> Phase 4 US3 Fail-safe ← blocks US1
└─> Phase 5 US1 Preview 🎯 MVP
├─> Phase 6 US5 Validation + editor
│ └─> Phase 7 US6 Pipeline mode
├─> Phase 8 US4 Summaries
│ └─> Phase 9 US7 Audit
│ └─> Phase 10 US9 Tracing
└─> Phase 11 US8 Enforcement 🔒 (also gated on V-4)
└─> Phase 12 Polish
└─> Phase 13 Stage B (deferred)
```
**Story independence, honestly stated**: US2 and US3 are genuinely independent of everything except
the foundation. US1 depends on both — the constitution's build order makes that unavoidable, and
pretending otherwise would produce a task list that cannot be executed in the mandated sequence.
US4US9 are independent of each other and may proceed in any order once US1 lands, except that US9
reads the audit shape US7 defines.
## Parallel opportunities
| Phase | Parallel set |
| --- | --- |
| 1 | T004, T005, T006, T007, T008 |
| 2 | T011, T012, T013, T014 |
| 3 | T016T020 (all tests, separate files) |
| 4 | T027, T028, T029 |
| 5 | T034T038 (tests); then T039, T040 |
| 6 | T057T060 |
| 7 | T071, T072 |
| 8 | T077, T078 |
| 9 | T083, T084, T085 |
| 10 | T091, T092 |
| 11 | T095, T096, T097 |
| 12 | T104T110 |
After Phase 5, three tracks can run concurrently: validation/editor (Phases 67),
observability (Phases 810), and the offline half of enforcement (T095T100).
## Implementation strategy
**MVP = Phases 15** (T001T056). Delivers complete classification visibility against the live tenant
with zero write capability, closes V-1 (read) and V-3, and establishes the performance baseline. This
is a genuinely useful deliverable on its own: it answers "what would this classify my tenant as?"
without touching anything.
**Increment 2 = Phases 67**. Configuration safety and CI gating — the prerequisite for letting anyone
other than the author edit rules.
**Increment 3 = Phases 810**. Observability, reconciliation, and audit. Required before enforcement
is defensible, because the `-WhatIf` impact evidence the constitution demands is only as good as the
output that produces it.
**Increment 4 = Phase 11**, behind the V-4 gate, against test accounts only.
**Stage B (Phase 13)** converts the result into an unattended service. Until T121 passes, the correct
status to report is **"Stage A complete"**, not "v1 done" — the Definition of Done requires an
Automation PowerShell 7 run.
## Task summary
| Phase | Story | Tasks | Count |
| --- | --- | --- | --- |
| 1 Setup | — | T001T008 | 8 |
| 2 Foundational | — | T009T015 | 7 |
| 3 | US2 (P1) | T016T026 | 11 |
| 4 | US3 (P1) | T027T033 | 7 |
| 5 | US1 (P1) 🎯 | T034T056 | 23 |
| 6 | US5 (P2) | T057T070 | 14 |
| 7 | US6 (P2) | T071T076 | 6 |
| 8 | US4 (P2) | T077T082 | 6 |
| 9 | US7 (P2) | T083T090 | 8 |
| 10 | US9 (P3) | T091T094 | 4 |
| 11 | US8 (P3) 🔒 | T095T103 | 9 |
| 12 Polish | — | T104T114 | 11 |
| 13 Stage B | — | T115T121 | 7 (deferred) |
| **Total** | | | **121** |
+114
View File
@@ -0,0 +1,114 @@
# Requirements traceability
Every functional requirement, non-functional requirement, and success criterion in
[spec.md](spec.md), mapped to the code that implements it and the test that holds it there.
A row with no test is a requirement nobody is checking. Those are listed explicitly at the bottom
rather than left out, because an incomplete matrix that looks complete is worse than no matrix.
**Status as at 2026-08-20**: 354 offline tests passing; engine purity and sanitization gates passing;
no tenant-dependent item verified.
## Functional requirements
| ID | Requirement | Implementation | Test |
| --- | --- | --- | --- |
| FR-001 | Load JSON configuration | `Import-PersonaConfiguration` | `LayerOrdering.Tests.ps1` |
| FR-002 | Validate before connecting | `Test-PersonaConfiguration`; entry script exits 1 before `Connect-` | `LayerOrdering.Tests.ps1`, `ExitCodes.Tests.ps1` |
| FR-003 | Adapter-isolated authentication | `Connect-PersonaGraphInteractive` | Purity gate; `ShouldProcessGate.Tests.ps1` |
| FR-004 | Enumerate all users with pagination | `Get-PersonaUsers` | `Pagination.Tests.ps1` |
| FR-005 | Select only required properties | `Get-PersonaRequiredProperties` | `Pagination.Tests.ps1` |
| FR-006 | Retrieve and cache related data | `Get-PersonaGroupMembership`, `Get-PersonaCachedMembership` | `Pagination.Tests.ps1`, `OutcomeExclusivity.Tests.ps1` |
| FR-007 | Normalize before evaluation | `ConvertTo-PersonaUserRecord`, `ConvertTo-PersonaMembershipRecord` | `RecordContracts.Tests.ps1` |
| FR-008 | Evaluate rules in priority order | `Resolve-UserPersona` | `Ordering.Tests.ps1` |
| FR-009 | Stop at first match | `Resolve-UserPersona` | `Ordering.Tests.ps1` |
| FR-010 | `Unclassified` when nothing matches | `Resolve-UserPersona` | `Unclassified.Tests.ps1` |
| FR-011 | Disabled accounts stay in scope | `New-PersonaUserRecord` exposes `AccountEnabled` | `Operators.Tests.ps1` |
| FR-012 | Null treated as empty | `Test-PersonaCondition` | `Operators.Tests.ps1` |
| FR-013 | Unretrievable group data yields `EvaluationError` | Tri-state evaluation; facet retrieval flags | `UnknownNotFalse.Tests.ps1`, `UnknownPropagation.Tests.ps1` |
| FR-014 | Preserve stored value on failure | `Compare-PersonaValue` sets `Skipped` first | `EvaluationError.Tests.ps1`, `WriteGate.Tests.ps1` |
| FR-015 | Compare stored and calculated | `Compare-PersonaValue`, ordinal | `WriteGate.Tests.ps1` |
| FR-016 | Write only changed values, four conditions | `Compare-PersonaValue` + run-loop gate | `WriteGate.Tests.ps1` |
| FR-017 | Preview issues no write request | `Invoke-PersonaEngineRun` gate | `WhatIfZeroWrites.Tests.ps1` |
| FR-018 | Immediate per-user output | `Write-UserPersonaResult` | Exercised by every run-loop suite |
| FR-019 | Periodic summary | `Write-PersonaSummary`, interval check | `SummaryInterval.Tests.ps1` |
| FR-020 | Interval semantics, final always shown | `Invoke-PersonaEngineRun` | `SummaryInterval.Tests.ps1` |
| FR-021 | Reconciliation at every summary | `Test-PersonaReconciliation` | `Reconciliation.Tests.ps1` |
| FR-022 | Structured audit records | `New-PersonaAuditRecord`, `Write-PersonaAuditRecord` | `AuditRecordShape.Tests.ps1` |
| FR-023 | Configuration editor | `Edit-PersonaEngineConfig.ps1` | `ExitCodes.Tests.ps1` (editor) |
| FR-024 | Non-interactive validation with exit codes | `-NonInteractive` short-circuit | `NonInteractive.Tests.ps1` |
| FR-025 | Synthetic rule testing, no tenant | `Invoke-SyntheticRuleTest` | `NonInteractive.Tests.ps1` |
| FR-026 | Validate and back up before save | `Save-PersonaConfiguration` | `Safety.Tests.ps1` (`PE-SAF-007`) |
## Rule engine requirements
| ID | Requirement | Implementation | Test |
| --- | --- | --- | --- |
| RE-001 | Required rule fields | Schema `definitions/rule` | `LayerOrdering.Tests.ps1` |
| RE-002 | Unique priorities, lower first | `Resolve-UserPersona`; `PE-SEM-002` | `Ordering.Tests.ps1`, `Semantic.Tests.ps1` |
| RE-003 | `all` / `any` with nesting | `Test-PersonaConditionGroup` | `Composition.Tests.ps1` |
| RE-004 | Depth limit and hard ceiling | `Test-PersonaConditionGroup`; `PE-SEM-012`, `PE-SEM-013` | `Composition.Tests.ps1`, `Semantic.Tests.ps1` |
| RE-005 | Thirteen operators | `Test-PersonaCondition` | `Operators.Tests.ps1` |
| RE-006 | Case-insensitive; regex validated first | `Test-PersonaCondition`; `PE-SEM-016` | `Operators.Tests.ps1`, `Semantic.Tests.ps1` |
| RE-007 | Per-condition membership mode | Three-facet `MembershipRecord` | `RecordContracts.Tests.ps1`, `UnknownPropagation.Tests.ps1` |
| RE-008 | Combined identity sources | `Get-PersonaRequiredFacets` | `OutcomeExclusivity.Tests.ps1` |
| RE-009 | Special accounts by Object ID | Example configuration; no hard-coded path | Purity gate |
## Validation requirements
| ID | Requirement | Implementation | Test |
| --- | --- | --- | --- |
| VR-001 | Four ordered layers, fail-fast | `Test-PersonaConfiguration` | `LayerOrdering.Tests.ps1` |
| VR-002 | Sixteen semantic conditions | `Test-PersonaConfigurationSemantic` | `Semantic.Tests.ps1` — one test per code |
| VR-003 | Seven safety conditions | `Test-PersonaConfigurationSafety` | `Safety.Tests.ps1`, `TraceAcknowledgement.Tests.ps1` |
| VR-004 | Finding shape | `New-PersonaValidationFinding` | `Semantic.Tests.ps1`, `Safety.Tests.ps1`, `RecordContracts.Tests.ps1` |
| VR-005 | Warnings block only on request | Editor exit-code mapping | `ExitCodes.Tests.ps1` (editor) |
## Non-functional requirements
| ID | Requirement | Implementation | Test |
| --- | --- | --- | --- |
| NFR-001 | PowerShell 7 | `#Requires -Version 7.2`; manifest floor | Runs on 7.6.5 |
| NFR-002 | Caching, per-user and total duration | `New-PersonaDataCache`; stopwatch in `Resolve-UserPersona`; `RunComplete.durationMs` | `AuditCompleteness.Tests.ps1`**no target set** |
| NFR-003 | Pagination, bounded retry, backoff | `Invoke-PersonaGraphRequest` | `RetryPolicy.Tests.ps1`, `Pagination.Tests.ps1` |
| NFR-004 | Comment-based help on public functions | Every function in `src/` | Manual review |
| NFR-005 | Run ID, UPN, Object ID, config hash | `New-PersonaAuditContext` | `AuditCompleteness.Tests.ps1` |
| NFR-006 | Least privilege, single attribute, no secrets | Six OTD-003 controls | `WriteBody.Tests.ps1`, `WriteBodyRejection.Tests.ps1`, sanitization gate |
| NFR-007 | Engine and validation run without the platform | Purity; layer dot-sourcing | Purity gate; whole offline suite |
| NFR-008 | No Windows PowerShell-only dependencies | `Microsoft.Graph.Authentication` only | **Unverified in Automation (V-5b)** |
## Success criteria
| ID | Criterion | Test | Status |
| --- | --- | --- | --- |
| SC-001 | Exactly one outcome per user | `OutcomeExclusivity.Tests.ps1` | Passing |
| SC-002 | Second run proposes zero changes | `Idempotence.Tests.ps1` | Passing |
| SC-003 | Determinism across shuffled input | `Determinism.Tests.ps1` | Passing |
| SC-004 | Zero writes under `-WhatIf` | `WhatIfZeroWrites.Tests.ps1` | Passing |
| SC-005 | Single-attribute request body | `WriteBody.Tests.ps1` | Passing |
| SC-006 | Audit completeness | `AuditCompleteness.Tests.ps1` | Passing |
| SC-007 | Reconciliation, and its failure path | `Reconciliation.Tests.ps1` | Passing |
| SC-008 | Everything runs offline | Whole offline suite; `validate.yml` gate 7 | Passing |
| SC-009 | Every VR-002 and VR-003 condition detected | `Semantic.Tests.ps1`, `Safety.Tests.ps1` | Passing |
| SC-010 | Non-interactive never prompts or hangs | `NonInteractive.Tests.ps1` | Passing |
| SC-011 | Every exit code reachable | `ExitCodes.Tests.ps1` (both) | Passing |
| SC-012 | Per-user output is immediate | `Write-UserPersonaResult` emits in `process` | Structural, not timed |
| SC-013 | No tenant data committed | `Test-Sanitization.ps1` | Passing |
## Gaps, stated plainly
| Item | Why it is not covered | What would close it |
| --- | --- | --- |
| NFR-002 performance | No target exists until representative tenant testing | A timed run against a real population |
| NFR-004 help coverage | Reviewed by eye, not asserted | A test parsing every exported function for a help block |
| NFR-008 Automation compatibility | No Automation account available | V-5b (T116) |
| SC-012 timing | Asserted structurally, not measured | A timed harness — low value against the cost |
| V-1, V-2, V-3 | Require a tenant | Stage A2 and A3 runs |
| V-4 | Requires a person | Written security sign-off |
| Phase 13 (T115T121) | Requires an Automation account | Stage B |
## How to keep this honest
When a requirement's implementation moves, this table moves with it. When a test is deleted, the row
it backed becomes a gap and belongs in the gaps table, not silently in the main one. A matrix that is
allowed to drift is worse than none, because it converts "we do not know" into "we checked".
@@ -0,0 +1,57 @@
# V-5a — `Test-Json -SchemaFile` failure behaviour (local PowerShell)
**Status**: CLOSED
**Date**: 2026-08-20
**Environment**: PowerShell 7.6.5, Windows 11, local workstation (Stage A1 — offline, no tenant)
**Task**: T061
**Pinned by**: [tests/Configuration/TestJsonBehaviour.Tests.ps1](../../../tests/Configuration/TestJsonBehaviour.Tests.ps1)
## Question
OTD-005 selected `Test-Json -SchemaFile` for layer 2 validation. The documented risk was that
`Test-Json` reports schema failure inconsistently across PowerShell versions — returning `$false`,
writing a non-terminating error, or throwing. Layer 2 cannot be written until the actual behaviour
on the target build is observed rather than assumed.
## Observed behaviour
| Scenario | Return value | Error stream | Terminating? |
| --- | --- | --- | --- |
| Valid document | `$true` | empty | no |
| Type mismatch (`"a": 123` against `"type": "string"`) | `$false` | 1 error: `The JSON is not valid with the schema: Value is "integer" but should be "string" at '/a'` | no |
| Missing required property | `$false` | 1 error: `The JSON is not valid with the schema: Required properties ["a"] are not present at ''` | no |
| **Schema file itself unparseable** | **`$true`** | 1 error: `Cannot parse the JSON schema.` | no |
Exception type on the error record is `System.Exception` in every failing case — there is no
distinct exception type to branch on, so the wrapper must branch on the message text or, better, on
error presence alone.
## Findings that shape the implementation
1. **Non-terminating, not throwing.** With the default `$ErrorActionPreference = 'Continue'` the
cmdlet writes to the error stream and execution continues, returning `$false`. It does not throw.
`-ErrorAction SilentlyContinue -ErrorVariable` is therefore sufficient to capture failures, as
the editor contract requires.
2. **An unparseable schema returns `$true`.** This is the load-bearing observation. A wrapper that
trusted the return value alone would report a configuration as schema-valid when the schema never
ran. Layer 2 MUST treat "error variable is non-empty" as failure regardless of the return value,
and MUST distinguish the `Cannot parse the JSON schema.` message so it can surface exit code 4
(schema file not found or itself invalid) rather than exit code 1 (configuration invalid).
3. **One error per violating location, but not exhaustive.** Two independent property violations
yield two error records, each with its own JSON pointer. Deeper or nested subschema failures may
still be reported as a single error at the outermost failing location. The wrapper therefore
emits one finding per collected error rather than assuming a single one, and the author may still
need more than one validation pass to see everything. That residual limitation is documented
rather than worked around — full violation reporting would require replacing `Test-Json` with a
third-party validator, which OTD-005 rejected.
## Consequences recorded elsewhere
- Layer 2 implementation: [src/Configuration/Test-PersonaConfiguration.ps1](../../../src/Configuration/Test-PersonaConfiguration.ps1)
- Regression pin: `tests/Configuration/TestJsonBehaviour.Tests.ps1` fails if a future PowerShell
build changes any row of the table above.
- **V-5b remains open**: this observation is for PowerShell 7.6.5 only. The Azure Automation runtime
version is unverified, and finding 2 in particular is version-sensitive. Re-run this probe there
before Stage B (T116).
@@ -0,0 +1,76 @@
# Sanitization scan result (SC-013)
**Status**: PASS
**Date**: 2026-08-20
**Task**: T113
**Scanner**: [tests/Test-Sanitization.ps1](../../../tests/Test-Sanitization.ps1)
**Files scanned**: 156
## What is scanned
`git ls-files --cached --others --exclude-standard` — tracked files **and** untracked files that are
not gitignored.
The original scanner walked `git ls-files` alone, which covered only tracked files. That made the
gate useless where it matters most: a leaked identifier in a file that has not been committed yet is
precisely the one worth catching, and scanning only what is already in history means the scan passes
right up until the commit that makes it too late. At the time this was found, the scan was covering
34 of the repository's 156 files and none of the implementation written in this phase.
`--exclude-standard` keeps gitignored build output and local scratch files out, so the scan covers
exactly what a commit would add.
## Patterns
| Pattern | Exemptions |
| --- | --- |
| GUIDs | Placeholder-shaped GUIDs (`00000000-0000-0000-0000-0000000000a0`); the module manifest's own `GUID =` identity line |
| Email addresses and UPNs | RFC 2606 / RFC 6761 reserved domains: `example.com/net/org`, `.invalid`, `.test`, `.localhost` |
| `onmicrosoft.com` domains | none |
| JWT and bearer-token shapes | none |
| Assigned secret, password, or key literals | none |
| PEM private key blocks | none |
Two exemptions were added during this scan, both narrow and both for things that cannot be replaced
with a placeholder:
**Reserved domains.** `alex.employee@example.invalid` is guaranteed by RFC to be unresolvable.
Rejecting reserved domains would push fixtures toward addresses that merely *look* fake, which is
worse — the difference between "obviously synthetic" and "probably nobody's" is the entire reason the
reserved list exists.
**The module manifest GUID.** A PowerShell module manifest must carry a genuine unique GUID as its
identity; it is what distinguishes this module from another of the same name. It identifies the
module, not a tenant. The exemption is **line-level** (`^\s*GUID\s*=`), not file-level: exempting the
whole manifest would let a real identifier land anywhere in it.
## Verification of the scanner itself
A negative control was run: a scratch file containing an email address on a real-world commercial
domain and a randomly generated real-shaped GUID was added to the working tree **without** committing
it. The scan failed with two findings and named both, by file and line. The file was then removed and
the scan returned to PASS.
The offending values are described here rather than quoted, because quoting them would make this
record itself a finding — which the scan promptly demonstrated when an earlier draft did exactly
that. That is the control working.
Without a negative control, a scanner that had silently stopped matching would report the same green
result as one that is working.
## Result
```
Sanitization scan passed: no tenant data, credentials, or real identifiers found.
```
## Standing obligations
This is a point-in-time result, not a property of the repository. The scan is **gate 1** of
[pipelines/validate.yml](../../../pipelines/validate.yml) and runs before every other gate on every
pull request — deliberately first, because a leaked identifier is a problem whether or not the code
compiles, and every later gate prints file contents into build logs.
Runtime audit records legitimately contain real UPNs and Object IDs, which are approved for logs. No
such value may ever be committed. When attaching evidence to a verification record (V-1, V-2, V-3),
redact identifiers to placeholders first.
+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
}
+130
View File
@@ -0,0 +1,130 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
Editor exit codes 0-4 (cli-edit-persona-engine-config.md).
An exit code is the only thing a pipeline sees. Every code has to be reachable and
has to mean what the contract says - in particular, code 3 (file unreadable) and
code 4 (schema unusable) must not collapse into code 1, or a missing schema file
gets reported to a rule author as "your configuration is invalid".
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
$script:editor = Join-Path $repoRoot 'Edit-PersonaEngineConfig.ps1'
$script:scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-ec-{0}" -f [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $script:scratch -Force
function Invoke-Editor {
param([string[]] $ArgumentList)
$exe = (Get-Process -Id $PID).Path
$null = & $exe -NoProfile -NonInteractive -File $script:editor @ArgumentList 2>&1
$LASTEXITCODE
}
}
AfterAll {
Remove-Item -LiteralPath $script:scratch -Recurse -Force -ErrorAction SilentlyContinue
}
Describe 'Editor exit codes' {
It 'returns 0 for a valid configuration' {
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
Invoke-Editor -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive') | Should -Be 0
}
It 'returns 1 when Error findings are present' {
$document = New-TestConfigurationDocument
$document.rules[1].persona = 'Undeclared-Persona'
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
Invoke-Editor -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive') | Should -Be 1
}
It 'returns 2 for Warning findings under -TreatWarningsAsErrors' {
# A pinned global mode plus a per-condition override: a Warning, not an Error.
# VR-005 escalates it only when the caller asks.
$document = New-TestConfigurationDocument
$document.dataSources.groups.membershipMode = 'direct'
$document.rules += @{
id = 'RULE-0030-TIER0'; name = 'Tier 0'; description = 'Tier 0 group members.'
enabled = $true; priority = 30; persona = 'Tier0-Admin'
match = @{
operator = 'all'
conditions = @(@{
type = 'membership'; operator = 'memberOf'; membershipMode = 'transitive'
groupObjectIds = @('00000000-0000-0000-0000-0000000000a0')
})
}
}
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
Invoke-Editor -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive') | Should -Be 0
Invoke-Editor -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive', '-TreatWarningsAsErrors') | Should -Be 2
}
It 'returns 3 when the configuration file is absent' {
Invoke-Editor -ArgumentList @('-ConfigPath', (Join-Path $script:scratch 'absent.json'), '-ValidateOnly', '-NonInteractive') | Should -Be 3
}
It 'returns 4 when the schema file is absent' {
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
Invoke-Editor -ArgumentList @(
'-ConfigPath', $path, '-ValidateOnly', '-NonInteractive',
'-SchemaPath', (Join-Path $script:scratch 'no-schema.json')) | Should -Be 4
}
It 'returns 4 when the schema file exists but cannot be parsed' {
# Distinct from code 1 on purpose. The configuration was never actually
# checked, so calling it invalid would be a guess.
$brokenSchema = Join-Path $script:scratch 'broken.json'
Set-Content -LiteralPath $brokenSchema -Value '{ not json' -Encoding utf8NoBOM
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
Invoke-Editor -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive', '-SchemaPath', $brokenSchema) | Should -Be 4
}
It 'prefers code 3 over code 1 when the file cannot be read at all' {
# An unreadable file produces a PE-SYN Error finding too. Reporting code 1
# would tell the author their rules are wrong when the file never opened.
Invoke-Editor -ArgumentList @('-ConfigPath', $script:scratch, '-ValidateOnly', '-NonInteractive') | Should -Be 3
}
It 'reaches every documented code across the corpus' {
$reached = [System.Collections.Generic.HashSet[int]]::new()
$valid = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
$null = $reached.Add((Invoke-Editor -ArgumentList @('-ConfigPath', $valid, '-ValidateOnly', '-NonInteractive')))
$invalidDoc = New-TestConfigurationDocument
$invalidDoc.rules[1].persona = 'Undeclared-Persona'
$invalid = Save-TestConfiguration -Document $invalidDoc -Directory $script:scratch
$null = $reached.Add((Invoke-Editor -ArgumentList @('-ConfigPath', $invalid, '-ValidateOnly', '-NonInteractive')))
$warnDoc = New-TestConfigurationDocument
$warnDoc.dataSources.groups.membershipMode = 'direct'
$warnDoc.rules += @{
id = 'RULE-0030-TIER0'; name = 'Tier 0'; description = 'Tier 0 group members.'
enabled = $true; priority = 30; persona = 'Tier0-Admin'
match = @{ operator = 'all'; conditions = @(@{
type = 'membership'; operator = 'memberOf'; membershipMode = 'transitive'
groupObjectIds = @('00000000-0000-0000-0000-0000000000a0') }) }
}
$warn = Save-TestConfiguration -Document $warnDoc -Directory $script:scratch
$null = $reached.Add((Invoke-Editor -ArgumentList @('-ConfigPath', $warn, '-ValidateOnly', '-NonInteractive', '-TreatWarningsAsErrors')))
$null = $reached.Add((Invoke-Editor -ArgumentList @('-ConfigPath', (Join-Path $script:scratch 'absent.json'), '-ValidateOnly', '-NonInteractive')))
$null = $reached.Add((Invoke-Editor -ArgumentList @('-ConfigPath', $valid, '-ValidateOnly', '-NonInteractive', '-SchemaPath', (Join-Path $script:scratch 'no-schema.json'))))
0..4 | ForEach-Object { $reached | Should -Contain $_ }
}
}
+127
View File
@@ -0,0 +1,127 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
VR-001: the four layers run in order and stop at the first that produces errors.
The reason is signal, not speed. A document missing a required section produces a
cascade of consequent semantic errors, and the author then has to guess which one
is the cause. Stopping at the structural failure reports the one thing that is
actually wrong.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:schema = Join-Path $repoRoot 'config/persona-engine.schema.json'
$script:scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-layers-{0}" -f [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $script:scratch -Force
}
AfterAll {
Remove-Item -LiteralPath $script:scratch -Recurse -Force -ErrorAction SilentlyContinue
}
Describe 'Layer ordering and fail-fast (VR-001)' {
It 'stops at layer 1 for malformed JSON' {
$path = Join-Path $script:scratch 'malformed.json'
Set-Content -LiteralPath $path -Value '{ "configVersion": "1.0.0", ' -Encoding utf8NoBOM
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
$result.IsValid | Should -BeFalse
$result.StoppedAtLayer | Should -Be 'Syntax'
$result.Findings.Code | Should -Contain 'PE-SYN-003'
}
It 'reports a missing file at layer 1 without attempting to parse it' {
$result = Test-PersonaConfiguration -Path (Join-Path $script:scratch 'absent.json') -SchemaPath $script:schema
$result.StoppedAtLayer | Should -Be 'Syntax'
$result.Findings.Code | Should -Contain 'PE-SYN-001'
}
It 'stops at layer 2 for a structurally invalid document, before semantic checks run' {
# The document below also has a semantic defect - a duplicate rule ID - which
# must NOT appear, because layer 3 never ran.
$document = New-TestConfigurationDocument
$document.rules[1].id = $document.rules[0].id
$document.engine.Remove('approvedWritableAttributes')
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
$result.IsValid | Should -BeFalse
$result.StoppedAtLayer | Should -Be 'Schema'
$result.Findings.Code | Should -Contain 'PE-SCH-001'
$result.Findings.Code | Should -Not -Contain 'PE-SEM-001'
}
It 'stops at layer 3 for a semantic error, before safety checks run' {
$document = New-TestConfigurationDocument
$document.rules[1].id = $document.rules[0].id
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
$result.StoppedAtLayer | Should -Be 'Semantic'
$result.Findings.Code | Should -Contain 'PE-SEM-001'
@($result.Findings | Where-Object Layer -EQ 'Safety') | Should -BeNullOrEmpty
}
It 'reaches layer 4 when layers 1 to 3 are clean' {
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
$result.IsValid | Should -BeTrue
$result.StoppedAtLayer | Should -BeNullOrEmpty
@($result.Findings | Where-Object Layer -EQ 'Safety') | Should -Not -BeNullOrEmpty
}
It 'skips layer 4 on request without claiming it passed' {
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema -SkipSafety
@($result.Findings | Where-Object Layer -EQ 'Safety') | Should -BeNullOrEmpty
}
}
Describe 'A schema that cannot be used is never reported as a pass (V-5a)' {
It 'flags an unparseable schema rather than trusting the $true return value' {
# Test-Json returns $true here. Trusting it would validate every configuration
# against a schema that never ran.
$brokenSchema = Join-Path $script:scratch 'broken-schema.json'
Set-Content -LiteralPath $brokenSchema -Value '{ not json' -Encoding utf8NoBOM
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
$result = Test-PersonaConfiguration -Path $path -SchemaPath $brokenSchema
$result.IsValid | Should -BeFalse
$result.SchemaUnusable | Should -BeTrue
$result.Findings.Code | Should -Contain 'PE-SCH-003'
}
It 'flags a missing schema file separately from an invalid configuration' {
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
$result = Test-PersonaConfiguration -Path $path -SchemaPath (Join-Path $script:scratch 'no-such-schema.json')
$result.SchemaUnusable | Should -BeTrue
$result.Findings.Code | Should -Contain 'PE-SCH-002'
}
}
Describe 'The shipped example configuration passes every layer' {
It 'validates cleanly against the shipped schema' {
# If the example the documentation points at cannot pass its own validator,
# every reader's first run fails.
$result = Test-PersonaConfiguration -Path (Join-Path $repoRoot 'config/persona-engine.example.json')
$result.IsValid | Should -BeTrue
$result.ErrorCount | Should -Be 0
$result.WarningCount | Should -Be 0
}
}
@@ -0,0 +1,124 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
SC-010: -NonInteractive never prompts and never hangs.
Each case runs the editor in a child pwsh with stdin redirected from an empty
file, which is what a build agent gives it. A tool that prompts there does not
fail - it blocks until the job times out, and the pipeline reports an
infrastructure problem rather than a bad configuration.
A wall-clock timeout is the assertion. That makes these the slowest tests in the
suite, and there is no cheaper way to prove the absence of a hang: inspecting the
source for Read-Host would only prove that one spelling of prompting is absent.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
$script:editor = Join-Path $repoRoot 'Edit-PersonaEngineConfig.ps1'
$script:scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-ni-{0}" -f [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $script:scratch -Force
$script:emptyStdin = Join-Path $script:scratch 'empty.txt'
Set-Content -LiteralPath $script:emptyStdin -Value '' -NoNewline
function Invoke-EditorWithClosedStdin {
<#
Runs the editor with stdin from an empty file and a hard timeout.
Returns the exit code, or -1 if it had to be killed.
#>
param(
[string[]] $ArgumentList,
[int] $TimeoutSeconds = 60
)
$stdout = Join-Path $script:scratch ("out-{0}.txt" -f [guid]::NewGuid().ToString('N'))
$stderr = Join-Path $script:scratch ("err-{0}.txt" -f [guid]::NewGuid().ToString('N'))
$process = Start-Process -FilePath (Get-Process -Id $PID).Path `
-ArgumentList (@('-NoProfile', '-NonInteractive', '-File', $script:editor) + $ArgumentList) `
-RedirectStandardInput $script:emptyStdin `
-RedirectStandardOutput $stdout `
-RedirectStandardError $stderr `
-PassThru -WindowStyle Hidden
if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
$process.Kill($true)
return [pscustomobject]@{ ExitCode = -1; Output = 'TIMED OUT'; TimedOut = $true }
}
[pscustomobject]@{
ExitCode = $process.ExitCode
Output = (Get-Content -LiteralPath $stdout -Raw -ErrorAction SilentlyContinue)
Error = (Get-Content -LiteralPath $stderr -Raw -ErrorAction SilentlyContinue)
TimedOut = $false
}
}
}
AfterAll {
Remove-Item -LiteralPath $script:scratch -Recurse -Force -ErrorAction SilentlyContinue
}
Describe 'Non-interactive mode never prompts or hangs (SC-010)' {
It 'completes on a valid configuration with stdin closed' {
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
$run = Invoke-EditorWithClosedStdin -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive')
$run.TimedOut | Should -BeFalse
$run.ExitCode | Should -Be 0
}
It 'completes on an invalid configuration with stdin closed' {
$document = New-TestConfigurationDocument
$document.rules[1].id = $document.rules[0].id
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
$run = Invoke-EditorWithClosedStdin -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive')
$run.TimedOut | Should -BeFalse
$run.ExitCode | Should -Be 1
}
It 'completes with -NonInteractive alone, without -ValidateOnly' {
# The case most likely to regress: -NonInteractive must short-circuit before
# the editor loop even when the caller did not also ask for validate-only.
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
$run = Invoke-EditorWithClosedStdin -ArgumentList @('-ConfigPath', $path, '-NonInteractive')
$run.TimedOut | Should -BeFalse
$run.ExitCode | Should -Be 0
}
It 'completes when the configuration file does not exist' {
$run = Invoke-EditorWithClosedStdin -ArgumentList @('-ConfigPath', (Join-Path $script:scratch 'absent.json'), '-NonInteractive')
$run.TimedOut | Should -BeFalse
$run.ExitCode | Should -Be 3
}
It 'completes when running synthetic rule tests' {
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
$run = Invoke-EditorWithClosedStdin -ArgumentList @(
'-ConfigPath', $path, '-NonInteractive', '-TestDataPath', (Join-Path $repoRoot 'tests/TestData'))
$run.TimedOut | Should -BeFalse
$run.ExitCode | Should -Be 0
}
It 'emits no prompt text on the output stream' {
$path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch
$run = Invoke-EditorWithClosedStdin -ArgumentList @('-ConfigPath', $path, '-NonInteractive')
$run.Output | Should -Not -Match 'Choice'
$run.Output | Should -Not -Match 'configuration editor'
}
}
+175
View File
@@ -0,0 +1,175 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
SC-009, VR-003 half: every safety condition produces a finding.
Layer 3 asks whether a configuration makes sense. These tests are about layer 4,
which asks what happens to the directory if it runs - a coherent configuration can
still be a silent no-op or a change nobody declared.
Several findings change severity with -EnforcementEnabled. Both cases are asserted
for each: the same configuration carries very different risk in preview and in
enforcement, and a validator that ignored the difference would either block
harmless previews or wave through real writes.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:corpus = Join-Path $repoRoot 'tests/TestData/InvalidConfigs'
$script:baseline = Join-Path $script:corpus 'baseline-deployed.json'
function Get-SafetyFinding {
param(
[string] $Fixture,
[string] $Code,
[switch] $EnforcementEnabled,
[string] $PreviousConfigPath
)
$document = Get-Content -LiteralPath (Join-Path $script:corpus "$Fixture.json") -Raw | ConvertFrom-Json -Depth 32
$params = @{ Document = $document; EnforcementEnabled = $EnforcementEnabled }
if ($PreviousConfigPath) { $params['PreviousConfigPath'] = $PreviousConfigPath }
@(Test-PersonaConfigurationSafety @params | Where-Object Code -EQ $Code)
}
}
Describe 'Safety validation, VR-003 conditions (SC-009)' {
It 'detects a blank target attribute and blocks it under enforcement' {
$findings = Get-SafetyFinding -Fixture 'PE-SAF-001-blank-target-production' -Code 'PE-SAF-001' -EnforcementEnabled
$findings.Count | Should -Be 1
$findings[0].Severity | Should -Be 'Error'
}
It 'reports the same blank target attribute as a Warning in preview' {
# In preview there is nothing to write, so the configuration is merely
# pointless rather than dangerous.
$findings = Get-SafetyFinding -Fixture 'PE-SAF-001-blank-target-production' -Code 'PE-SAF-001'
$findings.Count | Should -Be 1
$findings[0].Severity | Should -Be 'Warning'
}
It 'detects an unsupported writable attribute' {
# 'department' is authoritative in the sync source. Holding permission to
# write it is not the same as owning it.
$findings = Get-SafetyFinding -Fixture 'PE-SAF-002-unsupported-writable-attribute' -Code 'PE-SAF-002'
$findings.Count | Should -BeGreaterThan 0
$findings[0].Severity | Should -Be 'Error'
$findings[0].Description | Should -Match 'department'
}
It 'detects enabled group rules while group retrieval is disabled' {
$findings = Get-SafetyFinding -Fixture 'PE-SAF-003-group-rules-without-group-retrieval' -Code 'PE-SAF-003'
$findings.Count | Should -BeGreaterThan 0
$findings[0].Severity | Should -Be 'Error'
$findings[0].Location | Should -Be 'dataSources.groups.enabled'
}
It 'detects a configuration version downgrade against the deployed baseline' {
$findings = Get-SafetyFinding -Fixture 'PE-SAF-004-version-downgrade' -Code 'PE-SAF-004' `
-PreviousConfigPath $script:baseline -EnforcementEnabled
$findings.Count | Should -BeGreaterThan 0
$findings[0].Severity | Should -Be 'Error'
$findings[0].Location | Should -Be 'configVersion'
}
It 'detects a rule removed without a version change' {
$findings = Get-SafetyFinding -Fixture 'PE-SAF-005-rule-removed-without-version-change' -Code 'PE-SAF-005' `
-PreviousConfigPath $script:baseline -EnforcementEnabled
$findings.Count | Should -BeGreaterThan 0
$findings[0].Severity | Should -Be 'Error'
$findings[0].Description | Should -Match 'RULE-0900-EMPLOYEE'
}
It 'accepts a rule removal when the version was raised to declare it' {
$document = Get-Content -LiteralPath (Join-Path $script:corpus 'PE-SAF-005-rule-removed-without-version-change.json') -Raw | ConvertFrom-Json -Depth 32
$document.configVersion = '1.1.0'
$findings = @(Test-PersonaConfigurationSafety -Document $document -PreviousConfigPath $script:baseline -EnforcementEnabled |
Where-Object Code -EQ 'PE-SAF-005')
$findings.Count | Should -Be 0
}
It 'detects condition tracing enabled without acknowledgement' {
$findings = Get-SafetyFinding -Fixture 'PE-SAF-006-tracing-without-acknowledgement' -Code 'PE-SAF-006'
$findings.Count | Should -Be 1
$findings[0].Severity | Should -Be 'Error'
$findings[0].Location | Should -Be 'logging.traceConditionValues'
}
It 'accepts tracing when it is acknowledged in the same configuration' {
$document = Get-Content -LiteralPath (Join-Path $script:corpus 'PE-SAF-006-tracing-without-acknowledgement.json') -Raw | ConvertFrom-Json -Depth 32
$document.logging | Add-Member -NotePropertyName 'acknowledgeConditionTracing' -NotePropertyValue $true
@(Test-PersonaConfigurationSafety -Document $document | Where-Object Code -EQ 'PE-SAF-006') | Should -BeNullOrEmpty
}
It 'detects a save that would overwrite an existing configuration with no backup' {
$document = Get-Content -LiteralPath $script:baseline -Raw | ConvertFrom-Json -Depth 32
$findings = @(Test-PersonaConfigurationSafety -Document $document -SavePath $script:baseline |
Where-Object Code -EQ 'PE-SAF-007')
$findings.Count | Should -Be 1
$findings[0].Severity | Should -Be 'Error'
}
It 'accepts the same save when a backup is planned' {
$document = Get-Content -LiteralPath $script:baseline -Raw | ConvertFrom-Json -Depth 32
@(Test-PersonaConfigurationSafety -Document $document -SavePath $script:baseline -BackupPlanned |
Where-Object Code -EQ 'PE-SAF-007') | Should -BeNullOrEmpty
}
}
Describe 'A skipped check is reported as skipped, never as a pass' {
It 'reports an Information finding when no baseline is supplied' {
# Silence would be read as approval. The comparison checks did not run, and
# the output has to say so.
$document = Get-Content -LiteralPath $script:baseline -Raw | ConvertFrom-Json -Depth 32
$findings = @(Test-PersonaConfigurationSafety -Document $document | Where-Object Code -EQ 'PE-SAF-004')
$findings.Count | Should -Be 1
$findings[0].Severity | Should -Be 'Information'
$findings[0].Description | Should -Match 'not a pass'
}
It 'does not block on the skipped-check notice' {
$document = Get-Content -LiteralPath $script:baseline -Raw | ConvertFrom-Json -Depth 32
@(Test-PersonaConfigurationSafety -Document $document | Where-Object Severity -EQ 'Error') | Should -BeNullOrEmpty
}
}
Describe 'Safety findings carry everything VR-004 requires' {
It 'gives every finding a severity, code, location, description, resolution, and layer' {
foreach ($fixture in (Get-ChildItem -Path $script:corpus -Filter 'PE-SAF-*.json')) {
$document = Get-Content -LiteralPath $fixture.FullName -Raw | ConvertFrom-Json -Depth 32
foreach ($finding in (Test-PersonaConfigurationSafety -Document $document -PreviousConfigPath $script:baseline)) {
$finding.Severity | Should -BeIn @('Error', 'Warning', 'Information')
$finding.Code | Should -Match '^PE-SAF-\d{3}$'
$finding.Location | Should -Not -BeNullOrEmpty
$finding.Description | Should -Not -BeNullOrEmpty
$finding.SuggestedResolution | Should -Not -BeNullOrEmpty
$finding.Layer | Should -Be 'Safety'
}
}
}
}
+190
View File
@@ -0,0 +1,190 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
SC-009: every VR-002 condition produces a finding with a code, a severity, and a
location.
Layer 3 is exercised directly rather than through Test-PersonaConfiguration. Most
of these defects are also caught by the JSON Schema, so a full-pipeline test would
stop at layer 2 and never reach the code under test - it would be asserting that
the schema works, which LayerOrdering.Tests.ps1 already does.
The overlap is deliberate (see the note in Test-PersonaConfigurationSemantic):
layer 2 can be bypassed with -SchemaPath, and V-5a showed an unparseable schema
passes silently on this build. Anything that can misclassify a privileged account
is checked twice.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:corpus = Join-Path $repoRoot 'tests/TestData/InvalidConfigs'
function Get-Finding {
param([string] $Fixture, [string] $Code)
$document = Get-Content -LiteralPath (Join-Path $script:corpus "$Fixture.json") -Raw | ConvertFrom-Json -Depth 32
@(Test-PersonaConfigurationSemantic -Document $document | Where-Object Code -EQ $Code)
}
}
Describe 'Semantic validation, VR-002 conditions (SC-009)' {
It 'detects duplicate rule IDs' {
$findings = Get-Finding -Fixture 'PE-SEM-001-duplicate-rule-id' -Code 'PE-SEM-001'
$findings.Count | Should -BeGreaterThan 0
$findings[0].Severity | Should -Be 'Error'
$findings[0].Location | Should -Match 'RULE-0010-GUEST'
}
It 'detects duplicate priorities among enabled rules' {
$findings = Get-Finding -Fixture 'PE-SEM-002-duplicate-priority' -Code 'PE-SEM-002'
$findings.Count | Should -BeGreaterThan 0
$findings[0].Severity | Should -Be 'Error'
$findings[0].Description | Should -Match 'RULE-0010-GUEST'
$findings[0].Description | Should -Match 'RULE-0900-EMPLOYEE'
}
It 'detects a configuration with no enabled rules' {
$findings = Get-Finding -Fixture 'PE-SEM-003-no-enabled-rules' -Code 'PE-SEM-003'
$findings.Count | Should -Be 1
$findings[0].Severity | Should -Be 'Error'
}
It 'detects a blank target attribute' {
$findings = Get-Finding -Fixture 'PE-SEM-004-blank-target-attribute' -Code 'PE-SEM-004'
$findings.Count | Should -Be 1
$findings[0].Location | Should -Be 'engine.targetAttribute'
}
It 'detects a target attribute absent from the approved list' {
$findings = Get-Finding -Fixture 'PE-SEM-005-target-not-approved' -Code 'PE-SEM-005'
$findings.Count | Should -Be 1
$findings[0].Severity | Should -Be 'Error'
}
It 'detects a reference to a disabled data source' {
$findings = Get-Finding -Fixture 'PE-SEM-006-unavailable-data-source' -Code 'PE-SEM-006'
$findings.Count | Should -BeGreaterThan 0
$findings[0].Severity | Should -Be 'Error'
$findings[0].Description | Should -Match 'EvaluationError'
}
It 'detects memberOf with no group Object IDs' {
$findings = Get-Finding -Fixture 'PE-SEM-007-memberof-without-groups' -Code 'PE-SEM-007'
$findings.Count | Should -BeGreaterThan 0
$findings[0].Severity | Should -Be 'Error'
}
It 'detects in without a values array' {
$findings = Get-Finding -Fixture 'PE-SEM-008-in-without-values' -Code 'PE-SEM-008'
$findings.Count | Should -Be 1
$findings[0].Severity | Should -Be 'Error'
}
It 'detects isNotNull carrying a comparison value' {
# The value is silently ignored at evaluation time, so the rule does not do
# what the author plainly intended it to do.
$findings = Get-Finding -Fixture 'PE-SEM-009-isnull-with-value' -Code 'PE-SEM-009'
$findings.Count | Should -Be 1
$findings[0].Severity | Should -Be 'Error'
}
It 'detects a persona absent from the declared catalogue' {
$findings = Get-Finding -Fixture 'PE-SEM-010-undeclared-persona' -Code 'PE-SEM-010'
$findings.Count | Should -Be 1
$findings[0].Description | Should -Match 'Undeclared-Persona'
}
It 'detects Unclassified used as an ordinary rule persona' {
$findings = Get-Finding -Fixture 'PE-SEM-011-unclassified-as-persona' -Code 'PE-SEM-011'
$findings.Count | Should -Be 1
$findings[0].Severity | Should -Be 'Error'
}
It 'detects nesting deeper than the configured maximum' {
$findings = Get-Finding -Fixture 'PE-SEM-012-depth-over-configured-maximum' -Code 'PE-SEM-012'
$findings.Count | Should -BeGreaterThan 0
$findings[0].Severity | Should -Be 'Error'
$findings[0].Location | Should -Match 'conditions'
}
It 'detects a configured maximum above the hard ceiling of 10' {
$findings = Get-Finding -Fixture 'PE-SEM-013-depth-over-hard-ceiling' -Code 'PE-SEM-013'
$findings.Count | Should -Be 1
$findings[0].Location | Should -Be 'engine.maxConditionDepth'
}
It 'warns when a condition mode differs from an explicitly pinned global mode' {
# A Warning, not an Error: the three facets are retrieved independently, so
# the condition is answered correctly. The cost is an extra call per account,
# which is worth surfacing but not worth blocking.
$findings = Get-Finding -Fixture 'PE-SEM-014-mode-not-enabled-globally' -Code 'PE-SEM-014'
$findings.Count | Should -BeGreaterThan 0
$findings[0].Severity | Should -Be 'Warning'
}
It 'detects an unsupported property name' {
$findings = Get-Finding -Fixture 'PE-SEM-015-unsupported-property' -Code 'PE-SEM-015'
$findings.Count | Should -Be 1
$findings[0].Description | Should -Match 'employeeHireDate'
}
It 'detects an invalid regular expression' {
$findings = Get-Finding -Fixture 'PE-SEM-016-invalid-regex' -Code 'PE-SEM-016'
$findings.Count | Should -Be 1
$findings[0].Severity | Should -Be 'Error'
}
}
Describe 'Semantic findings carry everything VR-004 requires' {
It 'gives every finding a severity, code, location, description, resolution, and layer' {
$fixtures = Get-ChildItem -Path $script:corpus -Filter 'PE-SEM-*.json'
$fixtures.Count | Should -BeGreaterThan 0
foreach ($fixture in $fixtures) {
$document = Get-Content -LiteralPath $fixture.FullName -Raw | ConvertFrom-Json -Depth 32
foreach ($finding in (Test-PersonaConfigurationSemantic -Document $document)) {
$finding.Severity | Should -BeIn @('Error', 'Warning', 'Information')
$finding.Code | Should -Match '^PE-SEM-\d{3}$'
$finding.Location | Should -Not -BeNullOrEmpty
$finding.Description | Should -Not -BeNullOrEmpty
$finding.SuggestedResolution | Should -Not -BeNullOrEmpty
$finding.Layer | Should -Be 'Semantic'
}
}
}
It 'produces no findings for the valid baseline' {
# Without this, a validator that flagged everything would pass every test above.
$document = Get-Content -LiteralPath (Join-Path $script:corpus 'baseline-deployed.json') -Raw | ConvertFrom-Json -Depth 32
@(Test-PersonaConfigurationSemantic -Document $document) | Should -BeNullOrEmpty
}
It 'produces no findings for the shipped example configuration' {
$document = Get-Content -LiteralPath (Join-Path $repoRoot 'config/persona-engine.example.json') -Raw | ConvertFrom-Json -Depth 32
@(Test-PersonaConfigurationSemantic -Document $document) | Should -BeNullOrEmpty
}
}
@@ -0,0 +1,101 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
Pins the V-5a observation about Test-Json -SchemaFile.
OTD-005 chose a cmdlet whose failure reporting is version-dependent, and
validation layer 2 is written against the behaviour observed on PowerShell 7.6.5
(specs/001-persona-engine/verification/V-5a.md). If a future build changes any row
of that table, the wrapper's assumptions change with it - and the failure mode is
silent: configurations start passing validation that should not.
These tests fail loudly at that moment instead.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$script:schemaFile = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-pin-{0}.json" -f [guid]::NewGuid().ToString('N'))
Set-Content -LiteralPath $script:schemaFile -Encoding utf8NoBOM -Value @'
{ "type": "object", "required": ["a"], "properties": { "a": { "type": "string" } } }
'@
$script:brokenSchemaFile = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-pin-broken-{0}.json" -f [guid]::NewGuid().ToString('N'))
Set-Content -LiteralPath $script:brokenSchemaFile -Encoding utf8NoBOM -Value '{ not json'
}
AfterAll {
Remove-Item -LiteralPath $script:schemaFile, $script:brokenSchemaFile -Force -ErrorAction SilentlyContinue
}
Describe 'Test-Json -SchemaFile failure behaviour (V-5a)' {
It 'returns $true and writes nothing for a valid document' {
$errors = $null
$result = '{ "a": "ok" }' | Test-Json -SchemaFile $script:schemaFile -ErrorAction SilentlyContinue -ErrorVariable errors
$result | Should -BeTrue
@($errors).Count | Should -Be 0
}
It 'returns $false and writes one error for a type mismatch' {
$errors = $null
$result = '{ "a": 123 }' | Test-Json -SchemaFile $script:schemaFile -ErrorAction SilentlyContinue -ErrorVariable errors
$result | Should -BeFalse
@($errors).Count | Should -Be 1
}
It 'returns $false and writes one error for a missing required property' {
$errors = $null
$result = '{}' | Test-Json -SchemaFile $script:schemaFile -ErrorAction SilentlyContinue -ErrorVariable errors
$result | Should -BeFalse
@($errors).Count | Should -Be 1
}
It 'reports failure without throwing, so -ErrorVariable is sufficient' {
# If a future build made this terminating, layer 2 would abort the run instead
# of returning findings, and the editor would report an exception rather than
# a PE-SCH finding.
{ '{ "a": 123 }' | Test-Json -SchemaFile $script:schemaFile -ErrorAction SilentlyContinue } | Should -Not -Throw
}
It 'returns $TRUE when the schema itself cannot be parsed - the trap layer 2 is built around' {
# The load-bearing observation. A wrapper trusting the return value alone
# would report every configuration as schema-valid against a schema that
# never ran.
$errors = $null
$result = '{}' | Test-Json -SchemaFile $script:brokenSchemaFile -ErrorAction SilentlyContinue -ErrorVariable errors
$result | Should -BeTrue
@($errors).Count | Should -Be 1
$errors[0].Exception.Message | Should -Match 'Cannot parse the JSON schema'
}
It 'embeds a JSON pointer in the failure message, which VR-004 needs for Location' {
$errors = $null
$null = '{ "a": 123 }' | Test-Json -SchemaFile $script:schemaFile -ErrorAction SilentlyContinue -ErrorVariable errors
$errors[0].Exception.Message | Should -Match "at '/a'"
}
It 'reports one error per violating location when several properties fail' {
# Not exhaustive across nested subschemas, but not first-failure-only either.
# Layer 2 therefore emits one finding per collected error rather than assuming
# a single one.
$multiSchema = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-pin-multi-{0}.json" -f [guid]::NewGuid().ToString('N'))
Set-Content -LiteralPath $multiSchema -Encoding utf8NoBOM -Value @'
{ "type": "object", "required": ["a", "b"], "properties": { "a": { "type": "string" }, "b": { "type": "string" } } }
'@
try {
$errors = $null
$null = '{ "a": 1, "b": 2 }' | Test-Json -SchemaFile $multiSchema -ErrorAction SilentlyContinue -ErrorVariable errors
@($errors).Count | Should -Be 2
($errors | ForEach-Object { $_.Exception.Message }) -join ' ' | Should -Match "at '/a'"
($errors | ForEach-Object { $_.Exception.Message }) -join ' ' | Should -Match "at '/b'"
}
finally { Remove-Item -LiteralPath $multiSchema -Force -ErrorAction SilentlyContinue }
}
}
@@ -0,0 +1,109 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
VR-003: traceConditionValues without explicit acknowledgement is a safety finding.
The acknowledgement lives in the configuration rather than in a command-line
switch, and that placement is the point. A flag passed at the console is invisible
to review; a field in the configuration appears in the diff of the change that
enables tracing, next to the person who approved it.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:schema = Join-Path $repoRoot 'config/persona-engine.schema.json'
$script:scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-trace-{0}" -f [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $script:scratch -Force
}
AfterAll {
Remove-Item -LiteralPath $script:scratch -Recurse -Force -ErrorAction SilentlyContinue
}
Describe 'Tracing acknowledgement (VR-003)' {
It 'produces PE-SAF-006 when tracing is on and acknowledgement is absent' {
$document = New-TestConfigurationDocument
$document.logging = @{ destination = 'stream'; traceConditionValues = $true }
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
$result.IsValid | Should -BeFalse
$result.Findings.Code | Should -Contain 'PE-SAF-006'
}
It 'produces PE-SAF-006 when acknowledgement is present but false' {
$document = New-TestConfigurationDocument
$document.logging = @{ destination = 'stream'; traceConditionValues = $true; acknowledgeConditionTracing = $false }
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
$result.Findings.Code | Should -Contain 'PE-SAF-006'
}
It 'accepts tracing when acknowledgement is true' {
$document = New-TestConfigurationDocument
$document.logging = @{ destination = 'stream'; traceConditionValues = $true; acknowledgeConditionTracing = $true }
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
$result.IsValid | Should -BeTrue
$result.Findings.Code | Should -Not -Contain 'PE-SAF-006'
}
It 'does not require acknowledgement when tracing is off' {
# Acknowledging something that is not happening would train people to set the
# field reflexively, which is how an acknowledgement stops meaning anything.
$document = New-TestConfigurationDocument
$document.logging = @{ destination = 'stream'; traceConditionValues = $false }
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
$result.IsValid | Should -BeTrue
$result.Findings.Code | Should -Not -Contain 'PE-SAF-006'
}
It 'blocks the run: the finding is an Error, not a Warning' {
$document = New-TestConfigurationDocument
$document.logging = @{ destination = 'stream'; traceConditionValues = $true }
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
($result.Findings | Where-Object Code -EQ 'PE-SAF-006').Severity | Should -Be 'Error'
}
It 'explains what tracing actually widens, not merely that it is enabled' {
$document = New-TestConfigurationDocument
$document.logging = @{ destination = 'stream'; traceConditionValues = $true }
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
$finding = (Test-PersonaConfiguration -Path $path -SchemaPath $script:schema).Findings |
Where-Object Code -EQ 'PE-SAF-006'
$finding.Description | Should -Match 'attribute values'
$finding.SuggestedResolution | Should -Match 'acknowledgeConditionTracing'
}
}
Describe 'The schema accepts the acknowledgement field' {
It 'validates a configuration carrying acknowledgeConditionTracing' {
# additionalProperties is false on the logging block, so the field has to be
# declared in the schema or the acknowledgement itself becomes a schema error.
$document = New-TestConfigurationDocument
$document.logging = @{ destination = 'both'; path = '<LOG-OUTPUT-PATH>'; traceConditionValues = $true; acknowledgeConditionTracing = $true }
$path = Save-TestConfiguration -Document $document -Directory $script:scratch
$result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema
$result.Findings.Code | Should -Not -Contain 'PE-SCH-001'
}
}
+39
View File
@@ -0,0 +1,39 @@
<#
Shared Pester configuration.
Three suites, separated by tag so the offline set can be run with no tenant,
no credentials, and no network (SC-008):
Offline - rule engine, normalization, configuration validation
Safety - zero-write and single-attribute-body assertions (mocked adapter)
Integration - requires a delegated read-only tenant connection (Stage A2)
Usage:
$cfg = & ./tests/PesterConfiguration.ps1 -Suite Offline
Invoke-Pester -Configuration $cfg
#>
[CmdletBinding()]
param(
[ValidateSet('Offline', 'Safety', 'Integration', 'All')]
[string] $Suite = 'Offline',
[string] $Path = (Join-Path $PSScriptRoot '.')
)
$config = New-PesterConfiguration
$config.Run.Path = $Path
$config.Output.Verbosity = 'Detailed'
$config.Should.ErrorAction = 'Continue'
switch ($Suite) {
'Offline' {
# Integration is excluded rather than Offline included, so a new suite
# that forgets its tag still runs offline instead of silently never running.
$config.Filter.ExcludeTag = @('Integration')
}
'Safety' { $config.Filter.Tag = @('Safety') }
'Integration' { $config.Filter.Tag = @('Integration') }
'All' { }
}
$config
+107
View File
@@ -0,0 +1,107 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
foreach ($f in @(
'src/Normalization/New-PersonaMembershipRecord.ps1'
'src/Normalization/New-PersonaUserRecord.ps1'
'src/RuleEngine/Test-PersonaCondition.ps1'
'src/RuleEngine/Test-PersonaConditionGroup.ps1'
)) { . (Join-Path $repoRoot $f) }
$script:user = New-PersonaUserRecord `
-AccountObjectId '00000000-0000-0000-0000-000000000101' `
-UserPrincipalName 'svc-billing@example.invalid' `
-UserType 'Member' `
-Properties @{ Department = 'Finance'; JobTitle = 'Analyst' }
function Cond {
param([string] $Property, [string] $Operator = 'equals', [string] $Value)
[pscustomobject]@{ type = 'property'; property = $Property; operator = $Operator; value = $Value }
}
function New-ConditionGroup {
param([string] $Operator, [object[]] $Conditions)
[pscustomobject]@{ operator = $Operator; conditions = $Conditions }
}
# Builds exactly $Depth nested groups around a leaf condition that is always
# true. Leaf conditions do not add a level: the engine counts group nesting,
# so -Depth 3 yields group(group(group(leaf))) and evaluates at depths 1..3.
function New-NestedGroup {
param([int] $Depth)
$node = Cond -Property 'Department' -Value 'Finance'
for ($i = 0; $i -lt $Depth; $i++) {
$node = New-ConditionGroup -Operator 'all' -Conditions @($node)
}
$node
}
}
Describe 'Logical composition (RE-003)' {
Context 'all' {
It 'is true when every condition is true' {
Test-PersonaConditionGroup -Group (New-ConditionGroup 'all' @((Cond 'Department' -Value 'Finance'), (Cond 'JobTitle' -Value 'Analyst'))) -UserRecord $user | Should -Be 'True'
}
It 'is false when any condition is false' {
Test-PersonaConditionGroup -Group (New-ConditionGroup 'all' @((Cond 'Department' -Value 'Finance'), (Cond 'JobTitle' -Value 'Manager'))) -UserRecord $user | Should -Be 'False'
}
}
Context 'any' {
It 'is true when at least one condition is true' {
Test-PersonaConditionGroup -Group (New-ConditionGroup 'any' @((Cond 'Department' -Value 'Legal'), (Cond 'JobTitle' -Value 'Analyst'))) -UserRecord $user | Should -Be 'True'
}
It 'is false when every condition is false' {
Test-PersonaConditionGroup -Group (New-ConditionGroup 'any' @((Cond 'Department' -Value 'Legal'), (Cond 'JobTitle' -Value 'Manager'))) -UserRecord $user | Should -Be 'False'
}
}
Context 'nesting' {
It 'resolves an any group nested inside an all group' {
$inner = New-ConditionGroup 'any' @((Cond 'JobTitle' -Value 'Manager'), (Cond 'JobTitle' -Value 'Analyst'))
$outer = New-ConditionGroup 'all' @((Cond 'Department' -Value 'Finance'), $inner)
Test-PersonaConditionGroup -Group $outer -UserRecord $user | Should -Be 'True'
}
It 'resolves an all group nested inside an any group' {
$inner = New-ConditionGroup 'all' @((Cond 'Department' -Value 'Legal'), (Cond 'JobTitle' -Value 'Analyst'))
$outer = New-ConditionGroup 'any' @((Cond 'Department' -Value 'Finance'), $inner)
Test-PersonaConditionGroup -Group $outer -UserRecord $user | Should -Be 'True'
}
}
Context 'empty and malformed groups' {
It 'returns Unknown for an empty conditions collection' {
Test-PersonaConditionGroup -Group (New-ConditionGroup 'all' @()) -UserRecord $user | Should -Be 'Unknown'
}
It 'returns Unknown for an unrecognized group operator' {
Test-PersonaConditionGroup -Group (New-ConditionGroup 'either' @((Cond 'Department' -Value 'Finance'))) -UserRecord $user | Should -Be 'Unknown'
}
}
}
Describe 'Depth limits (RE-004)' {
It 'evaluates a tree exactly at the configured depth' {
Test-PersonaConditionGroup -Group (New-NestedGroup -Depth 5) -UserRecord $user -MaxDepth 5 | Should -Be 'True'
}
It 'returns Unknown beyond the configured depth rather than truncating' {
# Silent truncation would evaluate a rule the author did not write.
Test-PersonaConditionGroup -Group (New-NestedGroup -Depth 7) -UserRecord $user -MaxDepth 5 | Should -Be 'Unknown'
}
It 'honours a lowered depth limit' {
Test-PersonaConditionGroup -Group (New-NestedGroup -Depth 3) -UserRecord $user -MaxDepth 2 | Should -Be 'Unknown'
}
It 'rejects a MaxDepth above the hard ceiling of 10' {
{ Test-PersonaConditionGroup -Group (New-NestedGroup -Depth 2) -UserRecord $user -MaxDepth 11 } | Should -Throw
}
It 'rejects a MaxDepth below the minimum of 1' {
{ Test-PersonaConditionGroup -Group (New-NestedGroup -Depth 2) -UserRecord $user -MaxDepth 0 } | Should -Throw
}
}
+103
View File
@@ -0,0 +1,103 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
foreach ($f in @(
'src/Normalization/New-PersonaMembershipRecord.ps1'
'src/Normalization/New-PersonaUserRecord.ps1'
'src/RuleEngine/Test-PersonaCondition.ps1'
'src/RuleEngine/Test-PersonaConditionGroup.ps1'
'src/RuleEngine/Test-PersonaRule.ps1'
'src/RuleEngine/Resolve-UserPersona.ps1'
)) { . (Join-Path $repoRoot $f) }
$script:fixtures = (Get-Content (Join-Path $repoRoot 'tests/TestData/Users/users.json') -Raw | ConvertFrom-Json).users
$script:users = foreach ($f in $fixtures) {
$props = @{}
foreach ($p in $f.properties.PSObject.Properties) { $props[$p.Name] = $p.Value }
New-PersonaUserRecord `
-AccountObjectId $f.accountObjectId `
-UserPrincipalName $f.userPrincipalName `
-DisplayName $f.displayName `
-UserType $f.userType `
-AccountEnabled $f.accountEnabled `
-Properties $props `
-StoredPersona $f.storedPersona
}
$script:rules = @(
[pscustomobject]@{
id = 'R-020'; priority = 20; persona = 'Guest'; enabled = $true
match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' }) }
}
[pscustomobject]@{
id = 'R-040'; priority = 40; persona = 'Service-Account'; enabled = $true
match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'UserPrincipalName'; operator = 'startsWith'; value = 'svc-' }) }
}
[pscustomobject]@{
id = 'R-900'; priority = 900; persona = 'Employee'; enabled = $true
match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }) }
}
)
function Get-DecisionSignature {
param([object[]] $Users, [object[]] $RuleSet)
# Deliberately excludes DurationMs: timing is telemetry, not part of the
# decision, and including it would make this test measure the clock.
($Users | ForEach-Object {
$r = Resolve-UserPersona -UserRecord $_ -Rules $RuleSet
'{0}|{1}|{2}|{3}' -f $r.AccountObjectId, $r.Outcome, $r.CalculatedPersona, $r.MatchedRuleId
} | Sort-Object) -join "`n"
}
}
Describe 'Determinism (SC-003)' {
It 'produces identical results across repeated runs' {
$first = Get-DecisionSignature -Users $users -RuleSet $rules
$second = Get-DecisionSignature -Users $users -RuleSet $rules
$second | Should -Be $first
}
It 'produces identical results when the user collection is shuffled' {
$ordered = Get-DecisionSignature -Users $users -RuleSet $rules
foreach ($seed in 1..5) {
$shuffled = $users | Sort-Object { ($_.AccountObjectId + $seed).GetHashCode() }
Get-DecisionSignature -Users $shuffled -RuleSet $rules | Should -Be $ordered
}
}
It 'produces identical results when the rule collection is shuffled' {
$ordered = Get-DecisionSignature -Users $users -RuleSet $rules
foreach ($seed in 1..5) {
$shuffled = $rules | Sort-Object { ($_.id + $seed).GetHashCode() }
Get-DecisionSignature -Users $users -RuleSet $shuffled | Should -Be $ordered
}
}
It 'assigns exactly one outcome to every fixture (SC-001)' {
foreach ($u in $users) {
$r = Resolve-UserPersona -UserRecord $u -Rules $rules
$r.Outcome | Should -BeIn @('Matched', 'Unclassified', 'EvaluationError')
}
}
}
Describe 'Offline execution (SC-008)' {
It 'evaluates every fixture without any network-capable command in the engine' {
# The structural guarantee is enforced by tests/Test-EnginePurity.ps1. This
# asserts the practical consequence: the engine runs with nothing loaded but
# its own files.
$results = $users | ForEach-Object { Resolve-UserPersona -UserRecord $_ -Rules $rules }
$results | Should -HaveCount $users.Count
}
It 'has not loaded the Graph authentication module' {
(Get-Module -Name 'Microsoft.Graph.Authentication') | Should -BeNullOrEmpty
}
}
+132
View File
@@ -0,0 +1,132 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
foreach ($f in @(
'src/Normalization/New-PersonaMembershipRecord.ps1'
'src/Normalization/New-PersonaUserRecord.ps1'
'src/RuleEngine/Test-PersonaCondition.ps1'
'src/RuleEngine/Test-PersonaConditionGroup.ps1'
'src/RuleEngine/Test-PersonaRule.ps1'
'src/RuleEngine/Resolve-UserPersona.ps1'
)) { . (Join-Path $repoRoot $f) }
$script:tier0 = '00000000-0000-0000-0000-0000000000a0'
function New-UserWithFailedLookup {
param([string] $StoredPersona = 'Employee')
New-PersonaUserRecord `
-AccountObjectId '00000000-0000-0000-0000-000000000101' `
-UserPrincipalName 'alex.employee@example.invalid' `
-Properties @{ Department = 'Finance' } `
-StoredPersona $StoredPersona `
-Membership (New-PersonaMembershipRecord -DirectFailureReason 'Graph 503 after 5 attempts')
}
function New-UserWithGoodLookup {
New-PersonaUserRecord `
-AccountObjectId '00000000-0000-0000-0000-000000000102' `
-UserPrincipalName 'blair.ok@example.invalid' `
-Properties @{ Department = 'Finance' } `
-StoredPersona 'Employee' `
-Membership (New-PersonaMembershipRecord -AllRetrieved)
}
$script:membershipRule = [pscustomobject]@{
id = 'R-030'; priority = 30; persona = 'Tier0-Admin'; enabled = $true
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'membership'; operator = 'memberOf'; groupObjectIds = @($tier0) })
}
}
$script:catchAllRule = [pscustomobject]@{
id = 'R-900'; priority = 900; persona = 'Employee'; enabled = $true
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
}
}
Describe 'EvaluationError outcome (FR-013, FR-014)' {
It 'is produced when required membership data could not be retrieved' {
$result = Resolve-UserPersona -UserRecord (New-UserWithFailedLookup) -Rules @($membershipRule)
$result.Outcome | Should -Be 'EvaluationError'
}
It 'preserves the stored persona' {
$result = Resolve-UserPersona -UserRecord (New-UserWithFailedLookup -StoredPersona 'Tier0-Admin') -Rules @($membershipRule)
$result.StoredPersona | Should -Be 'Tier0-Admin'
}
It 'leaves CalculatedPersona null so nothing can be written' {
$result = Resolve-UserPersona -UserRecord (New-UserWithFailedLookup) -Rules @($membershipRule)
$result.CalculatedPersona | Should -BeNullOrEmpty
}
It 'records a reason naming the rule that could not be evaluated' {
$result = Resolve-UserPersona -UserRecord (New-UserWithFailedLookup) -Rules @($membershipRule)
$result.EvaluationErrorReason | Should -Not -BeNullOrEmpty
$result.EvaluationErrorReason | Should -BeLike '*R-030*'
}
It 'stops evaluation rather than falling through to a lower-priority rule' {
# The critical case. Falling through would assign Employee to an account
# that may in truth be a Tier 0 administrator — a silent privilege
# downgrade, which is exactly what FR-013 exists to prevent.
$result = Resolve-UserPersona -UserRecord (New-UserWithFailedLookup) -Rules @($membershipRule, $catchAllRule)
$result.Outcome | Should -Be 'EvaluationError'
$result.CalculatedPersona | Should -Not -Be 'Employee'
$result.RulesEvaluated | Should -Be 1
}
It 'still matches when a higher-priority rule resolves before the unknown one' {
# An unknown rule at priority 30 is irrelevant if priority 10 already matched.
$earlyMatch = [pscustomobject]@{
id = 'R-010'; priority = 10; persona = 'Guest'; enabled = $true
match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'Finance' }) }
}
$result = Resolve-UserPersona -UserRecord (New-UserWithFailedLookup) -Rules @($earlyMatch, $membershipRule)
$result.Outcome | Should -Be 'Matched'
$result.CalculatedPersona | Should -Be 'Guest'
}
It 'does not affect a user whose lookup succeeded' {
# Per-user isolation: one account's data failure must not contaminate another.
$result = Resolve-UserPersona -UserRecord (New-UserWithGoodLookup) -Rules @($membershipRule, $catchAllRule)
$result.Outcome | Should -Be 'Matched'
$result.CalculatedPersona | Should -Be 'Employee'
}
It 'processes a mixed population without one failure stopping the others' {
$population = @((New-UserWithFailedLookup), (New-UserWithGoodLookup))
$results = $population | ForEach-Object { Resolve-UserPersona -UserRecord $_ -Rules @($membershipRule, $catchAllRule) }
$results | Should -HaveCount 2
($results | Where-Object Outcome -EQ 'EvaluationError') | Should -HaveCount 1
($results | Where-Object Outcome -EQ 'Matched') | Should -HaveCount 1
}
It 'is produced when the condition tree exceeds the depth limit' {
$deep = [pscustomobject]@{
id = 'R-050'; priority = 50; persona = 'Employee'; enabled = $true
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'Finance' })
})
})
}
}
$result = Resolve-UserPersona -UserRecord (New-UserWithGoodLookup) -Rules @($deep) -MaxDepth 2
$result.Outcome | Should -Be 'EvaluationError'
}
}
+171
View File
@@ -0,0 +1,171 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'src/Normalization/New-PersonaMembershipRecord.ps1')
. (Join-Path $repoRoot 'src/Normalization/New-PersonaUserRecord.ps1')
. (Join-Path $repoRoot 'src/RuleEngine/Test-PersonaCondition.ps1')
function New-TestUser {
param([hashtable] $Properties = @{}, [object] $Membership)
New-PersonaUserRecord `
-AccountObjectId '00000000-0000-0000-0000-000000000101' `
-UserPrincipalName 'alex.employee@example.invalid' `
-UserType 'Member' `
-Properties $Properties `
-Membership $Membership
}
function Test-Op {
param([hashtable] $Condition, [object] $User, [string] $Mode = 'Direct')
Test-PersonaCondition -Condition ([pscustomobject]$Condition) -UserRecord $User -DefaultMembershipMode $Mode
}
}
Describe 'Property operators (RE-005)' {
BeforeAll {
$script:user = New-TestUser -Properties @{
Department = 'Finance'
JobTitle = 'Senior Analyst'
CompanyName = $null
EmptyString = ''
}
}
Context 'equals / notEquals' {
It 'equals matches' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'Finance' } $user | Should -Be 'True' }
It 'equals rejects a different value' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'Legal' } $user | Should -Be 'False' }
It 'notEquals is the inverse' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'notEquals'; value = 'Legal' } $user | Should -Be 'True' }
}
Context 'case insensitivity (RE-006)' {
It 'equals ignores case' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'FINANCE' } $user | Should -Be 'True' }
It 'contains ignores case' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'contains'; value = 'ANALYST' } $user | Should -Be 'True' }
It 'startsWith ignores case' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'startsWith'; value = 'senior' } $user | Should -Be 'True' }
It 'endsWith ignores case' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'endsWith'; value = 'ANALYST' } $user | Should -Be 'True' }
It 'resolves the property name case-insensitively' { Test-Op @{ type = 'property'; property = 'DEPARTMENT'; operator = 'equals'; value = 'Finance' } $user | Should -Be 'True' }
}
Context 'contains / notContains' {
It 'contains matches a substring' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'contains'; value = 'Analy' } $user | Should -Be 'True' }
It 'notContains is the inverse' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'notContains'; value = 'Manager' } $user | Should -Be 'True' }
}
Context 'startsWith / endsWith' {
It 'startsWith matches' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'startsWith'; value = 'Senior' } $user | Should -Be 'True' }
It 'startsWith rejects' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'startsWith'; value = 'Junior' } $user | Should -Be 'False' }
It 'endsWith matches' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'endsWith'; value = 'Analyst' } $user | Should -Be 'True' }
}
Context 'matchesRegex' {
It 'matches a valid pattern' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'matchesRegex'; value = '^Senior\s+\w+$' } $user | Should -Be 'True' }
It 'rejects a non-matching pattern' { Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'matchesRegex'; value = '^Junior' } $user | Should -Be 'False' }
It 'returns Unknown for an invalid pattern rather than a false non-match' {
# An unparseable regex is a configuration defect. Reporting False would
# hide it behind a plausible result.
Test-Op @{ type = 'property'; property = 'JobTitle'; operator = 'matchesRegex'; value = '[unclosed' } $user | Should -Be 'Unknown'
}
}
Context 'in / notIn' {
It 'in matches a listed value' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'in'; values = @('Legal', 'Finance') } $user | Should -Be 'True' }
It 'in rejects an unlisted value' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'in'; values = @('Legal', 'HR') } $user | Should -Be 'False' }
It 'notIn is the inverse' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'notIn'; values = @('Legal', 'HR') } $user | Should -Be 'True' }
It 'in ignores case' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'in'; values = @('FINANCE') } $user | Should -Be 'True' }
}
Context 'isNull / isNotNull' {
It 'isNull is true for an explicit null' { Test-Op @{ type = 'property'; property = 'CompanyName'; operator = 'isNull' } $user | Should -Be 'True' }
It 'isNull is true for an absent property' { Test-Op @{ type = 'property'; property = 'NoSuchProperty'; operator = 'isNull' } $user | Should -Be 'True' }
It 'isNull is true for an empty string' { Test-Op @{ type = 'property'; property = 'EmptyString'; operator = 'isNull' } $user | Should -Be 'True' }
It 'isNull is false for a populated value' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'isNull' } $user | Should -Be 'False' }
It 'isNotNull is the inverse' { Test-Op @{ type = 'property'; property = 'Department'; operator = 'isNotNull' } $user | Should -Be 'True' }
}
Context 'null handling for ordinary comparisons (FR-012)' {
It 'treats a null property as empty rather than failing' {
Test-Op @{ type = 'property'; property = 'CompanyName'; operator = 'equals'; value = '' } $user | Should -Be 'True'
}
It 'treats an absent property as empty rather than failing' {
Test-Op @{ type = 'property'; property = 'NoSuchProperty'; operator = 'equals'; value = 'anything' } $user | Should -Be 'False'
}
It 'never returns Unknown for a null property under an ordinary operator' {
# FR-012: null must not cause evaluation failure. Unknown here would
# turn every sparse account into an EvaluationError.
foreach ($op in @('equals', 'notEquals', 'contains', 'notContains', 'startsWith', 'endsWith')) {
Test-Op @{ type = 'property'; property = 'CompanyName'; operator = $op; value = 'x' } $user |
Should -Not -Be 'Unknown' -Because "operator '$op' must tolerate a null property"
}
}
}
Context 'unsupported operator' {
It 'returns Unknown rather than guessing' {
Test-Op @{ type = 'property'; property = 'Department'; operator = 'approximatelyEquals'; value = 'Finance' } $user | Should -Be 'Unknown'
}
}
}
Describe 'Membership operators' {
BeforeAll {
$script:tier0 = '00000000-0000-0000-0000-0000000000a0'
$script:other = '00000000-0000-0000-0000-0000000000b0'
}
Context 'successful retrieval' {
BeforeAll {
$script:member = New-TestUser -Membership (New-PersonaMembershipRecord -DirectGroupObjectIds @($tier0) -DirectRetrieved)
}
It 'memberOf matches a held group' { Test-Op @{ type = 'membership'; operator = 'memberOf'; groupObjectIds = @($tier0) } $member | Should -Be 'True' }
It 'memberOf rejects a group not held' { Test-Op @{ type = 'membership'; operator = 'memberOf'; groupObjectIds = @($other) } $member | Should -Be 'False' }
It 'notMemberOf is the inverse' { Test-Op @{ type = 'membership'; operator = 'notMemberOf'; groupObjectIds = @($other) } $member | Should -Be 'True' }
It 'matches when any listed group is held' { Test-Op @{ type = 'membership'; operator = 'memberOf'; groupObjectIds = @($other, $tier0) } $member | Should -Be 'True' }
}
Context 'membership mode (RE-007)' {
It 'answers a direct question from direct data' {
$u = New-TestUser -Membership (New-PersonaMembershipRecord -DirectGroupObjectIds @($tier0) -DirectRetrieved)
Test-Op @{ type = 'membership'; operator = 'memberOf'; membershipMode = 'Direct'; groupObjectIds = @($tier0) } $u | Should -Be 'True'
}
It 'returns Unknown when asked a transitive question with only direct data' {
# Transitive is a superset of direct, so answering from direct data
# would produce false negatives on nested groups.
$u = New-TestUser -Membership (New-PersonaMembershipRecord -DirectGroupObjectIds @($tier0) -DirectRetrieved)
Test-Op @{ type = 'membership'; operator = 'memberOf'; membershipMode = 'Transitive'; groupObjectIds = @($tier0) } $u | Should -Be 'Unknown'
}
It 'returns Unknown when asked a direct question with only transitive data' {
$u = New-TestUser -Membership (New-PersonaMembershipRecord -TransitiveGroupObjectIds @($tier0) -TransitiveRetrieved)
Test-Op @{ type = 'membership'; operator = 'memberOf'; membershipMode = 'Direct'; groupObjectIds = @($tier0) } $u | Should -Be 'Unknown'
}
It 'falls back to the engine default mode when the condition omits one' {
$u = New-TestUser -Membership (New-PersonaMembershipRecord -TransitiveGroupObjectIds @($tier0) -TransitiveRetrieved)
Test-Op @{ type = 'membership'; operator = 'memberOf'; groupObjectIds = @($tier0) } $u 'Transitive' | Should -Be 'True'
}
}
Context 'role conditions' {
It 'matches a held directory role' {
$u = New-TestUser -Membership (New-PersonaMembershipRecord -DirectoryRoleIds @('<TIER0-ROLE-TEMPLATE-ID>') -RolesRetrieved)
Test-Op @{ type = 'role'; operator = 'memberOf'; roleIds = @('<TIER0-ROLE-TEMPLATE-ID>') } $u | Should -Be 'True'
}
It 'is not affected by membership mode' {
# Role assignments have no direct/transitive distinction in this model.
$u = New-TestUser -Membership (New-PersonaMembershipRecord -DirectoryRoleIds @('<TIER0-ROLE-TEMPLATE-ID>') -RolesRetrieved)
Test-Op @{ type = 'role'; operator = 'memberOf'; roleIds = @('<TIER0-ROLE-TEMPLATE-ID>') } $u 'Direct' | Should -Be 'True'
}
}
}
+114
View File
@@ -0,0 +1,114 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
foreach ($f in @(
'src/Normalization/New-PersonaMembershipRecord.ps1'
'src/Normalization/New-PersonaUserRecord.ps1'
'src/RuleEngine/Test-PersonaCondition.ps1'
'src/RuleEngine/Test-PersonaConditionGroup.ps1'
'src/RuleEngine/Test-PersonaRule.ps1'
'src/RuleEngine/Resolve-UserPersona.ps1'
)) { . (Join-Path $repoRoot $f) }
function New-MatchAllRule {
param([string] $Id, [int] $Priority, [string] $Persona, [bool] $Enabled = $true, [string] $Department = 'Finance')
[pscustomobject]@{
id = $Id
priority = $Priority
persona = $Persona
enabled = $Enabled
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'equals'; value = $Department })
}
}
}
$script:user = New-PersonaUserRecord `
-AccountObjectId '00000000-0000-0000-0000-000000000101' `
-UserPrincipalName 'alex.employee@example.invalid' `
-Properties @{ Department = 'Finance' }
}
Describe 'Rule ordering and first-match (FR-008, FR-009, RE-002)' {
It 'evaluates in ascending priority order and stops at the first match' {
$rules = @(
New-MatchAllRule -Id 'R-020' -Priority 20 -Persona 'Tier1-Admin'
New-MatchAllRule -Id 'R-010' -Priority 10 -Persona 'Tier0-Admin'
)
$result = Resolve-UserPersona -UserRecord $user -Rules $rules
$result.CalculatedPersona | Should -Be 'Tier0-Admin'
$result.MatchedRuleId | Should -Be 'R-010'
}
It 'stops evaluating once matched, leaving lower-priority rules unevaluated' {
$rules = @(
New-MatchAllRule -Id 'R-010' -Priority 10 -Persona 'Tier0-Admin'
New-MatchAllRule -Id 'R-020' -Priority 20 -Persona 'Tier1-Admin'
New-MatchAllRule -Id 'R-030' -Priority 30 -Persona 'Employee'
)
(Resolve-UserPersona -UserRecord $user -Rules $rules).RulesEvaluated | Should -Be 1
}
It 'is unaffected by the order rules appear in the collection' {
$ascending = @(
New-MatchAllRule -Id 'R-010' -Priority 10 -Persona 'Tier0-Admin'
New-MatchAllRule -Id 'R-020' -Priority 20 -Persona 'Tier1-Admin'
)
$descending = @(
New-MatchAllRule -Id 'R-020' -Priority 20 -Persona 'Tier1-Admin'
New-MatchAllRule -Id 'R-010' -Priority 10 -Persona 'Tier0-Admin'
)
(Resolve-UserPersona -UserRecord $user -Rules $ascending).CalculatedPersona |
Should -Be (Resolve-UserPersona -UserRecord $user -Rules $descending).CalculatedPersona
}
It 'skips disabled rules and excludes them from the evaluated count' {
$rules = @(
New-MatchAllRule -Id 'R-010' -Priority 10 -Persona 'Tier0-Admin' -Enabled $false
New-MatchAllRule -Id 'R-020' -Priority 20 -Persona 'Tier1-Admin'
)
$result = Resolve-UserPersona -UserRecord $user -Rules $rules
$result.CalculatedPersona | Should -Be 'Tier1-Admin'
$result.RulesEvaluated | Should -Be 1
}
It 'breaks a duplicate-priority tie deterministically by rule id' {
# Duplicate priorities are a validation error (VR-002). If one reaches the
# engine anyway, the result must still not depend on collection order.
$a = @(
New-MatchAllRule -Id 'R-AAA' -Priority 10 -Persona 'Persona-A'
New-MatchAllRule -Id 'R-BBB' -Priority 10 -Persona 'Persona-B'
)
$b = @(
New-MatchAllRule -Id 'R-BBB' -Priority 10 -Persona 'Persona-B'
New-MatchAllRule -Id 'R-AAA' -Priority 10 -Persona 'Persona-A'
)
(Resolve-UserPersona -UserRecord $user -Rules $a).CalculatedPersona | Should -Be 'Persona-A'
(Resolve-UserPersona -UserRecord $user -Rules $b).CalculatedPersona | Should -Be 'Persona-A'
}
}
Describe 'Outcome exclusivity (SC-001)' {
It 'returns exactly one outcome for every user' {
$rules = @(New-MatchAllRule -Id 'R-010' -Priority 10 -Persona 'Employee')
$result = Resolve-UserPersona -UserRecord $user -Rules $rules
$result.Outcome | Should -BeIn @('Matched', 'Unclassified', 'EvaluationError')
}
It 'populates MatchedRuleId only when matched' {
$matched = Resolve-UserPersona -UserRecord $user -Rules @(New-MatchAllRule -Id 'R-010' -Priority 10 -Persona 'Employee')
$matched.MatchedRuleId | Should -Be 'R-010'
$unmatched = Resolve-UserPersona -UserRecord $user -Rules @(New-MatchAllRule -Id 'R-010' -Priority 10 -Persona 'Employee' -Department 'Legal')
$unmatched.MatchedRuleId | Should -BeNullOrEmpty
}
}
+82
View File
@@ -0,0 +1,82 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
foreach ($f in @(
'src/Normalization/New-PersonaMembershipRecord.ps1'
'src/Normalization/New-PersonaUserRecord.ps1'
'src/RuleEngine/Test-PersonaCondition.ps1'
'src/RuleEngine/Test-PersonaConditionGroup.ps1'
'src/RuleEngine/Test-PersonaRule.ps1'
'src/RuleEngine/Resolve-UserPersona.ps1'
)) { . (Join-Path $repoRoot $f) }
$script:user = New-PersonaUserRecord `
-AccountObjectId '00000000-0000-0000-0000-000000000105' `
-UserPrincipalName 'ellis.minimal@example.invalid' `
-UserType 'Member' `
-StoredPersona 'Employee'
function New-NonMatchingRule {
param([string] $Id, [int] $Priority)
[pscustomobject]@{
id = $Id; priority = $Priority; persona = 'Tier0-Admin'; enabled = $true
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'NoSuchDepartment' })
}
}
}
}
Describe 'Unclassified outcome (FR-010)' {
It 'is the result when every enabled rule evaluates successfully and none match' {
$result = Resolve-UserPersona -UserRecord $user -Rules @(
New-NonMatchingRule -Id 'R-010' -Priority 10
New-NonMatchingRule -Id 'R-020' -Priority 20
)
$result.Outcome | Should -Be 'Unclassified'
$result.CalculatedPersona | Should -Be 'Unclassified'
$result.MatchedRuleId | Should -BeNullOrEmpty
}
It 'evaluates every enabled rule before concluding' {
$result = Resolve-UserPersona -UserRecord $user -Rules @(
New-NonMatchingRule -Id 'R-010' -Priority 10
New-NonMatchingRule -Id 'R-020' -Priority 20
New-NonMatchingRule -Id 'R-030' -Priority 30
)
$result.RulesEvaluated | Should -Be 3
}
It 'is the result for an empty rule set' {
$result = Resolve-UserPersona -UserRecord $user -Rules @()
$result.Outcome | Should -Be 'Unclassified'
}
It 'is the result when every rule is disabled' {
$disabled = New-NonMatchingRule -Id 'R-010' -Priority 10
$disabled.enabled = $false
$result = Resolve-UserPersona -UserRecord $user -Rules @($disabled)
$result.Outcome | Should -Be 'Unclassified'
$result.RulesEvaluated | Should -Be 0
}
It 'is reported distinctly from EvaluationError' {
# Both mean "no persona was assigned", but only one means the engine failed.
# Conflating them would hide data-availability problems inside a normal-
# looking result bucket.
$result = Resolve-UserPersona -UserRecord $user -Rules @(New-NonMatchingRule -Id 'R-010' -Priority 10)
$result.Outcome | Should -Not -Be 'EvaluationError'
$result.EvaluationErrorReason | Should -BeNullOrEmpty
}
It 'preserves the stored persona on the result for later comparison' {
(Resolve-UserPersona -UserRecord $user -Rules @(New-NonMatchingRule -Id 'R-010' -Priority 10)).StoredPersona |
Should -Be 'Employee'
}
}
+116
View File
@@ -0,0 +1,116 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
Regression suite for the single most dangerous defect this system can have.
If a failed membership lookup is ever treated as "not a member", then:
notMemberOf <break-glass group> -> True
notMemberOf <tier 0 group> -> True
and a privileged account silently classifies as an ordinary user during a
transient Graph outage. The write then persists that downgrade to the
directory. Every assertion here exists to make that regression fail loudly.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
foreach ($f in @(
'src/Normalization/New-PersonaMembershipRecord.ps1'
'src/Normalization/New-PersonaUserRecord.ps1'
'src/RuleEngine/Test-PersonaCondition.ps1'
'src/RuleEngine/Test-PersonaConditionGroup.ps1'
'src/RuleEngine/Test-PersonaRule.ps1'
'src/RuleEngine/Resolve-UserPersona.ps1'
)) { . (Join-Path $repoRoot $f) }
$script:tier0 = '00000000-0000-0000-0000-0000000000a0'
$script:failedLookupUser = New-PersonaUserRecord `
-AccountObjectId '00000000-0000-0000-0000-000000000001' `
-UserPrincipalName 'emergency-access-01@example.invalid' `
-Properties @{ Department = 'IT' } `
-StoredPersona 'Tier0-Admin' `
-Membership (New-PersonaMembershipRecord -DirectFailureReason 'Graph 503 after 5 attempts')
}
Describe 'A failed membership lookup is never a non-match (FR-013)' {
It 'does not satisfy notMemberOf' {
$result = Test-PersonaCondition -UserRecord $failedLookupUser -Condition ([pscustomobject]@{
type = 'membership'; operator = 'notMemberOf'; groupObjectIds = @($tier0)
})
$result | Should -Be 'Unknown'
$result | Should -Not -Be 'True' -Because 'treating unknown membership as "not a member" silently downgrades privileged accounts'
}
It 'does not satisfy memberOf either' {
Test-PersonaCondition -UserRecord $failedLookupUser -Condition ([pscustomobject]@{
type = 'membership'; operator = 'memberOf'; groupObjectIds = @($tier0)
}) | Should -Be 'Unknown'
}
It 'does not satisfy a role condition' {
Test-PersonaCondition -UserRecord $failedLookupUser -Condition ([pscustomobject]@{
type = 'role'; operator = 'memberOf'; roleIds = @('<TIER0-ROLE-TEMPLATE-ID>')
}) | Should -Be 'Unknown'
}
It 'prevents a notMemberOf rule from classifying a privileged account as ordinary' {
# The end-to-end version of the hazard: a "restricted user" rule defined as
# "not in the admin group" must not capture an account whose membership is
# simply unknown.
$restrictedRule = [pscustomobject]@{
id = 'R-100'; priority = 100; persona = 'Restricted-User'; enabled = $true
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'membership'; operator = 'notMemberOf'; groupObjectIds = @($tier0) })
}
}
$result = Resolve-UserPersona -UserRecord $failedLookupUser -Rules @($restrictedRule)
$result.CalculatedPersona | Should -Not -Be 'Restricted-User'
$result.Outcome | Should -Be 'EvaluationError'
$result.StoredPersona | Should -Be 'Tier0-Admin'
}
}
Describe 'An empty successful lookup IS a legitimate non-match' {
BeforeAll {
# "Member of nothing" is a real, knowable answer and must evaluate normally.
# Over-applying the fail-safe would make every unaffiliated account an error.
$script:noGroupsUser = New-PersonaUserRecord `
-AccountObjectId '00000000-0000-0000-0000-000000000104' `
-UserPrincipalName 'drew.sparse@example.invalid' `
-Membership (New-PersonaMembershipRecord -DirectGroupObjectIds @() -DirectRetrieved)
}
It 'satisfies notMemberOf' {
Test-PersonaCondition -UserRecord $noGroupsUser -Condition ([pscustomobject]@{
type = 'membership'; operator = 'notMemberOf'; groupObjectIds = @($tier0)
}) | Should -Be 'True'
}
It 'does not satisfy memberOf' {
Test-PersonaCondition -UserRecord $noGroupsUser -Condition ([pscustomobject]@{
type = 'membership'; operator = 'memberOf'; groupObjectIds = @($tier0)
}) | Should -Be 'False'
}
It 'produces a normal Matched outcome, not an error' {
$restrictedRule = [pscustomobject]@{
id = 'R-100'; priority = 100; persona = 'Restricted-User'; enabled = $true
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'membership'; operator = 'notMemberOf'; groupObjectIds = @($tier0) })
}
}
$result = Resolve-UserPersona -UserRecord $noGroupsUser -Rules @($restrictedRule)
$result.Outcome | Should -Be 'Matched'
$result.CalculatedPersona | Should -Be 'Restricted-User'
}
}
@@ -0,0 +1,99 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
foreach ($f in @(
'src/Normalization/New-PersonaMembershipRecord.ps1'
'src/Normalization/New-PersonaUserRecord.ps1'
'src/RuleEngine/Test-PersonaCondition.ps1'
'src/RuleEngine/Test-PersonaConditionGroup.ps1'
)) { . (Join-Path $repoRoot $f) }
# A user whose membership lookup failed: any membership condition against this
# record yields Unknown, which is how these tests inject the tri-state.
$script:user = New-PersonaUserRecord `
-AccountObjectId '00000000-0000-0000-0000-000000000101' `
-UserPrincipalName 'alex.employee@example.invalid' `
-Properties @{ Department = 'Finance' } `
-Membership (New-PersonaMembershipRecord -DirectFailureReason 'Graph 503 after 5 attempts')
$script:TRUE_COND = [pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'Finance' }
$script:FALSE_COND = [pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'Legal' }
$script:UNKNOWN_COND = [pscustomobject]@{ type = 'membership'; operator = 'memberOf'; groupObjectIds = @('00000000-0000-0000-0000-0000000000a0') }
function Eval {
param([string] $Operator, [object[]] $Conditions)
Test-PersonaConditionGroup -Group ([pscustomobject]@{ operator = $Operator; conditions = $Conditions }) -UserRecord $user
}
}
Describe 'Unknown propagation table (data-model.md)' {
Context 'the injected conditions behave as intended' {
It 'TRUE_COND is True' { Test-PersonaCondition -Condition $TRUE_COND -UserRecord $user | Should -Be 'True' }
It 'FALSE_COND is False' { Test-PersonaCondition -Condition $FALSE_COND -UserRecord $user | Should -Be 'False' }
It 'UNKNOWN_COND is Unknown' { Test-PersonaCondition -Condition $UNKNOWN_COND -UserRecord $user | Should -Be 'Unknown' }
}
Context 'row 1: all + any False -> False' {
It 'a definite non-match decides the group despite an unknown sibling' {
# The rule cannot match either way, so degrading to Unknown here would
# manufacture EvaluationErrors for rules that were never going to match.
Eval 'all' @($FALSE_COND, $UNKNOWN_COND) | Should -Be 'False'
}
It 'holds regardless of sibling order' {
Eval 'all' @($UNKNOWN_COND, $FALSE_COND) | Should -Be 'False'
}
}
Context 'row 2: all + only True and Unknown -> Unknown' {
It 'cannot confirm a match' {
Eval 'all' @($TRUE_COND, $UNKNOWN_COND) | Should -Be 'Unknown'
}
It 'holds regardless of sibling order' {
Eval 'all' @($UNKNOWN_COND, $TRUE_COND) | Should -Be 'Unknown'
}
}
Context 'row 3: any + any True -> True' {
It 'a definite match decides the group despite an unknown sibling' {
Eval 'any' @($TRUE_COND, $UNKNOWN_COND) | Should -Be 'True'
}
It 'holds regardless of sibling order' {
Eval 'any' @($UNKNOWN_COND, $TRUE_COND) | Should -Be 'True'
}
}
Context 'row 4: any + only False and Unknown -> Unknown' {
It 'cannot rule out a match' {
Eval 'any' @($FALSE_COND, $UNKNOWN_COND) | Should -Be 'Unknown'
}
It 'holds regardless of sibling order' {
Eval 'any' @($UNKNOWN_COND, $FALSE_COND) | Should -Be 'Unknown'
}
}
Context 'no-unknown baselines' {
It 'all with only True is True' { Eval 'all' @($TRUE_COND, $TRUE_COND) | Should -Be 'True' }
It 'all with a False is False' { Eval 'all' @($TRUE_COND, $FALSE_COND) | Should -Be 'False' }
It 'any with a True is True' { Eval 'any' @($FALSE_COND, $TRUE_COND) | Should -Be 'True' }
It 'any with only False is False' { Eval 'any' @($FALSE_COND, $FALSE_COND) | Should -Be 'False' }
}
Context 'propagation through nesting' {
It 'carries Unknown up from a nested group' {
$inner = [pscustomobject]@{ operator = 'all'; conditions = @($TRUE_COND, $UNKNOWN_COND) }
Eval 'all' @($TRUE_COND, $inner) | Should -Be 'Unknown'
}
It 'lets a definite False at the outer level still decide the group' {
$inner = [pscustomobject]@{ operator = 'all'; conditions = @($TRUE_COND, $UNKNOWN_COND) }
Eval 'all' @($FALSE_COND, $inner) | Should -Be 'False'
}
It 'lets a definite True at the outer level still decide an any group' {
$inner = [pscustomobject]@{ operator = 'any'; conditions = @($FALSE_COND, $UNKNOWN_COND) }
Eval 'any' @($TRUE_COND, $inner) | Should -Be 'True'
}
}
}
+114
View File
@@ -0,0 +1,114 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
SC-002: a second consecutive run over unchanged input proposes zero changes.
Idempotence is what makes the engine safe to schedule. A run that rewrites the
same value every time produces directory churn, floods the audit trail, and makes
a genuine change indistinguishable from routine noise.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
$script:config = New-TestRuntimeConfiguration -TargetAttribute $target
}
Describe 'Idempotence across consecutive runs (SC-002)' -Tag 'Safety' {
BeforeEach {
Mock Write-Host { }
# A mutable population, so the second run genuinely sees what the first wrote
# rather than a fresh copy of the original fixtures. Re-reading the same
# unchanged fixtures would prove nothing about idempotence.
$script:store = @{}
foreach ($user in (New-TestPopulation -Count 20 -TargetAttribute $script:target)) {
$script:store[$user['id']] = $user
}
Mock Get-PersonaUsers { $script:store.Values }
Mock Set-UserPersonaAttribute {
$script:store[$UserObjectId][$AttributeName] = $Value
[pscustomobject]@{ Succeeded = $true; AccountObjectId = $UserObjectId; Value = $Value; PreviousValue = $PreviousValue }
}
}
It 'proposes zero changes on the second run' {
$first = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
-ShouldProcessGate { param($t, $d) $true }
$first.Counters.Updated | Should -BeGreaterThan 0
$second = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
-ShouldProcessGate { param($t, $d) $true }
$second.Counters.Updated | Should -Be 0
$second.Counters.WouldUpdate | Should -Be 0
}
It 'issues no write request at all on the second run' {
$null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
-ShouldProcessGate { param($t, $d) $true }
$writesAfterFirst = 0
Should -Invoke Set-UserPersonaAttribute -Times 0 -Exactly -Scope It -ParameterFilter { $false }
$before = $script:store.Values | ForEach-Object { $_[$script:target] }
$second = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
-ShouldProcessGate { param($t, $d) $true }
$after = $script:store.Values | ForEach-Object { $_[$script:target] }
($after -join '|') | Should -Be ($before -join '|')
$second.Counters.Unchanged | Should -Be $second.Counters.Processed
}
It 'produces identical counters on the second and third runs' {
$null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
-ShouldProcessGate { param($t, $d) $true }
$second = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
-ShouldProcessGate { param($t, $d) $true }
$third = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
-ShouldProcessGate { param($t, $d) $true }
$third.Counters.Matched | Should -Be $second.Counters.Matched
$third.Counters.Unclassified | Should -Be $second.Counters.Unclassified
$third.Counters.Unchanged | Should -Be $second.Counters.Unchanged
$third.Counters.Updated | Should -Be $second.Counters.Updated
}
It 'treats a case-only difference as a real change, so it converges rather than oscillating' {
# Change detection is ordinal (FR-015). A stored 'employee' against a
# calculated 'Employee' is corrected once and then stays corrected - the
# failure mode this guards against is a run that rewrites it every time.
foreach ($user in $script:store.Values) {
if ($user[$script:target]) { $user[$script:target] = ([string]$user[$script:target]).ToLowerInvariant() }
}
$first = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
-ShouldProcessGate { param($t, $d) $true }
$second = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
-ShouldProcessGate { param($t, $d) $true }
$first.Counters.Updated | Should -BeGreaterThan 0
$second.Counters.Updated | Should -Be 0
}
}
+119
View File
@@ -0,0 +1,119 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
The write gate has exactly one origin: ShouldProcess.
The tests that matter here are the negative ones. -Debug and -Verbose are the two
switches an operator is most likely to reach for believing they make a run safe,
and neither does. If that ever changes silently, someone will run an enforcing
pass believing they are looking rather than touching.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
$script:config = New-TestRuntimeConfiguration -TargetAttribute $target
$script:population = @(New-TestPopulation -Count 12 -TargetAttribute $target)
$script:entryScript = Join-Path $repoRoot 'Invoke-PersonaEngine.ps1'
}
Describe 'Mode derives from the gate alone' -Tag 'Safety' {
BeforeEach {
Mock Get-PersonaUsers { $script:population }
Mock Write-Host { }
# Two of these tests raise the verbose and debug preferences deliberately.
# Without this the per-user diagnostic lines flood the whole suite's output.
Mock Write-Verbose { }
Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $true } }
}
It 'writes when -Debug is active and the gate allows it' {
# -Debug must not imply read-only. An operator who believed otherwise would
# reach for it as a safety control and get an enforcing run.
$DebugPreference = 'Continue'
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Enforce') `
-IsEnforcing -Tracing `
-ShouldProcessGate { param($t, $d) $true }
$outcome.Counters.Updated | Should -BeGreaterThan 0
Should -Invoke Set-UserPersonaAttribute -Times $outcome.Counters.Updated -Exactly
}
It 'writes when -Verbose is active and the gate allows it' {
$VerbosePreference = 'Continue'
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Enforce') `
-IsEnforcing `
-ShouldProcessGate { param($t, $d) $true }
$outcome.Counters.Updated | Should -BeGreaterThan 0
}
It 'refuses every write when the gate refuses, regardless of IsEnforcing' {
# IsEnforcing shapes the Action label; the gate decides the write. A
# disagreement between them must resolve in favour of not writing.
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Enforce') `
-IsEnforcing `
-ShouldProcessGate { param($t, $d) $false }
Should -Invoke Set-UserPersonaAttribute -Times 0 -Exactly
$outcome.Counters.Updated | Should -Be 0
$outcome.Counters.WouldUpdate | Should -BeGreaterThan 0
}
It 'honours a gate that allows some accounts and refuses others' {
# A per-account gate, as ShouldProcess is when the operator answers "Yes"
# rather than "Yes to All". Both buckets must be populated in one run.
$script:gateCalls = 0
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Enforce') `
-IsEnforcing `
-ShouldProcessGate { param($t, $d) ($script:gateCalls++ % 2) -eq 0 }
($outcome.Counters.Updated + $outcome.Counters.WouldUpdate) | Should -BeGreaterThan 0
$outcome.Counters.Updated | Should -BeGreaterThan 0
$outcome.Counters.WouldUpdate | Should -BeGreaterThan 0
}
}
Describe 'The entry script declares the safety contract it promises' -Tag 'Safety' {
BeforeAll {
$script:entryText = Get-Content -LiteralPath $script:entryScript -Raw
}
It 'declares SupportsShouldProcess with a High confirm impact' {
$entryText | Should -Match 'SupportsShouldProcess\s*=\s*\$true'
$entryText | Should -Match "ConfirmImpact\s*=\s*'High'"
}
It 'does not declare a preview or no-write parameter of its own' {
# Two sources of truth for the write gate is the defect class Principle III
# exists to prevent. -WhatIf is the only approved control.
$entryText | Should -Not -Match '\[switch\]\s*\$Preview'
$entryText | Should -Not -Match '\[switch\]\s*\$NoWrite'
$entryText | Should -Not -Match '\[switch\]\s*\$ReadOnly'
$entryText | Should -Not -Match '\[switch\]\s*\$DryRun'
}
It 'derives the mode from ShouldProcess' {
$entryText | Should -Match '\$PSCmdlet\.ShouldProcess\('
}
It 'does not derive the mode from DebugPreference or WhatIfPreference' {
# Reading the preference variables directly would reintroduce a second source
# of truth by the back door.
$entryText | Should -Not -Match '\$WhatIfPreference'
$entryText | Should -Not -Match 'if\s*\(\s*\$DebugPreference'
}
}
+133
View File
@@ -0,0 +1,133 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
SC-004: a preview run issues zero write requests across a full population.
The assertion that matters is the call count on the write adapter, not the
absence of an error. A run that never reached the write path because it crashed
at user 3 would also record zero writes, so every test here checks the population
was fully processed as well.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
$script:config = [pscustomobject]@{
ConfigVersion = '1.0.0'
ConfigurationHash = ('0' * 64)
TargetAttribute = $target
ApprovedWritableAttributes = @($target)
MaxConditionDepth = 5
SummaryInterval = 0
DefaultMembershipMode = 'Direct'
EvaluationErrorThreshold = $null
Rules = @(
[pscustomobject]@{
id = 'RULE-0010-GUEST'; name = 'Guests'; enabled = $true; priority = 10; persona = 'Guest'
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' })
}
}
[pscustomobject]@{
id = 'RULE-0900-EMPLOYEE'; name = 'Employees'; enabled = $true; priority = 900; persona = 'Employee'
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
}
)
}
$script:population = @(New-TestPopulation -Count 30 -TargetAttribute $target)
}
Describe 'Zero writes in preview mode (SC-004, FR-017)' -Tag 'Safety' {
BeforeEach {
Mock Get-PersonaUsers { $script:population }
Mock Set-UserPersonaAttribute { throw 'The write adapter must never be reached in preview mode.' }
Mock Write-Host { }
}
It 'issues no write request across the full population' {
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Preview') `
-ShouldProcessGate { param($t, $d) $false }
Should -Invoke Set-UserPersonaAttribute -Times 0 -Exactly
}
It 'still processed every account, so the zero count is meaningful' {
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Preview') `
-ShouldProcessGate { param($t, $d) $false }
$outcome.Counters.Processed | Should -Be 30
}
It 'reports the intended changes as WouldUpdate rather than hiding them' {
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Preview') `
-ShouldProcessGate { param($t, $d) $false }
# Ten Guest accounts carry a stale stored value of 'Employee'.
$outcome.Counters.WouldUpdate | Should -BeGreaterThan 0
}
It 'returns exit code 0 - a preview that changes nothing is a successful run' {
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Preview') `
-ShouldProcessGate { param($t, $d) $false }
$outcome.ExitCode | Should -Be 0
}
It 'defaults to refusing writes when no gate is supplied' {
# A caller that forgets the gate must preview, not write. The default is the
# safe answer rather than the convenient one.
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Preview')
Should -Invoke Set-UserPersonaAttribute -Times 0 -Exactly
$outcome.Counters.Updated | Should -Be 0
}
}
Describe 'Writes do occur when the gate allows them' -Tag 'Safety' {
BeforeEach {
Mock Get-PersonaUsers { $script:population }
Mock Write-Host { }
Mock Set-UserPersonaAttribute {
[pscustomobject]@{ Succeeded = $true; AccountObjectId = $UserObjectId; Value = $Value; PreviousValue = $PreviousValue }
}
}
It 'writes exactly the accounts whose calculated value differs' {
# The counterpart to the zero-write test. Without this, a run loop that never
# writes under any circumstances would pass every assertion above.
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Enforce') `
-IsEnforcing `
-ShouldProcessGate { param($t, $d) $true }
$outcome.Counters.Updated | Should -BeGreaterThan 0
Should -Invoke Set-UserPersonaAttribute -Times $outcome.Counters.Updated -Exactly
}
It 'never writes an account whose stored value already matches' {
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target `
-Context (New-TestAuditContext -Mode 'Enforce') `
-IsEnforcing `
-ShouldProcessGate { param($t, $d) $true }
$outcome.Counters.Unchanged | Should -BeGreaterThan 0
($outcome.Counters.Updated + $outcome.Counters.Unchanged + $outcome.Counters.Skipped + $outcome.Counters.UpdateFailed) |
Should -Be $outcome.Counters.Processed
}
}
+128
View File
@@ -0,0 +1,128 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
SC-005: every write body has exactly one key, equal to engine.targetAttribute.
This is the assertion that bounds the blast radius. OTD-003 records that Graph
application permissions have no per-property scope: whatever this engine can write
to the persona attribute, it could equally write to any other user property. The
directory will not stop a body with a second key, so this test is the thing that
does.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
$script:approved = @($script:target)
}
Describe 'Write body construction (SC-005)' -Tag 'Safety' {
It 'produces a body with exactly one key' {
$body = New-PersonaWriteBody -AttributeName $target -Value 'Employee' `
-TargetAttribute $target -ApprovedWritableAttributes $approved
$body.Count | Should -Be 1
}
It 'names that key exactly the target attribute' {
$body = New-PersonaWriteBody -AttributeName $target -Value 'Employee' `
-TargetAttribute $target -ApprovedWritableAttributes $approved
@($body.Keys)[0] | Should -BeExactly $target
}
It 'carries the calculated value unchanged' {
$body = New-PersonaWriteBody -AttributeName $target -Value 'Tier0-Admin' `
-TargetAttribute $target -ApprovedWritableAttributes $approved
$body[$target] | Should -BeExactly 'Tier0-Admin'
}
It 'permits an empty value, which clears the attribute' {
# Clearing is a legitimate outcome when a rule set stops matching an account.
# It must go through the same single-key path as any other write.
$body = New-PersonaWriteBody -AttributeName $target -Value '' `
-TargetAttribute $target -ApprovedWritableAttributes $approved
$body.Count | Should -Be 1
$body[$target] | Should -Be ''
}
}
Describe 'Every body issued during a run has exactly one key' -Tag 'Safety' {
It 'holds across a full enforcing population' {
# The unit test above proves the builder is correct. This proves the run loop
# actually uses it, on every account, with no other path to a PATCH.
$script:captured = [System.Collections.Generic.List[object]]::new()
Mock Write-Host { }
Mock Get-PersonaUsers { @(New-TestPopulation -Count 20 -TargetAttribute $script:target) }
Mock Invoke-PersonaGraphRequest {
if ($Method -eq 'PATCH') { $script:captured.Add($Body) }
@{}
}
$outcome = Invoke-PersonaEngineRun `
-Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext -Mode 'Enforce') `
-IsEnforcing -ShouldProcessGate { param($t, $d) $true }
$script:captured.Count | Should -BeGreaterThan 0
$script:captured.Count | Should -Be $outcome.Counters.Updated
foreach ($body in $script:captured) {
$body.Count | Should -Be 1
@($body.Keys)[0] | Should -BeExactly $script:target
}
}
It 'issues a PATCH and nothing else as a write method' {
$script:methods = [System.Collections.Generic.List[string]]::new()
Mock Write-Host { }
Mock Get-PersonaUsers { @(New-TestPopulation -Count 10 -TargetAttribute $script:target) }
Mock Invoke-PersonaGraphRequest {
$script:methods.Add($Method)
@{}
}
$null = Invoke-PersonaEngineRun `
-Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext -Mode 'Enforce') `
-IsEnforcing -ShouldProcessGate { param($t, $d) $true }
# No PUT and no DELETE: a PUT would replace the whole user object, and there
# is no circumstance in which this engine removes one.
$script:methods | Should -Not -Contain 'PUT'
$script:methods | Should -Not -Contain 'DELETE'
$script:methods | Should -Not -Contain 'POST'
$script:methods | Should -Contain 'PATCH'
}
}
Describe 'New-PersonaWriteBody is the only construction path' -Tag 'Safety' {
It 'is the only source file that builds a PATCH body' {
# A second construction site would make SC-005 a property of a convention
# rather than of a testable function.
$sources = Get-ChildItem -Path (Join-Path $repoRoot 'src') -Filter '*.ps1' -Recurse -File
$offenders = foreach ($file in $sources) {
if ($file.Name -eq 'New-PersonaWriteBody.ps1') { continue }
$text = Get-Content -LiteralPath $file.FullName -Raw
if ($text -match "Method\s*=?\s*'PATCH'" -and $text -notmatch 'New-PersonaWriteBody') {
# Invoke-PersonaGraphRequest declares PATCH in a ValidateSet; it does
# not construct a body.
if ($file.Name -ne 'Invoke-PersonaGraphRequest.ps1') { $file.Name }
}
}
$offenders | Should -BeNullOrEmpty
}
}
+135
View File
@@ -0,0 +1,135 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
New-PersonaWriteBody throws for any attribute other than the configured target,
and for any target absent from the approved list.
Throwing rather than correcting is the design. A caller that asked to write the
wrong attribute has a defect; silently substituting the right one hides it until
the day the substitution is also wrong.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
$script:approved = @($script:target)
}
Describe 'Rejection of a non-target attribute' -Tag 'Safety' {
It 'refuses <_>' -ForEach @('department', 'jobTitle', 'userPrincipalName', 'accountEnabled', 'onPremisesImmutableId') {
{ New-PersonaWriteBody -AttributeName $_ -Value 'Employee' `
-TargetAttribute $script:target -ApprovedWritableAttributes $script:approved } |
Should -Throw -ExpectedMessage '*only the configured target attribute*'
}
It 'refuses a different extension property' {
{ New-PersonaWriteBody -AttributeName 'extension_<EXTENSION-APP-ID>_<SOMETHING-ELSE>' -Value 'Employee' `
-TargetAttribute $script:target -ApprovedWritableAttributes $script:approved } |
Should -Throw
}
It 'refuses a casing variant of the target' {
# Extension property names are case-sensitive in Graph, so this is a different
# attribute, not the same one spelled differently. Accepting it would write to
# a property nobody approved.
{ New-PersonaWriteBody -AttributeName $script:target.ToUpperInvariant() -Value 'Employee' `
-TargetAttribute $script:target -ApprovedWritableAttributes $script:approved } |
Should -Throw
}
}
Describe 'Rejection of an unapproved target' -Tag 'Safety' {
It 'refuses a target absent from the approved list' {
{ New-PersonaWriteBody -AttributeName $script:target -Value 'Employee' `
-TargetAttribute $script:target -ApprovedWritableAttributes @('extension_<EXTENSION-APP-ID>_<OTHER>') } |
Should -Throw -ExpectedMessage '*not present in approvedWritableAttributes*'
}
It 'refuses when the approved list is empty' {
{ New-PersonaWriteBody -AttributeName $script:target -Value 'Employee' `
-TargetAttribute $script:target -ApprovedWritableAttributes @() } |
Should -Throw
}
It 'refuses when the approved list differs only in casing' {
{ New-PersonaWriteBody -AttributeName $script:target -Value 'Employee' `
-TargetAttribute $script:target -ApprovedWritableAttributes @($script:target.ToUpperInvariant()) } |
Should -Throw
}
It 'refuses a blank attribute name' {
{ New-PersonaWriteBody -AttributeName '' -Value 'Employee' `
-TargetAttribute $script:target -ApprovedWritableAttributes $script:approved } |
Should -Throw
}
}
Describe 'Resolve-TargetAttribute enforces the same rule earlier' -Tag 'Safety' {
It 'returns the target when it is approved' {
$config = [pscustomobject]@{ TargetAttribute = $script:target; ApprovedWritableAttributes = $script:approved }
Resolve-TargetAttribute -Configuration $config | Should -BeExactly $script:target
}
It 'throws rather than returning null for a blank target' {
# A null return would be indistinguishable from a caller forgetting to check,
# and that caller writes to whatever name it was holding.
$config = [pscustomobject]@{ TargetAttribute = ' '; ApprovedWritableAttributes = $script:approved }
{ Resolve-TargetAttribute -Configuration $config } | Should -Throw -ExpectedMessage '*blank*'
}
It 'throws for an unapproved target' {
$config = [pscustomobject]@{ TargetAttribute = 'department'; ApprovedWritableAttributes = $script:approved }
{ Resolve-TargetAttribute -Configuration $config } | Should -Throw
}
}
Describe 'Set-UserPersonaAttribute refuses an unconfirmed call' -Tag 'Safety' {
It 'throws when -Confirmed is absent' {
# Reaching the write adapter without the gate is a control-flow defect. The
# only safe response is to refuse, not to infer intent.
Mock Invoke-PersonaGraphRequest { @{} }
{ Set-UserPersonaAttribute -UserObjectId '00000000-0000-0000-0000-000000000101' `
-AttributeName $script:target -Value 'Employee' `
-TargetAttribute $script:target -ApprovedWritableAttributes $script:approved } |
Should -Throw -ExpectedMessage '*without a confirmed ShouldProcess gate*'
Should -Invoke Invoke-PersonaGraphRequest -Times 0 -Exactly
}
It 'returns a failed result rather than throwing when the PATCH fails' {
# A failed write is a per-user outcome. Throwing would abandon the rest of the
# population over one account.
Mock Invoke-PersonaGraphRequest { throw 'Response status code does not indicate success: 403 (Forbidden).' }
$result = Set-UserPersonaAttribute -UserObjectId '00000000-0000-0000-0000-000000000101' `
-AttributeName $script:target -Value 'Employee' -PreviousValue 'Guest' `
-TargetAttribute $script:target -ApprovedWritableAttributes $script:approved -Confirmed
$result.Succeeded | Should -BeFalse
$result.FailureReason | Should -Not -BeNullOrEmpty
$result.PreviousValue | Should -Be 'Guest'
}
It 'captures previousValue from the value observed before the write' {
Mock Invoke-PersonaGraphRequest { @{} }
$result = Set-UserPersonaAttribute -UserObjectId '00000000-0000-0000-0000-000000000101' `
-AttributeName $script:target -Value 'Tier0-Admin' -PreviousValue 'Employee' `
-TargetAttribute $script:target -ApprovedWritableAttributes $script:approved -Confirmed
$result.Succeeded | Should -BeTrue
$result.PreviousValue | Should -Be 'Employee'
$result.Value | Should -Be 'Tier0-Admin'
}
}
+192
View File
@@ -0,0 +1,192 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-016: a write is issued only when all four conditions hold.
1. Evaluation completed successfully (Outcome is not EvaluationError)
2. Calculated differs from stored, ordinal comparison
3. The target attribute is non-blank and approved
4. ShouldProcess returned true for this user
Each condition is tested in isolation by failing exactly that one, so a passing
result cannot be explained by a different condition having blocked the write.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
$script:target = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
function New-Decision {
param(
[string] $Outcome = 'Matched',
[AllowNull()] [string] $Calculated = 'Tier0-Admin',
[AllowNull()] [string] $Stored = 'Employee',
[string] $ErrorReason = $null
)
[pscustomobject]@{
AccountObjectId = '00000000-0000-0000-0000-000000000101'
UserPrincipalName = 'alex@example.invalid'
Outcome = $Outcome
MatchedRuleId = 'RULE-0030-TIER0'
CalculatedPersona = $Calculated
StoredPersona = $Stored
Action = 'Pending'
EvaluationErrorReason = $ErrorReason
RulesEvaluated = 2
DurationMs = 4
ConditionTrace = $null
}
}
}
Describe 'Condition 1 - evaluation must have succeeded' -Tag 'Safety' {
It 'skips an EvaluationError user even when the values differ' {
$result = Compare-PersonaValue -Result (New-Decision -Outcome 'EvaluationError' -Calculated $null -ErrorReason 'lookup failed') `
-IsEnforcing -TargetAttribute $target -ApprovedWritableAttributes @($target)
$result.Action | Should -Be 'Skipped'
}
It 'preserves the stored value on an EvaluationError user (FR-014)' {
$result = Compare-PersonaValue -Result (New-Decision -Outcome 'EvaluationError' -Calculated $null -Stored 'Tier0-Admin' -ErrorReason 'lookup failed') `
-IsEnforcing -TargetAttribute $target -ApprovedWritableAttributes @($target)
$result.StoredPersona | Should -Be 'Tier0-Admin'
$result.Action | Should -Be 'Skipped'
}
It 'never reaches the write adapter for an EvaluationError user' {
Mock Write-Host { }
Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $true } }
Mock Get-PersonaUsers { @(New-TestPopulation -Count 8 -TargetAttribute $script:target) }
Mock Get-PersonaGroupMembership { New-PersonaMembershipRecord -DirectFailureReason 'Graph 503 after 5 attempts' }
$config = New-TestRuntimeConfiguration -TargetAttribute $script:target -Rules @(
(New-TestMembershipRule)
[pscustomobject]@{
id = 'RULE-0900-EMPLOYEE'; name = 'Employees'; enabled = $true; priority = 900; persona = 'Employee'
match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }) }
})
$outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $script:target `
-Context (New-TestAuditContext -Mode 'Enforce') -IsEnforcing `
-ShouldProcessGate { param($t, $d) $true }
$outcome.Counters.EvaluationError | Should -Be $outcome.Counters.Processed
Should -Invoke Set-UserPersonaAttribute -Times 0 -Exactly
}
}
Describe 'Condition 2 - the value must actually differ' -Tag 'Safety' {
It 'reports Unchanged when stored and calculated are identical' {
$result = Compare-PersonaValue -Result (New-Decision -Calculated 'Employee' -Stored 'Employee') `
-IsEnforcing -TargetAttribute $target -ApprovedWritableAttributes @($target)
$result.Action | Should -Be 'Unchanged'
}
It 'treats a case-only difference as a real change' {
# Change detection is ordinal (FR-015). Treating these as equal would leave the
# directory permanently inconsistent with the rule set.
$result = Compare-PersonaValue -Result (New-Decision -Calculated 'Employee' -Stored 'employee') `
-IsEnforcing -TargetAttribute $target -ApprovedWritableAttributes @($target)
$result.Action | Should -Be 'Updated'
}
It 'treats a blank stored value against a calculated persona as a change' {
$result = Compare-PersonaValue -Result (New-Decision -Calculated 'Employee' -Stored '') `
-IsEnforcing -TargetAttribute $target -ApprovedWritableAttributes @($target)
$result.Action | Should -Be 'Updated'
}
It 'treats a null stored value as equal to an empty calculated value' {
$result = Compare-PersonaValue -Result (New-Decision -Calculated '' -Stored $null) `
-IsEnforcing -TargetAttribute $target -ApprovedWritableAttributes @($target)
$result.Action | Should -Be 'Unchanged'
}
}
Describe 'Condition 3 - the target must be valid and approved' -Tag 'Safety' {
It 'skips when the target attribute is blank' {
$result = Compare-PersonaValue -Result (New-Decision) `
-IsEnforcing -TargetAttribute '' -ApprovedWritableAttributes @($target)
$result.Action | Should -Be 'Skipped'
}
It 'skips when the target is absent from the approved list' {
$result = Compare-PersonaValue -Result (New-Decision) `
-IsEnforcing -TargetAttribute $target -ApprovedWritableAttributes @('extension_<EXTENSION-APP-ID>_<OTHER>')
$result.Action | Should -Be 'Skipped'
}
It 'skips when the approved list is empty' {
$result = Compare-PersonaValue -Result (New-Decision) `
-IsEnforcing -TargetAttribute $target -ApprovedWritableAttributes @()
$result.Action | Should -Be 'Skipped'
}
}
Describe 'Condition 4 - the gate must have returned true' -Tag 'Safety' {
It 'reports WouldUpdate rather than Updated when not enforcing' {
$result = Compare-PersonaValue -Result (New-Decision) `
-TargetAttribute $target -ApprovedWritableAttributes @($target)
$result.Action | Should -Be 'WouldUpdate'
}
It 'downgrades Updated to WouldUpdate when the per-user gate refuses' {
Mock Write-Host { }
Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $true } }
Mock Get-PersonaUsers { @(New-TestPopulation -Count 12 -TargetAttribute $script:target) }
$outcome = Invoke-PersonaEngineRun `
-Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext -Mode 'Enforce') `
-IsEnforcing -ShouldProcessGate { param($t, $d) $false }
$outcome.Counters.Updated | Should -Be 0
$outcome.Counters.WouldUpdate | Should -BeGreaterThan 0
Should -Invoke Set-UserPersonaAttribute -Times 0 -Exactly
}
It 'reports UpdateFailed when the gate allowed the write but the PATCH failed' {
Mock Write-Host { }
Mock Get-PersonaUsers { @(New-TestPopulation -Count 12 -TargetAttribute $script:target) }
Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $false; FailureReason = 'Response status code does not indicate success: 403 (Forbidden).' } }
$outcome = Invoke-PersonaEngineRun `
-Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext -Mode 'Enforce') `
-IsEnforcing -ShouldProcessGate { param($t, $d) $true }
$outcome.Counters.UpdateFailed | Should -BeGreaterThan 0
$outcome.Counters.Updated | Should -Be 0
}
It 'continues the run after a failed write rather than abandoning the population' {
Mock Write-Host { }
Mock Get-PersonaUsers { @(New-TestPopulation -Count 12 -TargetAttribute $script:target) }
Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $false; FailureReason = 'Forbidden' } }
$outcome = Invoke-PersonaEngineRun `
-Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) `
-TargetAttribute $script:target -Context (New-TestAuditContext -Mode 'Enforce') `
-IsEnforcing -ShouldProcessGate { param($t, $d) $true }
$outcome.Counters.Processed | Should -Be 12
}
}
+102
View File
@@ -0,0 +1,102 @@
<#
.SYNOPSIS
Fails if the rule engine has acquired a dependency it must not have.
.DESCRIPTION
Constitution Principle IV: the pure rule engine must not depend on Microsoft
Graph, authentication, Azure Automation, or console rendering. Directory
structure alone does not enforce that one convenient call is all it takes to
make the engine untestable offline, and the failure is silent until someone
tries to run the tests without a tenant.
This check is the enforcement. It runs in CI (pipelines/validate.yml) and is
cheap enough to run locally on every change.
.EXAMPLE
./tests/Test-EnginePurity.ps1
#>
[CmdletBinding()]
param(
[string] $EnginePath = (Join-Path (Split-Path $PSScriptRoot -Parent) 'src/RuleEngine'),
[switch] $PassThru
)
$ErrorActionPreference = 'Stop'
$forbidden = @(
@{ Name = 'Microsoft Graph call'; Regex = 'Invoke-MgGraphRequest|Connect-MgGraph|graph\.microsoft\.com|Invoke-PersonaGraphRequest' }
@{ Name = 'Authentication layer'; Regex = 'Connect-Persona\w+' }
@{ Name = 'Data provider layer'; Regex = 'Get-Persona(Users|GroupMembership|DirectoryRoles)' }
@{ Name = 'Persistence layer'; Regex = 'Set-UserPersonaAttribute|New-PersonaWriteBody|Compare-PersonaValue' }
@{ Name = 'Console rendering'; Regex = 'Write-Host|Write-UserPersonaResult|Write-PersonaSummary' }
@{ Name = 'Direct HTTP'; Regex = 'Invoke-RestMethod|Invoke-WebRequest|System\.Net\.Http' }
@{ Name = 'Filesystem access'; Regex = 'Get-Content|Set-Content|Out-File|Export-Csv' }
@{ Name = 'Non-deterministic input'; Regex = 'Get-Random|Get-Date|\[datetime\]::(Now|UtcNow|Today)|New-Guid' }
)
if (-not (Test-Path $EnginePath)) {
Write-Host "Engine path '$EnginePath' does not exist yet - nothing to check." -ForegroundColor Yellow
if ($PassThru) { return @() }
exit 0
}
$findings = [System.Collections.Generic.List[object]]::new()
foreach ($file in Get-ChildItem -Path $EnginePath -Filter '*.ps1' -File -Recurse) {
# Tokenize rather than scan raw text. Comments in this codebase legitimately
# name the forbidden functions when explaining why the engine does not call
# them, and a text scan cannot tell a trailing comment from code. The parser
# can, exactly.
$tokens = $null
$parseErrors = $null
$null = [System.Management.Automation.Language.Parser]::ParseFile(
$file.FullName, [ref]$tokens, [ref]$parseErrors)
if ($parseErrors.Count -gt 0) {
$findings.Add([pscustomobject]@{
File = $file.Name
Line = $parseErrors[0].Extent.StartLineNumber
Dependency = 'Parse error'
Text = $parseErrors[0].Message
})
continue
}
# Rebuild each line from its non-comment tokens.
$codeByLine = @{}
foreach ($token in $tokens) {
if ($token.Kind -eq 'Comment') { continue }
$line = $token.Extent.StartLineNumber
if (-not $codeByLine.ContainsKey($line)) { $codeByLine[$line] = [System.Text.StringBuilder]::new() }
$null = $codeByLine[$line].Append($token.Text).Append(' ')
}
foreach ($line in ($codeByLine.Keys | Sort-Object)) {
$code = $codeByLine[$line].ToString()
foreach ($rule in $forbidden) {
if ($code -match $rule.Regex) {
$findings.Add([pscustomobject]@{
File = $file.Name
Line = $line
Dependency = $rule.Name
Text = $code.Trim()
})
}
}
}
}
if ($findings.Count -gt 0) {
Write-Host "Engine purity check FAILED - $($findings.Count) violation(s) of Principle IV:" -ForegroundColor Red
$findings | Format-Table -AutoSize | Out-String | Write-Host
Write-Host 'The rule engine must remain testable offline with synthetic data.' -ForegroundColor Red
if ($PassThru) { return $findings }
exit 1
}
Write-Host 'Engine purity check passed: the rule engine has no forbidden dependencies.' -ForegroundColor Green
if ($PassThru) { return @() }
exit 0
+138
View File
@@ -0,0 +1,138 @@
<#
.SYNOPSIS
Fails if any tracked file contains data that must never be committed (SC-013).
.DESCRIPTION
Constitution: every artifact must be free of organization names, real domains,
tenant or subscription IDs, real UPNs or Object IDs, and any secret.
The scan is intentionally blunt. A false positive costs a placeholder rewrite;
a false negative commits tenant data to history, where deleting it later does
not undo the disclosure.
Placeholder GUIDs are permitted: any GUID built only from zeros plus a short
hex suffix (00000000-0000-0000-0000-0000000000a0) is obviously synthetic.
.EXAMPLE
./tests/Test-Sanitization.ps1
./tests/Test-Sanitization.ps1 -Path ./src
#>
[CmdletBinding()]
param(
[string] $Path = (Split-Path $PSScriptRoot -Parent),
[switch] $PassThru
)
$ErrorActionPreference = 'Stop'
# A GUID is synthetic when every character before the final short suffix is 0.
$placeholderGuid = '^0{8}-0{4}-0{4}-0{4}-0{8}[0-9a-f]{4}$'
$patterns = @(
@{
Name = 'Real GUID (tenant, subscription, object, or group ID)'
Regex = '\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b'
Exclude = $placeholderGuid
# A PowerShell module manifest must carry a genuine, unique GUID as its
# identity - it is what distinguishes this module from another of the same
# name. It identifies the module, not a tenant, and cannot be replaced with a
# placeholder without breaking module resolution.
ExcludeLine = '^\s*GUID\s*='
}
@{
Name = 'Email address or UPN'
# Placeholders such as <USER>@<PRIMARY-DOMAIN> contain angle brackets and
# are excluded by requiring word characters on both sides.
Regex = '\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b'
# Reserved, permanently unresolvable domains from RFC 2606 and RFC 6761.
# These exist precisely so documentation and fixtures can use an address that
# is guaranteed never to reach a real mailbox. Rejecting them would push
# fixtures toward something that merely looks fake, which is worse: the
# difference between "obviously synthetic" and "probably nobody's" is the
# whole point of the reserved list.
Exclude = '@(?:[A-Za-z0-9-]+\.)*(?:example\.(?:com|net|org)|invalid|test|localhost)$'
}
@{
Name = 'onmicrosoft.com domain'
Regex = '[A-Za-z0-9-]+\.onmicrosoft\.com'
}
@{
Name = 'Bearer token or JWT'
Regex = 'eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}'
}
@{
Name = 'Assigned secret, password, or key literal'
Regex = '(?i)\b(client_?secret|password|api_?key|access_?token)\b\s*[:=]\s*["\x27][^"\x27<][^"\x27]*["\x27]'
}
@{
Name = 'PEM private key block'
Regex = '-----BEGIN [A-Z ]*PRIVATE KEY-----'
}
)
Push-Location $Path
try {
# Tracked files AND untracked files that are not gitignored.
#
# Tracked-only would make this gate useless where it matters most: a leaked
# identifier in a file that has not been committed yet is precisely the one worth
# catching, and scanning only what is already in history means the scan passes
# right up until the commit that makes it too late.
#
# --exclude-standard keeps gitignored build output and local scratch files out,
# so the scan covers exactly what a commit would add.
$files = git ls-files --cached --others --exclude-standard 2>$null | Sort-Object -Unique
if (-not $files) {
throw "Not a git repository or no files to scan under '$Path'."
}
# This scanner necessarily contains the patterns it searches for.
$selfName = 'tests/Test-Sanitization.ps1'
$findings = [System.Collections.Generic.List[object]]::new()
foreach ($file in $files) {
if ($file -eq $selfName) { continue }
if (-not (Test-Path $file -PathType Leaf)) { continue }
# Skip binaries.
if ($file -match '\.(png|jpg|jpeg|gif|ico|pdf|zip|dll|exe|pfx|cer)$') { continue }
$lineNumber = 0
foreach ($line in (Get-Content -LiteralPath $file -ErrorAction SilentlyContinue)) {
$lineNumber++
foreach ($pattern in $patterns) {
# A line-level exemption is narrower than a file-level one on purpose:
# exempting a whole file would let a real identifier land anywhere in
# it, and the files that need an exemption at all are exactly the ones
# worth keeping under scrutiny.
if ($pattern.ExcludeLine -and $line -match $pattern.ExcludeLine) { continue }
foreach ($match in [regex]::Matches($line, $pattern.Regex)) {
if ($pattern.Exclude -and $match.Value -match $pattern.Exclude) { continue }
$findings.Add([pscustomobject]@{
File = $file
Line = $lineNumber
Pattern = $pattern.Name
Match = $match.Value
})
}
}
}
}
}
finally {
Pop-Location
}
if ($findings.Count -gt 0) {
Write-Host "Sanitization scan FAILED - $($findings.Count) finding(s):" -ForegroundColor Red
$findings | Format-Table -AutoSize | Out-String | Write-Host
if ($PassThru) { return $findings }
exit 1
}
Write-Host 'Sanitization scan passed: no tenant data, credentials, or real identifiers found.' -ForegroundColor Green
if ($PassThru) { return @() }
exit 0
@@ -0,0 +1,188 @@
<#
Regenerates the invalid-configuration corpus.
Each file is a valid configuration with exactly ONE thing broken, named after the
finding code it must produce. One defect per file is the point: a fixture with two
problems cannot prove which one produced the finding, and a validator that
reported the wrong code would still pass.
Run this only when adding a condition. The generated files are committed, so a
reviewer sees the fixture in the diff rather than a script that produces it.
pwsh ./tests/TestData/InvalidConfigs/New-InvalidConfigCorpus.ps1
#>
[CmdletBinding()]
param(
[string] $OutputDirectory = $PSScriptRoot
)
$ErrorActionPreference = 'Stop'
function New-BaseDocument {
@{
configVersion = '1.0.0'
engine = @{
targetAttribute = 'extension_<EXTENSION-APP-ID>_<PERSONA>'
approvedWritableAttributes = @('extension_<EXTENSION-APP-ID>_<PERSONA>')
maxConditionDepth = 5
summaryInterval = 25
defaultMembershipMode = 'direct'
}
dataSources = @{
groups = @{ enabled = $true }
roles = @{ enabled = $true }
}
logging = @{ destination = 'stream'; traceConditionValues = $false }
personas = @('Employee', 'Guest', 'Tier0-Admin')
rules = @(
@{
id = 'RULE-0010-GUEST'; name = 'Guest accounts'; description = 'Accounts whose user type is Guest.'
enabled = $true; priority = 10; persona = 'Guest'
match = @{ operator = 'all'; conditions = @(@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' }) }
}
@{
id = 'RULE-0900-EMPLOYEE'; name = 'Employees'; description = 'Default classification for member accounts.'
enabled = $true; priority = 900; persona = 'Employee'
match = @{ operator = 'all'; conditions = @(@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }) }
}
)
}
}
function New-MembershipRule {
param([hashtable] $Condition)
@{
id = 'RULE-0030-TIER0'; name = 'Tier 0 administrators'; description = 'Members of the Tier 0 group.'
enabled = $true; priority = 30; persona = 'Tier0-Admin'
match = @{ operator = 'all'; conditions = @($Condition) }
}
}
$cases = [ordered]@{}
# ---------------------------------------------------------------- VR-002
$d = New-BaseDocument
$d.rules[1].id = 'RULE-0010-GUEST'
$cases['PE-SEM-001-duplicate-rule-id'] = $d
$d = New-BaseDocument
$d.rules[1].priority = 10
$cases['PE-SEM-002-duplicate-priority'] = $d
$d = New-BaseDocument
foreach ($rule in $d.rules) { $rule.enabled = $false }
$cases['PE-SEM-003-no-enabled-rules'] = $d
$d = New-BaseDocument
$d.engine.targetAttribute = ' '
$cases['PE-SEM-004-blank-target-attribute'] = $d
$d = New-BaseDocument
$d.engine.approvedWritableAttributes = @('extension_<EXTENSION-APP-ID>_<SOMETHING-ELSE>')
$cases['PE-SEM-005-target-not-approved'] = $d
$d = New-BaseDocument
$d.dataSources.roles.enabled = $false
$d.rules += New-MembershipRule -Condition @{ type = 'role'; operator = 'memberOf'; roleIds = @('<TIER0-ROLE-TEMPLATE-ID>') }
$cases['PE-SEM-006-unavailable-data-source'] = $d
$d = New-BaseDocument
$d.rules += New-MembershipRule -Condition @{ type = 'membership'; operator = 'memberOf' }
$cases['PE-SEM-007-memberof-without-groups'] = $d
$d = New-BaseDocument
$d.rules[0].match.conditions[0] = @{ type = 'property'; property = 'UserType'; operator = 'in' }
$cases['PE-SEM-008-in-without-values'] = $d
$d = New-BaseDocument
$d.rules[1].match.conditions[0] = @{ type = 'property'; property = 'Department'; operator = 'isNotNull'; value = 'Finance' }
$cases['PE-SEM-009-isnull-with-value'] = $d
$d = New-BaseDocument
$d.rules[1].persona = 'Undeclared-Persona'
$cases['PE-SEM-010-undeclared-persona'] = $d
$d = New-BaseDocument
$d.rules[1].persona = 'Unclassified'
$cases['PE-SEM-011-unclassified-as-persona'] = $d
$d = New-BaseDocument
$d.engine.maxConditionDepth = 2
$d.rules[0].match = @{
operator = 'all'
conditions = @(
@{ operator = 'all'; conditions = @(
@{ operator = 'all'; conditions = @(
@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' }
) }
) }
)
}
$cases['PE-SEM-012-depth-over-configured-maximum'] = $d
$d = New-BaseDocument
$d.engine.maxConditionDepth = 25
$cases['PE-SEM-013-depth-over-hard-ceiling'] = $d
$d = New-BaseDocument
$d.dataSources.groups.membershipMode = 'direct'
$d.rules += New-MembershipRule -Condition @{
type = 'membership'; operator = 'memberOf'; membershipMode = 'transitive'
groupObjectIds = @('00000000-0000-0000-0000-0000000000a0')
}
$cases['PE-SEM-014-mode-not-enabled-globally'] = $d
$d = New-BaseDocument
$d.rules[1].match.conditions[0] = @{ type = 'property'; property = 'employeeHireDate'; operator = 'isNotNull' }
$cases['PE-SEM-015-unsupported-property'] = $d
$d = New-BaseDocument
$d.rules[1].match.conditions[0] = @{ type = 'property'; property = 'Department'; operator = 'matchesRegex'; value = '[unclosed' }
$cases['PE-SEM-016-invalid-regex'] = $d
# ---------------------------------------------------------------- VR-003
$d = New-BaseDocument
$d.engine.targetAttribute = ''
$d.engine.approvedWritableAttributes = @('extension_<EXTENSION-APP-ID>_<PERSONA>')
$cases['PE-SAF-001-blank-target-production'] = $d
$d = New-BaseDocument
$d.engine.targetAttribute = 'department'
$d.engine.approvedWritableAttributes = @('department')
$cases['PE-SAF-002-unsupported-writable-attribute'] = $d
$d = New-BaseDocument
$d.dataSources.groups.enabled = $false
$d.rules += New-MembershipRule -Condition @{
type = 'membership'; operator = 'memberOf'; groupObjectIds = @('00000000-0000-0000-0000-0000000000a0')
}
$cases['PE-SAF-003-group-rules-without-group-retrieval'] = $d
$d = New-BaseDocument
$d.configVersion = '0.9.0'
$cases['PE-SAF-004-version-downgrade'] = $d
$d = New-BaseDocument
$d.rules = @($d.rules[0])
$cases['PE-SAF-005-rule-removed-without-version-change'] = $d
$d = New-BaseDocument
$d.logging.traceConditionValues = $true
$cases['PE-SAF-006-tracing-without-acknowledgement'] = $d
# ---------------------------------------------------------------- baseline
# The comparison baseline for PE-SAF-004 and PE-SAF-005. Valid on its own.
$d = New-BaseDocument
$cases['baseline-deployed'] = $d
foreach ($name in $cases.Keys) {
$path = Join-Path $OutputDirectory "$name.json"
Set-Content -LiteralPath $path -Value ($cases[$name] | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
Write-Host "wrote $name.json"
}
Write-Host ("{0} fixture(s) written to {1}" -f $cases.Count, $OutputDirectory)
@@ -0,0 +1,68 @@
{
"configVersion": "1.0.0",
"engine": {
"targetAttribute": "",
"maxConditionDepth": 5,
"approvedWritableAttributes": [
"extension_<EXTENSION-APP-ID>_<PERSONA>"
],
"defaultMembershipMode": "direct",
"summaryInterval": 25
},
"personas": [
"Employee",
"Guest",
"Tier0-Admin"
],
"dataSources": {
"groups": {
"enabled": true
},
"roles": {
"enabled": true
}
},
"logging": {
"destination": "stream",
"traceConditionValues": false
},
"rules": [
{
"id": "RULE-0010-GUEST",
"persona": "Guest",
"description": "Accounts whose user type is Guest.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "equals",
"type": "property",
"property": "UserType",
"value": "Guest"
}
]
},
"priority": 10,
"enabled": true,
"name": "Guest accounts"
},
{
"id": "RULE-0900-EMPLOYEE",
"persona": "Employee",
"description": "Default classification for member accounts.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "isNotNull",
"type": "property",
"property": "Department"
}
]
},
"priority": 900,
"enabled": true,
"name": "Employees"
}
]
}
@@ -0,0 +1,68 @@
{
"configVersion": "1.0.0",
"engine": {
"targetAttribute": "department",
"maxConditionDepth": 5,
"approvedWritableAttributes": [
"department"
],
"defaultMembershipMode": "direct",
"summaryInterval": 25
},
"personas": [
"Employee",
"Guest",
"Tier0-Admin"
],
"dataSources": {
"groups": {
"enabled": true
},
"roles": {
"enabled": true
}
},
"logging": {
"destination": "stream",
"traceConditionValues": false
},
"rules": [
{
"id": "RULE-0010-GUEST",
"persona": "Guest",
"description": "Accounts whose user type is Guest.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "equals",
"type": "property",
"property": "UserType",
"value": "Guest"
}
]
},
"priority": 10,
"enabled": true,
"name": "Guest accounts"
},
{
"id": "RULE-0900-EMPLOYEE",
"persona": "Employee",
"description": "Default classification for member accounts.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "isNotNull",
"type": "property",
"property": "Department"
}
]
},
"priority": 900,
"enabled": true,
"name": "Employees"
}
]
}
@@ -0,0 +1,88 @@
{
"configVersion": "1.0.0",
"engine": {
"maxConditionDepth": 5,
"defaultMembershipMode": "direct",
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
"summaryInterval": 25,
"approvedWritableAttributes": [
"extension_<EXTENSION-APP-ID>_<PERSONA>"
]
},
"personas": [
"Employee",
"Guest",
"Tier0-Admin"
],
"dataSources": {
"groups": {
"enabled": false
},
"roles": {
"enabled": true
}
},
"logging": {
"destination": "stream",
"traceConditionValues": false
},
"rules": [
{
"id": "RULE-0010-GUEST",
"persona": "Guest",
"description": "Accounts whose user type is Guest.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "equals",
"type": "property",
"property": "UserType",
"value": "Guest"
}
]
},
"priority": 10,
"enabled": true,
"name": "Guest accounts"
},
{
"id": "RULE-0900-EMPLOYEE",
"persona": "Employee",
"description": "Default classification for member accounts.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "isNotNull",
"type": "property",
"property": "Department"
}
]
},
"priority": 900,
"enabled": true,
"name": "Employees"
},
{
"id": "RULE-0030-TIER0",
"persona": "Tier0-Admin",
"description": "Members of the Tier 0 group.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "memberOf",
"type": "membership",
"groupObjectIds": [
"00000000-0000-0000-0000-0000000000a0"
]
}
]
},
"priority": 30,
"enabled": true,
"name": "Tier 0 administrators"
}
]
}
@@ -0,0 +1,68 @@
{
"configVersion": "0.9.0",
"engine": {
"maxConditionDepth": 5,
"defaultMembershipMode": "direct",
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
"summaryInterval": 25,
"approvedWritableAttributes": [
"extension_<EXTENSION-APP-ID>_<PERSONA>"
]
},
"personas": [
"Employee",
"Guest",
"Tier0-Admin"
],
"dataSources": {
"groups": {
"enabled": true
},
"roles": {
"enabled": true
}
},
"logging": {
"destination": "stream",
"traceConditionValues": false
},
"rules": [
{
"id": "RULE-0010-GUEST",
"persona": "Guest",
"description": "Accounts whose user type is Guest.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "equals",
"type": "property",
"property": "UserType",
"value": "Guest"
}
]
},
"priority": 10,
"enabled": true,
"name": "Guest accounts"
},
{
"id": "RULE-0900-EMPLOYEE",
"persona": "Employee",
"description": "Default classification for member accounts.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "isNotNull",
"type": "property",
"property": "Department"
}
]
},
"priority": 900,
"enabled": true,
"name": "Employees"
}
]
}
@@ -0,0 +1,50 @@
{
"configVersion": "1.0.0",
"engine": {
"maxConditionDepth": 5,
"defaultMembershipMode": "direct",
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
"summaryInterval": 25,
"approvedWritableAttributes": [
"extension_<EXTENSION-APP-ID>_<PERSONA>"
]
},
"personas": [
"Employee",
"Guest",
"Tier0-Admin"
],
"dataSources": {
"groups": {
"enabled": true
},
"roles": {
"enabled": true
}
},
"logging": {
"destination": "stream",
"traceConditionValues": false
},
"rules": [
{
"id": "RULE-0010-GUEST",
"persona": "Guest",
"description": "Accounts whose user type is Guest.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "equals",
"type": "property",
"property": "UserType",
"value": "Guest"
}
]
},
"priority": 10,
"enabled": true,
"name": "Guest accounts"
}
]
}
@@ -0,0 +1,68 @@
{
"configVersion": "1.0.0",
"engine": {
"maxConditionDepth": 5,
"defaultMembershipMode": "direct",
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
"summaryInterval": 25,
"approvedWritableAttributes": [
"extension_<EXTENSION-APP-ID>_<PERSONA>"
]
},
"personas": [
"Employee",
"Guest",
"Tier0-Admin"
],
"dataSources": {
"groups": {
"enabled": true
},
"roles": {
"enabled": true
}
},
"logging": {
"traceConditionValues": true,
"destination": "stream"
},
"rules": [
{
"id": "RULE-0010-GUEST",
"persona": "Guest",
"description": "Accounts whose user type is Guest.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "equals",
"type": "property",
"property": "UserType",
"value": "Guest"
}
]
},
"priority": 10,
"enabled": true,
"name": "Guest accounts"
},
{
"id": "RULE-0900-EMPLOYEE",
"persona": "Employee",
"description": "Default classification for member accounts.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "isNotNull",
"type": "property",
"property": "Department"
}
]
},
"priority": 900,
"enabled": true,
"name": "Employees"
}
]
}
@@ -0,0 +1,68 @@
{
"configVersion": "1.0.0",
"engine": {
"maxConditionDepth": 5,
"defaultMembershipMode": "direct",
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
"summaryInterval": 25,
"approvedWritableAttributes": [
"extension_<EXTENSION-APP-ID>_<PERSONA>"
]
},
"personas": [
"Employee",
"Guest",
"Tier0-Admin"
],
"dataSources": {
"groups": {
"enabled": true
},
"roles": {
"enabled": true
}
},
"logging": {
"destination": "stream",
"traceConditionValues": false
},
"rules": [
{
"id": "RULE-0010-GUEST",
"persona": "Guest",
"description": "Accounts whose user type is Guest.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "equals",
"type": "property",
"property": "UserType",
"value": "Guest"
}
]
},
"priority": 10,
"enabled": true,
"name": "Guest accounts"
},
{
"match": {
"operator": "all",
"conditions": [
{
"operator": "isNotNull",
"type": "property",
"property": "Department"
}
]
},
"persona": "Employee",
"enabled": true,
"priority": 900,
"description": "Default classification for member accounts.",
"name": "Employees",
"id": "RULE-0010-GUEST"
}
]
}
@@ -0,0 +1,68 @@
{
"configVersion": "1.0.0",
"engine": {
"maxConditionDepth": 5,
"defaultMembershipMode": "direct",
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
"summaryInterval": 25,
"approvedWritableAttributes": [
"extension_<EXTENSION-APP-ID>_<PERSONA>"
]
},
"personas": [
"Employee",
"Guest",
"Tier0-Admin"
],
"dataSources": {
"groups": {
"enabled": true
},
"roles": {
"enabled": true
}
},
"logging": {
"destination": "stream",
"traceConditionValues": false
},
"rules": [
{
"id": "RULE-0010-GUEST",
"persona": "Guest",
"description": "Accounts whose user type is Guest.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "equals",
"type": "property",
"property": "UserType",
"value": "Guest"
}
]
},
"priority": 10,
"enabled": true,
"name": "Guest accounts"
},
{
"match": {
"operator": "all",
"conditions": [
{
"operator": "isNotNull",
"type": "property",
"property": "Department"
}
]
},
"persona": "Employee",
"enabled": true,
"priority": 10,
"description": "Default classification for member accounts.",
"name": "Employees",
"id": "RULE-0900-EMPLOYEE"
}
]
}
@@ -0,0 +1,68 @@
{
"configVersion": "1.0.0",
"engine": {
"maxConditionDepth": 5,
"defaultMembershipMode": "direct",
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
"summaryInterval": 25,
"approvedWritableAttributes": [
"extension_<EXTENSION-APP-ID>_<PERSONA>"
]
},
"personas": [
"Employee",
"Guest",
"Tier0-Admin"
],
"dataSources": {
"groups": {
"enabled": true
},
"roles": {
"enabled": true
}
},
"logging": {
"destination": "stream",
"traceConditionValues": false
},
"rules": [
{
"match": {
"operator": "all",
"conditions": [
{
"operator": "equals",
"type": "property",
"property": "UserType",
"value": "Guest"
}
]
},
"persona": "Guest",
"enabled": false,
"priority": 10,
"description": "Accounts whose user type is Guest.",
"name": "Guest accounts",
"id": "RULE-0010-GUEST"
},
{
"match": {
"operator": "all",
"conditions": [
{
"operator": "isNotNull",
"type": "property",
"property": "Department"
}
]
},
"persona": "Employee",
"enabled": false,
"priority": 900,
"description": "Default classification for member accounts.",
"name": "Employees",
"id": "RULE-0900-EMPLOYEE"
}
]
}
@@ -0,0 +1,68 @@
{
"configVersion": "1.0.0",
"engine": {
"targetAttribute": " ",
"maxConditionDepth": 5,
"approvedWritableAttributes": [
"extension_<EXTENSION-APP-ID>_<PERSONA>"
],
"defaultMembershipMode": "direct",
"summaryInterval": 25
},
"personas": [
"Employee",
"Guest",
"Tier0-Admin"
],
"dataSources": {
"groups": {
"enabled": true
},
"roles": {
"enabled": true
}
},
"logging": {
"destination": "stream",
"traceConditionValues": false
},
"rules": [
{
"id": "RULE-0010-GUEST",
"persona": "Guest",
"description": "Accounts whose user type is Guest.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "equals",
"type": "property",
"property": "UserType",
"value": "Guest"
}
]
},
"priority": 10,
"enabled": true,
"name": "Guest accounts"
},
{
"id": "RULE-0900-EMPLOYEE",
"persona": "Employee",
"description": "Default classification for member accounts.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "isNotNull",
"type": "property",
"property": "Department"
}
]
},
"priority": 900,
"enabled": true,
"name": "Employees"
}
]
}
@@ -0,0 +1,68 @@
{
"configVersion": "1.0.0",
"engine": {
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
"maxConditionDepth": 5,
"approvedWritableAttributes": [
"extension_<EXTENSION-APP-ID>_<SOMETHING-ELSE>"
],
"defaultMembershipMode": "direct",
"summaryInterval": 25
},
"personas": [
"Employee",
"Guest",
"Tier0-Admin"
],
"dataSources": {
"groups": {
"enabled": true
},
"roles": {
"enabled": true
}
},
"logging": {
"destination": "stream",
"traceConditionValues": false
},
"rules": [
{
"id": "RULE-0010-GUEST",
"persona": "Guest",
"description": "Accounts whose user type is Guest.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "equals",
"type": "property",
"property": "UserType",
"value": "Guest"
}
]
},
"priority": 10,
"enabled": true,
"name": "Guest accounts"
},
{
"id": "RULE-0900-EMPLOYEE",
"persona": "Employee",
"description": "Default classification for member accounts.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "isNotNull",
"type": "property",
"property": "Department"
}
]
},
"priority": 900,
"enabled": true,
"name": "Employees"
}
]
}
@@ -0,0 +1,88 @@
{
"configVersion": "1.0.0",
"engine": {
"maxConditionDepth": 5,
"defaultMembershipMode": "direct",
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
"summaryInterval": 25,
"approvedWritableAttributes": [
"extension_<EXTENSION-APP-ID>_<PERSONA>"
]
},
"personas": [
"Employee",
"Guest",
"Tier0-Admin"
],
"dataSources": {
"groups": {
"enabled": true
},
"roles": {
"enabled": false
}
},
"logging": {
"destination": "stream",
"traceConditionValues": false
},
"rules": [
{
"id": "RULE-0010-GUEST",
"persona": "Guest",
"description": "Accounts whose user type is Guest.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "equals",
"type": "property",
"property": "UserType",
"value": "Guest"
}
]
},
"priority": 10,
"enabled": true,
"name": "Guest accounts"
},
{
"id": "RULE-0900-EMPLOYEE",
"persona": "Employee",
"description": "Default classification for member accounts.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "isNotNull",
"type": "property",
"property": "Department"
}
]
},
"priority": 900,
"enabled": true,
"name": "Employees"
},
{
"id": "RULE-0030-TIER0",
"persona": "Tier0-Admin",
"description": "Members of the Tier 0 group.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "memberOf",
"type": "role",
"roleIds": [
"<TIER0-ROLE-TEMPLATE-ID>"
]
}
]
},
"priority": 30,
"enabled": true,
"name": "Tier 0 administrators"
}
]
}
@@ -0,0 +1,85 @@
{
"configVersion": "1.0.0",
"engine": {
"maxConditionDepth": 5,
"defaultMembershipMode": "direct",
"targetAttribute": "extension_<EXTENSION-APP-ID>_<PERSONA>",
"summaryInterval": 25,
"approvedWritableAttributes": [
"extension_<EXTENSION-APP-ID>_<PERSONA>"
]
},
"personas": [
"Employee",
"Guest",
"Tier0-Admin"
],
"dataSources": {
"groups": {
"enabled": true
},
"roles": {
"enabled": true
}
},
"logging": {
"destination": "stream",
"traceConditionValues": false
},
"rules": [
{
"id": "RULE-0010-GUEST",
"persona": "Guest",
"description": "Accounts whose user type is Guest.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "equals",
"type": "property",
"property": "UserType",
"value": "Guest"
}
]
},
"priority": 10,
"enabled": true,
"name": "Guest accounts"
},
{
"id": "RULE-0900-EMPLOYEE",
"persona": "Employee",
"description": "Default classification for member accounts.",
"match": {
"operator": "all",
"conditions": [
{
"operator": "isNotNull",
"type": "property",
"property": "Department"
}
]
},
"priority": 900,
"enabled": true,
"name": "Employees"
},
{
"id": "RULE-0030-TIER0",
"persona": "Tier0-Admin",
"description": "Members of the Tier 0 group.",
"match": {
"operator": "all",
"conditions": [
{
"type": "membership",
"operator": "memberOf"
}
]
},
"priority": 30,
"enabled": true,
"name": "Tier 0 administrators"
}
]
}

Some files were not shown because too many files have changed in this diff Show More