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
+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'
}
}
}