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

13 KiB
Raw Permalink Blame History

Implementation Plan: Persona Engine

Branch: main (feature directory 001-persona-engine) | Date: 2026-08-20 | Spec: 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 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 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)

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)

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