Implement Stage A: rule engine, validation, audit, and safety gates
Completes 109 of 121 tasks. Every remaining task needs a tenant connection
(T055, T056, T101-T103) or an Azure Automation account (T115-T121).
354 offline Pester tests PASS
Engine purity (Principle IV) PASS
Sanitization (SC-013) PASS (156 files)
Graph module loaded in tests none (SC-008 holds)
What landed
- Four-layer configuration validation with stable finding codes, covering
every VR-002 and VR-003 condition, plus a 23-fixture invalid-config corpus
- Run loop, audit records (NDJSON through a single sink), summaries,
reconciliation, and exit codes 0-6
- Persistence behind a single write-body builder whose result always has
exactly one key
- Invoke-PersonaEngine.ps1 and Edit-PersonaEngineConfig.ps1
- Six docs, two pipelines, traceability matrix, V-5a and sanitization records
Three deviations from tasks.md, each recorded in its status block
T033 is not in Resolve-UserPersona. evaluationErrorThreshold is run-level
state and the rule engine is pure; a counter there would break Principle IV.
It lives in New-PersonaRunCounter and is applied in the run loop.
A new src/Engine/ layer holds Invoke-PersonaEngineRun. The entry script
imports the manifest, which requires Microsoft.Graph.Authentication, so a
loop living only inside it could not run on a machine without the Graph SDK
and SC-004 could not be proven at all. The entry script is now a thin
wrapper and what ships is what is tested.
The invalid-config corpus is generated by a committed script, with the
generated fixtures committed too, so a reviewer sees the fixture in the diff.
Defects found by running the code, not by reading it
Group and role ID lists were double-wrapped: @(Get-PersonaGroupIdPage ...)
around a comma-returned array collapsed every membership list into one
bogus space-joined entry. That is a silent false non-match, exactly what
FR-013 exists to prevent.
A 403 whose status appears only in the exception message parsed as $null,
which the retry policy treats as a transport error - five requests per
account against a tenant already refusing. Status extraction now falls back
to the message text, bounded to 400-599.
The sanitization scan walked tracked files only, so it covered 34 of 156
files and none of this phase's code. It now scans untracked non-ignored
files too, and a negative control confirms it catches a planted leak.
Test-Json reports one error per violating location, not first-failure-only
as the V-5a draft claimed. Record and pin corrected.
Enforcement remains blocked on the V-4 security sign-off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user