Files
dave cdc6bb33d3 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>
2026-08-20 21:48:19 -04:00

203 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 A1A3). 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 14 are Stage A1 (offline). Steps 56 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 17 — that is the test of whether the boundaries were drawn correctly.
## Complexity Tracking
> No Constitution Check violations. This section is intentionally empty.