Files
personaEngine2/README.md
T

24 KiB
Raw Blame History

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. 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 — a delegated, read-only -WhatIf run. Follow the testing checklist in order.


Quick start

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.
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

git clone <REPO-URL> persona-engine
cd persona-engine

Step 2 — Prove the machine can run it, before touching a tenant

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>.

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:

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
<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

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 — 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.


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 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/, tests/, pipelines/, 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. tests/RuleEngine/UnknownNotFalse.Tests.ps1 exists solely to prevent that regression.


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.
-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. 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.


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/
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

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:

  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.

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.

Placeholders: <ORGANIZATION-NAME> · <PRIMARY-DOMAIN> · <TENANT-ID> · <ACCOUNT-OBJECT-ID> · <GROUP-OBJECT-ID> · <APPROVED-PERSONA-ATTRIBUTE-NAME> · <AUTOMATION-ACCOUNT-NAME> · <LOG-OUTPUT-PATH>


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 ResolvedInvoke-MgGraphRequest
OTD-005 schema validation Resolved locallyTest-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 ClosedV-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
BusinessRules.md Writing and changing rules
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.