203 lines
13 KiB
Markdown
203 lines
13 KiB
Markdown
|
|
# Implementation Plan: Persona Engine
|
|||
|
|
|
|||
|
|
**Branch**: `main` (feature directory `001-persona-engine`) | **Date**: 2026-08-20 | **Spec**: [spec.md](spec.md)
|
|||
|
|
|
|||
|
|
**Input**: Feature specification from `/specs/001-persona-engine/spec.md`
|
|||
|
|
|
|||
|
|
## Summary
|
|||
|
|
|
|||
|
|
Deterministic, configuration-driven persona classification for Microsoft Entra ID user objects. The
|
|||
|
|
engine enumerates in-scope users, evaluates each against an ordered JSON rule set, assigns exactly
|
|||
|
|
one persona, and updates a single approved directory attribute only when the calculated value
|
|||
|
|
differs from the stored value.
|
|||
|
|
|
|||
|
|
**Technical approach**: a PowerShell 7 module (`PersonaEngine`) whose rule engine is a pure function
|
|||
|
|
over normalized records, with Graph access, persistence, and presentation isolated behind adapters.
|
|||
|
|
Directory access uses `Invoke-MgGraphRequest` (direct REST over the `Microsoft.Graph.Authentication`
|
|||
|
|
module) so the write body is explicitly constructed and test-assertable. The persona value is stored
|
|||
|
|
in a **directory (schema) extension** on the user object, consumed downstream by dynamic membership
|
|||
|
|
groups. Configuration is validated with the built-in `Test-Json -SchemaFile` against a draft-07
|
|||
|
|
schema. See [research.md](research.md) for the decisions and their rationale.
|
|||
|
|
|
|||
|
|
## Delivery Staging
|
|||
|
|
|
|||
|
|
**Constraint (2026-08-20)**: no Azure Automation account is available. All development and testing
|
|||
|
|
proceeds on a local PowerShell 7 workstation using **user accounts and delegated authentication**.
|
|||
|
|
|
|||
|
|
This changes sequencing, not architecture. The adapter boundaries that make the engine testable
|
|||
|
|
offline (Principle IV) are the same boundaries that make the Automation runtime a late, additive
|
|||
|
|
step — so the deferral costs nothing structurally.
|
|||
|
|
|
|||
|
|
| Stage | Environment | Auth | Status |
|
|||
|
|
| --- | --- | --- | --- |
|
|||
|
|
| **A1** — offline | Local PS7, synthetic fixtures | None | Available now. Covers the rule engine, all four validation layers, and the safety suites. No tenant, no network. |
|
|||
|
|
| **A2** — connected read-only | Local PS7, tenant | Delegated (`Connect-MgGraph -Scopes`) | Available now. Covers enumeration, membership, roles, normalization, presentation, reconciliation, and `-WhatIf`. |
|
|||
|
|
| **A3** — connected write | Local PS7, **test accounts only** | Delegated | Gated on V-4. Test accounts only — the baseline's read-only-during-early-development assumption still stands for the general population. |
|
|||
|
|
| **B** — Automation | Azure Automation PS7 | Managed identity | **Deferred.** Additive: a second authentication adapter, a runbook wrapper, and a schedule. |
|
|||
|
|
|
|||
|
|
**Consequences, stated plainly:**
|
|||
|
|
|
|||
|
|
1. **v1 cannot be declared complete while Stage B is deferred.** The Definition of Done requires an
|
|||
|
|
Azure Automation PowerShell 7 run to pass. Deferring it does not violate the constitution — it
|
|||
|
|
defers *completion*. The correct milestone to claim in the meantime is "Stage A complete", not
|
|||
|
|
"v1 done". Do not quietly redefine done.
|
|||
|
|
2. **`Connect-PersonaGraphManagedIdentity` will ship unexercised.** `Connect-MgGraph -Identity`
|
|||
|
|
cannot run on a workstation. The mitigation is to keep the authentication adapter's surface
|
|||
|
|
minimal — one function, returning the same handle shape as the interactive path, with no
|
|||
|
|
engine-visible difference — so that the untested code is a few lines rather than a subsystem.
|
|||
|
|
3. **Delegated authorization behaves differently from application permissions.** Effective access is
|
|||
|
|
the intersection of the requested scope and the signed-in user's directory roles. This makes V-3
|
|||
|
|
*more* meaningful when run as an ordinary user account, and meaningless when run as a Global
|
|||
|
|
Administrator. See research.md V-3.
|
|||
|
|
4. **Automation-specific risk stays open**: runtime PowerShell version, module availability, and
|
|||
|
|
sandbox behaviour are unverified until Stage B. The one-module dependency decision (OTD-004) is
|
|||
|
|
what keeps that risk small.
|
|||
|
|
|
|||
|
|
## Technical Context
|
|||
|
|
|
|||
|
|
**Language/Version**: PowerShell 7.4 locally. The Automation runtime version is unverified and
|
|||
|
|
remains so until Stage B (verification item V-5b in research.md). Avoid any construct newer than
|
|||
|
|
PS 7.2 so the eventual Automation runtime is not a constraint discovered late.
|
|||
|
|
|
|||
|
|
**Primary Dependencies**: `Microsoft.Graph.Authentication` (token acquisition and
|
|||
|
|
`Invoke-MgGraphRequest`) is the only runtime dependency. `Pester` 5.x and `PSScriptAnalyzer` are
|
|||
|
|
development/CI-only. No full Microsoft Graph SDK dependency — see OTD-004. Module availability in
|
|||
|
|
the Automation sandbox is unverified until Stage B.
|
|||
|
|
|
|||
|
|
**Storage**: JSON configuration file on disk; no database. Persona values live in the directory
|
|||
|
|
itself. Audit output is newline-delimited JSON to a file plus the Automation output stream.
|
|||
|
|
|
|||
|
|
**Testing**: Pester 5.x. Unit and rule-engine suites run fully offline against synthetic fixtures
|
|||
|
|
(SC-008); integration suites require a read-only tenant identity, satisfied in Stage A2 by a
|
|||
|
|
delegated connection; safety suites assert zero writes under `-WhatIf` (SC-004) and single-attribute
|
|||
|
|
write bodies (SC-005). The safety suites mock the write adapter, so they are fully available now and
|
|||
|
|
are **not** gated on Stage A3 or B — the zero-write guarantee is proven against the adapter contract,
|
|||
|
|
not against a tenant.
|
|||
|
|
|
|||
|
|
**Target Platform**: PowerShell 7 on a local workstation (Stages A1–A3). The Azure Automation
|
|||
|
|
PowerShell 7 runtime remains the eventual production target but is out of the current stage.
|
|||
|
|
|
|||
|
|
**Project Type**: PowerShell module plus two CLI entry-point scripts.
|
|||
|
|
|
|||
|
|
**Performance Goals**: None fixed. NFR-002 explicitly defers a hard target until representative
|
|||
|
|
tenant testing. The plan requires per-user and total duration to be recorded from the first
|
|||
|
|
connected run so a baseline exists before any target is set.
|
|||
|
|
|
|||
|
|
**Constraints**: Rule engine must be free of Graph, authentication, Automation, and console
|
|||
|
|
dependencies (Principle IV). `-WhatIf` must issue zero writes (Principle III). Write payloads carry
|
|||
|
|
exactly one attribute (Principle III). All artifacts sanitized to placeholders (Principle V,
|
|||
|
|
SC-013).
|
|||
|
|
|
|||
|
|
**Scale/Scope**: In-scope population size is tenant-specific and unknown at planning time. Full
|
|||
|
|
enumeration with pagination is the v1 processing model (OTD-008); delta processing is deferred. The
|
|||
|
|
read-only pilot establishes the population size and run duration baseline.
|
|||
|
|
|
|||
|
|
## Constitution Check
|
|||
|
|
|
|||
|
|
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.*
|
|||
|
|
|
|||
|
|
Evaluated against [constitution.md](../../.specify/memory/constitution.md) v1.0.0.
|
|||
|
|
|
|||
|
|
| Gate | Principle | Pre-research | Post-design | Notes |
|
|||
|
|
| --- | --- | --- | --- | --- |
|
|||
|
|
| Deterministic, single-persona result | I (NON-NEGOTIABLE) | PASS | PASS | Ordered priority evaluation, first-match stop, no clock/random/unordered inputs in the engine. Rejecting `extensionAttributeN` (research OTD-001) removes a population-dependent failure mode that would have broken determinism across a hybrid population. |
|
|||
|
|
| Configuration-driven rules | II | PASS | PASS | No persona, priority, group ID, role ID, or attribute name in source. Four-layer validation ordering preserved in the config contract. |
|
|||
|
|
| Fail-safe, idempotent persistence | III (NON-NEGOTIABLE) | PASS | PASS | `EvaluationError` preserves stored value; `SupportsShouldProcess` on both write paths; changed-values-only comparison; single-attribute body construction isolated in one function. |
|
|||
|
|
| Pure rule engine, offline-tested first | IV | PASS | PASS | Rule engine depends only on normalized records. Build order enforced in the task sequencing below; persistence adapter is last. |
|
|||
|
|
| Explainable, sanitized observability | V | PASS | PASS | Run ID, UPN, Account Object ID, and matched rule ID on every user event; condition-value tracing gated behind `-Debug`; placeholders only in all artifacts. |
|
|||
|
|
|
|||
|
|
**Security and least-privilege constraints**: PASS with a mandatory condition. Research OTD-003
|
|||
|
|
establishes that Microsoft Graph application permissions **cannot** be scoped to an individual user
|
|||
|
|
attribute for the selected mechanism. The constitution anticipates exactly this outcome and makes
|
|||
|
|
the compensating controls mandatory rather than optional; they are carried into the design as
|
|||
|
|
testable requirements (see the persistence contract). This is a documented and approved-by-design
|
|||
|
|
condition, not a constitution violation. Security approval of the compensating controls is a gate
|
|||
|
|
before enforcement, per the Development Workflow section.
|
|||
|
|
|
|||
|
|
**Automation deferral (Stage B)**: PASS. Every principle is satisfiable on a local workstation —
|
|||
|
|
determinism, configuration-driven rules, fail-safe persistence, engine purity, and observability are
|
|||
|
|
all properties of the code, not of the hosting environment. Two constitution items are *deferred, not
|
|||
|
|
waived*: the Definition of Done's Azure Automation PowerShell 7 run, and the release-pipeline stages
|
|||
|
|
that deploy to it. Both are recorded in the Delivery Staging table and gate the v1 completion claim.
|
|||
|
|
|
|||
|
|
**Result**: no unjustified violations. Complexity Tracking is empty.
|
|||
|
|
|
|||
|
|
## Project Structure
|
|||
|
|
|
|||
|
|
### Documentation (this feature)
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
specs/001-persona-engine/
|
|||
|
|
├── plan.md # This file
|
|||
|
|
├── research.md # Phase 0 output — OTD-001..010 decisions
|
|||
|
|
├── data-model.md # Phase 1 output — entity contracts
|
|||
|
|
├── quickstart.md # Phase 1 output — validation scenarios
|
|||
|
|
├── contracts/ # Phase 1 output
|
|||
|
|
│ ├── persona-engine.schema.json # Configuration JSON Schema (draft-07)
|
|||
|
|
│ ├── cli-invoke-persona-engine.md # Engine CLI contract
|
|||
|
|
│ ├── cli-edit-persona-engine-config.md # Editor CLI contract
|
|||
|
|
│ ├── graph-data-provider.md # Directory read/write contract
|
|||
|
|
│ └── audit-record.md # Structured log record contracts
|
|||
|
|
└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Source Code (repository root)
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
PersonaEngine.psd1 # Module manifest
|
|||
|
|
PersonaEngine.psm1 # Module loader
|
|||
|
|
Invoke-PersonaEngine.ps1 # Engine entry point (CmdletBinding, SupportsShouldProcess)
|
|||
|
|
Edit-PersonaEngineConfig.ps1 # Configuration validation / editor entry point
|
|||
|
|
|
|||
|
|
config/
|
|||
|
|
├── persona-engine.example.json # Placeholder-only example
|
|||
|
|
└── persona-engine.schema.json # Shipped schema (from contracts/)
|
|||
|
|
|
|||
|
|
src/
|
|||
|
|
├── Configuration/ # Import-PersonaConfiguration, Test-PersonaConfiguration, Resolve-TargetAttribute
|
|||
|
|
├── Authentication/ # Connect-PersonaGraphInteractive, Connect-PersonaGraphManagedIdentity
|
|||
|
|
├── DataProviders/ # Get-PersonaUsers, Get-PersonaGroupMembership, Get-PersonaDirectoryRoles
|
|||
|
|
├── Normalization/ # ConvertTo-PersonaUserRecord, ConvertTo-PersonaMembershipRecord
|
|||
|
|
├── RuleEngine/ # Test-PersonaCondition, Test-PersonaConditionGroup, Test-PersonaRule,
|
|||
|
|
│ # Resolve-UserPersona <-- no Graph/auth/console dependency
|
|||
|
|
├── Persistence/ # Compare-PersonaValue, New-PersonaWriteBody, Set-UserPersonaAttribute
|
|||
|
|
├── Presentation/ # Write-UserPersonaResult, Write-PersonaSummary
|
|||
|
|
└── Audit/ # New-PersonaAuditRecord, Export-PersonaRunReport
|
|||
|
|
|
|||
|
|
tests/
|
|||
|
|
├── Unit/ # Per-function offline tests
|
|||
|
|
├── RuleEngine/ # Rule evaluation matrix against synthetic fixtures
|
|||
|
|
├── Configuration/ # Schema, semantic (VR-002), and safety (VR-003) validation
|
|||
|
|
├── Integration/ # Read-only tenant tests
|
|||
|
|
├── Safety/ # SC-004 zero-write, SC-005 single-attribute-body assertions
|
|||
|
|
└── TestData/ # Obviously fictional synthetic users, memberships, configs
|
|||
|
|
|
|||
|
|
docs/ # Architecture, BusinessRules, ConfigurationReference, Logging,
|
|||
|
|
# SecurityModel, OperationsRunbook
|
|||
|
|
pipelines/ # validate.yml, test.yml, release.yml
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Structure Decision**: single PowerShell module with two CLI entry points, matching the layout
|
|||
|
|
already published in [README.md](../../README.md). The directory split is the enforcement mechanism
|
|||
|
|
for Principle IV — `src/RuleEngine/` may import nothing from `src/Authentication/`,
|
|||
|
|
`src/DataProviders/`, `src/Persistence/`, or `src/Presentation/`, and a CI check asserts this.
|
|||
|
|
|
|||
|
|
### Build order (Principle IV, non-negotiable sequencing)
|
|||
|
|
|
|||
|
|
1. Normalized record contracts and synthetic fixtures.
|
|||
|
|
2. Pure rule engine + offline Pester suite (no tenant connectivity).
|
|||
|
|
3. Configuration import, four-layer validation, and non-interactive pipeline mode.
|
|||
|
|
4. `Edit-PersonaEngineConfig.ps1` interactive editor and synthetic rule testing.
|
|||
|
|
5. Graph authentication and **read** adapters; normalization wiring.
|
|||
|
|
6. Presentation, summaries, reconciliation, and structured audit output.
|
|||
|
|
7. Persistence adapter **last**, with `ShouldProcess` and the zero-write/single-attribute suites.
|
|||
|
|
|
|||
|
|
Steps 1–4 are Stage A1 (offline). Steps 5–6 are Stage A2 (delegated read-only). Step 7 is built and
|
|||
|
|
fully unit-tested in Stage A1/A2 against a mocked adapter, and only *exercised against the tenant* in
|
|||
|
|
Stage A3, behind V-4. Adding the managed-identity adapter and runbook wrapper is Stage B and touches
|
|||
|
|
nothing in steps 1–7 — that is the test of whether the boundaries were drawn correctly.
|
|||
|
|
|
|||
|
|
## Complexity Tracking
|
|||
|
|
|
|||
|
|
> No Constitution Check violations. This section is intentionally empty.
|