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:
@@ -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, 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 [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. 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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/` | 5–12 | 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.
|
||||
|
||||
Reference in New Issue
Block a user