Implement Stage A: rule engine, validation, audit, and safety gates

Completes 109 of 121 tasks. Every remaining task needs a tenant connection
(T055, T056, T101-T103) or an Azure Automation account (T115-T121).

  354 offline Pester tests      PASS
  Engine purity (Principle IV)  PASS
  Sanitization (SC-013)         PASS  (156 files)
  Graph module loaded in tests  none  (SC-008 holds)

What landed
  - Four-layer configuration validation with stable finding codes, covering
    every VR-002 and VR-003 condition, plus a 23-fixture invalid-config corpus
  - Run loop, audit records (NDJSON through a single sink), summaries,
    reconciliation, and exit codes 0-6
  - Persistence behind a single write-body builder whose result always has
    exactly one key
  - Invoke-PersonaEngine.ps1 and Edit-PersonaEngineConfig.ps1
  - Six docs, two pipelines, traceability matrix, V-5a and sanitization records

Three deviations from tasks.md, each recorded in its status block

  T033 is not in Resolve-UserPersona. evaluationErrorThreshold is run-level
  state and the rule engine is pure; a counter there would break Principle IV.
  It lives in New-PersonaRunCounter and is applied in the run loop.

  A new src/Engine/ layer holds Invoke-PersonaEngineRun. The entry script
  imports the manifest, which requires Microsoft.Graph.Authentication, so a
  loop living only inside it could not run on a machine without the Graph SDK
  and SC-004 could not be proven at all. The entry script is now a thin
  wrapper and what ships is what is tested.

  The invalid-config corpus is generated by a committed script, with the
  generated fixtures committed too, so a reviewer sees the fixture in the diff.

Defects found by running the code, not by reading it

  Group and role ID lists were double-wrapped: @(Get-PersonaGroupIdPage ...)
  around a comma-returned array collapsed every membership list into one
  bogus space-joined entry. That is a silent false non-match, exactly what
  FR-013 exists to prevent.

  A 403 whose status appears only in the exception message parsed as $null,
  which the retry policy treats as a transport error - five requests per
  account against a tenant already refusing. Status extraction now falls back
  to the message text, bounded to 400-599.

  The sanitization scan walked tracked files only, so it covered 34 of 156
  files and none of this phase's code. It now scans untracked non-ignored
  files too, and a negative control confirms it catches a planted leak.

  Test-Json reports one error per violating location, not first-failure-only
  as the V-5a draft claimed. Record and pin corrected.

Enforcement remains blocked on the V-4 security sign-off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 21:48:19 -04:00
parent c59c85dd55
commit cdc6bb33d3
124 changed files with 16638 additions and 199 deletions
+429 -188
View File
@@ -4,12 +4,295 @@ A modular, configuration-driven **PowerShell 7** identity-classification service
The engine enumerates Entra user accounts, evaluates each one against an ordered, JSON-defined rule set, deterministically assigns **exactly one persona**, and updates a single approved persona attribute — and only when the calculated value differs from the current value.
> **Project status: Phase 1 complete — specification drafted.**
> No implementation code exists yet. The approved requirements baseline is
> [`Persona-Engine-Developer-Handoff.txt`](Persona-Engine-Developer-Handoff.txt), now converted
> into [`specs/001-persona-engine/spec.md`](specs/001-persona-engine/spec.md). Project governance
> is ratified in [`.specify/memory/constitution.md`](.specify/memory/constitution.md) (v1.0.0).
> The next step is Phase 2: produce `plan.md` and `research.md`, closing OTD-001 through OTD-005.
> **Project status: Stage A implementation complete — ready for tenant validation.**
>
> 109 of 121 tasks are done. **354 offline tests pass**, along with the engine-purity and
> sanitization gates. Every remaining task needs something a developer workstation does not have:
> a tenant connection (T055, T056, T101T103) or an Azure Automation account (T115T121).
>
> **Nothing has ever been run against a real directory.** The next step is [Stage A2](#stage-a2--tenant-preview-read-only)
> — a delegated, read-only `-WhatIf` run. Follow the [testing checklist](#testing-checklist) in order.
---
## Quick start
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -TestDataPath ./tests/TestData -ValidateOnly -NonInteractive
```
That validates the configuration through all four layers and runs the real rule engine against
synthetic fixtures. No tenant, no credentials, no network. It is the fastest way to see what the
engine does.
---
## Deployment
### Prerequisites
| Requirement | Notes |
| --- | --- |
| PowerShell 7.2 or later | Developed on 7.6.5. `pwsh -v` to check. |
| `Microsoft.Graph.Authentication` | **Runtime only.** Not needed for the offline suites or the config editor. |
| Pester 5.0+ | Only to run the tests. Developed against 6.1.0. |
| PSScriptAnalyzer | Only for the lint gate. |
| An Entra app registration | For the persona extension property and delegated scopes. |
```powershell
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser -SkipPublisherCheck
Install-Module PSScriptAnalyzer -Scope CurrentUser
```
> Only `Microsoft.Graph.Authentication` is a runtime dependency (OTD-004). The engine calls Graph
> through `Invoke-MgGraphRequest` rather than resource-specific SDK modules, which keeps the import
> surface to one module and makes request bodies explicit values that tests can assert on — that is
> what makes the single-attribute guarantee provable.
### Step 1 — Get the code onto the target machine
```bash
git clone <REPO-URL> persona-engine
cd persona-engine
```
### Step 2 — Prove the machine can run it, before touching a tenant
```bash
pwsh -NoProfile -Command "& { $c = & ./tests/PesterConfiguration.ps1 -Suite Offline; Invoke-Pester -Configuration $c }"
```
Expect **354 passed, 0 failed**. This needs no credentials and no network. If it does not pass, stop
— nothing downstream is trustworthy.
### Step 3 — Register the persona extension property
The persona is stored in a **directory (schema) extension property** on an app registration (OTD-001),
addressable in dynamic group rules as `user.extension_<appId>_<name>`.
```powershell
Connect-MgGraph -Scopes 'Application.ReadWrite.All'
$app = Get-MgApplication -Filter "displayName eq '<APP-REGISTRATION-NAME>'"
New-MgApplicationExtensionProperty -ApplicationId $app.Id -BodyParameter @{
name = '<APPROVED-PERSONA-ATTRIBUTE-NAME>'
dataType = 'String'
targetObjects = @('User')
}
```
Record the returned `name` — it is the full `extension_<32-hex-app-id>_<name>` string, and it is what
goes into `engine.targetAttribute`.
> `extensionAttribute1..15` were **rejected**: they cannot be written from the cloud on objects that
> are, or ever were, synchronized from on-premises, or on Exchange-originated objects. Custom
> security attributes were **rejected**: they are not exposed to the dynamic group engine, which
> defeats the purpose.
### Step 4 — Grant delegated scopes
Read-only, and enough for every preview run:
```powershell
Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'
```
The engine requests only what the enabled rules need — a configuration with no role conditions never
asks for `RoleManagement.Read.Directory`.
`User.ReadWrite.All` is added only for Stage A3, and only after V-4 sign-off.
> **Never sign in with a standing privileged account.** A Global Administrator run invalidates V-3
> as evidence and removes every practical limit on what a defect could reach.
### Step 5 — Build the configuration
```bash
cp ./config/persona-engine.example.json ./config/persona-engine.json
```
Then replace every placeholder:
| Placeholder | Replace with |
| --- | --- |
| `extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>` | The full extension property name from step 3 |
| `<TEAM-NAME>`, `<CHANGE-REFERENCE>` | Ownership metadata |
| `<TIER0-ROLE-TEMPLATE-ID>` | Role **template** IDs (stable across tenants) |
| `00000000-0000-...` group IDs | Real group Object IDs |
| `<ORGANIZATION-NAME>` | Your company name as it appears in `companyName` |
| `<LOG-OUTPUT-PATH>` | Audit log path |
> **`config/persona-engine.json` must never be committed.** It contains real group Object IDs and
> your tenant's attribute name. Keep it in a protected configuration store, and confirm `.gitignore`
> covers it. The sanitization gate scans untracked files too, so it will catch this — but do not rely
> on that as your only control.
### Step 6 — Validate, before connecting to anything
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
```
Exit code `0` required. Codes: `1` findings · `2` warnings with `-TreatWarningsAsErrors` ·
`3` file unreadable · `4` schema unusable.
### Step 7 — Preview
```bash
pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf -Verbose
```
One account first. Then the tenant:
```bash
pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf
```
`-WhatIf` is the **only** approved no-write control. `-Debug` does not imply read-only.
### Step 8 — CI
Two pipelines are included. `pipelines/validate.yml` gates every pull request and runs entirely
offline; `pipelines/test.yml` publishes test results and coverage.
### Step 9 — Enforcement 🔒
**Blocked on the V-4 security sign-off** (T101). Do not run without `-WhatIf` against anything other
than purpose-created test accounts until that is recorded. See [docs/SecurityModel.md](docs/SecurityModel.md).
---
## Testing checklist
Work through these in order. Each stage assumes the previous one passed. **Do not skip ahead** — the
whole point of the staging is that a failure is cheap at stage A1 and expensive at stage A3.
### Stage A1 — offline (no tenant, no credentials, no network)
Everything here runs on any machine with PowerShell 7.
- [ ] **Module manifest loads**
`pwsh -NoProfile -Command "Test-ModuleManifest ./PersonaEngine.psd1"`
Fails without `Microsoft.Graph.Authentication` installed. Expected on a bare machine.
- [ ] **Offline suite: 354 passed, 0 failed**
`pwsh -NoProfile -Command "& { $c = & ./tests/PesterConfiguration.ps1 -Suite Offline; Invoke-Pester -Configuration $c }"`
- [ ] **Safety suite passes and is not empty**
`pwsh -NoProfile -Command "& { $c = & ./tests/PesterConfiguration.ps1 -Suite Safety; Invoke-Pester -Configuration $c }"`
A zero-test green run is the most dangerous possible result — it is what a mis-tagged file looks
like, and the assertions it silently drops are the zero-write and single-attribute ones.
- [ ] **Engine purity (Principle IV)**
`pwsh ./tests/Test-EnginePurity.ps1`
- [ ] **Sanitization (SC-013)**
`pwsh ./tests/Test-Sanitization.ps1`
Scans tracked **and** untracked non-ignored files, so it catches a leak before the commit.
- [ ] **Lint**
`pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1"`
- [ ] **No Graph module was loaded during the offline suite**
`pwsh -NoProfile -Command "& { $c = & ./tests/PesterConfiguration.ps1 -Suite Offline; $c.Output.Verbosity='None'; $null = Invoke-Pester -Configuration $c; Get-Module Microsoft.Graph* }"`
Must print nothing. This is the proof that SC-008 holds.
- [ ] **Example configuration passes all four layers**
`pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive`
- [ ] **Your real configuration passes all four layers**
`pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive`
- [ ] **Synthetic rule test produces the personas you expect**
`pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData -ValidateOnly -NonInteractive`
The fixtures include two accounts whose membership lookups failed. Both must show
`EvaluationError`, not a persona. If they show a persona, stop — FR-013 is broken.
- [ ] **Drift check against the deployed configuration** (once one exists)
`pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -PreviousConfigPath ./deployed/persona-engine.json -ValidateOnly -NonInteractive`
### Stage A2 — tenant preview, read-only
Requires delegated read scopes. **Sign in as a non-privileged account.**
- [ ] **Connect with read-only scopes and confirm no write scope was granted**
`Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'`
then `(Get-MgContext).Scopes`
- [ ] **Single-user preview**
`pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf -Verbose`
Expect: one result line, `WouldUpdate` or `Unchanged`, exit `0`.
- [ ] **Single-user preview against each origin type — V-1 read half (T056)**
A cloud-only account, a currently-synced account, and a formerly-synced account. Record in
`specs/001-persona-engine/verification/V-1.md`. This is the check that proves the OTD-001
attribute choice actually works where `extensionAttributeN` would not.
- [ ] **Full tenant preview**
`pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf`
Expect exit `0` and reconciliation `PASS` at every summary.
- [ ] **Record the V-3 evidence (T055)**
Confirm the run completed as a **non-privileged** account. A Global Administrator run
invalidates this item. Record in `specs/001-persona-engine/verification/V-3.md`.
- [ ] **Review the impact before going further**
From the final summary: how many `WouldUpdate`? Which rules fired, and which fired zero times?
Is the `EvaluationError` count near zero? A high count means the rule set is asking for data
the tenant will not reliably give it.
- [ ] **Idempotence against the real tenant**
Run the full preview twice. The counters must be identical.
- [ ] **Performance baseline (NFR-002)**
Record `durationMs` from the `RunComplete` record and the account count. No target exists yet;
this run is how one gets set.
### Stage A3 — delegated write, test accounts only 🔒
**Gated on V-4** — the written security sign-off on the six OTD-003 compensating controls
(T101, `specs/001-persona-engine/verification/V-4.md`).
- [ ] **V-4 sign-off recorded** — nothing below may start before this
- [ ] **Purpose-created test accounts exist**, one per origin type, and no other account is in scope
- [ ] **Write scope added**: `User.ReadWrite.All`, still as a non-privileged account
- [ ] **Preview the test accounts first**, one at a time, with `-WhatIf`
- [ ] **Enforce one test account**
`pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <TEST-ACCOUNT-OBJECT-ID>`
Confirm the confirmation prompt appears — `ConfirmImpact` is `High`.
- [ ] **Verify the write landed and touched nothing else**
Re-read the account and diff every property against a copy taken beforehand. Only the persona
attribute may have changed.
- [ ] **Confirm `previousValue` is on the audit record** — without it, rollback is impossible retroactively
- [ ] **Second run proposes zero changes** (SC-002, against a real directory this time)
- [ ] **V-1 write half (T102)** — one write per origin type; append to `V-1.md`
- [ ] **V-2 (T103)** — build a dynamic group on `user.extension_<appId>_<name>`, assign a Conditional
Access policy to it in **report-only** mode, and confirm it applies. This is what proves the
persona is actually useful rather than merely stored.
### Stage B — Azure Automation ⛔
Deferred; no Automation account available. T115T121. Run **T116 first** (`Test-Json` behaviour in
the Automation runtime) — it is the cheapest item most likely to surprise, and V-5a already showed
this cmdlet behaves in a way nobody would guess.
### If something fails
| Symptom | Look at |
| --- | --- |
| Exit `1` | The findings printed above it. No connection was attempted. |
| Exit `2` | Scopes, consent, whether the account can sign in. |
| Exit `3` | Graph availability. **No accounts were processed** — a partial population is never used. |
| Exit `4` | Group/role endpoint health. Nothing was changed. |
| Exit `5` | **An engine defect.** File an issue with the `EngineDefect` record. |
| Every account `Skipped` | Target attribute blank or unapproved. |
| Every account `EvaluationError` | A required data source is disabled or unreachable. |
| Every account `Unclassified` | Every rule disabled, or no rule matches. The summary distinguishes these. |
[docs/OperationsRunbook.md](docs/OperationsRunbook.md) has the full table, the kill switch, and the
rollback procedure.
---
@@ -21,17 +304,15 @@ This project follows the **GitHub Spec Kit** workflow. Nothing is implemented be
Specify -> Plan -> Tasks -> Implement
```
| Stage | Artifact | Phase | State |
| --- | --- | --- | --- |
| Baseline | `Persona-Engine-Developer-Handoff.txt` | 0 | Approved |
| Constitution | `.specify/memory/constitution.md` | 0 | Ratified v1.0.0 |
| Specify | `specs/001-persona-engine/spec.md` | 1 | Draft complete |
| Plan | `specs/001-persona-engine/plan.md`, `research.md` | 2 | In progress |
| Contracts | `data-model.md`, `contracts/`, `persona-engine.schema.json` | 3 | Not started |
| Tasks | `specs/001-persona-engine/tasks.md` | 4 | Not started |
| Implement | `src/`, `tests/`, `pipelines/` | 512 | Not started |
Any item that is unresolved must be captured as an explicit **assumption, risk, or architecture decision**. It must never be silently implemented.
| Stage | Artifact | State |
| --- | --- | --- |
| Baseline | `Persona-Engine-Developer-Handoff.txt` | Approved |
| Constitution | `.specify/memory/constitution.md` | Ratified v1.0.0 |
| Specify | `specs/001-persona-engine/spec.md` | Complete |
| Plan | `plan.md`, `research.md` | Complete — OTD-001…007, 010 resolved |
| Contracts | `data-model.md`, `contracts/`, `persona-engine.schema.json` | Complete |
| Tasks | `specs/001-persona-engine/tasks.md` | Complete — 121 tasks |
| Implement | `src/`, `tests/`, `pipelines/`, `docs/` | **109 / 121** — remainder needs a tenant or Automation |
---
@@ -48,232 +329,192 @@ Any item that is unresolved must be captured as an explicit **assumption, risk,
| **Explainable** | Every result identifies the matched rule, run ID, UPN, and Account Object ID. |
| **Modular** | The pure rule engine has no dependency on Graph, Azure Automation, or the console. |
---
### The one that matters most
## Scope (version 1)
**Unknown is not false.** If a membership lookup fails, the condition is `Unknown`, not `False` — so a
`notMemberOf` condition does not become satisfied when the lookup fails.
**In scope**
- Microsoft Entra **user objects only**
- Ordered, first-match business rules defined in **JSON** (the only supported configuration format)
- Property, group-membership, and role-based conditions with nested `All` / `Any` composition
- Native PowerShell `-WhatIf` as the approved no-write control
- Structured, audit-friendly logging plus immediate per-user output and periodic summaries
- Local PowerShell 7 execution and Azure Automation PowerShell 7 runbook execution
**Out of scope for v1** — the architecture must not assume these share user-object properties:
- Service principals, managed identities, workload identities, agentic identities
### Candidate persona catalogue
These are candidate business classifications, **not** hard-coded engine behaviour:
`Guest` · `BreakGlass-Admin` · `Tier0-Admin` · `Tier1-Admin` · `Tier2-Admin` · `Restricted-User` · `Test-Account` · `Service-Account` · `Shared-Functional-Account` · `Meeting-Room-Device` · `Employee` · `Contractor` · `Student`
Two values are processing results rather than rules:
- **`Unclassified`** — evaluation succeeded, but no rule matched.
- **`EvaluationError`** — evaluation could not complete; the existing persona is preserved.
Without that, a transient Graph outage would make every privileged account look like a non-member of
its Tier 0 group, and one run would quietly demote the entire administrative population.
`tests/RuleEngine/UnknownNotFalse.Tests.ps1` exists solely to prevent that regression.
---
## Components
### 1. `Invoke-PersonaEngine.ps1`
Retrieval, evaluation, reporting, and controlled persistence.
### `Invoke-PersonaEngine.ps1`
| Parameter | Notes |
| --- | --- |
| `-ConfigPath <string>` | Required |
| `-WhatIf` | Native risk-mitigation parameter; the approved no-write control |
| `-Verbose` / `-Debug` | Native common parameters; `-Debug` must **not** mean read-only |
| `-UserObjectId <GUID>` | Optional single-user test execution |
| `-OutputPath <string>` | Optional override, if permitted |
| `-CorrelationId <GUID>` | Optional supplied run identifier |
| `-ConfigPath <string>` | Required. Validated through all four layers before any connection. |
| `-WhatIf` | **The approved no-write control.** |
| `-UserObjectId <GUID>` | Single-user execution. |
| `-OutputPath <string>` | Overrides `logging.path`. |
| `-CorrelationId <GUID>` | Run identifier; generated when absent. |
| `-SchemaPath <string>` | Schema override. |
| `-PreviousConfigPath <string>` | Enables the VR-003 drift checks. |
| `-Verbose` / `-Debug` | Common parameters. **Neither implies read-only.** |
The script uses `CmdletBinding` with `SupportsShouldProcess`.
**Exit codes**: `0` success · `1` config invalid · `2` auth · `3` enumeration · `4` data / threshold ·
`5` reconciliation · `6` unexpected.
```powershell
# Read-only evaluation
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf
# Read-only with operational detail
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf -Verbose
# Single-user validation
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf
# Production, changed-values-only processing
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json
```
**Exit codes**
| Code | Meaning |
| --- | --- |
| `0` | Successful run; no fatal processing errors |
| `1` | Configuration validation failure |
| `2` | Authentication / authorization failure |
| `3` | User enumeration failure |
| `4` | Fatal required data-provider failure |
| `5` | Reconciliation failure |
| `6` | Unexpected fatal engine error |
A per-user `EvaluationError` does not necessarily terminate the run, but the final status must report the number of affected accounts and may apply a configurable warning/failure threshold.
### 2. `Edit-PersonaEngineConfig.ps1`
Configuration validation, interactive editing, synthetic rule testing, and pipeline enforcement.
### `Edit-PersonaEngineConfig.ps1`
| Parameter | Notes |
| --- | --- |
| `-ConfigPath <string>` | Required |
| `-ValidateOnly` | Validate without entering the editor |
| `-NonInteractive` | Pipeline mode; returns codes instead of prompting |
| `-SchemaPath <string>` | Optional schema override |
| `-OutputPath <string>` | Optional Save-As target |
| `-TreatWarningsAsErrors` | Escalate warnings |
| `-TestDataPath <string>` | Optional synthetic sample input |
| `-ConfigPath <string>` | Required. |
| `-ValidateOnly` | Validate; never enter the editor. |
| `-NonInteractive` | Pipeline mode. Never prompts, never hangs. |
| `-SchemaPath` / `-OutputPath` | Schema override; Save-As target. |
| `-TreatWarningsAsErrors` | Escalate warnings (VR-005). |
| `-TestDataPath <string>` | Synthetic rule testing, no tenant. |
| `-PreviousConfigPath` / `-EnforcementEnabled` | Drift checks; raise safety severities. |
```powershell
# Validate only
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly
**Exit codes**: `0` valid · `1` errors · `2` warnings escalated · `3` file unreadable · `4` schema unusable.
# Pipeline validation
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
# Interactive editor
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json
```
Validation runs in four layers: **JSON syntax → JSON Schema → semantic → safety**.
Validation runs in four layers: **JSON syntax → JSON Schema → semantic → safety**, stopping at the
first that produces errors.
---
## Architecture
```
Invoke-PersonaEngine.ps1
|
+-- Configuration Import-PersonaConfiguration, Test-PersonaConfiguration, Resolve-TargetAttribute
+-- Authentication Connect-PersonaGraphInteractive, Connect-PersonaGraphManagedIdentity
+-- Data Providers Get-PersonaUsers, Get-PersonaGroupMembership, Get-PersonaDirectoryRoles
+-- Normalization ConvertTo-PersonaUserRecord, ConvertTo-PersonaMembershipRecord
+-- Rule Engine Test-PersonaCondition, Test-PersonaConditionGroup, Test-PersonaRule, Resolve-UserPersona
+-- Persistence Compare-PersonaValue, Set-UserPersonaAttribute
+-- Presentation Write-UserPersonaResult, Write-PersonaSummary
+-- Audit New-PersonaAuditRecord, Export-PersonaRunReport
Invoke-PersonaEngine.ps1 thin wrapper: parameters, ShouldProcess, exit code
└── src/Engine/ Invoke-PersonaEngineRun (the run loop, testable offline)
├── Configuration Import, Test (4 layers), Resolve-TargetAttribute
├── Authentication Connect-PersonaGraphInteractive
├── DataProviders Get-PersonaUsers, GroupMembership, DirectoryRoles, retry, cache
├── Normalization ConvertTo-PersonaUserRecord, ConvertTo-PersonaMembershipRecord
├── RuleEngine Test-PersonaCondition/ConditionGroup/Rule, Resolve-UserPersona
├── Persistence Compare-PersonaValue, New-PersonaWriteBody, Set-UserPersonaAttribute
├── Presentation Write-UserPersonaResult, Write-PersonaSummary, reconciliation
└── Audit New-PersonaAuditRecord, Write-PersonaAuditRecord, Export-PersonaRunReport
```
**Critical flow**
```
Graph acquisition -> normalized identity record -> pure rule engine
-> persona decision result -> comparison -> optional persistence adapter
-> console and structured audit output
```
The **pure rule engine must not depend** on Graph authentication, Azure Automation, or console rendering. It has to be testable offline with synthetic data.
The **rule engine is pure** — no Graph, no auth, no console, no filesystem, no clock. Enforced on
every build by `tests/Test-EnginePurity.ps1`, which parses each file with the PowerShell AST parser
and inspects only code tokens. That purity is why 354 tests run with no tenant.
---
## Planned repository structure
## Repository layout
```
PersonaEngine/
|-- README.md
|-- Invoke-PersonaEngine.ps1
|-- Edit-PersonaEngineConfig.ps1
|-- PersonaEngine.psd1
|-- PersonaEngine.psm1
|
|-- config/ persona-engine.example.json, persona-engine.schema.json
|-- src/ Authentication/ Configuration/ DataProviders/ Normalization/
| RuleEngine/ Persistence/ Presentation/ Audit/
|-- tests/ Unit/ Integration/ Configuration/ Safety/ TestData/
|-- docs/ Architecture.md BusinessRules.md ConfigurationReference.md
| Logging.md SecurityModel.md OperationsRunbook.md
|-- pipelines/ validate.yml test.yml release.yml
|-- specs/
|-- 001-persona-engine/
|-- spec.md plan.md tasks.md research.md data-model.md quickstart.md
|-- contracts/ checklists/
Invoke-PersonaEngine.ps1 Edit-PersonaEngineConfig.ps1
PersonaEngine.psd1 PersonaEngine.psm1
config/ persona-engine.example.json, persona-engine.schema.json
src/ Configuration/ Authentication/ DataProviders/ Normalization/
RuleEngine/ Persistence/ Presentation/ Engine/ Audit/
tests/ Unit/ RuleEngine/ Configuration/ Safety/ TestData/
Test-EnginePurity.ps1 Test-Sanitization.ps1 TestHelpers.ps1
docs/ Architecture.md SecurityModel.md ConfigurationReference.md
OperationsRunbook.md BusinessRules.md Logging.md
pipelines/ validate.yml test.yml
specs/001-persona-engine/
spec.md plan.md tasks.md research.md data-model.md
quickstart.md traceability.md contracts/ verification/
```
---
## Security model
- **Authentication** — Azure Automation uses a **managed identity**. Local development uses an approved interactive or read-only application identity. **No client secret in source control.**
- **Least privilege** — the execution identity gets only what the enabled rules require: in-scope user properties, configured group membership, configured role data, and the existing persona value.
- **Write permissions** — the production identity is granted the minimum permission needed to update the configured target attribute. Whether Entra can enforce write scope at the **individual attribute level must be verified, never assumed** (see OTD-003).
- **Compensating controls**, if the Graph permission proves broader than the single attribute: the persistence module accepts only the approved target attribute; that attribute must appear in `approvedWritableAttributes`; validation rejects all others; a dedicated function builds a request body containing only that attribute; unit and integration tests inspect the request body; code owners and branch policies gate persistence changes; directory audit logs are monitored for unexpected property writes.
- **Data handling** — UPN and Account Object ID are approved for logs. Never log access tokens, authorization headers, secrets, or full Graph responses. Detailed condition values are diagnostic-only, behind `-Debug`.
- **Kill switch** — disable the Automation schedule, run with `-WhatIf`, revoke production write permission, or disable write deployment stages.
The short version; the full one is [docs/SecurityModel.md](docs/SecurityModel.md).
**Graph application permissions have no per-property write scope** (OTD-003). An identity that can
write the persona attribute can write any writable user property. The directory will not stop a
malformed request on our behalf, so six compensating controls hold that line, and each is tested:
1. `New-PersonaWriteBody` throws for any attribute other than the configured target.
2. The target must appear in `approvedWritableAttributes` — checked at validation **and** at write time.
3. Validation rejects any approved attribute that is not a directory extension property (`PE-SAF-002`).
4. One function builds the request body, and it returns a hashtable whose `Count` is exactly 1.
5. Tests inspect every body issued during a full enforcing run.
6. Code owners and branch policies gate changes to `src/Persistence/`.
**V-4 — written security sign-off on these controls — gates all enforcement.**
Never logged: tokens, `Authorization` headers, secrets, certificates, raw Graph responses. The
guarantee is structural: the record builder accepts only named, typed values, so there is nothing for
a secret to ride in on.
---
## Sanitization requirements
## Sanitization (SC-013)
**Every** artifact in this repository — docs, examples, tests, configuration samples — must be free of organization names, real domains, tenant or subscription IDs, automation account names, real UPNs or Object IDs, real group/role identifiers, environment-specific attribute names, Log Analytics details, and any secret, token, certificate, or credential.
No organization name, real domain, tenant or subscription ID, real UPN or Object ID, real group or
role identifier, environment-specific attribute name, or secret may appear in any file this
repository would commit.
Use placeholders only:
`tests/Test-Sanitization.ps1` scans tracked **and** untracked non-ignored files. Reserved domains
(`example.com`, `.invalid`, `.test`) and the module manifest's own identity GUID are exempt; nothing
else is.
`<ORGANIZATION-NAME>` · `<PRIMARY-DOMAIN>` · `<TENANT-ID>` · `<ACCOUNT-OBJECT-ID>` · `<GROUP-OBJECT-ID>` · `<APPROVED-PERSONA-ATTRIBUTE-NAME>` · `<AUTOMATION-ACCOUNT-NAME>` · `<LOG-OUTPUT-PATH>`
Synthetic test data must be obviously fictional and must never reproduce real employee records. Tenant-specific configuration belongs in a protected repository or configuration store, not here.
Placeholders: `<ORGANIZATION-NAME>` · `<PRIMARY-DOMAIN>` · `<TENANT-ID>` · `<ACCOUNT-OBJECT-ID>` ·
`<GROUP-OBJECT-ID>` · `<APPROVED-PERSONA-ATTRIBUTE-NAME>` · `<AUTOMATION-ACCOUNT-NAME>` · `<LOG-OUTPUT-PATH>`
---
## Open technical decisions
## Open decisions and verification
Research items to be resolved in `research.md` or an ADR. **OTD-001 through OTD-005 must be closed before persistence implementation.**
| ID | Decision |
| ID | Status |
| --- | --- |
| OTD-001 | Select the exact Entra persona attribute mechanism — data type, Graph read/update method, discoverability, Conditional Access compatibility |
| OTD-002 | Confirm exact least-privilege Microsoft Graph permissions |
| OTD-003 | Confirm whether write authorization can be restricted to the individual target attribute |
| OTD-004 | Select the Graph access approach — SDK cmdlets, direct REST, or a controlled combination |
| OTD-005 | Select a JSON Schema validation approach compatible with PS7 locally and in Azure Automation |
| OTD-006 | Select structured-log destination and transport |
| OTD-007 | Define retry policy — retryable status codes, max attempts, backoff, jitter, logging |
| OTD-008 | Define full versus incremental processing roadmap |
| OTD-009 | Define production schedule and concurrency lock |
| OTD-010 | Define rollback implementation |
| OTD-001 persona attribute | **Resolved** — directory extension property |
| OTD-002 least-privilege permissions | **Resolved** |
| OTD-003 per-attribute write scope | **Resolved: not possible.** Six compensating controls; V-4 outstanding |
| OTD-004 Graph access approach | **Resolved**`Invoke-MgGraphRequest` |
| OTD-005 schema validation | **Resolved locally**`Test-Json`; V-5b open for Automation |
| OTD-006 log transport | **Resolved** — NDJSON via a single sink |
| OTD-007 retry policy | **Resolved** |
| OTD-008 incremental processing | Open — full enumeration only in v1 |
| OTD-009 concurrency lock | Open — deferred with Stage B |
| OTD-010 rollback | **Data captured**; tool out of scope for v1 |
| Verification | Status |
| --- | --- |
| V-1 read / write | Open — needs a tenant / test accounts |
| V-2 dynamic group + CA | Open |
| V-3 non-privileged preview | Open |
| **V-4 security sign-off** | **Open — gates enforcement** |
| V-5a `Test-Json` behaviour | **Closed** — [V-5a.md](specs/001-persona-engine/verification/V-5a.md) |
| V-5b `Test-Json` in Automation | Deferred |
Full requirement-to-test mapping, including the gaps: [traceability.md](specs/001-persona-engine/traceability.md).
---
## Getting started
## Documentation
Implementation has not begun. The current work item is Phase 2 — planning.
1. ~~Initialize the Spec Kit project structure.~~ Done.
2. ~~Convert the handoff baseline into `specs/001-persona-engine/spec.md`.~~ Done.
3. Build a requirements traceability list using FR/NFR identifiers.
4. Close OTD-001 through OTD-005 before any persistence work.
5. Create `persona-engine.schema.json` and a placeholder-only `persona-engine.example.json`.
6. Define normalized PowerShell object contracts.
7. **Build the pure rule engine first**, with offline Pester tests, before any Graph integration.
8. Implement configuration validation and non-interactive pipeline mode.
9. Implement Graph read adapters, then console and structured logging.
10. Implement the persistence adapter **last**, with `ShouldProcess` and tests proving zero writes under `-WhatIf`.
Requirements: **PowerShell 7**. Offline unit testing must not require tenant connectivity.
| Document | For |
| --- | --- |
| [Architecture.md](docs/Architecture.md) | Boundaries, and why the rule engine is pure |
| [SecurityModel.md](docs/SecurityModel.md) | OTD-003, the six controls, V-4 |
| [ConfigurationReference.md](docs/ConfigurationReference.md) | Every field and every finding code |
| [BusinessRules.md](docs/BusinessRules.md) | Writing and changing rules |
| [OperationsRunbook.md](docs/OperationsRunbook.md) | Kill switch, rollback, incidents |
| [Logging.md](docs/Logging.md) | Record types and querying |
---
## Delivery
## Scope (version 1)
- Hosted in **Azure DevOps Git** — feature branches, pull requests, protected release branch, code owners on persistence, security configuration, and production rules.
- **Validation pipeline** — repository hygiene checks, PowerShell static analysis, JSON Schema validation, semantic/safety configuration validation, Pester unit tests, Pester safety tests, test result publication, artifact packaging.
- **Release pipeline** — validate approved branch/tag, repeat validation and tests, package, deploy to Azure Automation, import modules, publish runbook, **keep the schedule disabled**, execute `-WhatIf` validation, approval gate, then enable enforcement.
- Changes to the target attribute, `approvedWritableAttributes`, rule priority, rule enablement, rule conditions, persona outputs, authentication permissions, persistence functions, logging destination, or `WhatIf`/`ShouldProcess` behaviour **all require review**.
**In scope** — Entra **user objects only**; ordered first-match rules in JSON; property,
group-membership, and role conditions with nested `all`/`any`; `-WhatIf` as the no-write control;
structured audit logging; local PowerShell 7 and (deferred) Azure Automation.
---
**Out of scope** — service principals, managed identities, workload identities, agentic identities;
non-JSON configuration; delta processing; condition-level case-sensitivity; PIM-eligible role
assignments; a rollback tool.
## Definition of done (v1)
### Candidate persona catalogue
Version 1 is complete when both PowerShell scripts are implemented; the JSON Schema exists and is documented; validation covers syntax, schema, semantic, and safety layers; ordered first-match evaluation and nested `All`/`Any` work within the configured depth; the initial property and membership operators are tested; null behaviour matches the approved decision; required group-lookup failures produce `EvaluationError` and preserve the existing persona; `Unclassified` users are reported distinctly; each user result displays immediately; interim and final summaries work — including interval `0` — and reconciliation checks pass; logs include UPN and Account Object ID; **`-WhatIf` produces zero Graph writes**; only changed valid values are written in enforcement mode; the write payload contains only the approved target attribute; local offline Pester, read-only production-tenant, Azure Automation PowerShell 7, and Azure DevOps pipeline runs all pass; security review confirms permissions and compensating controls; operational documentation, kill switch, and rollback procedure are complete; and `-WhatIf` impact evidence is reviewed before enforcement is enabled.
`Guest` · `BreakGlass-Admin` · `Tier0-Admin` · `Tier1-Admin` · `Tier2-Admin` · `Restricted-User` ·
`Test-Account` · `Service-Account` · `Shared-Functional-Account` · `Meeting-Room-Device` · `Employee` ·
`Contractor` · `Student`
Two values are processing results, not rule outcomes:
- **`Unclassified`** — evaluation succeeded, no rule matched.
- **`EvaluationError`** — evaluation could not complete; the existing persona is preserved.