diff --git a/Edit-PersonaEngineConfig.ps1 b/Edit-PersonaEngineConfig.ps1 new file mode 100644 index 0000000..74473e9 --- /dev/null +++ b/Edit-PersonaEngineConfig.ps1 @@ -0,0 +1,442 @@ +#Requires -Version 7.2 + +<# +.SYNOPSIS + Validates, tests, and interactively edits a Persona Engine configuration. + +.DESCRIPTION + Two jobs in one tool, deliberately: the thing that checks a configuration and the + thing that edits it must agree about what "valid" means, and the surest way to + guarantee that is to make them the same code path. + + -ValidateOnly report findings and exit. Never enters the editor. + -NonInteractive pipeline mode. Never prompts, never hangs (SC-010). + (neither) interactive editor, re-validating before every save. + + -NonInteractive is not a convenience flag. A build agent runs with stdin closed; + a tool that prompts there does not fail, it hangs until the job times out, and + the pipeline reports an infrastructure problem instead of a bad configuration. + Every prompting call in this script sits behind a check for it. + + Rule testing against synthetic users (-TestDataPath) runs the real rule engine + with no tenant connectivity (FR-025, SC-008). The engine is pure, so the answer + it gives offline is the answer it gives in production. + +.PARAMETER ConfigPath + Configuration to validate or edit. + +.PARAMETER ValidateOnly + Validate and report; do not enter the editor. + +.PARAMETER NonInteractive + Pipeline mode: no prompts, exit code only. + +.PARAMETER SchemaPath + Override the shipped schema. + +.PARAMETER OutputPath + Save-As target. Leaves the input file untouched. + +.PARAMETER TreatWarningsAsErrors + Escalate Warning findings to blocking (VR-005). + +.PARAMETER TestDataPath + Synthetic sample users for offline rule testing (FR-025). + +.PARAMETER PreviousConfigPath + Currently deployed configuration, enabling the VR-003 drift checks. + +.PARAMETER EnforcementEnabled + Validate as though this configuration will drive an enforcing run, which raises + the severity of several safety findings. + +.EXAMPLE + ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive + + The pipeline invocation. Returns 0 when the configuration is clean. + +.EXAMPLE + ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData + + Validate, then evaluate the rules against synthetic users with no tenant. + +.NOTES + Exit codes + 0 valid; no blocking findings + 1 one or more Error findings + 2 Warning findings present with -TreatWarningsAsErrors + 3 configuration file not found or unreadable + 4 schema file not found or itself invalid +#> +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ConfigPath, + + [switch] $ValidateOnly, + + [switch] $NonInteractive, + + [string] $SchemaPath, + + [string] $OutputPath, + + [switch] $TreatWarningsAsErrors, + + [string] $TestDataPath, + + [string] $PreviousConfigPath, + + [switch] $EnforcementEnabled +) + +$ErrorActionPreference = 'Stop' + +# The .psm1 directly, not the manifest. The manifest declares +# Microsoft.Graph.Authentication as a required module, and this tool never touches +# Graph - SC-008 requires full validation and synthetic rule testing to complete with +# no network access. Importing the manifest would make a build agent that only +# validates configuration install a Graph SDK it will never call. +Import-Module (Join-Path $PSScriptRoot 'PersonaEngine.psm1') -Force -ErrorAction Stop + +$EXIT_OK = 0 +$EXIT_FINDINGS = 1 +$EXIT_WARNINGS_AS_ERRORS = 2 +$EXIT_CONFIG_UNREADABLE = 3 +$EXIT_SCHEMA_UNUSABLE = 4 + +function Invoke-ConfigurationValidation { + <# + .SYNOPSIS + Runs all four layers and maps the result onto an exit code. + + .DESCRIPTION + One function so the interactive save path and the pipeline path cannot + disagree. The exit-code mapping lives here rather than at the call sites for + the same reason: an exit code is the pipeline's only view of what happened. + #> + param([string] $Path) + + $params = @{ Path = $Path; EnforcementEnabled = $EnforcementEnabled } + if ($SchemaPath) { $params['SchemaPath'] = $SchemaPath } + if ($PreviousConfigPath) { $params['PreviousConfigPath'] = $PreviousConfigPath } + + $result = Test-PersonaConfiguration @params + + $code = $EXIT_OK + + # Order matters. An unreadable file and an unusable schema are environment + # faults, not authoring faults, and a pipeline needs to tell them apart from a + # genuinely invalid configuration - otherwise a missing schema file is reported + # to the author as "your rules are wrong". + if (@($result.Findings | Where-Object Code -In @('PE-SYN-001', 'PE-SYN-002')).Count -gt 0) { + $code = $EXIT_CONFIG_UNREADABLE + } + elseif ($result.SchemaUnusable -or @($result.Findings | Where-Object Code -EQ 'PE-SCH-002').Count -gt 0) { + $code = $EXIT_SCHEMA_UNUSABLE + } + elseif (-not $result.IsValid) { + $code = $EXIT_FINDINGS + } + elseif ($TreatWarningsAsErrors -and $result.WarningCount -gt 0) { + # VR-005: warnings block only here. The finding severity is unchanged - the + # configuration is not retroactively more broken because a switch was passed; + # what changed is the caller's tolerance for it. + $code = $EXIT_WARNINGS_AS_ERRORS + } + + [pscustomobject]@{ Result = $result; ExitCode = $code } +} + +function Invoke-SyntheticRuleTest { + <# + .SYNOPSIS + Evaluates the configuration's rules against synthetic users (FR-025). + + .DESCRIPTION + No tenant, no credentials, no network. Reads the fixture files, builds + normalized records, and runs the real engine over them. + + Fixtures declare their membership facets explicitly, including failed ones. + A fixture with an unretrieved facet is the only way to see EvaluationError + behaviour before it happens against a live directory. + #> + param( + [Parameter(Mandatory)] [object] $Configuration, + [Parameter(Mandatory)] [string] $DataPath + ) + + $usersFile = (Test-Path -LiteralPath $DataPath -PathType Container) ` + ? (Join-Path $DataPath 'Users/users.json') ` + : $DataPath + + if (-not (Test-Path -LiteralPath $usersFile -PathType Leaf)) { + Write-Host "No synthetic user fixtures found at '$usersFile'." -ForegroundColor Yellow + return + } + + $fixtures = (Get-Content -LiteralPath $usersFile -Raw | ConvertFrom-Json -Depth 32).users + + $membershipFile = Join-Path (Split-Path (Split-Path $usersFile -Parent) -Parent) 'Memberships/memberships.json' + $membershipByFixture = @{} + + if (Test-Path -LiteralPath $membershipFile -PathType Leaf) { + foreach ($entry in (Get-Content -LiteralPath $membershipFile -Raw | ConvertFrom-Json -Depth 32).memberships) { + $params = @{ + DirectGroupObjectIds = @($entry.directGroupObjectIds) + TransitiveGroupObjectIds = @($entry.transitiveGroupObjectIds) + DirectoryRoleIds = @($entry.directoryRoleIds) + } + foreach ($facet in @('allRetrieved', 'directRetrieved', 'transitiveRetrieved', 'rolesRetrieved')) { + if ($entry.PSObject.Properties[$facet] -and $entry.$facet) { + $params[(Get-Culture).TextInfo.ToTitleCase($facet)] = $true + } + } + foreach ($facet in @('directFailureReason', 'transitiveFailureReason', 'rolesFailureReason')) { + if ($entry.PSObject.Properties[$facet] -and $entry.$facet) { + $params[(Get-Culture).TextInfo.ToTitleCase($facet)] = [string]$entry.$facet + } + } + $membershipByFixture[[string]$entry.fixtureId] = New-PersonaMembershipRecord @params + } + } + + Write-Host '' + Write-Host 'Synthetic rule test - no tenant connectivity (FR-025, SC-008)' -ForegroundColor Cyan + Write-Host ('{0,-24} {1,-45} {2,-20} {3}' -f 'Fixture', 'UPN', 'Outcome', 'Persona / rule') -ForegroundColor DarkGray + + foreach ($fixture in $fixtures) { + $properties = @{} + if ($fixture.properties) { + foreach ($p in $fixture.properties.PSObject.Properties) { $properties[$p.Name] = $p.Value } + } + + $record = New-PersonaUserRecord ` + -AccountObjectId ([string]$fixture.accountObjectId) ` + -UserPrincipalName ([string]$fixture.userPrincipalName) ` + -DisplayName ([string]$fixture.displayName) ` + -UserType ([string]$fixture.userType) ` + -AccountEnabled ([bool]$fixture.accountEnabled) ` + -Properties $properties ` + -StoredPersona ([string]$fixture.storedPersona) ` + -Membership $membershipByFixture[[string]$fixture.fixtureId] + + $result = Resolve-UserPersona -UserRecord $record -Rules $Configuration.Rules ` + -MaxDepth $Configuration.MaxConditionDepth ` + -DefaultMembershipMode $Configuration.DefaultMembershipMode + + $colour = switch ($result.Outcome) { + 'Matched' { 'Green' } + 'Unclassified' { 'Gray' } + 'EvaluationError' { 'Yellow' } + } + + $detail = $result.Outcome -eq 'Matched' ` + ? "$($result.CalculatedPersona) [$($result.MatchedRuleId)]" ` + : [string]$result.EvaluationErrorReason + + Write-Host ('{0,-24} {1,-45} {2,-20} {3}' -f + $fixture.fixtureId, $result.UserPrincipalName, $result.Outcome, $detail) -ForegroundColor $colour + } + + Write-Host '' +} + +function Save-PersonaConfiguration { + <# + .SYNOPSIS + Saves an edited configuration, backing up first (FR-026). + + .DESCRIPTION + The save order is the contract, and it is deliberate: + + 1. Re-validate in full. + 2. Refuse on any Error finding. + 3. Back up the existing file, or write to -OutputPath instead. + 4. Only then replace. + + Backing up before replacing rather than after is what makes step 4 safe to + interrupt. A crash between backup and write leaves the original and a copy; + a crash the other way round leaves neither. + #> + param( + [Parameter(Mandatory)] [object] $Document, + [Parameter(Mandatory)] [string] $Path, + [string] $SaveAs + ) + + $json = $Document | ConvertTo-Json -Depth 32 + + $temp = [System.IO.Path]::GetTempFileName() + try { + Set-Content -LiteralPath $temp -Value $json -Encoding utf8NoBOM + + $check = Invoke-ConfigurationValidation -Path $temp + if (-not $check.Result.IsValid) { + Write-Host 'Save blocked: the edited configuration has Error findings.' -ForegroundColor Red + Write-PersonaValidationFinding -Findings $check.Result.Findings + return $false + } + } + finally { + Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue + } + + $destination = $SaveAs ? $SaveAs : $Path + + if ((Test-Path -LiteralPath $destination -PathType Leaf) -and -not $SaveAs) { + $backup = '{0}.{1}.bak' -f $destination, [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ') + + if (-not $PSCmdlet.ShouldProcess($destination, "Back up to '$backup' and replace")) { + Write-Host 'Save cancelled.' -ForegroundColor Yellow + return $false + } + + Copy-Item -LiteralPath $destination -Destination $backup -Force + Write-Host "Backup written: $backup" -ForegroundColor DarkGray + } + elseif (-not $PSCmdlet.ShouldProcess($destination, 'Write configuration')) { + Write-Host 'Save cancelled.' -ForegroundColor Yellow + return $false + } + + Set-Content -LiteralPath $destination -Value $json -Encoding utf8NoBOM + Write-Host "Saved: $destination" -ForegroundColor Green + $true +} + +function Invoke-InteractiveEditor { + <# + .SYNOPSIS + The interactive loop (FR-023). + + .DESCRIPTION + Deliberately small. It toggles rules, adjusts priorities, re-validates, tests + against fixtures, and saves. It does not attempt to be a JSON editor - rule + authoring belongs in a text editor with schema completion, and a + half-featured structural editor would only add ways to corrupt a file that + is already under version control. + #> + param( + [Parameter(Mandatory)] [object] $Document, + [Parameter(Mandatory)] [string] $Path + ) + + $dirty = $false + + while ($true) { + Write-Host '' + Write-Host 'Persona Engine configuration editor' -ForegroundColor Cyan + Write-Host (" file {0}{1}" -f $Path, ($dirty ? ' [modified]' : '')) + Write-Host (" version {0}" -f $Document.configVersion) + Write-Host (" rules {0} ({1} enabled)" -f @($Document.rules).Count, @($Document.rules | Where-Object { $_.enabled }).Count) + Write-Host '' + Write-Host ' [L] list rules [T] toggle a rule [P] change a priority' + Write-Host ' [V] re-validate [R] run rule test [S] save' + Write-Host ' [Q] quit' + Write-Host '' + + $choice = (Read-Host 'Choice').Trim().ToUpperInvariant() + + switch ($choice) { + + 'L' { + Write-Host '' + foreach ($rule in ($Document.rules | Sort-Object { [int]$_.priority })) { + Write-Host (' {0,-6} {1,-28} {2,-30} {3}' -f + $rule.priority, $rule.id, $rule.persona, + ($rule.enabled ? 'enabled' : 'DISABLED')) -ForegroundColor ($rule.enabled ? 'Gray' : 'DarkGray') + } + } + + 'T' { + $id = (Read-Host 'Rule ID to toggle').Trim() + $rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1 + + if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break } + + $rule.enabled = -not $rule.enabled + $dirty = $true + Write-Host ("Rule {0} is now {1}." -f $id, ($rule.enabled ? 'enabled' : 'disabled')) -ForegroundColor Green + } + + 'P' { + $id = (Read-Host 'Rule ID').Trim() + $rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1 + + if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break } + + $value = 0 + if (-not [int]::TryParse((Read-Host 'New priority'), [ref] $value)) { + Write-Host 'Priority must be an integer.' -ForegroundColor Yellow + break + } + + $rule.priority = $value + $dirty = $true + Write-Host "Rule $id priority is now $value. Re-validate before saving - a reorder changes which rule wins." -ForegroundColor Green + } + + 'V' { + $temp = [System.IO.Path]::GetTempFileName() + try { + Set-Content -LiteralPath $temp -Value ($Document | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM + $check = Invoke-ConfigurationValidation -Path $temp + Write-PersonaValidationFinding -Findings $check.Result.Findings + } + finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } + } + + 'R' { + $dataPath = $TestDataPath ? $TestDataPath : (Join-Path $PSScriptRoot 'tests/TestData') + $temp = [System.IO.Path]::GetTempFileName() + try { + Set-Content -LiteralPath $temp -Value ($Document | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM + Invoke-SyntheticRuleTest -Configuration (Import-PersonaConfiguration -Path $temp) -DataPath $dataPath + } + finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } + } + + 'S' { + if (Save-PersonaConfiguration -Document $Document -Path $Path -SaveAs $OutputPath) { $dirty = $false } + } + + 'Q' { + if ($dirty) { + $confirm = (Read-Host 'Unsaved changes will be lost. Quit anyway? (y/N)').Trim() + if ($confirm -ne 'y') { break } + } + return + } + + default { Write-Host 'Unrecognized choice.' -ForegroundColor Yellow } + } + } +} + +# ============================================================ main + +$validation = Invoke-ConfigurationValidation -Path $ConfigPath + +Write-PersonaValidationFinding -Findings $validation.Result.Findings + +if ($validation.ExitCode -ne $EXIT_OK) { + Write-Host ("Validation failed with exit code {0}." -f $validation.ExitCode) -ForegroundColor Red + exit $validation.ExitCode +} + +if ($TestDataPath) { + Invoke-SyntheticRuleTest -Configuration (Import-PersonaConfiguration -Path $ConfigPath) -DataPath $TestDataPath +} + +# -ValidateOnly and -NonInteractive both short-circuit before any prompting call. +# The check is here, once, at the single point where the editor could be entered. +if ($ValidateOnly -or $NonInteractive) { + Write-Host 'Configuration is valid.' -ForegroundColor Green + exit $EXIT_OK +} + +Invoke-InteractiveEditor -Document $validation.Result.Document -Path $ConfigPath + +exit $EXIT_OK diff --git a/Invoke-PersonaEngine.ps1 b/Invoke-PersonaEngine.ps1 new file mode 100644 index 0000000..fa35d90 --- /dev/null +++ b/Invoke-PersonaEngine.ps1 @@ -0,0 +1,255 @@ +#Requires -Version 7.2 + +<# +.SYNOPSIS + Classifies Entra ID user accounts against a configuration-driven rule set. + +.DESCRIPTION + The engine entry point: validate, connect, enumerate, evaluate, report, and - + only when explicitly confirmed - persist. + + SAFETY MODEL + + Mode is derived from $PSCmdlet.ShouldProcess() and nothing else. There is no + -Preview switch and no configuration key that suppresses writes. Two sources of + truth for a write gate is precisely the defect class constitution Principle III + exists to prevent: the day they disagree, one of them is wrong and the directory + finds out first. + + -WhatIf is the approved no-write control. Under it, reads happen, rules evaluate, + values are compared, console output and summaries appear, and audit records are + written exactly as they would be in enforcement. The single difference is that no + write request is ever constructed (FR-017, SC-004). + + -Debug enables condition tracing. It does NOT imply read-only. A -Debug run + without -WhatIf writes, and a test asserts that it does - because an operator who + believed otherwise would reach for -Debug as a safety measure. + +.PARAMETER ConfigPath + Path to the JSON configuration. Validated through all four layers before any + connection is attempted (FR-002). + +.PARAMETER UserObjectId + Evaluate a single user instead of enumerating the tenant. The recommended first + run against any new configuration. + +.PARAMETER OutputPath + Overrides logging.path for this run. + +.PARAMETER CorrelationId + Run identifier. Generated when absent. Appears on every audit record (NFR-005). + +.PARAMETER SchemaPath + Schema override for validation layer 2. + +.PARAMETER PreviousConfigPath + Currently deployed configuration, enabling the VR-003 drift checks. + +.EXAMPLE + ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf + + The standard preview run. Reports what would change; writes nothing. + +.EXAMPLE + ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId -WhatIf -Verbose + + Single-user preview, the recommended first run against a new configuration. + +.OUTPUTS + Audit records on the success stream when logging.destination includes 'stream'. + The process exit code carries the run status; see the exit code table in + specs/001-persona-engine/contracts/cli-invoke-persona-engine.md. + +.NOTES + Exit codes + 0 successful run + 1 configuration validation failure + 2 authentication or authorization failure + 3 user enumeration failure + 4 fatal required data-provider failure, or evaluationErrorThreshold exceeded + 5 reconciliation failure + 6 unexpected fatal engine error +#> +[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] +param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ConfigPath, + + [guid] $UserObjectId, + + [string] $OutputPath, + + [guid] $CorrelationId, + + [string] $SchemaPath, + + [string] $PreviousConfigPath +) + +$ErrorActionPreference = 'Stop' + +$engineVersion = '0.1.0' +$startedUtc = [DateTime]::UtcNow +$runId = $CorrelationId -and $CorrelationId -ne [guid]::Empty ? $CorrelationId.ToString() : [guid]::NewGuid().ToString() + +Import-Module (Join-Path $PSScriptRoot 'PersonaEngine.psd1') -Force -ErrorAction Stop + +# Exit codes are named rather than sprinkled as literals so the contract table and +# the code cannot drift apart. +$EXIT_OK = 0 +$EXIT_CONFIG = 1 +$EXIT_AUTH = 2 +$EXIT_ENUMERATION = 3 +$EXIT_DATA = 4 +$EXIT_RECONCILIATION = 5 +$EXIT_UNEXPECTED = 6 + +$exitCode = $EXIT_OK +$counters = $null +$context = $null +$sinkState = New-PersonaAuditSinkState + +try { + + # ======================================================== 1. Validate (FR-002) + + Write-Verbose "Validating configuration: $ConfigPath" + + $validationParams = @{ Path = $ConfigPath } + if ($SchemaPath) { $validationParams['SchemaPath'] = $SchemaPath } + if ($PreviousConfigPath) { $validationParams['PreviousConfigPath'] = $PreviousConfigPath } + + $validation = Test-PersonaConfiguration @validationParams + + if (-not $validation.IsValid) { + Write-PersonaValidationFinding -Findings $validation.Findings + Write-Host "Configuration validation failed with $($validation.ErrorCount) error(s). No connection was attempted." -ForegroundColor Red + exit $EXIT_CONFIG + } + + if ($validation.WarningCount -gt 0) { + Write-PersonaValidationFinding -Findings @($validation.Findings | Where-Object Severity -NE 'Information') + } + + $config = Import-PersonaConfiguration -Path $ConfigPath + $target = Resolve-TargetAttribute -Configuration $config + + # ======================================================== 2. Mode (FR-016, FR-017) + + # The single write gate. Called once, before anything is read, so the mode is + # known when the RunStart record is written and cannot change mid-run. + # + # Under -WhatIf this returns $false without prompting. Without -WhatIf, the High + # confirm impact means the operator is asked to confirm the run; answering + # "Yes to All" also satisfies the per-user gate below without further prompts. + $runConfirmed = $PSCmdlet.ShouldProcess( + "Entra ID directory - $($config.SourcePath)", + "Classify users and write '$target' where the calculated persona differs") + + $mode = $runConfirmed ? 'Enforce' : 'Preview' + + $context = New-PersonaAuditContext -RunId $runId -EngineVersion $engineVersion -Configuration $config -Mode $mode + + $destination = $config.Logging.destination ? [string]$config.Logging.destination : 'stream' + $logPath = $OutputPath ? $OutputPath : [string]$config.Logging.path + $auditParams = @{ Destination = $destination; Path = $logPath; State = $sinkState } + + # Tracing is enabled by -Debug or by configuration, and requires acknowledgement + # in the configuration either way (VR-003, enforced in validation layer 4). + $traceRequested = $PSBoundParameters.ContainsKey('Debug') -or + ($config.Logging.traceConditionValues -and [bool]$config.Logging.traceConditionValues) + $traceAcknowledged = $config.Logging.PSObject.Properties['acknowledgeConditionTracing'] -and + [bool]$config.Logging.acknowledgeConditionTracing + $tracing = [bool]($traceRequested -and $traceAcknowledged) + + if ($traceRequested -and -not $traceAcknowledged) { + Write-Warning 'Condition tracing was requested but logging.acknowledgeConditionTracing is not set. Tracing is disabled for this run (VR-003).' + } + + Write-Host '' + Write-Host "Persona Engine $engineVersion run $runId mode $mode" -ForegroundColor Cyan + Write-Host "Configuration $($config.ConfigVersion) hash $($config.ConfigurationHash.Substring(0, 16))..." -ForegroundColor DarkGray + if ($mode -eq 'Preview') { + Write-Host 'PREVIEW - no write request will be constructed or sent.' -ForegroundColor Yellow + } + Write-Host '' + + New-PersonaAuditRecord -Context $context -RecordType 'RunStart' -Properties @{ + configPath = $config.SourcePath + targetAttribute = $target + ruleCount = @($config.Rules).Count + enabledRules = @($config.Rules | Where-Object { $_.enabled }).Count + singleUser = [bool]($UserObjectId -and $UserObjectId -ne [guid]::Empty) + tracing = $tracing + } | Write-PersonaAuditRecord @auditParams + + # ======================================================== 3. Connect (FR-003) + + $facets = Get-PersonaRequiredFacets -Rules $config.Rules -DefaultMembershipMode $config.DefaultMembershipMode + + try { + $null = Connect-PersonaGraphInteractive ` + -IncludeGroups:([bool]($facets.Direct -or $facets.Transitive)) ` + -IncludeRoles:([bool]$facets.Roles) ` + -IncludeWrite:$runConfirmed + } + catch { + Write-Host "Authentication failed: $($_.Exception.Message)" -ForegroundColor Red + $exitCode = $EXIT_AUTH + throw + } + + # ======================================================== 4. Run + + # The per-user gate, passed down rather than re-derived. $PSCmdlet.ShouldProcess + # remains the single origin of the write decision; the run loop never learns what + # -WhatIf is and so cannot disagree with it. + $gate = { param($Target, $Description) $PSCmdlet.ShouldProcess($Target, $Description) }.GetNewClosure() + + $runParams = @{ + Configuration = $config + TargetAttribute = $target + Context = $context + AuditParameters = $auditParams + IsEnforcing = $runConfirmed + ShouldProcessGate = $gate + Tracing = $tracing + } + if ($UserObjectId -and $UserObjectId -ne [guid]::Empty) { $runParams['UserObjectId'] = $UserObjectId.ToString() } + + $outcome = Invoke-PersonaEngineRun @runParams + + $counters = $outcome.Counters + if ($outcome.ExitCode -ne $EXIT_OK) { $exitCode = $outcome.ExitCode } + + if ($outcome.FailureReason) { + Write-Host "Run failed: $($outcome.FailureReason)" -ForegroundColor Red + } + + if ($exitCode -eq $EXIT_RECONCILIATION) { + Write-Host 'Reconciliation failed. This is an engine defect, not a data condition (FR-021).' -ForegroundColor Red + } + elseif ($exitCode -eq $EXIT_DATA) { + Write-Host ("EvaluationError count {0} exceeds the configured threshold of {1}. Reporting the run as failed." -f + $counters.EvaluationError, $config.EvaluationErrorThreshold) -ForegroundColor Red + } +} +catch { + if ($exitCode -eq $EXIT_OK) { $exitCode = $EXIT_UNEXPECTED } + + Write-Host "Run terminated: $($_.Exception.Message)" -ForegroundColor Red + Write-Verbose $_.ScriptStackTrace +} +finally { + # RunComplete is written even on a fatal error. A run that died at user 400 of + # 5000 leaves a record saying exactly that, which is what distinguishes "stopped + # early" from "never started" - two very different incidents that otherwise + # produce identical evidence. + if ($null -ne $context -and $null -ne $counters) { + Export-PersonaRunReport -Context $context -Counters $counters -StartedUtc $startedUtc -ExitCode $exitCode | + Write-PersonaAuditRecord @auditParams + } +} + +exit $exitCode diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..85c8233 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,34 @@ +@{ + Severity = @('Error', 'Warning') + + IncludeRules = @( + # Constitution Principle III: every state-changing function must support + # ShouldProcess, so -WhatIf reaches all of them. + 'PSUseShouldProcessForStateChangingFunctions' + 'PSShouldProcess' + + # Principle V: no credential material in source. + 'PSAvoidUsingPlainTextForPassword' + 'PSAvoidUsingConvertToSecureStringWithPlainText' + 'PSUsePSCredentialType' + + # NFR-004 maintainability. + 'PSUseApprovedVerbs' + 'PSUseSingularNouns' + 'PSAvoidUsingCmdletAliases' + 'PSUseDeclaredVarsMoreThanAssignments' + 'PSAvoidUsingPositionalParameters' + + # NFR-008 portability: no Windows PowerShell-only constructs. + 'PSUseCompatibleSyntax' + ) + + Rules = @{ + PSUseCompatibleSyntax = @{ + Enable = $true + # 7.2 is the floor because the Automation runtime version is unverified + # until Stage B (research.md V-5b). + TargetVersions = @('7.2') + } + } +} diff --git a/PersonaEngine.psd1 b/PersonaEngine.psd1 new file mode 100644 index 0000000..0e98178 --- /dev/null +++ b/PersonaEngine.psd1 @@ -0,0 +1,75 @@ +@{ + RootModule = 'PersonaEngine.psm1' + ModuleVersion = '0.1.0' + GUID = 'b3f1c2d4-5e6a-47b8-9c0d-1e2f3a4b5c6d' + Author = 'Persona Engine maintainers' + Description = 'Deterministic, configuration-driven identity classification for Microsoft Entra ID user objects.' + + # PS 7.2 floor rather than 7.4: the Azure Automation runtime version is + # unverified until Stage B (research.md V-5b). Do not raise without evidence. + PowerShellVersion = '7.2' + + # OTD-004: authentication and Invoke-MgGraphRequest only. No resource-specific + # Graph SDK modules — keeping the Automation import surface to one module. + RequiredModules = @('Microsoft.Graph.Authentication') + + FunctionsToExport = @( + # Configuration + 'Import-PersonaConfiguration' + 'Test-PersonaConfiguration' + 'Test-PersonaConfigurationSemantic' + 'Test-PersonaConfigurationSafety' + 'Write-PersonaValidationFinding' + 'Resolve-TargetAttribute' + 'Get-PersonaRequiredFacets' + 'New-PersonaValidationFinding' + # Normalization + 'New-PersonaUserRecord' + 'New-PersonaMembershipRecord' + 'ConvertTo-PersonaUserRecord' + 'ConvertTo-PersonaMembershipRecord' + # Rule engine + 'Test-PersonaCondition' + 'Test-PersonaConditionGroup' + 'Test-PersonaRule' + 'Resolve-UserPersona' + # Authentication and data providers + 'Connect-PersonaGraphInteractive' + 'Invoke-PersonaGraphRequest' + 'Get-PersonaUsers' + 'Get-PersonaRequiredProperties' + 'Get-PersonaGroupMembership' + 'Get-PersonaDirectoryRoles' + 'New-PersonaDataCache' + 'Get-PersonaCachedMembership' + # Persistence + 'Compare-PersonaValue' + 'New-PersonaWriteBody' + 'Set-UserPersonaAttribute' + # Presentation + 'Write-UserPersonaResult' + 'Write-PersonaSummary' + 'New-PersonaRunCounter' + 'Add-PersonaRunResult' + 'Test-PersonaReconciliation' + 'Get-PersonaReconciliationDetail' + # Audit + 'New-PersonaAuditContext' + 'New-PersonaAuditRecord' + 'Write-PersonaAuditRecord' + 'New-PersonaAuditSinkState' + 'Export-PersonaRunReport' + # Run loop + 'Invoke-PersonaEngineRun' + ) + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + + PrivateData = @{ + PSData = @{ + Tags = @('Entra', 'Identity', 'Classification') + ProjectUri = '' + } + } +} diff --git a/PersonaEngine.psm1 b/PersonaEngine.psm1 new file mode 100644 index 0000000..1e05013 --- /dev/null +++ b/PersonaEngine.psm1 @@ -0,0 +1,37 @@ +#Requires -Version 7.2 + +<# + Module loader. + + Dot-sources every function file under src/. Load order is layer-by-layer so a + file may rely on functions from a layer loaded before it. + + Note for tests: offline suites (rule engine, normalization, configuration) + deliberately dot-source individual layer folders rather than importing this + module, because importing the manifest pulls in Microsoft.Graph.Authentication. + Keeping the pure layers loadable without that module is the practical proof of + constitution Principle IV. +#> + +$ErrorActionPreference = 'Stop' + +$layerOrder = @( + 'Normalization' + 'Configuration' + 'RuleEngine' + 'Authentication' + 'DataProviders' + 'Persistence' + 'Presentation' + 'Engine' + 'Audit' +) + +foreach ($layer in $layerOrder) { + $layerPath = Join-Path $PSScriptRoot "src/$layer" + if (-not (Test-Path $layerPath)) { continue } + + foreach ($file in Get-ChildItem -Path $layerPath -Filter '*.ps1' -File | Sort-Object Name) { + . $file.FullName + } +} diff --git a/README.md b/README.md index 60da36b..dc8e103 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,295 @@ A modular, configuration-driven **PowerShell 7** identity-classification service The engine enumerates Entra user accounts, evaluates each one against an ordered, JSON-defined rule set, deterministically assigns **exactly one persona**, and updates a single approved persona attribute — and only when the calculated value differs from the current value. -> **Project status: Phase 1 complete — specification drafted.** -> No implementation code exists yet. The approved requirements baseline is -> [`Persona-Engine-Developer-Handoff.txt`](Persona-Engine-Developer-Handoff.txt), now converted -> into [`specs/001-persona-engine/spec.md`](specs/001-persona-engine/spec.md). Project governance -> is ratified in [`.specify/memory/constitution.md`](.specify/memory/constitution.md) (v1.0.0). -> The next step is Phase 2: produce `plan.md` and `research.md`, closing OTD-001 through OTD-005. +> **Project status: Stage A implementation complete — ready for tenant validation.** +> +> 109 of 121 tasks are done. **354 offline tests pass**, along with the engine-purity and +> sanitization gates. Every remaining task needs something a developer workstation does not have: +> a tenant connection (T055, T056, T101–T103) or an Azure Automation account (T115–T121). +> +> **Nothing has ever been run against a real directory.** The next step is [Stage A2](#stage-a2--tenant-preview-read-only) +> — a delegated, read-only `-WhatIf` run. Follow the [testing checklist](#testing-checklist) in order. + +--- + +## Quick start + +```bash +pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -TestDataPath ./tests/TestData -ValidateOnly -NonInteractive +``` + +That validates the configuration through all four layers and runs the real rule engine against +synthetic fixtures. No tenant, no credentials, no network. It is the fastest way to see what the +engine does. + +--- + +## Deployment + +### Prerequisites + +| Requirement | Notes | +| --- | --- | +| PowerShell 7.2 or later | Developed on 7.6.5. `pwsh -v` to check. | +| `Microsoft.Graph.Authentication` | **Runtime only.** Not needed for the offline suites or the config editor. | +| Pester 5.0+ | Only to run the tests. Developed against 6.1.0. | +| PSScriptAnalyzer | Only for the lint gate. | +| An Entra app registration | For the persona extension property and delegated scopes. | + +```powershell +Install-Module Microsoft.Graph.Authentication -Scope CurrentUser +Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser -SkipPublisherCheck +Install-Module PSScriptAnalyzer -Scope CurrentUser +``` + +> Only `Microsoft.Graph.Authentication` is a runtime dependency (OTD-004). The engine calls Graph +> through `Invoke-MgGraphRequest` rather than resource-specific SDK modules, which keeps the import +> surface to one module and makes request bodies explicit values that tests can assert on — that is +> what makes the single-attribute guarantee provable. + +### Step 1 — Get the code onto the target machine + +```bash +git clone persona-engine +cd persona-engine +``` + +### Step 2 — Prove the machine can run it, before touching a tenant + +```bash +pwsh -NoProfile -Command "& { $c = & ./tests/PesterConfiguration.ps1 -Suite Offline; Invoke-Pester -Configuration $c }" +``` + +Expect **354 passed, 0 failed**. This needs no credentials and no network. If it does not pass, stop +— nothing downstream is trustworthy. + +### Step 3 — Register the persona extension property + +The persona is stored in a **directory (schema) extension property** on an app registration (OTD-001), +addressable in dynamic group rules as `user.extension__`. + +```powershell +Connect-MgGraph -Scopes 'Application.ReadWrite.All' + +$app = Get-MgApplication -Filter "displayName eq ''" + +New-MgApplicationExtensionProperty -ApplicationId $app.Id -BodyParameter @{ + name = '' + dataType = 'String' + targetObjects = @('User') +} +``` + +Record the returned `name` — it is the full `extension_<32-hex-app-id>_` string, and it is what +goes into `engine.targetAttribute`. + +> `extensionAttribute1..15` were **rejected**: they cannot be written from the cloud on objects that +> are, or ever were, synchronized from on-premises, or on Exchange-originated objects. Custom +> security attributes were **rejected**: they are not exposed to the dynamic group engine, which +> defeats the purpose. + +### Step 4 — Grant delegated scopes + +Read-only, and enough for every preview run: + +```powershell +Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory' +``` + +The engine requests only what the enabled rules need — a configuration with no role conditions never +asks for `RoleManagement.Read.Directory`. + +`User.ReadWrite.All` is added only for Stage A3, and only after V-4 sign-off. + +> **Never sign in with a standing privileged account.** A Global Administrator run invalidates V-3 +> as evidence and removes every practical limit on what a defect could reach. + +### Step 5 — Build the configuration + +```bash +cp ./config/persona-engine.example.json ./config/persona-engine.json +``` + +Then replace every placeholder: + +| Placeholder | Replace with | +| --- | --- | +| `extension__` | The full extension property name from step 3 | +| ``, `` | Ownership metadata | +| `` | Role **template** IDs (stable across tenants) | +| `00000000-0000-...` group IDs | Real group Object IDs | +| `` | Your company name as it appears in `companyName` | +| `` | Audit log path | + +> **`config/persona-engine.json` must never be committed.** It contains real group Object IDs and +> your tenant's attribute name. Keep it in a protected configuration store, and confirm `.gitignore` +> covers it. The sanitization gate scans untracked files too, so it will catch this — but do not rely +> on that as your only control. + +### Step 6 — Validate, before connecting to anything + +```bash +pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive +``` + +Exit code `0` required. Codes: `1` findings · `2` warnings with `-TreatWarningsAsErrors` · +`3` file unreadable · `4` schema unusable. + +### Step 7 — Preview + +```bash +pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId -WhatIf -Verbose +``` + +One account first. Then the tenant: + +```bash +pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf +``` + +`-WhatIf` is the **only** approved no-write control. `-Debug` does not imply read-only. + +### Step 8 — CI + +Two pipelines are included. `pipelines/validate.yml` gates every pull request and runs entirely +offline; `pipelines/test.yml` publishes test results and coverage. + +### Step 9 — Enforcement 🔒 + +**Blocked on the V-4 security sign-off** (T101). Do not run without `-WhatIf` against anything other +than purpose-created test accounts until that is recorded. See [docs/SecurityModel.md](docs/SecurityModel.md). + +--- + +## Testing checklist + +Work through these in order. Each stage assumes the previous one passed. **Do not skip ahead** — the +whole point of the staging is that a failure is cheap at stage A1 and expensive at stage A3. + +### Stage A1 — offline (no tenant, no credentials, no network) + +Everything here runs on any machine with PowerShell 7. + +- [ ] **Module manifest loads** + `pwsh -NoProfile -Command "Test-ModuleManifest ./PersonaEngine.psd1"` + Fails without `Microsoft.Graph.Authentication` installed. Expected on a bare machine. + +- [ ] **Offline suite: 354 passed, 0 failed** + `pwsh -NoProfile -Command "& { $c = & ./tests/PesterConfiguration.ps1 -Suite Offline; Invoke-Pester -Configuration $c }"` + +- [ ] **Safety suite passes and is not empty** + `pwsh -NoProfile -Command "& { $c = & ./tests/PesterConfiguration.ps1 -Suite Safety; Invoke-Pester -Configuration $c }"` + A zero-test green run is the most dangerous possible result — it is what a mis-tagged file looks + like, and the assertions it silently drops are the zero-write and single-attribute ones. + +- [ ] **Engine purity (Principle IV)** + `pwsh ./tests/Test-EnginePurity.ps1` + +- [ ] **Sanitization (SC-013)** + `pwsh ./tests/Test-Sanitization.ps1` + Scans tracked **and** untracked non-ignored files, so it catches a leak before the commit. + +- [ ] **Lint** + `pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1"` + +- [ ] **No Graph module was loaded during the offline suite** + `pwsh -NoProfile -Command "& { $c = & ./tests/PesterConfiguration.ps1 -Suite Offline; $c.Output.Verbosity='None'; $null = Invoke-Pester -Configuration $c; Get-Module Microsoft.Graph* }"` + Must print nothing. This is the proof that SC-008 holds. + +- [ ] **Example configuration passes all four layers** + `pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive` + +- [ ] **Your real configuration passes all four layers** + `pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive` + +- [ ] **Synthetic rule test produces the personas you expect** + `pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData -ValidateOnly -NonInteractive` + The fixtures include two accounts whose membership lookups failed. Both must show + `EvaluationError`, not a persona. If they show a persona, stop — FR-013 is broken. + +- [ ] **Drift check against the deployed configuration** (once one exists) + `pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -PreviousConfigPath ./deployed/persona-engine.json -ValidateOnly -NonInteractive` + +### Stage A2 — tenant preview, read-only + +Requires delegated read scopes. **Sign in as a non-privileged account.** + +- [ ] **Connect with read-only scopes and confirm no write scope was granted** + `Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'` + then `(Get-MgContext).Scopes` + +- [ ] **Single-user preview** + `pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId -WhatIf -Verbose` + Expect: one result line, `WouldUpdate` or `Unchanged`, exit `0`. + +- [ ] **Single-user preview against each origin type — V-1 read half (T056)** + A cloud-only account, a currently-synced account, and a formerly-synced account. Record in + `specs/001-persona-engine/verification/V-1.md`. This is the check that proves the OTD-001 + attribute choice actually works where `extensionAttributeN` would not. + +- [ ] **Full tenant preview** + `pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf` + Expect exit `0` and reconciliation `PASS` at every summary. + +- [ ] **Record the V-3 evidence (T055)** + Confirm the run completed as a **non-privileged** account. A Global Administrator run + invalidates this item. Record in `specs/001-persona-engine/verification/V-3.md`. + +- [ ] **Review the impact before going further** + From the final summary: how many `WouldUpdate`? Which rules fired, and which fired zero times? + Is the `EvaluationError` count near zero? A high count means the rule set is asking for data + the tenant will not reliably give it. + +- [ ] **Idempotence against the real tenant** + Run the full preview twice. The counters must be identical. + +- [ ] **Performance baseline (NFR-002)** + Record `durationMs` from the `RunComplete` record and the account count. No target exists yet; + this run is how one gets set. + +### Stage A3 — delegated write, test accounts only 🔒 + +**Gated on V-4** — the written security sign-off on the six OTD-003 compensating controls +(T101, `specs/001-persona-engine/verification/V-4.md`). + +- [ ] **V-4 sign-off recorded** — nothing below may start before this +- [ ] **Purpose-created test accounts exist**, one per origin type, and no other account is in scope +- [ ] **Write scope added**: `User.ReadWrite.All`, still as a non-privileged account +- [ ] **Preview the test accounts first**, one at a time, with `-WhatIf` +- [ ] **Enforce one test account** + `pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId ` + Confirm the confirmation prompt appears — `ConfirmImpact` is `High`. +- [ ] **Verify the write landed and touched nothing else** + Re-read the account and diff every property against a copy taken beforehand. Only the persona + attribute may have changed. +- [ ] **Confirm `previousValue` is on the audit record** — without it, rollback is impossible retroactively +- [ ] **Second run proposes zero changes** (SC-002, against a real directory this time) +- [ ] **V-1 write half (T102)** — one write per origin type; append to `V-1.md` +- [ ] **V-2 (T103)** — build a dynamic group on `user.extension__`, assign a Conditional + Access policy to it in **report-only** mode, and confirm it applies. This is what proves the + persona is actually useful rather than merely stored. + +### Stage B — Azure Automation ⛔ + +Deferred; no Automation account available. T115–T121. Run **T116 first** (`Test-Json` behaviour in +the Automation runtime) — it is the cheapest item most likely to surprise, and V-5a already showed +this cmdlet behaves in a way nobody would guess. + +### If something fails + +| Symptom | Look at | +| --- | --- | +| Exit `1` | The findings printed above it. No connection was attempted. | +| Exit `2` | Scopes, consent, whether the account can sign in. | +| Exit `3` | Graph availability. **No accounts were processed** — a partial population is never used. | +| Exit `4` | Group/role endpoint health. Nothing was changed. | +| Exit `5` | **An engine defect.** File an issue with the `EngineDefect` record. | +| Every account `Skipped` | Target attribute blank or unapproved. | +| Every account `EvaluationError` | A required data source is disabled or unreachable. | +| Every account `Unclassified` | Every rule disabled, or no rule matches. The summary distinguishes these. | + +[docs/OperationsRunbook.md](docs/OperationsRunbook.md) has the full table, the kill switch, and the +rollback procedure. --- @@ -21,17 +304,15 @@ This project follows the **GitHub Spec Kit** workflow. Nothing is implemented be Specify -> Plan -> Tasks -> Implement ``` -| Stage | Artifact | Phase | State | -| --- | --- | --- | --- | -| Baseline | `Persona-Engine-Developer-Handoff.txt` | 0 | Approved | -| Constitution | `.specify/memory/constitution.md` | 0 | Ratified v1.0.0 | -| Specify | `specs/001-persona-engine/spec.md` | 1 | Draft complete | -| Plan | `specs/001-persona-engine/plan.md`, `research.md` | 2 | In progress | -| Contracts | `data-model.md`, `contracts/`, `persona-engine.schema.json` | 3 | Not started | -| Tasks | `specs/001-persona-engine/tasks.md` | 4 | Not started | -| Implement | `src/`, `tests/`, `pipelines/` | 5–12 | Not started | - -Any item that is unresolved must be captured as an explicit **assumption, risk, or architecture decision**. It must never be silently implemented. +| Stage | Artifact | State | +| --- | --- | --- | +| Baseline | `Persona-Engine-Developer-Handoff.txt` | Approved | +| Constitution | `.specify/memory/constitution.md` | Ratified v1.0.0 | +| Specify | `specs/001-persona-engine/spec.md` | Complete | +| Plan | `plan.md`, `research.md` | Complete — OTD-001…007, 010 resolved | +| Contracts | `data-model.md`, `contracts/`, `persona-engine.schema.json` | Complete | +| Tasks | `specs/001-persona-engine/tasks.md` | Complete — 121 tasks | +| Implement | `src/`, `tests/`, `pipelines/`, `docs/` | **109 / 121** — remainder needs a tenant or Automation | --- @@ -48,232 +329,192 @@ Any item that is unresolved must be captured as an explicit **assumption, risk, | **Explainable** | Every result identifies the matched rule, run ID, UPN, and Account Object ID. | | **Modular** | The pure rule engine has no dependency on Graph, Azure Automation, or the console. | ---- +### The one that matters most -## Scope (version 1) +**Unknown is not false.** If a membership lookup fails, the condition is `Unknown`, not `False` — so a +`notMemberOf` condition does not become satisfied when the lookup fails. -**In scope** - -- Microsoft Entra **user objects only** -- Ordered, first-match business rules defined in **JSON** (the only supported configuration format) -- Property, group-membership, and role-based conditions with nested `All` / `Any` composition -- Native PowerShell `-WhatIf` as the approved no-write control -- Structured, audit-friendly logging plus immediate per-user output and periodic summaries -- Local PowerShell 7 execution and Azure Automation PowerShell 7 runbook execution - -**Out of scope for v1** — the architecture must not assume these share user-object properties: - -- Service principals, managed identities, workload identities, agentic identities - -### Candidate persona catalogue - -These are candidate business classifications, **not** hard-coded engine behaviour: - -`Guest` · `BreakGlass-Admin` · `Tier0-Admin` · `Tier1-Admin` · `Tier2-Admin` · `Restricted-User` · `Test-Account` · `Service-Account` · `Shared-Functional-Account` · `Meeting-Room-Device` · `Employee` · `Contractor` · `Student` - -Two values are processing results rather than rules: - -- **`Unclassified`** — evaluation succeeded, but no rule matched. -- **`EvaluationError`** — evaluation could not complete; the existing persona is preserved. +Without that, a transient Graph outage would make every privileged account look like a non-member of +its Tier 0 group, and one run would quietly demote the entire administrative population. +`tests/RuleEngine/UnknownNotFalse.Tests.ps1` exists solely to prevent that regression. --- ## Components -### 1. `Invoke-PersonaEngine.ps1` - -Retrieval, evaluation, reporting, and controlled persistence. +### `Invoke-PersonaEngine.ps1` | Parameter | Notes | | --- | --- | -| `-ConfigPath ` | Required | -| `-WhatIf` | Native risk-mitigation parameter; the approved no-write control | -| `-Verbose` / `-Debug` | Native common parameters; `-Debug` must **not** mean read-only | -| `-UserObjectId ` | Optional single-user test execution | -| `-OutputPath ` | Optional override, if permitted | -| `-CorrelationId ` | Optional supplied run identifier | +| `-ConfigPath ` | Required. Validated through all four layers before any connection. | +| `-WhatIf` | **The approved no-write control.** | +| `-UserObjectId ` | Single-user execution. | +| `-OutputPath ` | Overrides `logging.path`. | +| `-CorrelationId ` | Run identifier; generated when absent. | +| `-SchemaPath ` | Schema override. | +| `-PreviousConfigPath ` | Enables the VR-003 drift checks. | +| `-Verbose` / `-Debug` | Common parameters. **Neither implies read-only.** | -The script uses `CmdletBinding` with `SupportsShouldProcess`. +**Exit codes**: `0` success · `1` config invalid · `2` auth · `3` enumeration · `4` data / threshold · +`5` reconciliation · `6` unexpected. -```powershell -# Read-only evaluation -./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf - -# Read-only with operational detail -./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf -Verbose - -# Single-user validation -./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId -WhatIf - -# Production, changed-values-only processing -./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -``` - -**Exit codes** - -| Code | Meaning | -| --- | --- | -| `0` | Successful run; no fatal processing errors | -| `1` | Configuration validation failure | -| `2` | Authentication / authorization failure | -| `3` | User enumeration failure | -| `4` | Fatal required data-provider failure | -| `5` | Reconciliation failure | -| `6` | Unexpected fatal engine error | - -A per-user `EvaluationError` does not necessarily terminate the run, but the final status must report the number of affected accounts and may apply a configurable warning/failure threshold. - -### 2. `Edit-PersonaEngineConfig.ps1` - -Configuration validation, interactive editing, synthetic rule testing, and pipeline enforcement. +### `Edit-PersonaEngineConfig.ps1` | Parameter | Notes | | --- | --- | -| `-ConfigPath ` | Required | -| `-ValidateOnly` | Validate without entering the editor | -| `-NonInteractive` | Pipeline mode; returns codes instead of prompting | -| `-SchemaPath ` | Optional schema override | -| `-OutputPath ` | Optional Save-As target | -| `-TreatWarningsAsErrors` | Escalate warnings | -| `-TestDataPath ` | Optional synthetic sample input | +| `-ConfigPath ` | Required. | +| `-ValidateOnly` | Validate; never enter the editor. | +| `-NonInteractive` | Pipeline mode. Never prompts, never hangs. | +| `-SchemaPath` / `-OutputPath` | Schema override; Save-As target. | +| `-TreatWarningsAsErrors` | Escalate warnings (VR-005). | +| `-TestDataPath ` | Synthetic rule testing, no tenant. | +| `-PreviousConfigPath` / `-EnforcementEnabled` | Drift checks; raise safety severities. | -```powershell -# Validate only -./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly +**Exit codes**: `0` valid · `1` errors · `2` warnings escalated · `3` file unreadable · `4` schema unusable. -# Pipeline validation -./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive - -# Interactive editor -./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -``` - -Validation runs in four layers: **JSON syntax → JSON Schema → semantic → safety**. +Validation runs in four layers: **JSON syntax → JSON Schema → semantic → safety**, stopping at the +first that produces errors. --- ## Architecture ``` -Invoke-PersonaEngine.ps1 -| -+-- Configuration Import-PersonaConfiguration, Test-PersonaConfiguration, Resolve-TargetAttribute -+-- Authentication Connect-PersonaGraphInteractive, Connect-PersonaGraphManagedIdentity -+-- Data Providers Get-PersonaUsers, Get-PersonaGroupMembership, Get-PersonaDirectoryRoles -+-- Normalization ConvertTo-PersonaUserRecord, ConvertTo-PersonaMembershipRecord -+-- Rule Engine Test-PersonaCondition, Test-PersonaConditionGroup, Test-PersonaRule, Resolve-UserPersona -+-- Persistence Compare-PersonaValue, Set-UserPersonaAttribute -+-- Presentation Write-UserPersonaResult, Write-PersonaSummary -+-- Audit New-PersonaAuditRecord, Export-PersonaRunReport +Invoke-PersonaEngine.ps1 thin wrapper: parameters, ShouldProcess, exit code + └── src/Engine/ Invoke-PersonaEngineRun (the run loop, testable offline) + ├── Configuration Import, Test (4 layers), Resolve-TargetAttribute + ├── Authentication Connect-PersonaGraphInteractive + ├── DataProviders Get-PersonaUsers, GroupMembership, DirectoryRoles, retry, cache + ├── Normalization ConvertTo-PersonaUserRecord, ConvertTo-PersonaMembershipRecord + ├── RuleEngine Test-PersonaCondition/ConditionGroup/Rule, Resolve-UserPersona + ├── Persistence Compare-PersonaValue, New-PersonaWriteBody, Set-UserPersonaAttribute + ├── Presentation Write-UserPersonaResult, Write-PersonaSummary, reconciliation + └── Audit New-PersonaAuditRecord, Write-PersonaAuditRecord, Export-PersonaRunReport ``` -**Critical flow** - -``` -Graph acquisition -> normalized identity record -> pure rule engine - -> persona decision result -> comparison -> optional persistence adapter - -> console and structured audit output -``` - -The **pure rule engine must not depend** on Graph authentication, Azure Automation, or console rendering. It has to be testable offline with synthetic data. +The **rule engine is pure** — no Graph, no auth, no console, no filesystem, no clock. Enforced on +every build by `tests/Test-EnginePurity.ps1`, which parses each file with the PowerShell AST parser +and inspects only code tokens. That purity is why 354 tests run with no tenant. --- -## Planned repository structure +## Repository layout ``` -PersonaEngine/ -|-- README.md -|-- Invoke-PersonaEngine.ps1 -|-- Edit-PersonaEngineConfig.ps1 -|-- PersonaEngine.psd1 -|-- PersonaEngine.psm1 -| -|-- config/ persona-engine.example.json, persona-engine.schema.json -|-- src/ Authentication/ Configuration/ DataProviders/ Normalization/ -| RuleEngine/ Persistence/ Presentation/ Audit/ -|-- tests/ Unit/ Integration/ Configuration/ Safety/ TestData/ -|-- docs/ Architecture.md BusinessRules.md ConfigurationReference.md -| Logging.md SecurityModel.md OperationsRunbook.md -|-- pipelines/ validate.yml test.yml release.yml -|-- specs/ - |-- 001-persona-engine/ - |-- spec.md plan.md tasks.md research.md data-model.md quickstart.md - |-- contracts/ checklists/ +Invoke-PersonaEngine.ps1 Edit-PersonaEngineConfig.ps1 +PersonaEngine.psd1 PersonaEngine.psm1 + +config/ persona-engine.example.json, persona-engine.schema.json +src/ Configuration/ Authentication/ DataProviders/ Normalization/ + RuleEngine/ Persistence/ Presentation/ Engine/ Audit/ +tests/ Unit/ RuleEngine/ Configuration/ Safety/ TestData/ + Test-EnginePurity.ps1 Test-Sanitization.ps1 TestHelpers.ps1 +docs/ Architecture.md SecurityModel.md ConfigurationReference.md + OperationsRunbook.md BusinessRules.md Logging.md +pipelines/ validate.yml test.yml +specs/001-persona-engine/ + spec.md plan.md tasks.md research.md data-model.md + quickstart.md traceability.md contracts/ verification/ ``` --- ## Security model -- **Authentication** — Azure Automation uses a **managed identity**. Local development uses an approved interactive or read-only application identity. **No client secret in source control.** -- **Least privilege** — the execution identity gets only what the enabled rules require: in-scope user properties, configured group membership, configured role data, and the existing persona value. -- **Write permissions** — the production identity is granted the minimum permission needed to update the configured target attribute. Whether Entra can enforce write scope at the **individual attribute level must be verified, never assumed** (see OTD-003). -- **Compensating controls**, if the Graph permission proves broader than the single attribute: the persistence module accepts only the approved target attribute; that attribute must appear in `approvedWritableAttributes`; validation rejects all others; a dedicated function builds a request body containing only that attribute; unit and integration tests inspect the request body; code owners and branch policies gate persistence changes; directory audit logs are monitored for unexpected property writes. -- **Data handling** — UPN and Account Object ID are approved for logs. Never log access tokens, authorization headers, secrets, or full Graph responses. Detailed condition values are diagnostic-only, behind `-Debug`. -- **Kill switch** — disable the Automation schedule, run with `-WhatIf`, revoke production write permission, or disable write deployment stages. +The short version; the full one is [docs/SecurityModel.md](docs/SecurityModel.md). + +**Graph application permissions have no per-property write scope** (OTD-003). An identity that can +write the persona attribute can write any writable user property. The directory will not stop a +malformed request on our behalf, so six compensating controls hold that line, and each is tested: + +1. `New-PersonaWriteBody` throws for any attribute other than the configured target. +2. The target must appear in `approvedWritableAttributes` — checked at validation **and** at write time. +3. Validation rejects any approved attribute that is not a directory extension property (`PE-SAF-002`). +4. One function builds the request body, and it returns a hashtable whose `Count` is exactly 1. +5. Tests inspect every body issued during a full enforcing run. +6. Code owners and branch policies gate changes to `src/Persistence/`. + +**V-4 — written security sign-off on these controls — gates all enforcement.** + +Never logged: tokens, `Authorization` headers, secrets, certificates, raw Graph responses. The +guarantee is structural: the record builder accepts only named, typed values, so there is nothing for +a secret to ride in on. --- -## Sanitization requirements +## Sanitization (SC-013) -**Every** artifact in this repository — docs, examples, tests, configuration samples — must be free of organization names, real domains, tenant or subscription IDs, automation account names, real UPNs or Object IDs, real group/role identifiers, environment-specific attribute names, Log Analytics details, and any secret, token, certificate, or credential. +No organization name, real domain, tenant or subscription ID, real UPN or Object ID, real group or +role identifier, environment-specific attribute name, or secret may appear in any file this +repository would commit. -Use placeholders only: +`tests/Test-Sanitization.ps1` scans tracked **and** untracked non-ignored files. Reserved domains +(`example.com`, `.invalid`, `.test`) and the module manifest's own identity GUID are exempt; nothing +else is. -`` · `` · `` · `` · `` · `` · `` · `` - -Synthetic test data must be obviously fictional and must never reproduce real employee records. Tenant-specific configuration belongs in a protected repository or configuration store, not here. +Placeholders: `` · `` · `` · `` · +`` · `` · `` · `` --- -## Open technical decisions +## Open decisions and verification -Research items to be resolved in `research.md` or an ADR. **OTD-001 through OTD-005 must be closed before persistence implementation.** - -| ID | Decision | +| ID | Status | | --- | --- | -| OTD-001 | Select the exact Entra persona attribute mechanism — data type, Graph read/update method, discoverability, Conditional Access compatibility | -| OTD-002 | Confirm exact least-privilege Microsoft Graph permissions | -| OTD-003 | Confirm whether write authorization can be restricted to the individual target attribute | -| OTD-004 | Select the Graph access approach — SDK cmdlets, direct REST, or a controlled combination | -| OTD-005 | Select a JSON Schema validation approach compatible with PS7 locally and in Azure Automation | -| OTD-006 | Select structured-log destination and transport | -| OTD-007 | Define retry policy — retryable status codes, max attempts, backoff, jitter, logging | -| OTD-008 | Define full versus incremental processing roadmap | -| OTD-009 | Define production schedule and concurrency lock | -| OTD-010 | Define rollback implementation | +| OTD-001 persona attribute | **Resolved** — directory extension property | +| OTD-002 least-privilege permissions | **Resolved** | +| OTD-003 per-attribute write scope | **Resolved: not possible.** Six compensating controls; V-4 outstanding | +| OTD-004 Graph access approach | **Resolved** — `Invoke-MgGraphRequest` | +| OTD-005 schema validation | **Resolved locally** — `Test-Json`; V-5b open for Automation | +| OTD-006 log transport | **Resolved** — NDJSON via a single sink | +| OTD-007 retry policy | **Resolved** | +| OTD-008 incremental processing | Open — full enumeration only in v1 | +| OTD-009 concurrency lock | Open — deferred with Stage B | +| OTD-010 rollback | **Data captured**; tool out of scope for v1 | + +| Verification | Status | +| --- | --- | +| V-1 read / write | Open — needs a tenant / test accounts | +| V-2 dynamic group + CA | Open | +| V-3 non-privileged preview | Open | +| **V-4 security sign-off** | **Open — gates enforcement** | +| V-5a `Test-Json` behaviour | **Closed** — [V-5a.md](specs/001-persona-engine/verification/V-5a.md) | +| V-5b `Test-Json` in Automation | Deferred | + +Full requirement-to-test mapping, including the gaps: [traceability.md](specs/001-persona-engine/traceability.md). --- -## Getting started +## Documentation -Implementation has not begun. The current work item is Phase 2 — planning. - -1. ~~Initialize the Spec Kit project structure.~~ Done. -2. ~~Convert the handoff baseline into `specs/001-persona-engine/spec.md`.~~ Done. -3. Build a requirements traceability list using FR/NFR identifiers. -4. Close OTD-001 through OTD-005 before any persistence work. -5. Create `persona-engine.schema.json` and a placeholder-only `persona-engine.example.json`. -6. Define normalized PowerShell object contracts. -7. **Build the pure rule engine first**, with offline Pester tests, before any Graph integration. -8. Implement configuration validation and non-interactive pipeline mode. -9. Implement Graph read adapters, then console and structured logging. -10. Implement the persistence adapter **last**, with `ShouldProcess` and tests proving zero writes under `-WhatIf`. - -Requirements: **PowerShell 7**. Offline unit testing must not require tenant connectivity. +| Document | For | +| --- | --- | +| [Architecture.md](docs/Architecture.md) | Boundaries, and why the rule engine is pure | +| [SecurityModel.md](docs/SecurityModel.md) | OTD-003, the six controls, V-4 | +| [ConfigurationReference.md](docs/ConfigurationReference.md) | Every field and every finding code | +| [BusinessRules.md](docs/BusinessRules.md) | Writing and changing rules | +| [OperationsRunbook.md](docs/OperationsRunbook.md) | Kill switch, rollback, incidents | +| [Logging.md](docs/Logging.md) | Record types and querying | --- -## Delivery +## Scope (version 1) -- Hosted in **Azure DevOps Git** — feature branches, pull requests, protected release branch, code owners on persistence, security configuration, and production rules. -- **Validation pipeline** — repository hygiene checks, PowerShell static analysis, JSON Schema validation, semantic/safety configuration validation, Pester unit tests, Pester safety tests, test result publication, artifact packaging. -- **Release pipeline** — validate approved branch/tag, repeat validation and tests, package, deploy to Azure Automation, import modules, publish runbook, **keep the schedule disabled**, execute `-WhatIf` validation, approval gate, then enable enforcement. -- Changes to the target attribute, `approvedWritableAttributes`, rule priority, rule enablement, rule conditions, persona outputs, authentication permissions, persistence functions, logging destination, or `WhatIf`/`ShouldProcess` behaviour **all require review**. +**In scope** — Entra **user objects only**; ordered first-match rules in JSON; property, +group-membership, and role conditions with nested `all`/`any`; `-WhatIf` as the no-write control; +structured audit logging; local PowerShell 7 and (deferred) Azure Automation. ---- +**Out of scope** — service principals, managed identities, workload identities, agentic identities; +non-JSON configuration; delta processing; condition-level case-sensitivity; PIM-eligible role +assignments; a rollback tool. -## Definition of done (v1) +### Candidate persona catalogue -Version 1 is complete when both PowerShell scripts are implemented; the JSON Schema exists and is documented; validation covers syntax, schema, semantic, and safety layers; ordered first-match evaluation and nested `All`/`Any` work within the configured depth; the initial property and membership operators are tested; null behaviour matches the approved decision; required group-lookup failures produce `EvaluationError` and preserve the existing persona; `Unclassified` users are reported distinctly; each user result displays immediately; interim and final summaries work — including interval `0` — and reconciliation checks pass; logs include UPN and Account Object ID; **`-WhatIf` produces zero Graph writes**; only changed valid values are written in enforcement mode; the write payload contains only the approved target attribute; local offline Pester, read-only production-tenant, Azure Automation PowerShell 7, and Azure DevOps pipeline runs all pass; security review confirms permissions and compensating controls; operational documentation, kill switch, and rollback procedure are complete; and `-WhatIf` impact evidence is reviewed before enforcement is enabled. +`Guest` · `BreakGlass-Admin` · `Tier0-Admin` · `Tier1-Admin` · `Tier2-Admin` · `Restricted-User` · +`Test-Account` · `Service-Account` · `Shared-Functional-Account` · `Meeting-Room-Device` · `Employee` · +`Contractor` · `Student` + +Two values are processing results, not rule outcomes: + +- **`Unclassified`** — evaluation succeeded, no rule matched. +- **`EvaluationError`** — evaluation could not complete; the existing persona is preserved. diff --git a/config/persona-engine.example.json b/config/persona-engine.example.json new file mode 100644 index 0000000..5048057 --- /dev/null +++ b/config/persona-engine.example.json @@ -0,0 +1,230 @@ +{ + "configVersion": "1.0.0", + "metadata": { + "owner": "", + "changeReference": "", + "description": "Placeholder-only example. Every identifier below is fictional. Real group Object IDs, attribute names, and domains belong in a protected configuration store, never in this repository." + }, + "engine": { + "targetAttribute": "extension__", + "approvedWritableAttributes": [ + "extension__" + ], + "maxConditionDepth": 5, + "summaryInterval": 25, + "defaultMembershipMode": "direct", + "evaluationErrorThreshold": 50 + }, + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true, + "includeEligible": false + } + }, + "logging": { + "destination": "both", + "path": "", + "traceConditionValues": false + }, + "personas": [ + "Guest", + "BreakGlass-Admin", + "Tier0-Admin", + "Tier1-Admin", + "Tier2-Admin", + "Restricted-User", + "Test-Account", + "Service-Account", + "Shared-Functional-Account", + "Meeting-Room-Device", + "Employee", + "Contractor", + "Student" + ], + "rules": [ + { + "id": "RULE-0010-BREAKGLASS", + "name": "Emergency access accounts", + "description": "Emergency access accounts identified by immutable Object ID (RE-009). Evaluated first so no later rule can reclassify them.", + "enabled": true, + "priority": 10, + "persona": "BreakGlass-Admin", + "owner": "", + "match": { + "operator": "any", + "conditions": [ + { + "type": "property", + "property": "AccountObjectId", + "operator": "in", + "values": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002" + ] + } + ] + } + }, + { + "id": "RULE-0020-GUEST", + "name": "Guest accounts", + "description": "Any account whose directory user type is Guest.", + "enabled": true, + "priority": 20, + "persona": "Guest", + "match": { + "operator": "all", + "conditions": [ + { + "type": "property", + "property": "UserType", + "operator": "equals", + "value": "Guest" + } + ] + } + }, + { + "id": "RULE-0030-TIER0", + "name": "Tier 0 administrators", + "description": "Members of the Tier 0 administrative group, or holders of a Tier 0 directory role.", + "enabled": true, + "priority": 30, + "persona": "Tier0-Admin", + "match": { + "operator": "any", + "conditions": [ + { + "type": "membership", + "operator": "memberOf", + "membershipMode": "transitive", + "groupObjectIds": [ + "00000000-0000-0000-0000-0000000000a0" + ] + }, + { + "type": "role", + "operator": "memberOf", + "roleIds": [ + "" + ] + } + ] + } + }, + { + "id": "RULE-0040-SERVICE", + "name": "Service accounts", + "description": "Non-human accounts identified by naming convention and the service account group. Both must hold, so a naming-convention collision alone cannot classify a person as a service account.", + "enabled": true, + "priority": 40, + "persona": "Service-Account", + "match": { + "operator": "all", + "conditions": [ + { + "type": "property", + "property": "UserPrincipalName", + "operator": "startsWith", + "value": "svc-" + }, + { + "type": "membership", + "operator": "memberOf", + "groupObjectIds": [ + "00000000-0000-0000-0000-0000000000b0" + ] + } + ] + } + }, + { + "id": "RULE-0050-TEST", + "name": "Test accounts", + "description": "Accounts in the test account group, or matching the test naming convention while disabled.", + "enabled": true, + "priority": 50, + "persona": "Test-Account", + "match": { + "operator": "any", + "conditions": [ + { + "type": "membership", + "operator": "memberOf", + "groupObjectIds": [ + "00000000-0000-0000-0000-0000000000c0" + ] + }, + { + "operator": "all", + "conditions": [ + { + "type": "property", + "property": "UserPrincipalName", + "operator": "startsWith", + "value": "test-" + }, + { + "type": "property", + "property": "AccountEnabled", + "operator": "equals", + "value": "False" + } + ] + } + ] + } + }, + { + "id": "RULE-0060-CONTRACTOR", + "name": "Contractors", + "description": "Accounts whose company name marks them as external, excluding those already classified by an earlier rule.", + "enabled": true, + "priority": 60, + "persona": "Contractor", + "match": { + "operator": "all", + "conditions": [ + { + "type": "property", + "property": "CompanyName", + "operator": "isNotNull" + }, + { + "type": "property", + "property": "CompanyName", + "operator": "notEquals", + "value": "" + } + ] + } + }, + { + "id": "RULE-0900-EMPLOYEE", + "name": "Employees", + "description": "Default classification for enabled member accounts with a department. Lowest priority so every more specific rule wins first.", + "enabled": true, + "priority": 900, + "persona": "Employee", + "match": { + "operator": "all", + "conditions": [ + { + "type": "property", + "property": "UserType", + "operator": "equals", + "value": "Member" + }, + { + "type": "property", + "property": "Department", + "operator": "isNotNull" + } + ] + } + } + ] +} diff --git a/config/persona-engine.schema.json b/config/persona-engine.schema.json new file mode 100644 index 0000000..3f259f3 --- /dev/null +++ b/config/persona-engine.schema.json @@ -0,0 +1,278 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://example.invalid/persona-engine.schema.json", + "title": "Persona Engine Configuration", + "description": "Draft-07 by decision OTD-005: validated with the built-in Test-Json -SchemaFile cmdlet, whose validator reliably supports draft-04/06/07 only. Do not introduce 2019-09 or 2020-12 constructs. This schema is validation layer 2 of 4; semantic rules (VR-002) and safety rules (VR-003) are enforced in PowerShell, not here.", + "type": "object", + "required": ["configVersion", "engine", "dataSources", "personas", "rules"], + "additionalProperties": false, + "properties": { + "configVersion": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", + "description": "Semantic version of this configuration. A downgrade is a safety violation (VR-003)." + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "properties": { + "owner": { "type": "string" }, + "changeReference": { "type": "string" }, + "description": { "type": "string" } + } + }, + "engine": { + "type": "object", + "required": ["targetAttribute", "approvedWritableAttributes"], + "additionalProperties": false, + "properties": { + "targetAttribute": { + "type": "string", + "minLength": 1, + "description": "The single attribute the engine may write. Must also appear in approvedWritableAttributes (semantic layer). Example placeholder: extension__" + }, + "approvedWritableAttributes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "maxConditionDepth": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "default": 5, + "description": "RE-004. The ceiling of 10 is a hard limit; the configured value may be lower." + }, + "summaryInterval": { + "type": "integer", + "minimum": 0, + "default": 25, + "description": "FR-020. Zero suppresses interim summaries; a final summary is always produced." + }, + "defaultMembershipMode": { + "type": "string", + "enum": ["direct", "transitive"], + "default": "direct" + }, + "evaluationErrorThreshold": { + "type": "integer", + "minimum": 0, + "description": "Optional. Count of EvaluationError results above which the run reports failure." + } + } + }, + "dataSources": { + "type": "object", + "required": ["groups", "roles"], + "additionalProperties": false, + "properties": { + "groups": { + "type": "object", + "required": ["enabled"], + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "membershipMode": { "type": "string", "enum": ["direct", "transitive"] } + } + }, + "roles": { + "type": "object", + "required": ["enabled"], + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "includeEligible": { + "type": "boolean", + "default": false, + "description": "Out of scope for v1 unless authorization is confirmed and the provider is implemented." + } + } + } + } + }, + "logging": { + "type": "object", + "additionalProperties": false, + "properties": { + "destination": { + "type": "string", + "enum": ["file", "stream", "both"], + "default": "both", + "description": "OTD-006. Additional transports are added behind the sink function, not by widening this enum without a version change." + }, + "path": { + "type": "string", + "description": "Placeholder in committed artifacts: " + }, + "traceConditionValues": { + "type": "boolean", + "default": false, + "description": "Diagnostic only. Enabling this without explicit acknowledgement is a safety finding (VR-003)." + }, + "acknowledgeConditionTracing": { + "type": "boolean", + "default": false, + "description": "Explicit acknowledgement that condition-value tracing writes evaluated attribute values into audit records. Required by VR-003 whenever traceConditionValues is true. Kept in the configuration rather than passed as a command-line flag so the acknowledgement is reviewable in the change that enables tracing." + } + } + }, + "personas": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "not": { "enum": ["EvaluationError"] } + }, + "description": "Defined persona catalogue. EvaluationError is an execution result and must never be declared as a persona." + }, + "rules": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/rule" } + } + }, + "definitions": { + "rule": { + "type": "object", + "required": ["id", "name", "description", "enabled", "priority", "persona", "match"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "enabled": { "type": "boolean" }, + "priority": { "type": "integer", "minimum": 0 }, + "persona": { + "type": "string", + "minLength": 1, + "not": { "enum": ["Unclassified", "EvaluationError"] }, + "description": "Unclassified is a processing result, not a rule outcome (VR-002)." + }, + "match": { "$ref": "#/definitions/conditionGroup" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "owner": { "type": "string" }, + "changeReference": { "type": "string" }, + "effectiveDate": { + "type": "string", + "format": "date", + "description": "Metadata only in v1. It must not gate evaluation — a date-dependent decision would break determinism (Principle I)." + }, + "notes": { "type": "string" }, + "testCases": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "expectedMatch"], + "additionalProperties": true, + "properties": { + "name": { "type": "string" }, + "expectedMatch": { "type": "boolean" }, + "user": { "type": "object" } + } + } + } + } + }, + "conditionGroup": { + "type": "object", + "required": ["operator", "conditions"], + "additionalProperties": false, + "properties": { + "operator": { "type": "string", "enum": ["all", "any"] }, + "conditions": { + "type": "array", + "minItems": 1, + "items": { + "anyOf": [ + { "$ref": "#/definitions/conditionGroup" }, + { "$ref": "#/definitions/condition" } + ] + } + } + } + }, + "condition": { + "type": "object", + "required": ["type", "operator"], + "additionalProperties": false, + "properties": { + "type": { "type": "string", "enum": ["property", "membership", "role"] }, + "property": { "type": "string", "minLength": 1 }, + "operator": { + "type": "string", + "enum": [ + "equals", "notEquals", "contains", "notContains", + "startsWith", "endsWith", "matchesRegex", + "in", "notIn", "isNull", "isNotNull", + "memberOf", "notMemberOf" + ] + }, + "value": { "type": "string" }, + "values": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string" } + }, + "groupObjectIds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "#/definitions/guid" } + }, + "roleIds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "membershipMode": { "type": "string", "enum": ["direct", "transitive"] }, + "caseSensitive": { + "type": "boolean", + "default": false, + "description": "Reserved. Condition-level case sensitivity is out of scope for v1; the schema accepts the key so a later version does not require a breaking change." + } + }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "property" } }, "required": ["type"] }, + "then": { "required": ["property"] } + }, + { + "if": { "properties": { "type": { "const": "membership" } }, "required": ["type"] }, + "then": { "required": ["groupObjectIds"] } + }, + { + "if": { "properties": { "type": { "const": "role" } }, "required": ["type"] }, + "then": { "required": ["roleIds"] } + }, + { + "if": { + "properties": { "operator": { "enum": ["in", "notIn"] } }, + "required": ["operator"] + }, + "then": { "required": ["values"] } + }, + { + "if": { + "properties": { "operator": { "enum": ["isNull", "isNotNull"] } }, + "required": ["operator"] + }, + "then": { + "allOf": [ + { "not": { "required": ["value"] } }, + { "not": { "required": ["values"] } } + ] + } + } + ] + }, + "guid": { + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + } + } +} diff --git a/docs/Architecture.md b/docs/Architecture.md new file mode 100644 index 0000000..5dfe6cc --- /dev/null +++ b/docs/Architecture.md @@ -0,0 +1,138 @@ +# Architecture + +How Persona Engine is put together, and why the boundaries sit where they do. + +## The one structural rule + +The rule engine is pure. It takes a normalized record and a rule set, and returns a decision. It has +no knowledge of Microsoft Graph, no authentication, no console, no filesystem, and no clock. + +Everything else follows from that. It is constitution Principle IV, it is enforced by +[`tests/Test-EnginePurity.ps1`](../tests/Test-EnginePurity.ps1) on every build, and it is the reason +354 tests can run on a laptop with no tenant, no credentials, and no network. + +The purity check is a tokenizer pass, not a text search: it parses each file under `src/RuleEngine/` +with the PowerShell AST parser and inspects only the code tokens. An earlier text-matching version +flagged a comment that merely *mentioned* a persistence function, which is the kind of false positive +that gets a check disabled. + +## Layers + +Loaded in this order by [`PersonaEngine.psm1`](../PersonaEngine.psm1). Order matters only for +readability — PowerShell resolves function names at call time — but the dependency direction is real +and one-way. + +| Layer | Responsibility | May depend on | +| --- | --- | --- | +| `Normalization` | Convert raw directory objects into `UserRecord` and `MembershipRecord` | nothing | +| `Configuration` | Load, validate, and resolve configuration | Normalization | +| `RuleEngine` | Evaluate conditions, groups, rules; produce a decision | Normalization only | +| `Authentication` | Acquire a Graph connection | nothing in this module | +| `DataProviders` | Retrieve users, membership, roles; retry policy | Normalization | +| `Persistence` | Compare values; build and issue the write | DataProviders | +| `Presentation` | Per-user output, summaries, reconciliation | nothing | +| `Engine` | The run loop that composes all of the above | everything | +| `Audit` | Build and emit structured records | Presentation | + +The arrow never points into `RuleEngine`. Nothing in `RuleEngine` may reference anything from +`Authentication`, `DataProviders`, `Persistence`, or `Presentation`. + +## The normalization boundary + +`ConvertTo-PersonaUserRecord` and `ConvertTo-PersonaMembershipRecord` are the only places where a raw +Graph shape becomes an engine shape. Downstream of them, nothing knows Graph exists. + +Two consequences worth stating plainly: + +**Fixtures are real inputs.** A synthetic `UserRecord` built in a test is indistinguishable to the +engine from one built from a live tenant response. That is what makes the offline suite evidence +rather than a rehearsal. + +**Filtering rules live in one place.** `memberOf` returns directory objects of mixed type. +Administrative units arriving on that endpoint are discarded during normalization, not by each +caller. An administrative unit ID treated as a group ID would never match — which reads as "not a +member", a false non-match, the exact outcome FR-013 exists to prevent. + +## The MembershipRecord shape + +Three independently-retrieved facets — direct groups, transitive groups, directory roles — each with +its own retrieval flag and failure reason. + +This started as a single record with one `Mode` field, and running the code against the example +configuration is what proved it wrong: RE-007 makes membership mode a **per-condition** choice, so a +rule set may legitimately ask for transitive membership in one rule and direct in another. A record +carrying only one mode cannot answer both, and seven of nine fixtures came back as `EvaluationError`. +The engine was right; the contract was wrong. + +Independent facets also contain failure. If the transitive lookup times out but the direct lookup +succeeded, only conditions needing transitive data become `Unknown`. One collapsed flag would turn +one slow endpoint into a tenant-wide outage. + +Every `*Retrieved` flag defaults to `$false`. An unset flag means *unknown*, never *not a member*, so +a forgotten flag degrades to `EvaluationError` rather than silently misclassifying a privileged +account. + +## Tri-state evaluation + +Conditions return `'True'`, `'False'`, or `'Unknown'` — not a boolean. `Unknown` propagates through +condition groups by the table in [data-model.md](../specs/001-persona-engine/data-model.md), and an +`Unknown` reaching a rule's root becomes `EvaluationError` for that account. + +An `Unknown` at priority 30 stops evaluation even though a lower-priority rule might have matched. +Continuing would risk assigning a persona from priority 900 when the account may in truth have +matched at 30 — a privilege downgrade drawn from data nobody could read. Preserving the stored value +is the only safe answer. + +## The write gate has one origin + +Mode is derived from `$PSCmdlet.ShouldProcess()` and nothing else. There is no `-Preview` switch, no +configuration key that suppresses writes, and no reading of `$WhatIfPreference`. Two sources of truth +for a write gate is the defect class Principle III exists to prevent: the day they disagree, one of +them is wrong and the directory finds out first. + +`Invoke-PersonaEngine.ps1` owns the `ShouldProcess` call and passes the result down to +`Invoke-PersonaEngineRun` as a scriptblock. The run loop never learns what `-WhatIf` is, so it cannot +disagree with it — and it defaults to a gate that refuses, so a caller that forgets to supply one +previews rather than writes. + +## Why the run loop is a module function + +`Invoke-PersonaEngine.ps1` is a thin wrapper: parameter binding, module import, `ShouldProcess`, +exit code. The loop itself is `Invoke-PersonaEngineRun` in `src/Engine/`. + +That split exists because SC-004 requires proof that a `-WhatIf` run issues zero writes across a full +population. A loop that only exists inside an entry script — one that imports a manifest requiring +the Graph SDK — cannot be exercised without a tenant, so the claim could not be tested. What ships +and what is tested are now the same code. + +## Single emission point for audit + +Every audit record passes through `Write-PersonaAuditRecord`. Adding a transport is a change to that +one function. If call sites wrote their own output, each new transport would mean auditing every call +site again, and the one that got missed would be silent. + +Records go to the **Information** stream, not the success stream. Audit records on the success stream +would be indistinguishable from a function's return value — the run loop returns its outcome there, +and mixing the two turns one object into an array of several thousand. + +## Failure containment + +| Failure | Scope | Outcome | +| --- | --- | --- | +| Configuration invalid | Run | Exit 1, before any connection is attempted | +| Authentication fails | Run | Exit 2 | +| Enumeration truncated | Run | Exit 3 — a partial population is never processed | +| One membership lookup fails | One facet, one user | `EvaluationError`, stored value preserved | +| Too many `EvaluationError` | Run status only | Exit 4; no value was changed | +| Counters disagree | Run | Exit 5, `EngineDefect` record | +| One write fails | One user | `UpdateFailed`, run continues | + +The dividing line: anything affecting the whole population ends the run; anything affecting one +account is contained and reported. + +## Related + +- [SecurityModel.md](SecurityModel.md) — the OTD-003 trade-off and its compensating controls +- [ConfigurationReference.md](ConfigurationReference.md) — every schema field and finding code +- [OperationsRunbook.md](OperationsRunbook.md) — kill switch and rollback +- [Logging.md](Logging.md) — record types and what they may contain diff --git a/docs/BusinessRules.md b/docs/BusinessRules.md new file mode 100644 index 0000000..0d76455 --- /dev/null +++ b/docs/BusinessRules.md @@ -0,0 +1,137 @@ +# Business rules + +How to write, order, and change the rules that decide what an account is. + +Field-by-field syntax is in [ConfigurationReference.md](ConfigurationReference.md). This document is +about judgement. + +## The model + +Rules are evaluated in ascending `priority`. The first rule that returns `True` wins, and evaluation +stops. An account matching no enabled rule is `Unclassified`. An account whose evaluation hits data +that could not be retrieved is `EvaluationError`, and its stored value is preserved. + +Three consequences worth internalising before writing a rule: + +**Order is meaning.** A rule at priority 900 only ever sees accounts that failed every rule above it. +Changing a priority silently reclassifies every account matched by more than one rule, which is why +`PE-SAF-005` blocks a reorder without a `configVersion` change. + +**A rule cannot express "and not the previous ones".** It does not need to. First-match already +excludes them. Adding explicit exclusions duplicates the ordering in two places, and the day they +disagree the ordering wins silently. + +**Determinism is absolute.** The same account and the same configuration always produce the same +persona. Nothing time-dependent, random, or order-dependent may enter a decision — `effectiveDate` is +metadata for exactly this reason, and the engine's per-user timing uses a monotonic stopwatch rather +than the wall clock so no clock value can reach a decision. + +## Priority bands + +A convention, not enforced, but it makes the intent of a rule set legible at a glance: + +| Band | Purpose | Examples | +| --- | --- | --- | +| 1–99 | Accounts that must never be reclassified by anything | Emergency access, Tier 0 | +| 100–199 | Directory facts that are definitional | Guest, external | +| 200–499 | Non-human accounts | Service, shared functional, room devices | +| 500–799 | Population subsets | Contractor, student, restricted | +| 800–999 | Defaults | Employee | + +Leave gaps. Renumbering to insert a rule is a reorder, and a reorder is a `PE-SAF-005` finding. + +## Writing a rule that holds up + +**Identify special accounts by Object ID, never by name.** Display names and UPNs change; Object IDs +do not. RE-009 exists because a break-glass account renamed during an incident must not silently stop +being a break-glass account. + +**Require two independent signals for a consequential classification.** The example configuration's +service-account rule requires both a naming convention *and* group membership, so a person whose UPN +happens to start with `svc-` is not classified as a service account. + +**Prefer group membership to string matching for anything privileged.** A group is administered, +auditable, and has an owner. A naming convention is a habit. + +**Give every rule a description that says why it exists**, not what it does — the conditions already +say what it does. A rule nobody can explain cannot be safely changed, which is why `description` is +required. + +## Membership mode + +`direct` asks whether the account is a member of the named group itself. `transitive` asks whether it +is a member through any chain of nesting. + +Mode is a per-condition choice (RE-007). The three facets — direct, transitive, roles — are retrieved +independently, so mixing modes in one rule set is fully supported. It costs one extra request per +account for each additional facet. + +Use `transitive` when the group is a role-holding group that other groups nest into — which is most +Tier 0 groups. Use `direct` when membership is explicitly managed and nesting would be a mistake. + +**Do not pin `dataSources.groups.membershipMode` unless you mean to restrict.** Absent means "any mode +is acceptable". Pinning it turns every per-condition override into a `PE-SEM-014` warning, which +trains people to ignore warnings. + +## Unknown is not false + +If a membership lookup fails, the condition is `Unknown`, not `False`. A `notMemberOf` condition +therefore does **not** become satisfied when the lookup fails. + +This is the single most important behaviour in the engine. Without it, a transient Graph outage would +make every privileged account look like a non-member of its Tier 0 group, and a single run would +quietly demote the entire administrative population. `UnknownNotFalse.Tests.ps1` exists solely to +prevent that regression. + +The cost is that a failed lookup produces `EvaluationError` rather than a classification. That is the +correct trade: preserving a possibly-stale value is recoverable, and writing a confidently wrong one +is not. + +## Nesting + +`all` and `any` groups nest to `maxConditionDepth` (default 5, ceiling 10). Beyond the limit the +engine returns `Unknown`, which becomes `EvaluationError` for every account the rule reaches — so a +too-deep rule fails safe rather than silently. + +A rule needing more than three levels is usually two rules with different priorities. Depth is +expensive to read and the ordering you would express with nesting is already available for free. + +## Changing a rule set + +1. Edit the configuration. +2. Validate against the deployed copy so the drift checks actually run: + + ```bash + pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -PreviousConfigPath ./deployed/persona-engine.json -ValidateOnly -NonInteractive + ``` + +3. Run the rules against synthetic fixtures. +4. Preview against one account, then against the tenant with `-WhatIf`. +5. Compare the summary's per-rule match counts against the previous run. A rule whose count moved + sharply is either the change you made or a change you did not intend. +6. Raise `configVersion`. + +Step 5 is the one people skip. The summary lists every rule including zero-match ones precisely so +that a rule which *stopped* firing is visible, and a rule that stopped firing is the usual signature +of an accidental reorder. + +## Disabling versus deleting + +Disable rather than delete. A disabled rule still appears in every summary with a zero count, so the +audit trail keeps reporting on it and an operator can see it was deliberately turned off. A deleted +rule is indistinguishable from one that never existed, which is why `PE-SAF-005` flags a deletion +without a version change. + +## Testing a rule set without a tenant + +```bash +pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData -ValidateOnly -NonInteractive +``` + +This runs the real engine against the synthetic fixtures in `tests/TestData/`. The fixtures include +accounts with null and absent properties, mixed casing, a guest, a disabled account, and — most +usefully — two accounts whose membership lookups failed, so `EvaluationError` behaviour is visible +before it happens against a real directory. + +Add fixtures for the cases your rule set actually cares about. A fixture that reproduces a real edge +case, sanitized, is worth more than any amount of reasoning about what the engine will probably do. diff --git a/docs/ConfigurationReference.md b/docs/ConfigurationReference.md new file mode 100644 index 0000000..5963f05 --- /dev/null +++ b/docs/ConfigurationReference.md @@ -0,0 +1,217 @@ +# Configuration reference + +Every field in `persona-engine.json`, and every finding code the validator can produce. + +The authoritative schema is [`config/persona-engine.schema.json`](../config/persona-engine.schema.json) +(JSON Schema draft-07). A working example is +[`config/persona-engine.example.json`](../config/persona-engine.example.json), which is validated by +CI against its own schema — if the example the documentation points at could not pass, every reader's +first run would fail. + +## Top level + +| Field | Required | Notes | +| --- | --- | --- | +| `configVersion` | yes | Semantic version, `major.minor.patch`. A downgrade is a safety finding. | +| `metadata` | no | `owner`, `changeReference`, `description`. Free-form; not read by the engine. | +| `engine` | yes | Engine behaviour. | +| `dataSources` | yes | Which directory data may be retrieved. | +| `logging` | no | Audit output. | +| `personas` | yes | The declared persona catalogue. | +| `rules` | yes | Ordered business rules. | + +## `engine` + +| Field | Required | Default | Notes | +| --- | --- | --- | --- | +| `targetAttribute` | yes | — | The single attribute the engine may write. Must be a directory extension property and must appear in `approvedWritableAttributes`. | +| `approvedWritableAttributes` | yes | — | The allow-list. Comparison is **ordinal** — extension property names are case-sensitive in Graph. | +| `maxConditionDepth` | no | `5` | RE-004. Minimum 1, hard ceiling 10. | +| `summaryInterval` | no | `25` | Interim summary frequency. `0` suppresses interim summaries; a final summary always appears. | +| `defaultMembershipMode` | no | `direct` | Mode for membership conditions that do not specify one. | +| `evaluationErrorThreshold` | no | unset | Count of `EvaluationError` results above which the run reports exit code 4. Unset means report, do not fail. | + +Setting `evaluationErrorThreshold` to `0` makes a single transient lookup failure fail the run. That +is occasionally what you want; it is rarely what you meant. + +## `dataSources` + +| Field | Required | Notes | +| --- | --- | --- | +| `groups.enabled` | yes | When false, no membership facet is retrieved. Rules needing it become `EvaluationError`. | +| `groups.membershipMode` | no | Pins a mode globally. **Leave it out unless you mean to restrict** — absent means "any mode is acceptable", and RE-007 makes mode a per-condition choice. Pinning it makes every per-condition override a `PE-SEM-014` warning. | +| `roles.enabled` | yes | Directory role assignments. | +| `roles.includeEligible` | no | PIM-eligible assignments. **Out of scope for v1**; no provider is implemented. | + +The engine retrieves only the facets enabled rules actually reference. A configuration with no role +conditions never calls the role endpoint, so a tenant where role reads are unavailable can still run +property-only rules. + +## `logging` + +| Field | Required | Default | Notes | +| --- | --- | --- | --- | +| `destination` | no | `both` | `file`, `stream`, `both`, or `none`. `stream` writes records to the PowerShell Information stream. | +| `path` | no | — | NDJSON output file. One record per line. | +| `traceConditionValues` | no | `false` | Writes evaluated attribute values into audit records. | +| `acknowledgeConditionTracing` | no | `false` | **Required whenever `traceConditionValues` is true** (VR-003). | + +## `personas` + +The declared catalogue. A rule assigning a persona absent from this list is a `PE-SEM-010` error — the +catalogue is what stops a typo from writing a new persona value into the directory. + +`Unclassified` and `EvaluationError` are processing results and may never be declared or assigned. + +## `rules` + +| Field | Required | Notes | +| --- | --- | --- | +| `id` | yes | Unique. Appears in every audit record; this is how a decision is traced to its rule. | +| `name` | yes | Human-readable. | +| `description` | yes | Why the rule exists. Required, because a rule nobody can explain cannot be safely changed. | +| `enabled` | yes | Disabled rules are excluded from evaluation but still appear in summaries with zero matches. | +| `priority` | yes | Unique integer. **Lower evaluates first.** | +| `persona` | yes | Must appear in `personas`. | +| `match` | yes | The root condition group. | +| `tags`, `owner`, `changeReference`, `effectiveDate`, `notes`, `testCases` | no | Metadata. `effectiveDate` is **not** evaluated — a date-dependent decision would break determinism. | + +Priorities must be unique among enabled rules. The engine breaks ties by rule ID so results stay +deterministic, but the resulting order is an accident rather than a decision, so `PE-SEM-002` blocks it. + +## Condition groups and conditions + +A group has `operator` (`all` or `any`) and a `conditions` array. Each entry is either another group +or a condition. + +| Field | Applies to | Notes | +| --- | --- | --- | +| `type` | all | `property`, `membership`, or `role`. | +| `property` | `property` | One of the supported names below, or an extension property. | +| `operator` | all | See the operator table. | +| `value` | most | Single comparison value. | +| `values` | `in`, `notIn` | Comparison set. | +| `groupObjectIds` | `membership` | Group Object IDs. Names are mutable; IDs are not (RE-009). | +| `roleIds` | `role` | Role **template** IDs, which are stable across tenants. | +| `membershipMode` | `membership` | `direct` or `transitive`, per condition. | +| `caseSensitive` | — | Reserved. Not implemented in v1; the schema accepts the key so a later version needs no breaking change. | + +### Supported properties + +`AccountObjectId` · `UserPrincipalName` · `DisplayName` · `UserType` · `AccountEnabled` · +`CompanyName` · `JobTitle` · `Department` + +Plus any directory extension property named `extension_<32-hex-app-id>_`. Anything else is +`PE-SEM-015`: unsupported properties are never retrieved, so the condition would compare against a +permanently absent value and quietly never match. + +### Operators (RE-005) + +| Operator | Applies to | Notes | +| --- | --- | --- | +| `equals`, `notEquals` | property | Case-insensitive (RE-006). | +| `contains`, `notContains` | property | Case-insensitive substring. | +| `startsWith`, `endsWith` | property | Case-insensitive. | +| `matchesRegex` | property | Pattern compiled at validation time. An invalid pattern is `PE-SEM-016`, not a runtime failure. | +| `in`, `notIn` | property | Requires `values`. | +| `isNull`, `isNotNull` | property | Tests presence. **Must not carry a value** — it would be silently ignored (`PE-SEM-009`). | +| `memberOf`, `notMemberOf` | membership, role | Requires `groupObjectIds` or `roleIds`. | + +Null and absent properties are treated as empty for ordinary comparisons and never cause an +evaluation failure (FR-012). Intentional null matching uses `isNull` / `isNotNull`. + +## Tri-state evaluation + +Conditions return `True`, `False`, or `Unknown`. `Unknown` means required data could not be +retrieved, and it propagates: + +| Group | Contains | Result | +| --- | --- | --- | +| `all` | any `False` | `False` | +| `all` | only `True` plus at least one `Unknown` | `Unknown` | +| `any` | any `True` | `True` | +| `any` | only `False` plus at least one `Unknown` | `Unknown` | + +An `Unknown` at a rule's root makes the account `EvaluationError`: the stored persona is preserved +and no write is attempted (FR-013, FR-014). + +## Validation layers + +Run in order, stopping at the first that produces `Error` findings. Running semantic checks over a +structurally invalid document yields noise, not signal. + +| Layer | Mechanism | Codes | +| --- | --- | --- | +| 1 Syntax | `ConvertFrom-Json` | `PE-SYN-nnn` | +| 2 Schema | `Test-Json -SchemaFile` | `PE-SCH-nnn` | +| 3 Semantic | PowerShell checks | `PE-SEM-nnn` | +| 4 Safety | PowerShell checks | `PE-SAF-nnn` | + +Codes are stable. Pipelines and runbooks match on them, so a code is never reused for a different +condition and never renumbered. + +### Syntax — `PE-SYN` + +| Code | Condition | +| --- | --- | +| `PE-SYN-001` | Configuration file not found, or is not a file | +| `PE-SYN-002` | File exists but could not be read | +| `PE-SYN-003` | File is not valid JSON | + +### Schema — `PE-SCH` + +| Code | Condition | +| --- | --- | +| `PE-SCH-001` | Document violates the schema | +| `PE-SCH-002` | Schema file not found | +| `PE-SCH-003` | Schema file exists but is not valid JSON Schema | + +`PE-SCH-003` exists because of V-5a: `Test-Json` returns `$true` when the schema itself cannot be +parsed. A wrapper trusting the return value would report every configuration as schema-valid against +a schema that never ran. + +### Semantic — `PE-SEM` (VR-002) + +| Code | Condition | Severity | +| --- | --- | --- | +| `PE-SEM-001` | Duplicate rule ID | Error | +| `PE-SEM-002` | Duplicate priority among enabled rules | Error | +| `PE-SEM-003` | No rules, or no enabled rules | Error | +| `PE-SEM-004` | Blank target attribute | Error | +| `PE-SEM-005` | Target attribute absent from the approved list | Error | +| `PE-SEM-006` | Rule references a disabled data source | Error | +| `PE-SEM-007` | `memberOf` / `notMemberOf` with no group or role IDs | Error | +| `PE-SEM-008` | `in` / `notIn` with no `values` | Error | +| `PE-SEM-009` | `isNull` / `isNotNull` carrying a comparison value | Error | +| `PE-SEM-010` | Persona not in the declared catalogue | Error | +| `PE-SEM-011` | `Unclassified` used as a rule persona | Error | +| `PE-SEM-012` | Nesting deeper than `maxConditionDepth` | Error | +| `PE-SEM-013` | `maxConditionDepth` outside 1–10 | Error | +| `PE-SEM-014` | Condition mode differs from an explicitly pinned global mode | Warning | +| `PE-SEM-015` | Unsupported property name | Error | +| `PE-SEM-016` | Invalid regular expression | Error | + +Several of these are also enforced by the schema. The overlap is deliberate: layer 2 can be bypassed +with `-SchemaPath`, and V-5a showed an unparseable schema passes silently. Anything that can +misclassify a privileged account is checked twice. + +### Safety — `PE-SAF` (VR-003) + +| Code | Condition | Severity | +| --- | --- | --- | +| `PE-SAF-001` | Blank target attribute | Error enforcing, Warning in preview | +| `PE-SAF-002` | Approved list contains a non-extension attribute | Error | +| `PE-SAF-003` | Enabled rules need data the data sources do not provide | Error | +| `PE-SAF-004` | `configVersion` lower than the deployed version | Error enforcing, Warning in preview | +| `PE-SAF-005` | Rules removed or reordered with no version change | Error enforcing, Warning in preview | +| `PE-SAF-006` | Tracing enabled without acknowledgement | Error | +| `PE-SAF-007` | Save would overwrite an existing configuration with no backup | Error | + +`PE-SAF-004` and `PE-SAF-005` need `-PreviousConfigPath`. Without it they are **skipped**, and an +`Information` finding says so — silence would be read as approval. + +## Escalation (VR-005) + +`Error` blocks execution and saving. `Warning` blocks only under `-TreatWarningsAsErrors`. +`Information` never blocks. Passing `-TreatWarningsAsErrors` does not change a finding's severity; +it changes the caller's tolerance for it. diff --git a/docs/Logging.md b/docs/Logging.md new file mode 100644 index 0000000..a6f85ce --- /dev/null +++ b/docs/Logging.md @@ -0,0 +1,136 @@ +# Logging + +Structured audit output: what is emitted, where it goes, and what may never appear in it. + +The serialized contract is [audit-record.md](../specs/001-persona-engine/contracts/audit-record.md). +This document covers the operational side. + +## Format and transport + +Newline-delimited JSON (OTD-006). One record per line, UTF-8 without BOM, appended. + +Every record passes through a single sink, `Write-PersonaAuditRecord`. Adding a transport — an +approved logging platform, an event hub, a different file layout — is a change to that one function. +If call sites wrote their own output, each new transport would mean auditing every call site again, +and the one that got missed would be silent. + +| `logging.destination` | Behaviour | +| --- | --- | +| `file` | Appends NDJSON to `logging.path` | +| `stream` | Emits the record object on the PowerShell **Information** stream | +| `both` | Both | +| `none` | Nothing | + +`stream` uses the Information stream rather than the success stream deliberately. Audit records on +the success stream would be indistinguishable from a function's return value — the run loop returns +its outcome there — and mixing the two turns one object into an array of several thousand. Capture +them with `-InformationVariable`, or redirect with `6>`. + +### Sink failure never ends a run + +A full disk or a locked file is an operational problem with the sink, not a reason to abandon a +classification run mid-population and leave the directory half-reconciled. The failure surfaces as a +warning, **once** per run, and processing continues. + +Once, not once per user: a run over five thousand accounts with a locked log file should warn once, +or the warning that matters is buried in the noise it generates. + +## Record types + +| Type | When | Carries | +| --- | --- | --- | +| `RunStart` | Once, after mode is determined | Config path, target attribute, rule counts, whether tracing is on | +| `UserEvent` | Once per processed account | The full decision | +| `Summary` | Every `summaryInterval` accounts, and once at the end | Counters, per-rule match counts, reconciliation result | +| `RunComplete` | Once, in a `finally` block | Final counters, timing, exit code | +| `EngineDefect` | Reconciliation failure, or threshold breach | What went wrong and by how much | + +`RunComplete` is written even on a fatal error. A run that died at account 400 of 5,000 leaves a +record saying exactly that — which is what lets an operator tell "the engine stopped early" from "the +engine never started", two very different incidents that produce identical evidence if the record is +written only on success. + +## The common envelope + +Every record, every type: + +`timestamp` · `recordType` · `runId` · `engineVersion` · `configVersion` · `configurationHash` · `mode` + +These appear **first** in each record, so a truncated line still identifies the run that produced it. + +`runId` comes from `-CorrelationId` or is generated, and is constant for the run (NFR-005). +`configurationHash` is the SHA-256 of the configuration file bytes — two files differing only in +whitespace are different configurations for audit purposes, and the hash must be reproducible from +the artifact on disk. + +## `UserEvent` + +100% carry `runId`, `userPrincipalName`, and `accountObjectId` (SC-006). 100% of `Matched` records +carry `matchedRuleId`. `evaluationErrorReason` is non-null exactly when `outcome` is +`EvaluationError`. + +`previousValue` is present **only** on `Updated` records, captured at write time. On any other action +there is nothing that was replaced, and a populated `previousValue` would imply otherwise to a +rollback tool reading these records later. Without it, OTD-010 rollback is impossible retroactively — +no future run can reconstruct what a value used to be. + +## What may never appear + +Access tokens, `Authorization` headers, client secrets, certificates, credentials, and full Graph +responses. + +The guarantee is structural rather than filtered. `New-PersonaAuditRecord` accepts only named, typed +values from the decision result and the counters — there is no pass-through of an arbitrary object, +so there is nothing for a secret to ride in on. +[`AuditRedaction.Tests.ps1`](../tests/Unit/AuditRedaction.Tests.ps1) asserts this holds even when a +caller attaches a token to the decision result, and scans every serialized record for JWT and Bearer +shapes. + +## Approved for logs + +User principal name, account object ID, matched rule ID, stored and calculated persona values, run +ID, configuration version and hash, per-rule match counts, timing. + +Runtime records naturally contain real UPNs and Object IDs. **No such value may ever be committed to +this repository** (SC-013) — the sanitization scan enforces that on every build. + +## Condition tracing + +`conditionTrace` is added to a `UserEvent` only when **both** gates are open: the decision result was +built with tracing, and the record was asked to include it. It carries the per-rule result +(`True` / `False` / `Unknown`) and priority. + +Tracing widens what the log contains beyond the approved set, so it requires +`logging.acknowledgeConditionTracing` in the same configuration (`PE-SAF-006`). Tracing never changes +a decision — if it could, a debug run would stop being evidence about the real one, and +[`ConditionTrace.Tests.ps1`](../tests/Unit/ConditionTrace.Tests.ps1) asserts the outcome is identical +with and without it. + +## Querying + +```powershell +# Everything from one run +Get-Content | ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object runId -eq '' + +# Accounts a run changed, with what it replaced +Get-Content | ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object { $_.recordType -eq 'UserEvent' -and $_.action -eq 'Updated' } | + Select-Object userPrincipalName, previousValue, calculatedPersona, matchedRuleId + +# Accounts that could not be evaluated, and why +Get-Content | ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object outcome -eq 'EvaluationError' | + Select-Object userPrincipalName, evaluationErrorReason + +# Which rule set produced a given decision +Get-Content | ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object recordType -eq 'RunStart' | + Select-Object runId, configVersion, configurationHash, mode +``` + +## Retention + +Not set by this engine. Records contain UPNs and Object IDs, so retention is governed by the +organization's identity-data policy rather than by anything in this repository. Decide it before the +first enforcing run, not after. diff --git a/docs/OperationsRunbook.md b/docs/OperationsRunbook.md new file mode 100644 index 0000000..353379c --- /dev/null +++ b/docs/OperationsRunbook.md @@ -0,0 +1,168 @@ +# Operations runbook + +What to do when the engine is running, and what to do when it should not be. + +## Before any run + +1. Validate the configuration. It costs seconds and needs no tenant. + + ```bash + pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive + ``` + +2. Run the rules against synthetic fixtures. This shows what the rule set *does* before it sees a + real account. + + ```bash + pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData -ValidateOnly -NonInteractive + ``` + +3. Preview a single user before previewing the tenant. + + ```bash + pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId -WhatIf -Verbose + ``` + +Never skip step 3. A rule set that behaves correctly against fixtures can still request a property +your tenant does not populate, and finding that out on one account is cheaper than on fifty thousand. + +## Reading a run + +Per-user lines appear immediately, one per account, colour-coded by action: + +| Action | Meaning | +| --- | --- | +| `Unchanged` | Calculated value already matches the stored value. Nothing to do. | +| `WouldUpdate` | A change is proposed. Preview mode, or the per-user gate refused. | +| `Updated` | The attribute was written. | +| `UpdateFailed` | The write was attempted and rejected. Stored value is untouched. | +| `Skipped` | No write is possible: `EvaluationError`, or a blank or unapproved target. | + +`Skipped` on every account almost always means the target attribute is blank or unapproved — check +the header line and `PE-SAF-001`. + +A summary appears every `summaryInterval` accounts and once at the end, listing **every** rule +including disabled and zero-match ones. A rule that never fired and a rule that is not in the +configuration look identical if zero-match rules are omitted, and that distinction is usually what +you are looking for. + +## Exit codes + +| Code | Meaning | First thing to check | +| --- | --- | --- | +| 0 | Success | — | +| 1 | Configuration validation failed | The findings printed above it; no connection was attempted | +| 2 | Authentication or authorization failed | Scopes, consent, and whether the account can sign in | +| 3 | User enumeration failed | Graph availability. **No accounts were processed** — a partial population is never used | +| 4 | `evaluationErrorThreshold` exceeded | Group or role endpoint health. Nothing was changed | +| 5 | Reconciliation failed | **An engine defect.** Open an issue with the `EngineDefect` record | +| 6 | Unexpected fatal error | The message and `-Verbose` stack trace | + +Exit 5 is never a data condition. Outcomes are assigned by the engine, exactly one per account, so if +`Processed` does not equal `Matched + Unclassified + EvaluationError` the engine lost a user or +double-counted one. Report it rather than re-running. + +Exit 4 means the population was classified from data that could not be trusted. Stored values were +preserved, so nothing is damaged — but do not draw conclusions from the run. + +## Kill switch + +In increasing order of severity. Pick the lowest one that addresses the problem. + +### 1. Stop writing — immediate, no deployment + +Add `-WhatIf` to the invocation. Reads, evaluation, output, and audit records continue unchanged; +zero write requests are constructed. + +### 2. Stop classifying — one configuration change + +Set `enabled: false` on every rule and deploy. + +> Every account becomes `Unclassified`. **In an enforcing run that proposes clearing every stored +> persona.** Combine with `-WhatIf`, or use option 1 instead, unless clearing is what you want. + +### 3. Stop running — Stage B only + +Disable the Automation schedule. Nothing is in flight; the next run simply does not start. + +### 4. Remove the capability — the one that holds if the code is the problem + +Revoke `User.ReadWrite.All` from the execution identity. The engine keeps running and every write +becomes `UpdateFailed`, which is loud, logged, and harmless. + +### 5. Remove the path — for a suspected defect in the write path + +Remove the write deployment stage from the release pipeline so no build can restore write capability +by accident. + +Options 1 through 3 rely on the engine behaving correctly. Options 4 and 5 do not, which is why they +exist. + +## Rollback (OTD-010) + +Every `Updated` audit record carries `previousValue`, captured **before** the PATCH. That is what +makes rollback possible; reading the value back afterwards would return the new one. + +To roll back a run: + +1. Find the run's records by `runId`. +2. Select records where `recordType` is `UserEvent` and `action` is `Updated`. +3. For each, write `previousValue` back to `accountObjectId`. + +```powershell +# Reads the NDJSON audit file and lists what a rollback would restore. +# Review this output before writing anything back. +Get-Content | + ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object { $_.runId -eq '' -and $_.recordType -eq 'UserEvent' -and $_.action -eq 'Updated' } | + Select-Object accountObjectId, userPrincipalName, previousValue, calculatedPersona +``` + +> A rollback is itself a directory write and is subject to the same V-4 gate and the same test-account +> restriction as any enforcement run. A rollback tool is **not implemented in v1** — the data needed +> to build one is captured, deliberately, because it cannot be reconstructed retroactively. + +If a rollback is genuinely intended at the configuration level, publish it as a **new higher +version** rather than reusing the old number. Two different rule sets sharing one `configVersion` +makes the audit trail unable to tell them apart (`PE-SAF-004`). + +## Common situations + +**Every account is `EvaluationError`.** A required data source is disabled or unreachable. Check +`dataSources.groups.enabled` and `dataSources.roles.enabled` against what the rules need — `PE-SAF-003` +catches this at validation time, so a run reaching this state usually means validation was bypassed. + +**Every account is `Unclassified`.** Either every rule is disabled, or no rule matches. The summary +table distinguishes these: disabled rules are dimmed, zero-match enabled rules show `0`. + +**The run is slow.** Check the cache hit ratio via `-Verbose`. Membership lookups dominate: one +account needing both direct and transitive facets plus roles is three requests. Narrowing rules to a +single membership mode roughly halves that. + +**A write failed with 403.** Non-retryable by design — retrying would hide a configuration or +authorization defect behind a timeout. Check that the identity holds `User.ReadWrite.All` and that +the target attribute exists on the application registration. + +**Audit file sink warnings.** The sink warns once per run and processing continues. A locked or full +log file is an operational problem with the sink, not a reason to abandon a run mid-population and +leave the directory half-reconciled. + +## Concurrency + +Two concurrent enforcing runs against the same tenant would race on the same attributes. Until the +OTD-009 run-start concurrency check is implemented (T120, Stage B), **the schedule is the lock**: do +not start a manual enforcing run while a scheduled one may be in flight. + +Preview runs are read-only and safe to run concurrently. + +## What to attach to a bug report + +- The exit code +- The `RunComplete` record for the run — it carries the counters, timing, and exit code even when the + run died early +- Any `EngineDefect` record +- The `configVersion` and `configurationHash` from any record +- The rule set, sanitized + +Do **not** attach raw audit records containing real UPNs or Object IDs to anything that leaves the +organization. They are approved for internal logs, not for public issue trackers. diff --git a/docs/SecurityModel.md b/docs/SecurityModel.md new file mode 100644 index 0000000..9252a73 --- /dev/null +++ b/docs/SecurityModel.md @@ -0,0 +1,169 @@ +# Security model + +What this engine is permitted to do, what it is not, and where the gap between those two is held +open by testing rather than by the platform. + +## The central trade-off (OTD-003) + +**Microsoft Graph application permissions have no per-property write scope.** An identity granted +`User.ReadWrite.All` can write *any* writable property on *any* user object. It cannot be narrowed to +one extension attribute. + +This is not a limitation to be worked around. It is a fact about the platform, recorded here so that +nobody later assumes the directory is enforcing something it is not. + +The consequence: **the only thing standing between this engine and every writable user property is +the code in this repository, and the tests that hold it to that.** Every control below exists because +the directory will not refuse a malformed request on our behalf. + +## The six compensating controls + +All six are mandatory. Each is testable, and each is tested. + +| # | Control | Where it lives | Proof | +| --- | --- | --- | --- | +| 1 | The persistence layer accepts only the configured target attribute | `New-PersonaWriteBody` throws for any other name | `WriteBodyRejection.Tests.ps1` | +| 2 | The target must appear in `approvedWritableAttributes` | `Resolve-TargetAttribute` and `New-PersonaWriteBody`, checked twice | `WriteBodyRejection.Tests.ps1` | +| 3 | Validation rejects every other attribute | `PE-SAF-002`, layer 4 | `Safety.Tests.ps1` | +| 4 | One dedicated function builds the request body, and it is the only one | `New-PersonaWriteBody` returns a hashtable whose `Count` is exactly 1 | `WriteBody.Tests.ps1` | +| 5 | Tests inspect the captured request body | Every body issued during a full enforcing run is asserted to have one key | `WriteBody.Tests.ps1` | +| 6 | Code owners and branch policies gate persistence changes | Repository configuration, outside this codebase | Branch protection on `src/Persistence/` | + +Control 4 is the load-bearing one. A single construction site makes SC-005 a property of one testable +function rather than a convention every future call site has to remember. `WriteBody.Tests.ps1` +includes a scan asserting that no other file under `src/` builds a PATCH body. + +Control 2 is deliberately redundant. Validation runs once at startup against the file; the write +builder checks again on every write against the values actually in hand — so a configuration object +mutated mid-run still cannot widen the blast radius. + +### Why comparison is ordinal here and case-insensitive elsewhere + +Rule matching is case-insensitive (RE-006), because a rule author should not have to match directory +casing. Attribute approval is **ordinal and case-sensitive**, because extension property names are +case-sensitive in Graph: `extension__Persona` and `extension__persona` are two different +attributes, and approving one does not approve the other. + +Change detection is also ordinal (FR-015). A stored `employee` against a calculated `Employee` is a +real difference worth correcting, not a formatting quirk. + +## Permissions + +### Stage A — local, delegated (current) + +```powershell +Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory' +``` + +Read-only. Sufficient for every preview run and for closing V-1 (read) and V-3. + +The engine requests only the scopes the enabled rules actually need: a configuration with no role +conditions never asks for `RoleManagement.Read.Directory`, and a configuration with no membership +conditions never asks for `GroupMember.Read.All`. Least privilege applies to data as well as to +permissions — properties nothing references are not even added to `$select`. + +**Stage A3 (delegated write) adds `User.ReadWrite.All` and targets purpose-created test accounts +only.** Under delegated authentication the write runs *as the operator*, which makes the compensating +controls more important rather than less: the directory sees the operator's own permissions, not a +narrowed service identity. + +> **Never sign in with a standing privileged account for a write run.** A Global Administrator +> session invalidates V-3 as evidence and removes every practical limit on what a defect could reach. + +### Stage B — Azure Automation, application permissions (deferred) + +`User.Read.All`, `GroupMember.Read.All`, `RoleManagement.Read.Directory`, and — for enforcement — +`User.ReadWrite.All`, granted to a **managed identity**. No client secret, ever, in source control or +in a runbook parameter. + +Deferred, not waived: no Automation account is available. V-3b and V-5b remain open. + +## The persona attribute (OTD-001) + +A **directory (schema) extension property**, registered on an application registration and addressable +as `extension__`. + +Two alternatives were rejected for concrete reasons: + +- **`extensionAttribute1..15`** — unavailable for cloud writes on objects that are, or ever were, + synchronized from on-premises, and on Exchange-originated objects. A classification engine that + silently cannot write to a subset of the population is worse than one that cannot write at all. +- **Custom security attributes** — not exposed to the dynamic group membership engine, which defeats + the purpose: the persona exists so that Conditional Access can be targeted through dynamic groups. + +`PE-SAF-002` rejects any approved attribute that is not shaped like a directory extension property. +Built-in attributes such as `department` or `jobTitle` are excluded **even when the operator holds +permission to write them** — they are authoritative in the sync source or in HR, and this engine does +not own them. + +## Data handling + +**Approved for logs**: user principal name, account object ID, matched rule ID, stored and calculated +persona values, run ID, configuration version and hash. + +**Never logged, under any setting**: access tokens, `Authorization` headers, client secrets, +certificates, credentials, or full Graph responses. + +The guarantee is structural rather than filtered. `New-PersonaAuditRecord` accepts only named, typed +values from the decision result and the counters — there is no pass-through of an arbitrary object, +so there is nothing for a secret to ride in on. `AuditRedaction.Tests.ps1` asserts this holds even +when a caller actively attaches a token to the decision result. + +### Condition tracing + +`logging.traceConditionValues` writes evaluated attribute values into audit records, widening what +the log contains beyond the approved set. It requires `logging.acknowledgeConditionTracing` in the +same configuration, or validation fails with `PE-SAF-006`. + +The acknowledgement lives in the configuration rather than in a command-line switch on purpose: a +flag passed at a console is invisible to review, while a field in the configuration appears in the +diff of the change that enables tracing, next to the person who approved it. + +## The no-write control + +`-WhatIf` is the only approved no-write control. + +`-Debug` does **not** imply read-only. A `-Debug` run without `-WhatIf` writes, and +`ShouldProcessGate.Tests.ps1` asserts that it does — because an operator who believed otherwise would +reach for `-Debug` as a safety measure and get an enforcing run. The same holds for `-Verbose`. + +`ShouldProcessGate.Tests.ps1` also asserts that the entry script declares no `-Preview`, `-NoWrite`, +`-ReadOnly`, or `-DryRun` parameter, and never reads `$WhatIfPreference`. + +## Verification gates + +| Item | Status | Blocks | +| --- | --- | --- | +| V-1 read half | Open — needs a tenant | Confidence in the read path across origin types | +| V-1 write half | Open — needs test accounts | Enforcement | +| V-2 dynamic group + CA | Open | Declaring the persona useful | +| V-3 non-privileged `-WhatIf` run | Open — needs a tenant | Stage A2 sign-off | +| V-3b managed-identity scopes | Deferred | Stage B | +| **V-4 security sign-off on these controls** | **Open** | **All enforcement (T101)** | +| V-5a `Test-Json` behaviour | **Closed** — see [V-5a.md](../specs/001-persona-engine/verification/V-5a.md) | Layer 2 implementation | +| V-5b `Test-Json` in Automation | Deferred | Stage B | + +**V-4 gates enforcement.** No write run against anything other than purpose-created test accounts +until it is recorded in `specs/001-persona-engine/verification/V-4.md`. + +## Kill switch + +In increasing order of severity — see [OperationsRunbook.md](OperationsRunbook.md) for the procedure: + +1. Run with `-WhatIf`. +2. Set every rule to `enabled: false` and deploy. +3. Disable the Automation schedule (Stage B). +4. Revoke `User.ReadWrite.All` from the execution identity. +5. Remove the write deployment stage. + +Steps 4 and 5 are the ones that hold if the code itself is the problem. + +## Sanitization (SC-013) + +No organization name, real domain, tenant or subscription ID, real UPN or Object ID, real group or +role identifier, environment-specific attribute name, or any secret may appear in any tracked file. +Placeholders only. + +[`tests/Test-Sanitization.ps1`](../tests/Test-Sanitization.ps1) scans every tracked file on every +build. Runtime records naturally contain real UPNs and Object IDs — approved for logs — but no such +value is ever committed. diff --git a/pipelines/test.yml b/pipelines/test.yml new file mode 100644 index 0000000..0eadf82 --- /dev/null +++ b/pipelines/test.yml @@ -0,0 +1,181 @@ +# Persona Engine - test stage +# +# Separate from validate.yml because the two answer different questions. Validation +# asks "is this repository well-formed?" and gates every pull request. This asks "does +# the engine behave correctly?" and publishes evidence. +# +# The Integration suite is present but disabled by default. It needs a delegated +# read-only connection to a real tenant (Stage A2), which a shared build agent cannot +# hold without storing credentials - and NFR-006 puts no secrets in source control. +# Enable it only on an agent with an interactive or workload-identity connection, +# never by adding a secret to this file. + +trigger: none + +pr: none + +schedules: + - cron: '0 6 * * 1-5' + displayName: 'Weekday morning regression' + branches: + include: + - main + always: false + +pool: + vmImage: windows-latest + +parameters: + - name: runIntegration + displayName: 'Run the Integration suite (requires a tenant connection)' + type: boolean + default: false + +variables: + pesterVersion: '5.6.1' + +stages: + - stage: OfflineSuites + displayName: 'Offline and safety suites' + jobs: + - job: Offline + displayName: 'Offline suite' + steps: + - checkout: self + + - task: PowerShell@2 + displayName: 'Install pinned Pester' + inputs: + pwsh: true + targetType: inline + script: | + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + Install-Module Pester -RequiredVersion $(pesterVersion) -Force -SkipPublisherCheck -Scope CurrentUser + + - task: PowerShell@2 + displayName: 'Offline suite' + inputs: + pwsh: true + targetType: inline + script: | + $ErrorActionPreference = 'Stop' + + $config = & ./tests/PesterConfiguration.ps1 -Suite Offline + $config.Run.Exit = $false + $config.Run.PassThru = $true + $config.TestResult.Enabled = $true + $config.TestResult.OutputPath = './testResults.offline.xml' + $config.CodeCoverage.Enabled = $true + $config.CodeCoverage.Path = @('./src') + $config.CodeCoverage.OutputPath = './coverage.offline.xml' + $config.Output.Verbosity = 'Normal' + + $result = Invoke-Pester -Configuration $config + + if ($result.TotalCount -eq 0) { throw 'The offline suite ran no tests.' } + if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) offline test(s) failed." } + + - task: PublishTestResults@2 + displayName: 'Publish offline results' + condition: succeededOrFailed() + inputs: + testResultsFormat: NUnit + testResultsFiles: './testResults.offline.xml' + testRunTitle: 'Persona Engine - offline suite' + + - task: PublishCodeCoverageResults@2 + displayName: 'Publish coverage' + condition: succeededOrFailed() + inputs: + summaryFileLocation: './coverage.offline.xml' + + - job: Safety + displayName: 'Safety suite' + dependsOn: Offline + steps: + - checkout: self + + - task: PowerShell@2 + displayName: 'Install pinned Pester' + inputs: + pwsh: true + targetType: inline + script: | + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + Install-Module Pester -RequiredVersion $(pesterVersion) -Force -SkipPublisherCheck -Scope CurrentUser + + - task: PowerShell@2 + displayName: 'Safety suite (SC-002, SC-004, SC-005)' + inputs: + pwsh: true + targetType: inline + script: | + $ErrorActionPreference = 'Stop' + + $config = & ./tests/PesterConfiguration.ps1 -Suite Safety + $config.Run.Exit = $false + $config.Run.PassThru = $true + $config.TestResult.Enabled = $true + $config.TestResult.OutputPath = './testResults.safety.xml' + $config.Output.Verbosity = 'Detailed' + + $result = Invoke-Pester -Configuration $config + + # The safety suite running zero tests is the most dangerous possible + # green build: it is exactly what a mis-tagged file looks like, and the + # assertions it drops are the zero-write and single-attribute ones. + if ($result.TotalCount -eq 0) { throw 'The safety suite ran no tests. Check the Safety tag.' } + if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) safety test(s) failed. Do not deploy." } + + Write-Host "Safety suite: $($result.PassedCount) passed." + + - task: PublishTestResults@2 + displayName: 'Publish safety results' + condition: succeededOrFailed() + inputs: + testResultsFormat: NUnit + testResultsFiles: './testResults.safety.xml' + testRunTitle: 'Persona Engine - safety suite' + + - stage: IntegrationSuite + displayName: 'Integration suite (tenant required)' + dependsOn: OfflineSuites + condition: and(succeeded(), eq('${{ parameters.runIntegration }}', true)) + jobs: + - job: Integration + displayName: 'Integration suite' + steps: + - checkout: self + + - task: PowerShell@2 + displayName: 'Integration suite' + inputs: + pwsh: true + targetType: inline + script: | + $ErrorActionPreference = 'Stop' + + # No credential handling here by design. The agent must already hold a + # delegated read-only connection; if it does not, this fails loudly + # rather than prompting or falling back to a stored secret. + if (-not (Get-Module -ListAvailable Microsoft.Graph.Authentication)) { + throw 'Microsoft.Graph.Authentication is not available on this agent.' + } + + $config = & ./tests/PesterConfiguration.ps1 -Suite Integration + $config.Run.Exit = $false + $config.Run.PassThru = $true + $config.TestResult.Enabled = $true + $config.TestResult.OutputPath = './testResults.integration.xml' + + $result = Invoke-Pester -Configuration $config + + if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) integration test(s) failed." } + + - task: PublishTestResults@2 + displayName: 'Publish integration results' + condition: succeededOrFailed() + inputs: + testResultsFormat: NUnit + testResultsFiles: './testResults.integration.xml' + testRunTitle: 'Persona Engine - integration suite' diff --git a/pipelines/validate.yml b/pipelines/validate.yml new file mode 100644 index 0000000..3a804e5 --- /dev/null +++ b/pipelines/validate.yml @@ -0,0 +1,175 @@ +# Persona Engine - validation stage +# +# Everything here runs with no tenant, no credentials, and no network (SC-008). That +# is the point: a pipeline that needs a directory connection to tell you a rule file +# is wrong cannot run on every pull request, and the check that only runs sometimes is +# the one that stops catching things. +# +# Gate order is deliberate, cheapest and most categorical first. Sanitization runs +# before anything else because a leaked identifier in a branch is a problem whether or +# not the code compiles, and every later stage prints file contents into build logs. + +trigger: + branches: + include: + - main + paths: + include: + - src/* + - tests/* + - config/* + - pipelines/* + - Invoke-PersonaEngine.ps1 + - Edit-PersonaEngineConfig.ps1 + - PersonaEngine.psd1 + - PersonaEngine.psm1 + +pr: + branches: + include: + - main + +pool: + vmImage: windows-latest + +variables: + # Pinned rather than latest. A validation stage that changes behaviour when an + # upstream module publishes is not a gate, it is a coin flip. + pesterVersion: '5.6.1' + analyzerVersion: '1.22.0' + +steps: + - checkout: self + fetchDepth: 0 + + - task: PowerShell@2 + displayName: 'Gate 1 - Sanitization (SC-013)' + inputs: + pwsh: true + filePath: tests/Test-Sanitization.ps1 + failOnStderr: false + + - task: PowerShell@2 + displayName: 'Install pinned analysis modules' + inputs: + pwsh: true + targetType: inline + script: | + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + Install-Module Pester -RequiredVersion $(pesterVersion) -Force -SkipPublisherCheck -Scope CurrentUser + Install-Module PSScriptAnalyzer -RequiredVersion $(analyzerVersion) -Force -Scope CurrentUser + + - task: PowerShell@2 + displayName: 'Gate 2 - PSScriptAnalyzer' + inputs: + pwsh: true + targetType: inline + script: | + $ErrorActionPreference = 'Stop' + $findings = Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 + + if ($findings) { + $findings | Format-Table -AutoSize | Out-String | Write-Host + } + + $blocking = @($findings | Where-Object Severity -in 'Error', 'Warning') + if ($blocking.Count -gt 0) { + throw "PSScriptAnalyzer reported $($blocking.Count) blocking finding(s)." + } + + - task: PowerShell@2 + displayName: 'Gate 3 - Engine purity (Principle IV)' + inputs: + pwsh: true + filePath: tests/Test-EnginePurity.ps1 + + - task: PowerShell@2 + displayName: 'Gate 4 - Shipped schema is valid JSON Schema' + inputs: + pwsh: true + targetType: inline + script: | + $ErrorActionPreference = 'Stop' + + # V-5a: Test-Json returns $true when the schema itself cannot be parsed, so a + # broken schema would let every later check pass while validating nothing. + # This stage exists solely to catch that. + $errors = $null + $null = '{}' | Test-Json -SchemaFile ./config/persona-engine.schema.json -ErrorAction SilentlyContinue -ErrorVariable errors + + $unusable = @($errors | Where-Object { $_.Exception.Message -match 'Cannot parse the JSON schema' }) + if ($unusable.Count -gt 0) { + throw 'config/persona-engine.schema.json is not valid JSON Schema. No configuration can be schema-validated until it is repaired.' + } + + # The contract copy and the shipped copy must stay identical, or a rule author + # reading the contract validates against a different schema than the engine. + $shipped = (Get-FileHash ./config/persona-engine.schema.json -Algorithm SHA256).Hash + $contract = (Get-FileHash ./specs/001-persona-engine/contracts/persona-engine.schema.json -Algorithm SHA256).Hash + + if ($shipped -ne $contract) { + throw 'config/persona-engine.schema.json and the contract copy have diverged.' + } + + - task: PowerShell@2 + displayName: 'Gate 5 - Example configuration passes all four layers' + inputs: + pwsh: true + targetType: inline + script: | + $ErrorActionPreference = 'Stop' + ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive + if ($LASTEXITCODE -ne 0) { throw "The shipped example configuration failed validation with exit code $LASTEXITCODE." } + + - task: PowerShell@2 + displayName: 'Gate 6 - Offline Pester suite (SC-008)' + inputs: + pwsh: true + targetType: inline + script: | + $ErrorActionPreference = 'Stop' + + $config = & ./tests/PesterConfiguration.ps1 -Suite Offline + $config.Run.Exit = $false + $config.Run.PassThru = $true + $config.TestResult.Enabled = $true + $config.TestResult.OutputPath = './testResults.offline.xml' + $config.Output.Verbosity = 'Normal' + + $result = Invoke-Pester -Configuration $config + + # Asserted, not assumed. A configuration change that silently filtered every + # test out would otherwise report a green pipeline over zero coverage. + if ($result.TotalCount -eq 0) { throw 'The offline suite ran no tests.' } + if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) offline test(s) failed." } + + Write-Host "Offline suite: $($result.PassedCount) passed, $($result.FailedCount) failed." + + - task: PublishTestResults@2 + displayName: 'Publish offline test results' + condition: succeededOrFailed() + inputs: + testResultsFormat: NUnit + testResultsFiles: './testResults.offline.xml' + testRunTitle: 'Persona Engine - offline suite' + + - task: PowerShell@2 + displayName: 'Gate 7 - No Graph module was loaded (SC-008)' + inputs: + pwsh: true + targetType: inline + script: | + # The proof that the offline suite is genuinely offline. If a test ever + # imports the Graph SDK, the "runs with no tenant" claim quietly stops being + # true and nobody notices until an air-gapped build fails. + $config = & ./tests/PesterConfiguration.ps1 -Suite Offline + $config.Run.PassThru = $true + $config.Output.Verbosity = 'None' + $null = Invoke-Pester -Configuration $config + + $graph = Get-Module | Where-Object Name -like 'Microsoft.Graph*' + if ($graph) { + throw "A Graph module was loaded during the offline suite: $($graph.Name -join ', ')" + } + + Write-Host 'No Graph module was loaded. SC-008 holds.' diff --git a/specs/001-persona-engine/contracts/audit-record.md b/specs/001-persona-engine/contracts/audit-record.md new file mode 100644 index 0000000..c9cdec6 --- /dev/null +++ b/specs/001-persona-engine/contracts/audit-record.md @@ -0,0 +1,127 @@ +# Contract: Structured Audit Records + +Serialized form of the audit trail (FR-022, NFR-005, Principle V). Format is newline-delimited JSON, +one record per line (OTD-006). All emission goes through a single `Write-PersonaAuditRecord` sink so +a future transport can be added without touching call sites. + +## Common envelope + +Every record carries: + +| Field | Type | Notes | +| --- | --- | --- | +| `timestamp` | string (ISO 8601 UTC) | | +| `recordType` | string | `RunStart`, `UserEvent`, `Summary`, `RunComplete`, `EngineDefect` | +| `runId` | string (GUID) | Constant for the run (NFR-005) | +| `engineVersion` | string | | +| `configVersion` | string | | +| `configurationHash` | string | SHA-256 of the configuration file | +| `mode` | string | `Preview` or `Enforce` | + +## `UserEvent` + +Emitted once per processed user. 100% of these records carry `runId`, `userPrincipalName`, and +`accountObjectId` (SC-006). + +```json +{ + "timestamp": "2026-08-20T09:14:02.187Z", + "recordType": "UserEvent", + "runId": "", + "engineVersion": "1.0.0", + "configVersion": "1.4.0", + "configurationHash": "", + "mode": "Preview", + "accountObjectId": "", + "userPrincipalName": "@", + "outcome": "Matched", + "matchedRuleId": "RULE-0100-TIER0", + "storedPersona": "Employee", + "calculatedPersona": "Tier0-Admin", + "previousValue": "Employee", + "action": "WouldUpdate", + "rulesEvaluated": 4, + "durationMs": 38, + "evaluationErrorReason": null +} +``` + +**Field requirements** + +| Field | Requirement | +| --- | --- | +| `outcome` | Exactly one of `Matched`, `Unclassified`, `EvaluationError` (SC-001) | +| `matchedRuleId` | Non-null on every `Matched` record (SC-006) | +| `previousValue` | **Captured at write time on every `Updated` record.** This is what makes OTD-010 rollback possible; omitting it in v1 makes rollback impossible retroactively | +| `evaluationErrorReason` | Non-null exactly when `outcome` is `EvaluationError` | + +**Prohibited fields**: access tokens, `Authorization` headers, secrets, and full Graph responses +MUST NEVER appear in any record. + +**Condition tracing**: a `conditionTrace` array may be added **only** under `-Debug` +(`logging.traceConditionValues`). It contains diagnostic condition-level values and is therefore +gated by acknowledgement (VR-003). + +## `Summary` + +Emitted every `summaryInterval` users and once at completion. + +```json +{ + "recordType": "Summary", + "runId": "", + "summaryType": "Interim", + "processed": 250, + "matched": 231, + "unclassified": 14, + "evaluationError": 5, + "unchanged": 220, + "wouldUpdate": 11, + "updated": 0, + "updateFailed": 0, + "reconciliationPassed": true, + "ruleCounts": [ + { "ruleId": "RULE-0100-TIER0", "name": "Tier 0 administrators", "enabled": true, "matches": 3 } + ] +} +``` + +`reconciliationPassed` is `processed == matched + unclassified + evaluationError` (FR-021, SC-007). +`ruleCounts` lists **all** business rules, including disabled and zero-match rules — an absent rule +is indistinguishable from a rule that never fired, and operators need that distinction. + +## `RunComplete` + +```json +{ + "recordType": "RunComplete", + "runId": "", + "startedUtc": "2026-08-20T09:00:00.000Z", + "completedUtc": "2026-08-20T09:12:44.913Z", + "durationMs": 764913, + "processed": 4820, + "matched": 4611, + "unclassified": 190, + "evaluationError": 19, + "unchanged": 4400, + "wouldUpdate": 211, + "updated": 0, + "updateFailed": 0, + "reconciliationPassed": true, + "exitCode": 0 +} +``` + +## `EngineDefect` + +Emitted when reconciliation fails (FR-021) or an internal invariant is violated. Severity is always +`Error`. A failed reconciliation is a defect in the engine, not a property of the data, and is +reported as such rather than being folded into ordinary counters. + +## Sanitization (SC-013) + +Committed artifacts — this contract, examples, fixtures, tests, and documentation — use placeholders +only: ``, ``, ``, ``, +``, ``, ``, +``, ``. Runtime records naturally contain real UPNs and Object IDs — which +are approved for logs — but no such value may ever be committed to this repository. diff --git a/specs/001-persona-engine/contracts/cli-edit-persona-engine-config.md b/specs/001-persona-engine/contracts/cli-edit-persona-engine-config.md new file mode 100644 index 0000000..6714bf4 --- /dev/null +++ b/specs/001-persona-engine/contracts/cli-edit-persona-engine-config.md @@ -0,0 +1,90 @@ +# Contract: `Edit-PersonaEngineConfig.ps1` + +Configuration validation, interactive editing, synthetic rule testing, and pipeline enforcement +(FR-023 – FR-026). + +## Signature + +```powershell +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [Parameter(Mandatory)][string] $ConfigPath, + [Parameter()][switch] $ValidateOnly, + [Parameter()][switch] $NonInteractive, + [Parameter()][string] $SchemaPath, + [Parameter()][string] $OutputPath, + [Parameter()][switch] $TreatWarningsAsErrors, + [Parameter()][string] $TestDataPath +) +``` + +## Parameter contract + +| Parameter | Behaviour | +| --- | --- | +| `-ConfigPath` | Required. Configuration to validate or edit. | +| `-ValidateOnly` | Validate and report; never enter the editor. | +| `-NonInteractive` | Pipeline mode. **MUST NOT prompt and MUST NOT hang** (SC-010). Returns an exit code. | +| `-SchemaPath` | Override the shipped schema. | +| `-OutputPath` | Save-As target; leaves the input file untouched. | +| `-TreatWarningsAsErrors` | Escalates `Warning` findings to blocking (VR-005). | +| `-TestDataPath` | Synthetic sample users for offline rule testing (FR-025). No tenant connectivity. | + +## Validation layers (VR-001, ordered, fail-fast between layers) + +| Layer | Mechanism | Example findings | +| --- | --- | --- | +| 1. Syntax | `ConvertFrom-Json` | Malformed JSON | +| 2. Schema | `Test-Json -SchemaFile` (draft-07, OTD-005) | Missing required field, wrong type, bad enum | +| 3. Semantic | PowerShell checks | Every condition in VR-002 | +| 4. Safety | PowerShell checks | Every condition in VR-003 | + +A layer that produces `Error` findings stops the sequence — running semantic checks over a +structurally invalid document yields noise, not signal. + +### Layer 2 error-handling requirement + +`Test-Json` reports schema failure by writing errors rather than returning `$false` in several +PowerShell versions. The wrapper MUST invoke it with `-ErrorAction SilentlyContinue -ErrorVariable` +and convert collected errors into `ValidationFinding` objects, so layer 2 emits the same structured +shape as every other layer (VR-004). + +## Finding contract + +Every finding carries `Severity`, `Code`, `Location` (JSON path or rule ID), `Description`, +`SuggestedResolution`, and `Layer`. Finding codes are stable and namespaced by layer: + +```text +PE-SYN-nnn syntax +PE-SCH-nnn schema +PE-SEM-nnn semantic (one code per VR-002 condition) +PE-SAF-nnn safety (one code per VR-003 condition) +``` + +Stability matters: pipelines and runbooks will match on these codes. + +## Exit codes + +| Code | Condition | +| --- | --- | +| `0` | Valid; no blocking findings | +| `1` | One or more `Error` findings | +| `2` | `Warning` findings present with `-TreatWarningsAsErrors` | +| `3` | Configuration file not found or unreadable | +| `4` | Schema file not found or itself invalid | + +## Save contract (FR-026) + +1. Re-validate the edited document in full. +2. Block the save on any `Error` finding. +3. Write a timestamped backup — or require `-OutputPath` — before replacing an existing file. +4. Overwriting the only valid configuration without a backup is a safety finding (VR-003), not + merely a warning. + +## Invariants (test-asserted) + +| Invariant | Assertion | +| --- | --- | +| Non-interactive never prompts | Runs to completion with stdin closed; no prompt, no hang (SC-010) | +| Every VR-002/VR-003 condition detected | One test per condition, each asserting code, severity, and location (SC-009) | +| Offline | Full validation and synthetic rule testing complete with no network access (SC-008) | diff --git a/specs/001-persona-engine/contracts/cli-invoke-persona-engine.md b/specs/001-persona-engine/contracts/cli-invoke-persona-engine.md new file mode 100644 index 0000000..9284630 --- /dev/null +++ b/specs/001-persona-engine/contracts/cli-invoke-persona-engine.md @@ -0,0 +1,90 @@ +# Contract: `Invoke-PersonaEngine.ps1` + +The engine entry point. Retrieval, evaluation, reporting, and controlled persistence. + +## Signature + +```powershell +[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] +param( + [Parameter(Mandatory)][string] $ConfigPath, + [Parameter()][guid] $UserObjectId, + [Parameter()][string] $OutputPath, + [Parameter()][guid] $CorrelationId +) +``` + +`SupportsShouldProcess` supplies `-WhatIf` and `-Confirm`. `-Verbose` and `-Debug` are common +parameters and are **not** declared. + +## Parameter contract + +| Parameter | Required | Behaviour | +| --- | --- | --- | +| `-ConfigPath` | Yes | Path to the JSON configuration. Validated through all four layers before any connection is made (FR-002). | +| `-WhatIf` | No | **The approved no-write control.** Reads, evaluation, comparison, console output, summaries, and audit records all behave identically to enforcement; zero write requests are issued (FR-017, SC-004). | +| `-UserObjectId` | No | Single-user execution for validation. Skips enumeration; retrieves one user. | +| `-OutputPath` | No | Overrides the configured audit output path where permitted. | +| `-CorrelationId` | No | Supplied run identifier. Generated when absent. Appears on every audit record. | +| `-Verbose` | No | Operational detail. **MUST NOT** alter write behaviour. | +| `-Debug` | No | Enables condition-value tracing (Principle V). **MUST NOT** imply read-only — a `-Debug` run without `-WhatIf` writes. | + +## Mode determination + +```text +$PSCmdlet.ShouldProcess() returns $false -> Preview mode -> no write request constructed or sent +$PSCmdlet.ShouldProcess() returns $true -> Enforce mode -> write permitted, subject to FR-016 +``` + +Mode MUST be derived from `ShouldProcess` alone. A separate boolean "preview" flag is prohibited — +two sources of truth for the write gate is precisely the defect class Principle III exists to +prevent. + +## Write gate (FR-016) + +A write is issued only when **all** hold: + +1. Evaluation completed successfully (`Outcome != EvaluationError`). +2. `CalculatedPersona != StoredPersona` (ordinal comparison, case-sensitive for change detection). +3. The target attribute is non-blank and present in `approvedWritableAttributes`. +4. `ShouldProcess` returned `$true` for this user. + +Failing any of these yields `Unchanged`, `WouldUpdate`, or `Skipped` — never a silent write. + +## Output contract + +- **Per user, immediately after evaluation** (FR-018, SC-012): one console line carrying UPN, + Account Object ID, outcome, matched rule ID, stored value, calculated value, and action. +- **Every `summaryInterval` users** (FR-019): a table of all business rules with match counts, plus + outcome totals and a reconciliation check. +- **At completion**: a final summary regardless of interval, including when the interval is `0` + (FR-020). +- **Reconciliation** at every summary: `Processed = Matched + Unclassified + EvaluationError` + (FR-021). A mismatch is logged as an engine defect, at `Error` severity. +- **Audit records**: see [audit-record.md](audit-record.md). + +## Exit codes + +| Code | Condition | +| --- | --- | +| `0` | Successful run; no fatal processing errors | +| `1` | Configuration validation failure | +| `2` | Authentication / authorization failure | +| `3` | User enumeration failure | +| `4` | Fatal required data-provider failure | +| `5` | Reconciliation failure | +| `6` | Unexpected fatal engine error | + +Every code MUST be reachable and returned for its documented condition (SC-011). A per-user +`EvaluationError` does **not** by itself terminate the run; the final status reports the affected +count and applies `evaluationErrorThreshold` when configured. + +## Invariants (test-asserted) + +| Invariant | Assertion | +| --- | --- | +| Zero writes under `-WhatIf` | The write adapter is mocked; call count is `0` over a full synthetic population (SC-004) | +| Single-attribute body | Every captured request body has exactly one key, equal to `engine.targetAttribute` (SC-005) | +| Idempotence | Second consecutive run over unchanged input issues zero writes (SC-002) | +| Determinism | Same fixture set, shuffled input order, identical results (SC-003) | +| Exactly one outcome | Every processed user appears in exactly one outcome bucket (SC-001) | diff --git a/specs/001-persona-engine/contracts/graph-data-provider.md b/specs/001-persona-engine/contracts/graph-data-provider.md new file mode 100644 index 0000000..03519ad --- /dev/null +++ b/specs/001-persona-engine/contracts/graph-data-provider.md @@ -0,0 +1,124 @@ +# Contract: Directory Data Provider + +The only component permitted to talk to Microsoft Graph. Implements OTD-002, OTD-004, and OTD-007. +Everything below the normalization boundary is invisible to the rule engine (Principle IV). + +**Transport**: `Invoke-MgGraphRequest` from `Microsoft.Graph.Authentication` (OTD-004). No +resource-specific SDK modules. + +## Authentication (FR-003) + +| Function | Environment | Mechanism | +| --- | --- | --- | +| `Connect-PersonaGraphManagedIdentity` | Azure Automation | `Connect-MgGraph -Identity` | +| `Connect-PersonaGraphInteractive` | Local development | `Connect-MgGraph -Scopes ` | + +Both return an opaque connection handle. No token, header, or secret is ever returned to a caller, +logged, or written to an audit record. + +## Permissions (OTD-002) + +| Function | Application permission | +| --- | --- | +| `Get-PersonaUsers` | `User.Read.All` (or `User.ReadWrite.All` for the enforcement identity) | +| `Get-PersonaGroupMembership` | `GroupMember.Read.All` | +| `Get-PersonaDirectoryRoles` | `RoleManagement.Read.Directory` | +| `Set-UserPersonaAttribute` | `User.ReadWrite.All` | + +`Directory.Read.All` is prohibited — materially broader than the three read permissions combined. + +## Read operations + +### `Get-PersonaUsers` + +```text +GET /v1.0/users?$select=&$top=999 +``` + +- `$select` carries the FR-005 baseline (`id`, `userPrincipalName`, `displayName`, `userType`, + `accountEnabled`, `companyName`, `jobTitle`, `department`) plus the configured target attribute and + any property referenced by an enabled rule. Unused properties are not requested. +- The persona directory extension is selected by its full name, + `extension__`. +- Pagination follows `@odata.nextLink` until absent (FR-004). A truncated enumeration MUST raise — + never return a partial population as if complete. +- `-UserObjectId` switches to `GET /v1.0/users/{id}?$select=...`. + +### `Get-PersonaGroupMembership` + +```text +GET /v1.0/users/{id}/memberOf # direct +GET /v1.0/users/{id}/transitiveMemberOf # transitive +``` + +Mode comes from the condition, falling back to `engine.defaultMembershipMode` (RE-007). + +**Return contract**: always a `MembershipRecord`. On failure it returns a record with +`RetrievalSucceeded = $false` and a `FailureReason` — it MUST NOT return an empty list, and MUST NOT +throw past the per-user boundary. This single behaviour is what makes FR-013 work: unknown membership +becomes `EvaluationError`, never a false non-match. + +### `Get-PersonaDirectoryRoles` + +```text +GET /v1.0/roleManagement/directory/roleAssignments?$filter=principalId eq '' +``` + +Eligible (PIM) assignments are out of scope for v1 unless authorization is confirmed. + +### Caching + +Group and role data reusable across users is cached for the run's lifetime (NFR-002). The cache is +keyed by group or role Object ID and is **never** persisted between runs — a stale cache would make +results depend on run history, breaking Principle I. + +## Write operation + +### `New-PersonaWriteBody` + +The **only** function permitted to construct a write body (OTD-003 control 3). + +```powershell +# Returns exactly one key. +@{ "" = "" } +``` + +Contract: + +- Throws if the attribute name is not `engine.targetAttribute`. +- Throws if the attribute is absent from `approvedWritableAttributes`. +- Returns a hashtable whose `Count` is exactly `1`. Tests assert on this directly (SC-005). + +### `Set-UserPersonaAttribute` + +```text +PATCH /v1.0/users/{id} +Content-Type: application/json + + +``` + +- Callable **only** when `ShouldProcess` returned `$true`. Under `-WhatIf` this function is not + reached — the caller does not construct a request at all (SC-004). Preview mode is an absence of a + call, not a suppressed call. +- A failure returns `UpdateFailed` for that user and does not terminate the run. + +## Retry policy (OTD-007) + +| Aspect | Value | +| --- | --- | +| Retryable | 429, 500, 502, 503, 504, transport timeout | +| Never retried | 400, 401, 403, 404, 409 | +| `Retry-After` | Honoured when present; overrides computed backoff | +| Attempts | Max 5 | +| Backoff | Exponential from 1s, full jitter, per-delay cap 60s | +| Logging | Attempt number, status code, and delay on every retry | + +Exhausted retries on **required** data produce `EvaluationError` for the affected user (FR-013). +Exhausted retries during enumeration are fatal (exit code `3`). + +## Prohibited in this layer + +- Logging tokens, `Authorization` headers, or full response bodies (Principle V). +- Returning raw Graph objects past `ConvertTo-Persona*Record`. +- Any reference to rule, persona, or condition concepts — this layer moves data, it does not decide. diff --git a/specs/001-persona-engine/contracts/persona-engine.schema.json b/specs/001-persona-engine/contracts/persona-engine.schema.json new file mode 100644 index 0000000..3f259f3 --- /dev/null +++ b/specs/001-persona-engine/contracts/persona-engine.schema.json @@ -0,0 +1,278 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://example.invalid/persona-engine.schema.json", + "title": "Persona Engine Configuration", + "description": "Draft-07 by decision OTD-005: validated with the built-in Test-Json -SchemaFile cmdlet, whose validator reliably supports draft-04/06/07 only. Do not introduce 2019-09 or 2020-12 constructs. This schema is validation layer 2 of 4; semantic rules (VR-002) and safety rules (VR-003) are enforced in PowerShell, not here.", + "type": "object", + "required": ["configVersion", "engine", "dataSources", "personas", "rules"], + "additionalProperties": false, + "properties": { + "configVersion": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", + "description": "Semantic version of this configuration. A downgrade is a safety violation (VR-003)." + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "properties": { + "owner": { "type": "string" }, + "changeReference": { "type": "string" }, + "description": { "type": "string" } + } + }, + "engine": { + "type": "object", + "required": ["targetAttribute", "approvedWritableAttributes"], + "additionalProperties": false, + "properties": { + "targetAttribute": { + "type": "string", + "minLength": 1, + "description": "The single attribute the engine may write. Must also appear in approvedWritableAttributes (semantic layer). Example placeholder: extension__" + }, + "approvedWritableAttributes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "maxConditionDepth": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "default": 5, + "description": "RE-004. The ceiling of 10 is a hard limit; the configured value may be lower." + }, + "summaryInterval": { + "type": "integer", + "minimum": 0, + "default": 25, + "description": "FR-020. Zero suppresses interim summaries; a final summary is always produced." + }, + "defaultMembershipMode": { + "type": "string", + "enum": ["direct", "transitive"], + "default": "direct" + }, + "evaluationErrorThreshold": { + "type": "integer", + "minimum": 0, + "description": "Optional. Count of EvaluationError results above which the run reports failure." + } + } + }, + "dataSources": { + "type": "object", + "required": ["groups", "roles"], + "additionalProperties": false, + "properties": { + "groups": { + "type": "object", + "required": ["enabled"], + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "membershipMode": { "type": "string", "enum": ["direct", "transitive"] } + } + }, + "roles": { + "type": "object", + "required": ["enabled"], + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "includeEligible": { + "type": "boolean", + "default": false, + "description": "Out of scope for v1 unless authorization is confirmed and the provider is implemented." + } + } + } + } + }, + "logging": { + "type": "object", + "additionalProperties": false, + "properties": { + "destination": { + "type": "string", + "enum": ["file", "stream", "both"], + "default": "both", + "description": "OTD-006. Additional transports are added behind the sink function, not by widening this enum without a version change." + }, + "path": { + "type": "string", + "description": "Placeholder in committed artifacts: " + }, + "traceConditionValues": { + "type": "boolean", + "default": false, + "description": "Diagnostic only. Enabling this without explicit acknowledgement is a safety finding (VR-003)." + }, + "acknowledgeConditionTracing": { + "type": "boolean", + "default": false, + "description": "Explicit acknowledgement that condition-value tracing writes evaluated attribute values into audit records. Required by VR-003 whenever traceConditionValues is true. Kept in the configuration rather than passed as a command-line flag so the acknowledgement is reviewable in the change that enables tracing." + } + } + }, + "personas": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "not": { "enum": ["EvaluationError"] } + }, + "description": "Defined persona catalogue. EvaluationError is an execution result and must never be declared as a persona." + }, + "rules": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/rule" } + } + }, + "definitions": { + "rule": { + "type": "object", + "required": ["id", "name", "description", "enabled", "priority", "persona", "match"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "enabled": { "type": "boolean" }, + "priority": { "type": "integer", "minimum": 0 }, + "persona": { + "type": "string", + "minLength": 1, + "not": { "enum": ["Unclassified", "EvaluationError"] }, + "description": "Unclassified is a processing result, not a rule outcome (VR-002)." + }, + "match": { "$ref": "#/definitions/conditionGroup" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "owner": { "type": "string" }, + "changeReference": { "type": "string" }, + "effectiveDate": { + "type": "string", + "format": "date", + "description": "Metadata only in v1. It must not gate evaluation — a date-dependent decision would break determinism (Principle I)." + }, + "notes": { "type": "string" }, + "testCases": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "expectedMatch"], + "additionalProperties": true, + "properties": { + "name": { "type": "string" }, + "expectedMatch": { "type": "boolean" }, + "user": { "type": "object" } + } + } + } + } + }, + "conditionGroup": { + "type": "object", + "required": ["operator", "conditions"], + "additionalProperties": false, + "properties": { + "operator": { "type": "string", "enum": ["all", "any"] }, + "conditions": { + "type": "array", + "minItems": 1, + "items": { + "anyOf": [ + { "$ref": "#/definitions/conditionGroup" }, + { "$ref": "#/definitions/condition" } + ] + } + } + } + }, + "condition": { + "type": "object", + "required": ["type", "operator"], + "additionalProperties": false, + "properties": { + "type": { "type": "string", "enum": ["property", "membership", "role"] }, + "property": { "type": "string", "minLength": 1 }, + "operator": { + "type": "string", + "enum": [ + "equals", "notEquals", "contains", "notContains", + "startsWith", "endsWith", "matchesRegex", + "in", "notIn", "isNull", "isNotNull", + "memberOf", "notMemberOf" + ] + }, + "value": { "type": "string" }, + "values": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string" } + }, + "groupObjectIds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "#/definitions/guid" } + }, + "roleIds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "membershipMode": { "type": "string", "enum": ["direct", "transitive"] }, + "caseSensitive": { + "type": "boolean", + "default": false, + "description": "Reserved. Condition-level case sensitivity is out of scope for v1; the schema accepts the key so a later version does not require a breaking change." + } + }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "property" } }, "required": ["type"] }, + "then": { "required": ["property"] } + }, + { + "if": { "properties": { "type": { "const": "membership" } }, "required": ["type"] }, + "then": { "required": ["groupObjectIds"] } + }, + { + "if": { "properties": { "type": { "const": "role" } }, "required": ["type"] }, + "then": { "required": ["roleIds"] } + }, + { + "if": { + "properties": { "operator": { "enum": ["in", "notIn"] } }, + "required": ["operator"] + }, + "then": { "required": ["values"] } + }, + { + "if": { + "properties": { "operator": { "enum": ["isNull", "isNotNull"] } }, + "required": ["operator"] + }, + "then": { + "allOf": [ + { "not": { "required": ["value"] } }, + { "not": { "required": ["values"] } } + ] + } + } + ] + }, + "guid": { + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + } + } +} diff --git a/specs/001-persona-engine/data-model.md b/specs/001-persona-engine/data-model.md new file mode 100644 index 0000000..9972a1e --- /dev/null +++ b/specs/001-persona-engine/data-model.md @@ -0,0 +1,228 @@ +# Data Model: Persona Engine + +**Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md) + +Normalized in-memory contracts. These are the objects the rule engine sees. Per Principle IV the +rule engine MUST NOT receive raw directory responses — normalization is the boundary. + +All types are plain `PSCustomObject` shapes. Field types are PowerShell types. + +--- + +## UserRecord + +Produced by `ConvertTo-PersonaUserRecord`. Consumed by the rule engine, presentation, and audit. + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `AccountObjectId` | `string` (GUID) | Yes | Immutable identity key. Approved for logs. | +| `UserPrincipalName` | `string` | Yes | Approved for logs. | +| `DisplayName` | `string` | No | Diagnostics only. | +| `UserType` | `string` | No | `Member` / `Guest`. | +| `AccountEnabled` | `bool` | Yes | Disabled accounts remain in scope (FR-011). | +| `Properties` | `hashtable` | Yes | Case-insensitive map of evaluable property name → value. Populated from FR-005 selection plus any property a rule references. Absent property returns `$null`. | +| `StoredPersona` | `string` | No | Current value of the target attribute; `$null` when unset. | +| `Membership` | `MembershipRecord` | Yes | Never `$null`; an unattempted lookup is represented by an empty record with `RetrievalSucceeded = $true` and `Mode = 'None'`. | + +**Validation rules** + +- `AccountObjectId` and `UserPrincipalName` MUST be non-empty; a record failing this is an + upstream defect and MUST raise, not silently skip. +- `Properties` lookups are case-insensitive (RE-006). +- A `$null` or absent value in `Properties` is treated as empty for ordinary string comparisons and + MUST NOT fail evaluation (FR-012). + +--- + +## MembershipRecord + +Produced by `ConvertTo-PersonaMembershipRecord`. This type carries the most safety-critical fields +in the model. + +**Revised 2026-08-20 during implementation.** The original design held a single `Mode` field +(`Direct` / `Transitive` / `None`) alongside one `GroupObjectIds` set. That cannot satisfy RE-007, +which makes membership mode a **per-condition** choice: a rule set may legitimately ask for +transitive membership in one rule and direct membership in another, and a single-mode record can +only answer one of them — every user became an `EvaluationError` on the other. The defect was +caught by running the shipped example configuration, which mixes both modes, against the fixtures. +The record now holds three independently-retrieved facets. + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `DirectGroupObjectIds` | `string[]` | Yes | May be empty. | +| `DirectRetrieved` | `bool` | Yes | **`$false` means "unknown", never "not a member".** | +| `DirectFailureReason` | `string` | No | Populated only when `DirectRetrieved` is `$false`. | +| `TransitiveGroupObjectIds` | `string[]` | Yes | May be empty. | +| `TransitiveRetrieved` | `bool` | Yes | Same semantics as `DirectRetrieved`. | +| `TransitiveFailureReason` | `string` | No | | +| `DirectoryRoleIds` | `string[]` | Yes | May be empty. | +| `RolesRetrieved` | `bool` | Yes | Same semantics. | +| `RolesFailureReason` | `string` | No | | + +**Validation rules** + +- Every `*Retrieved` flag defaults to `$false`. A condition MUST read the flag for the facet it + actually queries, and a `$false` MUST yield `Unknown`, propagating to `EvaluationError` (FR-013). +- Facets are independent: a failed transitive lookup MUST NOT make direct-membership conditions + unevaluable. Collapsing them would turn one slow endpoint into a tenant-wide outage. +- An empty identifier collection with its facet `Retrieved = $true` is a legitimate "member of + nothing" and evaluates normally. +- A facet MUST NOT be both retrieved and carry a failure reason; the constructor throws. +- Mode selection is exact. A condition asking for transitive membership MUST NOT be answered from + direct data (false negatives on nested groups) and vice versa (false positives). + +--- + +## BusinessRule + +Deserialized from configuration. Never constructed in source (Principle II). + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `Id` | `string` | Yes | Unique across the rule set (VR-002). | +| `Name` | `string` | Yes | | +| `Description` | `string` | Yes | | +| `Enabled` | `bool` | Yes | Disabled rules are skipped and excluded from the enabled count. | +| `Priority` | `int` | Yes | Unique; lower evaluates first (RE-002). | +| `Persona` | `string` | Yes | MUST be a defined persona; MUST NOT be `Unclassified` or `EvaluationError` (VR-002). | +| `Match` | `ConditionGroup` | Yes | Root condition group. | +| `Tags` | `string[]` | No | | +| `Owner` | `string` | No | | +| `ChangeReference` | `string` | No | | +| `EffectiveDate` | `string` | No | Metadata only in v1 — MUST NOT gate evaluation, as a date-dependent decision would break Principle I. | +| `Notes` | `string` | No | | +| `TestCases` | `object[]` | No | Consumed by the editor's synthetic testing (FR-025). | + +--- + +## ConditionGroup / Condition + +Recursive structure bounded by the configured depth (RE-004: default 5, min 1, ceiling 10). + +**ConditionGroup** + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `Operator` | `string` | Yes | `all` or `any`. | +| `Conditions` | `(Condition\|ConditionGroup)[]` | Yes | MUST be non-empty. | + +**Condition (leaf)** + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `Type` | `string` | Yes | `property`, `membership`, or `role`. | +| `Property` | `string` | For `property` | MUST be a supported property name (VR-002). | +| `Operator` | `string` | Yes | One of RE-005. | +| `Value` | `string` | Conditional | Required for comparison operators; MUST be absent for `isNull` / `isNotNull` (VR-002). | +| `Values` | `string[]` | Conditional | Required for `in` / `notIn`. | +| `GroupObjectIds` | `string[]` | For `membership` | MUST be non-empty (VR-002). | +| `RoleIds` | `string[]` | For `role` | MUST be non-empty. | +| `MembershipMode` | `string` | No | `direct` or `transitive`; defaults to the engine setting (RE-007). | + +**Evaluation result values**: every condition evaluates to `True`, `False`, or **`Unknown`**. +`Unknown` is what makes FR-013 expressible. + +**Propagation rules** (these are the whole safety argument — implement exactly): + +| Group | Contains `Unknown` | Result | +| --- | --- | --- | +| `all` | plus any `False` | `False` — a definite non-match wins; the unknown cannot rescue it | +| `all` | plus only `True` | `Unknown` | +| `any` | plus any `True` | `True` — a definite match wins | +| `any` | plus only `False` | `Unknown` | + +An `Unknown` at the rule root yields `EvaluationError` for that user. + +--- + +## PersonaDecisionResult + +Produced by `Resolve-UserPersona`. The engine's authoritative per-user output. + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `AccountObjectId` | `string` | Yes | | +| `UserPrincipalName` | `string` | Yes | | +| `Outcome` | `string` | Yes | `Matched`, `Unclassified`, or `EvaluationError` — exactly one (SC-001). | +| `MatchedRuleId` | `string` | When `Matched` | `$null` otherwise. | +| `CalculatedPersona` | `string` | Yes | The persona, `Unclassified`, or `$null` when `EvaluationError`. | +| `StoredPersona` | `string` | No | Copied from the `UserRecord`. | +| `Action` | `string` | Yes | `Unchanged`, `WouldUpdate`, `Updated`, `UpdateFailed`, or `Skipped`. | +| `EvaluationErrorReason` | `string` | When `EvaluationError` | | +| `RulesEvaluated` | `int` | Yes | Count until first match or exhaustion. | +| `DurationMs` | `int` | Yes | Per-user timing (NFR-002). | +| `ConditionTrace` | `object[]` | No | Populated only under `-Debug` (Principle V). | + +**State transitions for `Action`** + +```text +EvaluationError ─────────────────────────────► Skipped (FR-014, no write ever) +Calculated == Stored ────────────────────────► Unchanged +Calculated != Stored, preview mode ──────────► WouldUpdate (FR-017, no request issued) +Calculated != Stored, enforce, write ok ─────► Updated +Calculated != Stored, enforce, write fails ──► UpdateFailed +``` + +`Unclassified` follows the same comparison path as any other calculated value — it is a legitimate +value to write if the configuration approves it, and is reported distinctly either way. + +--- + +## Configuration + +The complete ordered decision process. Structure is normative in +[contracts/persona-engine.schema.json](contracts/persona-engine.schema.json). + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `configVersion` | `string` | Yes | Downgrade is a safety violation (VR-003). | +| `engine.targetAttribute` | `string` | Yes | MUST appear in `approvedWritableAttributes`. | +| `engine.approvedWritableAttributes` | `string[]` | Yes | MUST be non-empty. | +| `engine.maxConditionDepth` | `int` | No | Default 5, min 1, max 10 (RE-004). | +| `engine.summaryInterval` | `int` | No | Default 25; `0` suppresses interim summaries (FR-020). | +| `engine.defaultMembershipMode` | `string` | No | `direct` or `transitive`. | +| `dataSources.groups.enabled` | `bool` | Yes | Enabled group rules with this `false` is a safety violation (VR-003). | +| `dataSources.roles.enabled` | `bool` | Yes | | +| `personas` | `string[]` | Yes | Defined persona catalogue. `EvaluationError` MUST NOT appear. | +| `logging.*` | `object` | No | Destination and path (OTD-006). | +| `rules` | `BusinessRule[]` | Yes | MUST contain at least one enabled rule (VR-002). | + +**Derived at load**: `ConfigurationHash` (SHA-256 of the canonical file bytes) — recorded on every +run record (NFR-005). + +--- + +## ValidationFinding + +Produced by all four validation layers (VR-004). + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `Severity` | `string` | Yes | `Error`, `Warning`, `Information`. | +| `Code` | `string` | Yes | Stable finding code, e.g. `PE-SEM-012`. | +| `Location` | `string` | Yes | JSON path or rule ID. | +| `Description` | `string` | Yes | | +| `SuggestedResolution` | `string` | Yes | | +| `Layer` | `string` | Yes | `Syntax`, `Schema`, `Semantic`, `Safety`. | + +`Error` blocks execution and saving; `Warning` blocks only under `-TreatWarningsAsErrors` (VR-005). + +--- + +## RunRecord + +One per execution. See [contracts/audit-record.md](contracts/audit-record.md) for the serialized +form. + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `RunId` | `string` (GUID) | Yes | Supplied via `-CorrelationId` or generated. | +| `StartedUtc` / `CompletedUtc` | `datetime` | Yes | | +| `Mode` | `string` | Yes | `Preview` or `Enforce`. | +| `EngineVersion` | `string` | Yes | | +| `ConfigVersion` / `ConfigurationHash` | `string` | Yes | | +| `Processed` / `Matched` / `Unclassified` / `EvaluationError` | `int` | Yes | Reconciliation: `Processed = Matched + Unclassified + EvaluationError` (FR-021). | +| `Unchanged` / `WouldUpdate` / `Updated` / `UpdateFailed` | `int` | Yes | | +| `ExitCode` | `int` | Yes | Per the CLI contract. | + +A failed reconciliation MUST be logged as an engine defect, not merely reported. diff --git a/specs/001-persona-engine/plan.md b/specs/001-persona-engine/plan.md new file mode 100644 index 0000000..0488a18 --- /dev/null +++ b/specs/001-persona-engine/plan.md @@ -0,0 +1,202 @@ +# Implementation Plan: Persona Engine + +**Branch**: `main` (feature directory `001-persona-engine`) | **Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `/specs/001-persona-engine/spec.md` + +## Summary + +Deterministic, configuration-driven persona classification for Microsoft Entra ID user objects. The +engine enumerates in-scope users, evaluates each against an ordered JSON rule set, assigns exactly +one persona, and updates a single approved directory attribute only when the calculated value +differs from the stored value. + +**Technical approach**: a PowerShell 7 module (`PersonaEngine`) whose rule engine is a pure function +over normalized records, with Graph access, persistence, and presentation isolated behind adapters. +Directory access uses `Invoke-MgGraphRequest` (direct REST over the `Microsoft.Graph.Authentication` +module) so the write body is explicitly constructed and test-assertable. The persona value is stored +in a **directory (schema) extension** on the user object, consumed downstream by dynamic membership +groups. Configuration is validated with the built-in `Test-Json -SchemaFile` against a draft-07 +schema. See [research.md](research.md) for the decisions and their rationale. + +## Delivery Staging + +**Constraint (2026-08-20)**: no Azure Automation account is available. All development and testing +proceeds on a local PowerShell 7 workstation using **user accounts and delegated authentication**. + +This changes sequencing, not architecture. The adapter boundaries that make the engine testable +offline (Principle IV) are the same boundaries that make the Automation runtime a late, additive +step — so the deferral costs nothing structurally. + +| Stage | Environment | Auth | Status | +| --- | --- | --- | --- | +| **A1** — offline | Local PS7, synthetic fixtures | None | Available now. Covers the rule engine, all four validation layers, and the safety suites. No tenant, no network. | +| **A2** — connected read-only | Local PS7, tenant | Delegated (`Connect-MgGraph -Scopes`) | Available now. Covers enumeration, membership, roles, normalization, presentation, reconciliation, and `-WhatIf`. | +| **A3** — connected write | Local PS7, **test accounts only** | Delegated | Gated on V-4. Test accounts only — the baseline's read-only-during-early-development assumption still stands for the general population. | +| **B** — Automation | Azure Automation PS7 | Managed identity | **Deferred.** Additive: a second authentication adapter, a runbook wrapper, and a schedule. | + +**Consequences, stated plainly:** + +1. **v1 cannot be declared complete while Stage B is deferred.** The Definition of Done requires an + Azure Automation PowerShell 7 run to pass. Deferring it does not violate the constitution — it + defers *completion*. The correct milestone to claim in the meantime is "Stage A complete", not + "v1 done". Do not quietly redefine done. +2. **`Connect-PersonaGraphManagedIdentity` will ship unexercised.** `Connect-MgGraph -Identity` + cannot run on a workstation. The mitigation is to keep the authentication adapter's surface + minimal — one function, returning the same handle shape as the interactive path, with no + engine-visible difference — so that the untested code is a few lines rather than a subsystem. +3. **Delegated authorization behaves differently from application permissions.** Effective access is + the intersection of the requested scope and the signed-in user's directory roles. This makes V-3 + *more* meaningful when run as an ordinary user account, and meaningless when run as a Global + Administrator. See research.md V-3. +4. **Automation-specific risk stays open**: runtime PowerShell version, module availability, and + sandbox behaviour are unverified until Stage B. The one-module dependency decision (OTD-004) is + what keeps that risk small. + +## Technical Context + +**Language/Version**: PowerShell 7.4 locally. The Automation runtime version is unverified and +remains so until Stage B (verification item V-5b in research.md). Avoid any construct newer than +PS 7.2 so the eventual Automation runtime is not a constraint discovered late. + +**Primary Dependencies**: `Microsoft.Graph.Authentication` (token acquisition and +`Invoke-MgGraphRequest`) is the only runtime dependency. `Pester` 5.x and `PSScriptAnalyzer` are +development/CI-only. No full Microsoft Graph SDK dependency — see OTD-004. Module availability in +the Automation sandbox is unverified until Stage B. + +**Storage**: JSON configuration file on disk; no database. Persona values live in the directory +itself. Audit output is newline-delimited JSON to a file plus the Automation output stream. + +**Testing**: Pester 5.x. Unit and rule-engine suites run fully offline against synthetic fixtures +(SC-008); integration suites require a read-only tenant identity, satisfied in Stage A2 by a +delegated connection; safety suites assert zero writes under `-WhatIf` (SC-004) and single-attribute +write bodies (SC-005). The safety suites mock the write adapter, so they are fully available now and +are **not** gated on Stage A3 or B — the zero-write guarantee is proven against the adapter contract, +not against a tenant. + +**Target Platform**: PowerShell 7 on a local workstation (Stages A1–A3). The Azure Automation +PowerShell 7 runtime remains the eventual production target but is out of the current stage. + +**Project Type**: PowerShell module plus two CLI entry-point scripts. + +**Performance Goals**: None fixed. NFR-002 explicitly defers a hard target until representative +tenant testing. The plan requires per-user and total duration to be recorded from the first +connected run so a baseline exists before any target is set. + +**Constraints**: Rule engine must be free of Graph, authentication, Automation, and console +dependencies (Principle IV). `-WhatIf` must issue zero writes (Principle III). Write payloads carry +exactly one attribute (Principle III). All artifacts sanitized to placeholders (Principle V, +SC-013). + +**Scale/Scope**: In-scope population size is tenant-specific and unknown at planning time. Full +enumeration with pagination is the v1 processing model (OTD-008); delta processing is deferred. The +read-only pilot establishes the population size and run duration baseline. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.* + +Evaluated against [constitution.md](../../.specify/memory/constitution.md) v1.0.0. + +| Gate | Principle | Pre-research | Post-design | Notes | +| --- | --- | --- | --- | --- | +| Deterministic, single-persona result | I (NON-NEGOTIABLE) | PASS | PASS | Ordered priority evaluation, first-match stop, no clock/random/unordered inputs in the engine. Rejecting `extensionAttributeN` (research OTD-001) removes a population-dependent failure mode that would have broken determinism across a hybrid population. | +| Configuration-driven rules | II | PASS | PASS | No persona, priority, group ID, role ID, or attribute name in source. Four-layer validation ordering preserved in the config contract. | +| Fail-safe, idempotent persistence | III (NON-NEGOTIABLE) | PASS | PASS | `EvaluationError` preserves stored value; `SupportsShouldProcess` on both write paths; changed-values-only comparison; single-attribute body construction isolated in one function. | +| Pure rule engine, offline-tested first | IV | PASS | PASS | Rule engine depends only on normalized records. Build order enforced in the task sequencing below; persistence adapter is last. | +| Explainable, sanitized observability | V | PASS | PASS | Run ID, UPN, Account Object ID, and matched rule ID on every user event; condition-value tracing gated behind `-Debug`; placeholders only in all artifacts. | + +**Security and least-privilege constraints**: PASS with a mandatory condition. Research OTD-003 +establishes that Microsoft Graph application permissions **cannot** be scoped to an individual user +attribute for the selected mechanism. The constitution anticipates exactly this outcome and makes +the compensating controls mandatory rather than optional; they are carried into the design as +testable requirements (see the persistence contract). This is a documented and approved-by-design +condition, not a constitution violation. Security approval of the compensating controls is a gate +before enforcement, per the Development Workflow section. + +**Automation deferral (Stage B)**: PASS. Every principle is satisfiable on a local workstation — +determinism, configuration-driven rules, fail-safe persistence, engine purity, and observability are +all properties of the code, not of the hosting environment. Two constitution items are *deferred, not +waived*: the Definition of Done's Azure Automation PowerShell 7 run, and the release-pipeline stages +that deploy to it. Both are recorded in the Delivery Staging table and gate the v1 completion claim. + +**Result**: no unjustified violations. Complexity Tracking is empty. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-persona-engine/ +├── plan.md # This file +├── research.md # Phase 0 output — OTD-001..010 decisions +├── data-model.md # Phase 1 output — entity contracts +├── quickstart.md # Phase 1 output — validation scenarios +├── contracts/ # Phase 1 output +│ ├── persona-engine.schema.json # Configuration JSON Schema (draft-07) +│ ├── cli-invoke-persona-engine.md # Engine CLI contract +│ ├── cli-edit-persona-engine-config.md # Editor CLI contract +│ ├── graph-data-provider.md # Directory read/write contract +│ └── audit-record.md # Structured log record contracts +└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here) +``` + +### Source Code (repository root) + +```text +PersonaEngine.psd1 # Module manifest +PersonaEngine.psm1 # Module loader +Invoke-PersonaEngine.ps1 # Engine entry point (CmdletBinding, SupportsShouldProcess) +Edit-PersonaEngineConfig.ps1 # Configuration validation / editor entry point + +config/ +├── persona-engine.example.json # Placeholder-only example +└── persona-engine.schema.json # Shipped schema (from contracts/) + +src/ +├── Configuration/ # Import-PersonaConfiguration, Test-PersonaConfiguration, Resolve-TargetAttribute +├── Authentication/ # Connect-PersonaGraphInteractive, Connect-PersonaGraphManagedIdentity +├── DataProviders/ # Get-PersonaUsers, Get-PersonaGroupMembership, Get-PersonaDirectoryRoles +├── Normalization/ # ConvertTo-PersonaUserRecord, ConvertTo-PersonaMembershipRecord +├── RuleEngine/ # Test-PersonaCondition, Test-PersonaConditionGroup, Test-PersonaRule, +│ # Resolve-UserPersona <-- no Graph/auth/console dependency +├── Persistence/ # Compare-PersonaValue, New-PersonaWriteBody, Set-UserPersonaAttribute +├── Presentation/ # Write-UserPersonaResult, Write-PersonaSummary +└── Audit/ # New-PersonaAuditRecord, Export-PersonaRunReport + +tests/ +├── Unit/ # Per-function offline tests +├── RuleEngine/ # Rule evaluation matrix against synthetic fixtures +├── Configuration/ # Schema, semantic (VR-002), and safety (VR-003) validation +├── Integration/ # Read-only tenant tests +├── Safety/ # SC-004 zero-write, SC-005 single-attribute-body assertions +└── TestData/ # Obviously fictional synthetic users, memberships, configs + +docs/ # Architecture, BusinessRules, ConfigurationReference, Logging, + # SecurityModel, OperationsRunbook +pipelines/ # validate.yml, test.yml, release.yml +``` + +**Structure Decision**: single PowerShell module with two CLI entry points, matching the layout +already published in [README.md](../../README.md). The directory split is the enforcement mechanism +for Principle IV — `src/RuleEngine/` may import nothing from `src/Authentication/`, +`src/DataProviders/`, `src/Persistence/`, or `src/Presentation/`, and a CI check asserts this. + +### Build order (Principle IV, non-negotiable sequencing) + +1. Normalized record contracts and synthetic fixtures. +2. Pure rule engine + offline Pester suite (no tenant connectivity). +3. Configuration import, four-layer validation, and non-interactive pipeline mode. +4. `Edit-PersonaEngineConfig.ps1` interactive editor and synthetic rule testing. +5. Graph authentication and **read** adapters; normalization wiring. +6. Presentation, summaries, reconciliation, and structured audit output. +7. Persistence adapter **last**, with `ShouldProcess` and the zero-write/single-attribute suites. + +Steps 1–4 are Stage A1 (offline). Steps 5–6 are Stage A2 (delegated read-only). Step 7 is built and +fully unit-tested in Stage A1/A2 against a mocked adapter, and only *exercised against the tenant* in +Stage A3, behind V-4. Adding the managed-identity adapter and runbook wrapper is Stage B and touches +nothing in steps 1–7 — that is the test of whether the boundaries were drawn correctly. + +## Complexity Tracking + +> No Constitution Check violations. This section is intentionally empty. diff --git a/specs/001-persona-engine/quickstart.md b/specs/001-persona-engine/quickstart.md new file mode 100644 index 0000000..455de68 --- /dev/null +++ b/specs/001-persona-engine/quickstart.md @@ -0,0 +1,213 @@ +# Quickstart: Persona Engine Validation + +**Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md) + +Runnable scenarios that prove the feature works. Ordered by the build sequence in +[plan.md](plan.md) — each stage is validatable before the next exists. Scenarios 1–3 require no +tenant, no credentials, and no network. + +Structural details live in [data-model.md](data-model.md) and [contracts/](contracts/); this guide +does not repeat them. + +## Prerequisites + +**Current constraint**: no Azure Automation account. Everything runs locally on PowerShell 7 with +user accounts and delegated authentication. Scenario 5 is deferred; Scenarios 1–4 and 6 are all +available now. + +| Scenario | Stage | Requirement | +| --- | --- | --- | +| 1–3 (offline) | A1 | PowerShell 7.4, Pester 5.x, PSScriptAnalyzer. No tenant, no network. | +| 4 (read-only) | A2 | An app registration with admin consent for the three delegated scopes, and a **non-privileged** user account to sign in with. Closes V-1 (read), V-3. | +| 5 (automation) | B | **Deferred** — no Automation account available. Closes V-3b, V-5b when it lands. | +| 6 (enforcement) | A3 | Delegated `User.ReadWrite.All`, **written security sign-off (V-4)**, reviewed `-WhatIf` evidence, and **purpose-created test accounts** as the write targets. | + +### Local connection + +```powershell +Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory' +``` + +Sign in as an ordinary user account, not a Global Administrator — see the caution in Scenario 4. + +--- + +## Scenario 1 — Rule engine determinism, offline + +Proves SC-001, SC-003, SC-008 and the `Unknown` propagation table in +[data-model.md](data-model.md). + +```bash +pwsh -NoProfile -Command "Invoke-Pester ./tests/RuleEngine -Output Detailed" +``` + +**Expected**: all pass with no network access. Specifically: + +- Every synthetic user yields exactly one outcome. +- Shuffling fixture order changes nothing. +- A rule matching at priority 10 wins over one matching at 20, and evaluation stops. +- A `MembershipRecord` with `RetrievalSucceeded = $false` yields `EvaluationError`, never a + non-match. +- Depth beyond `maxConditionDepth` is rejected rather than silently truncated. + +**Disconnect the network and re-run.** Identical results, or SC-008 is not met. + +--- + +## Scenario 2 — Configuration validation + +Proves SC-009, SC-010 and the four-layer ordering. + +```bash +pwsh -NoProfile -File ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive +``` + +**Expected**: exit code `0`, no findings. + +Then run the invalid-configuration corpus in `tests/TestData/InvalidConfigs/` — one file per VR-002 +and VR-003 condition: + +```bash +pwsh -NoProfile -Command "Invoke-Pester ./tests/Configuration -Output Detailed" +``` + +**Expected**: each file produces its documented finding code, severity, and location; blocking +findings return a non-zero exit code with **no prompt and no hang** (SC-010). + +--- + +## Scenario 3 — Safety invariants + +Proves SC-004 and SC-005 with the write adapter mocked. This suite is the reason the persistence +adapter is built last. + +```bash +pwsh -NoProfile -Command "Invoke-Pester ./tests/Safety -Output Detailed" +``` + +**Expected**: + +- Full synthetic population under `-WhatIf`: write adapter call count is exactly `0` — asserted, not + inspected (SC-004). +- Every captured request body has exactly one key, equal to `engine.targetAttribute` (SC-005). +- `New-PersonaWriteBody` throws for any other attribute name and for an attribute absent from + `approvedWritableAttributes`. +- A `-Debug` run **without** `-WhatIf` still reaches the write path — `-Debug` is not a safety + control. + +--- + +## Scenario 4 — Read-only tenant preview (User Story 1) + +The first connected run. Uses a read-only identity, so it is safe by construction rather than by +correct behaviour. + +> **Sign in as a non-privileged account.** V-3 asks whether the three scopes are *sufficient*. +> A Global Administrator answers yes regardless — the scope narrows the token, but the account's +> directory roles still grant broad read access, so the run succeeds whether or not the permission +> set is correct. Running this as GA produces a green result that means nothing. + +```bash +pwsh -NoProfile -File ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf -Verbose +``` + +**Expected**: + +- A result line appears for every in-scope user, visible before the next user is processed (SC-012). +- Differences report as `WouldUpdate` with stored value, calculated value, and matched rule ID. +- Interim summaries at the configured interval; a final summary always; reconciliation passes at + every summary (SC-007). +- Zero write requests — confirm independently in the Entra sign-in and audit logs, not only from + console output. + +Single-user check first, before the full population: + +```bash +pwsh -NoProfile -File ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId -WhatIf +``` + +**Also close V-1 here**: run the single-user check against a cloud-only user, a currently-synced +user, and a formerly-synced user. Confirm the persona extension **reads** on all three. The write +half of V-1 is closed in Scenario 6. + +**Idempotence check** (SC-002): run twice unchanged. The second run reports the same counts and zero +additional proposed changes. + +--- + +## Scenario 5 — Azure Automation PowerShell 7 *(deferred — Stage B)* + +**Not runnable in the current stage.** No Automation account is available. Recorded here so it is +not lost, and so the Stage B entry cost stays visible. + +Proves NFR-001, NFR-008 and closes V-3b and V-5b. + +1. Import the module and publish the runbook with the schedule **disabled**. +2. Run the runbook with `-WhatIf` using the managed identity. +3. Record the runtime's exact PowerShell version. +4. Confirm `Test-Json -SchemaFile` behaves as observed locally in V-5a — its error-reporting + behaviour varies by version, and the layer-2 wrapper depends on it (OTD-005). **Run this first**; + it is the cheapest item with the highest chance of surprising you. +5. Confirm the three read scopes work as *application* permissions on the managed identity (V-3b). + +**Expected**: the run completes with only `Microsoft.Graph.Authentication` imported, and output +matches the equivalent local `-WhatIf` run. + +**Until this scenario passes, v1 is not complete.** The Definition of Done requires an Automation +PowerShell 7 run. Stage A completion is a real milestone and worth claiming — but it is not v1. + +--- + +## Scenario 6 — Enforcement (User Story 8) + +**Gated.** Do not run until all of these hold: + +- [ ] V-4 security sign-off on the OTD-003 compensating controls, in writing +- [ ] `-WhatIf` impact evidence from Scenario 4 reviewed and approved +- [ ] Scenarios 1–5 passing +- [ ] Kill switch and rollback procedure documented +- [ ] OTD-001 – OTD-005 closed + +```bash +pwsh -NoProfile -File ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json +``` + +**Expected**: + +- Only changed values are written; unchanged users produce no request (SC-002). +- Every write body contains exactly one attribute (SC-005). +- Every `Updated` audit record carries `previousValue` — without it, rollback is impossible + retroactively (OTD-010). +- `EvaluationError` users are skipped with their stored persona intact (FR-014). + +**Closes V-1 (write half)** and **V-2**: confirm the dynamic membership group built on the persona +extension populates, and that a Conditional Access policy assigned to that group applies. + +--- + +## Verification item coverage + +| Item | Closed by | Available now? | +| --- | --- | --- | +| V-1 | Scenario 4 (read) + Scenario 6 (write) | Yes | +| V-2 | Scenario 6 | Yes | +| V-3 | Scenario 4, as a non-privileged account | Yes | +| V-3b | Scenario 5 | **No — Stage B** | +| V-4 | Out-of-band security review — **gate on Scenario 6** | Yes (a conversation, not a tenant) | +| V-4a | Investigation; no scenario | Yes | +| V-5a | Scenario 2, behaviour pinned in a unit test | Yes | +| V-5b | Scenario 5 | **No — Stage B** | + +## Exit code check (SC-011) + +Every documented exit code must be reachable. Cover them deliberately rather than incidentally: + +| Code | How to trigger | +| --- | --- | +| `0` | Scenario 4 | +| `1` | Any invalid configuration from Scenario 2 | +| `2` | Run with an unauthorized or expired identity | +| `3` | Fault injection on enumeration | +| `4` | Fault injection on a required data provider | +| `5` | Fault injection on the counter path (reconciliation defect) | +| `6` | Fault injection on an unhandled engine path | diff --git a/specs/001-persona-engine/research.md b/specs/001-persona-engine/research.md new file mode 100644 index 0000000..ec441ea --- /dev/null +++ b/specs/001-persona-engine/research.md @@ -0,0 +1,322 @@ +# Phase 0 Research: Persona Engine + +**Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md) + +Resolves the Clarification Register in [spec.md](spec.md). OTD-001 through OTD-005 are +**persistence-blocking** and are decided here. OTD-006 through OTD-010 receive provisional decisions +sufficient to plan implementation. + +Every decision below that depends on a tenant-specific fact carries a **verification item (V-n)**. +Per the constitution, attribute-level write authorization "MUST be verified, never assumed" — the +decisions state what the product documentation says, and the V-items state what the team must prove +in its own tenant before enforcement is enabled. + +--- + +## OTD-001 — Persona attribute mechanism + +**Decision**: Store the persona in a **directory (schema) extension** single-valued string property +on the `user` resource, registered against a dedicated application registration in the tenant. The +property is referenced as `extension__`. + +**Rationale**: + +- **Writable via Graph for cloud-mastered users** with an ordinary `PATCH /users/{id}`, and readable + via `$select` on the extension property name. +- **Consumable by Conditional Access.** CA assigns policy by user and group, not by user attribute, + so the consumption path is: persona attribute → dynamic membership group rule → CA assignment. + Dynamic membership rules support custom extension properties in the form + `user.extension__`, provided the property is **single-valued** and the + extension belongs to an application in the tenant. Both conditions hold here. +- **Not population-dependent.** Unlike `extensionAttributeN`, it does not fail on accounts with an + external origin (see rejected alternative A). + +**Alternatives considered**: + +| Alternative | Verdict | Reason | +| --- | --- | --- | +| **A. `onPremisesExtensionAttributes.extensionAttributeN`** (extensionAttribute1–15) | **Rejected** | Updates via Graph succeed only for objects that have always been mastered in Entra. Accounts that were ever synced from on-premises AD — or that originated in Exchange Online — fail with *"Unable to update the specified properties for objects that have originated within an external service."* In a hybrid or formerly-hybrid tenant this produces write failures determined by an account's history rather than by its rule match, which is a direct hazard to Principle I (deterministic) and Principle III (fail-safe). Remediation would require an Exchange Online PowerShell write path — a second persistence mechanism and a second permission surface. | +| **B. Directory (schema) extension** | **Selected** | See rationale above. | +| **C. Custom security attribute** | **Rejected as primary; retained as the security-first alternative** | This is the *only* mechanism offering genuine attribute-scoped authorization (see OTD-003), which makes it attractive. But custom security attributes are **not exposed to the dynamic group evaluation engine** and cannot be referenced in dynamic membership rules, so they cannot drive the CA consumption path that motivates the persona value. They are also not returned by default and require a separate permission and role. Choosing C trades the feature's primary downstream use for a stronger write boundary. | + +**Consequences**: the compensating controls in OTD-003 become mandatory, because alternative B has +no attribute-scoped authorization. + +**Verification items**: + +- **V-1** — Register the extension application and property in a non-production tenant or an + isolated attribute name; confirm read via `$select` and write via `PATCH` for: a cloud-only user, a + currently-synced user, and a formerly-synced user. Directory extensions are not on-premises-mastered + properties, so all three are expected to succeed — but this must be proven, not assumed, because the + whole reason A was rejected is an origin-dependent write restriction. +- **V-2** — Confirm a dynamic membership group rule referencing the extension property evaluates and + populates as expected, and that a CA policy assigned to that group applies. + +--- + +## OTD-002 — Least-privilege Microsoft Graph permissions + +**Decision**: application (managed identity) permissions, granted only as each capability is enabled: + +| Capability | Permission | Notes | +| --- | --- | --- | +| Read users and the persona extension | `User.Read.All` | Extension property returned via `$select`. | +| Read group membership (`memberOf` conditions) | `GroupMember.Read.All` | Sufficient for `/users/{id}/memberOf` and `/transitiveMemberOf`. Narrower than `Group.Read.All`. | +| Read directory role assignments | `RoleManagement.Read.Directory` | For role-based conditions. | +| Write the persona attribute (enforcement only) | `User.ReadWrite.All` | **Supersedes** `User.Read.All`; grant only to the enforcement identity, and only after security approval of the OTD-003 controls. | + +Application permissions are the Stage B (Automation, managed identity) form. Stage A uses the same +four as **delegated** scopes on an interactive connection. + +**Rationale**: each permission maps to exactly one enabled capability, so a tenant that disables +group or role conditions grants strictly less. `Directory.Read.All` is deliberately **rejected** — it +is materially broader than the three read permissions combined and would grant visibility well +outside the enumerated data sources. + +**Local (Stage A) equivalent — delegated scopes.** With no Automation account, the same three +capabilities are requested as delegated scopes on an interactive connection: + +```powershell +Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory' +``` + +These delegated scopes require one-time admin consent for the app registration used locally; after +that, an ordinary user account can hold them. **Effective access is the intersection of the granted +scope and the signed-in user's directory roles** — which is precisely why V-3 must be run as a +non-privileged account (see below). + +**Alternatives considered**: `Directory.ReadWrite.All` (rejected — grossly over-broad); +`User.ManageIdentities.All` (not applicable); delegated-only operation (rejected — unattended +Automation requires application permissions; delegated remains the local development path per FR-003). + +**Verification items**: + +- **V-3** — During the read-only pilot, grant only the three read permissions and confirm every + enabled rule evaluates without a permission error. Any `EvaluationError` attributable to + authorization identifies a missing-but-required permission and must be resolved before enforcement. + **Stage A method**: connect with the three delegated scopes above while signed in as an **ordinary, + non-privileged user account**. Running this as a Global Administrator invalidates the test — the + scope narrows the token, but the account's directory roles still grant broad read access, so the + run would succeed regardless of whether the three permissions are actually sufficient. This item is + closeable now and does not need Automation. +- **V-3b** *(Stage B, deferred)* — Repeat as **application** permissions on the managed identity. + Delegated and application authorization are evaluated differently, so a passing V-3 is strong + evidence but not proof for the unattended path. + +--- + +## OTD-003 — Can write authorization be restricted to the single target attribute? + +**Decision**: **No — not for the mechanism selected in OTD-001.** Microsoft Graph application +permissions have no per-property scope: `User.ReadWrite.All` authorizes writes to every writable +property of every user in the tenant. There is no supported way to grant "write only +`extension__`". + +Therefore the compensating controls are **mandatory and testable**, not advisory: + +1. `Set-UserPersonaAttribute` accepts only the configured target attribute; any other name is a + terminating error. +2. The target attribute MUST appear in `approvedWritableAttributes`; validation rejects all others + (VR-002). +3. A single dedicated function, `New-PersonaWriteBody`, constructs the request body, and it emits a + hashtable containing exactly one key. +4. Unit and integration tests assert on the **request body**, not on observed behaviour (SC-005). +5. Code owners gate every change to persistence, `approvedWritableAttributes`, and the target + attribute. +6. Directory audit logs are monitored for property writes by the engine's service principal other + than the target attribute. + +**The one mechanism that *would* satisfy attribute-level authorization**: custom security attributes +(OTD-001 alternative C). Their assignment is governed by attribute sets: a principal is granted +`Attribute Assignment Administrator` **scoped to a specific attribute set**, plus the separate +`CustomSecAttributeAssignment.ReadWrite.All` permission — and notably, Global Administrator does not +hold this access by default. That is a real, enforced boundary rather than a compensating control. +It was rejected only because custom security attributes cannot feed dynamic groups (OTD-001). + +**Stage A makes these controls *more* important, not less.** Local delegated writes run as the +signed-in operator, whose directory roles are typically far broader than the eventual service +principal's. During Stage A3 the compensating controls are the **only** thing standing between the +engine and an unintended property write, because the authorization boundary is effectively "whatever +the operator can do." Two additional Stage A rules follow: + +- Stage A3 writes target **purpose-created test accounts only**. The baseline's read-only-during- + early-development assumption continues to hold for the general population. +- Never sign in with a standing privileged account for a write run. Elevate for the session, and + expect the directory audit log to attribute the write to the operator rather than to a service + principal — which is exactly why Stage A3 evidence does not substitute for Stage B evidence. + +**This trade-off requires explicit security sign-off.** The decision record for security review is: +*accept tenant-wide user-write permission plus six compensating controls, in exchange for a persona +value that Conditional Access can actually consume.* + +**Verification items**: + +- **V-4** — Confirm with the security owner, in writing, that the compensating-control set is + accepted in place of attribute-scoped authorization. This is a **gate before enforcement**, per the + constitution's Definition of Done. +- **V-4a** — Investigate whether an Administrative Unit-scoped role assignment can narrow the + enforcement identity's write scope to a subset of the user population. This narrows *which users*, + never *which attribute*, so it is a partial mitigation at best; do not present it as closing OTD-003. + +--- + +## OTD-004 — Directory access approach + +**Decision**: **Direct REST via `Invoke-MgGraphRequest`**, with `Microsoft.Graph.Authentication` as +the only runtime module. No resource-specific SDK modules (`Microsoft.Graph.Users`, +`Microsoft.Graph.Groups`, etc.). + +**Rationale**: + +- **Explicit request bodies.** The constitution requires the write payload to contain exactly one + attribute and requires tests to inspect that body. `Invoke-MgGraphRequest -Method PATCH -Body` makes + the body a first-class, assertable value. SDK cmdlets construct bodies internally from parameter + binding, which makes SC-005 far harder to prove. +- **Dynamic extension properties.** The persona property name is configuration-driven and unknown at + authoring time. Passing an arbitrary `extension__` key is natural in a hashtable body + and awkward through typed cmdlet parameters. +- **Automation footprint.** One small module to import instead of the SDK's large module set, which + reduces cold-start time, import failures, and version drift in the Automation PS7 environment + (NFR-008). +- Managed-identity and interactive token acquisition are still handled by `Connect-MgGraph`, so + nothing is reimplemented. + +**Alternatives considered**: full Graph SDK cmdlets (rejected — heavy, opaque bodies, version drift); +raw `Invoke-RestMethod` with hand-rolled token acquisition (rejected — reimplements managed-identity +token handling and refresh for no benefit). + +**Consequence**: pagination (`@odata.nextLink`), throttling, and error shaping are the engine's +responsibility. They are handled once, in the data-provider layer — see OTD-007 and +[contracts/graph-data-provider.md](contracts/graph-data-provider.md). + +--- + +## OTD-005 — JSON Schema validation approach + +**Decision**: the built-in **`Test-Json -SchemaFile`** cmdlet, with the schema authored to +**JSON Schema draft-07**. + +**Rationale**: `Test-Json` ships with PowerShell 6.1+ and therefore needs no module import in either +the local or Automation PS7 environment — the strongest possible answer to NFR-008 and OTD-005's +"compatible locally and in automation" requirement. Its underlying validator is the Newtonsoft JSON +Schema implementation, whose reliable coverage is draft-04/06/07; **draft 2019-09 and 2020-12 +constructs must not be used** in the schema. + +**Implementation notes**: + +- `Test-Json` signals failure by writing errors rather than simply returning `$false` in several + PowerShell versions. `Test-PersonaConfiguration` MUST wrap it with + `-ErrorAction SilentlyContinue -ErrorVariable` and translate the collected errors into + `Validation Finding` objects (VR-004), so that layer 2 produces structured findings like every other + layer. +- Schema validation is layer 2 of four. It cannot express the semantic rules in VR-002 (duplicate + priorities, depth limits, cross-field constraints), which is why layers 3 and 4 exist as PowerShell + checks. Do not attempt to push semantic rules into the schema. + +**Alternatives considered**: bundling a third-party schema library (rejected — an extra Automation +dependency for capability the platform already provides); hand-written structural validation only +(rejected — VR-001 mandates a schema layer, and a schema is also the editor's contract). + +**Verification items**: + +- **V-5a** *(Stage A, closeable now)* — Execute `Test-Json -SchemaFile` against the draft-07 schema + on the local PowerShell 7.4 workstation. Record the exact behaviour on failure: whether it returns + `$false`, writes a non-terminating error, or throws. The layer-2 wrapper is built against **this + observed behaviour**, and the observation is pinned in a unit test so a runtime change is caught + rather than discovered. +- **V-5b** *(Stage B, deferred)* — Repeat inside the Azure Automation PowerShell 7 runtime and record + its exact PowerShell version. If the behaviour differs from V-5a, the wrapper handles both — do not + assume parity. This is the single highest-value item to run on day one of Stage B. + +--- + +## Non-blocking decisions (OTD-006 – OTD-010) + +These do not block persistence. They are decided far enough to implement v1 without rework. + +### OTD-006 — Structured log destination and transport + +**Decision**: newline-delimited JSON (one audit record per line) written to a configurable file path, +plus the Automation output stream. Emission goes through a single `Write-PersonaAuditRecord` sink +function so a Log Analytics or Event Hub transport can be added later without touching call sites. +Log Analytics ingestion is **deferred**, not designed out. + +**Rationale**: NDJSON is append-safe, streamable, trivially ingestible later, and needs no +dependency. The sink indirection is what keeps the deferral cheap. + +### OTD-007 — Retry policy + +**Decision**: bounded exponential backoff with full jitter in the data-provider layer. + +- **Retryable**: HTTP 429, 500, 502, 503, 504, and transport-level timeouts. +- **Never retried**: 400, 401, 403, 404, 409 — these are configuration, authorization, or logic + defects and retrying masks them. +- **`Retry-After` honoured** whenever present; it overrides the computed backoff. +- **Max 5 attempts**, base delay 1s, exponential with full jitter, per-delay cap 60s. +- Every retry logs attempt number, status code, and delay. Exhausting retries on **required** data + yields `EvaluationError` for the affected user (FR-013) — never a silent non-match. + +**Rationale**: satisfies NFR-003 while keeping Principle III intact: the failure mode of exhausted +retries is preserve-and-report, not assume-false. + +### OTD-008 — Full versus incremental processing + +**Decision**: v1 performs **full enumeration** with pagination. Delta query is deferred and out of +scope for v1 (already recorded in spec Out of Scope). Revisit only when pilot timings justify it. + +### OTD-009 — Schedule and concurrency lock + +**Decision**: hourly is the candidate cadence, deployment-configurable, and the schedule ships +**disabled** (per the release pipeline). Concurrency control for v1 is the Automation account's own +job behaviour plus a run-start check that fails fast if another job for the same runbook is running. +A durable distributed lock is deferred. + +**Stage A status**: not applicable. With no Automation account there is no schedule and no +concurrency surface — runs are manual and serial by construction. Nothing here needs building until +Stage B, and nothing in Stages A1–A3 depends on it. + +**Rationale**: overlapping runs are idempotent by construction (Principle III) — the harm is wasted +Graph quota and confusing audit output, not incorrect data — so a lightweight check is proportionate +for v1. + +### OTD-010 — Rollback + +**Decision**: rollback is driven by the audit trail. Every write record carries the **previous +value**, the calculated value, the matched rule ID, and the run ID (NFR-005), which makes a run +reversible by replaying prior values from its audit output. A replay utility is deferred to v1.1; the +**record shape that makes it possible is v1** and is fixed in +[contracts/audit-record.md](contracts/audit-record.md). + +**Rationale**: the cheap, decisive part is capturing the previous value at write time. Miss that in +v1 and rollback becomes impossible retroactively. + +--- + +## Verification checklist + +| ID | Item | Stage | Blocks | +| --- | --- | --- | --- | +| V-1 | Extension property read/write proven for cloud-only, synced, and formerly-synced users | A2 (read) / A3 (write) | Persistence implementation | +| V-2 | Dynamic group rule on the extension property populates; CA assignment applies | A3 | Downstream value of the feature | +| V-3 | Delegated pilot completes with only the three scopes, signed in as a **non-privileged** account | A2 | Enforcement | +| V-3b | Same, as application permissions on the managed identity | **B — deferred** | Unattended enforcement | +| V-4 | Written security sign-off on compensating controls in place of attribute-scoped write | Out-of-band | **Enforcement (constitution gate)** | +| V-4a | Administrative Unit scoping investigated as partial mitigation | Any | Nothing (informational) | +| V-5a | `Test-Json -SchemaFile` failure behaviour observed and pinned locally | A1 | Layer-2 wrapper implementation | +| V-5b | Same, confirmed in the Automation runtime, with PS version recorded | **B — deferred** | Configuration validation sign-off | + +**Closeable in the current stage**: V-1 (read half), V-3, V-4a, V-5a — and V-4, which needs a +conversation rather than a tenant. **Deferred with Automation**: V-3b, V-5b. + +## Sources + +- [Manage rules for dynamic membership groups in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity/users/groups-dynamic-membership) +- [Creating dynamic groups using custom security attributes](https://learn.microsoft.com/en-us/answers/questions/5763638/creating-dynamic-groups-using-custom-security-attr) +- [Conditional Access: Users, Groups, Agents, and Workload Identities](https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-users-groups) +- [onPremisesExtensionAttributes resource type](https://learn.microsoft.com/en-us/graph/api/resources/onpremisesextensionattributes?view=graph-rest-1.0) +- [Update user — Microsoft Graph v1.0](https://learn.microsoft.com/en-us/graph/api/user-update?view=graph-rest-1.0) +- [Why is it not possible to update extension attributes of former hybrid users via Graph API?](https://learn.microsoft.com/en-us/answers/questions/1850101/why-is-it-not-possible-to-update-extension-attribu) +- [Add custom data to resources using extensions](https://learn.microsoft.com/en-us/graph/extensibility-overview) +- [What are custom security attributes in Microsoft Entra ID?](https://learn.microsoft.com/en-us/entra/fundamentals/custom-security-attributes-overview) +- [Manage access to custom security attributes in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/fundamentals/custom-security-attributes-manage) +- [Assign, update, list, or remove custom security attributes for a user](https://learn.microsoft.com/en-us/entra/identity/users/users-custom-security-attributes) diff --git a/specs/001-persona-engine/spec.md b/specs/001-persona-engine/spec.md index 3296b2f..af06e7b 100644 --- a/specs/001-persona-engine/spec.md +++ b/specs/001-persona-engine/spec.md @@ -310,20 +310,22 @@ Candidate business classifications — **not** hard-coded engine behaviour: `Gue Open items carried from the baseline (§24). These are implementation research items to be resolved in `plan.md` / `research.md` or an ADR — **not** unanswered business requirements. OTD-001 through OTD-005 must be closed before persistence implementation. +**Updated 2026-08-20 (T114): OTD-001 through OTD-007 and OTD-010 are resolved in [research.md](research.md). The persistence gate is lifted for Stage A3 test accounts, and remains closed for the general population until V-4 security sign-off is recorded.** + | ID | Item | Status | | --- | --- | --- | -| OTD-001 | Exact persona attribute mechanism — data type, read/update method, discoverability, Conditional Access compatibility | [NEEDS CLARIFICATION] Blocks persistence | -| OTD-002 | Exact least-privilege directory permissions for users, groups, roles, and the selected attribute | [NEEDS CLARIFICATION] Blocks persistence | -| OTD-003 | Whether write authorization can be restricted to the individual target attribute; if not, compensating controls plus security approval | [NEEDS CLARIFICATION] Blocks persistence | -| OTD-004 | Directory access approach — SDK cmdlets, direct REST, or a controlled combination | [NEEDS CLARIFICATION] Blocks persistence | -| OTD-005 | JSON Schema validation approach compatible with PowerShell 7 locally and in automation | [NEEDS CLARIFICATION] Blocks persistence | -| OTD-006 | Structured-log destination and transport | Open | -| OTD-007 | Retry policy — retryable status codes, max attempts, backoff, jitter, logging | Open | -| OTD-008 | Full versus incremental processing roadmap | Open | -| OTD-009 | Production schedule and concurrency lock to prevent overlapping runs | Open | -| OTD-010 | Rollback implementation — pre-change audit values or another approved mechanism | Open | +| OTD-001 | Exact persona attribute mechanism — data type, read/update method, discoverability, Conditional Access compatibility | **Resolved** — directory (schema) extension property on an app registration, addressable as `user.extension__` in dynamic group rules. `extensionAttributeN` rejected (unavailable for cloud writes on ever-synced and Exchange-originated objects); custom security attributes rejected (not exposed to the dynamic group engine). See research.md OTD-001. | +| OTD-002 | Exact least-privilege directory permissions for users, groups, roles, and the selected attribute | **Resolved** — `User.Read.All`, `GroupMember.Read.All`, `RoleManagement.Read.Directory`; `User.ReadWrite.All` for enforcement only. Scopes are requested per enabled-rule need, not unconditionally. See research.md OTD-002. | +| OTD-003 | Whether write authorization can be restricted to the individual target attribute; if not, compensating controls plus security approval | **Resolved: it cannot.** Graph application permissions have no per-property write scope. Six compensating controls are mandatory and implemented; see [docs/SecurityModel.md](../../docs/SecurityModel.md). **Security approval (V-4) is still outstanding and gates enforcement.** | +| OTD-004 | Directory access approach — SDK cmdlets, direct REST, or a controlled combination | **Resolved** — direct REST via `Invoke-MgGraphRequest`, so request bodies are explicit values that tests can assert on. This is what makes SC-005 provable. Only `Microsoft.Graph.Authentication` is a runtime dependency. | +| OTD-005 | JSON Schema validation approach compatible with PowerShell 7 locally and in automation | **Resolved locally** — `Test-Json -SchemaFile`, draft-07 only. Failure behaviour observed and pinned in [V-5a](verification/V-5a.md); note that an unparseable schema returns `$true`. **V-5b (Automation runtime) remains open.** | +| OTD-006 | Structured-log destination and transport | **Resolved** — NDJSON through a single sink, `Write-PersonaAuditRecord`. Additional transports are added behind that function, not by widening call sites. See [docs/Logging.md](../../docs/Logging.md). | +| OTD-007 | Retry policy — retryable status codes, max attempts, backoff, jitter, logging | **Resolved** — retry 429/500/502/503/504 and status-less transport failures; never 400/401/403/404/409; honour `Retry-After`; max 5 attempts; exponential backoff with full jitter capped at 60s. See research.md OTD-007. | +| OTD-008 | Full versus incremental processing roadmap | Open — full enumeration only in v1. | +| OTD-009 | Production schedule and concurrency lock to prevent overlapping runs | Open — deferred with Stage B (T120). Until then the schedule is the lock; see [docs/OperationsRunbook.md](../../docs/OperationsRunbook.md). | +| OTD-010 | Rollback implementation — pre-change audit values or another approved mechanism | **Data captured; tool not built.** `previousValue` is recorded at write time on every `Updated` record, which is the part that cannot be reconstructed retroactively. The rollback tool itself is out of scope for v1. | -**Mandatory research item**: determine the selected persona attribute mechanism and document the exact authorization boundary for updating it. If individual-attribute authorization is unavailable, document compensating controls before implementation approval. +**Mandatory research item**: determine the selected persona attribute mechanism and document the exact authorization boundary for updating it. If individual-attribute authorization is unavailable, document compensating controls before implementation approval. — **Done.** The mechanism is a directory extension property; the authorization boundary is *the whole user object*, because Graph offers nothing narrower; the compensating controls are documented in [docs/SecurityModel.md](../../docs/SecurityModel.md) and each one is test-enforced. Implementation approval for enforcement still requires the V-4 sign-off. --- diff --git a/specs/001-persona-engine/tasks.md b/specs/001-persona-engine/tasks.md new file mode 100644 index 0000000..587073e --- /dev/null +++ b/specs/001-persona-engine/tasks.md @@ -0,0 +1,481 @@ +--- + +description: "Task list for Persona Engine implementation" +--- + +# Tasks: Persona Engine + +**Input**: Design documents from `/specs/001-persona-engine/` + +**Prerequisites**: [plan.md](plan.md), [spec.md](spec.md), [research.md](research.md), +[data-model.md](data-model.md), [contracts/](contracts/), [quickstart.md](quickstart.md) + +**Tests**: **Included and mandatory.** Not an optional TDD preference here — SC-004, SC-005, SC-008, +and SC-009 are written as test assertions, and constitution Principle IV requires the rule engine to +pass offline Pester tests before any Graph integration exists. + +**Organization**: Grouped by user story. Story phases are ordered by the constitution's +**non-negotiable build order**, not strictly by priority — see the note below. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks) +- **[Story]**: US1–US9, mapping to the user stories in spec.md +- Every task names an exact file path + +## Path Conventions + +Single PowerShell module at repository root: `src/`, `tests/`, `config/`, `docs/`, `pipelines/`, +per the Project Structure section of [plan.md](plan.md). + +## Implementation status — 2026-08-20 + +**109 of 121 tasks complete.** All twelve remaining tasks require something this workstation does not +have: a tenant connection (T055, T056, T101–T103) or an Azure Automation account (Phase 13, +T115–T121). Nothing offline-implementable is outstanding. + +``` +354 offline Pester tests PASS +Engine purity (Principle IV) PASS +Sanitization (SC-013) PASS 156 files +Graph module loaded during tests False (SC-008 holds) +``` + +Three deviations from the task list as written, each made while implementing and each for a reason +worth recording: + +**T033 does not live in `Resolve-UserPersona`.** The task named that file, but `evaluationErrorThreshold` +is run-level state and the rule engine is pure — a counter there would break Principle IV. It is +implemented in `New-PersonaRunCounter` and applied in the run loop, which is what "and the run summary +path" in the task description points at. + +**A new `src/Engine/` layer exists** holding `Invoke-PersonaEngineRun`, which was not in the planned +structure. `Invoke-PersonaEngine.ps1` imports the manifest, which requires `Microsoft.Graph.Authentication`; +a run loop living only inside that script could not be executed on a machine without the Graph SDK, +so SC-004 — zero writes under `-WhatIf` across a full population — could not be proven at all. The +entry script is now a thin wrapper and the loop is testable. What ships and what is tested are the +same code. + +**T057's corpus is generated, not hand-written.** `tests/TestData/InvalidConfigs/New-InvalidConfigCorpus.ps1` +produces 23 fixtures, each a valid configuration with exactly one defect. The generated files are +committed so a reviewer sees the fixture in the diff rather than a script that produces it. + +## Ordering note (read before starting) + +Constitution Principle IV fixes the build order: pure rule engine first, persistence last. This +**overrides** strict priority ordering, so the P1 stories are sequenced US2 → US3 → US1 rather than +US1 first. US1 (preview) is the headline MVP story but cannot be built before the engine it previews. + +Stage labels refer to the Delivery Staging table in [plan.md](plan.md): **A1** offline, **A2** +delegated read-only, **A3** delegated write to test accounts, **B** Azure Automation (deferred — no +Automation account available). + +--- + +## Phase 1: Setup (Shared Infrastructure) — Stage A1 + +**Purpose**: Repository scaffolding and tooling. No behaviour. + +- [X] T001 Create the directory tree (`src/{Configuration,Authentication,DataProviders,Normalization,RuleEngine,Persistence,Presentation,Audit}`, `tests/{Unit,RuleEngine,Configuration,Integration,Safety,TestData}`, `config/`, `docs/`, `pipelines/`) per the Project Structure section of specs/001-persona-engine/plan.md +- [X] T002 Create the module manifest PersonaEngine.psd1 with `PowerShellVersion = '7.2'` and `RequiredModules = @('Microsoft.Graph.Authentication')` +- [X] T003 Create the module loader PersonaEngine.psm1 that dot-sources every `src/**/*.ps1` and exports only public functions +- [X] T004 [P] Add PSScriptAnalyzer settings in PSScriptAnalyzerSettings.psd1 enabling PSUseShouldProcessForStateChangingFunctions and PSAvoidUsingPlainTextForPassword +- [X] T005 [P] Add Pester configuration in tests/PesterConfiguration.ps1 with separate tags for Offline, Integration, and Safety suites +- [X] T006 [P] Copy specs/001-persona-engine/contracts/persona-engine.schema.json to config/persona-engine.schema.json as the shipped schema +- [X] T007 [P] Create the placeholder-only sample configuration config/persona-engine.example.json using `` and `` tokens only +- [X] T008 [P] Write the sanitization scan script tests/Test-Sanitization.ps1 that fails on real-looking GUIDs, domains, UPNs, or secrets in any tracked file (SC-013) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) — Stage A1 + +**Purpose**: The normalized contracts and guardrails every story depends on. + +**⚠️ CRITICAL**: No user story work begins until this phase is complete. + +- [X] T009 Implement `New-PersonaUserRecord` in src/Normalization/New-PersonaUserRecord.ps1 producing the UserRecord shape in data-model.md, with case-insensitive `Properties` lookup and mandatory `AccountObjectId`/`UserPrincipalName` +- [X] T010 Implement `New-PersonaMembershipRecord` in src/Normalization/New-PersonaMembershipRecord.ps1 with three independently-retrieved facets (direct, transitive, roles), every `*Retrieved` flag defaulting to `$false` so an unset record is never mistaken for "not a member" (revised during implementation — see data-model.md) +- [X] T011 [P] Implement `New-PersonaValidationFinding` in src/Configuration/New-PersonaValidationFinding.ps1 emitting Severity, Code, Location, Description, SuggestedResolution, Layer (VR-004) +- [X] T012 [P] Create synthetic fixtures in tests/TestData/Users/ covering enabled, disabled, guest, null-property, and missing-property accounts — obviously fictional, placeholders only +- [X] T013 [P] Create synthetic membership fixtures in tests/TestData/Memberships/ including a partial-failure record (one facet failed) and a total-failure record +- [X] T014 [P] Write Pester contract tests for both record types in tests/Unit/RecordContracts.Tests.ps1 +- [X] T015 Write the engine-purity CI check tests/Test-EnginePurity.ps1 asserting no file under src/RuleEngine/ references Graph, `Connect-`, `Invoke-MgGraphRequest`, `Write-Host`, or any function from src/Authentication|DataProviders|Persistence|Presentation (Principle IV) + +**Checkpoint**: normalized contracts exist and purity is enforced by CI. Story work can begin. + +--- + +## Phase 3: User Story 2 — Define classification rules without changing code (Priority: P1) 🎯 Engine core + +**Goal**: Ordered, JSON-defined rules evaluate against normalized records with no code changes and no +tenant. + +**Independent Test**: Author a multi-rule configuration, evaluate it against synthetic fixtures +offline with the network disconnected, and confirm the expected persona for each fixture. + +### Tests for User Story 2 + +> Write these first and confirm they fail before implementing. + +- [X] T016 [P] [US2] Operator matrix tests for all thirteen RE-005 operators in tests/RuleEngine/Operators.Tests.ps1, including case-insensitivity (RE-006) and null-as-empty (FR-012) +- [X] T017 [P] [US2] Ordering tests in tests/RuleEngine/Ordering.Tests.ps1 asserting ascending priority evaluation, first-match stop, and that a priority-10 match beats a priority-20 match +- [X] T018 [P] [US2] Composition tests in tests/RuleEngine/Composition.Tests.ps1 for nested `all`/`any` within the depth limit, and rejection beyond `maxConditionDepth` and the hard ceiling of 10 (RE-004) +- [X] T019 [P] [US2] Determinism tests in tests/RuleEngine/Determinism.Tests.ps1 asserting identical results across shuffled fixture order and repeated runs (SC-003) +- [X] T020 [P] [US2] Unclassified test in tests/RuleEngine/Unclassified.Tests.ps1 asserting that zero matches with all rules evaluating successfully yields `Unclassified` (FR-010) + +### Implementation for User Story 2 + +- [X] T021 [US2] Implement `Import-PersonaConfiguration` in src/Configuration/Import-PersonaConfiguration.ps1 converting JSON to BusinessRule/ConditionGroup/Condition objects and computing the SHA-256 `ConfigurationHash` +- [X] T022 [US2] Implement `Test-PersonaCondition` in src/RuleEngine/Test-PersonaCondition.ps1 covering all RE-005 operators, with regex patterns validated before execution (RE-006) +- [X] T023 [US2] Implement `Test-PersonaConditionGroup` in src/RuleEngine/Test-PersonaConditionGroup.ps1 with `all`/`any` composition and depth enforcement +- [X] T024 [US2] Implement `Test-PersonaRule` in src/RuleEngine/Test-PersonaRule.ps1 evaluating a rule's root condition group +- [X] T025 [US2] Implement `Resolve-UserPersona` in src/RuleEngine/Resolve-UserPersona.ps1 with priority sort, first-match stop, `Unclassified` fallback, `RulesEvaluated`, and `DurationMs` +- [X] T026 [US2] Implement membership and role condition types in src/RuleEngine/Test-PersonaCondition.ps1 reading only from the MembershipRecord, honouring per-condition `direct`/`transitive` mode (RE-007) + +**Checkpoint**: the rule engine classifies synthetic users offline. Disconnect the network and re-run +tests/RuleEngine — SC-008 holds or the phase is not done. + +--- + +## Phase 4: User Story 3 — Preserve existing values when evaluation cannot be trusted (Priority: P1) + +**Goal**: Unretrievable required data yields `EvaluationError`, preserves the stored persona, and +never becomes a silent non-match. + +**Independent Test**: Inject a group-lookup failure for one synthetic user; that user receives +`EvaluationError`, no write is attempted, and the run continues. + +### Tests for User Story 3 + +- [X] T027 [P] [US3] Tri-state propagation tests in tests/RuleEngine/UnknownPropagation.Tests.ps1 covering all four rows of the propagation table in data-model.md +- [X] T028 [P] [US3] Preservation tests in tests/RuleEngine/EvaluationError.Tests.ps1 asserting stored persona is retained, `Action` is `Skipped`, and processing continues to the next user (FR-014) +- [X] T029 [P] [US3] Regression test in tests/RuleEngine/UnknownNotFalse.Tests.ps1 asserting a failed membership lookup never satisfies `notMemberOf` — the specific misclassification hazard FR-013 exists to prevent + +### Implementation for User Story 3 + +- [X] T030 [US3] Extend condition evaluation in src/RuleEngine/Test-PersonaCondition.ps1 to return `True`/`False`/`Unknown` instead of a boolean +- [X] T031 [US3] Implement the propagation rules in src/RuleEngine/Test-PersonaConditionGroup.ps1: `all` with any `False` is `False`; `all` with only `True` plus `Unknown` is `Unknown`; `any` with any `True` is `True`; `any` with only `False` plus `Unknown` is `Unknown` +- [X] T032 [US3] Map a root-level `Unknown` to the `EvaluationError` outcome with `EvaluationErrorReason` in src/RuleEngine/Resolve-UserPersona.ps1 +- [X] T033 [US3] Add the `evaluationErrorThreshold` counter and final-status effect in src/RuleEngine/Resolve-UserPersona.ps1 and the run summary path + +**Checkpoint**: unknown data degrades to preserve-and-report. Principle III is satisfied in the pure +engine, before any tenant exists. + +--- + +## Phase 5: User Story 1 — Preview classification without changing the directory (Priority: P1) 🎯 MVP + +**Goal**: Every in-scope user is retrieved, evaluated, and reported with stored value, calculated +value, and matched rule — with zero directory writes. + +**Independent Test**: Run with `-WhatIf` against the tenant using a delegated read-only connection; +verify a result appears for every account and the write adapter receives zero calls. + +**Stage**: A2. Requires an app registration with admin consent for the three delegated scopes. + +### Tests for User Story 1 + +- [X] T034 [P] [US1] Zero-write safety test in tests/Safety/WhatIfZeroWrites.Tests.ps1 mocking the write adapter and asserting call count is exactly 0 across the full synthetic population (SC-004) +- [X] T035 [P] [US1] Mode-derivation test in tests/Safety/ShouldProcessGate.Tests.ps1 asserting `-Debug` without `-WhatIf` still reaches the write path — `-Debug` is not a safety control +- [X] T036 [P] [US1] Pagination test in tests/Unit/Pagination.Tests.ps1 asserting `@odata.nextLink` is followed to exhaustion and a truncated enumeration raises rather than returning a partial population +- [X] T037 [P] [US1] Idempotence test in tests/Safety/Idempotence.Tests.ps1 asserting a second consecutive run over unchanged input proposes zero changes (SC-002) +- [X] T038 [P] [US1] Exactly-one-outcome test in tests/Unit/OutcomeExclusivity.Tests.ps1 asserting every processed user lands in exactly one bucket (SC-001) + +### Implementation for User Story 1 + +- [X] T039 [P] [US1] Implement `Connect-PersonaGraphInteractive` in src/Authentication/Connect-PersonaGraphInteractive.ps1 requesting only `User.Read.All`, `GroupMember.Read.All`, `RoleManagement.Read.Directory`, returning an opaque handle that never exposes a token +- [X] T040 [P] [US1] Implement the retry helper `Invoke-PersonaGraphRequest` in src/DataProviders/Invoke-PersonaGraphRequest.ps1 per the OTD-007 table: retry 429/500/502/503/504/timeout, never 400/401/403/404/409, honour `Retry-After`, max 5 attempts, exponential backoff with full jitter capped at 60s +- [X] T041 [US1] Implement `Get-PersonaUsers` in src/DataProviders/Get-PersonaUsers.ps1 with `$select` built from FR-005 plus rule-referenced properties plus the target attribute, `$top=999`, and full `@odata.nextLink` pagination +- [X] T042 [US1] Add single-user retrieval to src/DataProviders/Get-PersonaUsers.ps1 for the `-UserObjectId` path +- [X] T043 [US1] Implement `Get-PersonaGroupMembership` in src/DataProviders/Get-PersonaGroupMembership.ps1 selecting `memberOf` or `transitiveMemberOf` by mode, returning a MembershipRecord with `RetrievalSucceeded = $false` on failure rather than throwing or returning an empty list +- [X] T044 [US1] Implement `Get-PersonaDirectoryRoles` in src/DataProviders/Get-PersonaDirectoryRoles.ps1 via `/roleManagement/directory/roleAssignments` +- [X] T045 [US1] Implement the run-scoped group/role cache in src/DataProviders/PersonaDataCache.ps1, never persisted between runs +- [X] T046 [US1] Implement `ConvertTo-PersonaUserRecord` in src/Normalization/ConvertTo-PersonaUserRecord.ps1 mapping raw Graph responses to UserRecord, including the persona extension property +- [X] T047 [US1] Implement `ConvertTo-PersonaMembershipRecord` in src/Normalization/ConvertTo-PersonaMembershipRecord.ps1 +- [X] T048 [US1] Implement `Compare-PersonaValue` in src/Persistence/Compare-PersonaValue.ps1 performing ordinal comparison of stored versus calculated (FR-015) +- [X] T049 [US1] Implement `Write-UserPersonaResult` in src/Presentation/Write-UserPersonaResult.ps1 emitting one line per user immediately with UPN, Account Object ID, outcome, matched rule ID, stored value, calculated value, and action (FR-018, SC-012) +- [X] T050 [US1] Create Invoke-PersonaEngine.ps1 with `CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')` and the parameter set from contracts/cli-invoke-persona-engine.md +- [X] T051 [US1] Derive execution mode solely from `$PSCmdlet.ShouldProcess()` in Invoke-PersonaEngine.ps1 — no separate preview boolean, per the contract's prohibition on two sources of truth for the write gate +- [X] T052 [US1] Implement the `WouldUpdate` reporting path in Invoke-PersonaEngine.ps1 so preview reports intended changes without constructing a request (FR-017) +- [X] T053 [US1] Implement exit codes 0–6 in Invoke-PersonaEngine.ps1 per the contract table +- [X] T054 [US1] Record per-user and total duration in Invoke-PersonaEngine.ps1 to establish the NFR-002 baseline no target yet exists for +- [ ] T055 [US1] Run quickstart Scenario 4 as a **non-privileged** account and record results in specs/001-persona-engine/verification/V-3.md — a Global Administrator run invalidates this item +- [ ] T056 [US1] Close the V-1 read half against a cloud-only, a currently-synced, and a formerly-synced account; record in specs/001-persona-engine/verification/V-1.md + +**Checkpoint**: 🎯 **MVP complete.** Full classification visibility against the live tenant with zero +write risk. Everything after this point adds observability, safety tooling, or enforcement. + +--- + +## Phase 6: User Story 5 — Validate and edit configuration safely (Priority: P2) + +**Goal**: Four-layer validation with structured findings, plus an interactive editor that cannot save +an invalid configuration. + +**Independent Test**: Run the invalid-configuration corpus and confirm each file produces its +documented finding code, severity, and location. + +### Tests for User Story 5 + +- [X] T057 [P] [US5] Build the invalid-configuration corpus in tests/TestData/InvalidConfigs/ — one file per VR-002 and VR-003 condition +- [X] T058 [P] [US5] Semantic validation tests in tests/Configuration/Semantic.Tests.ps1 asserting code, severity, and location for every VR-002 condition (SC-009) +- [X] T059 [P] [US5] Safety validation tests in tests/Configuration/Safety.Tests.ps1 asserting every VR-003 condition +- [X] T060 [P] [US5] Layer-ordering test in tests/Configuration/LayerOrdering.Tests.ps1 asserting a structurally invalid document stops before semantic checks run + +### Implementation for User Story 5 + +- [X] T061 [US5] Close V-5a: observe and record whether `Test-Json -SchemaFile` returns `$false`, writes a non-terminating error, or throws on this PowerShell build; record in specs/001-persona-engine/verification/V-5a.md and pin the observation in tests/Configuration/TestJsonBehaviour.Tests.ps1 +- [X] T062 [US5] Implement validation layer 1 (syntax) in src/Configuration/Test-PersonaConfiguration.ps1 +- [X] T063 [US5] Implement validation layer 2 (schema) in src/Configuration/Test-PersonaConfiguration.ps1 wrapping `Test-Json -SchemaFile` with `-ErrorAction SilentlyContinue -ErrorVariable` and converting collected errors into ValidationFinding objects per the V-5a observation +- [X] T064 [US5] Implement validation layer 3 (semantic) in src/Configuration/Test-PersonaConfigurationSemantic.ps1 covering every VR-002 condition with stable `PE-SEM-nnn` codes +- [X] T065 [US5] Implement validation layer 4 (safety) in src/Configuration/Test-PersonaConfigurationSafety.ps1 covering every VR-003 condition with stable `PE-SAF-nnn` codes +- [X] T066 [US5] Implement `Resolve-TargetAttribute` in src/Configuration/Resolve-TargetAttribute.ps1 rejecting any attribute absent from `approvedWritableAttributes` +- [X] T067 [US5] Create Edit-PersonaEngineConfig.ps1 with the parameter set from contracts/cli-edit-persona-engine-config.md +- [X] T068 [US5] Implement the interactive editor loop in Edit-PersonaEngineConfig.ps1 with re-validation before save +- [X] T069 [US5] Implement the timestamped backup and Save-As path in Edit-PersonaEngineConfig.ps1 (FR-026) +- [X] T070 [US5] Implement synthetic rule testing via `-TestDataPath` in Edit-PersonaEngineConfig.ps1, reusing the rule engine with no tenant connectivity (FR-025) + +**Checkpoint**: invalid configurations cannot reach the engine or overwrite a good file. + +--- + +## Phase 7: User Story 6 — Block invalid configuration in a pipeline (Priority: P2) + +**Goal**: Non-interactive validation that returns exit codes and never prompts. + +**Independent Test**: Run `-ValidateOnly -NonInteractive` against an invalid file with stdin closed; +confirm a non-zero exit code, no prompt, and no hang. + +**Depends on**: US5's validator (T062–T065). If pipeline gating is needed sooner than the interactive +editor, build T062–T065 and T071–T073 first and defer T067–T070. + +### Tests for User Story 6 + +- [X] T071 [P] [US6] Non-interactive test in tests/Configuration/NonInteractive.Tests.ps1 running with stdin closed and asserting no prompt and no hang (SC-010) +- [X] T072 [P] [US6] Exit-code test in tests/Configuration/ExitCodes.Tests.ps1 covering codes 0–4 from the editor contract + +### Implementation for User Story 6 + +- [X] T073 [US6] Implement `-NonInteractive` and `-ValidateOnly` short-circuits in Edit-PersonaEngineConfig.ps1 that never call a prompting cmdlet +- [X] T074 [US6] Implement `-TreatWarningsAsErrors` escalation in Edit-PersonaEngineConfig.ps1 (VR-005) +- [X] T075 [US6] Implement the editor exit codes 0–4 in Edit-PersonaEngineConfig.ps1 +- [X] T076 [US6] Add the validation stage to pipelines/validate.yml invoking sanitization, PSScriptAnalyzer, engine purity, schema validation, and the offline Pester suites + +**Checkpoint**: CI blocks a bad configuration before it can reach a tenant. + +--- + +## Phase 8: User Story 4 — Observe progress and reconcile results (Priority: P2) + +**Goal**: Interim and final summaries with a reconciliation check that treats a mismatch as an engine +defect. + +**Independent Test**: Run over a fixture population with `summaryInterval` set to 5, then to 0; +confirm interim summaries appear at the interval, are suppressed at 0, and a final summary appears in +both cases. + +### Tests for User Story 4 + +- [X] T077 [P] [US4] Interval-semantics tests in tests/Unit/SummaryInterval.Tests.ps1 covering default 25, a custom interval, and the `0` case that still produces a final summary (FR-020) +- [X] T078 [P] [US4] Reconciliation tests in tests/Unit/Reconciliation.Tests.ps1 asserting `Processed = Matched + Unclassified + EvaluationError` at every summary and that a forced mismatch raises an engine defect (SC-007) + +### Implementation for User Story 4 + +- [X] T079 [US4] Implement `Write-PersonaSummary` in src/Presentation/Write-PersonaSummary.ps1 rendering all business rules with match counts — including disabled and zero-match rules, so an absent rule is distinguishable from one that never fired +- [X] T080 [US4] Implement interval-triggered interim summaries in Invoke-PersonaEngine.ps1 (FR-019) +- [X] T081 [US4] Implement `Test-PersonaReconciliation` in src/Presentation/Test-PersonaReconciliation.ps1 and invoke it at every summary (FR-021) +- [X] T082 [US4] Emit an `EngineDefect` record and exit code 5 on reconciliation failure in Invoke-PersonaEngine.ps1 + +**Checkpoint**: operators can watch a long run and trust the counters. + +--- + +## Phase 9: User Story 7 — Audit any classification decision (Priority: P2) + +**Goal**: Structured, audit-friendly records that explain every decision and make rollback possible. + +**Independent Test**: Run over fixtures and confirm 100% of user events carry run ID, UPN, and +Account Object ID, and 100% of matched results carry a rule ID. + +### Tests for User Story 7 + +- [X] T083 [P] [US7] Completeness tests in tests/Unit/AuditCompleteness.Tests.ps1 asserting SC-006 across every record +- [X] T084 [P] [US7] Redaction tests in tests/Unit/AuditRedaction.Tests.ps1 asserting no token, `Authorization` header, secret, or raw Graph response can appear in any record +- [X] T085 [P] [US7] Schema tests in tests/Unit/AuditRecordShape.Tests.ps1 validating each record type against contracts/audit-record.md + +### Implementation for User Story 7 + +- [X] T086 [US7] Implement `New-PersonaAuditRecord` in src/Audit/New-PersonaAuditRecord.ps1 building the common envelope plus each `recordType` +- [X] T087 [US7] Implement the single sink `Write-PersonaAuditRecord` in src/Audit/Write-PersonaAuditRecord.ps1 emitting NDJSON to file and/or stream per `logging.destination`, as the only emission point so a future transport needs no call-site changes +- [X] T088 [US7] Implement `Export-PersonaRunReport` in src/Audit/Export-PersonaRunReport.ps1 producing the `RunComplete` record with all counters and the exit code +- [X] T089 [US7] Wire the run ID from `-CorrelationId` or a generated GUID through every record in Invoke-PersonaEngine.ps1 (NFR-005) +- [X] T090 [US7] Record `configVersion` and `configurationHash` on every record in src/Audit/New-PersonaAuditRecord.ps1 + +**Checkpoint**: every decision is explainable after the fact. + +--- + +## Phase 10: User Story 9 — Trace the values behind every rule decision (Priority: P3) + +**Goal**: Condition-level diagnostic tracing, available only when explicitly enabled. + +**Independent Test**: Run one user with `-Debug` and confirm a condition trace appears; run without +`-Debug` and confirm no condition values are emitted anywhere. + +### Tests for User Story 9 + +- [X] T091 [P] [US9] Gating tests in tests/Unit/ConditionTrace.Tests.ps1 asserting `conditionTrace` is absent without `-Debug` and present with it +- [X] T092 [P] [US9] Acknowledgement test in tests/Configuration/TraceAcknowledgement.Tests.ps1 asserting `traceConditionValues` without explicit acknowledgement produces a VR-003 safety finding + +### Implementation for User Story 9 + +- [X] T093 [US9] Populate `ConditionTrace` on the decision result in src/RuleEngine/Resolve-UserPersona.ps1, built only when tracing is active +- [X] T094 [US9] Add the `conditionTrace` array to user events in src/Audit/New-PersonaAuditRecord.ps1, gated on `-Debug` or `logging.traceConditionValues` + +**Checkpoint**: rule authors can debug a decision without loosening default logging. + +--- + +## Phase 11: User Story 8 — Enforce changes in production (Priority: P3) 🔒 Gated + +**Goal**: Write the calculated persona, only when changed, only the approved attribute. + +**Independent Test**: Against test accounts, confirm only changed values are written, every request +body has exactly one key, and every `Updated` record carries `previousValue`. + +**⚠️ Do not begin the tenant-facing tasks (T101–T103) until**: V-4 security sign-off is recorded, +`-WhatIf` impact evidence from Scenario 4 is reviewed, and Phases 1–10 pass. Tasks T095–T100 are +offline and may proceed at any time. + +### Tests for User Story 8 + +- [X] T095 [P] [US8] Single-attribute body test in tests/Safety/WriteBody.Tests.ps1 asserting every captured body has exactly one key equal to `engine.targetAttribute` (SC-005) +- [X] T096 [P] [US8] Rejection tests in tests/Safety/WriteBodyRejection.Tests.ps1 asserting `New-PersonaWriteBody` throws for any other attribute name and for an attribute absent from `approvedWritableAttributes` +- [X] T097 [P] [US8] Write-gate tests in tests/Safety/WriteGate.Tests.ps1 covering all four FR-016 conditions, including that an `EvaluationError` user is never written + +### Implementation for User Story 8 + +- [X] T098 [US8] Implement `New-PersonaWriteBody` in src/Persistence/New-PersonaWriteBody.ps1 as the only function permitted to construct a write body, returning a hashtable whose `Count` is exactly 1 +- [X] T099 [US8] Implement `Set-UserPersonaAttribute` in src/Persistence/Set-UserPersonaAttribute.ps1 issuing `PATCH /v1.0/users/{id}`, reachable only when `ShouldProcess` returned true +- [X] T100 [US8] Capture `previousValue` at write time into the audit record in src/Persistence/Set-UserPersonaAttribute.ps1 — missing this in v1 makes OTD-010 rollback impossible retroactively +- [ ] T101 [US8] Record the V-4 security sign-off in specs/001-persona-engine/verification/V-4.md before any enforcement run +- [ ] T102 [US8] Close the V-1 write half against purpose-created test accounts of each origin type; append to specs/001-persona-engine/verification/V-1.md +- [ ] T103 [US8] Close V-2 by building a dynamic membership group on the persona extension and confirming a CA policy assigned to it applies; record in specs/001-persona-engine/verification/V-2.md + +**Checkpoint**: enforcement works against test accounts with every safety invariant proven. + +--- + +## Phase 12: Polish & Cross-Cutting Concerns + +- [X] T104 [P] Add comment-based help to every public function across src/ (NFR-004) +- [X] T105 [P] Write docs/Architecture.md describing the adapter boundaries and why the rule engine is pure +- [X] T106 [P] Write docs/ConfigurationReference.md documenting every schema field and finding code +- [X] T107 [P] Write docs/SecurityModel.md recording the OTD-003 trade-off, the six compensating controls, and the V-4 sign-off +- [X] T108 [P] Write docs/OperationsRunbook.md including the kill switch and the rollback procedure +- [X] T109 [P] Write docs/BusinessRules.md and docs/Logging.md +- [X] T110 [P] Add the test stage to pipelines/test.yml publishing Pester results +- [X] T111 Build the FR/NFR traceability matrix in specs/001-persona-engine/traceability.md mapping every requirement ID to its implementing task and test +- [X] T112 Verify every exit code 0–6 is reachable via fault injection in tests/Unit/ExitCodes.Tests.ps1 (SC-011) +- [X] T113 Run tests/Test-Sanitization.ps1 across all tracked files and record the result in specs/001-persona-engine/verification/sanitization.md (SC-013) +- [X] T114 Update specs/001-persona-engine/spec.md Clarification Register to mark OTD-001 through OTD-005 resolved, citing research.md + +--- + +## Phase 13: Stage B — Azure Automation (DEFERRED) + +**Blocked**: no Automation account is available. Listed so the remaining entry cost stays visible and +nothing is lost. Nothing in Phases 1–12 depends on these. + +- [ ] T115 Implement `Connect-PersonaGraphManagedIdentity` in src/Authentication/Connect-PersonaGraphManagedIdentity.ps1 returning the same handle shape as the interactive path +- [ ] T116 Close V-5b: confirm `Test-Json -SchemaFile` behaviour in the Automation runtime and record its exact PowerShell version in specs/001-persona-engine/verification/V-5b.md — run this first, it is the cheapest item most likely to surprise +- [ ] T117 Close V-3b: confirm the three read scopes work as application permissions on the managed identity; record in specs/001-persona-engine/verification/V-3b.md +- [ ] T118 Create the runbook wrapper pipelines/runbook/Invoke-PersonaEngineRunbook.ps1 +- [ ] T119 Build pipelines/release.yml deploying to Automation with the schedule shipped **disabled**, a `-WhatIf` validation stage, and an approval gate before enforcement +- [ ] T120 Implement the run-start concurrency check in Invoke-PersonaEngine.ps1 per OTD-009 +- [ ] T121 Run quickstart Scenario 5 and record results in specs/001-persona-engine/verification/Scenario5.md — **this is what allows v1 to be declared complete** + +--- + +## Dependencies + +```text +Phase 1 (Setup) + └─> Phase 2 (Foundational) ← blocks everything + └─> Phase 3 US2 Rule engine ← blocks US3, US1 + └─> Phase 4 US3 Fail-safe ← blocks US1 + └─> Phase 5 US1 Preview 🎯 MVP + ├─> Phase 6 US5 Validation + editor + │ └─> Phase 7 US6 Pipeline mode + ├─> Phase 8 US4 Summaries + │ └─> Phase 9 US7 Audit + │ └─> Phase 10 US9 Tracing + └─> Phase 11 US8 Enforcement 🔒 (also gated on V-4) + └─> Phase 12 Polish + └─> Phase 13 Stage B (deferred) +``` + +**Story independence, honestly stated**: US2 and US3 are genuinely independent of everything except +the foundation. US1 depends on both — the constitution's build order makes that unavoidable, and +pretending otherwise would produce a task list that cannot be executed in the mandated sequence. +US4–US9 are independent of each other and may proceed in any order once US1 lands, except that US9 +reads the audit shape US7 defines. + +## Parallel opportunities + +| Phase | Parallel set | +| --- | --- | +| 1 | T004, T005, T006, T007, T008 | +| 2 | T011, T012, T013, T014 | +| 3 | T016–T020 (all tests, separate files) | +| 4 | T027, T028, T029 | +| 5 | T034–T038 (tests); then T039, T040 | +| 6 | T057–T060 | +| 7 | T071, T072 | +| 8 | T077, T078 | +| 9 | T083, T084, T085 | +| 10 | T091, T092 | +| 11 | T095, T096, T097 | +| 12 | T104–T110 | + +After Phase 5, three tracks can run concurrently: validation/editor (Phases 6–7), +observability (Phases 8–10), and the offline half of enforcement (T095–T100). + +## Implementation strategy + +**MVP = Phases 1–5** (T001–T056). Delivers complete classification visibility against the live tenant +with zero write capability, closes V-1 (read) and V-3, and establishes the performance baseline. This +is a genuinely useful deliverable on its own: it answers "what would this classify my tenant as?" +without touching anything. + +**Increment 2 = Phases 6–7**. Configuration safety and CI gating — the prerequisite for letting anyone +other than the author edit rules. + +**Increment 3 = Phases 8–10**. Observability, reconciliation, and audit. Required before enforcement +is defensible, because the `-WhatIf` impact evidence the constitution demands is only as good as the +output that produces it. + +**Increment 4 = Phase 11**, behind the V-4 gate, against test accounts only. + +**Stage B (Phase 13)** converts the result into an unattended service. Until T121 passes, the correct +status to report is **"Stage A complete"**, not "v1 done" — the Definition of Done requires an +Automation PowerShell 7 run. + +## Task summary + +| Phase | Story | Tasks | Count | +| --- | --- | --- | --- | +| 1 Setup | — | T001–T008 | 8 | +| 2 Foundational | — | T009–T015 | 7 | +| 3 | US2 (P1) | T016–T026 | 11 | +| 4 | US3 (P1) | T027–T033 | 7 | +| 5 | US1 (P1) 🎯 | T034–T056 | 23 | +| 6 | US5 (P2) | T057–T070 | 14 | +| 7 | US6 (P2) | T071–T076 | 6 | +| 8 | US4 (P2) | T077–T082 | 6 | +| 9 | US7 (P2) | T083–T090 | 8 | +| 10 | US9 (P3) | T091–T094 | 4 | +| 11 | US8 (P3) 🔒 | T095–T103 | 9 | +| 12 Polish | — | T104–T114 | 11 | +| 13 Stage B | — | T115–T121 | 7 (deferred) | +| **Total** | | | **121** | diff --git a/specs/001-persona-engine/traceability.md b/specs/001-persona-engine/traceability.md new file mode 100644 index 0000000..d886012 --- /dev/null +++ b/specs/001-persona-engine/traceability.md @@ -0,0 +1,114 @@ +# Requirements traceability + +Every functional requirement, non-functional requirement, and success criterion in +[spec.md](spec.md), mapped to the code that implements it and the test that holds it there. + +A row with no test is a requirement nobody is checking. Those are listed explicitly at the bottom +rather than left out, because an incomplete matrix that looks complete is worse than no matrix. + +**Status as at 2026-08-20**: 354 offline tests passing; engine purity and sanitization gates passing; +no tenant-dependent item verified. + +## Functional requirements + +| ID | Requirement | Implementation | Test | +| --- | --- | --- | --- | +| FR-001 | Load JSON configuration | `Import-PersonaConfiguration` | `LayerOrdering.Tests.ps1` | +| FR-002 | Validate before connecting | `Test-PersonaConfiguration`; entry script exits 1 before `Connect-` | `LayerOrdering.Tests.ps1`, `ExitCodes.Tests.ps1` | +| FR-003 | Adapter-isolated authentication | `Connect-PersonaGraphInteractive` | Purity gate; `ShouldProcessGate.Tests.ps1` | +| FR-004 | Enumerate all users with pagination | `Get-PersonaUsers` | `Pagination.Tests.ps1` | +| FR-005 | Select only required properties | `Get-PersonaRequiredProperties` | `Pagination.Tests.ps1` | +| FR-006 | Retrieve and cache related data | `Get-PersonaGroupMembership`, `Get-PersonaCachedMembership` | `Pagination.Tests.ps1`, `OutcomeExclusivity.Tests.ps1` | +| FR-007 | Normalize before evaluation | `ConvertTo-PersonaUserRecord`, `ConvertTo-PersonaMembershipRecord` | `RecordContracts.Tests.ps1` | +| FR-008 | Evaluate rules in priority order | `Resolve-UserPersona` | `Ordering.Tests.ps1` | +| FR-009 | Stop at first match | `Resolve-UserPersona` | `Ordering.Tests.ps1` | +| FR-010 | `Unclassified` when nothing matches | `Resolve-UserPersona` | `Unclassified.Tests.ps1` | +| FR-011 | Disabled accounts stay in scope | `New-PersonaUserRecord` exposes `AccountEnabled` | `Operators.Tests.ps1` | +| FR-012 | Null treated as empty | `Test-PersonaCondition` | `Operators.Tests.ps1` | +| FR-013 | Unretrievable group data yields `EvaluationError` | Tri-state evaluation; facet retrieval flags | `UnknownNotFalse.Tests.ps1`, `UnknownPropagation.Tests.ps1` | +| FR-014 | Preserve stored value on failure | `Compare-PersonaValue` sets `Skipped` first | `EvaluationError.Tests.ps1`, `WriteGate.Tests.ps1` | +| FR-015 | Compare stored and calculated | `Compare-PersonaValue`, ordinal | `WriteGate.Tests.ps1` | +| FR-016 | Write only changed values, four conditions | `Compare-PersonaValue` + run-loop gate | `WriteGate.Tests.ps1` | +| FR-017 | Preview issues no write request | `Invoke-PersonaEngineRun` gate | `WhatIfZeroWrites.Tests.ps1` | +| FR-018 | Immediate per-user output | `Write-UserPersonaResult` | Exercised by every run-loop suite | +| FR-019 | Periodic summary | `Write-PersonaSummary`, interval check | `SummaryInterval.Tests.ps1` | +| FR-020 | Interval semantics, final always shown | `Invoke-PersonaEngineRun` | `SummaryInterval.Tests.ps1` | +| FR-021 | Reconciliation at every summary | `Test-PersonaReconciliation` | `Reconciliation.Tests.ps1` | +| FR-022 | Structured audit records | `New-PersonaAuditRecord`, `Write-PersonaAuditRecord` | `AuditRecordShape.Tests.ps1` | +| FR-023 | Configuration editor | `Edit-PersonaEngineConfig.ps1` | `ExitCodes.Tests.ps1` (editor) | +| FR-024 | Non-interactive validation with exit codes | `-NonInteractive` short-circuit | `NonInteractive.Tests.ps1` | +| FR-025 | Synthetic rule testing, no tenant | `Invoke-SyntheticRuleTest` | `NonInteractive.Tests.ps1` | +| FR-026 | Validate and back up before save | `Save-PersonaConfiguration` | `Safety.Tests.ps1` (`PE-SAF-007`) | + +## Rule engine requirements + +| ID | Requirement | Implementation | Test | +| --- | --- | --- | --- | +| RE-001 | Required rule fields | Schema `definitions/rule` | `LayerOrdering.Tests.ps1` | +| RE-002 | Unique priorities, lower first | `Resolve-UserPersona`; `PE-SEM-002` | `Ordering.Tests.ps1`, `Semantic.Tests.ps1` | +| RE-003 | `all` / `any` with nesting | `Test-PersonaConditionGroup` | `Composition.Tests.ps1` | +| RE-004 | Depth limit and hard ceiling | `Test-PersonaConditionGroup`; `PE-SEM-012`, `PE-SEM-013` | `Composition.Tests.ps1`, `Semantic.Tests.ps1` | +| RE-005 | Thirteen operators | `Test-PersonaCondition` | `Operators.Tests.ps1` | +| RE-006 | Case-insensitive; regex validated first | `Test-PersonaCondition`; `PE-SEM-016` | `Operators.Tests.ps1`, `Semantic.Tests.ps1` | +| RE-007 | Per-condition membership mode | Three-facet `MembershipRecord` | `RecordContracts.Tests.ps1`, `UnknownPropagation.Tests.ps1` | +| RE-008 | Combined identity sources | `Get-PersonaRequiredFacets` | `OutcomeExclusivity.Tests.ps1` | +| RE-009 | Special accounts by Object ID | Example configuration; no hard-coded path | Purity gate | + +## Validation requirements + +| ID | Requirement | Implementation | Test | +| --- | --- | --- | --- | +| VR-001 | Four ordered layers, fail-fast | `Test-PersonaConfiguration` | `LayerOrdering.Tests.ps1` | +| VR-002 | Sixteen semantic conditions | `Test-PersonaConfigurationSemantic` | `Semantic.Tests.ps1` — one test per code | +| VR-003 | Seven safety conditions | `Test-PersonaConfigurationSafety` | `Safety.Tests.ps1`, `TraceAcknowledgement.Tests.ps1` | +| VR-004 | Finding shape | `New-PersonaValidationFinding` | `Semantic.Tests.ps1`, `Safety.Tests.ps1`, `RecordContracts.Tests.ps1` | +| VR-005 | Warnings block only on request | Editor exit-code mapping | `ExitCodes.Tests.ps1` (editor) | + +## Non-functional requirements + +| ID | Requirement | Implementation | Test | +| --- | --- | --- | --- | +| NFR-001 | PowerShell 7 | `#Requires -Version 7.2`; manifest floor | Runs on 7.6.5 | +| NFR-002 | Caching, per-user and total duration | `New-PersonaDataCache`; stopwatch in `Resolve-UserPersona`; `RunComplete.durationMs` | `AuditCompleteness.Tests.ps1` — **no target set** | +| NFR-003 | Pagination, bounded retry, backoff | `Invoke-PersonaGraphRequest` | `RetryPolicy.Tests.ps1`, `Pagination.Tests.ps1` | +| NFR-004 | Comment-based help on public functions | Every function in `src/` | Manual review | +| NFR-005 | Run ID, UPN, Object ID, config hash | `New-PersonaAuditContext` | `AuditCompleteness.Tests.ps1` | +| NFR-006 | Least privilege, single attribute, no secrets | Six OTD-003 controls | `WriteBody.Tests.ps1`, `WriteBodyRejection.Tests.ps1`, sanitization gate | +| NFR-007 | Engine and validation run without the platform | Purity; layer dot-sourcing | Purity gate; whole offline suite | +| NFR-008 | No Windows PowerShell-only dependencies | `Microsoft.Graph.Authentication` only | **Unverified in Automation (V-5b)** | + +## Success criteria + +| ID | Criterion | Test | Status | +| --- | --- | --- | --- | +| SC-001 | Exactly one outcome per user | `OutcomeExclusivity.Tests.ps1` | Passing | +| SC-002 | Second run proposes zero changes | `Idempotence.Tests.ps1` | Passing | +| SC-003 | Determinism across shuffled input | `Determinism.Tests.ps1` | Passing | +| SC-004 | Zero writes under `-WhatIf` | `WhatIfZeroWrites.Tests.ps1` | Passing | +| SC-005 | Single-attribute request body | `WriteBody.Tests.ps1` | Passing | +| SC-006 | Audit completeness | `AuditCompleteness.Tests.ps1` | Passing | +| SC-007 | Reconciliation, and its failure path | `Reconciliation.Tests.ps1` | Passing | +| SC-008 | Everything runs offline | Whole offline suite; `validate.yml` gate 7 | Passing | +| SC-009 | Every VR-002 and VR-003 condition detected | `Semantic.Tests.ps1`, `Safety.Tests.ps1` | Passing | +| SC-010 | Non-interactive never prompts or hangs | `NonInteractive.Tests.ps1` | Passing | +| SC-011 | Every exit code reachable | `ExitCodes.Tests.ps1` (both) | Passing | +| SC-012 | Per-user output is immediate | `Write-UserPersonaResult` emits in `process` | Structural, not timed | +| SC-013 | No tenant data committed | `Test-Sanitization.ps1` | Passing | + +## Gaps, stated plainly + +| Item | Why it is not covered | What would close it | +| --- | --- | --- | +| NFR-002 performance | No target exists until representative tenant testing | A timed run against a real population | +| NFR-004 help coverage | Reviewed by eye, not asserted | A test parsing every exported function for a help block | +| NFR-008 Automation compatibility | No Automation account available | V-5b (T116) | +| SC-012 timing | Asserted structurally, not measured | A timed harness — low value against the cost | +| V-1, V-2, V-3 | Require a tenant | Stage A2 and A3 runs | +| V-4 | Requires a person | Written security sign-off | +| Phase 13 (T115–T121) | Requires an Automation account | Stage B | + +## How to keep this honest + +When a requirement's implementation moves, this table moves with it. When a test is deleted, the row +it backed becomes a gap and belongs in the gaps table, not silently in the main one. A matrix that is +allowed to drift is worse than none, because it converts "we do not know" into "we checked". diff --git a/specs/001-persona-engine/verification/V-5a.md b/specs/001-persona-engine/verification/V-5a.md new file mode 100644 index 0000000..50af1b3 --- /dev/null +++ b/specs/001-persona-engine/verification/V-5a.md @@ -0,0 +1,57 @@ +# V-5a — `Test-Json -SchemaFile` failure behaviour (local PowerShell) + +**Status**: CLOSED +**Date**: 2026-08-20 +**Environment**: PowerShell 7.6.5, Windows 11, local workstation (Stage A1 — offline, no tenant) +**Task**: T061 +**Pinned by**: [tests/Configuration/TestJsonBehaviour.Tests.ps1](../../../tests/Configuration/TestJsonBehaviour.Tests.ps1) + +## Question + +OTD-005 selected `Test-Json -SchemaFile` for layer 2 validation. The documented risk was that +`Test-Json` reports schema failure inconsistently across PowerShell versions — returning `$false`, +writing a non-terminating error, or throwing. Layer 2 cannot be written until the actual behaviour +on the target build is observed rather than assumed. + +## Observed behaviour + +| Scenario | Return value | Error stream | Terminating? | +| --- | --- | --- | --- | +| Valid document | `$true` | empty | no | +| Type mismatch (`"a": 123` against `"type": "string"`) | `$false` | 1 error: `The JSON is not valid with the schema: Value is "integer" but should be "string" at '/a'` | no | +| Missing required property | `$false` | 1 error: `The JSON is not valid with the schema: Required properties ["a"] are not present at ''` | no | +| **Schema file itself unparseable** | **`$true`** | 1 error: `Cannot parse the JSON schema.` | no | + +Exception type on the error record is `System.Exception` in every failing case — there is no +distinct exception type to branch on, so the wrapper must branch on the message text or, better, on +error presence alone. + +## Findings that shape the implementation + +1. **Non-terminating, not throwing.** With the default `$ErrorActionPreference = 'Continue'` the + cmdlet writes to the error stream and execution continues, returning `$false`. It does not throw. + `-ErrorAction SilentlyContinue -ErrorVariable` is therefore sufficient to capture failures, as + the editor contract requires. + +2. **An unparseable schema returns `$true`.** This is the load-bearing observation. A wrapper that + trusted the return value alone would report a configuration as schema-valid when the schema never + ran. Layer 2 MUST treat "error variable is non-empty" as failure regardless of the return value, + and MUST distinguish the `Cannot parse the JSON schema.` message so it can surface exit code 4 + (schema file not found or itself invalid) rather than exit code 1 (configuration invalid). + +3. **One error per violating location, but not exhaustive.** Two independent property violations + yield two error records, each with its own JSON pointer. Deeper or nested subschema failures may + still be reported as a single error at the outermost failing location. The wrapper therefore + emits one finding per collected error rather than assuming a single one, and the author may still + need more than one validation pass to see everything. That residual limitation is documented + rather than worked around — full violation reporting would require replacing `Test-Json` with a + third-party validator, which OTD-005 rejected. + +## Consequences recorded elsewhere + +- Layer 2 implementation: [src/Configuration/Test-PersonaConfiguration.ps1](../../../src/Configuration/Test-PersonaConfiguration.ps1) +- Regression pin: `tests/Configuration/TestJsonBehaviour.Tests.ps1` fails if a future PowerShell + build changes any row of the table above. +- **V-5b remains open**: this observation is for PowerShell 7.6.5 only. The Azure Automation runtime + version is unverified, and finding 2 in particular is version-sensitive. Re-run this probe there + before Stage B (T116). diff --git a/specs/001-persona-engine/verification/sanitization.md b/specs/001-persona-engine/verification/sanitization.md new file mode 100644 index 0000000..d7e0629 --- /dev/null +++ b/specs/001-persona-engine/verification/sanitization.md @@ -0,0 +1,76 @@ +# Sanitization scan result (SC-013) + +**Status**: PASS +**Date**: 2026-08-20 +**Task**: T113 +**Scanner**: [tests/Test-Sanitization.ps1](../../../tests/Test-Sanitization.ps1) +**Files scanned**: 156 + +## What is scanned + +`git ls-files --cached --others --exclude-standard` — tracked files **and** untracked files that are +not gitignored. + +The original scanner walked `git ls-files` alone, which covered only tracked files. That made the +gate useless where it matters most: a leaked identifier in a file that has not been committed yet is +precisely the one worth catching, and scanning only what is already in history means the scan passes +right up until the commit that makes it too late. At the time this was found, the scan was covering +34 of the repository's 156 files and none of the implementation written in this phase. + +`--exclude-standard` keeps gitignored build output and local scratch files out, so the scan covers +exactly what a commit would add. + +## Patterns + +| Pattern | Exemptions | +| --- | --- | +| GUIDs | Placeholder-shaped GUIDs (`00000000-0000-0000-0000-0000000000a0`); the module manifest's own `GUID =` identity line | +| Email addresses and UPNs | RFC 2606 / RFC 6761 reserved domains: `example.com/net/org`, `.invalid`, `.test`, `.localhost` | +| `onmicrosoft.com` domains | none | +| JWT and bearer-token shapes | none | +| Assigned secret, password, or key literals | none | +| PEM private key blocks | none | + +Two exemptions were added during this scan, both narrow and both for things that cannot be replaced +with a placeholder: + +**Reserved domains.** `alex.employee@example.invalid` is guaranteed by RFC to be unresolvable. +Rejecting reserved domains would push fixtures toward addresses that merely *look* fake, which is +worse — the difference between "obviously synthetic" and "probably nobody's" is the entire reason the +reserved list exists. + +**The module manifest GUID.** A PowerShell module manifest must carry a genuine unique GUID as its +identity; it is what distinguishes this module from another of the same name. It identifies the +module, not a tenant. The exemption is **line-level** (`^\s*GUID\s*=`), not file-level: exempting the +whole manifest would let a real identifier land anywhere in it. + +## Verification of the scanner itself + +A negative control was run: a scratch file containing an email address on a real-world commercial +domain and a randomly generated real-shaped GUID was added to the working tree **without** committing +it. The scan failed with two findings and named both, by file and line. The file was then removed and +the scan returned to PASS. + +The offending values are described here rather than quoted, because quoting them would make this +record itself a finding — which the scan promptly demonstrated when an earlier draft did exactly +that. That is the control working. + +Without a negative control, a scanner that had silently stopped matching would report the same green +result as one that is working. + +## Result + +``` +Sanitization scan passed: no tenant data, credentials, or real identifiers found. +``` + +## Standing obligations + +This is a point-in-time result, not a property of the repository. The scan is **gate 1** of +[pipelines/validate.yml](../../../pipelines/validate.yml) and runs before every other gate on every +pull request — deliberately first, because a leaked identifier is a problem whether or not the code +compiles, and every later gate prints file contents into build logs. + +Runtime audit records legitimately contain real UPNs and Object IDs, which are approved for logs. No +such value may ever be committed. When attaching evidence to a verification record (V-1, V-2, V-3), +redact identifiers to placeholders first. diff --git a/src/Audit/Export-PersonaRunReport.ps1 b/src/Audit/Export-PersonaRunReport.ps1 new file mode 100644 index 0000000..2bdbe23 --- /dev/null +++ b/src/Audit/Export-PersonaRunReport.ps1 @@ -0,0 +1,59 @@ +function Export-PersonaRunReport { + <# + .SYNOPSIS + Builds the RunComplete record closing out a run (FR-022, NFR-005). + + .DESCRIPTION + The last record of every run, successful or not. It carries the final + counters, the wall-clock span, and the exit code the process returned. + + Emitted even on a fatal error. A run that died at user 400 of 5000 leaves a + RunComplete saying exactly that, which is what lets an operator tell "the + engine stopped early" from "the engine never started" - two very different + incidents that produce identical evidence if the record is written only on + success. + + startedUtc and completedUtc are wall-clock, unlike per-user durations, which + use a monotonic stopwatch. They are here for correlation with other systems' + logs, never as an input to a decision. + + .PARAMETER Context + The audit context. + + .PARAMETER Counters + The final run counters. + + .PARAMETER StartedUtc + Run start timestamp. + + .PARAMETER ExitCode + The exit code the run will return (0 - 6). + + .OUTPUTS + An ordered dictionary ready for Write-PersonaAuditRecord. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object] $Context, + + [Parameter(Mandatory)] + [object] $Counters, + + [Parameter(Mandatory)] + [datetime] $StartedUtc, + + [Parameter(Mandatory)] + [ValidateRange(0, 6)] + [int] $ExitCode + ) + + $completed = [DateTime]::UtcNow + + New-PersonaAuditRecord -Context $Context -RecordType 'RunComplete' -Counters $Counters -Properties @{ + startedUtc = $StartedUtc.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + completedUtc = $completed.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + durationMs = [int]($completed - $StartedUtc).TotalMilliseconds + exitCode = $ExitCode + } +} diff --git a/src/Audit/New-PersonaAuditRecord.ps1 b/src/Audit/New-PersonaAuditRecord.ps1 new file mode 100644 index 0000000..0000cf7 --- /dev/null +++ b/src/Audit/New-PersonaAuditRecord.ps1 @@ -0,0 +1,227 @@ +function New-PersonaAuditContext { + <# + .SYNOPSIS + Creates the constant envelope shared by every audit record in a run. + + .DESCRIPTION + Run ID, engine version, configuration version, configuration hash, and mode + are identical on every record (NFR-005). Building them once and carrying the + context means no call site can emit a record missing them, and no call site + can disagree about the mode. + + .PARAMETER RunId + The run identifier, from -CorrelationId or generated. + + .PARAMETER EngineVersion + Module version. + + .PARAMETER Configuration + The loaded configuration, source of configVersion and configurationHash. + + .PARAMETER Mode + Preview or Enforce, derived by the caller from ShouldProcess alone. + + .OUTPUTS + PersonaEngine.AuditContext + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [string] $RunId, + + [Parameter(Mandatory)] + [string] $EngineVersion, + + [Parameter(Mandatory)] + [object] $Configuration, + + [Parameter(Mandatory)] + [ValidateSet('Preview', 'Enforce')] + [string] $Mode + ) + + [pscustomobject]@{ + PSTypeName = 'PersonaEngine.AuditContext' + RunId = $RunId + EngineVersion = $EngineVersion + ConfigVersion = [string]$Configuration.ConfigVersion + ConfigurationHash = [string]$Configuration.ConfigurationHash + Mode = $Mode + } +} + +function New-PersonaAuditRecord { + <# + .SYNOPSIS + Builds a structured audit record of the requested type (FR-022, NFR-005). + + .DESCRIPTION + Every record type shares the common envelope from contracts/audit-record.md + and adds its own fields. One builder rather than five keeps the envelope in + a single place, so a field added to it appears on every record type without + five separate edits. + + Prohibited content - tokens, Authorization headers, secrets, raw Graph + responses - is not merely undocumented here, it is unreachable: this + function accepts only named, typed values from the decision result and the + counters. There is no pass-through of an arbitrary object, so there is + nothing for a secret to ride in on (Principle V). + + .PARAMETER Context + The audit context from New-PersonaAuditContext. + + .PARAMETER RecordType + RunStart, UserEvent, Summary, RunComplete, or EngineDefect. + + .PARAMETER Result + For UserEvent: the PersonaDecisionResult. + + .PARAMETER PreviousValue + For UserEvent: the value captured at write time on an Updated record. + + .PARAMETER Counters + For Summary and RunComplete: the run counter object. + + .PARAMETER IncludeTrace + Emits conditionTrace on a UserEvent. Gated by the caller on -Debug or + logging.traceConditionValues, never enabled by default (VR-003). + + .PARAMETER Properties + Additional fields for RunStart, RunComplete, and EngineDefect. + + .OUTPUTS + System.Collections.Specialized.OrderedDictionary - ordered so serialized + records list their fields in the documented sequence on every run. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateSet('RunStart', 'UserEvent', 'Summary', 'RunComplete', 'EngineDefect')] + [string] $RecordType, + + [object] $Result, + + [AllowNull()] + [AllowEmptyString()] + [string] $PreviousValue, + + [object] $Counters, + + [switch] $IncludeTrace, + + [hashtable] $Properties = @{} + ) + + $record = [ordered]@{ + timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + recordType = $RecordType + runId = $Context.RunId + engineVersion = $Context.EngineVersion + configVersion = $Context.ConfigVersion + configurationHash = $Context.ConfigurationHash + mode = $Context.Mode + } + + switch ($RecordType) { + + 'UserEvent' { + if ($null -eq $Result) { throw 'A UserEvent record requires -Result.' } + + $record['accountObjectId'] = [string]$Result.AccountObjectId + $record['userPrincipalName'] = [string]$Result.UserPrincipalName + $record['outcome'] = [string]$Result.Outcome + $record['matchedRuleId'] = $Result.MatchedRuleId + $record['storedPersona'] = $Result.StoredPersona + $record['calculatedPersona'] = $Result.CalculatedPersona + + # Present only on Updated. On any other action there is nothing that was + # replaced, and a populated previousValue would imply otherwise to a + # rollback tool reading these records later. + $record['previousValue'] = ($Result.Action -eq 'Updated') ? $PreviousValue : $null + + $record['action'] = [string]$Result.Action + $record['rulesEvaluated'] = [int]$Result.RulesEvaluated + $record['durationMs'] = [int]$Result.DurationMs + $record['evaluationErrorReason'] = $Result.EvaluationErrorReason + + if ($IncludeTrace -and $Result.ConditionTrace) { + $record['conditionTrace'] = @( + foreach ($entry in $Result.ConditionTrace) { + [ordered]@{ + ruleId = [string]$entry.RuleId + priority = [int]$entry.Priority + result = [string]$entry.Result + } + } + ) + } + } + + 'Summary' { + if ($null -eq $Counters) { throw 'A Summary record requires -Counters.' } + + $record['summaryType'] = $Properties.ContainsKey('summaryType') ? [string]$Properties['summaryType'] : 'Interim' + Add-PersonaCounterField -Record $record -Counters $Counters + $record['ruleCounts'] = @( + foreach ($entry in $Counters.RuleCounts) { + [ordered]@{ + ruleId = [string]$entry.RuleId + name = [string]$entry.Name + enabled = [bool]$entry.Enabled + matches = [int]$entry.Matches + } + } + ) + } + + 'RunComplete' { + if ($null -eq $Counters) { throw 'A RunComplete record requires -Counters.' } + + $record['startedUtc'] = $Properties['startedUtc'] + $record['completedUtc'] = $Properties['completedUtc'] + $record['durationMs'] = [int]$Properties['durationMs'] + Add-PersonaCounterField -Record $record -Counters $Counters + $record['exitCode'] = [int]$Properties['exitCode'] + } + + default { + # RunStart and EngineDefect carry only the envelope plus whatever the + # caller names explicitly. + foreach ($key in $Properties.Keys) { $record[$key] = $Properties[$key] } + } + } + + $record +} + +function Add-PersonaCounterField { + <# + .SYNOPSIS + Adds the shared counter block to a Summary or RunComplete record. + + .DESCRIPTION + Summary and RunComplete carry the same counters. Sharing the block means the + two record types cannot drift apart, which matters because reconciliation + tooling reads both. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Record, + [Parameter(Mandatory)] [object] $Counters + ) + + $Record['processed'] = [int]$Counters.Processed + $Record['matched'] = [int]$Counters.Matched + $Record['unclassified'] = [int]$Counters.Unclassified + $Record['evaluationError'] = [int]$Counters.EvaluationError + $Record['unchanged'] = [int]$Counters.Unchanged + $Record['wouldUpdate'] = [int]$Counters.WouldUpdate + $Record['updated'] = [int]$Counters.Updated + $Record['updateFailed'] = [int]$Counters.UpdateFailed + $Record['skipped'] = [int]$Counters.Skipped + $Record['reconciliationPassed'] = [bool](Test-PersonaReconciliation -Counters $Counters) +} diff --git a/src/Audit/Write-PersonaAuditRecord.ps1 b/src/Audit/Write-PersonaAuditRecord.ps1 new file mode 100644 index 0000000..cf06407 --- /dev/null +++ b/src/Audit/Write-PersonaAuditRecord.ps1 @@ -0,0 +1,108 @@ +function Write-PersonaAuditRecord { + <# + .SYNOPSIS + The single emission point for audit records (FR-022, OTD-006). + + .DESCRIPTION + Serializes one record as newline-delimited JSON and emits it to file, to the + object stream, or both, per logging.destination. + + Every audit record in the engine passes through here. That is the whole + design: adding a transport - an approved logging platform, an event hub, a + different file layout - is a change to this function and nothing else. If + call sites wrote their own output, each new transport would mean auditing + every call site again, and the one that got missed would be silent. + + Emission failure never ends the run. A full disk or a locked file is an + operational problem with the audit sink, not a reason to abandon a + classification run mid-population and leave the directory in a half-reconciled + state. The failure is surfaced as a warning, once, and processing continues. + + .PARAMETER Record + An ordered dictionary from New-PersonaAuditRecord. + + .PARAMETER Destination + file, stream, both, or none. + + .PARAMETER Path + Output file for the file and both destinations. + + .PARAMETER State + Optional sink state carrying the one-warning latch, so a failing sink warns + once per run rather than once per user. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [object] $Record, + + [ValidateSet('file', 'stream', 'both', 'none')] + [string] $Destination = 'stream', + + [string] $Path, + + [object] $State + ) + + process { + if ($Destination -eq 'none') { return } + + if ($Destination -in @('stream', 'both')) { + # The Information stream, not the success stream. Audit records emitted + # onto the success stream would be indistinguishable from a function's + # return value: the run loop returns its outcome there, and mixing the two + # would turn one object into an array of several thousand. + # + # The record object is emitted, not a string, so a caller capturing it + # with -InformationVariable can assert on fields without reparsing. + Write-Information -MessageData $Record -Tags 'PersonaEngine.Audit' + } + + if ($Destination -in @('file', 'both')) { + if (-not $Path) { + Write-Warning 'logging.destination requests file output but no path is configured. No audit file was written.' + return + } + + try { + $line = $Record | ConvertTo-Json -Depth 16 -Compress + + $directory = Split-Path -Parent $Path + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + $null = New-Item -ItemType Directory -Path $directory -Force + } + + # Append, one record per line. UTF-8 without BOM so the file is + # machine-readable by any NDJSON consumer. + Add-Content -LiteralPath $Path -Value $line -Encoding utf8NoBOM -ErrorAction Stop + } + catch { + if ($null -ne $State -and $State.FileSinkFailed) { return } + if ($null -ne $State) { $State.FileSinkFailed = $true } + + Write-Warning "Audit file sink failed; the run continues without file output: $($_.Exception.Message)" + } + } + } +} + +function New-PersonaAuditSinkState { + <# + .SYNOPSIS + Creates the per-run sink state for Write-PersonaAuditRecord. + + .DESCRIPTION + Holds the latch that keeps a failing file sink from emitting one warning per + user. A run over five thousand accounts with a locked log file should warn + once, not five thousand times, or the warning that matters is buried in the + noise it generates. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param() + + [pscustomobject]@{ + PSTypeName = 'PersonaEngine.AuditSinkState' + FileSinkFailed = $false + } +} diff --git a/src/Authentication/Connect-PersonaGraphInteractive.ps1 b/src/Authentication/Connect-PersonaGraphInteractive.ps1 new file mode 100644 index 0000000..a4c0ad6 --- /dev/null +++ b/src/Authentication/Connect-PersonaGraphInteractive.ps1 @@ -0,0 +1,59 @@ +function Connect-PersonaGraphInteractive { + <# + .SYNOPSIS + Establishes a delegated Microsoft Graph connection for local development. + + .DESCRIPTION + Stage A2 authentication (plan.md). Requests only the scopes the enabled + rules require (OTD-002) — never Directory.Read.All, which is materially + broader than the three read scopes combined. + + Returns an opaque handle. No token, header, or secret is ever returned to a + caller, logged, or written to an audit record (Principle V). + + Note on V-3: effective access is the intersection of the requested scope and + the signed-in account's directory roles. Signing in as a Global Administrator + makes the least-privilege test meaningless, because the account's roles grant + broad read regardless of the scope requested. + + .PARAMETER IncludeWrite + Adds User.ReadWrite.All. Enforcement only, and only after the V-4 security + sign-off is recorded. + + .PARAMETER TenantId + Optional tenant hint. + + .EXAMPLE + Connect-PersonaGraphInteractive + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [switch] $IncludeWrite, + [string] $TenantId, + [switch] $IncludeGroups, + [switch] $IncludeRoles + ) + + $scopes = [System.Collections.Generic.List[string]]::new() + $scopes.Add($IncludeWrite ? 'User.ReadWrite.All' : 'User.Read.All') + if ($IncludeGroups) { $scopes.Add('GroupMember.Read.All') } + if ($IncludeRoles) { $scopes.Add('RoleManagement.Read.Directory') } + + $connectArgs = @{ Scopes = $scopes.ToArray(); NoWelcome = $true; ErrorAction = 'Stop' } + if ($TenantId) { $connectArgs['TenantId'] = $TenantId } + + Write-Verbose "Connecting to Microsoft Graph with scopes: $($scopes -join ', ')" + Connect-MgGraph @connectArgs | Out-Null + + $context = Get-MgContext + + [pscustomobject]@{ + PSTypeName = 'PersonaEngine.GraphConnection' + AuthType = 'Delegated' + Account = $context.Account + TenantId = $context.TenantId + Scopes = @($context.Scopes) + WriteCapable = [bool]$IncludeWrite + } +} diff --git a/src/Configuration/Get-PersonaRequiredFacets.ps1 b/src/Configuration/Get-PersonaRequiredFacets.ps1 new file mode 100644 index 0000000..0738824 --- /dev/null +++ b/src/Configuration/Get-PersonaRequiredFacets.ps1 @@ -0,0 +1,68 @@ +function Get-PersonaRequiredFacets { + <# + .SYNOPSIS + Determines which membership facets the enabled rules actually need. + + .DESCRIPTION + Least privilege applied to data retrieval: a configuration with no role + conditions never calls the role endpoint, so a tenant where role reads are + unavailable can still run property-only rules. + + Walks every enabled rule's condition tree. A membership condition resolves + to the direct or transitive facet by its own membershipMode, falling back to + the configured default (RE-007). + + .PARAMETER Rules + The business rule collection. + + .PARAMETER DefaultMembershipMode + Mode for conditions that do not specify one. + + .OUTPUTS + A hashtable with Direct, Transitive, and Roles boolean keys. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $Rules, + + [ValidateSet('Direct', 'Transitive')] + [string] $DefaultMembershipMode = 'Direct' + ) + + $need = @{ Direct = $false; Transitive = $false; Roles = $false } + + function Test-Node { + param($Node) + if ($null -eq $Node) { return } + + if ($Node.PSObject.Properties['conditions'] -and $Node.conditions) { + foreach ($child in $Node.conditions) { Test-Node $child } + return + } + + $type = [string]$Node.type + + if ($type -eq 'role') { + $need.Roles = $true + return + } + + if ($type -eq 'membership') { + $mode = $Node.PSObject.Properties['membershipMode'] -and $Node.membershipMode ` + ? [string]$Node.membershipMode ` + : $DefaultMembershipMode + + if ($mode -ieq 'transitive') { $need.Transitive = $true } else { $need.Direct = $true } + } + } + + foreach ($rule in @($Rules)) { + if (-not $rule.enabled) { continue } + Test-Node $rule.match + } + + $need +} diff --git a/src/Configuration/Import-PersonaConfiguration.ps1 b/src/Configuration/Import-PersonaConfiguration.ps1 new file mode 100644 index 0000000..e5bb912 --- /dev/null +++ b/src/Configuration/Import-PersonaConfiguration.ps1 @@ -0,0 +1,76 @@ +function Import-PersonaConfiguration { + <# + .SYNOPSIS + Loads a JSON configuration file into the object shape the engine consumes. + + .DESCRIPTION + Layer 1 of validation (syntax) happens implicitly here: malformed JSON + throws. Layers 2-4 are Test-PersonaConfiguration's responsibility, and the + caller runs them before using the result (FR-002). + + Computes ConfigurationHash as the SHA-256 of the file bytes, which is + recorded on every audit record (NFR-005) so a run can be tied to the exact + configuration that produced it. + + .PARAMETER Path + Path to the JSON configuration file. + + .OUTPUTS + PersonaEngine.Configuration + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Configuration file not found: '$Path'." + } + + $raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop + + try { + $document = $raw | ConvertFrom-Json -Depth 32 -ErrorAction Stop + } + catch { + throw "Configuration is not valid JSON: $($_.Exception.Message)" + } + + # Hash the file bytes rather than the parsed object: two files that differ only + # in whitespace are different configurations for audit purposes, and the hash + # must be reproducible from the artifact on disk. + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $Path).ProviderPath) + $hash = [System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + } + finally { + $sha.Dispose() + } + + $engine = $document.engine + + [pscustomobject]@{ + PSTypeName = 'PersonaEngine.Configuration' + ConfigVersion = [string]$document.configVersion + ConfigurationHash = $hash + SourcePath = (Resolve-Path -LiteralPath $Path).ProviderPath + + TargetAttribute = [string]$engine.targetAttribute + ApprovedWritableAttributes = @($engine.approvedWritableAttributes) + MaxConditionDepth = ($null -ne $engine.maxConditionDepth) ? [int]$engine.maxConditionDepth : 5 + SummaryInterval = ($null -ne $engine.summaryInterval) ? [int]$engine.summaryInterval : 25 + DefaultMembershipMode = $engine.defaultMembershipMode ? (Get-Culture).TextInfo.ToTitleCase([string]$engine.defaultMembershipMode) : 'Direct' + EvaluationErrorThreshold = ($null -ne $engine.evaluationErrorThreshold) ? [int]$engine.evaluationErrorThreshold : $null + + DataSources = $document.dataSources + Logging = $document.logging + Personas = @($document.personas) + Rules = @($document.rules) + + Raw = $document + } +} diff --git a/src/Configuration/New-PersonaValidationFinding.ps1 b/src/Configuration/New-PersonaValidationFinding.ps1 new file mode 100644 index 0000000..a1943bc --- /dev/null +++ b/src/Configuration/New-PersonaValidationFinding.ps1 @@ -0,0 +1,78 @@ +function New-PersonaValidationFinding { + <# + .SYNOPSIS + Creates a structured validation finding (VR-004). + + .DESCRIPTION + Every one of the four validation layers emits this shape, so a caller — + console, pipeline, or editor — handles findings uniformly regardless of + which layer produced them. + + Finding codes are stable and namespaced by layer, because pipelines and + runbooks will match on them: + + PE-SYN-nnn syntax + PE-SCH-nnn schema + PE-SEM-nnn semantic + PE-SAF-nnn safety + + .PARAMETER Severity + Error blocks execution and saving. Warning blocks only under + -TreatWarningsAsErrors. Information never blocks (VR-005). + + .PARAMETER Code + Stable finding code, e.g. PE-SEM-012. + + .PARAMETER Location + JSON path or rule ID identifying where the problem is. + + .PARAMETER SuggestedResolution + What the author should do. Required — a finding without a resolution just + tells someone they are wrong. + + .EXAMPLE + New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-002' -Location 'rules[3].priority' -Description 'Duplicate priority 30.' -SuggestedResolution 'Assign a unique priority.' + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [ValidateSet('Error', 'Warning', 'Information')] + [string] $Severity, + + [Parameter(Mandatory)] + [ValidatePattern('^PE-(SYN|SCH|SEM|SAF)-\d{3}$')] + [string] $Code, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Location, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Description, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $SuggestedResolution + ) + + # The layer is derivable from the code, so it cannot drift out of agreement + # with it. + $layer = switch -Regex ($Code) { + '^PE-SYN-' { 'Syntax' } + '^PE-SCH-' { 'Schema' } + '^PE-SEM-' { 'Semantic' } + '^PE-SAF-' { 'Safety' } + } + + [pscustomobject]@{ + PSTypeName = 'PersonaEngine.ValidationFinding' + Severity = $Severity + Code = $Code + Location = $Location + Description = $Description + SuggestedResolution = $SuggestedResolution + Layer = $layer + } +} diff --git a/src/Configuration/Resolve-TargetAttribute.ps1 b/src/Configuration/Resolve-TargetAttribute.ps1 new file mode 100644 index 0000000..4877448 --- /dev/null +++ b/src/Configuration/Resolve-TargetAttribute.ps1 @@ -0,0 +1,50 @@ +function Resolve-TargetAttribute { + <# + .SYNOPSIS + Resolves the attribute the engine is permitted to write (NFR-006). + + .DESCRIPTION + Returns the configured target attribute only when it is non-blank and + present in approvedWritableAttributes. Anything else throws. + + Throwing rather than returning $null is the point. A caller that treated a + null return as "no writes this run" would be indistinguishable from a caller + that forgot to check, and the second one writes to whatever name it was + holding. There is no safe fallback value for an attribute name, so there is + no fallback. + + Comparison against the approved list is ORDINAL. Extension property names + are case-sensitive in Graph: extension__Persona and + extension__persona are two different attributes, and approving one does + not approve the other. Rule matching is case-insensitive (RE-006); attribute + approval is not, and the difference is deliberate. + + .PARAMETER Configuration + The loaded configuration. + + .EXAMPLE + $target = Resolve-TargetAttribute -Configuration $config + + .OUTPUTS + System.String + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [object] $Configuration + ) + + $target = [string]$Configuration.TargetAttribute + $approved = @($Configuration.ApprovedWritableAttributes) + + if ([string]::IsNullOrWhiteSpace($target)) { + throw 'engine.targetAttribute is blank. There is no attribute to compare against or write.' + } + + if ($target -cnotin $approved) { + throw "engine.targetAttribute '$target' is not present in engine.approvedWritableAttributes. Comparison is ordinal: extension property names are case-sensitive, so a casing difference is a different attribute." + } + + $target +} diff --git a/src/Configuration/Test-PersonaConfiguration.ps1 b/src/Configuration/Test-PersonaConfiguration.ps1 new file mode 100644 index 0000000..5c05475 --- /dev/null +++ b/src/Configuration/Test-PersonaConfiguration.ps1 @@ -0,0 +1,261 @@ +function Test-PersonaConfiguration { + <# + .SYNOPSIS + Runs all four validation layers over a configuration file (VR-001, FR-002). + + .DESCRIPTION + Layers run in order and fail fast between them: + + 1. Syntax ConvertFrom-Json PE-SYN-nnn + 2. Schema Test-Json -SchemaFile PE-SCH-nnn + 3. Semantic Test-PersonaConfigurationSemantic PE-SEM-nnn + 4. Safety Test-PersonaConfigurationSafety PE-SAF-nnn + + A layer that produces Error findings stops the sequence. Running semantic + checks over a structurally invalid document produces noise, not signal: every + missing field yields a cascade of consequent errors, and the author has to + guess which one is the actual cause. + + Layer 2 error handling is driven by the V-5a observation + (specs/001-persona-engine/verification/V-5a.md), which matters more than it + looks. On PowerShell 7.6.5, Test-Json returns $true when the SCHEMA ITSELF is + unparseable, writing the failure to the error stream instead. A wrapper that + trusted the return value would report a configuration as schema-valid when + the schema never ran. So this function treats a non-empty error variable as + failure regardless of what was returned, and separates the "schema is broken" + case from the "configuration is invalid" case, because they need different + exit codes. + + .PARAMETER Path + Configuration file to validate. + + .PARAMETER SchemaPath + Schema override. Defaults to the shipped config/persona-engine.schema.json. + + .PARAMETER PreviousConfigPath + A previously deployed configuration, enabling the VR-003 comparison checks + (version downgrade, undeclared rule deletion or reorder). + + .PARAMETER EnforcementEnabled + Whether this configuration will be used for an enforcing run. Raises the + severity of several safety findings. + + .PARAMETER SkipSafety + Runs layers 1-3 only. Used by the editor while a document is mid-edit. + + .OUTPUTS + PersonaEngine.ValidationResult + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path, + + [string] $SchemaPath, + + [string] $PreviousConfigPath, + + [switch] $EnforcementEnabled, + + [switch] $SkipSafety + ) + + $findings = [System.Collections.Generic.List[object]]::new() + $document = $null + $stoppedAtLayer = $null + $schemaUnusable = $false + + # ---------------------------------------------------------------- Layer 1 + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SYN-001' ` + -Location $Path ` + -Description "Configuration file not found or is not a file: '$Path'." ` + -SuggestedResolution 'Check the path. In a pipeline, confirm the file was checked out and the working directory is what you expect.')) + + return New-PersonaValidationResult -Findings $findings -Document $null -StoppedAtLayer 'Syntax' -SchemaUnusable $false + } + + $raw = $null + try { + $raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop + } + catch { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SYN-002' ` + -Location $Path ` + -Description "Configuration file could not be read: $($_.Exception.Message)" ` + -SuggestedResolution 'Check file permissions and that no other process holds an exclusive lock.')) + + return New-PersonaValidationResult -Findings $findings -Document $null -StoppedAtLayer 'Syntax' -SchemaUnusable $false + } + + try { + $document = $raw | ConvertFrom-Json -Depth 32 -ErrorAction Stop + } + catch { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SYN-003' ` + -Location $Path ` + -Description "Configuration is not valid JSON: $($_.Exception.Message)" ` + -SuggestedResolution 'Fix the JSON syntax. A trailing comma or an unquoted key is the usual cause.')) + + return New-PersonaValidationResult -Findings $findings -Document $null -StoppedAtLayer 'Syntax' -SchemaUnusable $false + } + + # ---------------------------------------------------------------- Layer 2 + + if (-not $SchemaPath) { + $moduleRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $SchemaPath = Join-Path $moduleRoot 'config/persona-engine.schema.json' + } + + if (-not (Test-Path -LiteralPath $SchemaPath -PathType Leaf)) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SCH-002' ` + -Location $SchemaPath ` + -Description "Schema file not found: '$SchemaPath'." ` + -SuggestedResolution 'Supply -SchemaPath, or restore config/persona-engine.schema.json.')) + + return New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer 'Schema' -SchemaUnusable $true + } + + $schemaErrors = $null + $schemaOk = $raw | Test-Json -SchemaFile $SchemaPath -ErrorAction SilentlyContinue -ErrorVariable schemaErrors + + foreach ($schemaError in @($schemaErrors)) { + $message = [string]$schemaError.Exception.Message + + # V-5a: this message arrives with a $true return value. Treating it as a + # pass would validate every configuration against a schema that never ran. + if ($message -match 'Cannot parse the JSON schema') { + $schemaUnusable = $true + + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SCH-003' ` + -Location $SchemaPath ` + -Description "The schema file itself is not valid JSON Schema and could not be used: $message" ` + -SuggestedResolution 'Repair the schema file. Until it parses, no configuration can be schema-validated, and a passing result would be meaningless.')) + continue + } + + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SCH-001' ` + -Location (Get-PersonaSchemaErrorLocation -Message $message) ` + -Description $message ` + -SuggestedResolution 'Correct the document to match config/persona-engine.schema.json. Test-Json reports only the first violation per run, so re-validate after each fix.')) + } + + if ($schemaUnusable -or -not $schemaOk -or @($schemaErrors).Count -gt 0) { + return New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer 'Schema' -SchemaUnusable $schemaUnusable + } + + # ---------------------------------------------------------------- Layer 3 + + foreach ($finding in (Test-PersonaConfigurationSemantic -Document $document)) { $findings.Add($finding) } + + if (@($findings | Where-Object Severity -EQ 'Error').Count -gt 0) { + return New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer 'Semantic' -SchemaUnusable $false + } + + # ---------------------------------------------------------------- Layer 4 + + if (-not $SkipSafety) { + $safetyParams = @{ Document = $document; EnforcementEnabled = $EnforcementEnabled } + if ($PreviousConfigPath) { $safetyParams['PreviousConfigPath'] = $PreviousConfigPath } + + foreach ($finding in (Test-PersonaConfigurationSafety @safetyParams)) { $findings.Add($finding) } + } + + New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer $null -SchemaUnusable $false +} + +function New-PersonaValidationResult { + <# + .SYNOPSIS + Wraps a finding collection into the shape every caller consumes. + + .DESCRIPTION + IsValid is computed here rather than by each caller, so console, pipeline, + and editor cannot disagree about what "valid" means. Warnings never affect + IsValid; escalation under -TreatWarningsAsErrors is the caller's decision + (VR-005) and belongs where the exit code is chosen. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] [AllowEmptyCollection()] [object] $Findings, + [AllowNull()] [object] $Document, + [AllowNull()] [string] $StoppedAtLayer, + [bool] $SchemaUnusable + ) + + $all = @($Findings) + + [pscustomobject]@{ + PSTypeName = 'PersonaEngine.ValidationResult' + IsValid = (@($all | Where-Object Severity -EQ 'Error').Count -eq 0) + Findings = $all + ErrorCount = @($all | Where-Object Severity -EQ 'Error').Count + WarningCount = @($all | Where-Object Severity -EQ 'Warning').Count + Document = $Document + StoppedAtLayer = $StoppedAtLayer + SchemaUnusable = $SchemaUnusable + } +} + +function Get-PersonaSchemaErrorLocation { + <# + .SYNOPSIS + Extracts the JSON pointer from a Test-Json error message. + + .DESCRIPTION + Test-Json embeds the failing location in prose - "... at '/rules/3/priority'". + VR-004 requires a location on every finding, so it is lifted out here rather + than leaving the caller to read it out of the description. When no pointer is + present the document root is reported, which is honest: the violation is + somewhere in the document and the message says where in words. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Message) + + if ($Message -match "at '([^']*)'") { + $pointer = $Matches[1] + return $pointer ? $pointer : '/' + } + + '/' +} + +function Write-PersonaValidationFinding { + <# + .SYNOPSIS + Renders validation findings for a human reader. + + .DESCRIPTION + Grouped by severity, most serious first, with the suggested resolution on + its own line. A finding without a visible resolution just tells someone they + are wrong, which is why VR-004 makes the field mandatory and why it is + printed rather than hidden behind a verbose switch. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Findings + ) + + if (@($Findings).Count -eq 0) { + Write-Host 'Validation passed with no findings.' -ForegroundColor Green + return + } + + foreach ($severity in @('Error', 'Warning', 'Information')) { + $set = @($Findings | Where-Object Severity -EQ $severity) + if ($set.Count -eq 0) { continue } + + $colour = switch ($severity) { 'Error' { 'Red' } 'Warning' { 'Yellow' } default { 'Gray' } } + + foreach ($finding in $set) { + Write-Host ("[{0}] {1} {2}" -f $finding.Severity.ToUpperInvariant(), $finding.Code, $finding.Location) -ForegroundColor $colour + Write-Host (" {0}" -f $finding.Description) + Write-Host (" -> {0}" -f $finding.SuggestedResolution) -ForegroundColor DarkGray + } + } +} diff --git a/src/Configuration/Test-PersonaConfigurationSafety.ps1 b/src/Configuration/Test-PersonaConfigurationSafety.ps1 new file mode 100644 index 0000000..90b5995 --- /dev/null +++ b/src/Configuration/Test-PersonaConfigurationSafety.ps1 @@ -0,0 +1,266 @@ +function Test-PersonaConfigurationSafety { + <# + .SYNOPSIS + Validation layer 4: safety checks (VR-003). + + .DESCRIPTION + Layer 3 asks "does this configuration make sense?". Layer 4 asks "what + happens to the directory if we run it?" - a configuration can be perfectly + coherent and still be dangerous. + + PE-SAF-001 production-capable configuration with a blank target attribute + PE-SAF-002 unsupported writable attribute in the approved list + PE-SAF-003 enabled group rules while group retrieval is disabled + PE-SAF-004 prohibited configuration version downgrade + PE-SAF-005 rule deletion or reorder without a version change, enforcing + PE-SAF-006 condition tracing enabled without explicit acknowledgement + PE-SAF-007 save path overwrites the only valid configuration with no backup + + PE-SAF-004 and PE-SAF-005 need a baseline to compare against and are skipped + when -PreviousConfigPath is absent. Skipped, not passed: a check that cannot + run has not been satisfied, and an Information finding says so rather than + leaving silence to be read as approval. + + .PARAMETER Document + The parsed configuration document. + + .PARAMETER PreviousConfigPath + The currently deployed configuration, for the comparison checks. + + .PARAMETER EnforcementEnabled + Whether this configuration will drive an enforcing run. Several findings are + Errors under enforcement and Warnings in preview, because the same + configuration carries very different risk in the two modes. + + .PARAMETER SavePath + Intended save target, for PE-SAF-007. + + .PARAMETER BackupPlanned + A timestamped backup or Save-As will be taken, satisfying PE-SAF-007. + + .OUTPUTS + PersonaEngine.ValidationFinding objects. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [object] $Document, + + [string] $PreviousConfigPath, + + [switch] $EnforcementEnabled, + + [string] $SavePath, + + [switch] $BackupPlanned + ) + + $findings = [System.Collections.Generic.List[object]]::new() + + $engine = $Document.engine + $target = [string]$engine.targetAttribute + $approved = @($engine.approvedWritableAttributes) + $rules = @($Document.rules) + + # ------------------------------------------------- PE-SAF-001 + + if ([string]::IsNullOrWhiteSpace($target)) { + $severity = $EnforcementEnabled ? 'Error' : 'Warning' + + $findings.Add((New-PersonaValidationFinding -Severity $severity -Code 'PE-SAF-001' ` + -Location 'engine.targetAttribute' ` + -Description 'The target attribute is blank. In enforce mode there is no attribute to write and every changed result becomes Skipped, so the run reports success while changing nothing.' ` + -SuggestedResolution 'Set engine.targetAttribute before running with enforcement.')) + } + + # ------------------------------------------------- PE-SAF-002 + + foreach ($attribute in $approved) { + if (Test-PersonaWritableAttributeShape -Name $attribute) { continue } + + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-002' ` + -Location 'engine.approvedWritableAttributes' ` + -Description "'$attribute' is not a supported writable attribute. Only directory (schema) extension properties may be written by this engine (OTD-001); built-in attributes such as department or jobTitle are authoritative elsewhere and writing them would overwrite another system's data." ` + -SuggestedResolution 'Remove the entry, or replace it with a directory extension property named extension__.')) + } + + # ------------------------------------------------- PE-SAF-003 + + $groupsEnabled = [bool]$Document.dataSources.groups.enabled + $rolesEnabled = [bool]$Document.dataSources.roles.enabled + + $needs = Get-PersonaRequiredFacets -Rules $rules -DefaultMembershipMode ( + $engine.defaultMembershipMode ? (Get-Culture).TextInfo.ToTitleCase([string]$engine.defaultMembershipMode) : 'Direct') + + if (($needs.Direct -or $needs.Transitive) -and -not $groupsEnabled) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-003' ` + -Location 'dataSources.groups.enabled' ` + -Description 'Enabled rules contain group membership conditions while group retrieval is disabled. Every account those rules reach becomes EvaluationError, so the run preserves stored values and classifies nobody - a silent no-op that still reports success.' ` + -SuggestedResolution 'Enable dataSources.groups, or disable the rules that depend on membership.')) + } + + if ($needs.Roles -and -not $rolesEnabled) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-003' ` + -Location 'dataSources.roles.enabled' ` + -Description 'Enabled rules contain directory role conditions while role retrieval is disabled. Tier 0 rules are the usual casualty, and an account that should have matched a privileged rule falls through to a lower-privilege persona or to EvaluationError.' ` + -SuggestedResolution 'Enable dataSources.roles, or disable the rules that depend on role assignments.')) + } + + # ------------------------------------------------- PE-SAF-006 + + $logging = $Document.logging + $tracing = $logging -and $logging.PSObject.Properties['traceConditionValues'] -and [bool]$logging.traceConditionValues + $acknowledged = $logging -and $logging.PSObject.Properties['acknowledgeConditionTracing'] -and [bool]$logging.acknowledgeConditionTracing + + if ($tracing -and -not $acknowledged) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-006' ` + -Location 'logging.traceConditionValues' ` + -Description 'Condition-value tracing is enabled without acknowledgement. Tracing writes evaluated attribute values into audit records, widening what the log contains beyond the UPN and Object ID that are approved by default (Principle V).' ` + -SuggestedResolution 'Set logging.acknowledgeConditionTracing to true in the same change that enables tracing, so the decision is visible in review, or disable tracing.')) + } + + # ------------------------------------------------- PE-SAF-004 / PE-SAF-005 + + if (-not $PreviousConfigPath) { + $findings.Add((New-PersonaValidationFinding -Severity Information -Code 'PE-SAF-004' ` + -Location 'configVersion' ` + -Description 'No previous configuration was supplied, so the version-downgrade and rule-drift checks did not run. This is not a pass - the checks were skipped.' ` + -SuggestedResolution 'Pass -PreviousConfigPath pointing at the currently deployed configuration to enable the comparison checks. In CI, this is the copy from the deployed branch.')) + } + elseif (-not (Test-Path -LiteralPath $PreviousConfigPath -PathType Leaf)) { + $findings.Add((New-PersonaValidationFinding -Severity Warning -Code 'PE-SAF-004' ` + -Location $PreviousConfigPath ` + -Description 'The previous configuration path was supplied but does not exist. The comparison checks did not run.' ` + -SuggestedResolution 'Correct the path, or omit it deliberately if this is the first deployment.')) + } + else { + foreach ($finding in (Compare-PersonaConfigurationVersion -Document $Document -PreviousConfigPath $PreviousConfigPath -EnforcementEnabled:$EnforcementEnabled)) { + $findings.Add($finding) + } + } + + # ------------------------------------------------- PE-SAF-007 + + if ($SavePath -and -not $BackupPlanned -and (Test-Path -LiteralPath $SavePath -PathType Leaf)) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-007' ` + -Location $SavePath ` + -Description 'The save would overwrite an existing configuration with no backup. If the replacement turns out to be wrong, the only known-good copy is gone and there is nothing to roll back to.' ` + -SuggestedResolution 'Allow the timestamped backup, or supply -OutputPath to save alongside the original (FR-026).')) + } + + $findings +} + +function Compare-PersonaConfigurationVersion { + <# + .SYNOPSIS + Compares a candidate configuration against the deployed one (PE-SAF-004, PE-SAF-005). + + .DESCRIPTION + Two questions, both about change control rather than correctness: + + Did the version go backwards? A downgrade means an older rule set is about + to replace a newer one, and audit records would carry a configVersion that + has already been superseded - so two different rule sets share one version + number and no record can tell them apart. + + Did rules disappear or change order without the version changing? Deleting + a rule silently reclassifies everyone it used to match; reordering does the + same for anyone matched by an overtaken rule. Neither is wrong in itself, + but doing it under an unchanged version number makes the change invisible + in the audit trail. + + Under enforcement these are Errors, because the consequence is a directory + write. In preview they are Warnings: the same drift is worth seeing but costs + nothing yet. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] [object] $Document, + [Parameter(Mandatory)] [string] $PreviousConfigPath, + [switch] $EnforcementEnabled + ) + + $findings = [System.Collections.Generic.List[object]]::new() + $severity = $EnforcementEnabled ? 'Error' : 'Warning' + + $previous = $null + try { + $previous = Get-Content -LiteralPath $PreviousConfigPath -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 32 -ErrorAction Stop + } + catch { + $findings.Add((New-PersonaValidationFinding -Severity Warning -Code 'PE-SAF-004' ` + -Location $PreviousConfigPath ` + -Description "The previous configuration could not be parsed, so the comparison checks did not run: $($_.Exception.Message)" ` + -SuggestedResolution 'Point -PreviousConfigPath at a valid configuration, or omit it.')) + return $findings + } + + $currentVersion = $null + $previousVersion = $null + $parsedBoth = [version]::TryParse([string]$Document.configVersion, [ref] $currentVersion) -and + [version]::TryParse([string]$previous.configVersion, [ref] $previousVersion) + + if ($parsedBoth -and $currentVersion -lt $previousVersion) { + $findings.Add((New-PersonaValidationFinding -Severity $severity -Code 'PE-SAF-004' ` + -Location 'configVersion' ` + -Description "configVersion $currentVersion is lower than the deployed version $previousVersion. Audit records would report a version that has already been superseded, making two different rule sets indistinguishable in the log." ` + -SuggestedResolution 'Raise configVersion above the deployed version. If a rollback is genuinely intended, publish it as a new higher version rather than reusing the old number.')) + } + + if ($parsedBoth -and $currentVersion -ne $previousVersion) { + # The version moved, so drift is declared. Nothing further to report. + return $findings + } + + $currentIds = @($Document.rules | ForEach-Object { [string]$_.id }) + $previousIds = @($previous.rules | ForEach-Object { [string]$_.id }) + + $removed = @($previousIds | Where-Object { $_ -notin $currentIds }) + if ($removed.Count -gt 0) { + $findings.Add((New-PersonaValidationFinding -Severity $severity -Code 'PE-SAF-005' ` + -Location 'rules' ` + -Description "Rules removed without a configVersion change: $($removed -join ', '). Every account these rules matched will be reclassified by a later rule, or become Unclassified, with nothing in the audit trail marking the change." ` + -SuggestedResolution 'Raise configVersion so the change is declared, or disable the rules instead of deleting them so the audit trail keeps reporting zero matches against them.')) + } + + $currentOrder = @($Document.rules | Sort-Object -Property @{ Expression = { [int]$_.priority } } | ForEach-Object { [string]$_.id }) + $previousOrder = @($previous.rules | Sort-Object -Property @{ Expression = { [int]$_.priority } } | ForEach-Object { [string]$_.id }) + $shared = @($currentOrder | Where-Object { $_ -in $previousIds }) + $sharedPrevious = @($previousOrder | Where-Object { $_ -in $currentIds }) + + if (($shared -join '>') -ne ($sharedPrevious -join '>')) { + $findings.Add((New-PersonaValidationFinding -Severity $severity -Code 'PE-SAF-005' ` + -Location 'rules[*].priority' ` + -Description 'Rule evaluation order changed without a configVersion change. First match wins (FR-009), so a reorder silently reassigns every account matched by more than one rule.' ` + -SuggestedResolution 'Raise configVersion so the reorder is declared and traceable in audit records.')) + } + + $findings +} + +function Test-PersonaWritableAttributeShape { + <# + .SYNOPSIS + Reports whether an attribute name is shaped like a writable extension property. + + .DESCRIPTION + OTD-001 selected directory (schema) extension properties as the persona + store. Only those may be written. Built-in attributes are deliberately + excluded even when the operator holds permission to write them: they are + authoritative in HR or in the sync source, and this engine is not their owner. + + The placeholder form is accepted so the committed example configuration + passes its own validator without carrying a real application ID (SC-013). + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Name) + + if ($Name -match '^extension_[0-9a-fA-F]{32}_[A-Za-z0-9]+$') { return $true } + if ($Name -match '^extension_<[^>]+>_]+>?$') { return $true } + + $false +} diff --git a/src/Configuration/Test-PersonaConfigurationSemantic.ps1 b/src/Configuration/Test-PersonaConfigurationSemantic.ps1 new file mode 100644 index 0000000..e29a507 --- /dev/null +++ b/src/Configuration/Test-PersonaConfigurationSemantic.ps1 @@ -0,0 +1,352 @@ +function Test-PersonaConfigurationSemantic { + <# + .SYNOPSIS + Validation layer 3: semantic checks (VR-002). + + .DESCRIPTION + Every condition VR-002 names, one stable code each. Codes are part of the + contract - pipelines and runbooks match on them - so a code is never reused + for a different condition and never renumbered. + + PE-SEM-001 duplicate rule IDs + PE-SEM-002 duplicate priorities + PE-SEM-003 no enabled rules + PE-SEM-004 invalid or blank target attribute + PE-SEM-005 target attribute absent from the approved writable list + PE-SEM-006 reference to an unavailable data source + PE-SEM-007 memberOf/notMemberOf without group Object IDs + PE-SEM-008 in/notIn without values + PE-SEM-009 isNull/isNotNull carrying a comparison value + PE-SEM-010 undefined or prohibited persona value + PE-SEM-011 Unclassified used as an ordinary rule persona + PE-SEM-012 condition depth over the configured maximum + PE-SEM-013 configured maximum over the hard ceiling of 10 + PE-SEM-014 membership mode not enabled globally + PE-SEM-015 unsupported property name + PE-SEM-016 invalid regular expression + + Several of these are also expressible in JSON Schema and some are already + caught there. They are repeated here deliberately: layer 2 can be bypassed + with -SchemaPath, and V-5a showed that an unparseable schema silently passes + on this build. A rule that can misclassify a privileged account should not + depend on one layer alone. + + .PARAMETER Document + The parsed configuration document. + + .OUTPUTS + PersonaEngine.ValidationFinding objects. Empty when the document is sound. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [object] $Document + ) + + $findings = [System.Collections.Generic.List[object]]::new() + + $engine = $Document.engine + $rules = @($Document.rules) + $personas = @($Document.personas) + + # ---------------------------------------------------- target attribute + + $target = [string]$engine.targetAttribute + $approved = @($engine.approvedWritableAttributes) + + if ([string]::IsNullOrWhiteSpace($target)) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-004' ` + -Location 'engine.targetAttribute' ` + -Description 'The target attribute is blank. The engine has no attribute to compare against or write.' ` + -SuggestedResolution 'Set engine.targetAttribute to the approved persona extension property name.')) + } + elseif ($target -cnotin $approved) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-005' ` + -Location 'engine.targetAttribute' ` + -Description "The target attribute '$target' does not appear in engine.approvedWritableAttributes. Comparison is ordinal, so a casing difference counts as absent." ` + -SuggestedResolution 'Add the exact attribute name to approvedWritableAttributes, or correct the target attribute. Extension property names are case-sensitive in Graph.')) + } + + # ---------------------------------------------------- depth ceiling + + $maxDepth = ($null -ne $engine.maxConditionDepth) ? [int]$engine.maxConditionDepth : 5 + + if ($maxDepth -gt 10) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-013' ` + -Location 'engine.maxConditionDepth' ` + -Description "maxConditionDepth is $maxDepth, above the hard ceiling of 10 (RE-004)." ` + -SuggestedResolution 'Lower maxConditionDepth to 10 or less. A rule needing deeper nesting is better split into two rules with distinct priorities.')) + } + + if ($maxDepth -lt 1) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-013' ` + -Location 'engine.maxConditionDepth' ` + -Description "maxConditionDepth is $maxDepth, below the minimum of 1 (RE-004). No rule could be evaluated." ` + -SuggestedResolution 'Set maxConditionDepth to at least 1.')) + } + + # ---------------------------------------------------- data sources + + $groupsEnabled = [bool]$Document.dataSources.groups.enabled + $rolesEnabled = [bool]$Document.dataSources.roles.enabled + # Left null when the configuration does not pin a mode. Absent means "any mode is + # acceptable", which is the normal case: RE-007 makes mode a per-condition + # choice, and the facets are retrieved independently. Pinning it globally is a + # deliberate restriction, and only then is a per-condition override worth + # flagging. + $globalMode = $Document.dataSources.groups.PSObject.Properties['membershipMode'] ` + ? [string]$Document.dataSources.groups.membershipMode : $null + $defaultMode = $engine.defaultMembershipMode ? [string]$engine.defaultMembershipMode : 'direct' + + # ---------------------------------------------------- rules + + if ($rules.Count -eq 0) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-003' ` + -Location 'rules' ` + -Description 'The configuration contains no rules.' ` + -SuggestedResolution 'Add at least one enabled rule. A run with no rules classifies every account as Unclassified.')) + } + elseif (@($rules | Where-Object { $_.enabled }).Count -eq 0) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-003' ` + -Location 'rules' ` + -Description 'Every rule in the configuration is disabled. The run would classify every account as Unclassified and, in enforce mode, propose clearing every stored persona.' ` + -SuggestedResolution 'Enable at least one rule, or do not deploy this configuration.')) + } + + foreach ($group in ($rules | Group-Object -Property { [string]$_.id } | Where-Object Count -GT 1)) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-001' ` + -Location "rules[id=$($group.Name)]" ` + -Description "Rule ID '$($group.Name)' is used by $($group.Count) rules. Rule IDs appear in audit records and are how a decision is traced back to its rule." ` + -SuggestedResolution 'Give each rule a unique ID.')) + } + + foreach ($group in ($rules | Where-Object { $_.enabled } | Group-Object -Property { [int]$_.priority } | Where-Object Count -GT 1)) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-002' ` + -Location "rules[priority=$($group.Name)]" ` + -Description "Priority $($group.Name) is shared by $($group.Count) enabled rules: $(($group.Group | ForEach-Object { [string]$_.id }) -join ', '). Evaluation order between them is not defined by the configuration (RE-002)." ` + -SuggestedResolution 'Assign a unique priority to each enabled rule. The engine breaks ties by rule ID so results stay deterministic, but the resulting order is an accident rather than a decision.')) + } + + foreach ($rule in $rules) { + $ruleId = [string]$rule.id + $persona = [string]$rule.persona + + if ($persona -ieq 'Unclassified') { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-011' ` + -Location "rules[$ruleId].persona" ` + -Description 'Unclassified is a processing result, not a rule outcome (FR-010). A rule that assigns it makes "no rule matched" indistinguishable from "this rule matched".' ` + -SuggestedResolution 'Remove the rule, or give it a real persona. Accounts matching no rule already receive Unclassified.')) + } + elseif ($persona -ieq 'EvaluationError') { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-010' ` + -Location "rules[$ruleId].persona" ` + -Description 'EvaluationError is an execution result and must never be assigned by a rule.' ` + -SuggestedResolution 'Give the rule a persona from the personas catalogue.')) + } + elseif ($persona -and $persona -notin $personas) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-010' ` + -Location "rules[$ruleId].persona" ` + -Description "Persona '$persona' is not declared in the personas catalogue. The catalogue is what stops a typo from writing a new persona value into the directory." ` + -SuggestedResolution "Add '$persona' to the personas array, or correct the spelling.")) + } + + if ($null -eq $rule.match) { continue } + + $context = @{ + RuleId = $ruleId + MaxDepth = $maxDepth + GroupsEnabled = $groupsEnabled + RolesEnabled = $rolesEnabled + GlobalMode = $globalMode + DefaultMode = $defaultMode + Findings = $findings + } + + Test-PersonaSemanticNode -Node $rule.match -Depth 1 -PathText "rules[$ruleId].match" -Context $context + } + + $findings +} + +function Test-PersonaSemanticNode { + <# + .SYNOPSIS + Recursively validates one condition group or condition. + + .DESCRIPTION + Depth is counted the same way the engine counts it, so the validator and the + runtime agree about what "too deep" means. A validator with its own depth + arithmetic would eventually pass a configuration the engine rejects at run + time, against a live tenant, which is the worst place to discover it. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Node, + [Parameter(Mandatory)] [int] $Depth, + [Parameter(Mandatory)] [string] $PathText, + [Parameter(Mandatory)] [hashtable] $Context + ) + + $findings = $Context.Findings + + if ($Depth -gt $Context.MaxDepth) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-012' ` + -Location $PathText ` + -Description "Condition nesting reaches depth $Depth, above the configured maximum of $($Context.MaxDepth) (RE-004). The engine returns Unknown beyond the limit, which becomes EvaluationError for every account this rule reaches." ` + -SuggestedResolution 'Flatten the condition tree, or raise engine.maxConditionDepth up to the ceiling of 10.')) + return + } + + # A group: recurse and stop. Groups carry no operator-level semantics of their own. + if ($Node.PSObject.Properties['conditions'] -and $Node.conditions) { + $index = 0 + foreach ($child in $Node.conditions) { + Test-PersonaSemanticNode -Node $child -Depth ($Depth + 1) -PathText "$PathText.conditions[$index]" -Context $Context + $index++ + } + return + } + + $type = [string]$Node.type + $operator = [string]$Node.operator + $hasValue = $Node.PSObject.Properties['value'] -and $null -ne $Node.value + + # @($Node.values) on an absent property yields @($null) - a one-element array - + # which would make an empty list look populated and a populated one look no + # different. Every array read here goes through the null filter for that reason. + $values = @($Node.values | Where-Object { $null -ne $_ }) + $groupIds = @($Node.groupObjectIds | Where-Object { $null -ne $_ }) + $roleIds = @($Node.roleIds | Where-Object { $null -ne $_ }) + + switch ($operator) { + + { $_ -in @('memberOf', 'notMemberOf') } { + if ($type -eq 'role') { + if ($roleIds.Count -eq 0) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-007' ` + -Location $PathText ` + -Description "A role condition using '$operator' carries no roleIds. It can never evaluate to a meaningful result." ` + -SuggestedResolution 'Add at least one role template ID to roleIds.')) + } + + if (-not $Context.RolesEnabled) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-006' ` + -Location $PathText ` + -Description 'This rule requires directory role data, but dataSources.roles.enabled is false. The role facet is never retrieved, so every account reaching this rule becomes EvaluationError (FR-013).' ` + -SuggestedResolution 'Enable dataSources.roles, or remove the role conditions.')) + } + } + else { + if ($groupIds.Count -eq 0) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-007' ` + -Location $PathText ` + -Description "A membership condition using '$operator' carries no groupObjectIds." ` + -SuggestedResolution 'Add at least one group Object ID to groupObjectIds. Object IDs are used rather than names because names are mutable (RE-009).')) + } + + if (-not $Context.GroupsEnabled) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-006' ` + -Location $PathText ` + -Description 'This rule requires group membership data, but dataSources.groups.enabled is false. Every account reaching this rule becomes EvaluationError (FR-013).' ` + -SuggestedResolution 'Enable dataSources.groups, or remove the membership conditions.')) + } + + $mode = ($Node.PSObject.Properties['membershipMode'] -and $Node.membershipMode) ? [string]$Node.membershipMode : $Context.DefaultMode + + if ($Context.GroupsEnabled -and $mode -ine $Context.GlobalMode -and $Context.GlobalMode) { + # Not an error. The three facets are retrieved independently, so a + # per-condition mode differing from the global one is served + # correctly - but it is worth flagging, because it usually means + # the author did not realise the global setting was there. + $findings.Add((New-PersonaValidationFinding -Severity Warning -Code 'PE-SEM-014' ` + -Location $PathText ` + -Description "This condition requests '$mode' membership while dataSources.groups.membershipMode is '$($Context.GlobalMode)'. Both facets will be retrieved, at the cost of an extra call per account." ` + -SuggestedResolution "Confirm '$mode' is intended here. If every rule wants the same mode, set it globally and drop the per-condition override.")) + } + } + } + + { $_ -in @('in', 'notIn') } { + if ($values.Count -eq 0) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-008' ` + -Location $PathText ` + -Description "Operator '$operator' requires a values array, which is absent or empty. An empty set matches nothing and would silently never fire." ` + -SuggestedResolution 'Populate values, or use equals/notEquals for a single comparison.')) + } + } + + { $_ -in @('isNull', 'isNotNull') } { + if ($hasValue -or $values.Count -gt 0) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-009' ` + -Location $PathText ` + -Description "Operator '$operator' tests for presence and ignores any comparison value. A value here is silently discarded, so the rule does not do what it appears to do." ` + -SuggestedResolution 'Remove value/values, or switch to equals if a comparison was intended.')) + } + } + + 'matchesRegex' { + if (-not $hasValue) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-016' ` + -Location $PathText ` + -Description 'matchesRegex requires a pattern in value.' ` + -SuggestedResolution 'Supply a regular expression in the value field.')) + } + else { + try { + # Compiling proves the pattern parses. Validating here rather than + # at run time means a bad pattern fails a pipeline, not a + # production run in which every account becomes EvaluationError. + $null = [System.Text.RegularExpressions.Regex]::new([string]$Node.value) + } + catch { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-016' ` + -Location $PathText ` + -Description "Invalid regular expression: $($_.Exception.Message)" ` + -SuggestedResolution 'Correct the pattern. Remember that JSON requires backslashes to be escaped, so \d is written \\d.')) + } + } + } + } + + if ($type -eq 'property') { + $property = [string]$Node.property + + if (-not (Test-PersonaSupportedProperty -Name $property)) { + $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-015' ` + -Location $PathText ` + -Description "Property '$property' is not a supported property name. Unsupported properties are never retrieved, so the condition would compare against a permanently absent value." ` + -SuggestedResolution 'Use one of AccountObjectId, UserPrincipalName, DisplayName, UserType, AccountEnabled, CompanyName, JobTitle, Department, or an extension property named extension__.')) + } + } +} + +function Test-PersonaSupportedProperty { + <# + .SYNOPSIS + Reports whether a property name can actually be retrieved and evaluated. + + .DESCRIPTION + The FR-005 baseline plus directory extension properties. Extension names are + accepted on shape alone - extension_<32 hex>_ - because the set of + registered extensions is tenant-specific and cannot be known offline, and + SC-008 requires validation to run with no tenant. + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Name) + + $supported = @( + 'AccountObjectId', 'UserPrincipalName', 'DisplayName', 'UserType', + 'AccountEnabled', 'CompanyName', 'JobTitle', 'Department' + ) + + if ($Name -in $supported) { return $true } + if ($Name -match '^extension_[0-9a-fA-F]{32}_[A-Za-z0-9]+$') { return $true } + + # The placeholder form used in committed examples (SC-013) must validate, or the + # shipped example configuration could never pass its own validator. + if ($Name -match '^extension_<[^>]+>_]+>?$') { return $true } + + $false +} diff --git a/src/DataProviders/Get-PersonaDirectoryRoles.ps1 b/src/DataProviders/Get-PersonaDirectoryRoles.ps1 new file mode 100644 index 0000000..2501c95 --- /dev/null +++ b/src/DataProviders/Get-PersonaDirectoryRoles.ps1 @@ -0,0 +1,45 @@ +function Get-PersonaDirectoryRoles { + <# + .SYNOPSIS + Retrieves active directory role assignments for one user. + + .DESCRIPTION + Uses the unified role management endpoint, which returns roleDefinitionId + values (role template IDs) — stable across tenants, unlike role instance IDs, + so a configuration written against them is portable. + + Eligible (PIM) assignments are out of scope for v1 unless authorization is + confirmed and a provider is implemented (spec Out of Scope). + + Throws on failure. The caller (Get-PersonaGroupMembership) contains the + failure into the Roles facet so it never becomes a false non-match. + + .PARAMETER UserObjectId + The principal to query. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] + [string] $UserObjectId + ) + + $uri = "/v1.0/roleManagement/directory/roleAssignments?`$filter=principalId eq '$UserObjectId'" + $ids = [System.Collections.Generic.List[string]]::new() + + while ($uri) { + $response = Invoke-PersonaGraphRequest -Uri $uri + + if ($null -eq $response -or -not $response.ContainsKey('value')) { + throw 'Role assignment endpoint returned an unexpected response shape.' + } + + foreach ($assignment in $response['value']) { + if ($assignment['roleDefinitionId']) { $ids.Add([string]$assignment['roleDefinitionId']) } + } + + $uri = $response.ContainsKey('@odata.nextLink') ? $response['@odata.nextLink'] : $null + } + + , $ids.ToArray() +} diff --git a/src/DataProviders/Get-PersonaGroupMembership.ps1 b/src/DataProviders/Get-PersonaGroupMembership.ps1 new file mode 100644 index 0000000..dee6b87 --- /dev/null +++ b/src/DataProviders/Get-PersonaGroupMembership.ps1 @@ -0,0 +1,130 @@ +function Get-PersonaGroupMembership { + <# + .SYNOPSIS + Retrieves group membership and directory roles for one user. + + .DESCRIPTION + Returns a MembershipRecord — ALWAYS. On failure it returns a record with the + affected facet unretrieved and a FailureReason. It never returns an empty + list on failure and never throws past the per-user boundary. + + That single behaviour is what makes FR-013 work: unknown membership becomes + EvaluationError, never a false non-match. If this function ever throws or + returns empty on error, a transient Graph outage silently reclassifies + privileged accounts. + + Fetches only the facets the configuration actually needs, and each one + independently, so a failure in one does not make the others unevaluable. + + .PARAMETER UserObjectId + The user to query. + + .PARAMETER NeedDirect + Fetch direct group membership. + + .PARAMETER NeedTransitive + Fetch transitive group membership. + + .PARAMETER NeedRoles + Fetch directory role assignments. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [string] $UserObjectId, + + [switch] $NeedDirect, + [switch] $NeedTransitive, + [switch] $NeedRoles + ) + + $direct = @() + $transitive = @() + $roles = @() + $directOk = $false + $transitiveOk = $false + $rolesOk = $false + $directErr = $null + $transitiveErr = $null + $rolesErr = $null + + if ($NeedDirect) { + try { + $direct = Get-PersonaGroupIdPage -Uri "/v1.0/users/$UserObjectId/memberOf?`$select=id&`$top=999" + $directOk = $true + } + catch { + $directErr = $_.Exception.Message + Write-Verbose "Direct membership lookup failed for $UserObjectId : $directErr" + } + } + + if ($NeedTransitive) { + try { + $transitive = Get-PersonaGroupIdPage -Uri "/v1.0/users/$UserObjectId/transitiveMemberOf?`$select=id&`$top=999" + $transitiveOk = $true + } + catch { + $transitiveErr = $_.Exception.Message + Write-Verbose "Transitive membership lookup failed for $UserObjectId : $transitiveErr" + } + } + + if ($NeedRoles) { + try { + $roles = Get-PersonaDirectoryRoles -UserObjectId $UserObjectId + $rolesOk = $true + } + catch { + $rolesErr = $_.Exception.Message + Write-Verbose "Role lookup failed for $UserObjectId : $rolesErr" + } + } + + $params = @{ + DirectGroupObjectIds = $direct + TransitiveGroupObjectIds = $transitive + DirectoryRoleIds = $roles + } + if ($directOk) { $params['DirectRetrieved'] = $true } elseif ($NeedDirect) { $params['DirectFailureReason'] = $directErr } + if ($transitiveOk) { $params['TransitiveRetrieved'] = $true } elseif ($NeedTransitive) { $params['TransitiveFailureReason'] = $transitiveErr } + if ($rolesOk) { $params['RolesRetrieved'] = $true } elseif ($NeedRoles) { $params['RolesFailureReason'] = $rolesErr } + + New-PersonaMembershipRecord @params +} + +function Get-PersonaGroupIdPage { + <# + .SYNOPSIS + Collects group Object IDs across all pages of a membership endpoint. + + .DESCRIPTION + memberOf returns directory objects of mixed type. Only group IDs are + collected; administrative units and other object types are ignored. + #> + [CmdletBinding()] + [OutputType([string[]])] + param([Parameter(Mandatory)] [string] $Uri) + + $ids = [System.Collections.Generic.List[string]]::new() + $next = $Uri + + while ($next) { + $response = Invoke-PersonaGraphRequest -Uri $next + + if ($null -eq $response -or -not $response.ContainsKey('value')) { + throw 'Membership endpoint returned an unexpected response shape.' + } + + foreach ($item in $response['value']) { + $type = $item['@odata.type'] + if ($type -and $type -ne '#microsoft.graph.group') { continue } + if ($item['id']) { $ids.Add([string]$item['id']) } + } + + $next = $response.ContainsKey('@odata.nextLink') ? $response['@odata.nextLink'] : $null + } + + , $ids.ToArray() +} diff --git a/src/DataProviders/Get-PersonaUsers.ps1 b/src/DataProviders/Get-PersonaUsers.ps1 new file mode 100644 index 0000000..60d3297 --- /dev/null +++ b/src/DataProviders/Get-PersonaUsers.ps1 @@ -0,0 +1,153 @@ +function Get-PersonaUsers { + <# + .SYNOPSIS + Retrieves in-scope user objects, following pagination to exhaustion. + + .DESCRIPTION + Requests only the properties enabled rules actually need (FR-005) plus the + configured target attribute and the operational fields required for logging. + + Pagination follows @odata.nextLink until absent (FR-004). A truncated + enumeration raises rather than returning a partial population — silently + classifying half a tenant is worse than failing. + + Emits raw Graph objects. Normalization is ConvertTo-PersonaUserRecord's job; + the rule engine never sees what this returns. + + .PARAMETER SelectProperties + Property names for $select. + + .PARAMETER UserObjectId + Retrieves a single user instead of enumerating (the -UserObjectId path). + + .PARAMETER PageSize + $top value. Graph caps user enumeration at 999. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string[]] $SelectProperties, + + [string] $UserObjectId, + + [ValidateRange(1, 999)] + [int] $PageSize = 999 + ) + + $select = ($SelectProperties | Select-Object -Unique) -join ',' + + if ($UserObjectId) { + Write-Verbose "Retrieving single user $UserObjectId" + return @(Invoke-PersonaGraphRequest -Uri "/v1.0/users/$UserObjectId`?`$select=$select") + } + + $uri = "/v1.0/users?`$select=$select&`$top=$PageSize" + $page = 0 + + while ($uri) { + $page++ + Write-Verbose "Retrieving user page $page" + + $response = Invoke-PersonaGraphRequest -Uri $uri + + if ($null -eq $response -or -not $response.ContainsKey('value')) { + throw "User enumeration returned an unexpected response shape on page $page. Refusing to continue with a partial population." + } + + foreach ($user in $response['value']) { $user } + + $uri = $response.ContainsKey('@odata.nextLink') ? $response['@odata.nextLink'] : $null + } +} + +function Get-PersonaRequiredProperties { + <# + .SYNOPSIS + Builds the $select list from the configuration and its enabled rules. + + .DESCRIPTION + The FR-005 baseline plus the target attribute plus every property an enabled + rule references. Properties nothing references are not requested — least + privilege applies to data as well as permissions. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] [object] $Configuration + ) + + # Graph property names, which differ in casing from the normalized record. + $baseline = @( + 'id', 'userPrincipalName', 'displayName', 'userType', + 'accountEnabled', 'companyName', 'jobTitle', 'department' + ) + + $properties = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($p in $baseline) { $null = $properties.Add($p) } + + if ($Configuration.TargetAttribute) { $null = $properties.Add($Configuration.TargetAttribute) } + + foreach ($name in (Get-PersonaReferencedProperties -Rules $Configuration.Rules)) { + # Intrinsics are already covered by the baseline under their Graph names. + if ($name -in @('AccountObjectId', 'UserPrincipalName', 'DisplayName', 'UserType', 'AccountEnabled')) { continue } + $null = $properties.Add((Get-PersonaGraphPropertyName $name)) + } + + , @($properties) +} + +function Get-PersonaReferencedProperties { + <# + .SYNOPSIS + Walks enabled rules and collects every referenced property name. + #> + [CmdletBinding()] + [OutputType([string[]])] + param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Rules) + + $found = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + function Walk { + param($Node) + if ($null -eq $Node) { return } + + if ($Node.PSObject.Properties['conditions'] -and $Node.conditions) { + foreach ($child in $Node.conditions) { Walk $child } + return + } + + if ($Node.PSObject.Properties['property'] -and $Node.property) { + $null = $found.Add([string]$Node.property) + } + } + + foreach ($rule in @($Rules)) { + if (-not $rule.enabled) { continue } + Walk $rule.match + } + + , @($found) +} + +function Get-PersonaGraphPropertyName { + <# + .SYNOPSIS + Maps a normalized property name to its Graph equivalent. + + .DESCRIPTION + Extension property names pass through unchanged — they are already in Graph + form and are case-sensitive, unlike the built-in properties. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory, Position = 0)] [string] $Name) + + if ($Name -like 'extension_*') { return $Name } + + # camelCase the first letter; Graph built-ins are camelCase. + if ($Name.Length -gt 0) { + return $Name.Substring(0, 1).ToLowerInvariant() + $Name.Substring(1) + } + + $Name +} diff --git a/src/DataProviders/Invoke-PersonaGraphRequest.ps1 b/src/DataProviders/Invoke-PersonaGraphRequest.ps1 new file mode 100644 index 0000000..5138d32 --- /dev/null +++ b/src/DataProviders/Invoke-PersonaGraphRequest.ps1 @@ -0,0 +1,176 @@ +function Invoke-PersonaGraphRequest { + <# + .SYNOPSIS + Issues a Microsoft Graph request with the retry policy from OTD-007. + + .DESCRIPTION + The single point at which the engine touches Graph. Direct REST via + Invoke-MgGraphRequest (OTD-004), so request bodies are explicit values that + tests can assert on — which is what makes SC-005 provable. + + Retry policy: + Retryable 429, 500, 502, 503, 504, transport timeout + Never 400, 401, 403, 404, 409 (configuration, authorization, or + logic defects — retrying masks them) + Retry-After honoured when present; overrides computed backoff + Attempts max 5, exponential from 1s, full jitter, per-delay cap 60s + + Never logs tokens, Authorization headers, or full response bodies. + + .PARAMETER Uri + Absolute or Graph-relative URI. + + .PARAMETER Method + HTTP method. Defaults to GET. + + .PARAMETER Body + Request body. Passed through unchanged so the caller controls exactly what + is sent. + + .PARAMETER MaxAttempts + Maximum attempts including the first. Default 5. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Uri, + + [ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')] + [string] $Method = 'GET', + + [object] $Body, + + [ValidateRange(1, 10)] + [int] $MaxAttempts = 5, + + [int] $BaseDelayMs = 1000, + + [int] $MaxDelayMs = 60000 + ) + + $retryableStatus = @(429, 500, 502, 503, 504) + $attempt = 0 + + while ($true) { + $attempt++ + + try { + $params = @{ Uri = $Uri; Method = $Method; ErrorAction = 'Stop' } + if ($null -ne $Body) { + $params['Body'] = ($Body -is [string]) ? $Body : ($Body | ConvertTo-Json -Depth 16 -Compress) + $params['ContentType'] = 'application/json' + } + + return Invoke-MgGraphRequest @params + } + catch { + $status = Get-PersonaGraphStatusCode -ErrorRecord $_ + $isRetryable = ($null -eq $status) -or ($status -in $retryableStatus) + + if (-not $isRetryable) { + # A definite client-side failure. Retrying would hide a + # configuration or authorization defect behind a timeout. + throw + } + + if ($attempt -ge $MaxAttempts) { + throw "Graph request failed after $attempt attempt(s) (last status: $status): $($_.Exception.Message)" + } + + $retryAfter = Get-PersonaRetryAfterMs -ErrorRecord $_ + + if ($null -ne $retryAfter) { + $delay = [Math]::Min($retryAfter, $MaxDelayMs) + } + else { + # Exponential with full jitter: a uniform draw from [0, backoff] + # rather than backoff itself, so concurrent callers do not retry in + # lockstep and re-create the throttling they are backing off from. + $backoff = [Math]::Min($BaseDelayMs * [Math]::Pow(2, $attempt - 1), $MaxDelayMs) + $delay = Get-Random -Minimum 0 -Maximum ([int]$backoff) + } + + Write-Verbose "Graph request attempt $attempt failed with status $status; retrying in $delay ms." + Start-Sleep -Milliseconds $delay + } + } +} + +function Get-PersonaGraphStatusCode { + <# + .SYNOPSIS + Extracts an HTTP status code from a Graph error record. Internal helper. + + .DESCRIPTION + Tries the structured properties first, then falls back to the message text. + + The message fallback is not cosmetic. Invoke-MgGraphRequest does not always + surface a Response object, and several of its failure paths put the status + only in prose: "Response status code does not indicate success: 403 + (Forbidden)." Without the fallback those failures return $null, which the + retry policy treats as a transport error and retries - so a single 403 becomes + five requests per account, hammering a tenant that is already refusing and + turning an instant authorization failure into a long, expensive one. + + A genuine transport failure still returns $null and is still retried. The + distinction being drawn is "no status exists" versus "the status was not in + the property I looked at first". + #> + [CmdletBinding()] + param([Parameter(Mandatory)] $ErrorRecord) + + $response = $ErrorRecord.Exception.PSObject.Properties['Response'] + if ($response -and $response.Value) { + $code = $response.Value.PSObject.Properties['StatusCode'] + if ($code -and $code.Value) { return [int]$code.Value } + } + + if ($ErrorRecord.Exception.PSObject.Properties['StatusCode']) { + return [int]$ErrorRecord.Exception.StatusCode + } + + $message = [string]$ErrorRecord.Exception.Message + + foreach ($pattern in @( + 'status code does not indicate success:\s*(\d{3})' + 'status(?:\s*code)?[\s:=]+(\d{3})' + 'HTTP\s+(\d{3})' + '\((\d{3})\)' + )) { + if ($message -match $pattern) { + $parsed = [int]$Matches[1] + # Only real HTTP error codes. A three-digit number elsewhere in a message + # is not a status, and guessing one would suppress a legitimate retry. + if ($parsed -ge 400 -and $parsed -le 599) { return $parsed } + } + } + + # No status anywhere: a transport failure. Retryable. + return $null +} + +function Get-PersonaRetryAfterMs { + <# + .SYNOPSIS + Reads a Retry-After header, in milliseconds, if present. Internal helper. + #> + [CmdletBinding()] + param([Parameter(Mandatory)] $ErrorRecord) + + try { + $headers = $ErrorRecord.Exception.Response.Headers + if (-not $headers) { return $null } + + $value = $null + if ($headers.PSObject.Properties['RetryAfter'] -and $headers.RetryAfter.Delta) { + $value = [int]$headers.RetryAfter.Delta.TotalSeconds + } + + if ($null -ne $value -and $value -gt 0) { return $value * 1000 } + } + catch { + return $null + } + + return $null +} diff --git a/src/DataProviders/PersonaDataCache.ps1 b/src/DataProviders/PersonaDataCache.ps1 new file mode 100644 index 0000000..92179d5 --- /dev/null +++ b/src/DataProviders/PersonaDataCache.ps1 @@ -0,0 +1,99 @@ +function New-PersonaDataCache { + <# + .SYNOPSIS + Creates a run-scoped cache for group and role lookups (NFR-002, FR-006). + + .DESCRIPTION + The cache exists for exactly one run and is discarded with it. It is never + written to disk and never reused across runs — a stale membership record + surviving into a later run would reclassify accounts from data nobody + checked, which is the same failure mode FR-013 guards against, arriving by a + different route. + + Scope is deliberately narrow. Only lookups that are stable for the duration + of a single run are cached: a user's membership facets, keyed by Object ID. + Nothing derived from a rule evaluation is cached, so the cache can never + change a decision — only how many times the same question is asked. + + .OUTPUTS + PersonaEngine.DataCache + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param() + + [pscustomobject]@{ + PSTypeName = 'PersonaEngine.DataCache' + Membership = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + Hits = 0 + Misses = 0 + } +} + +function Get-PersonaCachedMembership { + <# + .SYNOPSIS + Returns a cached MembershipRecord, or retrieves and caches one. + + .DESCRIPTION + A failed lookup is cached alongside a successful one. That is intentional: + retrying the same failing endpoint once per rule would multiply load on an + endpoint already in trouble, and a user's classification must not depend on + which attempt happened to succeed. One answer per user per run, whatever it + was. + + .PARAMETER Cache + The run-scoped cache from New-PersonaDataCache. When omitted, the retrieval + runs uncached — the offline test path uses this. + + .PARAMETER UserObjectId + The user to resolve. + + .PARAMETER NeedDirect + Direct group membership is required by at least one enabled rule. + + .PARAMETER NeedTransitive + Transitive group membership is required by at least one enabled rule. + + .PARAMETER NeedRoles + Directory role assignments are required by at least one enabled rule. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [AllowNull()] + [object] $Cache, + + [Parameter(Mandatory)] + [string] $UserObjectId, + + [switch] $NeedDirect, + [switch] $NeedTransitive, + [switch] $NeedRoles + ) + + if ($null -eq $Cache) { + return Get-PersonaGroupMembership -UserObjectId $UserObjectId ` + -NeedDirect:$NeedDirect -NeedTransitive:$NeedTransitive -NeedRoles:$NeedRoles + } + + # The key includes the requested facets. A record fetched for direct membership + # only cannot answer a transitive question, and returning it would present an + # unretrieved facet as though it had been checked. + $key = '{0}|{1}{2}{3}' -f $UserObjectId, [int]$NeedDirect.IsPresent, [int]$NeedTransitive.IsPresent, [int]$NeedRoles.IsPresent + + $existing = $null + if ($Cache.Membership.TryGetValue($key, [ref] $existing)) { + $Cache.Hits++ + return $existing + } + + $Cache.Misses++ + $record = Get-PersonaGroupMembership -UserObjectId $UserObjectId ` + -NeedDirect:$NeedDirect -NeedTransitive:$NeedTransitive -NeedRoles:$NeedRoles + + $Cache.Membership[$key] = $record + $record +} + diff --git a/src/Engine/Invoke-PersonaEngineRun.ps1 b/src/Engine/Invoke-PersonaEngineRun.ps1 new file mode 100644 index 0000000..cdf7bfd --- /dev/null +++ b/src/Engine/Invoke-PersonaEngineRun.ps1 @@ -0,0 +1,242 @@ +function Invoke-PersonaEngineRun { + <# + .SYNOPSIS + The classification run loop: retrieve, evaluate, compare, report, persist. + + .DESCRIPTION + Lives in the module rather than in Invoke-PersonaEngine.ps1 so it can be + exercised offline with mocked data providers. That is not a testing + convenience - SC-004 requires proof that a -WhatIf run issues zero writes + across a full population, and a loop that only exists inside an entry script + needing a live tenant cannot be proven at all. What ships and what is tested + are the same code. + + The write gate is supplied by the caller as a scriptblock, not re-derived + here. Invoke-PersonaEngine.ps1 passes one that closes over + $PSCmdlet.ShouldProcess, so there is still exactly one origin for the + decision (Principle III); this function does not know what -WhatIf is and + cannot accidentally disagree with it. + + Per-user failures are contained. A membership lookup that fails yields + EvaluationError for that account and the run continues; a write that fails + yields UpdateFailed and the run continues. Only enumeration and + authentication failures end a run, because those affect the whole population + rather than one account. + + .PARAMETER Configuration + The loaded configuration. + + .PARAMETER TargetAttribute + The resolved target attribute. + + .PARAMETER Context + The audit context. + + .PARAMETER AuditParameters + Splat for Write-PersonaAuditRecord. + + .PARAMETER IsEnforcing + Whether the run-level gate returned true. Controls the Action assigned by + Compare-PersonaValue; the per-user gate below still applies. + + .PARAMETER ShouldProcessGate + Scriptblock taking (upn, description) and returning a boolean. Called once + per user that would otherwise be written. Defaults to a gate that always + refuses - the safe default, so a caller that forgets to supply one previews + rather than writes. + + .PARAMETER UserObjectId + Single-user run. + + .PARAMETER Tracing + Include ConditionTrace on results and audit records. + + .OUTPUTS + PersonaEngine.RunOutcome carrying the counters and the exit code. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [object] $Configuration, + + [Parameter(Mandatory)] + [string] $TargetAttribute, + + [Parameter(Mandatory)] + [object] $Context, + + [hashtable] $AuditParameters = @{ Destination = 'none' }, + + [switch] $IsEnforcing, + + [scriptblock] $ShouldProcessGate = { param($Target, $Description) $false }, + + [string] $UserObjectId, + + [switch] $Tracing + ) + + $EXIT_OK = 0 + $EXIT_ENUMERATION = 3 + $EXIT_DATA = 4 + $EXIT_RECONCILIATION = 5 + + $exitCode = $EXIT_OK + + $facets = Get-PersonaRequiredFacets -Rules $Configuration.Rules -DefaultMembershipMode $Configuration.DefaultMembershipMode + $needMembership = [bool]($facets.Direct -or $facets.Transitive -or $facets.Roles) + + $selectProperties = Get-PersonaRequiredProperties -Configuration $Configuration + $cache = New-PersonaDataCache + $counters = New-PersonaRunCounter -Rules $Configuration.Rules + + $users = $null + try { + $users = $UserObjectId ` + ? @(Get-PersonaUsers -SelectProperties $selectProperties -UserObjectId $UserObjectId) ` + : @(Get-PersonaUsers -SelectProperties $selectProperties) + } + catch { + # A partial population is worse than none: half a tenant classified looks + # like a successful run to everything downstream. + return New-PersonaRunOutcome -Counters $counters -ExitCode $EXIT_ENUMERATION -FailureReason $_.Exception.Message + } + + foreach ($graphUser in $users) { + + $membership = $null + if ($needMembership) { + $membership = Get-PersonaCachedMembership -Cache $cache ` + -UserObjectId ([string](Get-PersonaMemberValue -Item $graphUser -Key 'id')) ` + -NeedDirect:([bool]$facets.Direct) ` + -NeedTransitive:([bool]$facets.Transitive) ` + -NeedRoles:([bool]$facets.Roles) + } + + $record = ConvertTo-PersonaUserRecord -GraphUser $graphUser -TargetAttribute $TargetAttribute -Membership $membership + + $result = Resolve-UserPersona -UserRecord $record -Rules $Configuration.Rules ` + -MaxDepth $Configuration.MaxConditionDepth ` + -DefaultMembershipMode $Configuration.DefaultMembershipMode ` + -IncludeTrace:$Tracing + + $result = Compare-PersonaValue -Result $result -IsEnforcing:$IsEnforcing ` + -TargetAttribute $TargetAttribute -ApprovedWritableAttributes $Configuration.ApprovedWritableAttributes + + $previousValue = $null + + # The only branch from which a write is reachable. Compare-PersonaValue has + # already applied FR-016 conditions 1-3; the gate below is condition 4. + if ($result.Action -eq 'Updated') { + + $description = "Set '$TargetAttribute' to '$($result.CalculatedPersona)'" + + if (& $ShouldProcessGate $result.UserPrincipalName $description) { + + # Captured before the PATCH, never read back afterwards - a read-back + # returns the new value, and OTD-010 rollback needs the old one. + $previousValue = [string]$result.StoredPersona + + $write = Set-UserPersonaAttribute ` + -UserObjectId $result.AccountObjectId ` + -AttributeName $TargetAttribute ` + -Value ([string]$result.CalculatedPersona) ` + -PreviousValue $previousValue ` + -TargetAttribute $TargetAttribute ` + -ApprovedWritableAttributes $Configuration.ApprovedWritableAttributes ` + -Confirmed + + if (-not $write.Succeeded) { + $result.Action = 'UpdateFailed' + $previousValue = $null + } + } + else { + $result.Action = 'WouldUpdate' + } + } + + Write-UserPersonaResult -Result $result + + Add-PersonaRunResult -Counters $counters -Result $result + + New-PersonaAuditRecord -Context $Context -RecordType 'UserEvent' -Result $result ` + -PreviousValue $previousValue -IncludeTrace:$Tracing | + Write-PersonaAuditRecord @AuditParameters + + if ($Configuration.SummaryInterval -gt 0 -and ($counters.Processed % $Configuration.SummaryInterval) -eq 0) { + + Write-PersonaSummary -Counters $counters -SummaryType 'Interim' -Mode $Context.Mode + + New-PersonaAuditRecord -Context $Context -RecordType 'Summary' -Counters $counters ` + -Properties @{ summaryType = 'Interim' } | Write-PersonaAuditRecord @AuditParameters + + if (-not (Test-PersonaReconciliation -Counters $counters)) { + $exitCode = $EXIT_RECONCILIATION + + New-PersonaAuditRecord -Context $Context -RecordType 'EngineDefect' ` + -Properties (Get-PersonaReconciliationDetail -Counters $counters) | + Write-PersonaAuditRecord @AuditParameters + } + } + } + + # Always emitted, whatever the interval - including 0 (FR-020). + Write-PersonaSummary -Counters $counters -SummaryType 'Final' -Mode $Context.Mode + + New-PersonaAuditRecord -Context $Context -RecordType 'Summary' -Counters $counters ` + -Properties @{ summaryType = 'Final' } | Write-PersonaAuditRecord @AuditParameters + + if (-not (Test-PersonaReconciliation -Counters $counters)) { + $exitCode = $EXIT_RECONCILIATION + + New-PersonaAuditRecord -Context $Context -RecordType 'EngineDefect' ` + -Properties (Get-PersonaReconciliationDetail -Counters $counters) | + Write-PersonaAuditRecord @AuditParameters + } + elseif ($null -ne $Configuration.EvaluationErrorThreshold -and + $counters.EvaluationError -gt $Configuration.EvaluationErrorThreshold) { + + # Past this count the population was classified from data that could not be + # trusted. Stored values were preserved (FR-014), so nothing is damaged - but + # reporting success would invite someone to draw conclusions from the run. + $exitCode = $EXIT_DATA + + New-PersonaAuditRecord -Context $Context -RecordType 'EngineDefect' -Properties @{ + severity = 'Error' + defect = 'EvaluationErrorThresholdExceeded' + evaluationError = [int]$counters.EvaluationError + threshold = [int]$Configuration.EvaluationErrorThreshold + processed = [int]$counters.Processed + description = 'Too many accounts could not be evaluated from trusted data. Stored personas were preserved; no classification conclusion should be drawn from this run.' + } | Write-PersonaAuditRecord @AuditParameters + } + + New-PersonaRunOutcome -Counters $counters -ExitCode $exitCode +} + +function New-PersonaRunOutcome { + <# + .SYNOPSIS + Wraps the run result for the entry script. + + .DESCRIPTION + Carries the counters and the exit code together, so the caller cannot report + an exit code that disagrees with the numbers it prints. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] [object] $Counters, + [Parameter(Mandatory)] [int] $ExitCode, + [string] $FailureReason + ) + + [pscustomobject]@{ + PSTypeName = 'PersonaEngine.RunOutcome' + Counters = $Counters + ExitCode = $ExitCode + FailureReason = $FailureReason + } +} diff --git a/src/Normalization/ConvertTo-PersonaMembershipRecord.ps1 b/src/Normalization/ConvertTo-PersonaMembershipRecord.ps1 new file mode 100644 index 0000000..1d0ab82 --- /dev/null +++ b/src/Normalization/ConvertTo-PersonaMembershipRecord.ps1 @@ -0,0 +1,175 @@ +function ConvertTo-PersonaMembershipRecord { + <# + .SYNOPSIS + Converts raw membership responses into a normalized MembershipRecord. + + .DESCRIPTION + The membership half of the normalization boundary (Principle IV). Callers + that already hold raw Graph collections — the offline replay path, and any + future provider that fetches membership in bulk rather than per user — use + this instead of reaching for New-PersonaMembershipRecord directly, so the + filtering rules live in one place. + + Two filtering rules are applied and are the reason this function exists + rather than a straight constructor call: + + 1. memberOf and transitiveMemberOf return directory objects of mixed type. + Only `#microsoft.graph.group` entries become group Object IDs; + administrative units and directory roles arriving on that endpoint are + discarded. An administrative unit ID treated as a group ID would never + match, which reads as "not a member" — a false non-match, the exact + outcome FR-013 exists to prevent. + + 2. Role assignments are reduced to their roleDefinitionId (the role template + ID), which is stable across tenants. Assignment instance IDs are not, so + a configuration written against them would not survive a tenant move. + + Retrieval status is supplied by the caller, never inferred from an empty + collection. An empty list means "checked, member of nothing"; only an unset + flag means "unknown". + + .PARAMETER DirectMemberOf + Raw objects from the memberOf endpoint. Omit when not retrieved. + + .PARAMETER TransitiveMemberOf + Raw objects from the transitiveMemberOf endpoint. Omit when not retrieved. + + .PARAMETER RoleAssignments + Raw objects from the roleAssignments endpoint. Omit when not retrieved. + + .PARAMETER DirectRetrieved + The direct lookup completed. + + .PARAMETER TransitiveRetrieved + The transitive lookup completed. + + .PARAMETER RolesRetrieved + The role lookup completed. + + .PARAMETER DirectFailureReason + Sanitized reason the direct lookup failed. + + .PARAMETER TransitiveFailureReason + Sanitized reason the transitive lookup failed. + + .PARAMETER RolesFailureReason + Sanitized reason the role lookup failed. + + .EXAMPLE + ConvertTo-PersonaMembershipRecord -DirectMemberOf $raw -DirectRetrieved + + .OUTPUTS + PersonaEngine.MembershipRecord + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [AllowNull()] + [object[]] $DirectMemberOf, + + [AllowNull()] + [object[]] $TransitiveMemberOf, + + [AllowNull()] + [object[]] $RoleAssignments, + + [switch] $DirectRetrieved, + [switch] $TransitiveRetrieved, + [switch] $RolesRetrieved, + + [string] $DirectFailureReason, + [string] $TransitiveFailureReason, + [string] $RolesFailureReason + ) + + $params = @{ + DirectGroupObjectIds = ConvertTo-PersonaGroupIdList -Objects $DirectMemberOf + TransitiveGroupObjectIds = ConvertTo-PersonaGroupIdList -Objects $TransitiveMemberOf + DirectoryRoleIds = ConvertTo-PersonaRoleIdList -Objects $RoleAssignments + } + + if ($DirectRetrieved) { $params['DirectRetrieved'] = $true } + if ($TransitiveRetrieved) { $params['TransitiveRetrieved'] = $true } + if ($RolesRetrieved) { $params['RolesRetrieved'] = $true } + + if ($DirectFailureReason) { $params['DirectFailureReason'] = $DirectFailureReason } + if ($TransitiveFailureReason) { $params['TransitiveFailureReason'] = $TransitiveFailureReason } + if ($RolesFailureReason) { $params['RolesFailureReason'] = $RolesFailureReason } + + New-PersonaMembershipRecord @params +} + +function ConvertTo-PersonaGroupIdList { + <# + .SYNOPSIS + Extracts group Object IDs from a mixed directory-object collection. + + .DESCRIPTION + Entries carrying an @odata.type other than #microsoft.graph.group are + discarded. An entry with no @odata.type is kept: the membership endpoints + omit the annotation when the collection is homogeneous, and discarding those + would silently empty the list. + #> + [CmdletBinding()] + [OutputType([string[]])] + param([AllowNull()] [object[]] $Objects) + + $ids = [System.Collections.Generic.List[string]]::new() + + foreach ($item in @($Objects)) { + if ($null -eq $item) { continue } + + $type = Get-PersonaMemberValue -Item $item -Key '@odata.type' + if ($type -and $type -ne '#microsoft.graph.group') { continue } + + $id = Get-PersonaMemberValue -Item $item -Key 'id' + if ($id) { $ids.Add([string]$id) } + } + + , $ids.ToArray() +} + +function ConvertTo-PersonaRoleIdList { + <# + .SYNOPSIS + Extracts role template IDs from a role-assignment collection. + #> + [CmdletBinding()] + [OutputType([string[]])] + param([AllowNull()] [object[]] $Objects) + + $ids = [System.Collections.Generic.List[string]]::new() + + foreach ($item in @($Objects)) { + if ($null -eq $item) { continue } + + $id = Get-PersonaMemberValue -Item $item -Key 'roleDefinitionId' + if ($id) { $ids.Add([string]$id) } + } + + , $ids.ToArray() +} + +function Get-PersonaMemberValue { + <# + .SYNOPSIS + Reads a key from either a hashtable or an object. + + .DESCRIPTION + Invoke-MgGraphRequest returns hashtables; fixtures loaded from JSON arrive as + PSCustomObjects. Both shapes reach normalization, so both are handled here + rather than forcing every call site to know which it has. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Item, + [Parameter(Mandatory)] [string] $Key + ) + + if ($Item -is [System.Collections.IDictionary]) { + return $Item.Contains($Key) ? $Item[$Key] : $null + } + + $prop = $Item.PSObject.Properties[$Key] + $prop ? $prop.Value : $null +} diff --git a/src/Normalization/ConvertTo-PersonaUserRecord.ps1 b/src/Normalization/ConvertTo-PersonaUserRecord.ps1 new file mode 100644 index 0000000..7b3e3a9 --- /dev/null +++ b/src/Normalization/ConvertTo-PersonaUserRecord.ps1 @@ -0,0 +1,80 @@ +function ConvertTo-PersonaUserRecord { + <# + .SYNOPSIS + Converts a raw Graph user object into a normalized UserRecord. + + .DESCRIPTION + The normalization boundary (Principle IV). Everything downstream of this + function is testable offline with synthetic data, because nothing downstream + knows Graph exists. + + Graph returns hashtables from Invoke-MgGraphRequest, with camelCase keys and + the persona value under its full extension property name. + + .PARAMETER GraphUser + The raw object from Get-PersonaUsers. + + .PARAMETER TargetAttribute + Name of the persona attribute, read into StoredPersona. + + .PARAMETER Membership + Optional MembershipRecord. Omitted when no rule needs membership data. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [object] $GraphUser, + + [Parameter(Mandatory)] + [string] $TargetAttribute, + + [object] $Membership + ) + + process { + $get = { + param($key) + if ($GraphUser -is [System.Collections.IDictionary]) { + return $GraphUser.Contains($key) ? $GraphUser[$key] : $null + } + $prop = $GraphUser.PSObject.Properties[$key] + return $prop ? $prop.Value : $null + } + + $id = & $get 'id' + $upn = & $get 'userPrincipalName' + + if (-not $id -or -not $upn) { + # An identity-less record is an upstream defect, not a user to skip. + # Skipping would silently shrink the population and still reconcile. + throw "Graph user object is missing 'id' or 'userPrincipalName'; cannot normalize." + } + + $properties = @{ + CompanyName = & $get 'companyName' + JobTitle = & $get 'jobTitle' + Department = & $get 'department' + } + + # Any additional selected property, including extension attributes, is + # carried through so a rule can reference it without a code change. + $keys = ($GraphUser -is [System.Collections.IDictionary]) ? $GraphUser.Keys : $GraphUser.PSObject.Properties.Name + foreach ($key in $keys) { + if ($key -like '@odata*') { continue } + if (-not $properties.ContainsKey($key)) { $properties[$key] = & $get $key } + } + + $enabled = & $get 'accountEnabled' + + New-PersonaUserRecord ` + -AccountObjectId ([string]$id) ` + -UserPrincipalName ([string]$upn) ` + -DisplayName ([string](& $get 'displayName')) ` + -UserType ([string](& $get 'userType')) ` + -AccountEnabled ($null -eq $enabled ? $true : [bool]$enabled) ` + -Properties $properties ` + -StoredPersona ([string](& $get $TargetAttribute)) ` + -Membership $Membership + } +} diff --git a/src/Normalization/New-PersonaMembershipRecord.ps1 b/src/Normalization/New-PersonaMembershipRecord.ps1 new file mode 100644 index 0000000..e03f852 --- /dev/null +++ b/src/Normalization/New-PersonaMembershipRecord.ps1 @@ -0,0 +1,97 @@ +function New-PersonaMembershipRecord { + <# + .SYNOPSIS + Creates a normalized MembershipRecord. + + .DESCRIPTION + Holds three independently-retrieved facets, each with its own retrieval + status: direct group membership, transitive group membership, and directory + role assignments. + + Three facets rather than one "mode" because RE-007 makes membership mode a + per-condition choice. A single rule set may legitimately ask for transitive + membership in one rule and direct membership in another, so a record + carrying only one mode cannot answer both — every user would become an + EvaluationError on whichever question the record could not serve. + + Independent statuses also mean a failure is contained: if the transitive + lookup times out but the direct lookup succeeded, only conditions that need + transitive data become Unknown. Collapsing them into one flag would turn one + slow endpoint into a tenant-wide outage. + + Every Retrieved flag defaults to $false. An unset flag means "unknown", + never "not a member" — so a forgotten flag degrades to EvaluationError + (FR-013) instead of silently misclassifying a privileged account. + + .PARAMETER DirectGroupObjectIds + Groups the user is a direct member of. + + .PARAMETER TransitiveGroupObjectIds + Groups the user is a transitive member of. + + .PARAMETER DirectoryRoleIds + Directory roles assigned to the user. + + .PARAMETER DirectRetrieved + Set only when the direct membership lookup genuinely completed. + + .PARAMETER TransitiveRetrieved + Set only when the transitive membership lookup genuinely completed. + + .PARAMETER RolesRetrieved + Set only when the role lookup genuinely completed. + + .EXAMPLE + New-PersonaMembershipRecord -DirectGroupObjectIds $ids -DirectRetrieved + + .EXAMPLE + New-PersonaMembershipRecord -TransitiveFailureReason 'Graph 503 after 5 attempts' + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [string[]] $DirectGroupObjectIds = @(), + [string[]] $TransitiveGroupObjectIds = @(), + [string[]] $DirectoryRoleIds = @(), + + [switch] $DirectRetrieved, + [switch] $TransitiveRetrieved, + [switch] $RolesRetrieved, + + [string] $DirectFailureReason, + [string] $TransitiveFailureReason, + [string] $RolesFailureReason, + + # Marks every facet as retrieved. Convenience for tests and for the common + # production case where all required lookups succeeded. + [switch] $AllRetrieved + ) + + foreach ($facet in @( + @{ Name = 'Direct'; Retrieved = $DirectRetrieved; Reason = $DirectFailureReason } + @{ Name = 'Transitive'; Retrieved = $TransitiveRetrieved; Reason = $TransitiveFailureReason } + @{ Name = 'Roles'; Retrieved = $RolesRetrieved; Reason = $RolesFailureReason } + )) { + # A success claim alongside a failure reason is a caller bug, not a state + # to interpret. Fail loudly rather than guess which was meant. + if (($facet.Retrieved -or $AllRetrieved) -and $facet.Reason) { + throw "Membership facet '$($facet.Name)' cannot be both retrieved and carry a failure reason." + } + } + + [pscustomobject]@{ + PSTypeName = 'PersonaEngine.MembershipRecord' + + DirectGroupObjectIds = @($DirectGroupObjectIds) + DirectRetrieved = [bool]($DirectRetrieved -or $AllRetrieved) + DirectFailureReason = $DirectFailureReason + + TransitiveGroupObjectIds = @($TransitiveGroupObjectIds) + TransitiveRetrieved = [bool]($TransitiveRetrieved -or $AllRetrieved) + TransitiveFailureReason = $TransitiveFailureReason + + DirectoryRoleIds = @($DirectoryRoleIds) + RolesRetrieved = [bool]($RolesRetrieved -or $AllRetrieved) + RolesFailureReason = $RolesFailureReason + } +} diff --git a/src/Normalization/New-PersonaUserRecord.ps1 b/src/Normalization/New-PersonaUserRecord.ps1 new file mode 100644 index 0000000..e4c3625 --- /dev/null +++ b/src/Normalization/New-PersonaUserRecord.ps1 @@ -0,0 +1,100 @@ +function New-PersonaUserRecord { + <# + .SYNOPSIS + Creates a normalized UserRecord — the only user shape the rule engine sees. + + .DESCRIPTION + Constitution Principle IV: the rule engine must never receive a raw directory + response. This function is that boundary. Everything downstream of it is + testable offline with synthetic data. + + Properties are stored in a case-insensitive dictionary so rule authors need + not match directory casing (RE-006). An absent property returns $null, which + ordinary string comparisons treat as empty (FR-012). + + .PARAMETER AccountObjectId + Immutable directory Object ID. Required; approved for logs. + + .PARAMETER UserPrincipalName + Required; approved for logs. + + .PARAMETER Properties + Evaluable property values. Copied into a case-insensitive dictionary. + + .PARAMETER Membership + A MembershipRecord. When omitted, an empty record with every facet marked + unretrieved is used, so any membership condition evaluated against it yields + Unknown rather than a false non-match. A rule set with no membership + conditions never consults it. + + .EXAMPLE + New-PersonaUserRecord -AccountObjectId $id -UserPrincipalName $upn -Properties @{ Department = 'Finance' } + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccountObjectId, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $UserPrincipalName, + + [string] $DisplayName, + + [string] $UserType, + + [bool] $AccountEnabled = $true, + + [hashtable] $Properties = @{}, + + [AllowNull()] + [string] $StoredPersona, + + [AllowNull()] + [pscustomobject] $Membership + ) + + # StringComparer::OrdinalIgnoreCase gives case-insensitive lookup without the + # cost of normalizing every key on every read. + $bag = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + + foreach ($key in $Properties.Keys) { + $bag[[string]$key] = $Properties[$key] + } + + # Intrinsic fields are also addressable as properties so a rule can target + # UserPrincipalName or AccountEnabled without a separate condition type. + # Explicit entries in -Properties win, so a caller can override for testing. + foreach ($intrinsic in @( + @{ Name = 'AccountObjectId'; Value = $AccountObjectId } + @{ Name = 'UserPrincipalName'; Value = $UserPrincipalName } + @{ Name = 'DisplayName'; Value = $DisplayName } + @{ Name = 'UserType'; Value = $UserType } + @{ Name = 'AccountEnabled'; Value = $AccountEnabled } + )) { + if (-not $bag.ContainsKey($intrinsic.Name)) { + $bag[$intrinsic.Name] = $intrinsic.Value + } + } + + if ($null -eq $Membership) { + # Unretrieved, not empty: an absent lookup is unknown, never "member of + # nothing". The safe default is the one that produces EvaluationError. + $Membership = New-PersonaMembershipRecord + } + + [pscustomobject]@{ + PSTypeName = 'PersonaEngine.UserRecord' + AccountObjectId = $AccountObjectId + UserPrincipalName = $UserPrincipalName + DisplayName = $DisplayName + UserType = $UserType + AccountEnabled = $AccountEnabled + Properties = $bag + StoredPersona = $StoredPersona + Membership = $Membership + } +} diff --git a/src/Persistence/Compare-PersonaValue.ps1 b/src/Persistence/Compare-PersonaValue.ps1 new file mode 100644 index 0000000..4728000 --- /dev/null +++ b/src/Persistence/Compare-PersonaValue.ps1 @@ -0,0 +1,70 @@ +function Compare-PersonaValue { + <# + .SYNOPSIS + Decides the action for a decision result by comparing stored and calculated + values (FR-015, FR-016). + + .DESCRIPTION + Sets Action on the result and returns it. The state machine (data-model.md): + + EvaluationError -> Skipped no write, ever + Calculated == Stored -> Unchanged + Calculated != Stored, preview mode -> WouldUpdate no request built + Calculated != Stored, enforce mode -> Updated / UpdateFailed + + Comparison is ORDINAL and case-sensitive, unlike rule evaluation. A stored + value of 'employee' against a calculated 'Employee' is a real difference + worth correcting, and treating it as equal would leave the directory + permanently inconsistent with the rule set. Rule matching stays + case-insensitive (RE-006); only change detection is exact. + + .PARAMETER Result + A PersonaDecisionResult. + + .PARAMETER IsEnforcing + Whether the caller's ShouldProcess gate returned true. + + .PARAMETER TargetAttribute + The configured target attribute. + + .PARAMETER ApprovedWritableAttributes + The approved list. A target absent from it can never be written. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [object] $Result, + + [switch] $IsEnforcing, + + [string] $TargetAttribute, + + [string[]] $ApprovedWritableAttributes = @() + ) + + if ($Result.Outcome -eq 'EvaluationError') { + # FR-014: preserve the stored value. No write is attempted, and this is the + # only branch that must never be reachable by any later condition. + $Result.Action = 'Skipped' + return $Result + } + + $stored = [string]$Result.StoredPersona + $calculated = [string]$Result.CalculatedPersona + + if ([string]::Equals($stored, $calculated, [System.StringComparison]::Ordinal)) { + $Result.Action = 'Unchanged' + return $Result + } + + # A target that is blank or unapproved can never be written, whatever the mode. + $targetValid = $TargetAttribute -and ($TargetAttribute -in $ApprovedWritableAttributes) + if (-not $targetValid) { + $Result.Action = 'Skipped' + return $Result + } + + $Result.Action = $IsEnforcing ? 'Updated' : 'WouldUpdate' + return $Result +} diff --git a/src/Persistence/New-PersonaWriteBody.ps1 b/src/Persistence/New-PersonaWriteBody.ps1 new file mode 100644 index 0000000..1682447 --- /dev/null +++ b/src/Persistence/New-PersonaWriteBody.ps1 @@ -0,0 +1,78 @@ +function New-PersonaWriteBody { + <# + .SYNOPSIS + Builds the PATCH body for a persona write — the only function permitted to + do so (SC-005, NFR-006). + + .DESCRIPTION + Returns a hashtable whose Count is exactly 1. Nothing else in the codebase + constructs a directory write body, so the single-attribute guarantee is a + property of one testable function rather than a convention every call site + must remember. + + Two rejections are enforced here, both throwing rather than returning a + corrected body. A caller that asked to write the wrong attribute has a + defect; quietly substituting the right one would hide it until the day the + substitution was also wrong. + + 1. AttributeName must equal the configured target attribute. + 2. The target attribute must appear in approvedWritableAttributes. + + The second check is deliberately redundant with configuration validation. + Validation runs once at startup against the file; this runs on every write + against the values actually in hand, so a configuration object mutated + mid-run still cannot widen the blast radius. + + .PARAMETER AttributeName + The attribute to write. Must equal TargetAttribute. + + .PARAMETER Value + The calculated persona. May be an empty string to clear the attribute; may + not be $null, which Graph would interpret as a removal the engine never + intends to request implicitly. + + .PARAMETER TargetAttribute + The configured target attribute. + + .PARAMETER ApprovedWritableAttributes + The approved list from configuration. + + .EXAMPLE + New-PersonaWriteBody -AttributeName $t -Value 'Employee' -TargetAttribute $t -ApprovedWritableAttributes @($t) + + .OUTPUTS + System.Collections.Hashtable with exactly one key. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AttributeName, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Value, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $TargetAttribute, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [string[]] $ApprovedWritableAttributes + ) + + # Ordinal comparison: extension property names are case-sensitive in Graph, and + # a case difference here means the caller is not writing the attribute the + # configuration approved. + if (-not [string]::Equals($AttributeName, $TargetAttribute, [System.StringComparison]::Ordinal)) { + throw "Refusing to build a write body for '$AttributeName': only the configured target attribute may be written." + } + + if ($TargetAttribute -cnotin $ApprovedWritableAttributes) { + throw "Refusing to build a write body for '$TargetAttribute': the attribute is not present in approvedWritableAttributes." + } + + @{ $AttributeName = $Value } +} diff --git a/src/Persistence/Set-UserPersonaAttribute.ps1 b/src/Persistence/Set-UserPersonaAttribute.ps1 new file mode 100644 index 0000000..190a9db --- /dev/null +++ b/src/Persistence/Set-UserPersonaAttribute.ps1 @@ -0,0 +1,110 @@ +function Set-UserPersonaAttribute { + <# + .SYNOPSIS + Writes the calculated persona to one user (FR-016). + + .DESCRIPTION + Issues PATCH /v1.0/users/{id} with a body built by New-PersonaWriteBody. + + This function is reachable only when the caller's ShouldProcess gate has + already returned true. It does not re-derive the mode and does not own a + preview flag of its own — a second source of truth for the write gate is + the defect class Principle III exists to prevent. What it does own is the + refusal to proceed without an explicit -Confirmed switch, so a call that + skipped the gate entirely fails loudly instead of writing. + + previousValue is captured here, at write time, from the value the engine + actually observed before the PATCH. Reading it back afterwards would return + the new value; deriving it from the decision result would record what the + engine believed rather than what it replaced. Without it, OTD-010 rollback + is impossible retroactively — no later run can reconstruct what a value used + to be. + + .PARAMETER UserObjectId + The user to update. + + .PARAMETER AttributeName + The attribute to write. Validated against the target by New-PersonaWriteBody. + + .PARAMETER Value + The calculated persona. + + .PARAMETER PreviousValue + The stored value observed before the write, recorded for rollback. + + .PARAMETER TargetAttribute + The configured target attribute. + + .PARAMETER ApprovedWritableAttributes + The approved list from configuration. + + .PARAMETER Confirmed + Asserts that the caller's ShouldProcess gate returned true. Required. + + .OUTPUTS + PersonaEngine.WriteResult + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $UserObjectId, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AttributeName, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Value, + + [AllowNull()] + [AllowEmptyString()] + [string] $PreviousValue, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $TargetAttribute, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [string[]] $ApprovedWritableAttributes, + + [switch] $Confirmed + ) + + if (-not $Confirmed) { + # Not a guess about intent. A caller that reached here without the gate has + # a control-flow defect, and the only safe response is to refuse. + throw 'Set-UserPersonaAttribute was called without a confirmed ShouldProcess gate. No write was attempted.' + } + + $body = New-PersonaWriteBody -AttributeName $AttributeName -Value $Value ` + -TargetAttribute $TargetAttribute -ApprovedWritableAttributes $ApprovedWritableAttributes + + $succeeded = $false + $failureReason = $null + + try { + $null = Invoke-PersonaGraphRequest -Uri "/v1.0/users/$UserObjectId" -Method 'PATCH' -Body $body + $succeeded = $true + } + catch { + # A failed write is a per-user outcome, not a run-ending one. The run + # continues and the count surfaces in the summary; the stored value is + # untouched because the PATCH did not land. + $failureReason = $_.Exception.Message + Write-Verbose "Write failed for $UserObjectId : $failureReason" + } + + [pscustomobject]@{ + PSTypeName = 'PersonaEngine.WriteResult' + AccountObjectId = $UserObjectId + AttributeName = $AttributeName + Value = $Value + PreviousValue = $PreviousValue + Succeeded = $succeeded + FailureReason = $failureReason + } +} diff --git a/src/Presentation/New-PersonaRunCounter.ps1 b/src/Presentation/New-PersonaRunCounter.ps1 new file mode 100644 index 0000000..c710692 --- /dev/null +++ b/src/Presentation/New-PersonaRunCounter.ps1 @@ -0,0 +1,120 @@ +function New-PersonaRunCounter { + <# + .SYNOPSIS + Creates the run counter set used by summaries and reconciliation (FR-019 - FR-021). + + .DESCRIPTION + Holds two independent tallies that must never be conflated: + + Outcome buckets Matched, Unclassified, EvaluationError - what the engine + decided. Mutually exclusive, and their sum must equal + Processed (SC-001, FR-021). + + Action buckets Unchanged, WouldUpdate, Updated, UpdateFailed, Skipped - + what happened to the directory. Also mutually exclusive, + but they do NOT reconcile against Processed, because a + user can be Matched and Unchanged at the same time. + + Reconciliation checks the outcome buckets only. Checking the action buckets + instead would pass on a run that lost users, because Skipped absorbs + anything unexplained. + + RuleCounts is seeded from the full rule set, including disabled rules, at + construction. Seeding at construction rather than on first match is what + makes a zero-match rule distinguishable from an absent one - an operator + asking "did RULE-0030 fire?" gets "no, zero matches" rather than silence. + + .PARAMETER Rules + The business rule collection, used to seed RuleCounts. + + .OUTPUTS + PersonaEngine.RunCounter + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $Rules + ) + + $ruleCounts = [System.Collections.Generic.List[object]]::new() + + foreach ($rule in (@($Rules) | Sort-Object -Property @{ Expression = { [int]$_.priority } }, @{ Expression = { [string]$_.id } })) { + $ruleCounts.Add([pscustomobject]@{ + RuleId = [string]$rule.id + Name = [string]$rule.name + Priority = [int]$rule.priority + Enabled = [bool]$rule.enabled + Persona = [string]$rule.persona + Matches = 0 + }) + } + + [pscustomobject]@{ + PSTypeName = 'PersonaEngine.RunCounter' + + Processed = 0 + + Matched = 0 + Unclassified = 0 + EvaluationError = 0 + + Unchanged = 0 + WouldUpdate = 0 + Updated = 0 + UpdateFailed = 0 + Skipped = 0 + + RuleCounts = $ruleCounts + } +} + +function Add-PersonaRunResult { + <# + .SYNOPSIS + Records one decision result into the run counters. + + .DESCRIPTION + The only function that increments counters. A single entry point is what + makes reconciliation meaningful: if call sites incremented directly, a + missed increment would look identical to a lost user, and the reconciliation + check would be reporting on its own bookkeeping rather than on the run. + + Processed increments exactly once per result, before the outcome switch, so + an unrecognized outcome shows up as a reconciliation failure rather than + being quietly dropped. + + .PARAMETER Counters + The run counter set. + + .PARAMETER Result + A PersonaDecisionResult with both Outcome and Action populated. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Counters, + [Parameter(Mandatory)] [object] $Result + ) + + $Counters.Processed++ + + switch ([string]$Result.Outcome) { + 'Matched' { + $Counters.Matched++ + + $entry = $Counters.RuleCounts | Where-Object { $_.RuleId -eq [string]$Result.MatchedRuleId } | Select-Object -First 1 + if ($entry) { $entry.Matches++ } + } + 'Unclassified' { $Counters.Unclassified++ } + 'EvaluationError' { $Counters.EvaluationError++ } + } + + switch ([string]$Result.Action) { + 'Unchanged' { $Counters.Unchanged++ } + 'WouldUpdate' { $Counters.WouldUpdate++ } + 'Updated' { $Counters.Updated++ } + 'UpdateFailed' { $Counters.UpdateFailed++ } + 'Skipped' { $Counters.Skipped++ } + } +} diff --git a/src/Presentation/Test-PersonaReconciliation.ps1 b/src/Presentation/Test-PersonaReconciliation.ps1 new file mode 100644 index 0000000..2b5f5a9 --- /dev/null +++ b/src/Presentation/Test-PersonaReconciliation.ps1 @@ -0,0 +1,70 @@ +function Test-PersonaReconciliation { + <# + .SYNOPSIS + Verifies Processed = Matched + Unclassified + EvaluationError (FR-021, SC-007). + + .DESCRIPTION + Run at every summary and once at completion. Returns $true when the outcome + buckets account for every processed user. + + A mismatch is not a data condition and is never reported as one. Outcomes are + assigned by the engine, exactly one per user (SC-001), so if the totals do + not add up the engine lost a user or double-counted one. That is a defect in + this codebase, and the caller emits an EngineDefect record and exit code 5 + rather than folding the discrepancy into an ordinary counter where it would + be invisible. + + Deliberately checks only the outcome buckets. The action buckets - Unchanged, + WouldUpdate, Updated, UpdateFailed, Skipped - also sum to Processed in a + correct run, but Skipped is a catch-all that would absorb a lost user and let + the check pass on a broken run. + + .PARAMETER Counters + The run counter set. + + .OUTPUTS + System.Boolean + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [object] $Counters + ) + + $sum = [int]$Counters.Matched + [int]$Counters.Unclassified + [int]$Counters.EvaluationError + [int]$Counters.Processed -eq $sum +} + +function Get-PersonaReconciliationDetail { + <# + .SYNOPSIS + Describes a reconciliation failure precisely enough to debug it. + + .DESCRIPTION + Emitted onto the EngineDefect record. Carries the expected total, the actual + total, and the difference, because "reconciliation failed" alone does not + tell a maintainer whether users were lost or double-counted - and the sign of + the difference does. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [object] $Counters + ) + + $sum = [int]$Counters.Matched + [int]$Counters.Unclassified + [int]$Counters.EvaluationError + + @{ + severity = 'Error' + defect = 'ReconciliationFailure' + processed = [int]$Counters.Processed + outcomeTotal = $sum + difference = [int]$Counters.Processed - $sum + matched = [int]$Counters.Matched + unclassified = [int]$Counters.Unclassified + evaluationError = [int]$Counters.EvaluationError + description = 'Processed does not equal Matched + Unclassified + EvaluationError. Every processed user must land in exactly one outcome bucket (SC-001); a mismatch is an engine defect, not a property of the data.' + } +} diff --git a/src/Presentation/Write-PersonaSummary.ps1 b/src/Presentation/Write-PersonaSummary.ps1 new file mode 100644 index 0000000..df0118b --- /dev/null +++ b/src/Presentation/Write-PersonaSummary.ps1 @@ -0,0 +1,82 @@ +function Write-PersonaSummary { + <# + .SYNOPSIS + Renders the rule-match table, outcome totals, and reconciliation result + (FR-019, FR-020, FR-021). + + .DESCRIPTION + Emitted every summaryInterval users and once at completion. + + Every business rule appears, including disabled rules and rules with zero + matches. A rule that never fired and a rule that is not in the configuration + look identical if zero-match rules are omitted, and the difference is exactly + what an operator investigating "why did nobody get classified as Tier0" needs + to see. + + Reconciliation is displayed on every summary, not only when it fails. A check + that is only visible when broken gives an operator no reason to believe it + ran at all. + + .PARAMETER Counters + The run counter set. + + .PARAMETER SummaryType + Interim or Final. Final is emitted regardless of interval, including when + the interval is 0 (FR-020). + + .PARAMETER Mode + Preview or Enforce, shown in the header so a screenshot of a summary is + self-describing. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object] $Counters, + + [ValidateSet('Interim', 'Final')] + [string] $SummaryType = 'Interim', + + [ValidateSet('Preview', 'Enforce')] + [string] $Mode = 'Preview' + ) + + $reconciled = Test-PersonaReconciliation -Counters $Counters + + Write-Host '' + Write-Host ('=' * 100) -ForegroundColor DarkGray + Write-Host ("{0} summary - mode: {1} - processed: {2}" -f $SummaryType, $Mode, $Counters.Processed) -ForegroundColor Cyan + Write-Host ('=' * 100) -ForegroundColor DarkGray + + Write-Host ('{0,-28} {1,-40} {2,-9} {3,10} {4,8}' -f 'Rule ID', 'Name', 'Priority', 'Enabled', 'Matches') -ForegroundColor DarkGray + + foreach ($entry in $Counters.RuleCounts) { + # A disabled rule is dimmed rather than hidden: it is part of the + # configuration and its absence from the output would read as a deletion. + $colour = if (-not $entry.Enabled) { 'DarkGray' } elseif ($entry.Matches -gt 0) { 'Green' } else { 'Gray' } + + Write-Host ('{0,-28} {1,-40} {2,-9} {3,10} {4,8}' -f + $entry.RuleId, + ($entry.Name.Length -gt 40 ? $entry.Name.Substring(0, 37) + '...' : $entry.Name), + $entry.Priority, + $entry.Enabled, + $entry.Matches) -ForegroundColor $colour + } + + Write-Host '' + Write-Host ('Outcomes Matched: {0} Unclassified: {1} EvaluationError: {2}' -f + $Counters.Matched, $Counters.Unclassified, $Counters.EvaluationError) + Write-Host ('Actions Unchanged: {0} WouldUpdate: {1} Updated: {2} UpdateFailed: {3} Skipped: {4}' -f + $Counters.Unchanged, $Counters.WouldUpdate, $Counters.Updated, $Counters.UpdateFailed, $Counters.Skipped) + + if ($reconciled) { + Write-Host ('Reconciliation PASS {0} = {1} + {2} + {3}' -f + $Counters.Processed, $Counters.Matched, $Counters.Unclassified, $Counters.EvaluationError) -ForegroundColor Green + } + else { + Write-Host ('Reconciliation FAIL {0} != {1} + {2} + {3} - this is an engine defect (FR-021)' -f + $Counters.Processed, $Counters.Matched, $Counters.Unclassified, $Counters.EvaluationError) -ForegroundColor Red + } + + Write-Host ('=' * 100) -ForegroundColor DarkGray + Write-Host '' +} diff --git a/src/Presentation/Write-UserPersonaResult.ps1 b/src/Presentation/Write-UserPersonaResult.ps1 new file mode 100644 index 0000000..b91ec25 --- /dev/null +++ b/src/Presentation/Write-UserPersonaResult.ps1 @@ -0,0 +1,52 @@ +function Write-UserPersonaResult { + <# + .SYNOPSIS + Displays one user's result immediately after evaluation (FR-018, SC-012). + + .DESCRIPTION + Emitted per user as it is processed, not batched at the end, so an operator + watching a long run sees progress and can stop early if the impact looks + wrong. That per-user visibility is the whole point of a preview run. + + Carries UPN and Account Object ID, which are approved for logs. Never emits + tokens, headers, or raw responses (Principle V). + + .PARAMETER Result + A PersonaDecisionResult with Action already set by Compare-PersonaValue. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [object] $Result + ) + + process { + $colour = switch ($Result.Action) { + 'Updated' { 'Green' } + 'WouldUpdate' { 'Yellow' } + 'UpdateFailed' { 'Red' } + 'Skipped' { 'Red' } + default { 'Gray' } + } + + $detail = switch ($Result.Outcome) { + 'Matched' { "$($Result.CalculatedPersona) [$($Result.MatchedRuleId)]" } + 'Unclassified' { 'Unclassified' } + 'EvaluationError' { "EvaluationError - $($Result.EvaluationErrorReason)" } + } + + $change = switch ($Result.Action) { + 'Unchanged' { '=' } + 'WouldUpdate' { "'$($Result.StoredPersona)' -> '$($Result.CalculatedPersona)'" } + 'Updated' { "'$($Result.StoredPersona)' -> '$($Result.CalculatedPersona)'" } + 'UpdateFailed' { "write failed; '$($Result.StoredPersona)' retained" } + 'Skipped' { "'$($Result.StoredPersona)' retained" } + default { '' } + } + + $line = '{0,-14} {1,-45} {2,-40} {3}' -f $Result.Action, $Result.UserPrincipalName, $detail, $change + + Write-Host $line -ForegroundColor $colour + Write-Verbose " ObjectId=$($Result.AccountObjectId) RulesEvaluated=$($Result.RulesEvaluated) DurationMs=$($Result.DurationMs)" + } +} diff --git a/src/RuleEngine/Resolve-UserPersona.ps1 b/src/RuleEngine/Resolve-UserPersona.ps1 new file mode 100644 index 0000000..53ecb5c --- /dev/null +++ b/src/RuleEngine/Resolve-UserPersona.ps1 @@ -0,0 +1,131 @@ +function Resolve-UserPersona { + <# + .SYNOPSIS + Produces the authoritative persona decision for one user. + + .DESCRIPTION + The engine's core. Pure: it takes a normalized record and a rule set and + returns a decision. No Graph, no authentication, no console, no filesystem, + no clock (constitution Principle IV, enforced by tests/Test-EnginePurity.ps1). + + Evaluation order (FR-008, FR-009): + 1. Discard disabled rules. + 2. Sort by ascending priority — lower evaluates first (RE-002). + 3. Evaluate in order and STOP at the first True. + + Outcomes are mutually exclusive (SC-001): + Matched a rule returned True + Unclassified every enabled rule returned False (FR-010) + EvaluationError any rule returned Unknown before a match was found + + The EvaluationError rule is subtle and deliberate: an Unknown encountered + at priority 30 stops evaluation even though a lower-priority rule might + have matched. Continuing would risk assigning a persona from priority 900 + when the account may in truth have matched at 30 — precisely the + privilege-downgrade misclassification FR-013 exists to prevent. Preserving + the stored value is the only safe answer. + + Timing uses a monotonic stopwatch rather than the wall clock, so no + wall-clock value can influence a decision. + + .PARAMETER UserRecord + Normalized record from New-PersonaUserRecord. + + .PARAMETER Rules + The business rule collection. + + .PARAMETER MaxDepth + Maximum condition nesting depth (RE-004). + + .PARAMETER DefaultMembershipMode + Membership mode for conditions that do not specify one (RE-007). + + .PARAMETER IncludeTrace + Populates ConditionTrace with per-rule diagnostic results. Off by default; + the caller gates this behind -Debug (Principle V). + + .OUTPUTS + PersonaEngine.PersonaDecisionResult + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [object] $UserRecord, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $Rules, + + [ValidateRange(1, 10)] + [int] $MaxDepth = 5, + + [ValidateSet('Direct', 'Transitive')] + [string] $DefaultMembershipMode = 'Direct', + + [switch] $IncludeTrace + ) + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + + $outcome = 'Unclassified' + $matchedRuleId = $null + $calculatedPersona = 'Unclassified' + $errorReason = $null + $rulesEvaluated = 0 + $trace = [System.Collections.Generic.List[object]]::new() + + # Sort by priority, then by Id. The Id tiebreak matters: duplicate priorities + # are a validation error, but if one reaches the engine the result must still + # be the same on every run and every host (SC-003) rather than depending on + # collection order. + $ordered = @($Rules) | + Where-Object { $_.enabled } | + Sort-Object -Property @{ Expression = { [int]$_.priority } }, @{ Expression = { [string]$_.id } } + + foreach ($rule in $ordered) { + $rulesEvaluated++ + + $result = Test-PersonaRule -Rule $rule -UserRecord $UserRecord ` + -MaxDepth $MaxDepth -DefaultMembershipMode $DefaultMembershipMode + + if ($IncludeTrace) { + $trace.Add([pscustomobject]@{ + RuleId = [string]$rule.id + Priority = [int]$rule.priority + Result = $result + }) + } + + if ($result -eq 'True') { + $outcome = 'Matched' + $matchedRuleId = [string]$rule.id + $calculatedPersona = [string]$rule.persona + break + } + + if ($result -eq 'Unknown') { + $outcome = 'EvaluationError' + $calculatedPersona = $null + $errorReason = "Rule '$([string]$rule.id)' could not be evaluated: required data was unavailable or the condition could not be interpreted." + break + } + } + + $stopwatch.Stop() + + [pscustomobject]@{ + PSTypeName = 'PersonaEngine.PersonaDecisionResult' + AccountObjectId = $UserRecord.AccountObjectId + UserPrincipalName = $UserRecord.UserPrincipalName + Outcome = $outcome + MatchedRuleId = $matchedRuleId + CalculatedPersona = $calculatedPersona + StoredPersona = $UserRecord.StoredPersona + Action = 'Pending' # set by the comparison stage (Compare-PersonaValue) + EvaluationErrorReason = $errorReason + RulesEvaluated = $rulesEvaluated + DurationMs = [int]$stopwatch.ElapsedMilliseconds + ConditionTrace = $IncludeTrace ? $trace.ToArray() : $null + } +} diff --git a/src/RuleEngine/Test-PersonaCondition.ps1 b/src/RuleEngine/Test-PersonaCondition.ps1 new file mode 100644 index 0000000..6af2ba4 --- /dev/null +++ b/src/RuleEngine/Test-PersonaCondition.ps1 @@ -0,0 +1,235 @@ +function Test-PersonaCondition { + <# + .SYNOPSIS + Evaluates one leaf condition against a normalized user record. + + .DESCRIPTION + Returns 'True', 'False', or 'Unknown' — never a boolean. + + The tri-state is the whole safety argument (FR-013). A boolean return has + no way to distinguish "the user is not in that group" from "we could not + find out", and collapsing the second into the first is exactly how an + unavailable data source misclassifies a privileged account as an ordinary + user. 'Unknown' propagates upward and ultimately produces EvaluationError, + which preserves the stored persona. + + Null handling (FR-012): for ordinary string comparisons an absent or null + property is treated as an empty string and never fails evaluation. The + isNull / isNotNull operators exist for intentional null matching, and treat + both $null and the empty string as null. + + Comparison is case-insensitive (RE-006). + + .PARAMETER Condition + A leaf condition object with Type, Operator, and the operands its operator + requires. + + .PARAMETER UserRecord + The normalized record produced by New-PersonaUserRecord. + + .PARAMETER DefaultMembershipMode + Membership mode used when the condition does not specify one (RE-007). + + .OUTPUTS + System.String — 'True', 'False', or 'Unknown'. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [object] $Condition, + + [Parameter(Mandatory)] + [object] $UserRecord, + + [ValidateSet('Direct', 'Transitive')] + [string] $DefaultMembershipMode = 'Direct' + ) + + $operator = [string]$Condition.operator + $type = if ($Condition.type) { [string]$Condition.type } else { 'property' } + + switch ($type) { + + { $_ -in @('membership', 'role') } { + return Test-PersonaMembershipCondition -Condition $Condition -UserRecord $UserRecord -DefaultMembershipMode $DefaultMembershipMode + } + + 'property' { + $name = [string]$Condition.property + $raw = $UserRecord.Properties[$name] + + # Intentional null matching happens before the empty-string coercion, + # otherwise isNull could never be true. + switch ($operator) { + 'isNull' { return (Test-PersonaValueIsNull $raw) ? 'True' : 'False' } + 'isNotNull' { return (Test-PersonaValueIsNull $raw) ? 'False' : 'True' } + } + + # FR-012: null and absent both compare as empty. + $value = if ($null -eq $raw) { '' } else { [string]$raw } + + switch ($operator) { + 'equals' { return (Test-PersonaStringEquals $value ([string]$Condition.value)) ? 'True' : 'False' } + 'notEquals' { return (Test-PersonaStringEquals $value ([string]$Condition.value)) ? 'False' : 'True' } + + 'contains' { return ($value.ToLowerInvariant().Contains(([string]$Condition.value).ToLowerInvariant())) ? 'True' : 'False' } + 'notContains' { return ($value.ToLowerInvariant().Contains(([string]$Condition.value).ToLowerInvariant())) ? 'False' : 'True' } + + 'startsWith' { return ($value.StartsWith([string]$Condition.value, [System.StringComparison]::OrdinalIgnoreCase)) ? 'True' : 'False' } + 'endsWith' { return ($value.EndsWith([string]$Condition.value, [System.StringComparison]::OrdinalIgnoreCase)) ? 'True' : 'False' } + + 'matchesRegex' { + $pattern = [string]$Condition.value + + # RE-006: validate before execution. An invalid pattern is a + # configuration defect, and treating it as a non-match would + # hide the defect behind a plausible-looking result. + try { + $regex = [regex]::new($pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + } + catch { + return 'Unknown' + } + + return ($regex.IsMatch($value)) ? 'True' : 'False' + } + + 'in' { + foreach ($candidate in @($Condition.values)) { + if (Test-PersonaStringEquals $value ([string]$candidate)) { return 'True' } + } + return 'False' + } + + 'notIn' { + foreach ($candidate in @($Condition.values)) { + if (Test-PersonaStringEquals $value ([string]$candidate)) { return 'False' } + } + return 'True' + } + + default { + # An unsupported operator reaching evaluation means validation + # let it through. Unknown preserves the stored value rather than + # inventing a decision from a condition nobody can interpret. + return 'Unknown' + } + } + } + + default { return 'Unknown' } + } +} + +function Test-PersonaMembershipCondition { + <# + .SYNOPSIS + Evaluates a membership or directory-role condition. Internal helper. + + .DESCRIPTION + Selects the facet of the membership record that answers the question the + condition actually asks — direct groups, transitive groups, or directory + roles — and returns 'Unknown' if that specific facet was not retrieved + (FR-013). + + Facet selection is deliberately exact. Transitive membership is a superset + of direct, so answering a direct question from transitive data would produce + false positives, and answering a transitive question from direct data would + produce false negatives. Neither is acceptable when the answer decides + whether an account is classified as an administrator. + + Because each facet carries its own retrieval status, a failure in one does + not contaminate the others: a transitive lookup that times out leaves + direct-membership conditions fully evaluable. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [object] $Condition, + [Parameter(Mandatory)] [object] $UserRecord, + [string] $DefaultMembershipMode = 'Direct' + ) + + $membership = $UserRecord.Membership + if ($null -eq $membership) { return 'Unknown' } + + $isRole = ([string]$Condition.type -eq 'role') + + if ($isRole) { + if (-not $membership.RolesRetrieved) { return 'Unknown' } + $haystack = @($membership.DirectoryRoleIds) + $needles = @($Condition.roleIds) + } + else { + $requestedMode = if ($Condition.membershipMode) { [string]$Condition.membershipMode } else { $DefaultMembershipMode } + + switch ($requestedMode.ToLowerInvariant()) { + 'transitive' { + if (-not $membership.TransitiveRetrieved) { return 'Unknown' } + $haystack = @($membership.TransitiveGroupObjectIds) + } + 'direct' { + if (-not $membership.DirectRetrieved) { return 'Unknown' } + $haystack = @($membership.DirectGroupObjectIds) + } + default { return 'Unknown' } + } + + $needles = @($Condition.groupObjectIds) + } + + $found = $false + foreach ($needle in $needles) { + foreach ($held in $haystack) { + if (Test-PersonaStringEquals ([string]$held) ([string]$needle)) { + $found = $true + break + } + } + if ($found) { break } + } + + switch ([string]$Condition.operator) { + 'memberOf' { return $found ? 'True' : 'False' } + 'notMemberOf' { return $found ? 'False' : 'True' } + default { return 'Unknown' } + } +} + +function Test-PersonaStringEquals { + <# + .SYNOPSIS + Case-insensitive ordinal string comparison (RE-006). Internal helper. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [AllowNull()] [string] $Left, + [AllowNull()] [string] $Right + ) + + [string]::Equals($Left, $Right, [System.StringComparison]::OrdinalIgnoreCase) +} + +function Test-PersonaValueIsNull { + <# + .SYNOPSIS + Determines whether a property value counts as null. Internal helper. + + .DESCRIPTION + Both $null and the empty string count. A directory routinely returns an + empty string for a cleared attribute, and a rule author asking "is this + unset" means the same thing in both cases. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Position = 0)] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { return $true } + return [string]::IsNullOrEmpty([string]$Value) +} diff --git a/src/RuleEngine/Test-PersonaConditionGroup.ps1 b/src/RuleEngine/Test-PersonaConditionGroup.ps1 new file mode 100644 index 0000000..45fd435 --- /dev/null +++ b/src/RuleEngine/Test-PersonaConditionGroup.ps1 @@ -0,0 +1,101 @@ +function Test-PersonaConditionGroup { + <# + .SYNOPSIS + Evaluates an all/any condition group, propagating Unknown correctly. + + .DESCRIPTION + The propagation table (data-model.md) is the safety argument in four rows: + + all + any False -> False a definite non-match wins + all + only True and Unknown -> Unknown cannot confirm + any + any True -> True a definite match wins + any + only False and Unknown -> Unknown cannot rule out + + The two "definite wins" rows matter as much as the two Unknown rows. If an + 'all' group already contains a False, the result is False regardless of any + unknown sibling — the rule cannot match either way, so degrading to + EvaluationError there would produce spurious errors and mask real ones. + + Depth is bounded (RE-004). The root group is depth 1. + + .PARAMETER Group + A condition group with operator 'all' or 'any' and a conditions collection. + + .PARAMETER UserRecord + The normalized record to evaluate against. + + .PARAMETER MaxDepth + Maximum nesting depth. Default 5, hard ceiling 10. + + .PARAMETER CurrentDepth + Internal recursion counter. Callers leave this at its default. + + .OUTPUTS + System.String — 'True', 'False', or 'Unknown'. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [object] $Group, + + [Parameter(Mandatory)] + [object] $UserRecord, + + [ValidateRange(1, 10)] + [int] $MaxDepth = 5, + + [ValidateSet('Direct', 'Transitive')] + [string] $DefaultMembershipMode = 'Direct', + + [int] $CurrentDepth = 1 + ) + + # Exceeding the depth limit is a configuration defect that validation should + # have caught. Unknown rather than silent truncation: a truncated condition + # tree evaluates a rule the author did not write. + if ($CurrentDepth -gt $MaxDepth) { return 'Unknown' } + + $operator = ([string]$Group.operator).ToLowerInvariant() + if ($operator -notin @('all', 'any')) { return 'Unknown' } + + $children = @($Group.conditions) + if ($children.Count -eq 0) { return 'Unknown' } + + $sawUnknown = $false + + foreach ($child in $children) { + + # A child is a group when it carries its own conditions collection. + $isGroup = $null -ne $child.PSObject.Properties['conditions'] -and $null -ne $child.conditions + + $result = if ($isGroup) { + Test-PersonaConditionGroup -Group $child -UserRecord $UserRecord ` + -MaxDepth $MaxDepth -DefaultMembershipMode $DefaultMembershipMode ` + -CurrentDepth ($CurrentDepth + 1) + } + else { + Test-PersonaCondition -Condition $child -UserRecord $UserRecord ` + -DefaultMembershipMode $DefaultMembershipMode + } + + switch ($result) { + 'Unknown' { $sawUnknown = $true } + + 'False' { + # Short-circuit only on the definite result that decides the group. + if ($operator -eq 'all') { return 'False' } + } + + 'True' { + if ($operator -eq 'any') { return 'True' } + } + } + } + + # No definite result decided the group. If anything was unknown, the group is + # unknown; otherwise every child agreed with the group's identity element. + if ($sawUnknown) { return 'Unknown' } + + return ($operator -eq 'all') ? 'True' : 'False' +} diff --git a/src/RuleEngine/Test-PersonaRule.ps1 b/src/RuleEngine/Test-PersonaRule.ps1 new file mode 100644 index 0000000..916d420 --- /dev/null +++ b/src/RuleEngine/Test-PersonaRule.ps1 @@ -0,0 +1,40 @@ +function Test-PersonaRule { + <# + .SYNOPSIS + Evaluates a single business rule's root condition group against a user. + + .DESCRIPTION + Returns 'True', 'False', or 'Unknown'. Disabled rules are not evaluated + here — Resolve-UserPersona filters them out before evaluation so they are + excluded from the enabled rule count as well as from the result. + + .PARAMETER Rule + A business rule with a match condition group. + + .PARAMETER UserRecord + The normalized record to evaluate against. + + .OUTPUTS + System.String — 'True', 'False', or 'Unknown'. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [object] $Rule, + + [Parameter(Mandatory)] + [object] $UserRecord, + + [ValidateRange(1, 10)] + [int] $MaxDepth = 5, + + [ValidateSet('Direct', 'Transitive')] + [string] $DefaultMembershipMode = 'Direct' + ) + + if ($null -eq $Rule.match) { return 'Unknown' } + + Test-PersonaConditionGroup -Group $Rule.match -UserRecord $UserRecord ` + -MaxDepth $MaxDepth -DefaultMembershipMode $DefaultMembershipMode +} diff --git a/tests/Configuration/ExitCodes.Tests.ps1 b/tests/Configuration/ExitCodes.Tests.ps1 new file mode 100644 index 0000000..9416f9f --- /dev/null +++ b/tests/Configuration/ExitCodes.Tests.ps1 @@ -0,0 +1,130 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + Editor exit codes 0-4 (cli-edit-persona-engine-config.md). + + An exit code is the only thing a pipeline sees. Every code has to be reachable and + has to mean what the contract says - in particular, code 3 (file unreadable) and + code 4 (schema unusable) must not collapse into code 1, or a missing schema file + gets reported to a rule author as "your configuration is invalid". +#> + +BeforeAll { + $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + . (Join-Path $repoRoot 'tests/TestHelpers.ps1') + + $script:editor = Join-Path $repoRoot 'Edit-PersonaEngineConfig.ps1' + $script:scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-ec-{0}" -f [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $script:scratch -Force + + function Invoke-Editor { + param([string[]] $ArgumentList) + + $exe = (Get-Process -Id $PID).Path + $null = & $exe -NoProfile -NonInteractive -File $script:editor @ArgumentList 2>&1 + $LASTEXITCODE + } +} + +AfterAll { + Remove-Item -LiteralPath $script:scratch -Recurse -Force -ErrorAction SilentlyContinue +} + +Describe 'Editor exit codes' { + + It 'returns 0 for a valid configuration' { + $path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch + + Invoke-Editor -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive') | Should -Be 0 + } + + It 'returns 1 when Error findings are present' { + $document = New-TestConfigurationDocument + $document.rules[1].persona = 'Undeclared-Persona' + + $path = Save-TestConfiguration -Document $document -Directory $script:scratch + + Invoke-Editor -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive') | Should -Be 1 + } + + It 'returns 2 for Warning findings under -TreatWarningsAsErrors' { + # A pinned global mode plus a per-condition override: a Warning, not an Error. + # VR-005 escalates it only when the caller asks. + $document = New-TestConfigurationDocument + $document.dataSources.groups.membershipMode = 'direct' + $document.rules += @{ + id = 'RULE-0030-TIER0'; name = 'Tier 0'; description = 'Tier 0 group members.' + enabled = $true; priority = 30; persona = 'Tier0-Admin' + match = @{ + operator = 'all' + conditions = @(@{ + type = 'membership'; operator = 'memberOf'; membershipMode = 'transitive' + groupObjectIds = @('00000000-0000-0000-0000-0000000000a0') + }) + } + } + + $path = Save-TestConfiguration -Document $document -Directory $script:scratch + + Invoke-Editor -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive') | Should -Be 0 + Invoke-Editor -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive', '-TreatWarningsAsErrors') | Should -Be 2 + } + + It 'returns 3 when the configuration file is absent' { + Invoke-Editor -ArgumentList @('-ConfigPath', (Join-Path $script:scratch 'absent.json'), '-ValidateOnly', '-NonInteractive') | Should -Be 3 + } + + It 'returns 4 when the schema file is absent' { + $path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch + + Invoke-Editor -ArgumentList @( + '-ConfigPath', $path, '-ValidateOnly', '-NonInteractive', + '-SchemaPath', (Join-Path $script:scratch 'no-schema.json')) | Should -Be 4 + } + + It 'returns 4 when the schema file exists but cannot be parsed' { + # Distinct from code 1 on purpose. The configuration was never actually + # checked, so calling it invalid would be a guess. + $brokenSchema = Join-Path $script:scratch 'broken.json' + Set-Content -LiteralPath $brokenSchema -Value '{ not json' -Encoding utf8NoBOM + + $path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch + + Invoke-Editor -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive', '-SchemaPath', $brokenSchema) | Should -Be 4 + } + + It 'prefers code 3 over code 1 when the file cannot be read at all' { + # An unreadable file produces a PE-SYN Error finding too. Reporting code 1 + # would tell the author their rules are wrong when the file never opened. + Invoke-Editor -ArgumentList @('-ConfigPath', $script:scratch, '-ValidateOnly', '-NonInteractive') | Should -Be 3 + } + + It 'reaches every documented code across the corpus' { + $reached = [System.Collections.Generic.HashSet[int]]::new() + + $valid = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch + $null = $reached.Add((Invoke-Editor -ArgumentList @('-ConfigPath', $valid, '-ValidateOnly', '-NonInteractive'))) + + $invalidDoc = New-TestConfigurationDocument + $invalidDoc.rules[1].persona = 'Undeclared-Persona' + $invalid = Save-TestConfiguration -Document $invalidDoc -Directory $script:scratch + $null = $reached.Add((Invoke-Editor -ArgumentList @('-ConfigPath', $invalid, '-ValidateOnly', '-NonInteractive'))) + + $warnDoc = New-TestConfigurationDocument + $warnDoc.dataSources.groups.membershipMode = 'direct' + $warnDoc.rules += @{ + id = 'RULE-0030-TIER0'; name = 'Tier 0'; description = 'Tier 0 group members.' + enabled = $true; priority = 30; persona = 'Tier0-Admin' + match = @{ operator = 'all'; conditions = @(@{ + type = 'membership'; operator = 'memberOf'; membershipMode = 'transitive' + groupObjectIds = @('00000000-0000-0000-0000-0000000000a0') }) } + } + $warn = Save-TestConfiguration -Document $warnDoc -Directory $script:scratch + $null = $reached.Add((Invoke-Editor -ArgumentList @('-ConfigPath', $warn, '-ValidateOnly', '-NonInteractive', '-TreatWarningsAsErrors'))) + + $null = $reached.Add((Invoke-Editor -ArgumentList @('-ConfigPath', (Join-Path $script:scratch 'absent.json'), '-ValidateOnly', '-NonInteractive'))) + $null = $reached.Add((Invoke-Editor -ArgumentList @('-ConfigPath', $valid, '-ValidateOnly', '-NonInteractive', '-SchemaPath', (Join-Path $script:scratch 'no-schema.json')))) + + 0..4 | ForEach-Object { $reached | Should -Contain $_ } + } +} diff --git a/tests/Configuration/LayerOrdering.Tests.ps1 b/tests/Configuration/LayerOrdering.Tests.ps1 new file mode 100644 index 0000000..f1babd2 --- /dev/null +++ b/tests/Configuration/LayerOrdering.Tests.ps1 @@ -0,0 +1,127 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + VR-001: the four layers run in order and stop at the first that produces errors. + + The reason is signal, not speed. A document missing a required section produces a + cascade of consequent semantic errors, and the author then has to guess which one + is the cause. Stopping at the structural failure reports the one thing that is + actually wrong. +#> + +BeforeAll { + $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + . (Join-Path $repoRoot 'tests/TestHelpers.ps1') + foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file } + + $script:schema = Join-Path $repoRoot 'config/persona-engine.schema.json' + $script:scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-layers-{0}" -f [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $script:scratch -Force +} + +AfterAll { + Remove-Item -LiteralPath $script:scratch -Recurse -Force -ErrorAction SilentlyContinue +} + +Describe 'Layer ordering and fail-fast (VR-001)' { + + It 'stops at layer 1 for malformed JSON' { + $path = Join-Path $script:scratch 'malformed.json' + Set-Content -LiteralPath $path -Value '{ "configVersion": "1.0.0", ' -Encoding utf8NoBOM + + $result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema + + $result.IsValid | Should -BeFalse + $result.StoppedAtLayer | Should -Be 'Syntax' + $result.Findings.Code | Should -Contain 'PE-SYN-003' + } + + It 'reports a missing file at layer 1 without attempting to parse it' { + $result = Test-PersonaConfiguration -Path (Join-Path $script:scratch 'absent.json') -SchemaPath $script:schema + + $result.StoppedAtLayer | Should -Be 'Syntax' + $result.Findings.Code | Should -Contain 'PE-SYN-001' + } + + It 'stops at layer 2 for a structurally invalid document, before semantic checks run' { + # The document below also has a semantic defect - a duplicate rule ID - which + # must NOT appear, because layer 3 never ran. + $document = New-TestConfigurationDocument + $document.rules[1].id = $document.rules[0].id + $document.engine.Remove('approvedWritableAttributes') + + $path = Save-TestConfiguration -Document $document -Directory $script:scratch + $result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema + + $result.IsValid | Should -BeFalse + $result.StoppedAtLayer | Should -Be 'Schema' + $result.Findings.Code | Should -Contain 'PE-SCH-001' + $result.Findings.Code | Should -Not -Contain 'PE-SEM-001' + } + + It 'stops at layer 3 for a semantic error, before safety checks run' { + $document = New-TestConfigurationDocument + $document.rules[1].id = $document.rules[0].id + + $path = Save-TestConfiguration -Document $document -Directory $script:scratch + $result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema + + $result.StoppedAtLayer | Should -Be 'Semantic' + $result.Findings.Code | Should -Contain 'PE-SEM-001' + @($result.Findings | Where-Object Layer -EQ 'Safety') | Should -BeNullOrEmpty + } + + It 'reaches layer 4 when layers 1 to 3 are clean' { + $path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch + $result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema + + $result.IsValid | Should -BeTrue + $result.StoppedAtLayer | Should -BeNullOrEmpty + @($result.Findings | Where-Object Layer -EQ 'Safety') | Should -Not -BeNullOrEmpty + } + + It 'skips layer 4 on request without claiming it passed' { + $path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch + $result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema -SkipSafety + + @($result.Findings | Where-Object Layer -EQ 'Safety') | Should -BeNullOrEmpty + } +} + +Describe 'A schema that cannot be used is never reported as a pass (V-5a)' { + + It 'flags an unparseable schema rather than trusting the $true return value' { + # Test-Json returns $true here. Trusting it would validate every configuration + # against a schema that never ran. + $brokenSchema = Join-Path $script:scratch 'broken-schema.json' + Set-Content -LiteralPath $brokenSchema -Value '{ not json' -Encoding utf8NoBOM + + $path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch + $result = Test-PersonaConfiguration -Path $path -SchemaPath $brokenSchema + + $result.IsValid | Should -BeFalse + $result.SchemaUnusable | Should -BeTrue + $result.Findings.Code | Should -Contain 'PE-SCH-003' + } + + It 'flags a missing schema file separately from an invalid configuration' { + $path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch + $result = Test-PersonaConfiguration -Path $path -SchemaPath (Join-Path $script:scratch 'no-such-schema.json') + + $result.SchemaUnusable | Should -BeTrue + $result.Findings.Code | Should -Contain 'PE-SCH-002' + } +} + +Describe 'The shipped example configuration passes every layer' { + + It 'validates cleanly against the shipped schema' { + # If the example the documentation points at cannot pass its own validator, + # every reader's first run fails. + $result = Test-PersonaConfiguration -Path (Join-Path $repoRoot 'config/persona-engine.example.json') + + $result.IsValid | Should -BeTrue + $result.ErrorCount | Should -Be 0 + $result.WarningCount | Should -Be 0 + } +} diff --git a/tests/Configuration/NonInteractive.Tests.ps1 b/tests/Configuration/NonInteractive.Tests.ps1 new file mode 100644 index 0000000..e8dc143 --- /dev/null +++ b/tests/Configuration/NonInteractive.Tests.ps1 @@ -0,0 +1,124 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + SC-010: -NonInteractive never prompts and never hangs. + + Each case runs the editor in a child pwsh with stdin redirected from an empty + file, which is what a build agent gives it. A tool that prompts there does not + fail - it blocks until the job times out, and the pipeline reports an + infrastructure problem rather than a bad configuration. + + A wall-clock timeout is the assertion. That makes these the slowest tests in the + suite, and there is no cheaper way to prove the absence of a hang: inspecting the + source for Read-Host would only prove that one spelling of prompting is absent. +#> + +BeforeAll { + $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + . (Join-Path $repoRoot 'tests/TestHelpers.ps1') + + $script:editor = Join-Path $repoRoot 'Edit-PersonaEngineConfig.ps1' + $script:scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-ni-{0}" -f [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $script:scratch -Force + + $script:emptyStdin = Join-Path $script:scratch 'empty.txt' + Set-Content -LiteralPath $script:emptyStdin -Value '' -NoNewline + + function Invoke-EditorWithClosedStdin { + <# + Runs the editor with stdin from an empty file and a hard timeout. + Returns the exit code, or -1 if it had to be killed. + #> + param( + [string[]] $ArgumentList, + [int] $TimeoutSeconds = 60 + ) + + $stdout = Join-Path $script:scratch ("out-{0}.txt" -f [guid]::NewGuid().ToString('N')) + $stderr = Join-Path $script:scratch ("err-{0}.txt" -f [guid]::NewGuid().ToString('N')) + + $process = Start-Process -FilePath (Get-Process -Id $PID).Path ` + -ArgumentList (@('-NoProfile', '-NonInteractive', '-File', $script:editor) + $ArgumentList) ` + -RedirectStandardInput $script:emptyStdin ` + -RedirectStandardOutput $stdout ` + -RedirectStandardError $stderr ` + -PassThru -WindowStyle Hidden + + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + $process.Kill($true) + return [pscustomobject]@{ ExitCode = -1; Output = 'TIMED OUT'; TimedOut = $true } + } + + [pscustomobject]@{ + ExitCode = $process.ExitCode + Output = (Get-Content -LiteralPath $stdout -Raw -ErrorAction SilentlyContinue) + Error = (Get-Content -LiteralPath $stderr -Raw -ErrorAction SilentlyContinue) + TimedOut = $false + } + } +} + +AfterAll { + Remove-Item -LiteralPath $script:scratch -Recurse -Force -ErrorAction SilentlyContinue +} + +Describe 'Non-interactive mode never prompts or hangs (SC-010)' { + + It 'completes on a valid configuration with stdin closed' { + $path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch + + $run = Invoke-EditorWithClosedStdin -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive') + + $run.TimedOut | Should -BeFalse + $run.ExitCode | Should -Be 0 + } + + It 'completes on an invalid configuration with stdin closed' { + $document = New-TestConfigurationDocument + $document.rules[1].id = $document.rules[0].id + + $path = Save-TestConfiguration -Document $document -Directory $script:scratch + + $run = Invoke-EditorWithClosedStdin -ArgumentList @('-ConfigPath', $path, '-ValidateOnly', '-NonInteractive') + + $run.TimedOut | Should -BeFalse + $run.ExitCode | Should -Be 1 + } + + It 'completes with -NonInteractive alone, without -ValidateOnly' { + # The case most likely to regress: -NonInteractive must short-circuit before + # the editor loop even when the caller did not also ask for validate-only. + $path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch + + $run = Invoke-EditorWithClosedStdin -ArgumentList @('-ConfigPath', $path, '-NonInteractive') + + $run.TimedOut | Should -BeFalse + $run.ExitCode | Should -Be 0 + } + + It 'completes when the configuration file does not exist' { + $run = Invoke-EditorWithClosedStdin -ArgumentList @('-ConfigPath', (Join-Path $script:scratch 'absent.json'), '-NonInteractive') + + $run.TimedOut | Should -BeFalse + $run.ExitCode | Should -Be 3 + } + + It 'completes when running synthetic rule tests' { + $path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch + + $run = Invoke-EditorWithClosedStdin -ArgumentList @( + '-ConfigPath', $path, '-NonInteractive', '-TestDataPath', (Join-Path $repoRoot 'tests/TestData')) + + $run.TimedOut | Should -BeFalse + $run.ExitCode | Should -Be 0 + } + + It 'emits no prompt text on the output stream' { + $path = Save-TestConfiguration -Document (New-TestConfigurationDocument) -Directory $script:scratch + + $run = Invoke-EditorWithClosedStdin -ArgumentList @('-ConfigPath', $path, '-NonInteractive') + + $run.Output | Should -Not -Match 'Choice' + $run.Output | Should -Not -Match 'configuration editor' + } +} diff --git a/tests/Configuration/Safety.Tests.ps1 b/tests/Configuration/Safety.Tests.ps1 new file mode 100644 index 0000000..3940b45 --- /dev/null +++ b/tests/Configuration/Safety.Tests.ps1 @@ -0,0 +1,175 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + SC-009, VR-003 half: every safety condition produces a finding. + + Layer 3 asks whether a configuration makes sense. These tests are about layer 4, + which asks what happens to the directory if it runs - a coherent configuration can + still be a silent no-op or a change nobody declared. + + Several findings change severity with -EnforcementEnabled. Both cases are asserted + for each: the same configuration carries very different risk in preview and in + enforcement, and a validator that ignored the difference would either block + harmless previews or wave through real writes. +#> + +BeforeAll { + $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + . (Join-Path $repoRoot 'tests/TestHelpers.ps1') + foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file } + + $script:corpus = Join-Path $repoRoot 'tests/TestData/InvalidConfigs' + $script:baseline = Join-Path $script:corpus 'baseline-deployed.json' + + function Get-SafetyFinding { + param( + [string] $Fixture, + [string] $Code, + [switch] $EnforcementEnabled, + [string] $PreviousConfigPath + ) + + $document = Get-Content -LiteralPath (Join-Path $script:corpus "$Fixture.json") -Raw | ConvertFrom-Json -Depth 32 + + $params = @{ Document = $document; EnforcementEnabled = $EnforcementEnabled } + if ($PreviousConfigPath) { $params['PreviousConfigPath'] = $PreviousConfigPath } + + @(Test-PersonaConfigurationSafety @params | Where-Object Code -EQ $Code) + } +} + +Describe 'Safety validation, VR-003 conditions (SC-009)' { + + It 'detects a blank target attribute and blocks it under enforcement' { + $findings = Get-SafetyFinding -Fixture 'PE-SAF-001-blank-target-production' -Code 'PE-SAF-001' -EnforcementEnabled + + $findings.Count | Should -Be 1 + $findings[0].Severity | Should -Be 'Error' + } + + It 'reports the same blank target attribute as a Warning in preview' { + # In preview there is nothing to write, so the configuration is merely + # pointless rather than dangerous. + $findings = Get-SafetyFinding -Fixture 'PE-SAF-001-blank-target-production' -Code 'PE-SAF-001' + + $findings.Count | Should -Be 1 + $findings[0].Severity | Should -Be 'Warning' + } + + It 'detects an unsupported writable attribute' { + # 'department' is authoritative in the sync source. Holding permission to + # write it is not the same as owning it. + $findings = Get-SafetyFinding -Fixture 'PE-SAF-002-unsupported-writable-attribute' -Code 'PE-SAF-002' + + $findings.Count | Should -BeGreaterThan 0 + $findings[0].Severity | Should -Be 'Error' + $findings[0].Description | Should -Match 'department' + } + + It 'detects enabled group rules while group retrieval is disabled' { + $findings = Get-SafetyFinding -Fixture 'PE-SAF-003-group-rules-without-group-retrieval' -Code 'PE-SAF-003' + + $findings.Count | Should -BeGreaterThan 0 + $findings[0].Severity | Should -Be 'Error' + $findings[0].Location | Should -Be 'dataSources.groups.enabled' + } + + It 'detects a configuration version downgrade against the deployed baseline' { + $findings = Get-SafetyFinding -Fixture 'PE-SAF-004-version-downgrade' -Code 'PE-SAF-004' ` + -PreviousConfigPath $script:baseline -EnforcementEnabled + + $findings.Count | Should -BeGreaterThan 0 + $findings[0].Severity | Should -Be 'Error' + $findings[0].Location | Should -Be 'configVersion' + } + + It 'detects a rule removed without a version change' { + $findings = Get-SafetyFinding -Fixture 'PE-SAF-005-rule-removed-without-version-change' -Code 'PE-SAF-005' ` + -PreviousConfigPath $script:baseline -EnforcementEnabled + + $findings.Count | Should -BeGreaterThan 0 + $findings[0].Severity | Should -Be 'Error' + $findings[0].Description | Should -Match 'RULE-0900-EMPLOYEE' + } + + It 'accepts a rule removal when the version was raised to declare it' { + $document = Get-Content -LiteralPath (Join-Path $script:corpus 'PE-SAF-005-rule-removed-without-version-change.json') -Raw | ConvertFrom-Json -Depth 32 + $document.configVersion = '1.1.0' + + $findings = @(Test-PersonaConfigurationSafety -Document $document -PreviousConfigPath $script:baseline -EnforcementEnabled | + Where-Object Code -EQ 'PE-SAF-005') + + $findings.Count | Should -Be 0 + } + + It 'detects condition tracing enabled without acknowledgement' { + $findings = Get-SafetyFinding -Fixture 'PE-SAF-006-tracing-without-acknowledgement' -Code 'PE-SAF-006' + + $findings.Count | Should -Be 1 + $findings[0].Severity | Should -Be 'Error' + $findings[0].Location | Should -Be 'logging.traceConditionValues' + } + + It 'accepts tracing when it is acknowledged in the same configuration' { + $document = Get-Content -LiteralPath (Join-Path $script:corpus 'PE-SAF-006-tracing-without-acknowledgement.json') -Raw | ConvertFrom-Json -Depth 32 + $document.logging | Add-Member -NotePropertyName 'acknowledgeConditionTracing' -NotePropertyValue $true + + @(Test-PersonaConfigurationSafety -Document $document | Where-Object Code -EQ 'PE-SAF-006') | Should -BeNullOrEmpty + } + + It 'detects a save that would overwrite an existing configuration with no backup' { + $document = Get-Content -LiteralPath $script:baseline -Raw | ConvertFrom-Json -Depth 32 + + $findings = @(Test-PersonaConfigurationSafety -Document $document -SavePath $script:baseline | + Where-Object Code -EQ 'PE-SAF-007') + + $findings.Count | Should -Be 1 + $findings[0].Severity | Should -Be 'Error' + } + + It 'accepts the same save when a backup is planned' { + $document = Get-Content -LiteralPath $script:baseline -Raw | ConvertFrom-Json -Depth 32 + + @(Test-PersonaConfigurationSafety -Document $document -SavePath $script:baseline -BackupPlanned | + Where-Object Code -EQ 'PE-SAF-007') | Should -BeNullOrEmpty + } +} + +Describe 'A skipped check is reported as skipped, never as a pass' { + + It 'reports an Information finding when no baseline is supplied' { + # Silence would be read as approval. The comparison checks did not run, and + # the output has to say so. + $document = Get-Content -LiteralPath $script:baseline -Raw | ConvertFrom-Json -Depth 32 + + $findings = @(Test-PersonaConfigurationSafety -Document $document | Where-Object Code -EQ 'PE-SAF-004') + + $findings.Count | Should -Be 1 + $findings[0].Severity | Should -Be 'Information' + $findings[0].Description | Should -Match 'not a pass' + } + + It 'does not block on the skipped-check notice' { + $document = Get-Content -LiteralPath $script:baseline -Raw | ConvertFrom-Json -Depth 32 + + @(Test-PersonaConfigurationSafety -Document $document | Where-Object Severity -EQ 'Error') | Should -BeNullOrEmpty + } +} + +Describe 'Safety findings carry everything VR-004 requires' { + + It 'gives every finding a severity, code, location, description, resolution, and layer' { + foreach ($fixture in (Get-ChildItem -Path $script:corpus -Filter 'PE-SAF-*.json')) { + $document = Get-Content -LiteralPath $fixture.FullName -Raw | ConvertFrom-Json -Depth 32 + + foreach ($finding in (Test-PersonaConfigurationSafety -Document $document -PreviousConfigPath $script:baseline)) { + $finding.Severity | Should -BeIn @('Error', 'Warning', 'Information') + $finding.Code | Should -Match '^PE-SAF-\d{3}$' + $finding.Location | Should -Not -BeNullOrEmpty + $finding.Description | Should -Not -BeNullOrEmpty + $finding.SuggestedResolution | Should -Not -BeNullOrEmpty + $finding.Layer | Should -Be 'Safety' + } + } + } +} diff --git a/tests/Configuration/Semantic.Tests.ps1 b/tests/Configuration/Semantic.Tests.ps1 new file mode 100644 index 0000000..c787b57 --- /dev/null +++ b/tests/Configuration/Semantic.Tests.ps1 @@ -0,0 +1,190 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + SC-009: every VR-002 condition produces a finding with a code, a severity, and a + location. + + Layer 3 is exercised directly rather than through Test-PersonaConfiguration. Most + of these defects are also caught by the JSON Schema, so a full-pipeline test would + stop at layer 2 and never reach the code under test - it would be asserting that + the schema works, which LayerOrdering.Tests.ps1 already does. + + The overlap is deliberate (see the note in Test-PersonaConfigurationSemantic): + layer 2 can be bypassed with -SchemaPath, and V-5a showed an unparseable schema + passes silently on this build. Anything that can misclassify a privileged account + is checked twice. +#> + +BeforeAll { + $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + . (Join-Path $repoRoot 'tests/TestHelpers.ps1') + foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file } + + $script:corpus = Join-Path $repoRoot 'tests/TestData/InvalidConfigs' + + function Get-Finding { + param([string] $Fixture, [string] $Code) + + $document = Get-Content -LiteralPath (Join-Path $script:corpus "$Fixture.json") -Raw | ConvertFrom-Json -Depth 32 + @(Test-PersonaConfigurationSemantic -Document $document | Where-Object Code -EQ $Code) + } +} + +Describe 'Semantic validation, VR-002 conditions (SC-009)' { + + It 'detects duplicate rule IDs' { + $findings = Get-Finding -Fixture 'PE-SEM-001-duplicate-rule-id' -Code 'PE-SEM-001' + + $findings.Count | Should -BeGreaterThan 0 + $findings[0].Severity | Should -Be 'Error' + $findings[0].Location | Should -Match 'RULE-0010-GUEST' + } + + It 'detects duplicate priorities among enabled rules' { + $findings = Get-Finding -Fixture 'PE-SEM-002-duplicate-priority' -Code 'PE-SEM-002' + + $findings.Count | Should -BeGreaterThan 0 + $findings[0].Severity | Should -Be 'Error' + $findings[0].Description | Should -Match 'RULE-0010-GUEST' + $findings[0].Description | Should -Match 'RULE-0900-EMPLOYEE' + } + + It 'detects a configuration with no enabled rules' { + $findings = Get-Finding -Fixture 'PE-SEM-003-no-enabled-rules' -Code 'PE-SEM-003' + + $findings.Count | Should -Be 1 + $findings[0].Severity | Should -Be 'Error' + } + + It 'detects a blank target attribute' { + $findings = Get-Finding -Fixture 'PE-SEM-004-blank-target-attribute' -Code 'PE-SEM-004' + + $findings.Count | Should -Be 1 + $findings[0].Location | Should -Be 'engine.targetAttribute' + } + + It 'detects a target attribute absent from the approved list' { + $findings = Get-Finding -Fixture 'PE-SEM-005-target-not-approved' -Code 'PE-SEM-005' + + $findings.Count | Should -Be 1 + $findings[0].Severity | Should -Be 'Error' + } + + It 'detects a reference to a disabled data source' { + $findings = Get-Finding -Fixture 'PE-SEM-006-unavailable-data-source' -Code 'PE-SEM-006' + + $findings.Count | Should -BeGreaterThan 0 + $findings[0].Severity | Should -Be 'Error' + $findings[0].Description | Should -Match 'EvaluationError' + } + + It 'detects memberOf with no group Object IDs' { + $findings = Get-Finding -Fixture 'PE-SEM-007-memberof-without-groups' -Code 'PE-SEM-007' + + $findings.Count | Should -BeGreaterThan 0 + $findings[0].Severity | Should -Be 'Error' + } + + It 'detects in without a values array' { + $findings = Get-Finding -Fixture 'PE-SEM-008-in-without-values' -Code 'PE-SEM-008' + + $findings.Count | Should -Be 1 + $findings[0].Severity | Should -Be 'Error' + } + + It 'detects isNotNull carrying a comparison value' { + # The value is silently ignored at evaluation time, so the rule does not do + # what the author plainly intended it to do. + $findings = Get-Finding -Fixture 'PE-SEM-009-isnull-with-value' -Code 'PE-SEM-009' + + $findings.Count | Should -Be 1 + $findings[0].Severity | Should -Be 'Error' + } + + It 'detects a persona absent from the declared catalogue' { + $findings = Get-Finding -Fixture 'PE-SEM-010-undeclared-persona' -Code 'PE-SEM-010' + + $findings.Count | Should -Be 1 + $findings[0].Description | Should -Match 'Undeclared-Persona' + } + + It 'detects Unclassified used as an ordinary rule persona' { + $findings = Get-Finding -Fixture 'PE-SEM-011-unclassified-as-persona' -Code 'PE-SEM-011' + + $findings.Count | Should -Be 1 + $findings[0].Severity | Should -Be 'Error' + } + + It 'detects nesting deeper than the configured maximum' { + $findings = Get-Finding -Fixture 'PE-SEM-012-depth-over-configured-maximum' -Code 'PE-SEM-012' + + $findings.Count | Should -BeGreaterThan 0 + $findings[0].Severity | Should -Be 'Error' + $findings[0].Location | Should -Match 'conditions' + } + + It 'detects a configured maximum above the hard ceiling of 10' { + $findings = Get-Finding -Fixture 'PE-SEM-013-depth-over-hard-ceiling' -Code 'PE-SEM-013' + + $findings.Count | Should -Be 1 + $findings[0].Location | Should -Be 'engine.maxConditionDepth' + } + + It 'warns when a condition mode differs from an explicitly pinned global mode' { + # A Warning, not an Error: the three facets are retrieved independently, so + # the condition is answered correctly. The cost is an extra call per account, + # which is worth surfacing but not worth blocking. + $findings = Get-Finding -Fixture 'PE-SEM-014-mode-not-enabled-globally' -Code 'PE-SEM-014' + + $findings.Count | Should -BeGreaterThan 0 + $findings[0].Severity | Should -Be 'Warning' + } + + It 'detects an unsupported property name' { + $findings = Get-Finding -Fixture 'PE-SEM-015-unsupported-property' -Code 'PE-SEM-015' + + $findings.Count | Should -Be 1 + $findings[0].Description | Should -Match 'employeeHireDate' + } + + It 'detects an invalid regular expression' { + $findings = Get-Finding -Fixture 'PE-SEM-016-invalid-regex' -Code 'PE-SEM-016' + + $findings.Count | Should -Be 1 + $findings[0].Severity | Should -Be 'Error' + } +} + +Describe 'Semantic findings carry everything VR-004 requires' { + + It 'gives every finding a severity, code, location, description, resolution, and layer' { + $fixtures = Get-ChildItem -Path $script:corpus -Filter 'PE-SEM-*.json' + $fixtures.Count | Should -BeGreaterThan 0 + + foreach ($fixture in $fixtures) { + $document = Get-Content -LiteralPath $fixture.FullName -Raw | ConvertFrom-Json -Depth 32 + + foreach ($finding in (Test-PersonaConfigurationSemantic -Document $document)) { + $finding.Severity | Should -BeIn @('Error', 'Warning', 'Information') + $finding.Code | Should -Match '^PE-SEM-\d{3}$' + $finding.Location | Should -Not -BeNullOrEmpty + $finding.Description | Should -Not -BeNullOrEmpty + $finding.SuggestedResolution | Should -Not -BeNullOrEmpty + $finding.Layer | Should -Be 'Semantic' + } + } + } + + It 'produces no findings for the valid baseline' { + # Without this, a validator that flagged everything would pass every test above. + $document = Get-Content -LiteralPath (Join-Path $script:corpus 'baseline-deployed.json') -Raw | ConvertFrom-Json -Depth 32 + + @(Test-PersonaConfigurationSemantic -Document $document) | Should -BeNullOrEmpty + } + + It 'produces no findings for the shipped example configuration' { + $document = Get-Content -LiteralPath (Join-Path $repoRoot 'config/persona-engine.example.json') -Raw | ConvertFrom-Json -Depth 32 + + @(Test-PersonaConfigurationSemantic -Document $document) | Should -BeNullOrEmpty + } +} diff --git a/tests/Configuration/TestJsonBehaviour.Tests.ps1 b/tests/Configuration/TestJsonBehaviour.Tests.ps1 new file mode 100644 index 0000000..bdd649c --- /dev/null +++ b/tests/Configuration/TestJsonBehaviour.Tests.ps1 @@ -0,0 +1,101 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + Pins the V-5a observation about Test-Json -SchemaFile. + + OTD-005 chose a cmdlet whose failure reporting is version-dependent, and + validation layer 2 is written against the behaviour observed on PowerShell 7.6.5 + (specs/001-persona-engine/verification/V-5a.md). If a future build changes any row + of that table, the wrapper's assumptions change with it - and the failure mode is + silent: configurations start passing validation that should not. + + These tests fail loudly at that moment instead. +#> + +BeforeAll { + $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + + $script:schemaFile = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-pin-{0}.json" -f [guid]::NewGuid().ToString('N')) + Set-Content -LiteralPath $script:schemaFile -Encoding utf8NoBOM -Value @' +{ "type": "object", "required": ["a"], "properties": { "a": { "type": "string" } } } +'@ + + $script:brokenSchemaFile = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-pin-broken-{0}.json" -f [guid]::NewGuid().ToString('N')) + Set-Content -LiteralPath $script:brokenSchemaFile -Encoding utf8NoBOM -Value '{ not json' +} + +AfterAll { + Remove-Item -LiteralPath $script:schemaFile, $script:brokenSchemaFile -Force -ErrorAction SilentlyContinue +} + +Describe 'Test-Json -SchemaFile failure behaviour (V-5a)' { + + It 'returns $true and writes nothing for a valid document' { + $errors = $null + $result = '{ "a": "ok" }' | Test-Json -SchemaFile $script:schemaFile -ErrorAction SilentlyContinue -ErrorVariable errors + + $result | Should -BeTrue + @($errors).Count | Should -Be 0 + } + + It 'returns $false and writes one error for a type mismatch' { + $errors = $null + $result = '{ "a": 123 }' | Test-Json -SchemaFile $script:schemaFile -ErrorAction SilentlyContinue -ErrorVariable errors + + $result | Should -BeFalse + @($errors).Count | Should -Be 1 + } + + It 'returns $false and writes one error for a missing required property' { + $errors = $null + $result = '{}' | Test-Json -SchemaFile $script:schemaFile -ErrorAction SilentlyContinue -ErrorVariable errors + + $result | Should -BeFalse + @($errors).Count | Should -Be 1 + } + + It 'reports failure without throwing, so -ErrorVariable is sufficient' { + # If a future build made this terminating, layer 2 would abort the run instead + # of returning findings, and the editor would report an exception rather than + # a PE-SCH finding. + { '{ "a": 123 }' | Test-Json -SchemaFile $script:schemaFile -ErrorAction SilentlyContinue } | Should -Not -Throw + } + + It 'returns $TRUE when the schema itself cannot be parsed - the trap layer 2 is built around' { + # The load-bearing observation. A wrapper trusting the return value alone + # would report every configuration as schema-valid against a schema that + # never ran. + $errors = $null + $result = '{}' | Test-Json -SchemaFile $script:brokenSchemaFile -ErrorAction SilentlyContinue -ErrorVariable errors + + $result | Should -BeTrue + @($errors).Count | Should -Be 1 + $errors[0].Exception.Message | Should -Match 'Cannot parse the JSON schema' + } + + It 'embeds a JSON pointer in the failure message, which VR-004 needs for Location' { + $errors = $null + $null = '{ "a": 123 }' | Test-Json -SchemaFile $script:schemaFile -ErrorAction SilentlyContinue -ErrorVariable errors + + $errors[0].Exception.Message | Should -Match "at '/a'" + } + + It 'reports one error per violating location when several properties fail' { + # Not exhaustive across nested subschemas, but not first-failure-only either. + # Layer 2 therefore emits one finding per collected error rather than assuming + # a single one. + $multiSchema = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-pin-multi-{0}.json" -f [guid]::NewGuid().ToString('N')) + Set-Content -LiteralPath $multiSchema -Encoding utf8NoBOM -Value @' +{ "type": "object", "required": ["a", "b"], "properties": { "a": { "type": "string" }, "b": { "type": "string" } } } +'@ + try { + $errors = $null + $null = '{ "a": 1, "b": 2 }' | Test-Json -SchemaFile $multiSchema -ErrorAction SilentlyContinue -ErrorVariable errors + + @($errors).Count | Should -Be 2 + ($errors | ForEach-Object { $_.Exception.Message }) -join ' ' | Should -Match "at '/a'" + ($errors | ForEach-Object { $_.Exception.Message }) -join ' ' | Should -Match "at '/b'" + } + finally { Remove-Item -LiteralPath $multiSchema -Force -ErrorAction SilentlyContinue } + } +} diff --git a/tests/Configuration/TraceAcknowledgement.Tests.ps1 b/tests/Configuration/TraceAcknowledgement.Tests.ps1 new file mode 100644 index 0000000..1c9e47f --- /dev/null +++ b/tests/Configuration/TraceAcknowledgement.Tests.ps1 @@ -0,0 +1,109 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + VR-003: traceConditionValues without explicit acknowledgement is a safety finding. + + The acknowledgement lives in the configuration rather than in a command-line + switch, and that placement is the point. A flag passed at the console is invisible + to review; a field in the configuration appears in the diff of the change that + enables tracing, next to the person who approved it. +#> + +BeforeAll { + $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + . (Join-Path $repoRoot 'tests/TestHelpers.ps1') + foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file } + + $script:schema = Join-Path $repoRoot 'config/persona-engine.schema.json' + $script:scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-trace-{0}" -f [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $script:scratch -Force +} + +AfterAll { + Remove-Item -LiteralPath $script:scratch -Recurse -Force -ErrorAction SilentlyContinue +} + +Describe 'Tracing acknowledgement (VR-003)' { + + It 'produces PE-SAF-006 when tracing is on and acknowledgement is absent' { + $document = New-TestConfigurationDocument + $document.logging = @{ destination = 'stream'; traceConditionValues = $true } + + $path = Save-TestConfiguration -Document $document -Directory $script:scratch + $result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema + + $result.IsValid | Should -BeFalse + $result.Findings.Code | Should -Contain 'PE-SAF-006' + } + + It 'produces PE-SAF-006 when acknowledgement is present but false' { + $document = New-TestConfigurationDocument + $document.logging = @{ destination = 'stream'; traceConditionValues = $true; acknowledgeConditionTracing = $false } + + $path = Save-TestConfiguration -Document $document -Directory $script:scratch + $result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema + + $result.Findings.Code | Should -Contain 'PE-SAF-006' + } + + It 'accepts tracing when acknowledgement is true' { + $document = New-TestConfigurationDocument + $document.logging = @{ destination = 'stream'; traceConditionValues = $true; acknowledgeConditionTracing = $true } + + $path = Save-TestConfiguration -Document $document -Directory $script:scratch + $result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema + + $result.IsValid | Should -BeTrue + $result.Findings.Code | Should -Not -Contain 'PE-SAF-006' + } + + It 'does not require acknowledgement when tracing is off' { + # Acknowledging something that is not happening would train people to set the + # field reflexively, which is how an acknowledgement stops meaning anything. + $document = New-TestConfigurationDocument + $document.logging = @{ destination = 'stream'; traceConditionValues = $false } + + $path = Save-TestConfiguration -Document $document -Directory $script:scratch + $result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema + + $result.IsValid | Should -BeTrue + $result.Findings.Code | Should -Not -Contain 'PE-SAF-006' + } + + It 'blocks the run: the finding is an Error, not a Warning' { + $document = New-TestConfigurationDocument + $document.logging = @{ destination = 'stream'; traceConditionValues = $true } + + $path = Save-TestConfiguration -Document $document -Directory $script:scratch + $result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema + + ($result.Findings | Where-Object Code -EQ 'PE-SAF-006').Severity | Should -Be 'Error' + } + + It 'explains what tracing actually widens, not merely that it is enabled' { + $document = New-TestConfigurationDocument + $document.logging = @{ destination = 'stream'; traceConditionValues = $true } + + $path = Save-TestConfiguration -Document $document -Directory $script:scratch + $finding = (Test-PersonaConfiguration -Path $path -SchemaPath $script:schema).Findings | + Where-Object Code -EQ 'PE-SAF-006' + + $finding.Description | Should -Match 'attribute values' + $finding.SuggestedResolution | Should -Match 'acknowledgeConditionTracing' + } +} + +Describe 'The schema accepts the acknowledgement field' { + + It 'validates a configuration carrying acknowledgeConditionTracing' { + # additionalProperties is false on the logging block, so the field has to be + # declared in the schema or the acknowledgement itself becomes a schema error. + $document = New-TestConfigurationDocument + $document.logging = @{ destination = 'both'; path = ''; traceConditionValues = $true; acknowledgeConditionTracing = $true } + + $path = Save-TestConfiguration -Document $document -Directory $script:scratch + $result = Test-PersonaConfiguration -Path $path -SchemaPath $script:schema + + $result.Findings.Code | Should -Not -Contain 'PE-SCH-001' + } +} diff --git a/tests/PesterConfiguration.ps1 b/tests/PesterConfiguration.ps1 new file mode 100644 index 0000000..e1dac02 --- /dev/null +++ b/tests/PesterConfiguration.ps1 @@ -0,0 +1,39 @@ +<# + Shared Pester configuration. + + Three suites, separated by tag so the offline set can be run with no tenant, + no credentials, and no network (SC-008): + + Offline - rule engine, normalization, configuration validation + Safety - zero-write and single-attribute-body assertions (mocked adapter) + Integration - requires a delegated read-only tenant connection (Stage A2) + + Usage: + $cfg = & ./tests/PesterConfiguration.ps1 -Suite Offline + Invoke-Pester -Configuration $cfg +#> +[CmdletBinding()] +param( + [ValidateSet('Offline', 'Safety', 'Integration', 'All')] + [string] $Suite = 'Offline', + + [string] $Path = (Join-Path $PSScriptRoot '.') +) + +$config = New-PesterConfiguration +$config.Run.Path = $Path +$config.Output.Verbosity = 'Detailed' +$config.Should.ErrorAction = 'Continue' + +switch ($Suite) { + 'Offline' { + # Integration is excluded rather than Offline included, so a new suite + # that forgets its tag still runs offline instead of silently never running. + $config.Filter.ExcludeTag = @('Integration') + } + 'Safety' { $config.Filter.Tag = @('Safety') } + 'Integration' { $config.Filter.Tag = @('Integration') } + 'All' { } +} + +$config diff --git a/tests/RuleEngine/Composition.Tests.ps1 b/tests/RuleEngine/Composition.Tests.ps1 new file mode 100644 index 0000000..47ed37f --- /dev/null +++ b/tests/RuleEngine/Composition.Tests.ps1 @@ -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 + } +} diff --git a/tests/RuleEngine/Determinism.Tests.ps1 b/tests/RuleEngine/Determinism.Tests.ps1 new file mode 100644 index 0000000..5e1563d --- /dev/null +++ b/tests/RuleEngine/Determinism.Tests.ps1 @@ -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 + } +} diff --git a/tests/RuleEngine/EvaluationError.Tests.ps1 b/tests/RuleEngine/EvaluationError.Tests.ps1 new file mode 100644 index 0000000..93b8a70 --- /dev/null +++ b/tests/RuleEngine/EvaluationError.Tests.ps1 @@ -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' + } +} diff --git a/tests/RuleEngine/Operators.Tests.ps1 b/tests/RuleEngine/Operators.Tests.ps1 new file mode 100644 index 0000000..d68f939 --- /dev/null +++ b/tests/RuleEngine/Operators.Tests.ps1 @@ -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 @('') -RolesRetrieved) + Test-Op @{ type = 'role'; operator = 'memberOf'; roleIds = @('') } $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 @('') -RolesRetrieved) + Test-Op @{ type = 'role'; operator = 'memberOf'; roleIds = @('') } $u 'Direct' | Should -Be 'True' + } + } +} diff --git a/tests/RuleEngine/Ordering.Tests.ps1 b/tests/RuleEngine/Ordering.Tests.ps1 new file mode 100644 index 0000000..d8654bd --- /dev/null +++ b/tests/RuleEngine/Ordering.Tests.ps1 @@ -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 + } +} diff --git a/tests/RuleEngine/Unclassified.Tests.ps1 b/tests/RuleEngine/Unclassified.Tests.ps1 new file mode 100644 index 0000000..211cf06 --- /dev/null +++ b/tests/RuleEngine/Unclassified.Tests.ps1 @@ -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' + } +} diff --git a/tests/RuleEngine/UnknownNotFalse.Tests.ps1 b/tests/RuleEngine/UnknownNotFalse.Tests.ps1 new file mode 100644 index 0000000..5fe1ea4 --- /dev/null +++ b/tests/RuleEngine/UnknownNotFalse.Tests.ps1 @@ -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 -> True + notMemberOf -> 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 = @('') + }) | 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' + } +} diff --git a/tests/RuleEngine/UnknownPropagation.Tests.ps1 b/tests/RuleEngine/UnknownPropagation.Tests.ps1 new file mode 100644 index 0000000..400b568 --- /dev/null +++ b/tests/RuleEngine/UnknownPropagation.Tests.ps1 @@ -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' + } + } +} diff --git a/tests/Safety/Idempotence.Tests.ps1 b/tests/Safety/Idempotence.Tests.ps1 new file mode 100644 index 0000000..0b94499 --- /dev/null +++ b/tests/Safety/Idempotence.Tests.ps1 @@ -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__' + $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 + } +} diff --git a/tests/Safety/ShouldProcessGate.Tests.ps1 b/tests/Safety/ShouldProcessGate.Tests.ps1 new file mode 100644 index 0000000..9edb66b --- /dev/null +++ b/tests/Safety/ShouldProcessGate.Tests.ps1 @@ -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__' + $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' + } +} diff --git a/tests/Safety/WhatIfZeroWrites.Tests.ps1 b/tests/Safety/WhatIfZeroWrites.Tests.ps1 new file mode 100644 index 0000000..cb2fbda --- /dev/null +++ b/tests/Safety/WhatIfZeroWrites.Tests.ps1 @@ -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__' + + $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 + } +} diff --git a/tests/Safety/WriteBody.Tests.ps1 b/tests/Safety/WriteBody.Tests.ps1 new file mode 100644 index 0000000..4e48beb --- /dev/null +++ b/tests/Safety/WriteBody.Tests.ps1 @@ -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__' + $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 + } +} diff --git a/tests/Safety/WriteBodyRejection.Tests.ps1 b/tests/Safety/WriteBodyRejection.Tests.ps1 new file mode 100644 index 0000000..95ed6e7 --- /dev/null +++ b/tests/Safety/WriteBodyRejection.Tests.ps1 @@ -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__' + $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__' -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__') } | + 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' + } +} diff --git a/tests/Safety/WriteGate.Tests.ps1 b/tests/Safety/WriteGate.Tests.ps1 new file mode 100644 index 0000000..cb55807 --- /dev/null +++ b/tests/Safety/WriteGate.Tests.ps1 @@ -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__' + + 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__') + + $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 + } +} diff --git a/tests/Test-EnginePurity.ps1 b/tests/Test-EnginePurity.ps1 new file mode 100644 index 0000000..d7b777e --- /dev/null +++ b/tests/Test-EnginePurity.ps1 @@ -0,0 +1,102 @@ +<# +.SYNOPSIS + Fails if the rule engine has acquired a dependency it must not have. + +.DESCRIPTION + Constitution Principle IV: the pure rule engine must not depend on Microsoft + Graph, authentication, Azure Automation, or console rendering. Directory + structure alone does not enforce that — one convenient call is all it takes to + make the engine untestable offline, and the failure is silent until someone + tries to run the tests without a tenant. + + This check is the enforcement. It runs in CI (pipelines/validate.yml) and is + cheap enough to run locally on every change. + +.EXAMPLE + ./tests/Test-EnginePurity.ps1 +#> +[CmdletBinding()] +param( + [string] $EnginePath = (Join-Path (Split-Path $PSScriptRoot -Parent) 'src/RuleEngine'), + [switch] $PassThru +) + +$ErrorActionPreference = 'Stop' + +$forbidden = @( + @{ Name = 'Microsoft Graph call'; Regex = 'Invoke-MgGraphRequest|Connect-MgGraph|graph\.microsoft\.com|Invoke-PersonaGraphRequest' } + @{ Name = 'Authentication layer'; Regex = 'Connect-Persona\w+' } + @{ Name = 'Data provider layer'; Regex = 'Get-Persona(Users|GroupMembership|DirectoryRoles)' } + @{ Name = 'Persistence layer'; Regex = 'Set-UserPersonaAttribute|New-PersonaWriteBody|Compare-PersonaValue' } + @{ Name = 'Console rendering'; Regex = 'Write-Host|Write-UserPersonaResult|Write-PersonaSummary' } + @{ Name = 'Direct HTTP'; Regex = 'Invoke-RestMethod|Invoke-WebRequest|System\.Net\.Http' } + @{ Name = 'Filesystem access'; Regex = 'Get-Content|Set-Content|Out-File|Export-Csv' } + @{ Name = 'Non-deterministic input'; Regex = 'Get-Random|Get-Date|\[datetime\]::(Now|UtcNow|Today)|New-Guid' } +) + +if (-not (Test-Path $EnginePath)) { + Write-Host "Engine path '$EnginePath' does not exist yet - nothing to check." -ForegroundColor Yellow + if ($PassThru) { return @() } + exit 0 +} + +$findings = [System.Collections.Generic.List[object]]::new() + +foreach ($file in Get-ChildItem -Path $EnginePath -Filter '*.ps1' -File -Recurse) { + + # Tokenize rather than scan raw text. Comments in this codebase legitimately + # name the forbidden functions when explaining why the engine does not call + # them, and a text scan cannot tell a trailing comment from code. The parser + # can, exactly. + $tokens = $null + $parseErrors = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile( + $file.FullName, [ref]$tokens, [ref]$parseErrors) + + if ($parseErrors.Count -gt 0) { + $findings.Add([pscustomobject]@{ + File = $file.Name + Line = $parseErrors[0].Extent.StartLineNumber + Dependency = 'Parse error' + Text = $parseErrors[0].Message + }) + continue + } + + # Rebuild each line from its non-comment tokens. + $codeByLine = @{} + foreach ($token in $tokens) { + if ($token.Kind -eq 'Comment') { continue } + + $line = $token.Extent.StartLineNumber + if (-not $codeByLine.ContainsKey($line)) { $codeByLine[$line] = [System.Text.StringBuilder]::new() } + $null = $codeByLine[$line].Append($token.Text).Append(' ') + } + + foreach ($line in ($codeByLine.Keys | Sort-Object)) { + $code = $codeByLine[$line].ToString() + + foreach ($rule in $forbidden) { + if ($code -match $rule.Regex) { + $findings.Add([pscustomobject]@{ + File = $file.Name + Line = $line + Dependency = $rule.Name + Text = $code.Trim() + }) + } + } + } +} + +if ($findings.Count -gt 0) { + Write-Host "Engine purity check FAILED - $($findings.Count) violation(s) of Principle IV:" -ForegroundColor Red + $findings | Format-Table -AutoSize | Out-String | Write-Host + Write-Host 'The rule engine must remain testable offline with synthetic data.' -ForegroundColor Red + if ($PassThru) { return $findings } + exit 1 +} + +Write-Host 'Engine purity check passed: the rule engine has no forbidden dependencies.' -ForegroundColor Green +if ($PassThru) { return @() } +exit 0 diff --git a/tests/Test-Sanitization.ps1 b/tests/Test-Sanitization.ps1 new file mode 100644 index 0000000..22e9e4c --- /dev/null +++ b/tests/Test-Sanitization.ps1 @@ -0,0 +1,138 @@ +<# +.SYNOPSIS + Fails if any tracked file contains data that must never be committed (SC-013). + +.DESCRIPTION + Constitution: every artifact must be free of organization names, real domains, + tenant or subscription IDs, real UPNs or Object IDs, and any secret. + + The scan is intentionally blunt. A false positive costs a placeholder rewrite; + a false negative commits tenant data to history, where deleting it later does + not undo the disclosure. + + Placeholder GUIDs are permitted: any GUID built only from zeros plus a short + hex suffix (00000000-0000-0000-0000-0000000000a0) is obviously synthetic. + +.EXAMPLE + ./tests/Test-Sanitization.ps1 + ./tests/Test-Sanitization.ps1 -Path ./src +#> +[CmdletBinding()] +param( + [string] $Path = (Split-Path $PSScriptRoot -Parent), + [switch] $PassThru +) + +$ErrorActionPreference = 'Stop' + +# A GUID is synthetic when every character before the final short suffix is 0. +$placeholderGuid = '^0{8}-0{4}-0{4}-0{4}-0{8}[0-9a-f]{4}$' + +$patterns = @( + @{ + Name = 'Real GUID (tenant, subscription, object, or group ID)' + Regex = '\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b' + Exclude = $placeholderGuid + # A PowerShell module manifest must carry a genuine, unique GUID as its + # identity - it is what distinguishes this module from another of the same + # name. It identifies the module, not a tenant, and cannot be replaced with a + # placeholder without breaking module resolution. + ExcludeLine = '^\s*GUID\s*=' + } + @{ + Name = 'Email address or UPN' + # Placeholders such as @ contain angle brackets and + # are excluded by requiring word characters on both sides. + Regex = '\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b' + # Reserved, permanently unresolvable domains from RFC 2606 and RFC 6761. + # These exist precisely so documentation and fixtures can use an address that + # is guaranteed never to reach a real mailbox. Rejecting them would push + # fixtures toward something that merely looks fake, which is worse: the + # difference between "obviously synthetic" and "probably nobody's" is the + # whole point of the reserved list. + Exclude = '@(?:[A-Za-z0-9-]+\.)*(?:example\.(?:com|net|org)|invalid|test|localhost)$' + } + @{ + Name = 'onmicrosoft.com domain' + Regex = '[A-Za-z0-9-]+\.onmicrosoft\.com' + } + @{ + Name = 'Bearer token or JWT' + Regex = 'eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}' + } + @{ + Name = 'Assigned secret, password, or key literal' + Regex = '(?i)\b(client_?secret|password|api_?key|access_?token)\b\s*[:=]\s*["\x27][^"\x27<][^"\x27]*["\x27]' + } + @{ + Name = 'PEM private key block' + Regex = '-----BEGIN [A-Z ]*PRIVATE KEY-----' + } +) + +Push-Location $Path +try { + # Tracked files AND untracked files that are not gitignored. + # + # Tracked-only would make this gate useless where it matters most: a leaked + # identifier in a file that has not been committed yet is precisely the one worth + # catching, and scanning only what is already in history means the scan passes + # right up until the commit that makes it too late. + # + # --exclude-standard keeps gitignored build output and local scratch files out, + # so the scan covers exactly what a commit would add. + $files = git ls-files --cached --others --exclude-standard 2>$null | Sort-Object -Unique + if (-not $files) { + throw "Not a git repository or no files to scan under '$Path'." + } + + # This scanner necessarily contains the patterns it searches for. + $selfName = 'tests/Test-Sanitization.ps1' + + $findings = [System.Collections.Generic.List[object]]::new() + + foreach ($file in $files) { + if ($file -eq $selfName) { continue } + if (-not (Test-Path $file -PathType Leaf)) { continue } + + # Skip binaries. + if ($file -match '\.(png|jpg|jpeg|gif|ico|pdf|zip|dll|exe|pfx|cer)$') { continue } + + $lineNumber = 0 + foreach ($line in (Get-Content -LiteralPath $file -ErrorAction SilentlyContinue)) { + $lineNumber++ + foreach ($pattern in $patterns) { + # A line-level exemption is narrower than a file-level one on purpose: + # exempting a whole file would let a real identifier land anywhere in + # it, and the files that need an exemption at all are exactly the ones + # worth keeping under scrutiny. + if ($pattern.ExcludeLine -and $line -match $pattern.ExcludeLine) { continue } + + foreach ($match in [regex]::Matches($line, $pattern.Regex)) { + if ($pattern.Exclude -and $match.Value -match $pattern.Exclude) { continue } + + $findings.Add([pscustomobject]@{ + File = $file + Line = $lineNumber + Pattern = $pattern.Name + Match = $match.Value + }) + } + } + } + } +} +finally { + Pop-Location +} + +if ($findings.Count -gt 0) { + Write-Host "Sanitization scan FAILED - $($findings.Count) finding(s):" -ForegroundColor Red + $findings | Format-Table -AutoSize | Out-String | Write-Host + if ($PassThru) { return $findings } + exit 1 +} + +Write-Host 'Sanitization scan passed: no tenant data, credentials, or real identifiers found.' -ForegroundColor Green +if ($PassThru) { return @() } +exit 0 diff --git a/tests/TestData/InvalidConfigs/New-InvalidConfigCorpus.ps1 b/tests/TestData/InvalidConfigs/New-InvalidConfigCorpus.ps1 new file mode 100644 index 0000000..854c99e --- /dev/null +++ b/tests/TestData/InvalidConfigs/New-InvalidConfigCorpus.ps1 @@ -0,0 +1,188 @@ +<# + Regenerates the invalid-configuration corpus. + + Each file is a valid configuration with exactly ONE thing broken, named after the + finding code it must produce. One defect per file is the point: a fixture with two + problems cannot prove which one produced the finding, and a validator that + reported the wrong code would still pass. + + Run this only when adding a condition. The generated files are committed, so a + reviewer sees the fixture in the diff rather than a script that produces it. + + pwsh ./tests/TestData/InvalidConfigs/New-InvalidConfigCorpus.ps1 +#> +[CmdletBinding()] +param( + [string] $OutputDirectory = $PSScriptRoot +) + +$ErrorActionPreference = 'Stop' + +function New-BaseDocument { + @{ + configVersion = '1.0.0' + engine = @{ + targetAttribute = 'extension__' + approvedWritableAttributes = @('extension__') + maxConditionDepth = 5 + summaryInterval = 25 + defaultMembershipMode = 'direct' + } + dataSources = @{ + groups = @{ enabled = $true } + roles = @{ enabled = $true } + } + logging = @{ destination = 'stream'; traceConditionValues = $false } + personas = @('Employee', 'Guest', 'Tier0-Admin') + rules = @( + @{ + id = 'RULE-0010-GUEST'; name = 'Guest accounts'; description = 'Accounts whose user type is Guest.' + enabled = $true; priority = 10; persona = 'Guest' + match = @{ operator = 'all'; conditions = @(@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' }) } + } + @{ + id = 'RULE-0900-EMPLOYEE'; name = 'Employees'; description = 'Default classification for member accounts.' + enabled = $true; priority = 900; persona = 'Employee' + match = @{ operator = 'all'; conditions = @(@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }) } + } + ) + } +} + +function New-MembershipRule { + param([hashtable] $Condition) + + @{ + id = 'RULE-0030-TIER0'; name = 'Tier 0 administrators'; description = 'Members of the Tier 0 group.' + enabled = $true; priority = 30; persona = 'Tier0-Admin' + match = @{ operator = 'all'; conditions = @($Condition) } + } +} + +$cases = [ordered]@{} + +# ---------------------------------------------------------------- VR-002 + +$d = New-BaseDocument +$d.rules[1].id = 'RULE-0010-GUEST' +$cases['PE-SEM-001-duplicate-rule-id'] = $d + +$d = New-BaseDocument +$d.rules[1].priority = 10 +$cases['PE-SEM-002-duplicate-priority'] = $d + +$d = New-BaseDocument +foreach ($rule in $d.rules) { $rule.enabled = $false } +$cases['PE-SEM-003-no-enabled-rules'] = $d + +$d = New-BaseDocument +$d.engine.targetAttribute = ' ' +$cases['PE-SEM-004-blank-target-attribute'] = $d + +$d = New-BaseDocument +$d.engine.approvedWritableAttributes = @('extension__') +$cases['PE-SEM-005-target-not-approved'] = $d + +$d = New-BaseDocument +$d.dataSources.roles.enabled = $false +$d.rules += New-MembershipRule -Condition @{ type = 'role'; operator = 'memberOf'; roleIds = @('') } +$cases['PE-SEM-006-unavailable-data-source'] = $d + +$d = New-BaseDocument +$d.rules += New-MembershipRule -Condition @{ type = 'membership'; operator = 'memberOf' } +$cases['PE-SEM-007-memberof-without-groups'] = $d + +$d = New-BaseDocument +$d.rules[0].match.conditions[0] = @{ type = 'property'; property = 'UserType'; operator = 'in' } +$cases['PE-SEM-008-in-without-values'] = $d + +$d = New-BaseDocument +$d.rules[1].match.conditions[0] = @{ type = 'property'; property = 'Department'; operator = 'isNotNull'; value = 'Finance' } +$cases['PE-SEM-009-isnull-with-value'] = $d + +$d = New-BaseDocument +$d.rules[1].persona = 'Undeclared-Persona' +$cases['PE-SEM-010-undeclared-persona'] = $d + +$d = New-BaseDocument +$d.rules[1].persona = 'Unclassified' +$cases['PE-SEM-011-unclassified-as-persona'] = $d + +$d = New-BaseDocument +$d.engine.maxConditionDepth = 2 +$d.rules[0].match = @{ + operator = 'all' + conditions = @( + @{ operator = 'all'; conditions = @( + @{ operator = 'all'; conditions = @( + @{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' } + ) } + ) } + ) +} +$cases['PE-SEM-012-depth-over-configured-maximum'] = $d + +$d = New-BaseDocument +$d.engine.maxConditionDepth = 25 +$cases['PE-SEM-013-depth-over-hard-ceiling'] = $d + +$d = New-BaseDocument +$d.dataSources.groups.membershipMode = 'direct' +$d.rules += New-MembershipRule -Condition @{ + type = 'membership'; operator = 'memberOf'; membershipMode = 'transitive' + groupObjectIds = @('00000000-0000-0000-0000-0000000000a0') +} +$cases['PE-SEM-014-mode-not-enabled-globally'] = $d + +$d = New-BaseDocument +$d.rules[1].match.conditions[0] = @{ type = 'property'; property = 'employeeHireDate'; operator = 'isNotNull' } +$cases['PE-SEM-015-unsupported-property'] = $d + +$d = New-BaseDocument +$d.rules[1].match.conditions[0] = @{ type = 'property'; property = 'Department'; operator = 'matchesRegex'; value = '[unclosed' } +$cases['PE-SEM-016-invalid-regex'] = $d + +# ---------------------------------------------------------------- VR-003 + +$d = New-BaseDocument +$d.engine.targetAttribute = '' +$d.engine.approvedWritableAttributes = @('extension__') +$cases['PE-SAF-001-blank-target-production'] = $d + +$d = New-BaseDocument +$d.engine.targetAttribute = 'department' +$d.engine.approvedWritableAttributes = @('department') +$cases['PE-SAF-002-unsupported-writable-attribute'] = $d + +$d = New-BaseDocument +$d.dataSources.groups.enabled = $false +$d.rules += New-MembershipRule -Condition @{ + type = 'membership'; operator = 'memberOf'; groupObjectIds = @('00000000-0000-0000-0000-0000000000a0') +} +$cases['PE-SAF-003-group-rules-without-group-retrieval'] = $d + +$d = New-BaseDocument +$d.configVersion = '0.9.0' +$cases['PE-SAF-004-version-downgrade'] = $d + +$d = New-BaseDocument +$d.rules = @($d.rules[0]) +$cases['PE-SAF-005-rule-removed-without-version-change'] = $d + +$d = New-BaseDocument +$d.logging.traceConditionValues = $true +$cases['PE-SAF-006-tracing-without-acknowledgement'] = $d + +# ---------------------------------------------------------------- baseline + +# The comparison baseline for PE-SAF-004 and PE-SAF-005. Valid on its own. +$d = New-BaseDocument +$cases['baseline-deployed'] = $d + +foreach ($name in $cases.Keys) { + $path = Join-Path $OutputDirectory "$name.json" + Set-Content -LiteralPath $path -Value ($cases[$name] | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM + Write-Host "wrote $name.json" +} + +Write-Host ("{0} fixture(s) written to {1}" -f $cases.Count, $OutputDirectory) diff --git a/tests/TestData/InvalidConfigs/PE-SAF-001-blank-target-production.json b/tests/TestData/InvalidConfigs/PE-SAF-001-blank-target-production.json new file mode 100644 index 0000000..ade4af6 --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SAF-001-blank-target-production.json @@ -0,0 +1,68 @@ +{ + "configVersion": "1.0.0", + "engine": { + "targetAttribute": "", + "maxConditionDepth": 5, + "approvedWritableAttributes": [ + "extension__" + ], + "defaultMembershipMode": "direct", + "summaryInterval": 25 + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SAF-002-unsupported-writable-attribute.json b/tests/TestData/InvalidConfigs/PE-SAF-002-unsupported-writable-attribute.json new file mode 100644 index 0000000..1eef46a --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SAF-002-unsupported-writable-attribute.json @@ -0,0 +1,68 @@ +{ + "configVersion": "1.0.0", + "engine": { + "targetAttribute": "department", + "maxConditionDepth": 5, + "approvedWritableAttributes": [ + "department" + ], + "defaultMembershipMode": "direct", + "summaryInterval": 25 + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SAF-003-group-rules-without-group-retrieval.json b/tests/TestData/InvalidConfigs/PE-SAF-003-group-rules-without-group-retrieval.json new file mode 100644 index 0000000..6041ca9 --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SAF-003-group-rules-without-group-retrieval.json @@ -0,0 +1,88 @@ +{ + "configVersion": "1.0.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": false + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + }, + { + "id": "RULE-0030-TIER0", + "persona": "Tier0-Admin", + "description": "Members of the Tier 0 group.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "memberOf", + "type": "membership", + "groupObjectIds": [ + "00000000-0000-0000-0000-0000000000a0" + ] + } + ] + }, + "priority": 30, + "enabled": true, + "name": "Tier 0 administrators" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SAF-004-version-downgrade.json b/tests/TestData/InvalidConfigs/PE-SAF-004-version-downgrade.json new file mode 100644 index 0000000..daaf94b --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SAF-004-version-downgrade.json @@ -0,0 +1,68 @@ +{ + "configVersion": "0.9.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SAF-005-rule-removed-without-version-change.json b/tests/TestData/InvalidConfigs/PE-SAF-005-rule-removed-without-version-change.json new file mode 100644 index 0000000..95a9149 --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SAF-005-rule-removed-without-version-change.json @@ -0,0 +1,50 @@ +{ + "configVersion": "1.0.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SAF-006-tracing-without-acknowledgement.json b/tests/TestData/InvalidConfigs/PE-SAF-006-tracing-without-acknowledgement.json new file mode 100644 index 0000000..74198b5 --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SAF-006-tracing-without-acknowledgement.json @@ -0,0 +1,68 @@ +{ + "configVersion": "1.0.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "traceConditionValues": true, + "destination": "stream" + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-001-duplicate-rule-id.json b/tests/TestData/InvalidConfigs/PE-SEM-001-duplicate-rule-id.json new file mode 100644 index 0000000..56274fa --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SEM-001-duplicate-rule-id.json @@ -0,0 +1,68 @@ +{ + "configVersion": "1.0.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "persona": "Employee", + "enabled": true, + "priority": 900, + "description": "Default classification for member accounts.", + "name": "Employees", + "id": "RULE-0010-GUEST" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-002-duplicate-priority.json b/tests/TestData/InvalidConfigs/PE-SEM-002-duplicate-priority.json new file mode 100644 index 0000000..1495a0c --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SEM-002-duplicate-priority.json @@ -0,0 +1,68 @@ +{ + "configVersion": "1.0.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "persona": "Employee", + "enabled": true, + "priority": 10, + "description": "Default classification for member accounts.", + "name": "Employees", + "id": "RULE-0900-EMPLOYEE" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-003-no-enabled-rules.json b/tests/TestData/InvalidConfigs/PE-SEM-003-no-enabled-rules.json new file mode 100644 index 0000000..25f582d --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SEM-003-no-enabled-rules.json @@ -0,0 +1,68 @@ +{ + "configVersion": "1.0.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "persona": "Guest", + "enabled": false, + "priority": 10, + "description": "Accounts whose user type is Guest.", + "name": "Guest accounts", + "id": "RULE-0010-GUEST" + }, + { + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "persona": "Employee", + "enabled": false, + "priority": 900, + "description": "Default classification for member accounts.", + "name": "Employees", + "id": "RULE-0900-EMPLOYEE" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-004-blank-target-attribute.json b/tests/TestData/InvalidConfigs/PE-SEM-004-blank-target-attribute.json new file mode 100644 index 0000000..57adaa7 --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SEM-004-blank-target-attribute.json @@ -0,0 +1,68 @@ +{ + "configVersion": "1.0.0", + "engine": { + "targetAttribute": " ", + "maxConditionDepth": 5, + "approvedWritableAttributes": [ + "extension__" + ], + "defaultMembershipMode": "direct", + "summaryInterval": 25 + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-005-target-not-approved.json b/tests/TestData/InvalidConfigs/PE-SEM-005-target-not-approved.json new file mode 100644 index 0000000..c214cbe --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SEM-005-target-not-approved.json @@ -0,0 +1,68 @@ +{ + "configVersion": "1.0.0", + "engine": { + "targetAttribute": "extension__", + "maxConditionDepth": 5, + "approvedWritableAttributes": [ + "extension__" + ], + "defaultMembershipMode": "direct", + "summaryInterval": 25 + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-006-unavailable-data-source.json b/tests/TestData/InvalidConfigs/PE-SEM-006-unavailable-data-source.json new file mode 100644 index 0000000..f227381 --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SEM-006-unavailable-data-source.json @@ -0,0 +1,88 @@ +{ + "configVersion": "1.0.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": false + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + }, + { + "id": "RULE-0030-TIER0", + "persona": "Tier0-Admin", + "description": "Members of the Tier 0 group.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "memberOf", + "type": "role", + "roleIds": [ + "" + ] + } + ] + }, + "priority": 30, + "enabled": true, + "name": "Tier 0 administrators" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-007-memberof-without-groups.json b/tests/TestData/InvalidConfigs/PE-SEM-007-memberof-without-groups.json new file mode 100644 index 0000000..dec4a2e --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SEM-007-memberof-without-groups.json @@ -0,0 +1,85 @@ +{ + "configVersion": "1.0.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + }, + { + "id": "RULE-0030-TIER0", + "persona": "Tier0-Admin", + "description": "Members of the Tier 0 group.", + "match": { + "operator": "all", + "conditions": [ + { + "type": "membership", + "operator": "memberOf" + } + ] + }, + "priority": 30, + "enabled": true, + "name": "Tier 0 administrators" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-008-in-without-values.json b/tests/TestData/InvalidConfigs/PE-SEM-008-in-without-values.json new file mode 100644 index 0000000..c6bfe3e --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SEM-008-in-without-values.json @@ -0,0 +1,67 @@ +{ + "configVersion": "1.0.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "in", + "type": "property", + "property": "UserType" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-009-isnull-with-value.json b/tests/TestData/InvalidConfigs/PE-SEM-009-isnull-with-value.json new file mode 100644 index 0000000..950d2ce --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SEM-009-isnull-with-value.json @@ -0,0 +1,69 @@ +{ + "configVersion": "1.0.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department", + "value": "Finance" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-010-undeclared-persona.json b/tests/TestData/InvalidConfigs/PE-SEM-010-undeclared-persona.json new file mode 100644 index 0000000..a34e243 --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SEM-010-undeclared-persona.json @@ -0,0 +1,68 @@ +{ + "configVersion": "1.0.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "persona": "Undeclared-Persona", + "enabled": true, + "priority": 900, + "description": "Default classification for member accounts.", + "name": "Employees", + "id": "RULE-0900-EMPLOYEE" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-011-unclassified-as-persona.json b/tests/TestData/InvalidConfigs/PE-SEM-011-unclassified-as-persona.json new file mode 100644 index 0000000..35782d2 --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SEM-011-unclassified-as-persona.json @@ -0,0 +1,68 @@ +{ + "configVersion": "1.0.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "persona": "Unclassified", + "enabled": true, + "priority": 900, + "description": "Default classification for member accounts.", + "name": "Employees", + "id": "RULE-0900-EMPLOYEE" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-012-depth-over-configured-maximum.json b/tests/TestData/InvalidConfigs/PE-SEM-012-depth-over-configured-maximum.json new file mode 100644 index 0000000..ce81eaf --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SEM-012-depth-over-configured-maximum.json @@ -0,0 +1,78 @@ +{ + "configVersion": "1.0.0", + "engine": { + "targetAttribute": "extension__", + "maxConditionDepth": 2, + "approvedWritableAttributes": [ + "extension__" + ], + "defaultMembershipMode": "direct", + "summaryInterval": 25 + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "match": { + "operator": "all", + "conditions": [ + { + "operator": "all", + "conditions": [ + { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + } + ] + } + ] + }, + "persona": "Guest", + "enabled": true, + "priority": 10, + "description": "Accounts whose user type is Guest.", + "name": "Guest accounts", + "id": "RULE-0010-GUEST" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-013-depth-over-hard-ceiling.json b/tests/TestData/InvalidConfigs/PE-SEM-013-depth-over-hard-ceiling.json new file mode 100644 index 0000000..3e75745 --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SEM-013-depth-over-hard-ceiling.json @@ -0,0 +1,68 @@ +{ + "configVersion": "1.0.0", + "engine": { + "targetAttribute": "extension__", + "maxConditionDepth": 25, + "approvedWritableAttributes": [ + "extension__" + ], + "defaultMembershipMode": "direct", + "summaryInterval": 25 + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-014-mode-not-enabled-globally.json b/tests/TestData/InvalidConfigs/PE-SEM-014-mode-not-enabled-globally.json new file mode 100644 index 0000000..d0b3799 --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SEM-014-mode-not-enabled-globally.json @@ -0,0 +1,90 @@ +{ + "configVersion": "1.0.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "membershipMode": "direct", + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + }, + { + "id": "RULE-0030-TIER0", + "persona": "Tier0-Admin", + "description": "Members of the Tier 0 group.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "memberOf", + "type": "membership", + "membershipMode": "transitive", + "groupObjectIds": [ + "00000000-0000-0000-0000-0000000000a0" + ] + } + ] + }, + "priority": 30, + "enabled": true, + "name": "Tier 0 administrators" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-015-unsupported-property.json b/tests/TestData/InvalidConfigs/PE-SEM-015-unsupported-property.json new file mode 100644 index 0000000..5c601a8 --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SEM-015-unsupported-property.json @@ -0,0 +1,68 @@ +{ + "configVersion": "1.0.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "employeeHireDate" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/PE-SEM-016-invalid-regex.json b/tests/TestData/InvalidConfigs/PE-SEM-016-invalid-regex.json new file mode 100644 index 0000000..d16a17e --- /dev/null +++ b/tests/TestData/InvalidConfigs/PE-SEM-016-invalid-regex.json @@ -0,0 +1,69 @@ +{ + "configVersion": "1.0.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "matchesRegex", + "type": "property", + "property": "Department", + "value": "[unclosed" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + } + ] +} diff --git a/tests/TestData/InvalidConfigs/baseline-deployed.json b/tests/TestData/InvalidConfigs/baseline-deployed.json new file mode 100644 index 0000000..c0be146 --- /dev/null +++ b/tests/TestData/InvalidConfigs/baseline-deployed.json @@ -0,0 +1,68 @@ +{ + "configVersion": "1.0.0", + "engine": { + "maxConditionDepth": 5, + "defaultMembershipMode": "direct", + "targetAttribute": "extension__", + "summaryInterval": 25, + "approvedWritableAttributes": [ + "extension__" + ] + }, + "personas": [ + "Employee", + "Guest", + "Tier0-Admin" + ], + "dataSources": { + "groups": { + "enabled": true + }, + "roles": { + "enabled": true + } + }, + "logging": { + "destination": "stream", + "traceConditionValues": false + }, + "rules": [ + { + "id": "RULE-0010-GUEST", + "persona": "Guest", + "description": "Accounts whose user type is Guest.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "equals", + "type": "property", + "property": "UserType", + "value": "Guest" + } + ] + }, + "priority": 10, + "enabled": true, + "name": "Guest accounts" + }, + { + "id": "RULE-0900-EMPLOYEE", + "persona": "Employee", + "description": "Default classification for member accounts.", + "match": { + "operator": "all", + "conditions": [ + { + "operator": "isNotNull", + "type": "property", + "property": "Department" + } + ] + }, + "priority": 900, + "enabled": true, + "name": "Employees" + } + ] +} diff --git a/tests/TestData/Memberships/memberships.json b/tests/TestData/Memberships/memberships.json new file mode 100644 index 0000000..2da0fe4 --- /dev/null +++ b/tests/TestData/Memberships/memberships.json @@ -0,0 +1,111 @@ +{ + "_comment": "Synthetic membership fixtures keyed by user fixtureId. Each record carries three independently-retrieved facets (RE-007). The 'transitive-failed' and 'all-failed' entries are the most important here: they are what prove an unknown lookup never becomes a false non-match (FR-013), and that a failure in one facet does not contaminate the others.", + "groups": { + "tier0": "00000000-0000-0000-0000-0000000000a0", + "serviceAccounts": "00000000-0000-0000-0000-0000000000b0", + "testAccounts": "00000000-0000-0000-0000-0000000000c0", + "restricted": "00000000-0000-0000-0000-0000000000d0" + }, + "memberships": [ + { + "fixtureId": "enabled-employee", + "directGroupObjectIds": [], + "transitiveGroupObjectIds": [], + "directoryRoleIds": [], + "allRetrieved": true + }, + { + "fixtureId": "disabled-employee", + "directGroupObjectIds": [], + "transitiveGroupObjectIds": [], + "directoryRoleIds": [], + "allRetrieved": true + }, + { + "fixtureId": "guest", + "directGroupObjectIds": [], + "transitiveGroupObjectIds": [], + "directoryRoleIds": [], + "allRetrieved": true + }, + { + "fixtureId": "null-properties", + "directGroupObjectIds": [], + "transitiveGroupObjectIds": [], + "directoryRoleIds": [], + "allRetrieved": true + }, + { + "fixtureId": "missing-properties", + "directGroupObjectIds": [], + "transitiveGroupObjectIds": [], + "directoryRoleIds": [], + "allRetrieved": true + }, + { + "fixtureId": "mixed-case-properties", + "directGroupObjectIds": [], + "transitiveGroupObjectIds": [], + "directoryRoleIds": [], + "allRetrieved": true + }, + { + "fixtureId": "contractor", + "directGroupObjectIds": [], + "transitiveGroupObjectIds": [], + "directoryRoleIds": [], + "allRetrieved": true + }, + { + "fixtureId": "service-account", + "directGroupObjectIds": [ + "00000000-0000-0000-0000-0000000000b0" + ], + "transitiveGroupObjectIds": [ + "00000000-0000-0000-0000-0000000000b0" + ], + "directoryRoleIds": [], + "allRetrieved": true + }, + { + "fixtureId": "breakglass", + "directGroupObjectIds": [ + "00000000-0000-0000-0000-0000000000a0" + ], + "transitiveGroupObjectIds": [ + "00000000-0000-0000-0000-0000000000a0" + ], + "directoryRoleIds": [ + "" + ], + "allRetrieved": true + }, + { + "fixtureId": "tier0-admin", + "directGroupObjectIds": [], + "transitiveGroupObjectIds": [ + "00000000-0000-0000-0000-0000000000a0" + ], + "directoryRoleIds": [], + "allRetrieved": true + }, + { + "fixtureId": "transitive-failed", + "directGroupObjectIds": [], + "transitiveGroupObjectIds": [], + "directoryRoleIds": [], + "directRetrieved": true, + "rolesRetrieved": true, + "transitiveFailureReason": "Graph 503 after 5 attempts" + }, + { + "fixtureId": "all-failed", + "directGroupObjectIds": [], + "transitiveGroupObjectIds": [], + "directoryRoleIds": [], + "directFailureReason": "Graph 503 after 5 attempts", + "transitiveFailureReason": "Graph 503 after 5 attempts", + "rolesFailureReason": "Graph 503 after 5 attempts" + } + ] +} diff --git a/tests/TestData/Users/users.json b/tests/TestData/Users/users.json new file mode 100644 index 0000000..42cd38d --- /dev/null +++ b/tests/TestData/Users/users.json @@ -0,0 +1,162 @@ +{ + "_comment": "Synthetic fixtures. Every value is fictional and every identifier is a placeholder-shaped GUID. These must never be replaced with real directory data (SC-013).", + "users": [ + { + "fixtureId": "enabled-employee", + "accountObjectId": "00000000-0000-0000-0000-000000000101", + "userPrincipalName": "alex.employee@example.invalid", + "displayName": "Alex Employee", + "userType": "Member", + "accountEnabled": true, + "properties": { + "CompanyName": "", + "Department": "Finance", + "JobTitle": "Analyst" + }, + "storedPersona": "Employee" + }, + { + "fixtureId": "disabled-employee", + "accountObjectId": "00000000-0000-0000-0000-000000000102", + "userPrincipalName": "blair.disabled@example.invalid", + "displayName": "Blair Disabled", + "userType": "Member", + "accountEnabled": false, + "properties": { + "CompanyName": "", + "Department": "Operations", + "JobTitle": "Coordinator" + }, + "storedPersona": "Employee" + }, + { + "fixtureId": "guest", + "accountObjectId": "00000000-0000-0000-0000-000000000103", + "userPrincipalName": "casey.guest@example.invalid", + "displayName": "Casey Guest", + "userType": "Guest", + "accountEnabled": true, + "properties": { + "CompanyName": "", + "Department": null + }, + "storedPersona": null + }, + { + "fixtureId": "null-properties", + "accountObjectId": "00000000-0000-0000-0000-000000000104", + "userPrincipalName": "drew.sparse@example.invalid", + "displayName": "Drew Sparse", + "userType": "Member", + "accountEnabled": true, + "properties": { + "CompanyName": null, + "Department": null, + "JobTitle": null + }, + "storedPersona": null + }, + { + "fixtureId": "missing-properties", + "accountObjectId": "00000000-0000-0000-0000-000000000105", + "userPrincipalName": "ellis.minimal@example.invalid", + "displayName": "Ellis Minimal", + "userType": "Member", + "accountEnabled": true, + "properties": {}, + "storedPersona": null + }, + { + "fixtureId": "service-account", + "accountObjectId": "00000000-0000-0000-0000-000000000106", + "userPrincipalName": "svc-billing@example.invalid", + "displayName": "Billing Service", + "userType": "Member", + "accountEnabled": true, + "properties": { + "CompanyName": "", + "Department": "IT" + }, + "storedPersona": "Employee" + }, + { + "fixtureId": "contractor", + "accountObjectId": "00000000-0000-0000-0000-000000000107", + "userPrincipalName": "flynn.external@example.invalid", + "displayName": "Flynn External", + "userType": "Member", + "accountEnabled": true, + "properties": { + "CompanyName": "", + "Department": "Engineering" + }, + "storedPersona": null + }, + { + "fixtureId": "breakglass", + "accountObjectId": "00000000-0000-0000-0000-000000000001", + "userPrincipalName": "emergency-access-01@example.invalid", + "displayName": "Emergency Access 01", + "userType": "Member", + "accountEnabled": true, + "properties": { + "CompanyName": "", + "Department": "IT" + }, + "storedPersona": "BreakGlass-Admin" + }, + { + "fixtureId": "mixed-case-properties", + "accountObjectId": "00000000-0000-0000-0000-000000000108", + "userPrincipalName": "GRAY.MixedCase@example.invalid", + "displayName": "Gray MixedCase", + "userType": "MEMBER", + "accountEnabled": true, + "properties": { + "CompanyName": "", + "Department": "FINANCE" + }, + "storedPersona": "employee" + }, + { + "fixtureId": "tier0-admin", + "accountObjectId": "00000000-0000-0000-0000-000000000109", + "userPrincipalName": "harper.admin@example.invalid", + "displayName": "Harper Admin", + "userType": "Member", + "accountEnabled": true, + "properties": { + "CompanyName": "", + "Department": "IT", + "JobTitle": "Directory Administrator" + }, + "storedPersona": "Employee" + }, + { + "fixtureId": "transitive-failed", + "accountObjectId": "00000000-0000-0000-0000-000000000110", + "userPrincipalName": "indigo.partial@example.invalid", + "displayName": "Indigo Partial", + "userType": "Member", + "accountEnabled": true, + "properties": { + "CompanyName": "", + "Department": "Operations" + }, + "storedPersona": "Tier0-Admin" + }, + { + "fixtureId": "all-failed", + "accountObjectId": "00000000-0000-0000-0000-000000000111", + "userPrincipalName": "jordan.unknown@example.invalid", + "displayName": "Jordan Unknown", + "userType": "Member", + "accountEnabled": true, + "properties": { + "CompanyName": "", + "Department": "Finance" + }, + "storedPersona": "Tier0-Admin" + } + ] +} diff --git a/tests/TestHelpers.ps1 b/tests/TestHelpers.ps1 new file mode 100644 index 0000000..7e26ee5 --- /dev/null +++ b/tests/TestHelpers.ps1 @@ -0,0 +1,380 @@ +<# + Shared test helpers. + + Loads the module's source files by dot-sourcing each layer rather than importing + PersonaEngine.psd1. Two reasons, both load-bearing: + + 1. The manifest declares Microsoft.Graph.Authentication as a required module. + Importing it would pull that module into the session, and SC-008 requires + the offline suites to run with no Graph module loaded at all. Dot-sourcing + is the practical proof that the pure layers do not need it. + + 2. Pester's Mock replaces functions in the scope where they are defined. + Dot-sourced functions land in the test file's scope, so mocking + Get-PersonaUsers or Set-UserPersonaAttribute works without -ModuleName + plumbing on every call. + + Nothing here fabricates engine behaviour. The functions under test are the + shipped ones; only the Graph boundary is replaced. +#> + +if (-not (Get-Command 'Invoke-MgGraphRequest' -ErrorAction SilentlyContinue)) { + function Invoke-MgGraphRequest { + <# + .SYNOPSIS + Stand-in for the real cmdlet when Microsoft.Graph.Authentication is absent. + + .DESCRIPTION + SC-008 requires the offline suites to run with no Graph module loaded, and + Pester cannot mock a command that does not exist. This stub gives Mock + something to replace. + + It throws if it is ever actually called. A stub that returned plausible + data would let a test pass while silently exercising nothing, which is + worse than no test at all. + #> + [CmdletBinding()] + param( + [string] $Uri, + [string] $Method, + [object] $Body, + [string] $ContentType, + [hashtable] $Headers, + [string] $OutputType + ) + + throw 'Invoke-MgGraphRequest stub was called without being mocked. A test reached the real Graph boundary.' + } +} + +function Get-PersonaSourceFile { + <# + .SYNOPSIS + Returns the module's source files in load order, for the caller to dot-source. + + .DESCRIPTION + Returns paths rather than dot-sourcing them itself. Dot-sourcing inside a + function loads into that function's scope, which disappears when it returns - + the functions would be defined and immediately unreachable. The caller has to + do it: + + foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file } + + The Authentication layer is excluded by default: it is the one layer that + calls Connect-MgGraph, and loading it is unnecessary for any offline suite. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] + [string] $RepoRoot, + + [string[]] $Layers = @( + 'Normalization', 'Configuration', 'RuleEngine', + 'DataProviders', 'Persistence', 'Presentation', 'Engine', 'Audit' + ) + ) + + $files = [System.Collections.Generic.List[string]]::new() + + foreach ($layer in $Layers) { + $path = Join-Path $RepoRoot "src/$layer" + if (-not (Test-Path $path)) { continue } + + foreach ($file in (Get-ChildItem -Path $path -Filter '*.ps1' -File | Sort-Object Name)) { + $files.Add($file.FullName) + } + } + + , $files.ToArray() +} + +function New-TestConfigurationDocument { + <# + .SYNOPSIS + Builds a minimal valid configuration document as a hashtable. + + .DESCRIPTION + Tests that need an INVALID configuration start from this and break exactly + one thing, so the finding under test is unambiguously caused by that one + change rather than by an unrelated defect in a hand-written fixture. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param() + + @{ + configVersion = '1.0.0' + engine = @{ + targetAttribute = 'extension__' + approvedWritableAttributes = @('extension__') + maxConditionDepth = 5 + summaryInterval = 25 + defaultMembershipMode = 'direct' + } + dataSources = @{ + groups = @{ enabled = $true } + roles = @{ enabled = $true } + } + logging = @{ destination = 'stream' } + personas = @('Employee', 'Guest', 'Tier0-Admin') + rules = @( + @{ + id = 'RULE-0010-GUEST' + name = 'Guest accounts' + description = 'Accounts whose user type is Guest.' + enabled = $true + priority = 10 + persona = 'Guest' + match = @{ + operator = 'all' + conditions = @( + @{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' } + ) + } + } + @{ + id = 'RULE-0900-EMPLOYEE' + name = 'Employees' + description = 'Default classification for member accounts with a department.' + enabled = $true + priority = 900 + persona = 'Employee' + match = @{ + operator = 'all' + conditions = @( + @{ type = 'property'; property = 'Department'; operator = 'isNotNull' } + ) + } + } + ) + } +} + +function Save-TestConfiguration { + <# + .SYNOPSIS + Writes a configuration document to a temporary file and returns its path. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [hashtable] $Document, + [string] $Directory = ([System.IO.Path]::GetTempPath()) + ) + + $path = Join-Path $Directory ("pe-test-{0}.json" -f [guid]::NewGuid().ToString('N')) + Set-Content -LiteralPath $path -Value ($Document | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM + $path +} + +function New-TestGraphUser { + <# + .SYNOPSIS + Builds a raw Graph-shaped user hashtable. + + .DESCRIPTION + A hashtable, because that is what Invoke-MgGraphRequest returns. Building + fixtures in the shape the real boundary produces is what makes the + normalization tests meaningful. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] [string] $Id, + [Parameter(Mandatory)] [string] $UserPrincipalName, + [string] $DisplayName = 'Test Account', + [string] $UserType = 'Member', + [bool] $AccountEnabled = $true, + [string] $Department = 'Finance', + [string] $CompanyName = '', + [string] $TargetAttribute = 'extension__', + [AllowEmptyString()] [string] $StoredPersona = '' + ) + + @{ + id = $Id + userPrincipalName = $UserPrincipalName + displayName = $DisplayName + userType = $UserType + accountEnabled = $AccountEnabled + department = $Department + companyName = $CompanyName + $TargetAttribute = $StoredPersona + } +} + +function New-TestPopulation { + <# + .SYNOPSIS + Builds a synthetic population spanning every outcome and action. + + .DESCRIPTION + Deliberately includes accounts that produce Matched, Unclassified, and - via + the membership fixture wired by the caller - EvaluationError, plus accounts + whose stored value already matches and accounts whose value would change. A + zero-write assertion over a population where nothing would change proves + nothing. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [int] $Count = 30, + [string] $TargetAttribute = 'extension__' + ) + + $users = [System.Collections.Generic.List[object]]::new() + + for ($i = 1; $i -le $Count; $i++) { + $id = '00000000-0000-0000-0000-{0:d12}' -f $i + + # Every third account is a Guest whose stored value is stale, so a change is + # proposed. Every fifth has no department and matches nothing. + $isGuest = ($i % 3) -eq 0 + $isUnclassifiable = -not $isGuest -and ($i % 5) -eq 0 + + $users.Add((New-TestGraphUser ` + -Id $id ` + -UserPrincipalName ("user{0:d4}@example.invalid" -f $i) ` + -UserType ($isGuest ? 'Guest' : 'Member') ` + -Department ($isUnclassifiable ? $null : 'Finance') ` + -TargetAttribute $TargetAttribute ` + -StoredPersona ($isGuest ? 'Employee' : ($isUnclassifiable ? '' : 'Employee')))) + } + + # Emitted unwrapped, so a Mock body returning this call unrolls into the pipeline + # the way a real Get-PersonaUsers does. The `, $array` idiom would emit one + # object containing the array, and @() around the call would then produce a + # single-element population - a mistake that makes a 30-user test silently a + # 1-user test. + $users.ToArray() +} + +function New-TestRuntimeConfiguration { + <# + .SYNOPSIS + Builds the configuration object shape Import-PersonaConfiguration produces. + + .DESCRIPTION + The run loop consumes the imported object, not the raw JSON document. Tests + that exercise the loop build this directly so they are not also testing the + importer - a failure here should mean the loop is wrong, not that the parser + changed. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [string] $TargetAttribute = 'extension__', + [int] $SummaryInterval = 0, + [AllowNull()] [object] $EvaluationErrorThreshold = $null, + [object[]] $Rules, + [string] $DefaultMembershipMode = 'Direct' + ) + + if (-not $Rules) { + $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' }) + } + } + ) + } + + [pscustomobject]@{ + ConfigVersion = '1.0.0' + ConfigurationHash = ('0' * 64) + SourcePath = '' + TargetAttribute = $TargetAttribute + ApprovedWritableAttributes = @($TargetAttribute) + MaxConditionDepth = 5 + SummaryInterval = $SummaryInterval + DefaultMembershipMode = $DefaultMembershipMode + EvaluationErrorThreshold = $EvaluationErrorThreshold + Rules = $Rules + } +} + +function New-TestMembershipRule { + <# + .SYNOPSIS + A rule requiring group membership, so a failed lookup becomes EvaluationError. + #> + [CmdletBinding()] + param( + [string] $GroupObjectId = '00000000-0000-0000-0000-0000000000a0', + [int] $Priority = 30, + [string] $Persona = 'Tier0-Admin' + ) + + [pscustomobject]@{ + id = 'RULE-0030-TIER0'; name = 'Tier 0 administrators'; enabled = $true + priority = $Priority; persona = $Persona + match = [pscustomobject]@{ + operator = 'all' + conditions = @([pscustomobject]@{ type = 'membership'; operator = 'memberOf'; groupObjectIds = @($GroupObjectId) }) + } + } +} + +function Get-CapturedAuditRecord { + <# + .SYNOPSIS + Extracts audit records from a captured Information stream. + + .DESCRIPTION + Write-PersonaAuditRecord emits records on the Information stream so the + success stream stays free for the run outcome. Captured entries arrive as + InformationRecord wrappers; this unwraps them and optionally filters by type. + + Usage: + + $info = $null + $outcome = Invoke-PersonaEngineRun ... -InformationVariable info + $events = Get-CapturedAuditRecord -Captured $info -RecordType 'UserEvent' + #> + [CmdletBinding()] + param( + [AllowNull()] [object] $Captured, + [string] $RecordType + ) + + $records = foreach ($entry in @($Captured)) { + $record = ($entry -is [System.Management.Automation.InformationRecord]) ? $entry.MessageData : $entry + if ($record -is [System.Collections.IDictionary]) { $record } + } + + # Both returns use the comma idiom. Without it a single matching record is + # unrolled onto the pipeline and the caller assigns the dictionary itself, so + # .Count reports the key count and [0] indexes a field rather than a record - + # which fails as a confusing type mismatch rather than as a missing record. + if ($RecordType) { return , @($records | Where-Object { $_['recordType'] -eq $RecordType }) } + + , @($records) +} + +function New-TestAuditContext { + <# + .SYNOPSIS + Builds an audit context without needing a real configuration file. + #> + [CmdletBinding()] + param( + [string] $Mode = 'Preview', + [string] $RunId = '00000000-0000-0000-0000-00000000f001' + ) + + New-PersonaAuditContext -RunId $RunId -EngineVersion '0.1.0' -Mode $Mode -Configuration ([pscustomobject]@{ + ConfigVersion = '1.0.0' + ConfigurationHash = ('0' * 64) + }) +} diff --git a/tests/Unit/AuditCompleteness.Tests.ps1 b/tests/Unit/AuditCompleteness.Tests.ps1 new file mode 100644 index 0000000..921c18c --- /dev/null +++ b/tests/Unit/AuditCompleteness.Tests.ps1 @@ -0,0 +1,170 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + SC-006, NFR-005: 100% of user events carry run ID, UPN, and Account Object ID, and + 100% of matched results carry a rule ID. + + "100%" is asserted across a whole run rather than on a single hand-built record. + A record builder can be correct in isolation and still be called wrongly on one + branch, and the branch that gets missed is invariably the error path - which is + the one an auditor will ask about. +#> + +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__' + $script:runId = '00000000-0000-0000-0000-00000000f001' + + $script: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' }) + } + } + ) + + $script:config = New-TestRuntimeConfiguration -TargetAttribute $target -Rules $script:rules +} + +Describe 'Audit completeness across a full run (SC-006)' { + + BeforeAll { + Mock Get-PersonaUsers { @(New-TestPopulation -Count 24 -TargetAttribute $script:target) } + Mock Write-Host { } + Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $true } } + + # A mixed run: some lookups fail so EvaluationError records are produced too. + $script:lookup = 0 + Mock Get-PersonaGroupMembership { + $script:lookup++ + ($script:lookup % 4) -eq 0 ` + ? (New-PersonaMembershipRecord -DirectFailureReason 'Graph 503 after 5 attempts') ` + : (New-PersonaMembershipRecord -AllRetrieved) + } + + $info = $null + $script:outcome = Invoke-PersonaEngineRun -Configuration $script:config -TargetAttribute $script:target ` + -Context (New-TestAuditContext -Mode 'Enforce' -RunId $script:runId) ` + -AuditParameters @{ Destination = 'stream' } ` + -IsEnforcing -ShouldProcessGate { param($t, $d) $true } ` + -InformationVariable info + + $script:records = Get-CapturedAuditRecord -Captured $info + $script:userEvents = Get-CapturedAuditRecord -Captured $info -RecordType 'UserEvent' + } + + It 'emits one UserEvent per processed user' { + $script:userEvents.Count | Should -Be $script:outcome.Counters.Processed + } + + It 'carries the run ID on every record, not only user events' { + foreach ($record in $script:records) { + $record['runId'] | Should -Be $script:runId + } + } + + It 'carries the UPN and Account Object ID on every user event' { + foreach ($event in $script:userEvents) { + $event['userPrincipalName'] | Should -Not -BeNullOrEmpty + $event['accountObjectId'] | Should -Not -BeNullOrEmpty + } + } + + It 'carries a matched rule ID on every Matched record' { + $matched = @($script:userEvents | Where-Object { $_['outcome'] -eq 'Matched' }) + + $matched.Count | Should -BeGreaterThan 0 + foreach ($event in $matched) { + $event['matchedRuleId'] | Should -Not -BeNullOrEmpty + } + } + + It 'carries an evaluationErrorReason on exactly the EvaluationError records' { + $errors = @($script:userEvents | Where-Object { $_['outcome'] -eq 'EvaluationError' }) + $errors.Count | Should -BeGreaterThan 0 + + foreach ($event in $script:userEvents) { + if ($event['outcome'] -eq 'EvaluationError') { + $event['evaluationErrorReason'] | Should -Not -BeNullOrEmpty + } + else { + $event['evaluationErrorReason'] | Should -BeNullOrEmpty + } + } + } + + It 'carries the configuration version and hash on every record' { + foreach ($record in $script:records) { + $record['configVersion'] | Should -Be '1.0.0' + $record['configurationHash'] | Should -Not -BeNullOrEmpty + } + } + + It 'carries the mode on every record' { + foreach ($record in $script:records) { + $record['mode'] | Should -Be 'Enforce' + } + } + + It 'carries an ISO 8601 UTC timestamp on every record' { + foreach ($record in $script:records) { + $record['timestamp'] | Should -Match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$' + } + } + + It 'records previousValue on every Updated event and on no other' { + # Captured at write time. Without it, OTD-010 rollback is impossible + # retroactively - no later run can reconstruct what a value used to be. + $updated = @($script:userEvents | Where-Object { $_['action'] -eq 'Updated' }) + $updated.Count | Should -BeGreaterThan 0 + + foreach ($event in $script:userEvents) { + if ($event['action'] -eq 'Updated') { + $event.Contains('previousValue') | Should -BeTrue + } + else { + $event['previousValue'] | Should -BeNullOrEmpty + } + } + } + + It 'emits exactly one outcome value per user event' { + foreach ($event in $script:userEvents) { + $event['outcome'] | Should -BeIn @('Matched', 'Unclassified', 'EvaluationError') + } + } +} + +Describe 'Run report completeness' { + + It 'produces a RunComplete record carrying the counters and the exit code' { + $counters = New-PersonaRunCounter -Rules $script:rules + $counters.Processed = 10 + $counters.Matched = 8 + $counters.Unclassified = 2 + + $record = Export-PersonaRunReport -Context (New-TestAuditContext) -Counters $counters ` + -StartedUtc ([DateTime]::UtcNow.AddMinutes(-5)) -ExitCode 0 + + $record['recordType'] | Should -Be 'RunComplete' + $record['processed'] | Should -Be 10 + $record['exitCode'] | Should -Be 0 + $record['reconciliationPassed'] | Should -BeTrue + $record['startedUtc'] | Should -Match '^\d{4}-\d{2}-\d{2}T' + $record['completedUtc'] | Should -Match '^\d{4}-\d{2}-\d{2}T' + $record['durationMs'] | Should -BeGreaterThan 0 + } + + It 'refuses an exit code outside the documented range' { + $counters = New-PersonaRunCounter -Rules @() + + { Export-PersonaRunReport -Context (New-TestAuditContext) -Counters $counters ` + -StartedUtc ([DateTime]::UtcNow) -ExitCode 9 } | Should -Throw + } +} diff --git a/tests/Unit/AuditRecordShape.Tests.ps1 b/tests/Unit/AuditRecordShape.Tests.ps1 new file mode 100644 index 0000000..a1900ba --- /dev/null +++ b/tests/Unit/AuditRecordShape.Tests.ps1 @@ -0,0 +1,221 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + Each record type matches contracts/audit-record.md. + + The contract is what downstream ingestion will be written against, so a field + quietly renamed here breaks a consumer that this repository never sees. These + tests are the contract's enforcement. +#> + +BeforeAll { + $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + . (Join-Path $repoRoot 'tests/TestHelpers.ps1') + foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file } + + $script:envelope = @('timestamp', 'recordType', 'runId', 'engineVersion', 'configVersion', 'configurationHash', 'mode') + + $script:counterFields = @('processed', 'matched', 'unclassified', 'evaluationError', + 'unchanged', 'wouldUpdate', 'updated', 'updateFailed', 'skipped', 'reconciliationPassed') + + function New-Result { + param( + [string] $Outcome = 'Matched', + [string] $Action = 'Unchanged', + [string] $MatchedRuleId = 'RULE-0010-GUEST', + [string] $ErrorReason = $null + ) + + [pscustomobject]@{ + AccountObjectId = '00000000-0000-0000-0000-000000000101' + UserPrincipalName = 'alex@example.invalid' + Outcome = $Outcome + MatchedRuleId = $MatchedRuleId + CalculatedPersona = 'Guest' + StoredPersona = 'Employee' + Action = $Action + EvaluationErrorReason = $ErrorReason + RulesEvaluated = 3 + DurationMs = 12 + ConditionTrace = $null + } + } +} + +Describe 'Common envelope' { + + It 'appears on every record type' { + $counters = New-PersonaRunCounter -Rules @() + $context = New-TestAuditContext + + $records = @( + New-PersonaAuditRecord -Context $context -RecordType 'RunStart' + New-PersonaAuditRecord -Context $context -RecordType 'UserEvent' -Result (New-Result) + New-PersonaAuditRecord -Context $context -RecordType 'Summary' -Counters $counters + New-PersonaAuditRecord -Context $context -RecordType 'EngineDefect' -Properties @{ severity = 'Error' } + Export-PersonaRunReport -Context $context -Counters $counters -StartedUtc ([DateTime]::UtcNow) -ExitCode 0 + ) + + foreach ($record in $records) { + foreach ($field in $script:envelope) { + $record.Contains($field) | Should -BeTrue -Because "$($record['recordType']) must carry '$field'" + } + } + } + + It 'places the envelope first, so a truncated line still identifies the run' { + $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result (New-Result) + + @($record.Keys)[0..6] | Should -Be $script:envelope + } +} + +Describe 'UserEvent shape' { + + It 'carries every documented field' { + $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result (New-Result) + + foreach ($field in @('accountObjectId', 'userPrincipalName', 'outcome', 'matchedRuleId', + 'storedPersona', 'calculatedPersona', 'previousValue', 'action', + 'rulesEvaluated', 'durationMs', 'evaluationErrorReason')) { + $record.Contains($field) | Should -BeTrue -Because "the contract names '$field'" + } + } + + It 'populates previousValue only on an Updated action' { + $updated = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' ` + -Result (New-Result -Action 'Updated') -PreviousValue 'Employee' + + $wouldUpdate = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' ` + -Result (New-Result -Action 'WouldUpdate') -PreviousValue 'Employee' + + $updated['previousValue'] | Should -Be 'Employee' + $wouldUpdate['previousValue'] | Should -BeNullOrEmpty + } + + It 'carries a null matchedRuleId on an Unclassified record' { + $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' ` + -Result (New-Result -Outcome 'Unclassified' -MatchedRuleId $null) + + $record['outcome'] | Should -Be 'Unclassified' + $record['matchedRuleId'] | Should -BeNullOrEmpty + } + + It 'carries an evaluationErrorReason on an EvaluationError record' { + $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' ` + -Result (New-Result -Outcome 'EvaluationError' -MatchedRuleId $null -Action 'Skipped' -ErrorReason 'membership lookup failed') + + $record['evaluationErrorReason'] | Should -Be 'membership lookup failed' + } + + It 'omits conditionTrace unless tracing is explicitly requested' { + $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result (New-Result) + + $record.Contains('conditionTrace') | Should -BeFalse + } + + It 'refuses to build a UserEvent without a result' { + { New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' } | Should -Throw + } +} + +Describe 'Summary shape' { + + It 'carries the summary type, counters, and rule counts' { + $rules = @( + [pscustomobject]@{ id = 'RULE-0010'; name = 'Guests'; enabled = $true; priority = 10; persona = 'Guest'; match = $null } + [pscustomobject]@{ id = 'RULE-0020'; name = 'Disabled'; enabled = $false; priority = 20; persona = 'Employee'; match = $null } + ) + $counters = New-PersonaRunCounter -Rules $rules + + $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'Summary' ` + -Counters $counters -Properties @{ summaryType = 'Interim' } + + $record['summaryType'] | Should -Be 'Interim' + foreach ($field in $script:counterFields) { $record.Contains($field) | Should -BeTrue } + + $record['ruleCounts'].Count | Should -Be 2 + $record['ruleCounts'][0]['ruleId'] | Should -Be 'RULE-0010' + $record['ruleCounts'][1]['enabled'] | Should -BeFalse + } + + It 'defaults to Interim when no summary type is given' { + $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'Summary' ` + -Counters (New-PersonaRunCounter -Rules @()) + + $record['summaryType'] | Should -Be 'Interim' + } + + It 'refuses to build a Summary without counters' { + { New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'Summary' } | Should -Throw + } +} + +Describe 'RunComplete shape' { + + It 'carries the timing fields, counters, and exit code' { + $record = Export-PersonaRunReport -Context (New-TestAuditContext) ` + -Counters (New-PersonaRunCounter -Rules @()) -StartedUtc ([DateTime]::UtcNow.AddSeconds(-3)) -ExitCode 4 + + foreach ($field in (@('startedUtc', 'completedUtc', 'durationMs', 'exitCode') + $script:counterFields)) { + $record.Contains($field) | Should -BeTrue + } + + $record['exitCode'] | Should -Be 4 + } +} + +Describe 'NDJSON serialization' { + + It 'writes one record per line to file' { + $path = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-ndjson-{0}.log" -f [guid]::NewGuid().ToString('N')) + $context = New-TestAuditContext + + try { + 1..3 | ForEach-Object { + New-PersonaAuditRecord -Context $context -RecordType 'UserEvent' -Result (New-Result) | + Write-PersonaAuditRecord -Destination 'file' -Path $path + } + + $lines = @(Get-Content -LiteralPath $path) + $lines.Count | Should -Be 3 + + foreach ($line in $lines) { + $line | Should -Not -Match "`n" + { $line | ConvertFrom-Json } | Should -Not -Throw + ($line | ConvertFrom-Json).recordType | Should -Be 'UserEvent' + } + } + finally { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue } + } + + It 'emits nothing when the destination is none' { + $path = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-none-{0}.log" -f [guid]::NewGuid().ToString('N')) + + New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'RunStart' | + Write-PersonaAuditRecord -Destination 'none' -Path $path + + Test-Path -LiteralPath $path | Should -BeFalse + } + + It 'warns once and keeps running when the file sink fails' { + # A locked log file is an operational problem with the sink, not a reason to + # abandon a run mid-population and leave the directory half-reconciled. + $state = New-PersonaAuditSinkState + $badPath = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-bad-{0}" -f [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $badPath -Force + + try { + $warnings = @() + 1..5 | ForEach-Object { + New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'RunStart' | + Write-PersonaAuditRecord -Destination 'file' -Path $badPath -State $state 3>&1 | + ForEach-Object { $warnings += $_ } + } + + $state.FileSinkFailed | Should -BeTrue + @($warnings).Count | Should -Be 1 + } + finally { Remove-Item -LiteralPath $badPath -Recurse -Force -ErrorAction SilentlyContinue } + } +} diff --git a/tests/Unit/AuditRedaction.Tests.ps1 b/tests/Unit/AuditRedaction.Tests.ps1 new file mode 100644 index 0000000..dd15ee4 --- /dev/null +++ b/tests/Unit/AuditRedaction.Tests.ps1 @@ -0,0 +1,145 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + Principle V: no token, Authorization header, secret, or raw Graph response can + appear in an audit record. + + The strongest guarantee here is structural rather than filtered. New-PersonaAuditRecord + accepts only named, typed values from the decision result and the counters - there + is no pass-through of an arbitrary object, so there is nothing for a secret to ride + in on. These tests assert that property holds, and that it still holds when a + caller actively tries to smuggle one in. +#> + +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__' + + # Values that must never survive into a record, each distinctive enough to find in + # a serialized blob. + $script:secrets = @( + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.SYNTHETIC.TOKEN' + 'Bearer SYNTHETIC-ACCESS-TOKEN-VALUE' + 'SYNTHETIC-CLIENT-SECRET-VALUE' + ) + + $script:forbiddenKeys = @('authorization', 'accesstoken', 'access_token', 'token', + 'clientsecret', 'client_secret', 'secret', 'password', 'rawresponse', 'credential') +} + +Describe 'Audit records contain no credential material' { + + BeforeAll { + Mock Get-PersonaUsers { @(New-TestPopulation -Count 10 -TargetAttribute $script:target) } + Mock Write-Host { } + Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $true } } + + $info = $null + $null = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) ` + -TargetAttribute $script:target -Context (New-TestAuditContext -Mode 'Enforce') ` + -AuditParameters @{ Destination = 'stream' } ` + -IsEnforcing -ShouldProcessGate { param($t, $d) $true } ` + -InformationVariable info + + $script:records = Get-CapturedAuditRecord -Captured $info + } + + It 'produced records to inspect' { + $script:records.Count | Should -BeGreaterThan 0 + } + + It 'contains no field whose name suggests credential material' { + foreach ($record in $script:records) { + foreach ($key in $record.Keys) { + $key.ToLowerInvariant() | Should -Not -BeIn $script:forbiddenKeys + } + } + } + + It 'contains nothing that looks like a JWT once serialized' { + foreach ($record in $script:records) { + ($record | ConvertTo-Json -Depth 16 -Compress) | Should -Not -Match 'eyJ[A-Za-z0-9_-]{10,}' + } + } + + It 'contains no Bearer prefix once serialized' { + foreach ($record in $script:records) { + ($record | ConvertTo-Json -Depth 16 -Compress) | Should -Not -Match '(?i)bearer\s' + } + } +} + +Describe 'A record cannot be made to carry a secret through the decision result' { + + It 'ignores unexpected fields on the decision result' { + # A result object carrying an extra field - as it would if some future adapter + # attached a raw response - must not propagate it. The builder reads named + # fields only. + $result = [pscustomobject]@{ + AccountObjectId = '00000000-0000-0000-0000-000000000101' + UserPrincipalName = 'a@example.invalid' + Outcome = 'Matched' + MatchedRuleId = 'RULE-0010' + CalculatedPersona = 'Employee' + StoredPersona = 'Employee' + Action = 'Unchanged' + EvaluationErrorReason = $null + RulesEvaluated = 1 + DurationMs = 2 + ConditionTrace = $null + AccessToken = $script:secrets[1] + RawGraphResponse = @{ Authorization = $script:secrets[0] } + } + + $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result $result + + $record.Contains('AccessToken') | Should -BeFalse + $record.Contains('RawGraphResponse') | Should -BeFalse + ($record | ConvertTo-Json -Depth 16 -Compress) | Should -Not -Match 'SYNTHETIC' + } + + It 'keeps the UPN and Object ID, which are approved for logs' { + # Redaction must not go so far that the record stops being useful. NFR-005 + # requires both fields on every user event. + $result = [pscustomobject]@{ + AccountObjectId = '00000000-0000-0000-0000-000000000101' + UserPrincipalName = 'a@example.invalid' + Outcome = 'Matched'; MatchedRuleId = 'RULE-0010' + CalculatedPersona = 'Employee'; StoredPersona = 'Employee' + Action = 'Unchanged'; EvaluationErrorReason = $null + RulesEvaluated = 1; DurationMs = 2; ConditionTrace = $null + } + + $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result $result + + $record['userPrincipalName'] | Should -Be 'a@example.invalid' + $record['accountObjectId'] | Should -Be '00000000-0000-0000-0000-000000000101' + } +} + +Describe 'The Graph request helper never logs credential material' { + + It 'does not write the request body or headers to the verbose stream' { + Mock Invoke-MgGraphRequest { @{ value = @() } } + + $captured = Invoke-PersonaGraphRequest -Uri '/v1.0/users' -Body @{ secret = $script:secrets[2] } -Method 'PATCH' -Verbose 4>&1 | + Out-String + + $captured | Should -Not -Match 'Bearer' + $captured | Should -Not -Match 'eyJ' + $captured | Should -Not -Match 'SYNTHETIC' + } + + It 'reports a failure without echoing the response body' { + Mock Invoke-MgGraphRequest { throw 'Response status code does not indicate success: 403 (Forbidden).' } + + { Invoke-PersonaGraphRequest -Uri '/v1.0/users' } | Should -Throw + + # A 403 is never retried, so the message surfaces once, unchanged, and carries + # only what Graph put in the status line. + Should -Invoke Invoke-MgGraphRequest -Times 1 -Exactly + } +} diff --git a/tests/Unit/ConditionTrace.Tests.ps1 b/tests/Unit/ConditionTrace.Tests.ps1 new file mode 100644 index 0000000..3e1af51 --- /dev/null +++ b/tests/Unit/ConditionTrace.Tests.ps1 @@ -0,0 +1,141 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + US9: condition tracing is available only when explicitly enabled. + + The default matters more than the feature. Tracing widens what an audit record + contains beyond the UPN and Object ID that are approved by default (Principle V), + so a trace that appeared without being asked for would quietly change what the log + holds - and nobody reviews a log for fields they did not know were there. +#> + +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__' + + $script: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:record = New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000101' ` + -UserPrincipalName 'alex@example.invalid' -UserType 'Member' ` + -Properties @{ Department = 'Finance' } -StoredPersona 'Employee' ` + -Membership (New-PersonaMembershipRecord -AllRetrieved) +} + +Describe 'Trace gating on the decision result' { + + It 'leaves ConditionTrace null by default' { + $result = Resolve-UserPersona -UserRecord $script:record -Rules $script:rules + + $result.ConditionTrace | Should -BeNullOrEmpty + } + + It 'populates ConditionTrace when tracing is requested' { + $result = Resolve-UserPersona -UserRecord $script:record -Rules $script:rules -IncludeTrace + + $result.ConditionTrace | Should -Not -BeNullOrEmpty + $result.ConditionTrace.Count | Should -Be $result.RulesEvaluated + } + + It 'records the per-rule result and priority in the trace' { + $result = Resolve-UserPersona -UserRecord $script:record -Rules $script:rules -IncludeTrace + + $result.ConditionTrace[0].RuleId | Should -Be 'RULE-0010-GUEST' + $result.ConditionTrace[0].Priority | Should -Be 10 + $result.ConditionTrace[0].Result | Should -Be 'False' + + $result.ConditionTrace[1].RuleId | Should -Be 'RULE-0900-EMPLOYEE' + $result.ConditionTrace[1].Result | Should -Be 'True' + } + + It 'does not change the decision when tracing is on' { + # Tracing is diagnostic. If it could alter an outcome, a debug run would stop + # being evidence about the real one. + $plain = Resolve-UserPersona -UserRecord $script:record -Rules $script:rules + $traced = Resolve-UserPersona -UserRecord $script:record -Rules $script:rules -IncludeTrace + + $traced.Outcome | Should -Be $plain.Outcome + $traced.MatchedRuleId | Should -Be $plain.MatchedRuleId + $traced.CalculatedPersona | Should -Be $plain.CalculatedPersona + $traced.RulesEvaluated | Should -Be $plain.RulesEvaluated + } +} + +Describe 'Trace gating on the audit record' { + + It 'omits conditionTrace when tracing is off' { + $result = Resolve-UserPersona -UserRecord $script:record -Rules $script:rules -IncludeTrace + $result.Action = 'Unchanged' + + $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result $result + + # The result carries a trace, but the record was not asked to include it. + # Both gates have to be open. + $record.Contains('conditionTrace') | Should -BeFalse + } + + It 'includes conditionTrace when both the result and the record are traced' { + $result = Resolve-UserPersona -UserRecord $script:record -Rules $script:rules -IncludeTrace + $result.Action = 'Unchanged' + + $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result $result -IncludeTrace + + $record.Contains('conditionTrace') | Should -BeTrue + $record['conditionTrace'].Count | Should -Be 2 + $record['conditionTrace'][0]['ruleId'] | Should -Be 'RULE-0010-GUEST' + } + + It 'omits conditionTrace when tracing was requested but the result carries none' { + $result = Resolve-UserPersona -UserRecord $script:record -Rules $script:rules + $result.Action = 'Unchanged' + + $record = New-PersonaAuditRecord -Context (New-TestAuditContext) -RecordType 'UserEvent' -Result $result -IncludeTrace + + $record.Contains('conditionTrace') | Should -BeFalse + } +} + +Describe 'Trace gating across a run' { + + BeforeEach { + Mock Write-Host { } + Mock Get-PersonaUsers { @(New-TestPopulation -Count 6 -TargetAttribute $script:target) } + } + + It 'emits no condition values anywhere when tracing is off' { + $info = $null + $null = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) ` + -TargetAttribute $script:target -Context (New-TestAuditContext) ` + -AuditParameters @{ Destination = 'stream' } -InformationVariable info + + foreach ($event in (Get-CapturedAuditRecord -Captured $info -RecordType 'UserEvent')) { + $event.Contains('conditionTrace') | Should -BeFalse + } + } + + It 'emits a trace on every user event when tracing is on' { + $info = $null + $null = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) ` + -TargetAttribute $script:target -Context (New-TestAuditContext) ` + -AuditParameters @{ Destination = 'stream' } -Tracing -InformationVariable info + + $events = Get-CapturedAuditRecord -Captured $info -RecordType 'UserEvent' + $events.Count | Should -BeGreaterThan 0 + + foreach ($event in $events) { + $event.Contains('conditionTrace') | Should -BeTrue + $event['conditionTrace'].Count | Should -BeGreaterThan 0 + } + } +} diff --git a/tests/Unit/ExitCodes.Tests.ps1 b/tests/Unit/ExitCodes.Tests.ps1 new file mode 100644 index 0000000..fbbccd8 --- /dev/null +++ b/tests/Unit/ExitCodes.Tests.ps1 @@ -0,0 +1,191 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + SC-011: every engine exit code 0-6 is reachable and returned for its documented + condition. + + An exit code is what a scheduler, a pipeline, or an on-call runbook reacts to. A + code that cannot be produced is a promise the contract makes and the engine does + not keep, and it is invisible until the day someone builds an alert on it. + + Codes 0, 3, 4, and 5 are reachable inside the run loop and are tested by fault + injection here. Codes 1, 2, and 6 are owned by the entry script, which decides + them before or around the loop; those are asserted against the script's own + control flow. +#> + +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__' + $script:entryScript = Join-Path $repoRoot 'Invoke-PersonaEngine.ps1' +} + +Describe 'Exit codes produced by the run loop' { + + BeforeEach { + Mock Write-Host { } + Mock Write-Verbose { } + } + + It 'returns 0 for a clean run' { + Mock Get-PersonaUsers { @(New-TestPopulation -Count 10 -TargetAttribute $script:target) } + + $outcome = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) ` + -TargetAttribute $script:target -Context (New-TestAuditContext) + + $outcome.ExitCode | Should -Be 0 + } + + It 'returns 3 when user enumeration fails' { + Mock Get-PersonaUsers { throw 'User enumeration returned an unexpected response shape on page 4.' } + + $outcome = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) ` + -TargetAttribute $script:target -Context (New-TestAuditContext) + + $outcome.ExitCode | Should -Be 3 + $outcome.FailureReason | Should -Not -BeNullOrEmpty + } + + It 'processes nobody when enumeration fails, rather than reporting a partial run' { + Mock Get-PersonaUsers { throw 'truncated' } + + $outcome = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) ` + -TargetAttribute $script:target -Context (New-TestAuditContext) + + $outcome.Counters.Processed | Should -Be 0 + } + + It 'returns 4 when the EvaluationError count exceeds the configured threshold' { + Mock Get-PersonaUsers { @(New-TestPopulation -Count 10 -TargetAttribute $script:target) } + Mock Get-PersonaGroupMembership { New-PersonaMembershipRecord -DirectFailureReason 'Graph 503 after 5 attempts' } + + $config = New-TestRuntimeConfiguration -TargetAttribute $script:target -EvaluationErrorThreshold 3 -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) + + $outcome.Counters.EvaluationError | Should -BeGreaterThan 3 + $outcome.ExitCode | Should -Be 4 + } + + It 'returns 0 when the EvaluationError count is at or below the threshold' { + Mock Get-PersonaUsers { @(New-TestPopulation -Count 10 -TargetAttribute $script:target) } + Mock Get-PersonaGroupMembership { New-PersonaMembershipRecord -DirectFailureReason 'Graph 503' } + + $config = New-TestRuntimeConfiguration -TargetAttribute $script:target -EvaluationErrorThreshold 100 -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) + + $outcome.Counters.EvaluationError | Should -BeGreaterThan 0 + $outcome.ExitCode | Should -Be 0 + } + + It 'leaves the threshold inactive when it is not configured' { + # An unset threshold means "report, do not fail". Defaulting it to 0 would turn + # a single transient lookup failure into a failed run. + Mock Get-PersonaUsers { @(New-TestPopulation -Count 10 -TargetAttribute $script:target) } + Mock Get-PersonaGroupMembership { New-PersonaMembershipRecord -DirectFailureReason 'Graph 503' } + + $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) + + $outcome.Counters.EvaluationError | Should -BeGreaterThan 0 + $outcome.ExitCode | Should -Be 0 + } + + It 'returns 5 when reconciliation fails' { + Mock Get-PersonaUsers { @(New-TestPopulation -Count 6 -TargetAttribute $script:target) } + Mock Add-PersonaRunResult { $Counters.Processed++ } + + $outcome = Invoke-PersonaEngineRun -Configuration (New-TestRuntimeConfiguration -TargetAttribute $script:target) ` + -TargetAttribute $script:target -Context (New-TestAuditContext) + + $outcome.ExitCode | Should -Be 5 + } + + It 'prefers 5 over 4 when both conditions hold' { + # A reconciliation failure means the counters cannot be trusted, so the + # threshold check is reading numbers that may be wrong. The engine defect is + # the more serious and more actionable report. + Mock Get-PersonaUsers { @(New-TestPopulation -Count 6 -TargetAttribute $script:target) } + Mock Get-PersonaGroupMembership { New-PersonaMembershipRecord -DirectFailureReason 'Graph 503' } + # Processed advances twice per user while only one outcome is tallied: the + # buckets no longer add up AND the error count clears the threshold, so both + # conditions are genuinely true at once. + Mock Add-PersonaRunResult { $Counters.Processed += 2; $Counters.EvaluationError++ } + + $config = New-TestRuntimeConfiguration -TargetAttribute $script:target -EvaluationErrorThreshold 1 -Rules @( + (New-TestMembershipRule) + ) + + $outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $script:target -Context (New-TestAuditContext) + + $outcome.ExitCode | Should -Be 5 + } +} + +Describe 'Exit codes owned by the entry script' { + + BeforeAll { + $script:entryText = Get-Content -LiteralPath $script:entryScript -Raw + } + + It 'declares all seven documented codes' { + foreach ($name in @('EXIT_OK', 'EXIT_CONFIG', 'EXIT_AUTH', 'EXIT_ENUMERATION', + 'EXIT_DATA', 'EXIT_RECONCILIATION', 'EXIT_UNEXPECTED')) { + $script:entryText | Should -Match "\`$$name\s*=\s*\d" + } + } + + It 'assigns each code the value the contract documents' { + $expected = @{ + 'EXIT_OK' = 0; 'EXIT_CONFIG' = 1; 'EXIT_AUTH' = 2; 'EXIT_ENUMERATION' = 3 + 'EXIT_DATA' = 4; 'EXIT_RECONCILIATION' = 5; 'EXIT_UNEXPECTED' = 6 + } + + foreach ($name in $expected.Keys) { + $script:entryText | Should -Match "\`$$name\s*=\s*$($expected[$name])\b" + } + } + + It 'returns exit code 1 without connecting when validation fails' { + # The ordering matters as much as the code: FR-002 requires validation to + # complete before any connection is attempted. + $configIndex = $script:entryText.IndexOf('exit $EXIT_CONFIG') + $connectIndex = $script:entryText.IndexOf('Connect-PersonaGraphInteractive') + + $configIndex | Should -BeGreaterThan 0 + $connectIndex | Should -BeGreaterThan $configIndex + } + + It 'maps an authentication failure to code 2' { + $script:entryText | Should -Match '(?s)Connect-PersonaGraphInteractive.*?\$exitCode = \$EXIT_AUTH' + } + + It 'falls back to code 6 for an unexpected error' { + $script:entryText | Should -Match '\$exitCode = \$EXIT_UNEXPECTED' + } + + It 'writes a RunComplete record even on a fatal error' { + # A run that died at user 400 of 5000 has to leave evidence saying so. + $script:entryText | Should -Match '(?s)finally\s*\{.*Export-PersonaRunReport' + } +} diff --git a/tests/Unit/OutcomeExclusivity.Tests.ps1 b/tests/Unit/OutcomeExclusivity.Tests.ps1 new file mode 100644 index 0000000..d15f652 --- /dev/null +++ b/tests/Unit/OutcomeExclusivity.Tests.ps1 @@ -0,0 +1,106 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + SC-001: every processed user lands in exactly one outcome bucket. + + Exclusivity is what makes reconciliation meaningful. If a user could be both + Matched and EvaluationError, the sum would still be checkable but would no longer + mean anything, and FR-021 would be verifying arithmetic rather than correctness. +#> + +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__' + + # A rule set that needs membership data, so a failed lookup produces + # EvaluationError and all three buckets are populated in one run. + $script: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' }) + } + } + ) + + $script:config = New-TestRuntimeConfiguration -TargetAttribute $target -Rules $script:rules +} + +Describe 'Exactly one outcome per user (SC-001)' { + + BeforeEach { + Mock Write-Host { } + Mock Set-UserPersonaAttribute { [pscustomobject]@{ Succeeded = $true } } + Mock Get-PersonaUsers { New-TestPopulation -Count 24 -TargetAttribute $script:target } + + # Every fourth account fails its membership lookup, producing a genuine mix + # of all three outcomes rather than a run where only one bucket is exercised. + $script:lookup = 0 + Mock Get-PersonaGroupMembership { + $script:lookup++ + ($script:lookup % 4) -eq 0 ` + ? (New-PersonaMembershipRecord -DirectFailureReason 'Graph 503 after 5 attempts') ` + : (New-PersonaMembershipRecord -AllRetrieved) + } + } + + It 'accounts for every processed user across the three outcome buckets' { + $outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target ` + -Context (New-TestAuditContext) -ShouldProcessGate { param($t, $d) $false } + + $sum = $outcome.Counters.Matched + $outcome.Counters.Unclassified + $outcome.Counters.EvaluationError + $sum | Should -Be $outcome.Counters.Processed + } + + It 'populates all three buckets, so the sum is not trivially satisfied' { + $outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target ` + -Context (New-TestAuditContext) -ShouldProcessGate { param($t, $d) $false } + + $outcome.Counters.Matched | Should -BeGreaterThan 0 + $outcome.Counters.Unclassified | Should -BeGreaterThan 0 + $outcome.Counters.EvaluationError | Should -BeGreaterThan 0 + } + + It 'accounts for every processed user across the action buckets too' { + $outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target ` + -Context (New-TestAuditContext) -ShouldProcessGate { param($t, $d) $false } + + $actions = $outcome.Counters.Unchanged + $outcome.Counters.WouldUpdate + + $outcome.Counters.Updated + $outcome.Counters.UpdateFailed + $outcome.Counters.Skipped + + $actions | Should -Be $outcome.Counters.Processed + } + + It 'assigns a single outcome value to each decision result' { + $record = New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000101' ` + -UserPrincipalName 'a@example.invalid' -Properties @{ Department = 'Finance' } ` + -Membership (New-PersonaMembershipRecord -AllRetrieved) + + $result = Resolve-UserPersona -UserRecord $record -Rules $script:rules + + @($result.Outcome).Count | Should -Be 1 + $result.Outcome | Should -BeIn @('Matched', 'Unclassified', 'EvaluationError') + } + + It 'never reports an EvaluationError user as Matched' { + # The specific confusion FR-013 exists to prevent: an unevaluable account + # must never carry a persona, or the preserved value and the calculated value + # would both look authoritative. + $record = New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000102' ` + -UserPrincipalName 'b@example.invalid' -Properties @{ Department = 'Finance' } ` + -StoredPersona 'Employee' ` + -Membership (New-PersonaMembershipRecord -DirectFailureReason 'lookup failed') + + $result = Resolve-UserPersona -UserRecord $record -Rules $script:rules + + $result.Outcome | Should -Be 'EvaluationError' + $result.MatchedRuleId | Should -BeNullOrEmpty + $result.CalculatedPersona | Should -BeNullOrEmpty + $result.StoredPersona | Should -Be 'Employee' + } +} diff --git a/tests/Unit/Pagination.Tests.ps1 b/tests/Unit/Pagination.Tests.ps1 new file mode 100644 index 0000000..88dd95a --- /dev/null +++ b/tests/Unit/Pagination.Tests.ps1 @@ -0,0 +1,126 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + FR-004: @odata.nextLink is followed to exhaustion, and a truncated enumeration + raises rather than returning a partial population. + + The second half is the one worth the effort. A partial population reconciles + cleanly, produces a plausible summary, and reports success - so nothing + downstream can tell that half the tenant was never looked at. +#> + +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__' +} + +Describe 'User enumeration pagination (FR-004)' { + + It 'follows nextLink across every page' { + $script:page = 0 + + Mock Invoke-PersonaGraphRequest { + $script:page++ + $users = 1..3 | ForEach-Object { + New-TestGraphUser -Id ('00000000-0000-0000-0000-{0:d12}' -f (($script:page - 1) * 3 + $_)) ` + -UserPrincipalName ("user{0}-{1}@example.invalid" -f $script:page, $_) + } + + $response = @{ value = $users } + if ($script:page -lt 4) { $response['@odata.nextLink'] = "/v1.0/users?`$skiptoken=page$script:page" } + $response + } + + $result = @(Get-PersonaUsers -SelectProperties @('id', 'userPrincipalName')) + + $result.Count | Should -Be 12 + Should -Invoke Invoke-PersonaGraphRequest -Times 4 -Exactly + } + + It 'stops when nextLink is absent rather than looping' { + Mock Invoke-PersonaGraphRequest { @{ value = @(New-TestGraphUser -Id '00000000-0000-0000-0000-000000000001' -UserPrincipalName 'a@example.invalid') } } + + $result = @(Get-PersonaUsers -SelectProperties @('id')) + + $result.Count | Should -Be 1 + Should -Invoke Invoke-PersonaGraphRequest -Times 1 -Exactly + } + + It 'raises on a response with no value collection rather than returning what it has' { + $script:page = 0 + + Mock Invoke-PersonaGraphRequest { + $script:page++ + if ($script:page -eq 1) { + return @{ + value = @(New-TestGraphUser -Id '00000000-0000-0000-0000-000000000001' -UserPrincipalName 'a@example.invalid') + '@odata.nextLink' = '/v1.0/users?$skiptoken=abc' + } + } + # Page two comes back malformed. Returning page one alone would look like + # a complete, tiny tenant. + @{ error = 'unexpected' } + } + + { @(Get-PersonaUsers -SelectProperties @('id')) } | Should -Throw -ExpectedMessage '*partial population*' + } + + It 'raises on a null response' { + Mock Invoke-PersonaGraphRequest { $null } + + { @(Get-PersonaUsers -SelectProperties @('id')) } | Should -Throw + } + + It 'requests the maximum page size so a large tenant needs fewer round trips' { + Mock Invoke-PersonaGraphRequest -ParameterFilter { $Uri -match '\$top=999' } -MockWith { @{ value = @() } } + + $null = @(Get-PersonaUsers -SelectProperties @('id')) + + Should -Invoke Invoke-PersonaGraphRequest -Times 1 -Exactly + } +} + +Describe 'Membership pagination' { + + It 'follows nextLink and collects only group objects' { + $script:page = 0 + + Mock Invoke-PersonaGraphRequest { + $script:page++ + if ($script:page -eq 1) { + return @{ + value = @( + @{ '@odata.type' = '#microsoft.graph.group'; id = '00000000-0000-0000-0000-0000000000a0' } + # An administrative unit arriving on memberOf. Treating it as + # a group ID would never match, which reads as "not a member". + @{ '@odata.type' = '#microsoft.graph.administrativeUnit'; id = '00000000-0000-0000-0000-0000000000e0' } + ) + '@odata.nextLink' = '/v1.0/users/x/memberOf?$skiptoken=abc' + } + } + @{ value = @(@{ '@odata.type' = '#microsoft.graph.group'; id = '00000000-0000-0000-0000-0000000000b0' }) } + } + + $record = Get-PersonaGroupMembership -UserObjectId '00000000-0000-0000-0000-000000000101' -NeedDirect + + $record.DirectRetrieved | Should -BeTrue + $record.DirectGroupObjectIds.Count | Should -Be 2 + $record.DirectGroupObjectIds | Should -Contain '00000000-0000-0000-0000-0000000000a0' + $record.DirectGroupObjectIds | Should -Contain '00000000-0000-0000-0000-0000000000b0' + $record.DirectGroupObjectIds | Should -Not -Contain '00000000-0000-0000-0000-0000000000e0' + } + + It 'contains a mid-pagination failure to the affected facet instead of returning a short list' { + # The critical case. A truncated membership list looks exactly like a user who + # left a group, and would silently reclassify them (FR-013). + Mock Invoke-PersonaGraphRequest { throw 'Graph 503 after 5 attempts' } + + $record = Get-PersonaGroupMembership -UserObjectId '00000000-0000-0000-0000-000000000101' -NeedDirect + + $record.DirectRetrieved | Should -BeFalse + $record.DirectFailureReason | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/Unit/Reconciliation.Tests.ps1 b/tests/Unit/Reconciliation.Tests.ps1 new file mode 100644 index 0000000..91da753 --- /dev/null +++ b/tests/Unit/Reconciliation.Tests.ps1 @@ -0,0 +1,156 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + FR-021, SC-007: Processed = Matched + Unclassified + EvaluationError at every + summary, and a mismatch is reported as an engine defect. + + The forced-mismatch tests are the point. A reconciliation check that only ever + sees correct data proves that the arithmetic works, not that the failure path + does - and the failure path is the only part anyone will ever depend on. +#> + +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__' + $script:population = @(New-TestPopulation -Count 15 -TargetAttribute $target) +} + +Describe 'Reconciliation arithmetic (FR-021)' { + + It 'passes when the outcome buckets account for every processed user' { + $counters = New-PersonaRunCounter -Rules @() + $counters.Processed = 10 + $counters.Matched = 7 + $counters.Unclassified = 2 + $counters.EvaluationError = 1 + + Test-PersonaReconciliation -Counters $counters | Should -BeTrue + } + + It 'fails when a user was processed but landed in no bucket' { + $counters = New-PersonaRunCounter -Rules @() + $counters.Processed = 10 + $counters.Matched = 7 + $counters.Unclassified = 2 + $counters.EvaluationError = 0 + + Test-PersonaReconciliation -Counters $counters | Should -BeFalse + } + + It 'fails when a user was double-counted' { + $counters = New-PersonaRunCounter -Rules @() + $counters.Processed = 10 + $counters.Matched = 8 + $counters.Unclassified = 2 + $counters.EvaluationError = 1 + + Test-PersonaReconciliation -Counters $counters | Should -BeFalse + } + + It 'passes trivially on an empty run' { + Test-PersonaReconciliation -Counters (New-PersonaRunCounter -Rules @()) | Should -BeTrue + } + + It 'ignores the action buckets, which would otherwise mask a lost user' { + # Skipped is a catch-all. If reconciliation checked the action buckets, a lost + # user absorbed into Skipped would keep the sums balanced on a broken run. + $counters = New-PersonaRunCounter -Rules @() + $counters.Processed = 5 + $counters.Matched = 5 + $counters.Skipped = 99 + + Test-PersonaReconciliation -Counters $counters | Should -BeTrue + } +} + +Describe 'Reconciliation failure detail' { + + It 'reports the difference and its sign so a maintainer can tell loss from duplication' { + $counters = New-PersonaRunCounter -Rules @() + $counters.Processed = 10 + $counters.Matched = 7 + $counters.Unclassified = 2 + $counters.EvaluationError = 0 + + $detail = Get-PersonaReconciliationDetail -Counters $counters + + $detail.processed | Should -Be 10 + $detail.outcomeTotal | Should -Be 9 + $detail.difference | Should -Be 1 + $detail.severity | Should -Be 'Error' + $detail.defect | Should -Be 'ReconciliationFailure' + } + + It 'reports a negative difference when a user was double-counted' { + $counters = New-PersonaRunCounter -Rules @() + $counters.Processed = 10 + $counters.Matched = 9 + $counters.Unclassified = 2 + + (Get-PersonaReconciliationDetail -Counters $counters).difference | Should -Be -1 + } +} + +Describe 'Reconciliation over a real run (SC-007)' { + + BeforeEach { + Mock Get-PersonaUsers { $script:population } + Mock Write-Host { } + } + + It 'reconciles at every interim summary and at completion' { + $config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 5 + + $info = $null + $null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target ` + -Context (New-TestAuditContext) -AuditParameters @{ Destination = 'stream' } ` + -InformationVariable info + + $summaries = Get-CapturedAuditRecord -Captured $info -RecordType 'Summary' + + # Three interim boundaries at 5, 10, 15 plus the final summary. + $summaries.Count | Should -BeGreaterThan 1 + foreach ($summary in $summaries) { + $summary['reconciliationPassed'] | Should -BeTrue + $summary['processed'] | Should -Be ($summary['matched'] + $summary['unclassified'] + $summary['evaluationError']) + } + } + + It 'returns exit code 5 and emits an EngineDefect when reconciliation fails' { + # The failure is forced by making Add-PersonaRunResult drop the outcome tally + # while still counting the user as processed - precisely the bookkeeping defect + # FR-021 exists to catch. + Mock Add-PersonaRunResult { $Counters.Processed++ } + + $config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 0 + + $info = $null + $outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target ` + -Context (New-TestAuditContext) -AuditParameters @{ Destination = 'stream' } ` + -InformationVariable info + + $outcome.ExitCode | Should -Be 5 + + $defects = Get-CapturedAuditRecord -Captured $info -RecordType 'EngineDefect' + + $defects.Count | Should -BeGreaterThan 0 + $defects[0]['defect'] | Should -Be 'ReconciliationFailure' + $defects[0]['severity'] | Should -Be 'Error' + } + + It 'marks the summary record itself as failed when reconciliation fails' { + Mock Add-PersonaRunResult { $Counters.Processed++ } + + $config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 0 + + $info = $null + $null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target ` + -Context (New-TestAuditContext) -AuditParameters @{ Destination = 'stream' } ` + -InformationVariable info + + (Get-CapturedAuditRecord -Captured $info -RecordType 'Summary')[0]['reconciliationPassed'] | Should -BeFalse + } +} diff --git a/tests/Unit/RecordContracts.Tests.ps1 b/tests/Unit/RecordContracts.Tests.ps1 new file mode 100644 index 0000000..4e1e53e --- /dev/null +++ b/tests/Unit/RecordContracts.Tests.ps1 @@ -0,0 +1,181 @@ +#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/Configuration/New-PersonaValidationFinding.ps1') +} + +Describe 'New-PersonaMembershipRecord' { + + Context 'retrieval flags default to unretrieved' { + + It 'defaults every facet to $false so an unset flag means unknown, never "not a member"' { + # The safety-critical default. If any of these flip, a failed group + # lookup satisfies notMemberOf and privileged accounts get misclassified. + $record = New-PersonaMembershipRecord + $record.DirectRetrieved | Should -BeFalse + $record.TransitiveRetrieved | Should -BeFalse + $record.RolesRetrieved | Should -BeFalse + } + + It 'sets a facet only when explicitly requested' { + $record = New-PersonaMembershipRecord -DirectRetrieved + $record.DirectRetrieved | Should -BeTrue + $record.TransitiveRetrieved | Should -BeFalse + } + + It 'AllRetrieved sets every facet' { + $record = New-PersonaMembershipRecord -AllRetrieved + $record.DirectRetrieved | Should -BeTrue + $record.TransitiveRetrieved | Should -BeTrue + $record.RolesRetrieved | Should -BeTrue + } + } + + Context 'independent facets (RE-007)' { + + It 'carries direct and transitive membership simultaneously' { + # The reason this record has three facets rather than one mode: a rule + # set may ask for direct membership in one rule and transitive in + # another, and a single-mode record cannot answer both. + $record = New-PersonaMembershipRecord ` + -DirectGroupObjectIds @('00000000-0000-0000-0000-0000000000a0') ` + -TransitiveGroupObjectIds @('00000000-0000-0000-0000-0000000000a0', '00000000-0000-0000-0000-0000000000b0') ` + -AllRetrieved + + $record.DirectGroupObjectIds | Should -HaveCount 1 + $record.TransitiveGroupObjectIds | Should -HaveCount 2 + } + + It 'contains a failure to the facet that failed' { + # A transitive lookup that times out must not make direct-membership + # conditions unevaluable — otherwise one slow endpoint becomes a + # tenant-wide outage. + $record = New-PersonaMembershipRecord -DirectRetrieved -RolesRetrieved -TransitiveFailureReason 'Graph 503' + + $record.DirectRetrieved | Should -BeTrue + $record.RolesRetrieved | Should -BeTrue + $record.TransitiveRetrieved | Should -BeFalse + $record.TransitiveFailureReason | Should -Be 'Graph 503' + } + } + + Context 'shape' { + + It 'always returns arrays, never $null, for the identifier collections' { + $record = New-PersonaMembershipRecord -AllRetrieved + , $record.DirectGroupObjectIds | Should -BeOfType [System.Array] + , $record.TransitiveGroupObjectIds | Should -BeOfType [System.Array] + , $record.DirectoryRoleIds | Should -BeOfType [System.Array] + } + + It 'refuses to be both retrieved and failed on the same facet' { + { New-PersonaMembershipRecord -DirectRetrieved -DirectFailureReason 'boom' } | + Should -Throw '*cannot be both*' + } + + It 'refuses AllRetrieved alongside any failure reason' { + { New-PersonaMembershipRecord -AllRetrieved -RolesFailureReason 'boom' } | + Should -Throw '*cannot be both*' + } + } +} + +Describe 'New-PersonaUserRecord' { + + Context 'required identity fields' { + + It 'requires a non-empty AccountObjectId' { + { New-PersonaUserRecord -AccountObjectId '' -UserPrincipalName 'a@example.invalid' } | + Should -Throw + } + + It 'requires a non-empty UserPrincipalName' { + { New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000101' -UserPrincipalName '' } | + Should -Throw + } + } + + Context 'property bag' { + + BeforeAll { + $script:record = New-PersonaUserRecord ` + -AccountObjectId '00000000-0000-0000-0000-000000000101' ` + -UserPrincipalName 'alex.employee@example.invalid' ` + -UserType 'Member' ` + -Properties @{ Department = 'Finance'; CompanyName = $null } + } + + It 'looks up properties case-insensitively (RE-006)' { + $record.Properties['department'] | Should -Be 'Finance' + $record.Properties['DEPARTMENT'] | Should -Be 'Finance' + } + + It 'returns $null for an absent property rather than throwing (FR-012)' { + $record.Properties['NoSuchProperty'] | Should -BeNullOrEmpty + } + + It 'preserves an explicit null value' { + $record.Properties['CompanyName'] | Should -BeNullOrEmpty + } + + It 'exposes intrinsic fields as addressable properties' { + $record.Properties['UserPrincipalName'] | Should -Be 'alex.employee@example.invalid' + $record.Properties['UserType'] | Should -Be 'Member' + $record.Properties['AccountEnabled'] | Should -Be $true + } + + It 'lets an explicit property override an intrinsic default' { + $overridden = New-PersonaUserRecord ` + -AccountObjectId '00000000-0000-0000-0000-000000000101' ` + -UserPrincipalName 'a@example.invalid' ` + -Properties @{ UserType = 'Overridden' } + $overridden.Properties['UserType'] | Should -Be 'Overridden' + } + } + + Context 'membership default' { + + It 'never leaves Membership null' { + $record = New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000101' -UserPrincipalName 'a@example.invalid' + $record.Membership | Should -Not -BeNullOrEmpty + } + + It 'defaults to a fully unretrieved record so membership conditions yield Unknown' { + # An absent lookup is unknown, never "member of nothing". A rule set + # with no membership conditions never consults this; one that does must + # get EvaluationError rather than a fabricated non-match. + $record = New-PersonaUserRecord -AccountObjectId '00000000-0000-0000-0000-000000000101' -UserPrincipalName 'a@example.invalid' + $record.Membership.DirectRetrieved | Should -BeFalse + $record.Membership.TransitiveRetrieved | Should -BeFalse + $record.Membership.RolesRetrieved | Should -BeFalse + } + } +} + +Describe 'New-PersonaValidationFinding' { + + It 'derives the layer from the code namespace' { + (New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-001' -Location 'rules[0]' -Description 'd' -SuggestedResolution 'r').Layer | + Should -Be 'Semantic' + (New-PersonaValidationFinding -Severity Warning -Code 'PE-SAF-001' -Location 'engine' -Description 'd' -SuggestedResolution 'r').Layer | + Should -Be 'Safety' + } + + It 'rejects a malformed finding code' { + { New-PersonaValidationFinding -Severity Error -Code 'BAD-001' -Location 'x' -Description 'd' -SuggestedResolution 'r' } | + Should -Throw + } + + It 'requires a suggested resolution' { + { New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-001' -Location 'x' -Description 'd' -SuggestedResolution '' } | + Should -Throw + } + + It 'rejects an unrecognized severity' { + { New-PersonaValidationFinding -Severity Critical -Code 'PE-SEM-001' -Location 'x' -Description 'd' -SuggestedResolution 'r' } | + Should -Throw + } +} diff --git a/tests/Unit/RetryPolicy.Tests.ps1 b/tests/Unit/RetryPolicy.Tests.ps1 new file mode 100644 index 0000000..ddd34c7 --- /dev/null +++ b/tests/Unit/RetryPolicy.Tests.ps1 @@ -0,0 +1,111 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + NFR-003 and the OTD-007 retry table. + + The non-retryable cases carry the operational weight. A 403 retried five times per + account turns an instant authorization failure into a long, expensive one, and + hammers a tenant that is already refusing - so status extraction has to work even + when the code appears only in the exception message, which is how + Invoke-MgGraphRequest reports several of its failures. +#> + +BeforeAll { + $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + . (Join-Path $repoRoot 'tests/TestHelpers.ps1') + foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file } + + function New-StatusError { + param([int] $Status, [string] $Reason = 'Failed') + [System.Management.Automation.ErrorRecord]::new( + [System.Exception]::new("Response status code does not indicate success: $Status ($Reason)."), + 'GraphError', [System.Management.Automation.ErrorCategory]::InvalidResult, $null) + } +} + +Describe 'Status extraction from an error record' { + + It 'reads a status embedded only in the message' { + Get-PersonaGraphStatusCode -ErrorRecord (New-StatusError -Status 403 -Reason 'Forbidden') | Should -Be 403 + } + + It 'reads each status the retry table names' { + foreach ($status in @(400, 401, 403, 404, 409, 429, 500, 502, 503, 504)) { + Get-PersonaGraphStatusCode -ErrorRecord (New-StatusError -Status $status) | Should -Be $status + } + } + + It 'returns null for a transport failure with no status anywhere' { + $record = [System.Management.Automation.ErrorRecord]::new( + [System.Exception]::new('The operation was canceled.'), + 'Timeout', [System.Management.Automation.ErrorCategory]::OperationTimeout, $null) + + Get-PersonaGraphStatusCode -ErrorRecord $record | Should -BeNullOrEmpty + } + + It 'does not mistake an unrelated three-digit number for a status' { + $record = [System.Management.Automation.ErrorRecord]::new( + [System.Exception]::new('Processed 250 objects before the connection dropped.'), + 'Transport', [System.Management.Automation.ErrorCategory]::ConnectionError, $null) + + # 250 is outside the HTTP error range, so it is not treated as a status and + # the failure stays retryable - which is the correct answer for a dropped + # connection. + Get-PersonaGraphStatusCode -ErrorRecord $record | Should -BeNullOrEmpty + } +} + +Describe 'Retry policy (OTD-007)' { + + # One It per status rather than a loop: Should -Invoke counts across the whole It + # block, so a loop would accumulate calls and the second iteration would fail on + # the first one's arithmetic. + It 'never retries status <_>' -ForEach @(400, 401, 403, 404, 409) { + $status = $_ + Mock Invoke-MgGraphRequest { throw "Response status code does not indicate success: $status (Failed)." }.GetNewClosure() + + { Invoke-PersonaGraphRequest -Uri '/v1.0/users' -BaseDelayMs 1 } | Should -Throw + + Should -Invoke Invoke-MgGraphRequest -Times 1 -Exactly -Because "status $status is a client-side defect that retrying would only hide" + } + + It 'retries a retryable status up to the attempt limit' { + Mock Invoke-MgGraphRequest { throw 'Response status code does not indicate success: 503 (Service Unavailable).' } + + { Invoke-PersonaGraphRequest -Uri '/v1.0/users' -MaxAttempts 3 -BaseDelayMs 1 } | Should -Throw + + Should -Invoke Invoke-MgGraphRequest -Times 3 -Exactly + } + + It 'retries a transport failure that carries no status' { + Mock Invoke-MgGraphRequest { throw 'The operation was canceled.' } + + { Invoke-PersonaGraphRequest -Uri '/v1.0/users' -MaxAttempts 2 -BaseDelayMs 1 } | Should -Throw + + Should -Invoke Invoke-MgGraphRequest -Times 2 -Exactly + } + + It 'returns as soon as an attempt succeeds' { + $script:attempts = 0 + Mock Invoke-MgGraphRequest { + $script:attempts++ + if ($script:attempts -lt 3) { throw 'Response status code does not indicate success: 429 (Too Many Requests).' } + @{ value = @('ok') } + } + + $result = Invoke-PersonaGraphRequest -Uri '/v1.0/users' -BaseDelayMs 1 + + $result['value'] | Should -Be @('ok') + Should -Invoke Invoke-MgGraphRequest -Times 3 -Exactly + } + + It 'passes the body through unchanged as JSON' { + # SC-005 depends on this: the body a test captures must be the body sent. + $script:sentBody = $null + Mock Invoke-MgGraphRequest { $script:sentBody = $Body; @{} } + + $null = Invoke-PersonaGraphRequest -Uri '/v1.0/users/x' -Method 'PATCH' -Body @{ 'extension_x_Persona' = 'Employee' } + + ($script:sentBody | ConvertFrom-Json).'extension_x_Persona' | Should -Be 'Employee' + } +} diff --git a/tests/Unit/SummaryInterval.Tests.ps1 b/tests/Unit/SummaryInterval.Tests.ps1 new file mode 100644 index 0000000..949e1af --- /dev/null +++ b/tests/Unit/SummaryInterval.Tests.ps1 @@ -0,0 +1,127 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# + FR-019, FR-020: interim summaries appear at the configured interval, an interval of + 0 suppresses them, and a final summary appears in every case. + + The zero case is the one that matters. Suppressing interim output is a reasonable + thing to want on a large run; suppressing the final summary too would leave an + operator with per-user lines and no totals. +#> + +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__' + $script:population = @(New-TestPopulation -Count 20 -TargetAttribute $target) +} + +Describe 'Summary interval semantics (FR-019, FR-020)' { + + BeforeEach { + Mock Get-PersonaUsers { $script:population } + Mock Write-Host { } + Mock Write-PersonaSummary { } + } + + It 'emits a final summary and no interim summaries when the interval is 0' { + $config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 0 + + $null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target -Context (New-TestAuditContext) + + Should -Invoke Write-PersonaSummary -Times 1 -Exactly + Should -Invoke Write-PersonaSummary -Times 1 -Exactly -ParameterFilter { $SummaryType -eq 'Final' } + Should -Invoke Write-PersonaSummary -Times 0 -Exactly -ParameterFilter { $SummaryType -eq 'Interim' } + } + + It 'emits an interim summary at each interval boundary' { + $config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 5 + + $null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target -Context (New-TestAuditContext) + + # 20 users at an interval of 5: boundaries at 5, 10, 15, 20. + Should -Invoke Write-PersonaSummary -Times 4 -Exactly -ParameterFilter { $SummaryType -eq 'Interim' } + Should -Invoke Write-PersonaSummary -Times 1 -Exactly -ParameterFilter { $SummaryType -eq 'Final' } + } + + It 'honours the documented default of 25' { + # 20 users at the default interval: no boundary reached, so only the final + # summary appears. This is the shape of a small run. + $config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 25 + + $null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target -Context (New-TestAuditContext) + + Should -Invoke Write-PersonaSummary -Times 0 -Exactly -ParameterFilter { $SummaryType -eq 'Interim' } + Should -Invoke Write-PersonaSummary -Times 1 -Exactly -ParameterFilter { $SummaryType -eq 'Final' } + } + + It 'emits one interim summary per user at an interval of 1' { + $config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 1 + + $null = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target -Context (New-TestAuditContext) + + Should -Invoke Write-PersonaSummary -Times 20 -Exactly -ParameterFilter { $SummaryType -eq 'Interim' } + } + + It 'emits a final summary even when the population is empty' { + Mock Get-PersonaUsers { @() } + + $config = New-TestRuntimeConfiguration -TargetAttribute $target -SummaryInterval 5 + $outcome = Invoke-PersonaEngineRun -Configuration $config -TargetAttribute $target -Context (New-TestAuditContext) + + Should -Invoke Write-PersonaSummary -Times 1 -Exactly -ParameterFilter { $SummaryType -eq 'Final' } + $outcome.Counters.Processed | Should -Be 0 + } +} + +Describe 'Summary content (FR-019)' { + + It 'lists every rule, including disabled ones and those with no matches' { + # A rule that never fired and a rule that is not in the configuration look + # identical if zero-match rules are omitted, and that difference is exactly + # what an operator investigating a missing classification needs. + $rules = @( + [pscustomobject]@{ id = 'RULE-0010-OFF'; name = 'Disabled rule'; enabled = $false; priority = 10; persona = 'Guest' + match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' }) } } + [pscustomobject]@{ id = 'RULE-0020-NEVER'; name = 'Never matches'; enabled = $true; priority = 20; persona = 'Tier0-Admin' + match = [pscustomobject]@{ operator = 'all'; conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'equals'; value = 'NoSuchDepartment' }) } } + [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' }) } } + ) + + $counters = New-PersonaRunCounter -Rules $rules + + $counters.RuleCounts.Count | Should -Be 3 + ($counters.RuleCounts | Where-Object RuleId -EQ 'RULE-0010-OFF').Enabled | Should -BeFalse + ($counters.RuleCounts | Where-Object RuleId -EQ 'RULE-0020-NEVER').Matches | Should -Be 0 + } + + It 'orders rules by priority so the table reads in evaluation order' { + $rules = @( + [pscustomobject]@{ id = 'C'; name = 'C'; enabled = $true; priority = 900; persona = 'Employee'; match = $null } + [pscustomobject]@{ id = 'A'; name = 'A'; enabled = $true; priority = 10; persona = 'Guest'; match = $null } + [pscustomobject]@{ id = 'B'; name = 'B'; enabled = $true; priority = 30; persona = 'Tier0-Admin'; match = $null } + ) + + $counters = New-PersonaRunCounter -Rules $rules + + ($counters.RuleCounts | ForEach-Object RuleId) -join ',' | Should -Be 'A,B,C' + } + + It 'increments the matched rule and no other' { + $rules = @( + [pscustomobject]@{ id = 'RULE-A'; name = 'A'; enabled = $true; priority = 10; persona = 'Guest'; match = $null } + [pscustomobject]@{ id = 'RULE-B'; name = 'B'; enabled = $true; priority = 20; persona = 'Employee'; match = $null } + ) + + $counters = New-PersonaRunCounter -Rules $rules + + Add-PersonaRunResult -Counters $counters -Result ([pscustomobject]@{ + Outcome = 'Matched'; MatchedRuleId = 'RULE-B'; Action = 'Unchanged' }) + + ($counters.RuleCounts | Where-Object RuleId -EQ 'RULE-A').Matches | Should -Be 0 + ($counters.RuleCounts | Where-Object RuleId -EQ 'RULE-B').Matches | Should -Be 1 + } +}