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 — a delegated, read-only
-WhatIfrun. Follow the checklist in order.
Quick start
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. |
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
Install-Module PSScriptAnalyzer -Scope CurrentUser
Only
Microsoft.Graph.Authenticationis a runtime dependency (OTD-004). The engine calls Graph throughInvoke-MgGraphRequestrather 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
git clone <REPO-URL> persona-engine
cd persona-engine
Step 2 — Prove the machine can run it, before touching a tenant
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>.
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..15were 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:
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
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.jsonmust never be committed. It contains real group Object IDs and your tenant's attribute name. Keep it in a protected configuration store, and confirm.gitignorecovers 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
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
pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf -Verbose
One account first. Then the tenant:
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.
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 withoutMicrosoft.Graph.Authenticationinstalled. 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 -NonInteractivePoint-TestDataPathat a directory of synthetic user/membership fixtures. If any of them simulate a failed membership lookup, that account must showEvaluationError, 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 -VerboseExpect: one result line,WouldUpdateorUnchanged, exit0. -
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 whereextensionAttributeNwould not. -
Full tenant preview
pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIfExpect exit0and reconciliationPASSat 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 theEvaluationErrorcount 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
durationMsfrom theRunCompleterecord 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 —ConfirmImpactisHigh. - 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
previousValueis 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. 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 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> |
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. 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.
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:
New-PersonaWriteBodythrows for any attribute other than the configured target.- The target must appear in
approvedWritableAttributes— checked at validation and at write time. - Validation rejects any approved attribute that is not a directory extension property (
PE-SAF-002). - One function builds the request body, and it returns a hashtable whose
Countis exactly 1. - Tests inspect every body issued during a full enforcing run.
- 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 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 |
V-5b Test-Json in Automation |
Deferred |
Full requirement-to-test mapping, including the gaps: traceability.md.
Documentation
| Document | For |
|---|---|
| Architecture.md | Boundaries, and why the rule engine is pure |
| SecurityModel.md | OTD-003, the six controls, V-4 |
| ConfigurationReference.md | Every field and every finding code |
| RuleAuthoringGuide.md | User manual: every condition type and operator, worked examples, the interactive editor |
| BusinessRules.md | Writing and changing rules — the judgement calls |
| OperationsRunbook.md | Kill switch, rollback, incidents |
| 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.