# 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, 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 [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 ` 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 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__`. ```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` | `logging.path` was removed from the copy — it's optional and defaults to `/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 -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 -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 -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. --- ## 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 ` | Required. Validated through all four layers before any connection. | | `-WhatIf` | **The approved no-write control.** | | `-UserObjectId ` | Single-user execution. | | `-OutputPath ` | Overrides `logging.path`. Both default to `/logs/persona-engine-audit.ndjson`. | | `-CorrelationId ` | Run identifier; generated when absent. | | `-SchemaPath ` | Schema override. | | `-PreviousConfigPath ` | 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 ` | 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. | **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: `` · `` · `` · `` · `` · `` · `` --- ## 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.