Files
personaEngine2/README.md
T
dave 5f125c34f2 Add elapsed run time, per-account results CSV, and a default config path
Summaries now show elapsed wall-clock time since the run started, and every
summary (interim and final) overwrites a results.csv (Object ID, UPN,
persona/status) next to the audit log, so an operator has a plain export
without parsing NDJSON. ConfigPath also now defaults to
./config/persona-engine.json instead of requiring -ConfigPath every run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 22:10:24 -04:00

495 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Persona Engine
A modular, configuration-driven **PowerShell 7** identity-classification service for **Microsoft Entra ID**.
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: Stage A implementation complete — ready for tenant validation.**
>
> 109 of 121 tasks are done. 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 [checklist](#tenant-validation-checklist) in order.
---
## Quick start
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive
```
That validates the configuration through all four layers. No tenant, no credentials, no network. It
is the fastest way to see what the engine does. Pass `-TestDataPath <dir>` with a directory of
synthetic user/membership fixtures to also run the real rule engine against them and see what
personas it would assign.
---
## 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 config editor's offline validation. |
| PSScriptAnalyzer | Only if you want to run the lint checks locally. |
| An Entra app registration | For the persona extension property and delegated scopes. |
```powershell
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
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 ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive
```
Exit code `0` expected. 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` |
`logging.path` was removed from the copy — it's optional and defaults to
`<current-directory>/logs/persona-engine-audit.ndjson`. Set it (or pass `-OutputPath`) only if you
want the audit log somewhere else.
> **`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 — 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).
---
## Tenant validation 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.
- [ ] **Lint**
`pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1"`
- [ ] **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 <fixtures-dir> -ValidateOnly -NonInteractive`
Point `-TestDataPath` at a directory of synthetic user/membership fixtures. If any of them
simulate a failed membership lookup, that account must show `EvaluationError`, not a persona.
If it shows a persona instead, 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.
---
## Spec-driven development
This project follows the **GitHub Spec Kit** workflow. Nothing is implemented before it is specified, planned, and decomposed into tasks.
```
Specify -> Plan -> Tasks -> Implement
```
| 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/`, `docs/` | **109 / 121** — remainder needs a tenant or Automation |
---
## Core design principles
| Principle | Meaning |
| --- | --- |
| **Deterministic** | The same input and configuration always produce the same persona. |
| **Exactly one result** | Every evaluated account receives one persona — never zero, never several. |
| **First match wins** | Rules are ordered; evaluation stops at the first match. |
| **Idempotent** | Re-running changes nothing unless the calculated value actually changed. |
| **Configuration-driven** | Business rules live in JSON, never in PowerShell source. |
| **Fail safe** | If evaluation cannot complete reliably, the existing persona is preserved. |
| **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
**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.
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.
---
## Components
### `Invoke-PersonaEngine.ps1`
| Parameter | Notes |
| --- | --- |
| `-ConfigPath <string>` | Defaults to `./config/persona-engine.json`. Validated through all four layers before any connection. |
| `-WhatIf` | **The approved no-write control.** |
| `-UserObjectId <GUID>` | Single-user execution. |
| `-OutputPath <string>` | Overrides `logging.path`. Both default to `<current-directory>/logs/persona-engine-audit.ndjson`. |
| `-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.** |
**Exit codes**: `0` success · `1` config invalid · `2` auth · `3` enumeration · `4` data / threshold ·
`5` reconciliation · `6` unexpected.
### `Edit-PersonaEngineConfig.ps1`
| Parameter | Notes |
| --- | --- |
| `-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. |
**Exit codes**: `0` valid · `1` errors · `2` warnings escalated · `3` file unreadable · `4` schema unusable.
Validation runs in four layers: **JSON syntax → JSON Schema → semantic → safety**, stopping at the
first that produces errors.
---
## Architecture
```
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
```
The **rule engine is pure** — no Graph, no auth, no console, no filesystem, no clock. That purity is
what lets it be evaluated offline against synthetic fixtures with no tenant connection.
---
## Repository layout
```
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/
docs/ Architecture.md SecurityModel.md ConfigurationReference.md
RuleAuthoringGuide.md OperationsRunbook.md BusinessRules.md Logging.md
specs/001-persona-engine/
spec.md plan.md tasks.md research.md data-model.md
quickstart.md traceability.md contracts/ verification/
```
---
## Security model
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 (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 secret may appear in any file this
repository would commit.
Reserved domains (`example.com`, `.invalid`, `.test`) and the module manifest's own identity GUID are
exempt; nothing else is. See [docs/SecurityModel.md](docs/SecurityModel.md) for the full policy.
Placeholders: `<ORGANIZATION-NAME>` · `<PRIMARY-DOMAIN>` · `<TENANT-ID>` · `<ACCOUNT-OBJECT-ID>` ·
`<GROUP-OBJECT-ID>` · `<APPROVED-PERSONA-ATTRIBUTE-NAME>` · `<AUTOMATION-ACCOUNT-NAME>`
---
## Open decisions and verification
| ID | Status |
| --- | --- |
| 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).
---
## Documentation
| 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 |
| [RuleAuthoringGuide.md](docs/RuleAuthoringGuide.md) | User manual: every condition type and operator, worked examples, the interactive editor |
| [BusinessRules.md](docs/BusinessRules.md) | Writing and changing rules — the judgement calls |
| [OperationsRunbook.md](docs/OperationsRunbook.md) | Kill switch, rollback, incidents |
| [Logging.md](docs/Logging.md) | Record types and querying |
---
## Scope (version 1)
**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.
### Candidate persona catalogue
`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.