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