Compare commits
12 Commits
cfdcfcd873
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 80ef1303ea | |||
| d1b0ac992e | |||
| 5f125c34f2 | |||
| 8c8fd47f74 | |||
| 8c2dc842e6 | |||
| a54ff3c8f2 | |||
| 01d08e635c | |||
| c30ef6ec24 | |||
| aeedb7170a | |||
| cdc6bb33d3 | |||
| c59c85dd55 | |||
| db6ad1f3e8 |
@@ -0,0 +1,262 @@
|
||||
---
|
||||
name: "speckit-analyze"
|
||||
description: "Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation."
|
||||
argument-hint: "Optional focus areas for analysis"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/analyze.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before analysis)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_analyze` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Goal.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Goal
|
||||
|
||||
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit-tasks` has successfully produced a complete `tasks.md`.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
|
||||
|
||||
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit-analyze`.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Initialize Analysis Context
|
||||
|
||||
Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
|
||||
|
||||
- SPEC = FEATURE_DIR/spec.md
|
||||
- PLAN = FEATURE_DIR/plan.md
|
||||
- TASKS = FEATURE_DIR/tasks.md
|
||||
|
||||
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
|
||||
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
### 2. Load Artifacts (Progressive Disclosure)
|
||||
|
||||
Load only the minimal necessary context from each artifact:
|
||||
|
||||
**From spec.md:**
|
||||
|
||||
- Overview/Context
|
||||
- Functional Requirements
|
||||
- Success Criteria (measurable outcomes — e.g., performance, security, availability, user success, business impact)
|
||||
- User Stories
|
||||
- Edge Cases (if present)
|
||||
|
||||
**From plan.md:**
|
||||
|
||||
- Architecture/stack choices
|
||||
- Data Model references
|
||||
- Phases
|
||||
- Technical constraints
|
||||
|
||||
**From tasks.md:**
|
||||
|
||||
- Task IDs
|
||||
- Descriptions
|
||||
- Phase grouping
|
||||
- Parallel markers [P]
|
||||
- Referenced file paths
|
||||
|
||||
**From constitution:**
|
||||
|
||||
- Load `.specify/memory/constitution.md` for principle validation
|
||||
|
||||
### 3. Build Semantic Models
|
||||
|
||||
Create internal representations (do not include raw artifacts in output):
|
||||
|
||||
- **Requirements inventory**: For each Functional Requirement (FR-###) and Success Criterion (SC-###), record a stable key. Use the explicit FR-/SC- identifier as the primary key when present, and optionally also derive an imperative-phrase slug for readability (e.g., "User can upload file" → `user-can-upload-file`). Include only Success Criteria items that require buildable work (e.g., load-testing infrastructure, security audit tooling), and exclude post-launch outcome metrics and business KPIs (e.g., "Reduce support tickets by 50%").
|
||||
- **User story/action inventory**: Discrete user actions with acceptance criteria
|
||||
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
|
||||
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
|
||||
|
||||
### 4. Detection Passes (Token-Efficient Analysis)
|
||||
|
||||
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
|
||||
|
||||
#### A. Duplication Detection
|
||||
|
||||
- Identify near-duplicate requirements
|
||||
- Mark lower-quality phrasing for consolidation
|
||||
|
||||
#### B. Ambiguity Detection
|
||||
|
||||
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
|
||||
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
|
||||
|
||||
#### C. Underspecification
|
||||
|
||||
- Requirements with verbs but missing object or measurable outcome
|
||||
- User stories missing acceptance criteria alignment
|
||||
- Tasks referencing files or components not defined in spec/plan
|
||||
|
||||
#### D. Constitution Alignment
|
||||
|
||||
- Any requirement or plan element conflicting with a MUST principle
|
||||
- Missing mandated sections or quality gates from constitution
|
||||
|
||||
#### E. Coverage Gaps
|
||||
|
||||
- Requirements with zero associated tasks
|
||||
- Tasks with no mapped requirement/story
|
||||
- Success Criteria requiring buildable work (performance, security, availability) not reflected in tasks
|
||||
|
||||
#### F. Inconsistency
|
||||
|
||||
- Terminology drift (same concept named differently across files)
|
||||
- Data entities referenced in plan but absent in spec (or vice versa)
|
||||
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
|
||||
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
|
||||
|
||||
### 5. Severity Assignment
|
||||
|
||||
Use this heuristic to prioritize findings:
|
||||
|
||||
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
|
||||
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
|
||||
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
|
||||
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
|
||||
|
||||
### 6. Produce Compact Analysis Report
|
||||
|
||||
Output a Markdown report (no file writes) with the following structure:
|
||||
|
||||
## Specification Analysis Report
|
||||
|
||||
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|
||||
|----|----------|----------|-------------|---------|----------------|
|
||||
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
|
||||
|
||||
(Add one row per finding; generate stable IDs prefixed by category initial.)
|
||||
|
||||
**Coverage Summary Table:**
|
||||
|
||||
| Requirement Key | Has Task? | Task IDs | Notes |
|
||||
|-----------------|-----------|----------|-------|
|
||||
|
||||
**Constitution Alignment Issues:** (if any)
|
||||
|
||||
**Unmapped Tasks:** (if any)
|
||||
|
||||
**Metrics:**
|
||||
|
||||
- Total Requirements
|
||||
- Total Tasks
|
||||
- Coverage % (requirements with >=1 task)
|
||||
- Ambiguity Count
|
||||
- Duplication Count
|
||||
- Critical Issues Count
|
||||
|
||||
### 7. Provide Next Actions
|
||||
|
||||
At end of report, output a concise Next Actions block:
|
||||
|
||||
- If CRITICAL issues exist: Recommend resolving before `/speckit-implement`
|
||||
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
||||
- Provide explicit command suggestions: e.g., "Run /speckit-specify with refinement", "Run /speckit-plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
|
||||
|
||||
### 8. Offer Remediation
|
||||
|
||||
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
|
||||
|
||||
### 9. Check for extension hooks
|
||||
|
||||
After reporting, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_analyze` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Operating Principles
|
||||
|
||||
### Context Efficiency
|
||||
|
||||
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
|
||||
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
|
||||
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
|
||||
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
|
||||
|
||||
### Analysis Guidelines
|
||||
|
||||
- **NEVER modify files** (this is read-only analysis)
|
||||
- **NEVER hallucinate missing sections** (if absent, report them accurately)
|
||||
- **Prioritize constitution violations** (these are always CRITICAL)
|
||||
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
|
||||
- **Report zero issues gracefully** (emit success report with coverage statistics)
|
||||
|
||||
## Context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -0,0 +1,386 @@
|
||||
---
|
||||
name: "speckit-checklist"
|
||||
description: "Generate a custom checklist for the current feature based on user requirements."
|
||||
argument-hint: "Domain or focus area for the checklist"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/checklist.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## Checklist Purpose: "Unit Tests for English"
|
||||
|
||||
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
|
||||
|
||||
**NOT for verification/testing**:
|
||||
|
||||
- ❌ NOT "Verify the button clicks correctly"
|
||||
- ❌ NOT "Test error handling works"
|
||||
- ❌ NOT "Confirm the API returns 200"
|
||||
- ❌ NOT checking if code/implementation matches the spec
|
||||
|
||||
**FOR requirements quality validation**:
|
||||
|
||||
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
|
||||
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
|
||||
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
|
||||
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
|
||||
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
|
||||
|
||||
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
|
||||
|
||||
**Ownership and checkbox lifecycle**:
|
||||
|
||||
- Custom checklists generated by this command are reviewer-owned requirements-quality review artifacts.
|
||||
- `[x]` means the reviewer determined the requirements-quality criterion is satisfied.
|
||||
- `[x]` does NOT mean implementation work is complete.
|
||||
- This command generates or appends checklist items; it MUST NOT mark generated items `[x]`.
|
||||
- An agent may assist with evaluating items only when explicitly asked by the reviewer.
|
||||
- `checklists/requirements.md` is a separate built-in spec-quality checklist maintained by `/speckit-specify` and `/speckit-clarify`; do not treat that exception as applying to custom checklists generated here.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before checklist generation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_checklist` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Execution Steps.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -Template checklist-template` from repo root and parse JSON for FEATURE_DIR, AVAILABLE_DOCS list, and TEMPLATE_CONTENT.
|
||||
- All file paths must be absolute.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
3. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
|
||||
- Only ask about information that materially changes checklist content
|
||||
- Be skipped individually if already unambiguous in `$ARGUMENTS`
|
||||
- Prefer precision over breadth
|
||||
|
||||
Generation algorithm:
|
||||
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
|
||||
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
|
||||
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
|
||||
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
|
||||
5. Formulate questions chosen from these archetypes:
|
||||
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
|
||||
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
|
||||
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
|
||||
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
|
||||
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
|
||||
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
|
||||
|
||||
Question formatting rules:
|
||||
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
|
||||
- Limit to A–E options maximum; omit table if a free-form answer is clearer
|
||||
- Never ask the user to restate what they already said
|
||||
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
|
||||
|
||||
Defaults when interaction impossible:
|
||||
- Depth: Standard
|
||||
- Audience: Reviewer (PR) if code-related; Author otherwise
|
||||
- Focus: Top 2 relevance clusters
|
||||
|
||||
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
|
||||
|
||||
4. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
- Derive checklist theme (e.g., security, review, deploy, ux)
|
||||
- Consolidate explicit must-have items mentioned by user
|
||||
- Map focus selections to category scaffolding
|
||||
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
|
||||
|
||||
5. **Load feature context**: Read from FEATURE_DIR:
|
||||
- spec.md: Feature requirements and scope
|
||||
- plan.md (if exists): Technical details, dependencies
|
||||
- tasks.md (if exists): Implementation tasks
|
||||
|
||||
**Context Loading Strategy**:
|
||||
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
|
||||
- Prefer summarizing long sections into concise scenario/requirement bullets
|
||||
- Use progressive disclosure: add follow-on retrieval only if gaps detected
|
||||
- If source docs are large, generate interim summary items instead of embedding raw text
|
||||
|
||||
6. **Generate checklist** - Use TEMPLATE_CONTENT as the structural template and create "Unit Tests for Requirements":
|
||||
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
|
||||
- Generate unique checklist filename:
|
||||
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
|
||||
- Format: `[domain].md`
|
||||
- File handling behavior:
|
||||
- If file does NOT exist: Create new file and number items starting from CHK001
|
||||
- If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016)
|
||||
- Never delete or replace existing checklist content - always preserve and append
|
||||
- Leave every newly generated item unchecked (`[ ]`); checkbox state belongs to the reviewer
|
||||
|
||||
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
|
||||
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
|
||||
- **Completeness**: Are all necessary requirements present?
|
||||
- **Clarity**: Are requirements unambiguous and specific?
|
||||
- **Consistency**: Do requirements align with each other?
|
||||
- **Measurability**: Can requirements be objectively verified?
|
||||
- **Coverage**: Are all scenarios/edge cases addressed?
|
||||
|
||||
**Category Structure** - Group items by requirement quality dimensions:
|
||||
- **Requirement Completeness** (Are all necessary requirements documented?)
|
||||
- **Requirement Clarity** (Are requirements specific and unambiguous?)
|
||||
- **Requirement Consistency** (Do requirements align without conflicts?)
|
||||
- **Acceptance Criteria Quality** (Are success criteria measurable?)
|
||||
- **Scenario Coverage** (Are all flows/cases addressed?)
|
||||
- **Edge Case Coverage** (Are boundary conditions defined?)
|
||||
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
|
||||
- **Dependencies & Assumptions** (Are they documented and validated?)
|
||||
- **Ambiguities & Conflicts** (What needs clarification?)
|
||||
|
||||
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
|
||||
|
||||
❌ **WRONG** (Testing implementation):
|
||||
- "Verify landing page displays 3 episode cards"
|
||||
- "Test hover states work on desktop"
|
||||
- "Confirm logo click navigates home"
|
||||
|
||||
✅ **CORRECT** (Testing requirements quality):
|
||||
- "Are the exact number and layout of featured episodes specified?" [Completeness]
|
||||
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
|
||||
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
|
||||
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
|
||||
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
|
||||
- "Are loading states defined for asynchronous episode data?" [Completeness]
|
||||
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
|
||||
|
||||
**ITEM STRUCTURE**:
|
||||
Each item should follow this pattern:
|
||||
- Question format asking about requirement quality
|
||||
- Focus on what's WRITTEN (or not written) in the spec/plan
|
||||
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
|
||||
- Reference spec section `[Spec §X.Y]` when checking existing requirements
|
||||
- Use `[Gap]` marker when checking for missing requirements
|
||||
|
||||
**EXAMPLES BY QUALITY DIMENSION**:
|
||||
|
||||
Completeness:
|
||||
- "Are error handling requirements defined for all API failure modes? [Gap]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
|
||||
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
|
||||
|
||||
Clarity:
|
||||
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
|
||||
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
|
||||
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
|
||||
|
||||
Consistency:
|
||||
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
|
||||
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
|
||||
|
||||
Coverage:
|
||||
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
|
||||
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
|
||||
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
|
||||
|
||||
Measurability:
|
||||
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
|
||||
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
|
||||
|
||||
**Scenario Classification & Coverage** (Requirements Quality Focus):
|
||||
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
|
||||
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
|
||||
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
|
||||
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
|
||||
|
||||
**Traceability Requirements**:
|
||||
- MINIMUM: ≥80% of items MUST include at least one traceability reference
|
||||
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
|
||||
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
|
||||
|
||||
**Surface & Resolve Issues** (Requirements Quality Problems):
|
||||
Ask questions about the requirements themselves:
|
||||
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
|
||||
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
|
||||
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
|
||||
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
|
||||
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
|
||||
|
||||
**Content Consolidation**:
|
||||
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
|
||||
- Merge near-duplicates checking the same requirement aspect
|
||||
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
|
||||
|
||||
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
|
||||
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
|
||||
- ❌ References to code execution, user actions, system behavior
|
||||
- ❌ "Displays correctly", "works properly", "functions as expected"
|
||||
- ❌ "Click", "navigate", "render", "load", "execute"
|
||||
- ❌ Test cases, test plans, QA procedures
|
||||
- ❌ Implementation details (frameworks, APIs, algorithms)
|
||||
|
||||
**✅ REQUIRED PATTERNS** - These test requirements quality:
|
||||
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
|
||||
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
|
||||
- ✅ "Are requirements consistent between [section A] and [section B]?"
|
||||
- ✅ "Can [requirement] be objectively measured/verified?"
|
||||
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||
- ✅ "Does the spec define [missing aspect]?"
|
||||
|
||||
7. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, ownership note, notes section, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, an ownership note explaining that `[x]` means reviewer approval of requirements quality, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001, and notes that `/speckit-implement` reads checklist state but does not modify markers.
|
||||
|
||||
8. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
|
||||
- Focus areas selected
|
||||
- Depth level
|
||||
- Actor/timing
|
||||
- Any explicit user-specified must-have items incorporated
|
||||
|
||||
**Important**: Each `/speckit-checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
|
||||
|
||||
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
||||
- Simple, memorable filenames that indicate checklist purpose
|
||||
- Easy identification and navigation in the `checklists/` folder
|
||||
|
||||
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
|
||||
|
||||
## Example Checklist Types & Sample Items
|
||||
|
||||
**UX Requirements Quality:** `ux.md`
|
||||
|
||||
Sample items (testing the requirements, NOT the implementation):
|
||||
|
||||
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
|
||||
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
|
||||
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
|
||||
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
|
||||
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
|
||||
|
||||
**API Requirements Quality:** `api.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are error response formats specified for all failure scenarios? [Completeness]"
|
||||
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
|
||||
- "Are authentication requirements consistent across all endpoints? [Consistency]"
|
||||
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
|
||||
- "Is versioning strategy documented in requirements? [Gap]"
|
||||
|
||||
**Performance Requirements Quality:** `performance.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are performance requirements quantified with specific metrics? [Clarity]"
|
||||
- "Are performance targets defined for all critical user journeys? [Coverage]"
|
||||
- "Are performance requirements under different load conditions specified? [Completeness]"
|
||||
- "Can performance requirements be objectively measured? [Measurability]"
|
||||
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
|
||||
|
||||
**Security Requirements Quality:** `security.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are authentication requirements specified for all protected resources? [Coverage]"
|
||||
- "Are data protection requirements defined for sensitive information? [Completeness]"
|
||||
- "Is the threat model documented and requirements aligned to it? [Traceability]"
|
||||
- "Are security requirements consistent with compliance obligations? [Consistency]"
|
||||
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
|
||||
|
||||
## Anti-Examples: What NOT To Do
|
||||
|
||||
**❌ WRONG - These test implementation, not requirements:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
|
||||
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
|
||||
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
|
||||
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
|
||||
```
|
||||
|
||||
**✅ CORRECT - These test requirements quality:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
|
||||
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
|
||||
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
|
||||
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
|
||||
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
|
||||
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
|
||||
```
|
||||
|
||||
**Key Differences:**
|
||||
|
||||
- Wrong: Tests if the system works correctly
|
||||
- Correct: Tests if the requirements are written correctly
|
||||
- Wrong: Verification of behavior
|
||||
- Correct: Validation of requirement quality
|
||||
- Wrong: "Does it do X?"
|
||||
- Correct: "Is X clearly specified?"
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after checklist generation)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_checklist` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
@@ -0,0 +1,294 @@
|
||||
---
|
||||
name: "speckit-clarify"
|
||||
description: "Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec."
|
||||
argument-hint: "Optional areas to clarify in the spec"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/clarify.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before clarification)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_clarify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
|
||||
|
||||
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit-plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
|
||||
|
||||
Execution steps:
|
||||
|
||||
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
|
||||
- `FEATURE_DIR`
|
||||
- `FEATURE_SPEC`
|
||||
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
||||
- If JSON parsing fails, abort and instruct user to re-run `/speckit-specify` or verify feature branch environment.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
3. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
|
||||
Functional Scope & Behavior:
|
||||
- Core user goals & success criteria
|
||||
- Explicit out-of-scope declarations
|
||||
- User roles / personas differentiation
|
||||
|
||||
Domain & Data Model:
|
||||
- Entities, attributes, relationships
|
||||
- Identity & uniqueness rules
|
||||
- Lifecycle/state transitions
|
||||
- Data volume / scale assumptions
|
||||
|
||||
Interaction & UX Flow:
|
||||
- Critical user journeys / sequences
|
||||
- Error/empty/loading states
|
||||
- Accessibility or localization notes
|
||||
|
||||
Non-Functional Quality Attributes:
|
||||
- Performance (latency, throughput targets)
|
||||
- Scalability (horizontal/vertical, limits)
|
||||
- Reliability & availability (uptime, recovery expectations)
|
||||
- Observability (logging, metrics, tracing signals)
|
||||
- Security & privacy (authN/Z, data protection, threat assumptions)
|
||||
- Compliance / regulatory constraints (if any)
|
||||
|
||||
Integration & External Dependencies:
|
||||
- External services/APIs and failure modes
|
||||
- Data import/export formats
|
||||
- Protocol/versioning assumptions
|
||||
|
||||
Edge Cases & Failure Handling:
|
||||
- Negative scenarios
|
||||
- Rate limiting / throttling
|
||||
- Conflict resolution (e.g., concurrent edits)
|
||||
|
||||
Constraints & Tradeoffs:
|
||||
- Technical constraints (language, storage, hosting)
|
||||
- Explicit tradeoffs or rejected alternatives
|
||||
|
||||
Terminology & Consistency:
|
||||
- Canonical glossary terms
|
||||
- Avoided synonyms / deprecated terms
|
||||
|
||||
Completion Signals:
|
||||
- Acceptance criteria testability
|
||||
- Measurable Definition of Done style indicators
|
||||
|
||||
Misc / Placeholders:
|
||||
- TODO markers / unresolved decisions
|
||||
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
|
||||
|
||||
For each category with Partial or Missing status, add a candidate question opportunity unless:
|
||||
- Clarification would not materially change implementation or validation strategy
|
||||
- Information is better deferred to planning phase (note internally)
|
||||
|
||||
4. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
- Maximum of 5 total questions across the whole session.
|
||||
- Each question must be answerable with EITHER:
|
||||
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
|
||||
- A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words").
|
||||
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
|
||||
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
|
||||
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
|
||||
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
|
||||
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
|
||||
|
||||
5. Sequential questioning loop (interactive):
|
||||
- Present EXACTLY ONE question at a time.
|
||||
- **Question writing quality (applies to every question, MC or short-answer):**
|
||||
- Lead with `**Question:**` followed by a full interrogative that ends with `?`. The question text before the `?` must make sense on its own.
|
||||
- NEVER use a topic label, section heading, or requirement id as the question itself. For example, `Acceptance device/runtime matrix (FR-023)` is INVALID — it is a label, not a question.
|
||||
- After the `?`, the only permitted suffix is an optional parenthesized requirement/question id. Exact format: `**Question:** <interrogative>?` or `**Question:** <interrogative>? (FR-023)`. Never put the id before the `?`, and never use the id (alone or with a topic label) as the whole prompt.
|
||||
- Immediately after the question line, add one plain-language "Why it matters" sentence (the stake for acceptance or shipping) before the recommendation/options.
|
||||
- Use everyday wording; introduce jargon only if defined in the same sentence. Self-check: a reader who does not know Spec Kit must be able to answer from the Question line alone. Terse is fine; cryptic labels are not.
|
||||
- For multiple‑choice questions:
|
||||
- **Analyze all options** and determine the **most suitable option** based on:
|
||||
- Best practices for the project type
|
||||
- Common patterns in similar implementations
|
||||
- Risk reduction (security, performance, maintainability)
|
||||
- Alignment with any explicit project goals or constraints visible in the spec
|
||||
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
|
||||
- Format as: `**Recommended:** Option [X] - <reasoning>`
|
||||
- Then render all options as a Markdown table:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| A | <Option A description> |
|
||||
| B | <Option B description> |
|
||||
| C | <Option C description> (add D/E as needed up to 5) |
|
||||
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
|
||||
|
||||
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
|
||||
- For short‑answer style (no meaningful discrete options):
|
||||
- Provide your **suggested answer** based on best practices and context.
|
||||
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
|
||||
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
|
||||
- After the user answers:
|
||||
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
|
||||
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
|
||||
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
|
||||
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
|
||||
- Stop asking further questions when:
|
||||
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
|
||||
- User signals completion ("done", "good", "no more"), OR
|
||||
- You reach 5 asked questions.
|
||||
- Never reveal future queued questions in advance.
|
||||
- If no valid questions exist at start, immediately report no critical ambiguities.
|
||||
|
||||
6. Integration after EACH accepted answer (incremental update approach):
|
||||
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
|
||||
- For the first integrated answer in this session:
|
||||
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
|
||||
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
|
||||
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
|
||||
- Then immediately apply the clarification to the most appropriate section(s):
|
||||
- Functional ambiguity → Update or add a bullet in Functional Requirements.
|
||||
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
|
||||
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
|
||||
- Non-functional constraint → Add/modify measurable criteria in Success Criteria > Measurable Outcomes (convert vague adjective to metric or explicit target).
|
||||
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
|
||||
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
|
||||
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
|
||||
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
|
||||
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
|
||||
- Keep each inserted clarification minimal and testable (avoid narrative drift).
|
||||
|
||||
7. Validation (performed after EACH write plus final pass):
|
||||
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
|
||||
- Total asked (accepted) questions ≤ 5.
|
||||
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
|
||||
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
|
||||
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
|
||||
- Terminology consistency: same canonical term used across all updated sections.
|
||||
|
||||
8. Write the updated spec back to `FEATURE_SPEC`.
|
||||
|
||||
9. **Re-validate Spec Quality Checklist** (if it exists):
|
||||
- Check if `FEATURE_DIR/checklists/requirements.md` exists.
|
||||
- If it does NOT exist, skip this step silently.
|
||||
- If it exists:
|
||||
1. Read the checklist file.
|
||||
2. Identify all GitHub task-list checkbox lines — lines matching `- [ ]`, `- [x]`, or `- [X]` (case-insensitive, tolerant of leading whitespace for nested items) outside of code fences. Ignore all other content (headings, notes, non-checkbox bullets, metadata).
|
||||
3. For each checkbox line, record its current marker state (checked or unchecked) and item text into a before-snapshot list.
|
||||
4. Re-evaluate each checkbox item against the **updated** spec (the version just saved in step 7).
|
||||
5. For each checkbox item, update only if the checked/unchecked state actually changes:
|
||||
- If the item now passes and was unchecked: change `[ ]` to `[x]`.
|
||||
- If the item now fails and was checked: change `[x]`/`[X]` to `[ ]`.
|
||||
- If the state is unchanged: leave the marker as-is (preserve existing case to avoid cosmetic diffs).
|
||||
6. Save the updated checklist file. **Only toggle the `[ ]`/`[x]` marker portion of checkbox lines whose state changed.** All other file content — headings, metadata, notes, line ordering, whitespace — must remain unchanged to avoid noisy diffs.
|
||||
7. Compare the before-snapshot with the current state to compute three lists for the Completion Report:
|
||||
- **Newly passing**: items that changed from unchecked to checked.
|
||||
- **Regressions**: items that changed from checked to unchecked.
|
||||
- **Still unchecked**: items that remain unchecked.
|
||||
8. Record the before/after pass counts as checked/total checkbox items (e.g., "12/16 → 15/16 items passing").
|
||||
|
||||
Behavior rules:
|
||||
|
||||
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
|
||||
- If spec file missing, instruct user to run `/speckit-specify` first (do not create a new spec here).
|
||||
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
|
||||
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
|
||||
- Respect user early termination signals ("stop", "done", "proceed").
|
||||
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
|
||||
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
|
||||
|
||||
Context for prioritization: $ARGUMENTS
|
||||
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_clarify`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_clarify` key.
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Report completion (after questioning loop ends or early termination):
|
||||
- Number of questions asked & answered.
|
||||
- Path to updated spec.
|
||||
- Sections touched (list names).
|
||||
- Spec quality checklist status (if `FEATURE_DIR/checklists/requirements.md` was re-validated): show before/after pass counts (e.g., "Spec Quality Checklist: 12/16 → 15/16 items passing") and list any items that changed state — both newly checked (unchecked → checked) and any regressions (checked → unchecked). If any items remain unchecked, list them as areas needing attention.
|
||||
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
|
||||
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit-plan` or run `/speckit-clarify` again later post-plan.
|
||||
- Suggested next command.
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Spec ambiguities identified and clarifications integrated into spec file
|
||||
- [ ] Spec quality checklist re-validated against updated spec (if `FEATURE_DIR/checklists/requirements.md` exists)
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with questions answered, sections touched, checklist status, and coverage summary
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
name: "speckit-constitution"
|
||||
description: "Create or update the project constitution from interactive or provided principle inputs."
|
||||
argument-hint: "Principles or values for the project constitution"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/constitution.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Scope Guard
|
||||
|
||||
This command's own work is limited to updating the project constitution itself. Dependent templates
|
||||
and commands read the constitution at runtime and are not modified here.
|
||||
|
||||
- Classify every part of the user input as either constitution content or a separate,
|
||||
non-governance intent.
|
||||
- If the input includes feature implementation, code generation, refactoring, building, or
|
||||
deployment requests, you **MUST NOT** execute them. Extract them as deferred intents instead.
|
||||
- You **MUST NOT** create, modify, or delete application source files, feature routes,
|
||||
components, tests, deployment files, or other artifacts unrelated to the constitution
|
||||
workflow.
|
||||
- If it is unclear whether an instruction is constitution content, ask for clarification before
|
||||
making changes.
|
||||
- After completing the constitution update, include a `Next Actions` section for each deferred
|
||||
intent. List the original intent and suggest the appropriate follow-up Spec Kit command, such
|
||||
as `/speckit-specify`, without invoking it.
|
||||
- If there are no non-governance intents, omit the `Next Actions` section.
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before constitution update)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_constitution` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
You are updating the project constitution at `.specify/memory/constitution.md`. The active
|
||||
constitution scaffold is resolved at command time from `constitution-template` through the Spec Kit
|
||||
preset/template resolution stack.
|
||||
|
||||
Follow this execution flow:
|
||||
|
||||
1. Run `.specify/scripts/powershell/resolve-template.ps1 constitution-template -Json` from the repository root and parse `TEMPLATE_CONTENT` as the active template.
|
||||
- The shared resolver applies project overrides, composing preset layers, and extension layers
|
||||
before the core template fallback. It MUST succeed before continuing.
|
||||
- If it fails, stop and report the resolution error; do not continue with only one contributing
|
||||
template layer.
|
||||
- If `.specify/memory/constitution.md` exists, load it as the source of current project-specific
|
||||
values and amendments. Preserve information that is still applicable when applying the newly
|
||||
resolved scaffold.
|
||||
- If it does not exist, use the resolved template as the initial document.
|
||||
- Do not write back to any versioned template layer.
|
||||
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
|
||||
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
|
||||
|
||||
2. Collect/derive values for placeholders:
|
||||
- If user input (conversation) supplies a value, use it.
|
||||
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
|
||||
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
|
||||
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
|
||||
- MAJOR: Backward incompatible governance/principle removals or redefinitions.
|
||||
- MINOR: New principle/section added or materially expanded guidance.
|
||||
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
|
||||
- If version bump type ambiguous, propose reasoning before finalizing.
|
||||
|
||||
3. Draft the updated constitution content using the resolved template as the required structure:
|
||||
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
|
||||
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
|
||||
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious.
|
||||
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
|
||||
|
||||
4. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
|
||||
- Version change: old → new
|
||||
- List of modified principles (old title → new title if renamed)
|
||||
- Added sections
|
||||
- Removed sections
|
||||
- Follow-up TODOs if any placeholders intentionally deferred.
|
||||
|
||||
5. Validation before final output:
|
||||
- No remaining unexplained bracket tokens.
|
||||
- Version line matches report.
|
||||
- Dates ISO format YYYY-MM-DD.
|
||||
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
|
||||
|
||||
6. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
|
||||
|
||||
7. Output a final summary to the user with:
|
||||
- New version and bump rationale.
|
||||
- Any TODO placeholders or deferred items requiring manual follow-up.
|
||||
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
|
||||
- A `Next Actions` section for any deferred non-governance intents.
|
||||
|
||||
Formatting & Style Requirements:
|
||||
|
||||
- Use Markdown headings exactly as in the template (do not demote/promote levels).
|
||||
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
|
||||
- Keep a single blank line between sections.
|
||||
- Avoid trailing whitespace.
|
||||
|
||||
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
|
||||
|
||||
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
|
||||
|
||||
Write only `.specify/memory/constitution.md`; do not create or modify template source files.
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after constitution update)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_constitution` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
@@ -0,0 +1,279 @@
|
||||
---
|
||||
name: "speckit-converge"
|
||||
description: "Assess the current codebase against the feature's spec, plan, and tasks, then append any remaining unbuilt work as new tasks to tasks.md so implement can complete it."
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/converge.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before convergence)**:
|
||||
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_converge` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
|
||||
```text
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
|
||||
```text
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Goal.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Goal
|
||||
|
||||
Close the gap between what a feature's specification, plan, and tasks call for and what the
|
||||
codebase currently implements. Read `spec.md`, `plan.md`, and `tasks.md` as the **sole
|
||||
source of intent** (with the constitution as governing constraints), assess the current
|
||||
state of the code, determine which requirements, acceptance criteria, plan decisions, and
|
||||
existing tasks are unmet, incomplete, or only partially satisfied, and **append each piece
|
||||
of remaining work as a new, traceable task** at the bottom of `tasks.md` so that
|
||||
`/speckit-implement` can complete it. This command MUST run only after
|
||||
`/speckit-implement` has run on the current `tasks.md`, and after `/speckit-tasks` has produced a complete `tasks.md`.
|
||||
|
||||
This is **not** a diff tool and does **not** track changes. It assesses the present state
|
||||
of the code relative to the feature's artifacts — no git, no branch comparison, no history.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
**APPEND-ONLY, NEVER REWRITE**: The command's **only** write is appending a new
|
||||
`## Phase N: Convergence` section to `tasks.md`. It MUST NOT:
|
||||
|
||||
- modify `spec.md` or `plan.md` in any way;
|
||||
- rewrite, renumber, reorder, or delete any existing task (including tasks from a prior
|
||||
Convergence phase);
|
||||
- modify, create, or delete any application code — completing the appended tasks is the
|
||||
job of `/speckit-implement`.
|
||||
|
||||
When the codebase already satisfies everything, the command MUST leave `tasks.md`
|
||||
**byte-for-byte unchanged** (no empty Convergence header) and report a clean result.
|
||||
|
||||
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is
|
||||
**non-negotiable**. Code that violates a MUST principle is the highest-severity finding and
|
||||
produces a corresponding remediation task. If the constitution is an unfilled template,
|
||||
skip constitution checks gracefully rather than failing.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Initialize Convergence Context
|
||||
|
||||
Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
|
||||
|
||||
- SPEC = FEATURE_DIR/spec.md
|
||||
- PLAN = FEATURE_DIR/plan.md
|
||||
- TASKS = FEATURE_DIR/tasks.md
|
||||
- CONSTITUTION = `.specify/memory/constitution.md` (if present)
|
||||
If `spec.md`, `plan.md`, or `tasks.md` is missing, STOP with a clear, actionable message naming the
|
||||
prerequisite command to run (`/speckit-specify` for a missing spec, `/speckit-plan` for a missing plan,
|
||||
`/speckit-tasks` for missing tasks). Do not produce partial output.
|
||||
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
### 2. Load Artifacts (Progressive Disclosure)
|
||||
|
||||
Load only the minimal necessary context from each artifact:
|
||||
|
||||
**From spec.md:**
|
||||
|
||||
- Functional Requirements (FR-###)
|
||||
- Success Criteria (SC-###) — include only items requiring buildable work; exclude
|
||||
post-launch outcome metrics and business KPIs
|
||||
- User Stories and their Acceptance Scenarios
|
||||
- Edge Cases (if present)
|
||||
|
||||
**From plan.md:**
|
||||
|
||||
- Architecture/stack choices and technical decisions
|
||||
- Data Model references
|
||||
- Phases and named touch-points (files/components the plan says will be created or edited)
|
||||
- Technical constraints
|
||||
|
||||
**From tasks.md:**
|
||||
|
||||
- Task IDs (to compute the next ID and next phase number)
|
||||
- Descriptions, phase grouping, and referenced file paths
|
||||
|
||||
**From constitution (if not an unfilled template):**
|
||||
|
||||
- Principle names and MUST/SHOULD normative statements
|
||||
|
||||
### 3. Build the Intent Inventory
|
||||
|
||||
Create an internal model (do not echo raw artifacts):
|
||||
|
||||
- **Requirements inventory**: one stable key per FR-### / SC-### / user-story acceptance
|
||||
scenario (e.g. `US1/AC2`), plus the plan decisions and constitution principles that
|
||||
impose buildable obligations.
|
||||
- **Code-scope map**: from the file paths named in `plan.md` and `tasks.md`, plus a keyword
|
||||
search for the concepts each requirement describes, derive the set of source files and
|
||||
components in scope for assessment. Bound the assessment to these — do **not** infer
|
||||
scope beyond what the artifacts define.
|
||||
|
||||
### 4. Assess the Codebase and Classify Findings
|
||||
|
||||
For each item in the intent inventory, inspect the current code in scope and produce a
|
||||
`Finding` only where there is a gap. Classify every finding by **gap type**:
|
||||
|
||||
- **`missing`**: the required work is absent from the code entirely.
|
||||
- **`partial`**: the work exists but does not yet fully satisfy the requirement /
|
||||
acceptance criterion / plan decision.
|
||||
- **`contradicts`**: the code does something that conflicts with stated intent or a
|
||||
constitution MUST principle.
|
||||
- **`unrequested`**: the code contains work not called for by the spec, plan, or tasks
|
||||
(surfaced for awareness — converge does **not** delete code, it only appends a task to
|
||||
review/justify or remove it).
|
||||
|
||||
Each `Finding` records: a stable id, the `source-ref` it traces to, the `gap-type`, a
|
||||
severity, and a short human-readable description with the evidence (the file/area observed).
|
||||
|
||||
**Edge cases:**
|
||||
|
||||
- **Little or no code yet**: treat the entire specified scope as `missing` remaining work
|
||||
rather than failing.
|
||||
- **Nothing remains**: produce zero findings and follow the converged branch in Step 7.
|
||||
|
||||
### 5. Assign Severity
|
||||
|
||||
- **CRITICAL**: violates a constitution MUST principle, or a `missing`/`contradicts` gap
|
||||
that blocks baseline functionality of a P1 user story.
|
||||
- **HIGH**: a `missing` or `partial` gap on a core functional requirement or acceptance
|
||||
criterion.
|
||||
- **MEDIUM**: a `partial` gap on a secondary requirement, or an `unrequested` addition with
|
||||
unclear justification.
|
||||
- **LOW**: minor partial gaps, polish, or low-risk `unrequested` additions.
|
||||
|
||||
### 6. Present the In-Session Findings Summary
|
||||
|
||||
Before appending anything, output a compact, severity-graded summary (no file writes yet):
|
||||
|
||||
## Convergence Findings
|
||||
|
||||
| ID | Gap Type | Severity | Source | Evidence | Remaining Work |
|
||||
|----|----------|----------|--------|----------|----------------|
|
||||
| F1 | missing | HIGH | FR-008 | Example: no append-only guard detected in path/to/module.py when writing tasks.md | Add append-only enforcement |
|
||||
|
||||
**Summary metrics:**
|
||||
|
||||
- Requirements / acceptance criteria checked
|
||||
- Plan decisions checked
|
||||
- Constitution principles checked (or "skipped — template")
|
||||
- Findings by gap type (missing / partial / contradicts / unrequested)
|
||||
- Findings by severity
|
||||
|
||||
### 7. Append Convergence Tasks (or report converged)
|
||||
|
||||
**If there are one or more actionable findings** (`tasks_appended` outcome):
|
||||
|
||||
Append to the **end** of `tasks.md`, per the append contract:
|
||||
|
||||
1. Scan all existing task IDs; let `M` be the maximum. Determine the next phase number `N`
|
||||
(highest existing phase + 1).
|
||||
2. Write a single new section header `## Phase N: Convergence`.
|
||||
3. Emit one checklist item per actionable finding, ordered CRITICAL/HIGH first, assigning
|
||||
zero-padded IDs `T{M+1:03d}, T{M+2:03d}, …`:
|
||||
|
||||
```markdown
|
||||
- [ ] T042 <imperative description> per <source-ref> (<gap-type>)
|
||||
```
|
||||
|
||||
`<source-ref>` traces the task to its origin: e.g. `FR-003`, `SC-002`,
|
||||
`US1/AC2`, `plan: storage decision`, `Constitution II`.
|
||||
|
||||
`<gap-type>` is one of `missing`, `partial`, `contradicts`, `unrequested`.
|
||||
|
||||
Constitution-violation tasks MUST be emitted first and described as
|
||||
`CRITICAL`.
|
||||
4. Never reuse or renumber existing IDs. If a prior Convergence phase exists, add a new,
|
||||
separately-numbered one below it — do not touch the old one.
|
||||
|
||||
**If there are no actionable findings** (`converged` outcome):
|
||||
|
||||
- Do **not** modify `tasks.md` at all — no empty phase header.
|
||||
- Report: **"✅ Converged — the implementation satisfies the spec, plan, and tasks."**
|
||||
- Include the summary counts of what was checked.
|
||||
|
||||
### 8. Provide Next Actions (Handoff)
|
||||
|
||||
- On `tasks_appended`: state how many tasks were appended under which phase, and recommend
|
||||
running `/speckit-implement` to complete them; note that a follow-up converge
|
||||
run will find fewer or no remaining items.
|
||||
- On `converged`: recommend proceeding to review / opening a PR. No further implement pass
|
||||
is needed for this feature's specified scope.
|
||||
|
||||
### 9. Check for extension hooks
|
||||
|
||||
After producing the result, check if `.specify/extensions.yml` exists in the project root.
|
||||
|
||||
- If it exists, read it and look for entries under the `hooks.after_converge` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- Report the convergence outcome (`converged` or `tasks_appended`) in-session before listing
|
||||
any hooks, so users can decide whether to run optional follow-up commands.
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
|
||||
```text
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
|
||||
```text
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
name: "speckit-implement"
|
||||
description: "Execute the implementation plan by processing and executing all tasks defined in tasks.md"
|
||||
argument-hint: "Optional implementation guidance or task filter"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/implement.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before implementation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_implement` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
|
||||
- Treat checklist markers as a read-only gate: scan checkbox state, report status, and ask before proceeding when needed; do NOT modify checklist files or markers
|
||||
- `checklists/requirements.md` is the built-in spec-quality checklist maintained by `/speckit-specify` and `/speckit-clarify`; custom checklists generated by `/speckit-checklist` are reviewer-owned requirements-quality review artifacts
|
||||
- For custom checklists, `[x]` means the reviewer determined the requirements-quality criterion is satisfied; it does NOT mean implementation work is complete
|
||||
- Scan all checklist files in the checklists/ directory
|
||||
- For each checklist, count:
|
||||
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
|
||||
- Checked items: Lines matching `- [X]` or `- [x]`
|
||||
- Unchecked items: Lines matching `- [ ]`
|
||||
- Create a status table:
|
||||
|
||||
```text
|
||||
| Checklist | Total | Checked | Unchecked | Status |
|
||||
|-----------|-------|---------|-----------|--------|
|
||||
| ux.md | 12 | 12 | 0 | ✓ PASS |
|
||||
| test.md | 8 | 5 | 3 | ✗ FAIL |
|
||||
| security.md | 6 | 6 | 0 | ✓ PASS |
|
||||
```
|
||||
|
||||
- Calculate overall status:
|
||||
- **PASS**: All checklists have 0 unchecked items
|
||||
- **FAIL**: One or more checklists have unchecked items
|
||||
|
||||
- **If any checklist has unchecked items**:
|
||||
- Display the table with unchecked item counts
|
||||
- **STOP** and ask: "Some checklists have unchecked items. Do you want to proceed with implementation anyway? (yes/no)"
|
||||
- Wait for user response before continuing
|
||||
- If user says "no" or "wait" or "stop", halt execution
|
||||
- If user says "yes" or "proceed" or "continue", proceed to step 3
|
||||
|
||||
- **If all checklists are checked**:
|
||||
- Display the table showing all checklists passed
|
||||
- Automatically proceed to step 3
|
||||
|
||||
3. Load and analyze the implementation context:
|
||||
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
||||
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
||||
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
|
||||
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
||||
- **IF EXISTS**: Read .specify/memory/constitution.md for governance constraints
|
||||
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
||||
|
||||
4. **Project Setup Verification**:
|
||||
- **REQUIRED**: Create/verify ignore files based on actual project setup:
|
||||
|
||||
**Detection & Creation Logic**:
|
||||
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
|
||||
|
||||
```sh
|
||||
git rev-parse --git-dir 2>/dev/null
|
||||
```
|
||||
|
||||
- Check if Dockerfile* exists or Docker in plan.md → create/verify .dockerignore
|
||||
- Check if .eslintrc* exists → create/verify .eslintignore
|
||||
- Check if eslint.config.* exists → ensure the config's `ignores` entries cover required patterns
|
||||
- Check if .prettierrc* exists → create/verify .prettierignore
|
||||
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
|
||||
- Check if terraform files (*.tf) exist → create/verify .terraformignore
|
||||
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
|
||||
|
||||
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
|
||||
**If ignore file missing**: Create with full pattern set for detected technology
|
||||
|
||||
**Common Patterns by Technology** (from plan.md tech stack):
|
||||
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
|
||||
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
|
||||
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
|
||||
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
|
||||
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
|
||||
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
|
||||
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
|
||||
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
|
||||
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
|
||||
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
|
||||
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `*.dll`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*`
|
||||
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
|
||||
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
|
||||
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
|
||||
|
||||
**Tool-Specific Patterns**:
|
||||
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
|
||||
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
|
||||
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
|
||||
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
|
||||
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
|
||||
|
||||
5. Parse tasks.md structure and extract:
|
||||
- **Task phases**: Setup, Tests, Core, Integration, Polish
|
||||
- **Task dependencies**: Sequential vs parallel execution rules
|
||||
- **Task details**: ID, description, file paths, parallel markers [P]
|
||||
- **Execution flow**: Order and dependency requirements
|
||||
|
||||
6. Execute implementation following the task plan:
|
||||
- **Phase-by-phase execution**: Complete each phase before moving to the next
|
||||
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
|
||||
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
|
||||
- **File-based coordination**: Tasks affecting the same files must run sequentially
|
||||
- **Validation checkpoints**: Verify each phase completion before proceeding
|
||||
|
||||
7. Implementation execution rules:
|
||||
- **Setup first**: Initialize project structure, dependencies, configuration
|
||||
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
|
||||
- **Core development**: Implement models, services, CLI commands, endpoints
|
||||
- **Integration work**: Database connections, middleware, logging, external services
|
||||
- **Polish and validation**: Unit tests, performance optimization, documentation
|
||||
|
||||
8. Progress tracking and error handling:
|
||||
- Report progress after each completed task
|
||||
- Halt execution if any non-parallel task fails
|
||||
- For parallel tasks [P], continue with successful tasks, report failed ones
|
||||
- Provide clear error messages with context for debugging
|
||||
- Suggest next steps if implementation cannot proceed
|
||||
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
|
||||
|
||||
9. Completion validation:
|
||||
- Verify all required tasks are completed
|
||||
- Check that implemented features match the original specification
|
||||
- Validate that tests pass and coverage meets requirements
|
||||
- Confirm the implementation follows the technical plan
|
||||
|
||||
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit-tasks` first to regenerate the task list.
|
||||
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_implement`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_implement` key.
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Report final status with summary of completed work.
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] All tasks in tasks.md completed and marked `[X]`
|
||||
- [ ] Implementation validated against specification, plan, and test coverage
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with summary of completed work
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: "speckit-plan"
|
||||
description: "Execute the implementation planning workflow using the plan template to generate design artifacts."
|
||||
argument-hint: "Optional guidance for the planning phase"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/plan.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before planning)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_plan` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/powershell/setup-plan.ps1 -Json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load context**: Read FEATURE_SPEC and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
|
||||
|
||||
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
|
||||
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
|
||||
- Fill Constitution Check section from constitution
|
||||
- Evaluate gates (ERROR if violations unjustified)
|
||||
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
|
||||
- Phase 1: Generate data-model.md, contracts/, quickstart.md
|
||||
- Re-evaluate Constitution Check post-design
|
||||
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_plan`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_plan` key.
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Command ends after Phase 1 design. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0: Outline & Research
|
||||
|
||||
1. **Extract unknowns from Technical Context** above:
|
||||
- For each NEEDS CLARIFICATION → research task
|
||||
- For each dependency → best practices task
|
||||
- For each integration → patterns task
|
||||
|
||||
2. **Generate and dispatch research agents**:
|
||||
|
||||
```text
|
||||
For each unknown in Technical Context:
|
||||
Task: "Research {unknown} for {feature context}"
|
||||
For each technology choice:
|
||||
Task: "Find best practices for {tech} in {domain}"
|
||||
```
|
||||
|
||||
3. **Consolidate findings** in `research.md` using format:
|
||||
- Decision: [what was chosen]
|
||||
- Rationale: [why chosen]
|
||||
- Alternatives considered: [what else evaluated]
|
||||
|
||||
**Output**: research.md with all NEEDS CLARIFICATION resolved
|
||||
|
||||
### Phase 1: Design & Contracts
|
||||
|
||||
**Prerequisites:** `research.md` complete
|
||||
|
||||
1. **Extract entities from feature spec** → `data-model.md`:
|
||||
- Entity name, fields, relationships
|
||||
- Validation rules from requirements
|
||||
- State transitions if applicable
|
||||
|
||||
2. **Define interface contracts** (if project has external interfaces) → `/contracts/`:
|
||||
- Identify what interfaces the project exposes to users or other systems
|
||||
- Document the contract format appropriate for the project type
|
||||
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
|
||||
- Skip if project is purely internal (build scripts, one-off tools, etc.)
|
||||
|
||||
3. **Create quickstart validation guide** → `quickstart.md`:
|
||||
- Document runnable validation scenarios that prove the feature works end-to-end
|
||||
- Include prerequisites, setup commands, test/run commands, and expected outcomes
|
||||
- Use links or references to contracts and data model details instead of duplicating them
|
||||
- Do not include full implementation code, model/service/controller bodies, migrations, or complete test suites
|
||||
- Keep this artifact as a validation/run guide; implementation details belong in `tasks.md` and the implementation phase
|
||||
|
||||
**Output**: data-model.md, /contracts/*, quickstart.md
|
||||
|
||||
## Key rules
|
||||
|
||||
- Use absolute paths for filesystem operations; use project-relative paths for references in documentation
|
||||
- ERROR on gate failures or unresolved clarifications
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Plan workflow executed and design artifacts generated
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with branch, plan path, and generated artifacts
|
||||
@@ -0,0 +1,348 @@
|
||||
---
|
||||
name: "speckit-specify"
|
||||
description: "Create or update the feature specification from a natural language feature description."
|
||||
argument-hint: "Describe the feature you want to specify"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/specify.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before specification)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_specify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
The text the user typed after `/speckit-specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
|
||||
|
||||
Given that feature description, do this:
|
||||
|
||||
1. **Generate a concise short name** (2-4 words) for the feature:
|
||||
- Analyze the feature description and extract the most meaningful keywords
|
||||
- Create a 2-4 word short name that captures the essence of the feature
|
||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||
- Keep it concise but descriptive enough to understand the feature at a glance
|
||||
- Examples:
|
||||
- "I want to add user authentication" → "user-auth"
|
||||
- "Implement OAuth2 integration for the API" → "oauth2-api-integration"
|
||||
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
||||
|
||||
2. **Branch creation** (optional, via hook):
|
||||
|
||||
If a `before_specify` hook ran successfully in the Pre-Execution Checks above, it will have created/switched to a git branch and output JSON containing `BRANCH_NAME` and `FEATURE_NUM`. Note these values for reference, but the branch name does **not** dictate the spec directory name.
|
||||
|
||||
If the user explicitly provided `GIT_BRANCH_NAME`, pass it through to the hook so the branch script uses the exact value as the branch name (bypassing all prefix/suffix generation).
|
||||
|
||||
3. **Create the spec feature directory**:
|
||||
|
||||
Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`.
|
||||
|
||||
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
|
||||
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
|
||||
2. Otherwise, auto-generate it under `specs/`:
|
||||
- Check `.specify/init-options.json` for `feature_numbering` (preferred) or `branch_numbering` (deprecated, migration only — will be removed in a future release)
|
||||
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
|
||||
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
|
||||
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
|
||||
- If `branch_numbering` was used (and `feature_numbering` was absent), emit a one-line warning: "⚠️ `branch_numbering` in init-options.json is deprecated. Rename to `feature_numbering`."
|
||||
|
||||
**Create the directory and spec file**:
|
||||
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
|
||||
- Resolve the active `spec-template` through the Spec Kit preset/template resolution stack (equivalent to `specify preset resolve spec-template`)
|
||||
- Copy the resolved `spec-template` file to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
|
||||
- Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md`
|
||||
- Persist the resolved path to `.specify/feature.json`:
|
||||
```json
|
||||
{
|
||||
"feature_directory": "<resolved feature dir>"
|
||||
}
|
||||
```
|
||||
Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`.
|
||||
This allows downstream commands (`/speckit-plan`, `/speckit-tasks`, etc.) to locate the feature directory without relying on git branch name conventions.
|
||||
|
||||
**IMPORTANT**:
|
||||
- You must only create one feature per `/speckit-specify` invocation
|
||||
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
|
||||
- The spec directory and file are always created by this command, never by the hook
|
||||
|
||||
4. Load the resolved active `spec-template` file to understand required sections.
|
||||
|
||||
5. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
6. Follow this execution flow:
|
||||
1. Parse user description from arguments
|
||||
If empty: ERROR "No feature description provided"
|
||||
2. Extract key concepts from description
|
||||
Identify: actors, actions, data, constraints
|
||||
3. For unclear aspects:
|
||||
- Make informed guesses based on context and industry standards
|
||||
- Only mark with [NEEDS CLARIFICATION: specific question] if:
|
||||
- The choice significantly impacts feature scope or user experience
|
||||
- Multiple reasonable interpretations exist with different implications
|
||||
- No reasonable default exists
|
||||
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**
|
||||
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details
|
||||
4. Fill User Scenarios & Testing section
|
||||
If no clear user flow: ERROR "Cannot determine user scenarios"
|
||||
5. Generate Functional Requirements
|
||||
Each requirement must be testable
|
||||
Use reasonable defaults for unspecified details (document assumptions in Assumptions section)
|
||||
6. Define Success Criteria
|
||||
Create measurable, technology-agnostic outcomes
|
||||
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)
|
||||
Each criterion must be verifiable without implementation details
|
||||
7. Identify Key Entities (if data involved)
|
||||
8. Return: SUCCESS (spec ready for planning)
|
||||
|
||||
7. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
|
||||
|
||||
8. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
|
||||
|
||||
a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items:
|
||||
|
||||
```markdown
|
||||
# Specification Quality Checklist: [FEATURE NAME]
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md]
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [ ] No implementation details (languages, frameworks, APIs)
|
||||
- [ ] Focused on user value and business needs
|
||||
- [ ] Written for non-technical stakeholders
|
||||
- [ ] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [ ] No [NEEDS CLARIFICATION] markers remain
|
||||
- [ ] Requirements are testable and unambiguous
|
||||
- [ ] Success criteria are measurable
|
||||
- [ ] Success criteria are technology-agnostic (no implementation details)
|
||||
- [ ] All acceptance scenarios are defined
|
||||
- [ ] Edge cases are identified
|
||||
- [ ] Scope is clearly bounded
|
||||
- [ ] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [ ] All functional requirements have clear acceptance criteria
|
||||
- [ ] User scenarios cover primary flows
|
||||
- [ ] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [ ] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan`
|
||||
```
|
||||
|
||||
b. **Run Validation Check**: Review the spec against each checklist item:
|
||||
- For each item, determine if it passes or fails
|
||||
- Document specific issues found (quote relevant spec sections)
|
||||
|
||||
c. **Handle Validation Results**:
|
||||
|
||||
- **If all items pass**: Mark checklist complete and proceed to the Mandatory Post-Execution Hooks section
|
||||
|
||||
- **If items fail (excluding [NEEDS CLARIFICATION])**:
|
||||
1. List the failing items and specific issues
|
||||
2. Update the spec to address each issue
|
||||
3. Re-run validation until all items pass (max 3 iterations)
|
||||
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user
|
||||
|
||||
- **If [NEEDS CLARIFICATION] markers remain**:
|
||||
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec
|
||||
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest
|
||||
3. For each clarification needed (max 3), present options to user in this format:
|
||||
|
||||
```markdown
|
||||
## Question [N]: [Topic]
|
||||
|
||||
**Context**: [Quote relevant spec section]
|
||||
|
||||
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
|
||||
|
||||
**Suggested Answers**:
|
||||
|
||||
| Option | Answer | Implications |
|
||||
|--------|--------|--------------|
|
||||
| A | [First suggested answer] | [What this means for the feature] |
|
||||
| B | [Second suggested answer] | [What this means for the feature] |
|
||||
| C | [Third suggested answer] | [What this means for the feature] |
|
||||
| Custom | Provide your own answer | [Explain how to provide custom input] |
|
||||
|
||||
**Your choice**: _[Wait for user response]_
|
||||
```
|
||||
|
||||
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:
|
||||
- Use consistent spacing with pipes aligned
|
||||
- Each cell should have spaces around content: `| Content |` not `|Content|`
|
||||
- Header separator must have at least 3 dashes: `|--------|`
|
||||
- Test that the table renders correctly in markdown preview
|
||||
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)
|
||||
6. Present all questions together before waiting for responses
|
||||
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B")
|
||||
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer
|
||||
9. Re-run validation after all clarifications are resolved
|
||||
|
||||
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
|
||||
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_specify`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_specify` key.
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Report completion to the user with:
|
||||
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
|
||||
- `SPEC_FILE` — the spec file path
|
||||
- Checklist results summary
|
||||
- Readiness for the next phase (`/speckit-clarify` or `/speckit-plan`)
|
||||
|
||||
**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command.
|
||||
|
||||
## Quick Guidelines
|
||||
|
||||
- Focus on **WHAT** users need and **WHY**.
|
||||
- Avoid HOW to implement (no tech stack, APIs, code structure).
|
||||
- Written for business stakeholders, not developers.
|
||||
- DO NOT create any checklists that are embedded in the spec. That will be a separate command.
|
||||
|
||||
### Section Requirements
|
||||
|
||||
- **Mandatory sections**: Must be completed for every feature
|
||||
- **Optional sections**: Include only when relevant to the feature
|
||||
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
|
||||
|
||||
### For AI Generation
|
||||
|
||||
When creating this spec from a user prompt:
|
||||
|
||||
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps
|
||||
2. **Document assumptions**: Record reasonable defaults in the Assumptions section
|
||||
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:
|
||||
- Significantly impact feature scope or user experience
|
||||
- Have multiple reasonable interpretations with different implications
|
||||
- Lack any reasonable default
|
||||
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details
|
||||
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
|
||||
6. **Common areas needing clarification** (only if no reasonable default exists):
|
||||
- Feature scope and boundaries (include/exclude specific use cases)
|
||||
- User types and permissions (if multiple conflicting interpretations possible)
|
||||
- Security/compliance requirements (when legally/financially significant)
|
||||
|
||||
**Examples of reasonable defaults** (don't ask about these):
|
||||
|
||||
- Data retention: Industry-standard practices for the domain
|
||||
- Performance targets: Standard web/mobile app expectations unless specified
|
||||
- Error handling: User-friendly messages with appropriate fallbacks
|
||||
- Authentication method: Standard session-based or OAuth2 for web apps
|
||||
- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.)
|
||||
|
||||
### Success Criteria Guidelines
|
||||
|
||||
Success criteria must be:
|
||||
|
||||
1. **Measurable**: Include specific metrics (time, percentage, count, rate)
|
||||
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools
|
||||
3. **User-focused**: Describe outcomes from user/business perspective, not system internals
|
||||
4. **Verifiable**: Can be tested/validated without knowing implementation details
|
||||
|
||||
**Good examples**:
|
||||
|
||||
- "Users can complete checkout in under 3 minutes"
|
||||
- "System supports 10,000 concurrent users"
|
||||
- "95% of searches return results in under 1 second"
|
||||
- "Task completion rate improves by 40%"
|
||||
|
||||
**Bad examples** (implementation-focused):
|
||||
|
||||
- "API response time is under 200ms" (too technical, use "Users see results instantly")
|
||||
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
|
||||
- "React components render efficiently" (framework-specific)
|
||||
- "Redis cache hit rate above 80%" (technology-specific)
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Specification written to `SPEC_FILE` and validated against quality checklist
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with feature directory, spec file path, and checklist results
|
||||
@@ -0,0 +1,217 @@
|
||||
---
|
||||
name: "speckit-tasks"
|
||||
description: "Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts."
|
||||
argument-hint: "Optional task generation constraints"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/tasks.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before tasks generation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_tasks` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/powershell/setup-tasks.ps1 -Json` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE_CONTENT, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load design documents**: Read from FEATURE_DIR:
|
||||
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
|
||||
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
|
||||
- **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints
|
||||
- Note: Not all projects have all documents. Generate tasks based on what's available.
|
||||
|
||||
3. **Execute task generation workflow**:
|
||||
- Load plan.md and extract tech stack, libraries, project structure
|
||||
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
|
||||
- If data-model.md exists: Extract entities and map to user stories
|
||||
- If contracts/ exists: Map interface contracts to user stories
|
||||
- If research.md exists: Extract decisions for setup tasks
|
||||
- Generate tasks organized by user story (see Task Generation Rules below)
|
||||
- Generate dependency graph showing user story completion order
|
||||
- Create parallel execution examples per user story
|
||||
- Validate task completeness (each user story has all needed tasks, independently testable)
|
||||
|
||||
4. **Generate tasks.md**: Use TASKS_TEMPLATE_CONTENT (from the JSON output above) as the structure. For compatibility with older setup scripts that omit TASKS_TEMPLATE_CONTENT, read TASKS_TEMPLATE instead. Fill with:
|
||||
- Correct feature name from plan.md
|
||||
- Phase 1: Setup tasks (project initialization)
|
||||
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
|
||||
- Phase 3+: One phase per user story (in priority order from spec.md)
|
||||
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
|
||||
- Final Phase: Polish & cross-cutting concerns
|
||||
- All tasks must follow the strict checklist format (see Task Generation Rules below)
|
||||
- Clear file paths for each task
|
||||
- Dependencies section showing story completion order
|
||||
- Parallel execution examples per story
|
||||
- Implementation strategy section (MVP first, incremental delivery)
|
||||
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_tasks`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_tasks` key.
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Output path to generated tasks.md and summary:
|
||||
- Total task count
|
||||
- Task count per user story
|
||||
- Parallel opportunities identified
|
||||
- Independent test criteria for each story
|
||||
- Suggested MVP scope (typically just User Story 1)
|
||||
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
|
||||
|
||||
Context for task generation: $ARGUMENTS
|
||||
|
||||
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
|
||||
|
||||
## Task Generation Rules
|
||||
|
||||
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
|
||||
|
||||
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
|
||||
|
||||
### Checklist Format (REQUIRED)
|
||||
|
||||
Every task MUST strictly follow this format:
|
||||
|
||||
```text
|
||||
- [ ] [TaskID] [P?] [Story?] Description with file path
|
||||
```
|
||||
|
||||
**Format Components**:
|
||||
|
||||
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
|
||||
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
|
||||
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
|
||||
4. **[Story] label**: REQUIRED for user story phase tasks only
|
||||
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
|
||||
- Setup phase: NO story label
|
||||
- Foundational phase: NO story label
|
||||
- User Story phases: MUST have story label
|
||||
- Polish phase: NO story label
|
||||
5. **Description**: Clear action with exact file path
|
||||
|
||||
**Examples**:
|
||||
|
||||
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
|
||||
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
|
||||
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
|
||||
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
|
||||
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
|
||||
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
|
||||
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
|
||||
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
|
||||
|
||||
### Task Organization
|
||||
|
||||
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
|
||||
- Each user story (P1, P2, P3...) gets its own phase
|
||||
- Map all related components to their story:
|
||||
- Models needed for that story
|
||||
- Services needed for that story
|
||||
- Interfaces/UI needed for that story
|
||||
- If tests requested: Tests specific to that story
|
||||
- Mark story dependencies (most stories should be independent)
|
||||
|
||||
2. **From Contracts**:
|
||||
- Map each interface contract → to the user story it serves
|
||||
- If tests requested: Each interface contract → contract test task [P] before implementation in that story's phase
|
||||
|
||||
3. **From Data Model**:
|
||||
- Map each entity to the user story(ies) that need it
|
||||
- If entity serves multiple stories: Put in earliest story or Setup phase
|
||||
- Relationships → service layer tasks in appropriate story phase
|
||||
|
||||
4. **From Setup/Infrastructure**:
|
||||
- Shared infrastructure → Setup phase (Phase 1)
|
||||
- Foundational/blocking tasks → Foundational phase (Phase 2)
|
||||
- Story-specific setup → within that story's phase
|
||||
|
||||
### Phase Structure
|
||||
|
||||
- **Phase 1**: Setup (project initialization)
|
||||
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
|
||||
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
|
||||
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
|
||||
- Each phase should be a complete, independently testable increment
|
||||
- **Final Phase**: Polish & Cross-Cutting Concerns
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] tasks.md generated with all phases, task IDs, and file paths
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with task count, story breakdown, and MVP scope
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: "speckit-taskstoissues"
|
||||
description: "Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts."
|
||||
argument-hint: "Optional filter or label for GitHub issues"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/taskstoissues.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before tasks-to-issues conversion)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
1. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
|
||||
1. From the executed script, extract the path to **tasks**.
|
||||
1. Get the Git remote by running:
|
||||
|
||||
```bash
|
||||
git config --get remote.origin.url
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
|
||||
|
||||
1. **Fetch existing issues for deduplication**: Before creating anything, build the set of task IDs you are about to process from `tasks.md` (each is a `T` followed by **at least** three digits, e.g. `T001` — `/speckit-converge` assigns new IDs with `T{M+1:03d}`, which is a floor rather than a cap, so once a file has more than 999 tasks the IDs are four digits or longer). Then use the GitHub MCP server's `list_issues` tool to look for issues that already cover those IDs. Do not pass a `state` value, since omitting it makes the tool return both open and closed issues. Request `perPage: 100` to keep the number of calls down, and since the tool uses cursor-based pagination, request pages with the `after` parameter (using the `endCursor` from the previous response). For each issue title, match it against the task ID pattern `\bT\d{3,}\b` (the `{3,}` accepts four-digit and longer IDs — with `\d{3}` a title containing `T1000` would not match at all, because the trailing `\b` cannot fall between two digits, so that task would be silently neither deduplicated nor created; word boundaries still stop a token like `ST001` from matching, and force the whole digit run to be consumed so `T100` can never match inside `T1000`; this also recognises titles written as `T001 ...`, `T001: ...` or `[T001] ...`) and, when it matches one of your task IDs, mark that ID as already having an issue. Stop paginating as soon as every task ID has been matched, or when there are no more pages, so you do not keep fetching the whole repository's issue history once all task IDs are accounted for. This bounds the number of calls on repos with large issue histories and still prevents duplicates when the command is re-run after `tasks.md` is regenerated or the skill is re-invoked.
|
||||
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. Task lines in `tasks.md` start with a markdown checkbox, so first strip the leading `- [ ]` (and any `[P]` / `[US#]` markers) to recover the task ID and its description. Create the issue with a single canonical title of the form `T001: <description>`, with the ID written once followed by the task description (for example, the line `- [ ] T001 Create project structure` becomes the title `T001: Create project structure`).
|
||||
- **Skip** any task whose ID is already present in the set of existing issues from the previous step, and report it (for example, `T001 already has an issue, skipping`).
|
||||
- Only create issues for tasks that do not yet have a matching issue.
|
||||
|
||||
> [!CAUTION]
|
||||
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after tasks-to-issues conversion)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
+160
-35
@@ -1,50 +1,175 @@
|
||||
# [PROJECT_NAME] Constitution
|
||||
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
||||
<!--
|
||||
Sync Impact Report
|
||||
==================
|
||||
Version change: (unversioned template) → 1.0.0
|
||||
Bump rationale: Initial ratification. All placeholder tokens replaced with concrete,
|
||||
project-specific governance derived from README.md and the approved
|
||||
Phase 0 baseline (Persona-Engine-Developer-Handoff.txt).
|
||||
|
||||
Modified principles:
|
||||
[PRINCIPLE_1_NAME] → I. Deterministic, Single-Persona Classification (NON-NEGOTIABLE)
|
||||
[PRINCIPLE_2_NAME] → II. Configuration-Driven Rules
|
||||
[PRINCIPLE_3_NAME] → III. Fail-Safe, Idempotent Persistence (NON-NEGOTIABLE)
|
||||
[PRINCIPLE_4_NAME] → IV. Pure Rule Engine, Tested Offline First
|
||||
[PRINCIPLE_5_NAME] → V. Explainable, Sanitized Observability
|
||||
|
||||
Added sections:
|
||||
[SECTION_2_NAME] → Security and Least-Privilege Constraints
|
||||
[SECTION_3_NAME] → Development Workflow and Quality Gates
|
||||
Governance rules populated (amendment procedure, versioning policy, compliance review)
|
||||
|
||||
Removed sections: none
|
||||
|
||||
Follow-up TODOs: none — no placeholders deferred.
|
||||
-->
|
||||
|
||||
# Persona Engine Constitution
|
||||
|
||||
## Core Principles
|
||||
|
||||
### [PRINCIPLE_1_NAME]
|
||||
<!-- Example: I. Library-First -->
|
||||
[PRINCIPLE_1_DESCRIPTION]
|
||||
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
||||
### I. Deterministic, Single-Persona Classification (NON-NEGOTIABLE)
|
||||
|
||||
### [PRINCIPLE_2_NAME]
|
||||
<!-- Example: II. CLI Interface -->
|
||||
[PRINCIPLE_2_DESCRIPTION]
|
||||
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
||||
Identical input records and identical configuration MUST always produce an identical persona
|
||||
decision. Every evaluated account MUST receive exactly one persona — never zero, never
|
||||
several. Rules MUST be evaluated in ascending numeric priority order and evaluation MUST stop
|
||||
at the first match. When all enabled rules evaluate successfully and none match, the result
|
||||
MUST be `Unclassified`; when evaluation cannot complete reliably, the result MUST be
|
||||
`EvaluationError`. Non-deterministic inputs — wall-clock time, random values, unordered
|
||||
collection enumeration, or environment state — MUST NOT influence a decision.
|
||||
|
||||
### [PRINCIPLE_3_NAME]
|
||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
||||
[PRINCIPLE_3_DESCRIPTION]
|
||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
||||
**Rationale**: Persona values drive access and Conditional Access decisions. A classification
|
||||
that varies between runs is unreviewable and cannot be safely enforced.
|
||||
|
||||
### [PRINCIPLE_4_NAME]
|
||||
<!-- Example: IV. Integration Testing -->
|
||||
[PRINCIPLE_4_DESCRIPTION]
|
||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
||||
### II. Configuration-Driven Rules
|
||||
|
||||
### [PRINCIPLE_5_NAME]
|
||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
||||
[PRINCIPLE_5_DESCRIPTION]
|
||||
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
||||
Business classification logic MUST live in JSON configuration, never in PowerShell source. The
|
||||
engine implements condition operators and composition (`all` / `any` nesting within the
|
||||
configured depth limit); it MUST NOT hard-code personas, priorities, group identifiers, role
|
||||
identifiers, or tenant-specific attribute names. The candidate persona catalogue is business
|
||||
data, not engine behaviour. JSON is the only supported configuration format for v1.
|
||||
Configuration MUST be validated in four layers — JSON syntax, JSON Schema, semantic, then
|
||||
safety — before it is used to evaluate any account.
|
||||
|
||||
## [SECTION_2_NAME]
|
||||
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
||||
**Rationale**: Rule changes are business changes. Requiring a code change to reclassify accounts
|
||||
couples routine policy updates to the release pipeline and invites unreviewed edits.
|
||||
|
||||
[SECTION_2_CONTENT]
|
||||
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
||||
### III. Fail-Safe, Idempotent Persistence (NON-NEGOTIABLE)
|
||||
|
||||
## [SECTION_3_NAME]
|
||||
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
||||
Re-running the engine MUST change nothing unless a calculated value actually differs from the
|
||||
stored value. A missing or failed required data source MUST NOT be interpreted as a non-match:
|
||||
the account MUST be recorded as `EvaluationError`, its existing persona MUST be preserved, no
|
||||
write MUST be attempted for it, and the run MUST continue. Write-capable entry points MUST use
|
||||
`CmdletBinding` with `SupportsShouldProcess`, and `-WhatIf` MUST produce zero directory writes.
|
||||
`-Debug` and `-Verbose` MUST NOT be treated as read-only controls. A write payload MUST contain
|
||||
only the single approved target attribute.
|
||||
|
||||
[SECTION_3_CONTENT]
|
||||
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
||||
**Rationale**: The engine writes to a live directory. Silent misclassification of a privileged
|
||||
account is a security incident, so unavailable data must degrade to no change.
|
||||
|
||||
### IV. Pure Rule Engine, Tested Offline First
|
||||
|
||||
The rule engine MUST NOT depend on Microsoft Graph, authentication, Azure Automation, or console
|
||||
rendering. Graph acquisition MUST be normalized into plain identity records before evaluation,
|
||||
and persistence MUST sit behind an adapter the engine does not call directly. The rule engine
|
||||
MUST be implemented and passing Pester tests against synthetic data before any Graph integration
|
||||
is written, and the persistence adapter MUST be implemented last. Offline unit tests MUST run to
|
||||
completion with no tenant connectivity and no credentials.
|
||||
|
||||
**Rationale**: Correctness of classification is provable only in isolation. A rule engine
|
||||
reachable only through a live tenant cannot be exhaustively tested before it is trusted.
|
||||
|
||||
### V. Explainable, Sanitized Observability
|
||||
|
||||
Every persona result MUST identify the matched rule ID (or `Unclassified` / `EvaluationError`),
|
||||
the run correlation ID, the UPN, and the Account Object ID. Each user result MUST be emitted
|
||||
immediately as it is produced, with interim and final summaries, and reconciliation MUST confirm
|
||||
that reported counts match accounts processed. Structured, audit-friendly logging is required
|
||||
alongside console output. UPN and Account Object ID are approved for logs. Access tokens,
|
||||
authorization headers, secrets, and full Graph responses MUST NEVER be logged. Detailed
|
||||
condition-level values are diagnostic-only and MUST be gated behind `-Debug`.
|
||||
|
||||
**Rationale**: An unexplainable decision cannot be reviewed, appealed, or audited, and the
|
||||
`-WhatIf` impact evidence required before enforcement depends on complete per-user output.
|
||||
|
||||
## Security and Least-Privilege Constraints
|
||||
|
||||
- **Authentication**: Azure Automation MUST use a managed identity. Local development MUST use an
|
||||
approved interactive or read-only application identity. Client secrets MUST NEVER be committed
|
||||
to source control.
|
||||
- **Least privilege**: The execution identity MUST be granted only what the enabled rules require
|
||||
— in-scope user properties, configured group membership, configured role data, and the existing
|
||||
persona value.
|
||||
- **Attribute-scoped writes**: Whether Entra can enforce write authorization at the individual
|
||||
attribute level MUST be verified, never assumed (OTD-003). Until verified, the compensating
|
||||
controls are mandatory: 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; tests inspect the
|
||||
request body; and code owners gate persistence changes.
|
||||
- **Sanitization**: Every artifact in this repository — docs, examples, tests, configuration
|
||||
samples, specs — MUST be free of organization names, real domains, tenant or subscription IDs,
|
||||
automation account names, real UPNs or Object IDs, real group or role identifiers,
|
||||
environment-specific attribute names, log destination details, and any secret, token,
|
||||
certificate, or credential. Only approved placeholders such as `<ORGANIZATION-NAME>`,
|
||||
`<TENANT-ID>`, `<ACCOUNT-OBJECT-ID>`, `<GROUP-OBJECT-ID>`, and
|
||||
`<APPROVED-PERSONA-ATTRIBUTE-NAME>` may be used. 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.
|
||||
- **Scope**: v1 covers Entra user objects only. Service principals, managed identities, workload
|
||||
identities, and agentic identities are out of scope, and the architecture MUST NOT assume they
|
||||
share user-object properties.
|
||||
- **Kill switch and rollback**: A documented kill switch (disable the Automation schedule, run
|
||||
with `-WhatIf`, revoke production write permission, or disable write deployment stages) and a
|
||||
documented rollback procedure MUST exist before enforcement is enabled.
|
||||
- **Runtime**: PowerShell 7 is the target runtime for both local execution and Azure Automation.
|
||||
|
||||
## Development Workflow and Quality Gates
|
||||
|
||||
- **Spec-driven delivery**: Nothing is implemented before it is specified, planned, and decomposed
|
||||
into tasks (Specify → Plan → Tasks → Implement). Any unresolved question MUST be captured as an
|
||||
explicit assumption, risk, or architecture decision — it MUST NEVER be silently implemented.
|
||||
- **Open technical decisions**: OTD-001 through OTD-005 MUST be closed in `research.md` or an ADR
|
||||
before any persistence implementation begins.
|
||||
- **Build order**: Pure rule engine with offline Pester tests → configuration validation and
|
||||
non-interactive pipeline mode → Graph read adapters → console and structured logging →
|
||||
persistence adapter last, with tests proving zero writes under `-WhatIf`.
|
||||
- **Traceability**: Requirements carry FR/NFR identifiers and MUST be traceable from spec through
|
||||
tasks to tests.
|
||||
- **Validation pipeline** (MUST pass before merge): repository hygiene and sanitization checks,
|
||||
PowerShell static analysis, JSON Schema validation, semantic and safety configuration
|
||||
validation, Pester unit tests, Pester safety tests, test result publication, and artifact
|
||||
packaging.
|
||||
- **Release pipeline**: validate the approved branch or tag, repeat validation and tests, package,
|
||||
deploy to Azure Automation, import modules, publish the runbook with the schedule **disabled**,
|
||||
execute `-WhatIf` validation, pass an approval gate, then enable enforcement.
|
||||
- **Mandatory review**: Changes to the target attribute, `approvedWritableAttributes`, rule
|
||||
priority, rule enablement, rule conditions, persona outputs, authentication permissions,
|
||||
persistence functions, logging destination, or `WhatIf` / `ShouldProcess` behaviour MUST be
|
||||
reviewed by a code owner. Delivery uses feature branches, pull requests, and a protected release
|
||||
branch.
|
||||
- **Definition of done**: A version is complete only when its `-WhatIf` impact evidence has been
|
||||
reviewed and security review has confirmed permissions and compensating controls.
|
||||
|
||||
## Governance
|
||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
||||
|
||||
[GOVERNANCE_RULES]
|
||||
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
||||
This constitution supersedes all other development practices for this repository. Where a plan,
|
||||
task list, or review comment conflicts with it, this document wins.
|
||||
|
||||
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
||||
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
||||
**Amendment procedure**: Amendments MUST be proposed as a change to this file in a pull request,
|
||||
MUST state the rationale and the version bump type, and MUST be approved by a code owner. An
|
||||
amendment that invalidates existing artifacts MUST include a migration note naming the specs,
|
||||
plans, tasks, or code that require updating.
|
||||
|
||||
**Versioning policy**: This constitution uses semantic versioning.
|
||||
|
||||
- **MAJOR** — a principle or governance rule is removed or redefined in a backward-incompatible way.
|
||||
- **MINOR** — a new principle or section is added, or existing guidance is materially expanded.
|
||||
- **PATCH** — clarifications, wording, and typo fixes with no change in obligation.
|
||||
|
||||
**Compliance review**: Every pull request MUST verify compliance with these principles, and
|
||||
reviewers MUST reject changes that violate a NON-NEGOTIABLE principle regardless of urgency.
|
||||
Complexity that departs from these principles MUST be justified in writing in the plan's
|
||||
complexity tracking, or the simpler compliant approach MUST be taken instead. `README.md` and the
|
||||
feature's `spec.md`, `plan.md`, and `tasks.md` provide runtime development guidance and MUST be
|
||||
kept consistent with this constitution.
|
||||
|
||||
**Version**: 1.0.0 | **Ratified**: 2026-08-20 | **Last Amended**: 2026-08-20
|
||||
|
||||
@@ -0,0 +1,826 @@
|
||||
#Requires -Version 7.2
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validates, tests, and interactively edits a Persona Engine configuration.
|
||||
|
||||
.DESCRIPTION
|
||||
Two jobs in one tool, deliberately: the thing that checks a configuration and the
|
||||
thing that edits it must agree about what "valid" means, and the surest way to
|
||||
guarantee that is to make them the same code path.
|
||||
|
||||
-ValidateOnly report findings and exit. Never enters the editor.
|
||||
-NonInteractive pipeline mode. Never prompts, never hangs (SC-010).
|
||||
(neither) interactive editor, re-validating before every save.
|
||||
|
||||
-NonInteractive is not a convenience flag. A build agent runs with stdin closed;
|
||||
a tool that prompts there does not fail, it hangs until the job times out, and
|
||||
the pipeline reports an infrastructure problem instead of a bad configuration.
|
||||
Every prompting call in this script sits behind a check for it.
|
||||
|
||||
Rule testing against synthetic users (-TestDataPath) runs the real rule engine
|
||||
with no tenant connectivity (FR-025, SC-008). The engine is pure, so the answer
|
||||
it gives offline is the answer it gives in production.
|
||||
|
||||
.PARAMETER ConfigPath
|
||||
Configuration to validate or edit.
|
||||
|
||||
.PARAMETER ValidateOnly
|
||||
Validate and report; do not enter the editor.
|
||||
|
||||
.PARAMETER NonInteractive
|
||||
Pipeline mode: no prompts, exit code only.
|
||||
|
||||
.PARAMETER SchemaPath
|
||||
Override the shipped schema.
|
||||
|
||||
.PARAMETER OutputPath
|
||||
Save-As target. Leaves the input file untouched.
|
||||
|
||||
.PARAMETER TreatWarningsAsErrors
|
||||
Escalate Warning findings to blocking (VR-005).
|
||||
|
||||
.PARAMETER TestDataPath
|
||||
Synthetic sample users for offline rule testing (FR-025).
|
||||
|
||||
.PARAMETER PreviousConfigPath
|
||||
Currently deployed configuration, enabling the VR-003 drift checks.
|
||||
|
||||
.PARAMETER EnforcementEnabled
|
||||
Validate as though this configuration will drive an enforcing run, which raises
|
||||
the severity of several safety findings.
|
||||
|
||||
.EXAMPLE
|
||||
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
|
||||
|
||||
The pipeline invocation. Returns 0 when the configuration is clean.
|
||||
|
||||
.EXAMPLE
|
||||
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData
|
||||
|
||||
Validate, then evaluate the rules against synthetic users with no tenant.
|
||||
|
||||
.NOTES
|
||||
Exit codes
|
||||
0 valid; no blocking findings
|
||||
1 one or more Error findings
|
||||
2 Warning findings present with -TreatWarningsAsErrors
|
||||
3 configuration file not found or unreadable
|
||||
4 schema file not found or itself invalid
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $ConfigPath,
|
||||
|
||||
[switch] $ValidateOnly,
|
||||
|
||||
[switch] $NonInteractive,
|
||||
|
||||
[string] $SchemaPath,
|
||||
|
||||
[string] $OutputPath,
|
||||
|
||||
[switch] $TreatWarningsAsErrors,
|
||||
|
||||
[string] $TestDataPath,
|
||||
|
||||
[string] $PreviousConfigPath,
|
||||
|
||||
[switch] $EnforcementEnabled
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# The .psm1 directly, not the manifest. The manifest declares
|
||||
# Microsoft.Graph.Authentication as a required module, and this tool never touches
|
||||
# Graph - SC-008 requires full validation and synthetic rule testing to complete with
|
||||
# no network access. Importing the manifest would make a build agent that only
|
||||
# validates configuration install a Graph SDK it will never call.
|
||||
Import-Module (Join-Path $PSScriptRoot 'PersonaEngine.psm1') -Force -ErrorAction Stop
|
||||
|
||||
$EXIT_OK = 0
|
||||
$EXIT_FINDINGS = 1
|
||||
$EXIT_WARNINGS_AS_ERRORS = 2
|
||||
$EXIT_CONFIG_UNREADABLE = 3
|
||||
$EXIT_SCHEMA_UNUSABLE = 4
|
||||
|
||||
function Invoke-ConfigurationValidation {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs all four layers and maps the result onto an exit code.
|
||||
|
||||
.DESCRIPTION
|
||||
One function so the interactive save path and the pipeline path cannot
|
||||
disagree. The exit-code mapping lives here rather than at the call sites for
|
||||
the same reason: an exit code is the pipeline's only view of what happened.
|
||||
#>
|
||||
param([string] $Path)
|
||||
|
||||
$params = @{ Path = $Path; EnforcementEnabled = $EnforcementEnabled }
|
||||
if ($SchemaPath) { $params['SchemaPath'] = $SchemaPath }
|
||||
if ($PreviousConfigPath) { $params['PreviousConfigPath'] = $PreviousConfigPath }
|
||||
|
||||
$result = Test-PersonaConfiguration @params
|
||||
|
||||
$code = $EXIT_OK
|
||||
|
||||
# Order matters. An unreadable file and an unusable schema are environment
|
||||
# faults, not authoring faults, and a pipeline needs to tell them apart from a
|
||||
# genuinely invalid configuration - otherwise a missing schema file is reported
|
||||
# to the author as "your rules are wrong".
|
||||
if (@($result.Findings | Where-Object Code -In @('PE-SYN-001', 'PE-SYN-002')).Count -gt 0) {
|
||||
$code = $EXIT_CONFIG_UNREADABLE
|
||||
}
|
||||
elseif ($result.SchemaUnusable -or @($result.Findings | Where-Object Code -EQ 'PE-SCH-002').Count -gt 0) {
|
||||
$code = $EXIT_SCHEMA_UNUSABLE
|
||||
}
|
||||
elseif (-not $result.IsValid) {
|
||||
$code = $EXIT_FINDINGS
|
||||
}
|
||||
elseif ($TreatWarningsAsErrors -and $result.WarningCount -gt 0) {
|
||||
# VR-005: warnings block only here. The finding severity is unchanged - the
|
||||
# configuration is not retroactively more broken because a switch was passed;
|
||||
# what changed is the caller's tolerance for it.
|
||||
$code = $EXIT_WARNINGS_AS_ERRORS
|
||||
}
|
||||
|
||||
[pscustomobject]@{ Result = $result; ExitCode = $code }
|
||||
}
|
||||
|
||||
function Invoke-SyntheticRuleTest {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Evaluates the configuration's rules against synthetic users (FR-025).
|
||||
|
||||
.DESCRIPTION
|
||||
No tenant, no credentials, no network. Reads the fixture files, builds
|
||||
normalized records, and runs the real engine over them.
|
||||
|
||||
Fixtures declare their membership facets explicitly, including failed ones.
|
||||
A fixture with an unretrieved facet is the only way to see EvaluationError
|
||||
behaviour before it happens against a live directory.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Configuration,
|
||||
[Parameter(Mandatory)] [string] $DataPath
|
||||
)
|
||||
|
||||
$usersFile = (Test-Path -LiteralPath $DataPath -PathType Container) `
|
||||
? (Join-Path $DataPath 'Users/users.json') `
|
||||
: $DataPath
|
||||
|
||||
if (-not (Test-Path -LiteralPath $usersFile -PathType Leaf)) {
|
||||
Write-Host "No synthetic user fixtures found at '$usersFile'." -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
|
||||
$fixtures = (Get-Content -LiteralPath $usersFile -Raw | ConvertFrom-Json -Depth 32).users
|
||||
|
||||
$membershipFile = Join-Path (Split-Path (Split-Path $usersFile -Parent) -Parent) 'Memberships/memberships.json'
|
||||
$membershipByFixture = @{}
|
||||
|
||||
if (Test-Path -LiteralPath $membershipFile -PathType Leaf) {
|
||||
foreach ($entry in (Get-Content -LiteralPath $membershipFile -Raw | ConvertFrom-Json -Depth 32).memberships) {
|
||||
$params = @{
|
||||
DirectGroupObjectIds = @($entry.directGroupObjectIds)
|
||||
TransitiveGroupObjectIds = @($entry.transitiveGroupObjectIds)
|
||||
DirectoryRoleIds = @($entry.directoryRoleIds)
|
||||
}
|
||||
foreach ($facet in @('allRetrieved', 'directRetrieved', 'transitiveRetrieved', 'rolesRetrieved')) {
|
||||
if ($entry.PSObject.Properties[$facet] -and $entry.$facet) {
|
||||
$params[(Get-Culture).TextInfo.ToTitleCase($facet)] = $true
|
||||
}
|
||||
}
|
||||
foreach ($facet in @('directFailureReason', 'transitiveFailureReason', 'rolesFailureReason')) {
|
||||
if ($entry.PSObject.Properties[$facet] -and $entry.$facet) {
|
||||
$params[(Get-Culture).TextInfo.ToTitleCase($facet)] = [string]$entry.$facet
|
||||
}
|
||||
}
|
||||
$membershipByFixture[[string]$entry.fixtureId] = New-PersonaMembershipRecord @params
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'Synthetic rule test - no tenant connectivity (FR-025, SC-008)' -ForegroundColor Cyan
|
||||
Write-Host ('{0,-24} {1,-45} {2,-20} {3}' -f 'Fixture', 'UPN', 'Outcome', 'Persona / rule') -ForegroundColor DarkGray
|
||||
|
||||
foreach ($fixture in $fixtures) {
|
||||
$properties = @{}
|
||||
if ($fixture.properties) {
|
||||
foreach ($p in $fixture.properties.PSObject.Properties) { $properties[$p.Name] = $p.Value }
|
||||
}
|
||||
|
||||
$record = New-PersonaUserRecord `
|
||||
-AccountObjectId ([string]$fixture.accountObjectId) `
|
||||
-UserPrincipalName ([string]$fixture.userPrincipalName) `
|
||||
-DisplayName ([string]$fixture.displayName) `
|
||||
-UserType ([string]$fixture.userType) `
|
||||
-AccountEnabled ([bool]$fixture.accountEnabled) `
|
||||
-Properties $properties `
|
||||
-StoredPersona ([string]$fixture.storedPersona) `
|
||||
-Membership $membershipByFixture[[string]$fixture.fixtureId]
|
||||
|
||||
$result = Resolve-UserPersona -UserRecord $record -Rules $Configuration.Rules `
|
||||
-MaxDepth $Configuration.MaxConditionDepth `
|
||||
-DefaultMembershipMode $Configuration.DefaultMembershipMode
|
||||
|
||||
$colour = switch ($result.Outcome) {
|
||||
'Matched' { 'Green' }
|
||||
'Unclassified' { 'Gray' }
|
||||
'EvaluationError' { 'Yellow' }
|
||||
}
|
||||
|
||||
$detail = $result.Outcome -eq 'Matched' `
|
||||
? "$($result.CalculatedPersona) [$($result.MatchedRuleId)]" `
|
||||
: [string]$result.EvaluationErrorReason
|
||||
|
||||
Write-Host ('{0,-24} {1,-45} {2,-20} {3}' -f
|
||||
$fixture.fixtureId, $result.UserPrincipalName, $result.Outcome, $detail) -ForegroundColor $colour
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
}
|
||||
|
||||
function Save-PersonaConfiguration {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Saves an edited configuration, backing up first (FR-026).
|
||||
|
||||
.DESCRIPTION
|
||||
The save order is the contract, and it is deliberate:
|
||||
|
||||
1. Re-validate in full.
|
||||
2. Refuse on any Error finding.
|
||||
3. Back up the existing file, or write to -OutputPath instead.
|
||||
4. Only then replace.
|
||||
|
||||
Backing up before replacing rather than after is what makes step 4 safe to
|
||||
interrupt. A crash between backup and write leaves the original and a copy;
|
||||
a crash the other way round leaves neither.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Document,
|
||||
[Parameter(Mandatory)] [string] $Path,
|
||||
[string] $SaveAs
|
||||
)
|
||||
|
||||
$json = $Document | ConvertTo-Json -Depth 32
|
||||
|
||||
$temp = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
Set-Content -LiteralPath $temp -Value $json -Encoding utf8NoBOM
|
||||
|
||||
$check = Invoke-ConfigurationValidation -Path $temp
|
||||
if (-not $check.Result.IsValid) {
|
||||
Write-Host 'Save blocked: the edited configuration has Error findings.' -ForegroundColor Red
|
||||
Write-PersonaValidationFinding -Findings $check.Result.Findings
|
||||
return $false
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$destination = $SaveAs ? $SaveAs : $Path
|
||||
|
||||
if ((Test-Path -LiteralPath $destination -PathType Leaf) -and -not $SaveAs) {
|
||||
$backup = '{0}.{1}.bak' -f $destination, [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ')
|
||||
|
||||
if (-not $PSCmdlet.ShouldProcess($destination, "Back up to '$backup' and replace")) {
|
||||
Write-Host 'Save cancelled.' -ForegroundColor Yellow
|
||||
return $false
|
||||
}
|
||||
|
||||
Copy-Item -LiteralPath $destination -Destination $backup -Force
|
||||
Write-Host "Backup written: $backup" -ForegroundColor DarkGray
|
||||
}
|
||||
elseif (-not $PSCmdlet.ShouldProcess($destination, 'Write configuration')) {
|
||||
Write-Host 'Save cancelled.' -ForegroundColor Yellow
|
||||
return $false
|
||||
}
|
||||
|
||||
Set-Content -LiteralPath $destination -Value $json -Encoding utf8NoBOM
|
||||
Write-Host "Saved: $destination" -ForegroundColor Green
|
||||
$true
|
||||
}
|
||||
|
||||
function Test-PersonaCandidateEdit {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Applies a structural edit to a cloned document and re-validates it (FR-030).
|
||||
|
||||
.DESCRIPTION
|
||||
Deep-clones Document via a JSON round trip (nested objects are reference
|
||||
types, so mutating Document directly would leave a half-applied edit in
|
||||
place if validation then rejected it), runs Apply against the clone, and
|
||||
validates the clone in full - the identical check the `[V]` command and
|
||||
`Save-PersonaConfiguration` use, so add/edit/delete can never be held to a
|
||||
looser standard than a hand edit.
|
||||
|
||||
The document entering the interactive editor is already Error-free, so a
|
||||
clone that fails here failed because of Apply, not because of pre-existing
|
||||
state.
|
||||
|
||||
.PARAMETER Document
|
||||
The current in-memory configuration document.
|
||||
|
||||
.PARAMETER Apply
|
||||
A scriptblock taking the clone as its first parameter, followed by whatever
|
||||
ApplyArgs supplies, and mutating the clone in place. Values are passed as
|
||||
explicit arguments rather than closed over from the caller's scope: `&`
|
||||
invocation runs a scriptblock in a new child scope of wherever it is invoked
|
||||
(here, inside this function) rather than of the scope where the scriptblock
|
||||
literal was written, so a bare `{ $doc.rules = ... $id ... }` would see $id as
|
||||
unbound. Explicit parameters sidestep that entirely.
|
||||
|
||||
.PARAMETER ApplyArgs
|
||||
Positional arguments passed to Apply after the clone.
|
||||
|
||||
.OUTPUTS
|
||||
pscustomobject with Applied (bool), Document (the clone if Applied, otherwise
|
||||
the original Document, unchanged), and Result (the validation result).
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Document,
|
||||
[Parameter(Mandatory)] [scriptblock] $Apply,
|
||||
[object[]] $ApplyArgs = @()
|
||||
)
|
||||
|
||||
$clone = $Document | ConvertTo-Json -Depth 32 | ConvertFrom-Json -Depth 32
|
||||
& $Apply $clone @ApplyArgs
|
||||
|
||||
$temp = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
Set-Content -LiteralPath $temp -Value ($clone | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
|
||||
$check = Invoke-ConfigurationValidation -Path $temp
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
Applied = $check.Result.IsValid
|
||||
Document = $check.Result.IsValid ? $clone : $Document
|
||||
Result = $check.Result
|
||||
}
|
||||
}
|
||||
|
||||
function ConvertTo-PersonaConditionPath {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Parses an operator-typed path like "1.0" into the int[] Get-PersonaConditionNode expects.
|
||||
|
||||
.DESCRIPTION
|
||||
Blank input, or the literal word "root", addresses the rule's match group
|
||||
itself.
|
||||
#>
|
||||
param([string] $Text)
|
||||
|
||||
$trimmed = ([string]$Text).Trim()
|
||||
if ([string]::IsNullOrEmpty($trimmed) -or $trimmed -eq 'root') { return , @() }
|
||||
|
||||
$indices = foreach ($part in ($trimmed -split '[.\s]+' | Where-Object { $_ -ne '' })) {
|
||||
$value = 0
|
||||
if (-not [int]::TryParse($part, [ref] $value)) {
|
||||
throw "'$part' is not a valid path segment - use dot-separated indices, for example '1.0'."
|
||||
}
|
||||
$value
|
||||
}
|
||||
|
||||
, @($indices)
|
||||
}
|
||||
|
||||
function Read-PersonaConditionLeafFields {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Prompts for one leaf condition's fields, in the shape Set-PersonaConditionLeaf expects.
|
||||
#>
|
||||
Write-Host ' Condition type: [P]roperty [M]embership [R]ole' -ForegroundColor DarkGray
|
||||
$typeChoice = (Read-Host ' Type').Trim().ToUpperInvariant()
|
||||
$type = switch ($typeChoice) { 'P' { 'property' } 'M' { 'membership' } 'R' { 'role' } default { $null } }
|
||||
if (-not $type) { throw "Unrecognized condition type '$typeChoice'." }
|
||||
|
||||
Write-Host ' Operators: equals notEquals contains notContains startsWith endsWith matchesRegex in notIn isNull isNotNull memberOf notMemberOf' -ForegroundColor DarkGray
|
||||
$operator = (Read-Host ' Operator').Trim()
|
||||
|
||||
$fields = @{ type = $type; operator = $operator }
|
||||
|
||||
switch ($type) {
|
||||
'property' { $fields['property'] = (Read-Host ' Property name').Trim() }
|
||||
'membership' {
|
||||
$ids = (Read-Host ' Group Object IDs (comma-separated)')
|
||||
$fields['groupObjectIds'] = @($ids -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
||||
$mode = (Read-Host ' Membership mode (direct/transitive, blank = configuration default)').Trim()
|
||||
if ($mode) { $fields['membershipMode'] = $mode }
|
||||
}
|
||||
'role' {
|
||||
$ids = (Read-Host ' Role IDs (comma-separated)')
|
||||
$fields['roleIds'] = @($ids -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
||||
}
|
||||
}
|
||||
|
||||
if ($operator -in @('in', 'notIn')) {
|
||||
$values = (Read-Host ' Values (comma-separated)')
|
||||
$fields['values'] = @($values -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
||||
}
|
||||
elseif ($operator -notin @('isNull', 'isNotNull', 'memberOf', 'notMemberOf')) {
|
||||
$fields['value'] = Read-Host ' Comparison value'
|
||||
}
|
||||
|
||||
$fields
|
||||
}
|
||||
|
||||
function Read-PersonaConditionNode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Interactively builds one condition or nested group (FR-027, FR-028).
|
||||
|
||||
.DESCRIPTION
|
||||
Recurses for a group's children. Nesting depth is not limited here - see
|
||||
Test-PersonaCandidateEdit and Add-PersonaConditionNode's doc comment for why
|
||||
depth is enforced by the real validator instead of a second, local count.
|
||||
#>
|
||||
param([int] $Depth = 1)
|
||||
|
||||
$indent = ' ' * $Depth
|
||||
Write-Host ("{0}[C]ondition or [G]roup?" -f $indent) -ForegroundColor DarkGray
|
||||
$kind = (Read-Host "$indent Kind").Trim().ToUpperInvariant()
|
||||
|
||||
if ($kind -eq 'G') {
|
||||
$operator = (Read-Host "$indent Group operator (all/any)").Trim().ToLowerInvariant()
|
||||
if ($operator -notin @('all', 'any')) { throw "Group operator must be 'all' or 'any'." }
|
||||
|
||||
$children = [System.Collections.Generic.List[object]]::new()
|
||||
do {
|
||||
$children.Add((Read-PersonaConditionNode -Depth ($Depth + 1)))
|
||||
$more = (Read-Host "$indent Add another condition to this group? (y/N)").Trim()
|
||||
} while ($more.ToLowerInvariant() -eq 'y')
|
||||
|
||||
return [pscustomobject]@{ operator = $operator; conditions = $children.ToArray() }
|
||||
}
|
||||
|
||||
[pscustomobject](Read-PersonaConditionLeafFields)
|
||||
}
|
||||
|
||||
function Read-PersonaRuleFields {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Prompts for a new rule's top-level fields (RE-001), excluding its match tree.
|
||||
#>
|
||||
$id = (Read-Host 'Rule id').Trim()
|
||||
$name = (Read-Host 'Rule name').Trim()
|
||||
$description = (Read-Host 'Description').Trim()
|
||||
|
||||
$priority = 0
|
||||
if (-not [int]::TryParse((Read-Host 'Priority (integer)'), [ref] $priority)) {
|
||||
throw 'Priority must be an integer.'
|
||||
}
|
||||
|
||||
$persona = (Read-Host 'Persona').Trim()
|
||||
$enabled = (Read-Host 'Enabled? (Y/n)').Trim().ToLowerInvariant() -ne 'n'
|
||||
|
||||
[pscustomobject]@{
|
||||
id = $id; name = $name; description = $description
|
||||
enabled = $enabled; priority = $priority; persona = $persona
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-InteractiveEditor {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
The interactive loop (FR-023).
|
||||
|
||||
.DESCRIPTION
|
||||
Toggles rules, adjusts priorities, adds/edits/deletes whole rules including
|
||||
their condition trees (FR-027 - FR-030), re-validates, tests against
|
||||
fixtures, and saves.
|
||||
|
||||
Every structural edit (add/edit/delete) goes through Test-PersonaCandidateEdit,
|
||||
which applies it to a clone and re-runs full validation before it is allowed
|
||||
to reach $Document. The live document entering this loop is already
|
||||
Error-free - the main script exits before this function is ever called
|
||||
otherwise - so a clone that fails validation failed because of the edit just
|
||||
applied, and is discarded rather than committed. This is what makes
|
||||
structural editing safe to add at all: the editor can never produce a worse
|
||||
document than a careful hand edit would, because it runs the same four
|
||||
validation layers a hand edit is checked against at save time.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Document,
|
||||
[Parameter(Mandatory)] [string] $Path
|
||||
)
|
||||
|
||||
$dirty = $false
|
||||
|
||||
while ($true) {
|
||||
Write-Host ''
|
||||
Write-Host 'Persona Engine configuration editor' -ForegroundColor Cyan
|
||||
Write-Host (" file {0}{1}" -f $Path, ($dirty ? ' [modified]' : ''))
|
||||
Write-Host (" version {0}" -f $Document.configVersion)
|
||||
Write-Host (" rules {0} ({1} enabled)" -f @($Document.rules).Count, @($Document.rules | Where-Object { $_.enabled }).Count)
|
||||
Write-Host ''
|
||||
Write-Host ' [L] list rules [T] toggle a rule [P] change a priority'
|
||||
Write-Host ' [A] add a rule [E] edit a rule [D] delete a rule'
|
||||
Write-Host ' [V] re-validate [R] run rule test [S] save'
|
||||
Write-Host ' [Q] quit'
|
||||
Write-Host ''
|
||||
|
||||
$choice = (Read-Host 'Choice').Trim().ToUpperInvariant()
|
||||
|
||||
switch ($choice) {
|
||||
|
||||
'L' {
|
||||
Write-Host ''
|
||||
foreach ($rule in ($Document.rules | Sort-Object { [int]$_.priority })) {
|
||||
Write-Host (' {0,-6} {1,-28} {2,-30} {3}' -f
|
||||
$rule.priority, $rule.id, $rule.persona,
|
||||
($rule.enabled ? 'enabled' : 'DISABLED')) -ForegroundColor ($rule.enabled ? 'Gray' : 'DarkGray')
|
||||
}
|
||||
}
|
||||
|
||||
'T' {
|
||||
$id = (Read-Host 'Rule ID to toggle').Trim()
|
||||
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
|
||||
|
||||
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
|
||||
|
||||
$rule.enabled = -not $rule.enabled
|
||||
$dirty = $true
|
||||
Write-Host ("Rule {0} is now {1}." -f $id, ($rule.enabled ? 'enabled' : 'disabled')) -ForegroundColor Green
|
||||
}
|
||||
|
||||
'P' {
|
||||
$id = (Read-Host 'Rule ID').Trim()
|
||||
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
|
||||
|
||||
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
|
||||
|
||||
$value = 0
|
||||
if (-not [int]::TryParse((Read-Host 'New priority'), [ref] $value)) {
|
||||
Write-Host 'Priority must be an integer.' -ForegroundColor Yellow
|
||||
break
|
||||
}
|
||||
|
||||
$rule.priority = $value
|
||||
$dirty = $true
|
||||
Write-Host "Rule $id priority is now $value. Re-validate before saving - a reorder changes which rule wins." -ForegroundColor Green
|
||||
}
|
||||
|
||||
'A' {
|
||||
# FR-027. The candidate rule is built entirely from prompts before
|
||||
# Test-PersonaCandidateEdit ever sees it, so a collision or a
|
||||
# validation failure is reported once, against the whole rule, rather
|
||||
# than mid-build against a rule that only half exists yet.
|
||||
try {
|
||||
$fields = Read-PersonaRuleFields
|
||||
|
||||
Write-Host 'Build the condition tree for this rule (the root group):' -ForegroundColor Cyan
|
||||
$rootOperator = (Read-Host 'Root operator (all/any)').Trim().ToLowerInvariant()
|
||||
if ($rootOperator -notin @('all', 'any')) { throw "Root operator must be 'all' or 'any'." }
|
||||
|
||||
$rootChildren = [System.Collections.Generic.List[object]]::new()
|
||||
do {
|
||||
$rootChildren.Add((Read-PersonaConditionNode -Depth 1))
|
||||
$more = (Read-Host 'Add another top-level condition? (y/N)').Trim()
|
||||
} while ($more.ToLowerInvariant() -eq 'y')
|
||||
|
||||
$newRule = [pscustomobject]@{
|
||||
id = $fields.id
|
||||
name = $fields.name
|
||||
description = $fields.description
|
||||
enabled = $fields.enabled
|
||||
priority = $fields.priority
|
||||
persona = $fields.persona
|
||||
match = [pscustomobject]@{ operator = $rootOperator; conditions = $rootChildren.ToArray() }
|
||||
}
|
||||
|
||||
$attempt = Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($newRule) -Apply {
|
||||
param($doc, $rule)
|
||||
$doc.rules = Add-PersonaConfigRule -Rules $doc.rules -Rule $rule
|
||||
}
|
||||
|
||||
if ($attempt.Applied) {
|
||||
$Document = $attempt.Document
|
||||
$dirty = $true
|
||||
Write-Host "Rule $($newRule.id) added." -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host 'Add rejected: the resulting configuration would be invalid.' -ForegroundColor Red
|
||||
Write-PersonaValidationFinding -Findings $attempt.Result.Findings
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host "Add cancelled: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
'E' {
|
||||
# FR-028. One structural operation per pass through this case - the
|
||||
# operator repeats [E] to make further changes, which keeps each
|
||||
# Test-PersonaCandidateEdit call scoped to one edit and one finding
|
||||
# set, rather than a batch where a rejection leaves it unclear which
|
||||
# of several changes caused it.
|
||||
try {
|
||||
$id = (Read-Host 'Rule id to edit').Trim()
|
||||
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
|
||||
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
|
||||
|
||||
Write-Host ("Editing rule {0} ({1})." -f $rule.id, $rule.name) -ForegroundColor Cyan
|
||||
Write-Host ' [F] change a top-level field [C] add a condition/group'
|
||||
Write-Host ' [E] edit a leaf condition [X] remove a condition/group'
|
||||
Write-Host ' [B] back to the main menu, no change'
|
||||
|
||||
$subChoice = (Read-Host ' Edit action').Trim().ToUpperInvariant()
|
||||
|
||||
$attempt = switch ($subChoice) {
|
||||
|
||||
'F' {
|
||||
$field = (Read-Host ' Field (name/description/priority/persona/enabled)').Trim().ToLowerInvariant()
|
||||
$value = Read-Host ' New value'
|
||||
|
||||
Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($id, $field, $value) -Apply {
|
||||
param($doc, $ruleId, $fieldName, $newValue)
|
||||
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
|
||||
switch ($fieldName) {
|
||||
'priority' { $target.priority = [int]$newValue }
|
||||
'enabled' { $target.enabled = $newValue.Trim().ToLowerInvariant() -in @('y', 'yes', 'true', '1') }
|
||||
'name' { $target.name = $newValue }
|
||||
'description' { $target.description = $newValue }
|
||||
'persona' { $target.persona = $newValue }
|
||||
default { throw "Unrecognized field '$fieldName'." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
'C' {
|
||||
$parentPath = ConvertTo-PersonaConditionPath -Text (Read-Host ' Parent path (blank = root, e.g. "1.0")')
|
||||
$newNode = Read-PersonaConditionNode -Depth ($parentPath.Count + 1)
|
||||
|
||||
# ApplyArgs is built with .Add() rather than @(...), because an
|
||||
# int[] placed inside a `@()` array literal is unrolled into the
|
||||
# outer array (the same gotcha Get-CapturedAuditRecord's comma
|
||||
# idiom guards against elsewhere) - @($id, $parentPath, $newNode)
|
||||
# would silently flatten a two-segment path into two extra
|
||||
# positional arguments instead of passing it as one array.
|
||||
$args = [System.Collections.Generic.List[object]]::new()
|
||||
$args.Add($id); $args.Add($parentPath); $args.Add($newNode)
|
||||
|
||||
Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply {
|
||||
param($doc, $ruleId, $parentPathArg, $node)
|
||||
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
|
||||
Add-PersonaConditionNode -Group $target.match -ParentPath $parentPathArg -Node $node | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
'E' {
|
||||
$path = ConvertTo-PersonaConditionPath -Text (Read-Host ' Leaf condition path (e.g. "1.0")')
|
||||
if ($path.Count -eq 0) { throw 'The root is a group, not a leaf condition - navigate to a leaf path.' }
|
||||
$fields = Read-PersonaConditionLeafFields
|
||||
|
||||
$args = [System.Collections.Generic.List[object]]::new()
|
||||
$args.Add($id); $args.Add($path); $args.Add($fields)
|
||||
|
||||
Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply {
|
||||
param($doc, $ruleId, $pathArg, $fieldsArg)
|
||||
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
|
||||
$leaf = Get-PersonaConditionNode -Group $target.match -Path $pathArg
|
||||
Set-PersonaConditionLeaf -Node $leaf -Fields $fieldsArg | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
'X' {
|
||||
$path = ConvertTo-PersonaConditionPath -Text (Read-Host ' Condition/group path to remove (e.g. "1.0")')
|
||||
|
||||
$args = [System.Collections.Generic.List[object]]::new()
|
||||
$args.Add($id); $args.Add($path)
|
||||
|
||||
Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply {
|
||||
param($doc, $ruleId, $pathArg)
|
||||
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
|
||||
Remove-PersonaConditionNode -Group $target.match -Path $pathArg | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
'B' { $null }
|
||||
|
||||
default { Write-Host 'Unrecognized edit action.' -ForegroundColor Yellow; $null }
|
||||
}
|
||||
|
||||
if ($null -ne $attempt) {
|
||||
if ($attempt.Applied) {
|
||||
$Document = $attempt.Document
|
||||
$dirty = $true
|
||||
Write-Host "Rule $id updated." -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host 'Edit rejected: the resulting configuration would be invalid.' -ForegroundColor Red
|
||||
Write-PersonaValidationFinding -Findings $attempt.Result.Findings
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host "Edit cancelled: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
'D' {
|
||||
# FR-029. Confirmation names id, name, and priority before anything is
|
||||
# touched, matching the acceptance criterion literally rather than a
|
||||
# generic "are you sure?".
|
||||
try {
|
||||
$id = (Read-Host 'Rule id to delete').Trim()
|
||||
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
|
||||
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
|
||||
|
||||
Write-Host ("About to delete rule {0} '{1}' priority {2}." -f $rule.id, $rule.name, $rule.priority) -ForegroundColor Yellow
|
||||
$confirm = (Read-Host 'Delete this rule? (y/N)').Trim().ToLowerInvariant()
|
||||
|
||||
if ($confirm -ne 'y') {
|
||||
Write-Host 'Delete cancelled.' -ForegroundColor Yellow
|
||||
break
|
||||
}
|
||||
|
||||
$attempt = Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($id) -Apply {
|
||||
param($doc, $ruleId)
|
||||
$doc.rules = Remove-PersonaConfigRule -Rules $doc.rules -RuleId $ruleId
|
||||
}
|
||||
|
||||
if ($attempt.Applied) {
|
||||
$Document = $attempt.Document
|
||||
$dirty = $true
|
||||
Write-Host "Rule $id deleted." -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host 'Delete rejected: the resulting configuration would be invalid.' -ForegroundColor Red
|
||||
Write-PersonaValidationFinding -Findings $attempt.Result.Findings
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host "Delete cancelled: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
'V' {
|
||||
$temp = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
Set-Content -LiteralPath $temp -Value ($Document | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
|
||||
$check = Invoke-ConfigurationValidation -Path $temp
|
||||
Write-PersonaValidationFinding -Findings $check.Result.Findings
|
||||
}
|
||||
finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
'R' {
|
||||
$dataPath = $TestDataPath ? $TestDataPath : (Join-Path $PSScriptRoot 'tests/TestData')
|
||||
$temp = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
Set-Content -LiteralPath $temp -Value ($Document | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
|
||||
Invoke-SyntheticRuleTest -Configuration (Import-PersonaConfiguration -Path $temp) -DataPath $dataPath
|
||||
}
|
||||
finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
'S' {
|
||||
if (Save-PersonaConfiguration -Document $Document -Path $Path -SaveAs $OutputPath) { $dirty = $false }
|
||||
}
|
||||
|
||||
'Q' {
|
||||
if ($dirty) {
|
||||
$confirm = (Read-Host 'Unsaved changes will be lost. Quit anyway? (y/N)').Trim()
|
||||
if ($confirm -ne 'y') { break }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
default { Write-Host 'Unrecognized choice.' -ForegroundColor Yellow }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================ main
|
||||
|
||||
$validation = Invoke-ConfigurationValidation -Path $ConfigPath
|
||||
|
||||
Write-PersonaValidationFinding -Findings $validation.Result.Findings
|
||||
|
||||
if ($validation.ExitCode -ne $EXIT_OK) {
|
||||
Write-Host ("Validation failed with exit code {0}." -f $validation.ExitCode) -ForegroundColor Red
|
||||
exit $validation.ExitCode
|
||||
}
|
||||
|
||||
if ($TestDataPath) {
|
||||
Invoke-SyntheticRuleTest -Configuration (Import-PersonaConfiguration -Path $ConfigPath) -DataPath $TestDataPath
|
||||
}
|
||||
|
||||
# -ValidateOnly and -NonInteractive both short-circuit before any prompting call.
|
||||
# The check is here, once, at the single point where the editor could be entered.
|
||||
if ($ValidateOnly -or $NonInteractive) {
|
||||
Write-Host 'Configuration is valid.' -ForegroundColor Green
|
||||
exit $EXIT_OK
|
||||
}
|
||||
|
||||
Invoke-InteractiveEditor -Document $validation.Result.Document -Path $ConfigPath
|
||||
|
||||
exit $EXIT_OK
|
||||
@@ -0,0 +1,271 @@
|
||||
#Requires -Version 7.2
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Classifies Entra ID user accounts against a configuration-driven rule set.
|
||||
|
||||
.DESCRIPTION
|
||||
The engine entry point: validate, connect, enumerate, evaluate, report, and -
|
||||
only when explicitly confirmed - persist.
|
||||
|
||||
SAFETY MODEL
|
||||
|
||||
Mode is derived from $PSCmdlet.ShouldProcess() and nothing else. There is no
|
||||
-Preview switch and no configuration key that suppresses writes. Two sources of
|
||||
truth for a write gate is precisely the defect class constitution Principle III
|
||||
exists to prevent: the day they disagree, one of them is wrong and the directory
|
||||
finds out first.
|
||||
|
||||
-WhatIf is the approved no-write control. Under it, reads happen, rules evaluate,
|
||||
values are compared, console output and summaries appear, and audit records are
|
||||
written exactly as they would be in enforcement. The single difference is that no
|
||||
write request is ever constructed (FR-017, SC-004).
|
||||
|
||||
-Debug enables condition tracing. It does NOT imply read-only. A -Debug run
|
||||
without -WhatIf writes, and a test asserts that it does - because an operator who
|
||||
believed otherwise would reach for -Debug as a safety measure.
|
||||
|
||||
.PARAMETER ConfigPath
|
||||
Path to the JSON configuration. Validated through all four layers before any
|
||||
connection is attempted (FR-002). Defaults to ./config/persona-engine.json,
|
||||
resolved against the current directory, when omitted.
|
||||
|
||||
.PARAMETER UserObjectId
|
||||
Evaluate a single user instead of enumerating the tenant. The recommended first
|
||||
run against any new configuration.
|
||||
|
||||
.PARAMETER OutputPath
|
||||
Overrides logging.path for this run. When neither this nor logging.path is set,
|
||||
audit records are written to '<current-directory>/logs/persona-engine-audit.ndjson'.
|
||||
|
||||
.PARAMETER CorrelationId
|
||||
Run identifier. Generated when absent. Appears on every audit record (NFR-005).
|
||||
|
||||
.PARAMETER SchemaPath
|
||||
Schema override for validation layer 2.
|
||||
|
||||
.PARAMETER PreviousConfigPath
|
||||
Currently deployed configuration, enabling the VR-003 drift checks.
|
||||
|
||||
.EXAMPLE
|
||||
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf
|
||||
|
||||
The standard preview run. Reports what would change; writes nothing.
|
||||
|
||||
.EXAMPLE
|
||||
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf -Verbose
|
||||
|
||||
Single-user preview, the recommended first run against a new configuration.
|
||||
|
||||
.OUTPUTS
|
||||
Audit records on the success stream when logging.destination includes 'stream'.
|
||||
The process exit code carries the run status; see the exit code table in
|
||||
specs/001-persona-engine/contracts/cli-invoke-persona-engine.md.
|
||||
|
||||
.NOTES
|
||||
Exit codes
|
||||
0 successful run
|
||||
1 configuration validation failure
|
||||
2 authentication or authorization failure
|
||||
3 user enumeration failure
|
||||
4 fatal required data-provider failure, or evaluationErrorThreshold exceeded
|
||||
5 reconciliation failure
|
||||
6 unexpected fatal engine error
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
|
||||
param(
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $ConfigPath = (Join-Path (Get-Location).Path 'config' 'persona-engine.json'),
|
||||
|
||||
[guid] $UserObjectId,
|
||||
|
||||
[string] $OutputPath,
|
||||
|
||||
[guid] $CorrelationId,
|
||||
|
||||
[string] $SchemaPath,
|
||||
|
||||
[string] $PreviousConfigPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$engineVersion = '0.1.0'
|
||||
$startedUtc = [DateTime]::UtcNow
|
||||
$runId = $CorrelationId -and $CorrelationId -ne [guid]::Empty ? $CorrelationId.ToString() : [guid]::NewGuid().ToString()
|
||||
|
||||
Import-Module (Join-Path $PSScriptRoot 'PersonaEngine.psd1') -Force -ErrorAction Stop
|
||||
|
||||
# Exit codes are named rather than sprinkled as literals so the contract table and
|
||||
# the code cannot drift apart.
|
||||
$EXIT_OK = 0
|
||||
$EXIT_CONFIG = 1
|
||||
$EXIT_AUTH = 2
|
||||
$EXIT_ENUMERATION = 3
|
||||
$EXIT_DATA = 4
|
||||
$EXIT_RECONCILIATION = 5
|
||||
$EXIT_UNEXPECTED = 6
|
||||
|
||||
$exitCode = $EXIT_OK
|
||||
$counters = $null
|
||||
$context = $null
|
||||
$sinkState = New-PersonaAuditSinkState
|
||||
|
||||
try {
|
||||
|
||||
# ======================================================== 1. Validate (FR-002)
|
||||
|
||||
Write-Verbose "Validating configuration: $ConfigPath"
|
||||
|
||||
$validationParams = @{ Path = $ConfigPath }
|
||||
if ($SchemaPath) { $validationParams['SchemaPath'] = $SchemaPath }
|
||||
if ($PreviousConfigPath) { $validationParams['PreviousConfigPath'] = $PreviousConfigPath }
|
||||
|
||||
$validation = Test-PersonaConfiguration @validationParams
|
||||
|
||||
if (-not $validation.IsValid) {
|
||||
Write-PersonaValidationFinding -Findings $validation.Findings
|
||||
Write-Host "Configuration validation failed with $($validation.ErrorCount) error(s). No connection was attempted." -ForegroundColor Red
|
||||
exit $EXIT_CONFIG
|
||||
}
|
||||
|
||||
if ($validation.WarningCount -gt 0) {
|
||||
Write-PersonaValidationFinding -Findings @($validation.Findings | Where-Object Severity -NE 'Information')
|
||||
}
|
||||
|
||||
$config = Import-PersonaConfiguration -Path $ConfigPath
|
||||
$target = Resolve-TargetAttribute -Configuration $config
|
||||
|
||||
# ======================================================== 2. Mode (FR-016, FR-017)
|
||||
|
||||
# The single write gate. Called once, before anything is read, so the mode is
|
||||
# known when the RunStart record is written and cannot change mid-run.
|
||||
#
|
||||
# Under -WhatIf this returns $false without prompting. Without -WhatIf, the High
|
||||
# confirm impact means the operator is asked to confirm the run; answering
|
||||
# "Yes to All" also satisfies the per-user gate below without further prompts.
|
||||
$runConfirmed = $PSCmdlet.ShouldProcess(
|
||||
"Entra ID directory - $($config.SourcePath)",
|
||||
"Classify users and write '$target' where the calculated persona differs")
|
||||
|
||||
$mode = $runConfirmed ? 'Enforce' : 'Preview'
|
||||
|
||||
$context = New-PersonaAuditContext -RunId $runId -EngineVersion $engineVersion -Configuration $config -Mode $mode
|
||||
|
||||
$destination = $config.Logging.destination ? [string]$config.Logging.destination : 'stream'
|
||||
|
||||
# No -OutputPath and no logging.path means "just log somewhere sane" rather than
|
||||
# "log nowhere" - an operator running a plain -WhatIf shouldn't have to configure
|
||||
# a file path just to see what the engine would have done.
|
||||
$configuredPath = [string]$config.Logging.path
|
||||
$defaultLogPath = Join-Path (Get-Location).Path 'logs' 'persona-engine-audit.ndjson'
|
||||
$logPath = $OutputPath ? $OutputPath : ($configuredPath ? $configuredPath : $defaultLogPath)
|
||||
$auditParams = @{ Destination = $destination; Path = $logPath; State = $sinkState }
|
||||
|
||||
# Lives next to the audit log rather than under its own config key for path -
|
||||
# one directory to point an operator at, not two. The file name alone is
|
||||
# configurable because "results.csv" may collide with something else already
|
||||
# written there.
|
||||
$resultsFileName = $config.Logging.resultsFileName ? [string]$config.Logging.resultsFileName : 'results.csv'
|
||||
$resultsPath = Join-Path (Split-Path -Parent $logPath) $resultsFileName
|
||||
|
||||
# Tracing is enabled by -Debug or by configuration, and requires acknowledgement
|
||||
# in the configuration either way (VR-003, enforced in validation layer 4).
|
||||
$traceRequested = $PSBoundParameters.ContainsKey('Debug') -or
|
||||
($config.Logging.traceConditionValues -and [bool]$config.Logging.traceConditionValues)
|
||||
$traceAcknowledged = $config.Logging.PSObject.Properties['acknowledgeConditionTracing'] -and
|
||||
[bool]$config.Logging.acknowledgeConditionTracing
|
||||
$tracing = [bool]($traceRequested -and $traceAcknowledged)
|
||||
|
||||
if ($traceRequested -and -not $traceAcknowledged) {
|
||||
Write-Warning 'Condition tracing was requested but logging.acknowledgeConditionTracing is not set. Tracing is disabled for this run (VR-003).'
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "Persona Engine $engineVersion run $runId mode $mode" -ForegroundColor Cyan
|
||||
Write-Host "Configuration $($config.ConfigVersion) hash $($config.ConfigurationHash.Substring(0, 16))..." -ForegroundColor DarkGray
|
||||
if ($mode -eq 'Preview') {
|
||||
Write-Host 'PREVIEW - no write request will be constructed or sent.' -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host ''
|
||||
|
||||
New-PersonaAuditRecord -Context $context -RecordType 'RunStart' -Properties @{
|
||||
configPath = $config.SourcePath
|
||||
targetAttribute = $target
|
||||
ruleCount = @($config.Rules).Count
|
||||
enabledRules = @($config.Rules | Where-Object { $_.enabled }).Count
|
||||
singleUser = [bool]($UserObjectId -and $UserObjectId -ne [guid]::Empty)
|
||||
tracing = $tracing
|
||||
} | Write-PersonaAuditRecord @auditParams
|
||||
|
||||
# ======================================================== 3. Connect (FR-003)
|
||||
|
||||
$facets = Get-PersonaRequiredFacets -Rules $config.Rules -DefaultMembershipMode $config.DefaultMembershipMode
|
||||
|
||||
try {
|
||||
$null = Connect-PersonaGraphInteractive `
|
||||
-IncludeGroups:([bool]($facets.Direct -or $facets.Transitive)) `
|
||||
-IncludeRoles:([bool]$facets.Roles) `
|
||||
-IncludeWrite:$runConfirmed
|
||||
}
|
||||
catch {
|
||||
Write-Host "Authentication failed: $($_.Exception.Message)" -ForegroundColor Red
|
||||
$exitCode = $EXIT_AUTH
|
||||
throw
|
||||
}
|
||||
|
||||
# ======================================================== 4. Run
|
||||
|
||||
# The per-user gate, passed down rather than re-derived. $PSCmdlet.ShouldProcess
|
||||
# remains the single origin of the write decision; the run loop never learns what
|
||||
# -WhatIf is and so cannot disagree with it.
|
||||
$gate = { param($Target, $Description) $PSCmdlet.ShouldProcess($Target, $Description) }.GetNewClosure()
|
||||
|
||||
$runParams = @{
|
||||
Configuration = $config
|
||||
TargetAttribute = $target
|
||||
Context = $context
|
||||
AuditParameters = $auditParams
|
||||
IsEnforcing = $runConfirmed
|
||||
ShouldProcessGate = $gate
|
||||
Tracing = $tracing
|
||||
StartedUtc = $startedUtc
|
||||
ResultsPath = $resultsPath
|
||||
}
|
||||
if ($UserObjectId -and $UserObjectId -ne [guid]::Empty) { $runParams['UserObjectId'] = $UserObjectId.ToString() }
|
||||
|
||||
$outcome = Invoke-PersonaEngineRun @runParams
|
||||
|
||||
$counters = $outcome.Counters
|
||||
if ($outcome.ExitCode -ne $EXIT_OK) { $exitCode = $outcome.ExitCode }
|
||||
|
||||
if ($outcome.FailureReason) {
|
||||
Write-Host "Run failed: $($outcome.FailureReason)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if ($exitCode -eq $EXIT_RECONCILIATION) {
|
||||
Write-Host 'Reconciliation failed. This is an engine defect, not a data condition (FR-021).' -ForegroundColor Red
|
||||
}
|
||||
elseif ($exitCode -eq $EXIT_DATA) {
|
||||
Write-Host ("EvaluationError count {0} exceeds the configured threshold of {1}. Reporting the run as failed." -f
|
||||
$counters.EvaluationError, $config.EvaluationErrorThreshold) -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($exitCode -eq $EXIT_OK) { $exitCode = $EXIT_UNEXPECTED }
|
||||
|
||||
Write-Host "Run terminated: $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Verbose $_.ScriptStackTrace
|
||||
}
|
||||
finally {
|
||||
# RunComplete is written even on a fatal error. A run that died at user 400 of
|
||||
# 5000 leaves a record saying exactly that, which is what distinguishes "stopped
|
||||
# early" from "never started" - two very different incidents that otherwise
|
||||
# produce identical evidence.
|
||||
if ($null -ne $context -and $null -ne $counters) {
|
||||
Export-PersonaRunReport -Context $context -Counters $counters -StartedUtc $startedUtc -ExitCode $exitCode |
|
||||
Write-PersonaAuditRecord @auditParams
|
||||
}
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
@@ -0,0 +1,34 @@
|
||||
@{
|
||||
Severity = @('Error', 'Warning')
|
||||
|
||||
IncludeRules = @(
|
||||
# Constitution Principle III: every state-changing function must support
|
||||
# ShouldProcess, so -WhatIf reaches all of them.
|
||||
'PSUseShouldProcessForStateChangingFunctions'
|
||||
'PSShouldProcess'
|
||||
|
||||
# Principle V: no credential material in source.
|
||||
'PSAvoidUsingPlainTextForPassword'
|
||||
'PSAvoidUsingConvertToSecureStringWithPlainText'
|
||||
'PSUsePSCredentialType'
|
||||
|
||||
# NFR-004 maintainability.
|
||||
'PSUseApprovedVerbs'
|
||||
'PSUseSingularNouns'
|
||||
'PSAvoidUsingCmdletAliases'
|
||||
'PSUseDeclaredVarsMoreThanAssignments'
|
||||
'PSAvoidUsingPositionalParameters'
|
||||
|
||||
# NFR-008 portability: no Windows PowerShell-only constructs.
|
||||
'PSUseCompatibleSyntax'
|
||||
)
|
||||
|
||||
Rules = @{
|
||||
PSUseCompatibleSyntax = @{
|
||||
Enable = $true
|
||||
# 7.2 is the floor because the Automation runtime version is unverified
|
||||
# until Stage B (research.md V-5b).
|
||||
TargetVersions = @('7.2')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
@{
|
||||
RootModule = 'PersonaEngine.psm1'
|
||||
ModuleVersion = '0.1.0'
|
||||
GUID = 'b3f1c2d4-5e6a-47b8-9c0d-1e2f3a4b5c6d'
|
||||
Author = 'Persona Engine maintainers'
|
||||
Description = 'Deterministic, configuration-driven identity classification for Microsoft Entra ID user objects.'
|
||||
|
||||
# PS 7.2 floor rather than 7.4: the Azure Automation runtime version is
|
||||
# unverified until Stage B (research.md V-5b). Do not raise without evidence.
|
||||
PowerShellVersion = '7.2'
|
||||
|
||||
# OTD-004: authentication and Invoke-MgGraphRequest only. No resource-specific
|
||||
# Graph SDK modules — keeping the Automation import surface to one module.
|
||||
RequiredModules = @('Microsoft.Graph.Authentication')
|
||||
|
||||
FunctionsToExport = @(
|
||||
# Configuration
|
||||
'Import-PersonaConfiguration'
|
||||
'Test-PersonaConfiguration'
|
||||
'Test-PersonaConfigurationSemantic'
|
||||
'Test-PersonaConfigurationSafety'
|
||||
'Write-PersonaValidationFinding'
|
||||
'Resolve-TargetAttribute'
|
||||
'Get-PersonaRequiredFacets'
|
||||
'New-PersonaValidationFinding'
|
||||
# Normalization
|
||||
'New-PersonaUserRecord'
|
||||
'New-PersonaMembershipRecord'
|
||||
'ConvertTo-PersonaUserRecord'
|
||||
'ConvertTo-PersonaMembershipRecord'
|
||||
# Rule engine
|
||||
'Test-PersonaCondition'
|
||||
'Test-PersonaConditionGroup'
|
||||
'Test-PersonaRule'
|
||||
'Resolve-UserPersona'
|
||||
# Authentication and data providers
|
||||
'Connect-PersonaGraphInteractive'
|
||||
'Invoke-PersonaGraphRequest'
|
||||
'Get-PersonaUsers'
|
||||
'Get-PersonaRequiredProperties'
|
||||
'Get-PersonaGroupMembership'
|
||||
'Get-PersonaDirectoryRoles'
|
||||
'New-PersonaDataCache'
|
||||
'Get-PersonaCachedMembership'
|
||||
# Persistence
|
||||
'Compare-PersonaValue'
|
||||
'New-PersonaWriteBody'
|
||||
'Set-UserPersonaAttribute'
|
||||
# Presentation
|
||||
'Write-UserPersonaResult'
|
||||
'Write-PersonaSummary'
|
||||
'New-PersonaRunCounter'
|
||||
'Add-PersonaRunResult'
|
||||
'Test-PersonaReconciliation'
|
||||
'Get-PersonaReconciliationDetail'
|
||||
'Export-PersonaResultsCsv'
|
||||
# Audit
|
||||
'New-PersonaAuditContext'
|
||||
'New-PersonaAuditRecord'
|
||||
'Write-PersonaAuditRecord'
|
||||
'New-PersonaAuditSinkState'
|
||||
'Export-PersonaRunReport'
|
||||
# Run loop
|
||||
'Invoke-PersonaEngineRun'
|
||||
)
|
||||
CmdletsToExport = @()
|
||||
VariablesToExport = @()
|
||||
AliasesToExport = @()
|
||||
|
||||
PrivateData = @{
|
||||
PSData = @{
|
||||
Tags = @('Entra', 'Identity', 'Classification')
|
||||
ProjectUri = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#Requires -Version 7.2
|
||||
|
||||
<#
|
||||
Module loader.
|
||||
|
||||
Dot-sources every function file under src/. Load order is layer-by-layer so a
|
||||
file may rely on functions from a layer loaded before it.
|
||||
|
||||
Note for tests: offline suites (rule engine, normalization, configuration)
|
||||
deliberately dot-source individual layer folders rather than importing this
|
||||
module, because importing the manifest pulls in Microsoft.Graph.Authentication.
|
||||
Keeping the pure layers loadable without that module is the practical proof of
|
||||
constitution Principle IV.
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$layerOrder = @(
|
||||
'Normalization'
|
||||
'Configuration'
|
||||
'RuleEngine'
|
||||
'Authentication'
|
||||
'DataProviders'
|
||||
'Persistence'
|
||||
'Presentation'
|
||||
'Engine'
|
||||
'Audit'
|
||||
)
|
||||
|
||||
foreach ($layer in $layerOrder) {
|
||||
$layerPath = Join-Path $PSScriptRoot "src/$layer"
|
||||
if (-not (Test-Path $layerPath)) { continue }
|
||||
|
||||
foreach ($file in Get-ChildItem -Path $layerPath -Filter '*.ps1' -File | Sort-Object Name) {
|
||||
. $file.FullName
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,274 @@ 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 0 — approved requirements baseline.**
|
||||
> No implementation code exists yet. The authoritative baseline is
|
||||
> [`Persona-Engine-Developer-Handoff.txt`](Persona-Engine-Developer-Handoff.txt).
|
||||
> The next step is Phase 1: convert that baseline into `specs/001-persona-engine/spec.md`.
|
||||
> **Project status: Stage A implementation complete — ready for tenant validation.**
|
||||
>
|
||||
> 109 of 121 tasks are done. 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 [checklist](#tenant-validation-checklist) in order.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive
|
||||
```
|
||||
|
||||
That validates the configuration through all four layers. No tenant, no credentials, no network. It
|
||||
is the fastest way to see what the engine does. Pass `-TestDataPath <dir>` with a directory of
|
||||
synthetic user/membership fixtures to also run the real rule engine against them and see what
|
||||
personas it would assign.
|
||||
|
||||
---
|
||||
|
||||
## 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 config editor's offline validation. |
|
||||
| PSScriptAnalyzer | Only if you want to run the lint checks locally. |
|
||||
| An Entra app registration | For the persona extension property and delegated scopes. |
|
||||
|
||||
|
||||
```powershell
|
||||
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
|
||||
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 ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive
|
||||
```
|
||||
|
||||
Exit code `0` expected. 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` |
|
||||
|
||||
`logging.path` was removed from the copy — it's optional and defaults to
|
||||
`<current-directory>/logs/persona-engine-audit.ndjson`. Set it (or pass `-OutputPath`) only if you
|
||||
want the audit log somewhere else.
|
||||
|
||||
> **`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 — 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).
|
||||
|
||||
---
|
||||
|
||||
## Tenant validation 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.
|
||||
|
||||
- [ ] **Lint**
|
||||
`pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1"`
|
||||
|
||||
- [ ] **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 <fixtures-dir> -ValidateOnly -NonInteractive`
|
||||
Point `-TestDataPath` at a directory of synthetic user/membership fixtures. If any of them
|
||||
simulate a failed membership lookup, that account must show `EvaluationError`, not a persona.
|
||||
If it shows a persona instead, 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -19,16 +283,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 |
|
||||
| Specify | `specs/001-persona-engine/spec.md` | 1 | Not started |
|
||||
| Plan | `specs/001-persona-engine/plan.md`, `research.md` | 2 | Not started |
|
||||
| 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/`, `docs/` | **109 / 121** — remainder needs a tenant or Automation |
|
||||
|
||||
---
|
||||
|
||||
@@ -45,232 +308,187 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## 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>` | Defaults to `./config/persona-engine.json`. Validated through all four layers before any connection. |
|
||||
| `-WhatIf` | **The approved no-write control.** |
|
||||
| `-UserObjectId <GUID>` | Single-user execution. |
|
||||
| `-OutputPath <string>` | Overrides `logging.path`. Both default to `<current-directory>/logs/persona-engine-audit.ndjson`. |
|
||||
| `-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. That purity is
|
||||
what lets it be evaluated offline against synthetic fixtures with no tenant connection.
|
||||
|
||||
---
|
||||
|
||||
## 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/
|
||||
docs/ Architecture.md SecurityModel.md ConfigurationReference.md
|
||||
RuleAuthoringGuide.md OperationsRunbook.md BusinessRules.md Logging.md
|
||||
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:
|
||||
Reserved domains (`example.com`, `.invalid`, `.test`) and the module manifest's own identity GUID are
|
||||
exempt; nothing else is. See [docs/SecurityModel.md](docs/SecurityModel.md) for the full policy.
|
||||
|
||||
`<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>`
|
||||
|
||||
---
|
||||
|
||||
## 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 1.
|
||||
|
||||
1. Initialize the Spec Kit project structure.
|
||||
2. Convert the handoff baseline into `specs/001-persona-engine/spec.md`.
|
||||
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 |
|
||||
| [RuleAuthoringGuide.md](docs/RuleAuthoringGuide.md) | User manual: every condition type and operator, worked examples, the interactive editor |
|
||||
| [BusinessRules.md](docs/BusinessRules.md) | Writing and changing rules — the judgement calls |
|
||||
| [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.
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
{
|
||||
"configVersion": "1.0.0",
|
||||
"metadata": {
|
||||
"owner": "<TEAM-NAME>",
|
||||
"changeReference": "<CHANGE-REFERENCE>",
|
||||
"description": "Placeholder-only example. Every identifier below is fictional. Real group Object IDs, attribute names, and domains belong in a protected configuration store, never in this repository."
|
||||
},
|
||||
"engine": {
|
||||
"targetAttribute": "extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>",
|
||||
"approvedWritableAttributes": [
|
||||
"extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>"
|
||||
],
|
||||
"maxConditionDepth": 5,
|
||||
"summaryInterval": 25,
|
||||
"defaultMembershipMode": "direct",
|
||||
"evaluationErrorThreshold": 50
|
||||
},
|
||||
"dataSources": {
|
||||
"groups": {
|
||||
"enabled": true
|
||||
},
|
||||
"roles": {
|
||||
"enabled": true,
|
||||
"includeEligible": false
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"destination": "both",
|
||||
"resultsFileName": "results.csv",
|
||||
"traceConditionValues": false
|
||||
},
|
||||
"personas": [
|
||||
"Guest",
|
||||
"BreakGlass-Admin",
|
||||
"Tier0-Admin",
|
||||
"Tier1-Admin",
|
||||
"Tier2-Admin",
|
||||
"Restricted-User",
|
||||
"Test-Account",
|
||||
"Service-Account",
|
||||
"Shared-Functional-Account",
|
||||
"Meeting-Room-Device",
|
||||
"Employee",
|
||||
"Contractor",
|
||||
"Student"
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"id": "RULE-0010-BREAKGLASS",
|
||||
"name": "Emergency access accounts",
|
||||
"description": "Emergency access accounts identified by immutable Object ID (RE-009). Evaluated first so no later rule can reclassify them.",
|
||||
"enabled": true,
|
||||
"priority": 10,
|
||||
"persona": "BreakGlass-Admin",
|
||||
"owner": "<TEAM-NAME>",
|
||||
"match": {
|
||||
"operator": "any",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "property",
|
||||
"property": "AccountObjectId",
|
||||
"operator": "in",
|
||||
"values": [
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
"00000000-0000-0000-0000-000000000002"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "RULE-0020-GUEST",
|
||||
"name": "Guest accounts",
|
||||
"description": "Any account whose directory user type is Guest.",
|
||||
"enabled": true,
|
||||
"priority": 20,
|
||||
"persona": "Guest",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"operator": "equals",
|
||||
"value": "Guest"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "RULE-0030-TIER0",
|
||||
"name": "Tier 0 administrators",
|
||||
"description": "Members of the Tier 0 administrative group, or holders of a Tier 0 directory role.",
|
||||
"enabled": true,
|
||||
"priority": 30,
|
||||
"persona": "Tier0-Admin",
|
||||
"match": {
|
||||
"operator": "any",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "membership",
|
||||
"operator": "memberOf",
|
||||
"membershipMode": "transitive",
|
||||
"groupObjectIds": [
|
||||
"00000000-0000-0000-0000-0000000000a0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "role",
|
||||
"operator": "memberOf",
|
||||
"roleIds": [
|
||||
"<TIER0-ROLE-TEMPLATE-ID>"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "RULE-0040-SERVICE",
|
||||
"name": "Service accounts",
|
||||
"description": "Non-human accounts identified by naming convention and the service account group. Both must hold, so a naming-convention collision alone cannot classify a person as a service account.",
|
||||
"enabled": true,
|
||||
"priority": 40,
|
||||
"persona": "Service-Account",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "property",
|
||||
"property": "UserPrincipalName",
|
||||
"operator": "startsWith",
|
||||
"value": "svc-"
|
||||
},
|
||||
{
|
||||
"type": "membership",
|
||||
"operator": "memberOf",
|
||||
"groupObjectIds": [
|
||||
"00000000-0000-0000-0000-0000000000b0"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "RULE-0050-TEST",
|
||||
"name": "Test accounts",
|
||||
"description": "Accounts in the test account group, or matching the test naming convention while disabled.",
|
||||
"enabled": true,
|
||||
"priority": 50,
|
||||
"persona": "Test-Account",
|
||||
"match": {
|
||||
"operator": "any",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "membership",
|
||||
"operator": "memberOf",
|
||||
"groupObjectIds": [
|
||||
"00000000-0000-0000-0000-0000000000c0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "property",
|
||||
"property": "UserPrincipalName",
|
||||
"operator": "startsWith",
|
||||
"value": "test-"
|
||||
},
|
||||
{
|
||||
"type": "property",
|
||||
"property": "AccountEnabled",
|
||||
"operator": "equals",
|
||||
"value": "False"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "RULE-0060-CONTRACTOR",
|
||||
"name": "Contractors",
|
||||
"description": "Accounts whose company name marks them as external, excluding those already classified by an earlier rule.",
|
||||
"enabled": true,
|
||||
"priority": 60,
|
||||
"persona": "Contractor",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "property",
|
||||
"property": "CompanyName",
|
||||
"operator": "isNotNull"
|
||||
},
|
||||
{
|
||||
"type": "property",
|
||||
"property": "CompanyName",
|
||||
"operator": "notEquals",
|
||||
"value": "<ORGANIZATION-NAME>"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "RULE-0900-EMPLOYEE",
|
||||
"name": "Employees",
|
||||
"description": "Default classification for enabled member accounts with a department. Lowest priority so every more specific rule wins first.",
|
||||
"enabled": true,
|
||||
"priority": 900,
|
||||
"persona": "Employee",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "property",
|
||||
"property": "UserType",
|
||||
"operator": "equals",
|
||||
"value": "Member"
|
||||
},
|
||||
{
|
||||
"type": "property",
|
||||
"property": "Department",
|
||||
"operator": "isNotNull"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://example.invalid/persona-engine.schema.json",
|
||||
"title": "Persona Engine Configuration",
|
||||
"description": "Draft-07 by decision OTD-005: validated with the built-in Test-Json -SchemaFile cmdlet, whose validator reliably supports draft-04/06/07 only. Do not introduce 2019-09 or 2020-12 constructs. This schema is validation layer 2 of 4; semantic rules (VR-002) and safety rules (VR-003) are enforced in PowerShell, not here.",
|
||||
"type": "object",
|
||||
"required": ["configVersion", "engine", "dataSources", "personas", "rules"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"configVersion": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$",
|
||||
"description": "Semantic version of this configuration. A downgrade is a safety violation (VR-003)."
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"owner": { "type": "string" },
|
||||
"changeReference": { "type": "string" },
|
||||
"description": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"engine": {
|
||||
"type": "object",
|
||||
"required": ["targetAttribute", "approvedWritableAttributes"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"targetAttribute": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "The single attribute the engine may write. Must also appear in approvedWritableAttributes (semantic layer). Example placeholder: extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>"
|
||||
},
|
||||
"approvedWritableAttributes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"maxConditionDepth": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 10,
|
||||
"default": 5,
|
||||
"description": "RE-004. The ceiling of 10 is a hard limit; the configured value may be lower."
|
||||
},
|
||||
"summaryInterval": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"default": 25,
|
||||
"description": "FR-020. Zero suppresses interim summaries; a final summary is always produced."
|
||||
},
|
||||
"defaultMembershipMode": {
|
||||
"type": "string",
|
||||
"enum": ["direct", "transitive"],
|
||||
"default": "direct"
|
||||
},
|
||||
"evaluationErrorThreshold": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Optional. Count of EvaluationError results above which the run reports failure."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dataSources": {
|
||||
"type": "object",
|
||||
"required": ["groups", "roles"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"groups": {
|
||||
"type": "object",
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"membershipMode": { "type": "string", "enum": ["direct", "transitive"] }
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"type": "object",
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"includeEligible": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Out of scope for v1 unless authorization is confirmed and the provider is implemented."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"destination": {
|
||||
"type": "string",
|
||||
"enum": ["file", "stream", "both"],
|
||||
"default": "both",
|
||||
"description": "OTD-006. Additional transports are added behind the sink function, not by widening this enum without a version change."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "NDJSON output file for 'file'/'both' destinations. Defaults to <current-directory>/logs/persona-engine-audit.ndjson when unset."
|
||||
},
|
||||
"resultsFileName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"default": "results.csv",
|
||||
"description": "Per-account results CSV (AccountObjectId, UserPrincipalName, persona/status), written alongside the audit log and overwritten on every summary."
|
||||
},
|
||||
"traceConditionValues": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Diagnostic only. Enabling this without explicit acknowledgement is a safety finding (VR-003)."
|
||||
},
|
||||
"acknowledgeConditionTracing": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Explicit acknowledgement that condition-value tracing writes evaluated attribute values into audit records. Required by VR-003 whenever traceConditionValues is true. Kept in the configuration rather than passed as a command-line flag so the acknowledgement is reviewable in the change that enables tracing."
|
||||
}
|
||||
}
|
||||
},
|
||||
"personas": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"not": { "enum": ["EvaluationError"] }
|
||||
},
|
||||
"description": "Defined persona catalogue. EvaluationError is an execution result and must never be declared as a persona."
|
||||
},
|
||||
"rules": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/definitions/rule" }
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"rule": {
|
||||
"type": "object",
|
||||
"required": ["id", "name", "description", "enabled", "priority", "persona", "match"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"description": { "type": "string", "minLength": 1 },
|
||||
"enabled": { "type": "boolean" },
|
||||
"priority": { "type": "integer", "minimum": 0 },
|
||||
"persona": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"not": { "enum": ["Unclassified", "EvaluationError"] },
|
||||
"description": "Unclassified is a processing result, not a rule outcome (VR-002)."
|
||||
},
|
||||
"match": { "$ref": "#/definitions/conditionGroup" },
|
||||
"tags": { "type": "array", "items": { "type": "string" } },
|
||||
"owner": { "type": "string" },
|
||||
"changeReference": { "type": "string" },
|
||||
"effectiveDate": {
|
||||
"type": "string",
|
||||
"format": "date",
|
||||
"description": "Metadata only in v1. It must not gate evaluation — a date-dependent decision would break determinism (Principle I)."
|
||||
},
|
||||
"notes": { "type": "string" },
|
||||
"testCases": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name", "expectedMatch"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"expectedMatch": { "type": "boolean" },
|
||||
"user": { "type": "object" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"conditionGroup": {
|
||||
"type": "object",
|
||||
"required": ["operator", "conditions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"operator": { "type": "string", "enum": ["all", "any"] },
|
||||
"conditions": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{ "$ref": "#/definitions/conditionGroup" },
|
||||
{ "$ref": "#/definitions/condition" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"condition": {
|
||||
"type": "object",
|
||||
"required": ["type", "operator"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["property", "membership", "role"] },
|
||||
"property": { "type": "string", "minLength": 1 },
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"equals", "notEquals", "contains", "notContains",
|
||||
"startsWith", "endsWith", "matchesRegex",
|
||||
"in", "notIn", "isNull", "isNotNull",
|
||||
"memberOf", "notMemberOf"
|
||||
]
|
||||
},
|
||||
"value": { "type": "string" },
|
||||
"values": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"groupObjectIds": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "$ref": "#/definitions/guid" }
|
||||
},
|
||||
"roleIds": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"membershipMode": { "type": "string", "enum": ["direct", "transitive"] },
|
||||
"caseSensitive": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Reserved. Condition-level case sensitivity is out of scope for v1; the schema accepts the key so a later version does not require a breaking change."
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": { "properties": { "type": { "const": "property" } }, "required": ["type"] },
|
||||
"then": { "required": ["property"] }
|
||||
},
|
||||
{
|
||||
"if": { "properties": { "type": { "const": "membership" } }, "required": ["type"] },
|
||||
"then": { "required": ["groupObjectIds"] }
|
||||
},
|
||||
{
|
||||
"if": { "properties": { "type": { "const": "role" } }, "required": ["type"] },
|
||||
"then": { "required": ["roleIds"] }
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": { "operator": { "enum": ["in", "notIn"] } },
|
||||
"required": ["operator"]
|
||||
},
|
||||
"then": { "required": ["values"] }
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": { "operator": { "enum": ["isNull", "isNotNull"] } },
|
||||
"required": ["operator"]
|
||||
},
|
||||
"then": {
|
||||
"allOf": [
|
||||
{ "not": { "required": ["value"] } },
|
||||
{ "not": { "required": ["values"] } }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"guid": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
# Architecture
|
||||
|
||||
How Persona Engine is put together, and why the boundaries sit where they do.
|
||||
|
||||
## The one structural rule
|
||||
|
||||
The rule engine is pure. It takes a normalized record and a rule set, and returns a decision. It has
|
||||
no knowledge of Microsoft Graph, no authentication, no console, no filesystem, and no clock.
|
||||
|
||||
Everything else follows from that. It is constitution Principle IV, and it is the reason the rule
|
||||
engine can be evaluated on a laptop with no tenant, no credentials, and no network.
|
||||
|
||||
## Layers
|
||||
|
||||
Loaded in this order by [`PersonaEngine.psm1`](../PersonaEngine.psm1). Order matters only for
|
||||
readability — PowerShell resolves function names at call time — but the dependency direction is real
|
||||
and one-way.
|
||||
|
||||
| Layer | Responsibility | May depend on |
|
||||
| --- | --- | --- |
|
||||
| `Normalization` | Convert raw directory objects into `UserRecord` and `MembershipRecord` | nothing |
|
||||
| `Configuration` | Load, validate, and resolve configuration | Normalization |
|
||||
| `RuleEngine` | Evaluate conditions, groups, rules; produce a decision | Normalization only |
|
||||
| `Authentication` | Acquire a Graph connection | nothing in this module |
|
||||
| `DataProviders` | Retrieve users, membership, roles; retry policy | Normalization |
|
||||
| `Persistence` | Compare values; build and issue the write | DataProviders |
|
||||
| `Presentation` | Per-user output, summaries, reconciliation | nothing |
|
||||
| `Engine` | The run loop that composes all of the above | everything |
|
||||
| `Audit` | Build and emit structured records | Presentation |
|
||||
|
||||
The arrow never points into `RuleEngine`. Nothing in `RuleEngine` may reference anything from
|
||||
`Authentication`, `DataProviders`, `Persistence`, or `Presentation`.
|
||||
|
||||
## The normalization boundary
|
||||
|
||||
`ConvertTo-PersonaUserRecord` and `ConvertTo-PersonaMembershipRecord` are the only places where a raw
|
||||
Graph shape becomes an engine shape. Downstream of them, nothing knows Graph exists.
|
||||
|
||||
Two consequences worth stating plainly:
|
||||
|
||||
**Fixtures are real inputs.** A synthetic `UserRecord` built in a test is indistinguishable to the
|
||||
engine from one built from a live tenant response. That is what makes the offline suite evidence
|
||||
rather than a rehearsal.
|
||||
|
||||
**Filtering rules live in one place.** `memberOf` returns directory objects of mixed type.
|
||||
Administrative units arriving on that endpoint are discarded during normalization, not by each
|
||||
caller. An administrative unit ID treated as a group ID would never match — which reads as "not a
|
||||
member", a false non-match, the exact outcome FR-013 exists to prevent.
|
||||
|
||||
## The MembershipRecord shape
|
||||
|
||||
Three independently-retrieved facets — direct groups, transitive groups, directory roles — each with
|
||||
its own retrieval flag and failure reason.
|
||||
|
||||
This started as a single record with one `Mode` field, and running the code against the example
|
||||
configuration is what proved it wrong: RE-007 makes membership mode a **per-condition** choice, so a
|
||||
rule set may legitimately ask for transitive membership in one rule and direct in another. A record
|
||||
carrying only one mode cannot answer both, and seven of nine fixtures came back as `EvaluationError`.
|
||||
The engine was right; the contract was wrong.
|
||||
|
||||
Independent facets also contain failure. If the transitive lookup times out but the direct lookup
|
||||
succeeded, only conditions needing transitive data become `Unknown`. One collapsed flag would turn
|
||||
one slow endpoint into a tenant-wide outage.
|
||||
|
||||
Every `*Retrieved` flag defaults to `$false`. An unset flag means *unknown*, never *not a member*, so
|
||||
a forgotten flag degrades to `EvaluationError` rather than silently misclassifying a privileged
|
||||
account.
|
||||
|
||||
## Tri-state evaluation
|
||||
|
||||
Conditions return `'True'`, `'False'`, or `'Unknown'` — not a boolean. `Unknown` propagates through
|
||||
condition groups by the table in [data-model.md](../specs/001-persona-engine/data-model.md), and an
|
||||
`Unknown` reaching a rule's root becomes `EvaluationError` for that account.
|
||||
|
||||
An `Unknown` at priority 30 stops evaluation even though a lower-priority rule might have matched.
|
||||
Continuing would risk assigning a persona from priority 900 when the account may in truth have
|
||||
matched at 30 — a privilege downgrade drawn from data nobody could read. Preserving the stored value
|
||||
is the only safe answer.
|
||||
|
||||
## The write gate has one origin
|
||||
|
||||
Mode is derived from `$PSCmdlet.ShouldProcess()` and nothing else. There is no `-Preview` switch, no
|
||||
configuration key that suppresses writes, and no reading of `$WhatIfPreference`. Two sources of truth
|
||||
for a write gate is the defect class Principle III exists to prevent: the day they disagree, one of
|
||||
them is wrong and the directory finds out first.
|
||||
|
||||
`Invoke-PersonaEngine.ps1` owns the `ShouldProcess` call and passes the result down to
|
||||
`Invoke-PersonaEngineRun` as a scriptblock. The run loop never learns what `-WhatIf` is, so it cannot
|
||||
disagree with it — and it defaults to a gate that refuses, so a caller that forgets to supply one
|
||||
previews rather than writes.
|
||||
|
||||
## Why the run loop is a module function
|
||||
|
||||
`Invoke-PersonaEngine.ps1` is a thin wrapper: parameter binding, module import, `ShouldProcess`,
|
||||
exit code. The loop itself is `Invoke-PersonaEngineRun` in `src/Engine/`.
|
||||
|
||||
That split exists because SC-004 requires proof that a `-WhatIf` run issues zero writes across a full
|
||||
population. A loop that only exists inside an entry script — one that imports a manifest requiring
|
||||
the Graph SDK — cannot be exercised without a tenant, so the claim could not be tested. What ships
|
||||
and what is tested are now the same code.
|
||||
|
||||
## Single emission point for audit
|
||||
|
||||
Every audit record passes through `Write-PersonaAuditRecord`. Adding a transport is a change to that
|
||||
one function. If call sites wrote their own output, each new transport would mean auditing every call
|
||||
site again, and the one that got missed would be silent.
|
||||
|
||||
Records go to the **Information** stream, not the success stream. Audit records on the success stream
|
||||
would be indistinguishable from a function's return value — the run loop returns its outcome there,
|
||||
and mixing the two turns one object into an array of several thousand.
|
||||
|
||||
## Failure containment
|
||||
|
||||
| Failure | Scope | Outcome |
|
||||
| --- | --- | --- |
|
||||
| Configuration invalid | Run | Exit 1, before any connection is attempted |
|
||||
| Authentication fails | Run | Exit 2 |
|
||||
| Enumeration truncated | Run | Exit 3 — a partial population is never processed |
|
||||
| One membership lookup fails | One facet, one user | `EvaluationError`, stored value preserved |
|
||||
| Too many `EvaluationError` | Run status only | Exit 4; no value was changed |
|
||||
| Counters disagree | Run | Exit 5, `EngineDefect` record |
|
||||
| One write fails | One user | `UpdateFailed`, run continues |
|
||||
|
||||
The dividing line: anything affecting the whole population ends the run; anything affecting one
|
||||
account is contained and reported.
|
||||
|
||||
## Related
|
||||
|
||||
- [SecurityModel.md](SecurityModel.md) — the OTD-003 trade-off and its compensating controls
|
||||
- [ConfigurationReference.md](ConfigurationReference.md) — every schema field and finding code
|
||||
- [OperationsRunbook.md](OperationsRunbook.md) — kill switch and rollback
|
||||
- [Logging.md](Logging.md) — record types and what they may contain
|
||||
@@ -0,0 +1,139 @@
|
||||
# Business rules
|
||||
|
||||
How to write, order, and change the rules that decide what an account is.
|
||||
|
||||
Field-by-field syntax is in [ConfigurationReference.md](ConfigurationReference.md). Every condition
|
||||
type and operator, with worked examples, is in [RuleAuthoringGuide.md](RuleAuthoringGuide.md). This
|
||||
document is about judgement.
|
||||
|
||||
## The model
|
||||
|
||||
Rules are evaluated in ascending `priority`. The first rule that returns `True` wins, and evaluation
|
||||
stops. An account matching no enabled rule is `Unclassified`. An account whose evaluation hits data
|
||||
that could not be retrieved is `EvaluationError`, and its stored value is preserved.
|
||||
|
||||
Three consequences worth internalising before writing a rule:
|
||||
|
||||
**Order is meaning.** A rule at priority 900 only ever sees accounts that failed every rule above it.
|
||||
Changing a priority silently reclassifies every account matched by more than one rule, which is why
|
||||
`PE-SAF-005` blocks a reorder without a `configVersion` change.
|
||||
|
||||
**A rule cannot express "and not the previous ones".** It does not need to. First-match already
|
||||
excludes them. Adding explicit exclusions duplicates the ordering in two places, and the day they
|
||||
disagree the ordering wins silently.
|
||||
|
||||
**Determinism is absolute.** The same account and the same configuration always produce the same
|
||||
persona. Nothing time-dependent, random, or order-dependent may enter a decision — `effectiveDate` is
|
||||
metadata for exactly this reason, and the engine's per-user timing uses a monotonic stopwatch rather
|
||||
than the wall clock so no clock value can reach a decision.
|
||||
|
||||
## Priority bands
|
||||
|
||||
A convention, not enforced, but it makes the intent of a rule set legible at a glance:
|
||||
|
||||
| Band | Purpose | Examples |
|
||||
| --- | --- | --- |
|
||||
| 1–99 | Accounts that must never be reclassified by anything | Emergency access, Tier 0 |
|
||||
| 100–199 | Directory facts that are definitional | Guest, external |
|
||||
| 200–499 | Non-human accounts | Service, shared functional, room devices |
|
||||
| 500–799 | Population subsets | Contractor, student, restricted |
|
||||
| 800–999 | Defaults | Employee |
|
||||
|
||||
Leave gaps. Renumbering to insert a rule is a reorder, and a reorder is a `PE-SAF-005` finding.
|
||||
|
||||
## Writing a rule that holds up
|
||||
|
||||
**Identify special accounts by Object ID, never by name.** Display names and UPNs change; Object IDs
|
||||
do not. RE-009 exists because a break-glass account renamed during an incident must not silently stop
|
||||
being a break-glass account.
|
||||
|
||||
**Require two independent signals for a consequential classification.** The example configuration's
|
||||
service-account rule requires both a naming convention *and* group membership, so a person whose UPN
|
||||
happens to start with `svc-` is not classified as a service account.
|
||||
|
||||
**Prefer group membership to string matching for anything privileged.** A group is administered,
|
||||
auditable, and has an owner. A naming convention is a habit.
|
||||
|
||||
**Give every rule a description that says why it exists**, not what it does — the conditions already
|
||||
say what it does. A rule nobody can explain cannot be safely changed, which is why `description` is
|
||||
required.
|
||||
|
||||
## Membership mode
|
||||
|
||||
`direct` asks whether the account is a member of the named group itself. `transitive` asks whether it
|
||||
is a member through any chain of nesting.
|
||||
|
||||
Mode is a per-condition choice (RE-007). The three facets — direct, transitive, roles — are retrieved
|
||||
independently, so mixing modes in one rule set is fully supported. It costs one extra request per
|
||||
account for each additional facet.
|
||||
|
||||
Use `transitive` when the group is a role-holding group that other groups nest into — which is most
|
||||
Tier 0 groups. Use `direct` when membership is explicitly managed and nesting would be a mistake.
|
||||
|
||||
**Do not pin `dataSources.groups.membershipMode` unless you mean to restrict.** Absent means "any mode
|
||||
is acceptable". Pinning it turns every per-condition override into a `PE-SEM-014` warning, which
|
||||
trains people to ignore warnings.
|
||||
|
||||
## Unknown is not false
|
||||
|
||||
If a membership lookup fails, the condition is `Unknown`, not `False`. A `notMemberOf` condition
|
||||
therefore does **not** become satisfied when the lookup fails.
|
||||
|
||||
This is the single most important behaviour in the engine. Without it, a transient Graph outage would
|
||||
make every privileged account look like a non-member of its Tier 0 group, and a single run would
|
||||
quietly demote the entire administrative population. `UnknownNotFalse.Tests.ps1` exists solely to
|
||||
prevent that regression.
|
||||
|
||||
The cost is that a failed lookup produces `EvaluationError` rather than a classification. That is the
|
||||
correct trade: preserving a possibly-stale value is recoverable, and writing a confidently wrong one
|
||||
is not.
|
||||
|
||||
## Nesting
|
||||
|
||||
`all` and `any` groups nest to `maxConditionDepth` (default 5, ceiling 10). Beyond the limit the
|
||||
engine returns `Unknown`, which becomes `EvaluationError` for every account the rule reaches — so a
|
||||
too-deep rule fails safe rather than silently.
|
||||
|
||||
A rule needing more than three levels is usually two rules with different priorities. Depth is
|
||||
expensive to read and the ordering you would express with nesting is already available for free.
|
||||
|
||||
## Changing a rule set
|
||||
|
||||
1. Edit the configuration.
|
||||
2. Validate against the deployed copy so the drift checks actually run:
|
||||
|
||||
```bash
|
||||
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -PreviousConfigPath ./deployed/persona-engine.json -ValidateOnly -NonInteractive
|
||||
```
|
||||
|
||||
3. Run the rules against synthetic fixtures.
|
||||
4. Preview against one account, then against the tenant with `-WhatIf`.
|
||||
5. Compare the summary's per-rule match counts against the previous run. A rule whose count moved
|
||||
sharply is either the change you made or a change you did not intend.
|
||||
6. Raise `configVersion`.
|
||||
|
||||
Step 5 is the one people skip. The summary lists every rule including zero-match ones precisely so
|
||||
that a rule which *stopped* firing is visible, and a rule that stopped firing is the usual signature
|
||||
of an accidental reorder.
|
||||
|
||||
## Disabling versus deleting
|
||||
|
||||
Disable rather than delete. A disabled rule still appears in every summary with a zero count, so the
|
||||
audit trail keeps reporting on it and an operator can see it was deliberately turned off. A deleted
|
||||
rule is indistinguishable from one that never existed, which is why `PE-SAF-005` flags a deletion
|
||||
without a version change.
|
||||
|
||||
## Testing a rule set without a tenant
|
||||
|
||||
```bash
|
||||
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath <fixtures-dir> -ValidateOnly -NonInteractive
|
||||
```
|
||||
|
||||
This runs the real engine against a directory of synthetic user/membership fixtures you provide.
|
||||
Build fixtures for the cases your rule set actually cares about: accounts with null and absent
|
||||
properties, mixed casing, a guest, a disabled account, and — most usefully — an account whose
|
||||
membership lookup fails, so `EvaluationError` behaviour is visible before it happens against a real
|
||||
directory.
|
||||
|
||||
A fixture that reproduces a real edge case, sanitized, is worth more than any amount of reasoning
|
||||
about what the engine will probably do.
|
||||
@@ -0,0 +1,222 @@
|
||||
# Configuration reference
|
||||
|
||||
Every field in `persona-engine.json`, and every finding code the validator can produce.
|
||||
|
||||
The authoritative schema is [`config/persona-engine.schema.json`](../config/persona-engine.schema.json)
|
||||
(JSON Schema draft-07). A working example is
|
||||
[`config/persona-engine.example.json`](../config/persona-engine.example.json), which is validated
|
||||
against its own schema before every commit — if the example the documentation points at could not
|
||||
pass, every reader's first run would fail.
|
||||
|
||||
For a task-oriented walkthrough of every condition type and operator with worked JSON examples, see
|
||||
[RuleAuthoringGuide.md](RuleAuthoringGuide.md). This document is the terse reference; that one
|
||||
teaches by example.
|
||||
|
||||
## Top level
|
||||
|
||||
| Field | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `configVersion` | yes | Semantic version, `major.minor.patch`. A downgrade is a safety finding. |
|
||||
| `metadata` | no | `owner`, `changeReference`, `description`. Free-form; not read by the engine. |
|
||||
| `engine` | yes | Engine behaviour. |
|
||||
| `dataSources` | yes | Which directory data may be retrieved. |
|
||||
| `logging` | no | Audit output. |
|
||||
| `personas` | yes | The declared persona catalogue. |
|
||||
| `rules` | yes | Ordered business rules. |
|
||||
|
||||
## `engine`
|
||||
|
||||
| Field | Required | Default | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `targetAttribute` | yes | — | The single attribute the engine may write. Must be a directory extension property and must appear in `approvedWritableAttributes`. |
|
||||
| `approvedWritableAttributes` | yes | — | The allow-list. Comparison is **ordinal** — extension property names are case-sensitive in Graph. |
|
||||
| `maxConditionDepth` | no | `5` | RE-004. Minimum 1, hard ceiling 10. |
|
||||
| `summaryInterval` | no | `25` | Interim summary frequency. `0` suppresses interim summaries; a final summary always appears. |
|
||||
| `defaultMembershipMode` | no | `direct` | Mode for membership conditions that do not specify one. |
|
||||
| `evaluationErrorThreshold` | no | unset | Count of `EvaluationError` results above which the run reports exit code 4. Unset means report, do not fail. |
|
||||
|
||||
Setting `evaluationErrorThreshold` to `0` makes a single transient lookup failure fail the run. That
|
||||
is occasionally what you want; it is rarely what you meant.
|
||||
|
||||
## `dataSources`
|
||||
|
||||
| Field | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `groups.enabled` | yes | When false, no membership facet is retrieved. Rules needing it become `EvaluationError`. |
|
||||
| `groups.membershipMode` | no | Pins a mode globally. **Leave it out unless you mean to restrict** — absent means "any mode is acceptable", and RE-007 makes mode a per-condition choice. Pinning it makes every per-condition override a `PE-SEM-014` warning. |
|
||||
| `roles.enabled` | yes | Directory role assignments. |
|
||||
| `roles.includeEligible` | no | PIM-eligible assignments. **Out of scope for v1**; no provider is implemented. |
|
||||
|
||||
The engine retrieves only the facets enabled rules actually reference. A configuration with no role
|
||||
conditions never calls the role endpoint, so a tenant where role reads are unavailable can still run
|
||||
property-only rules.
|
||||
|
||||
## `logging`
|
||||
|
||||
| Field | Required | Default | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `destination` | no | `both` | `file`, `stream`, `both`, or `none`. `stream` writes records to the PowerShell Information stream. |
|
||||
| `path` | no | `<current-directory>/logs/persona-engine-audit.ndjson` | NDJSON output file. One record per line. |
|
||||
| `resultsFileName` | no | `results.csv` | Per-account results CSV, written next to `path`'s directory. Lists `AccountObjectId`, `UserPrincipalName`, `PersonaStatus`, `CompanyName`, `Department` for every account processed so far. Overwritten on every summary, interim and final. |
|
||||
| `traceConditionValues` | no | `false` | Writes evaluated attribute values into audit records. |
|
||||
| `acknowledgeConditionTracing` | no | `false` | **Required whenever `traceConditionValues` is true** (VR-003). |
|
||||
|
||||
## `personas`
|
||||
|
||||
The declared catalogue. A rule assigning a persona absent from this list is a `PE-SEM-010` error — the
|
||||
catalogue is what stops a typo from writing a new persona value into the directory.
|
||||
|
||||
`Unclassified` and `EvaluationError` are processing results and may never be declared or assigned.
|
||||
|
||||
## `rules`
|
||||
|
||||
| Field | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | yes | Unique. Appears in every audit record; this is how a decision is traced to its rule. |
|
||||
| `name` | yes | Human-readable. |
|
||||
| `description` | yes | Why the rule exists. Required, because a rule nobody can explain cannot be safely changed. |
|
||||
| `enabled` | yes | Disabled rules are excluded from evaluation but still appear in summaries with zero matches. |
|
||||
| `priority` | yes | Unique integer. **Lower evaluates first.** |
|
||||
| `persona` | yes | Must appear in `personas`. |
|
||||
| `match` | yes | The root condition group. |
|
||||
| `tags`, `owner`, `changeReference`, `effectiveDate`, `notes`, `testCases` | no | Metadata. `effectiveDate` is **not** evaluated — a date-dependent decision would break determinism. |
|
||||
|
||||
Priorities must be unique among enabled rules. The engine breaks ties by rule ID so results stay
|
||||
deterministic, but the resulting order is an accident rather than a decision, so `PE-SEM-002` blocks it.
|
||||
|
||||
## Condition groups and conditions
|
||||
|
||||
A group has `operator` (`all` or `any`) and a `conditions` array. Each entry is either another group
|
||||
or a condition.
|
||||
|
||||
| Field | Applies to | Notes |
|
||||
| --- | --- | --- |
|
||||
| `type` | all | `property`, `membership`, or `role`. |
|
||||
| `property` | `property` | One of the supported names below, or an extension property. |
|
||||
| `operator` | all | See the operator table. |
|
||||
| `value` | most | Single comparison value. |
|
||||
| `values` | `in`, `notIn` | Comparison set. |
|
||||
| `groupObjectIds` | `membership` | Group Object IDs. Names are mutable; IDs are not (RE-009). |
|
||||
| `roleIds` | `role` | Role **template** IDs, which are stable across tenants. |
|
||||
| `membershipMode` | `membership` | `direct` or `transitive`, per condition. |
|
||||
| `caseSensitive` | — | Reserved. Not implemented in v1; the schema accepts the key so a later version needs no breaking change. |
|
||||
|
||||
### Supported properties
|
||||
|
||||
`AccountObjectId` · `UserPrincipalName` · `DisplayName` · `UserType` · `AccountEnabled` ·
|
||||
`CompanyName` · `JobTitle` · `Department`
|
||||
|
||||
Plus any directory extension property named `extension_<32-hex-app-id>_<name>`. Anything else is
|
||||
`PE-SEM-015`: unsupported properties are never retrieved, so the condition would compare against a
|
||||
permanently absent value and quietly never match.
|
||||
|
||||
### Operators (RE-005)
|
||||
|
||||
| Operator | Applies to | Notes |
|
||||
| --- | --- | --- |
|
||||
| `equals`, `notEquals` | property | Case-insensitive (RE-006). |
|
||||
| `contains`, `notContains` | property | Case-insensitive substring. |
|
||||
| `startsWith`, `endsWith` | property | Case-insensitive. |
|
||||
| `matchesRegex` | property | Pattern compiled at validation time. An invalid pattern is `PE-SEM-016`, not a runtime failure. |
|
||||
| `in`, `notIn` | property | Requires `values`. |
|
||||
| `isNull`, `isNotNull` | property | Tests presence. **Must not carry a value** — it would be silently ignored (`PE-SEM-009`). |
|
||||
| `memberOf`, `notMemberOf` | membership, role | Requires `groupObjectIds` or `roleIds`. |
|
||||
|
||||
Null and absent properties are treated as empty for ordinary comparisons and never cause an
|
||||
evaluation failure (FR-012). Intentional null matching uses `isNull` / `isNotNull`.
|
||||
|
||||
## Tri-state evaluation
|
||||
|
||||
Conditions return `True`, `False`, or `Unknown`. `Unknown` means required data could not be
|
||||
retrieved, and it propagates:
|
||||
|
||||
| Group | Contains | Result |
|
||||
| --- | --- | --- |
|
||||
| `all` | any `False` | `False` |
|
||||
| `all` | only `True` plus at least one `Unknown` | `Unknown` |
|
||||
| `any` | any `True` | `True` |
|
||||
| `any` | only `False` plus at least one `Unknown` | `Unknown` |
|
||||
|
||||
An `Unknown` at a rule's root makes the account `EvaluationError`: the stored persona is preserved
|
||||
and no write is attempted (FR-013, FR-014).
|
||||
|
||||
## Validation layers
|
||||
|
||||
Run in order, stopping at the first that produces `Error` findings. Running semantic checks over a
|
||||
structurally invalid document yields noise, not signal.
|
||||
|
||||
| Layer | Mechanism | Codes |
|
||||
| --- | --- | --- |
|
||||
| 1 Syntax | `ConvertFrom-Json` | `PE-SYN-nnn` |
|
||||
| 2 Schema | `Test-Json -SchemaFile` | `PE-SCH-nnn` |
|
||||
| 3 Semantic | PowerShell checks | `PE-SEM-nnn` |
|
||||
| 4 Safety | PowerShell checks | `PE-SAF-nnn` |
|
||||
|
||||
Codes are stable. Pipelines and runbooks match on them, so a code is never reused for a different
|
||||
condition and never renumbered.
|
||||
|
||||
### Syntax — `PE-SYN`
|
||||
|
||||
| Code | Condition |
|
||||
| --- | --- |
|
||||
| `PE-SYN-001` | Configuration file not found, or is not a file |
|
||||
| `PE-SYN-002` | File exists but could not be read |
|
||||
| `PE-SYN-003` | File is not valid JSON |
|
||||
|
||||
### Schema — `PE-SCH`
|
||||
|
||||
| Code | Condition |
|
||||
| --- | --- |
|
||||
| `PE-SCH-001` | Document violates the schema |
|
||||
| `PE-SCH-002` | Schema file not found |
|
||||
| `PE-SCH-003` | Schema file exists but is not valid JSON Schema |
|
||||
|
||||
`PE-SCH-003` exists because of V-5a: `Test-Json` returns `$true` when the schema itself cannot be
|
||||
parsed. A wrapper trusting the return value would report every configuration as schema-valid against
|
||||
a schema that never ran.
|
||||
|
||||
### Semantic — `PE-SEM` (VR-002)
|
||||
|
||||
| Code | Condition | Severity |
|
||||
| --- | --- | --- |
|
||||
| `PE-SEM-001` | Duplicate rule ID | Error |
|
||||
| `PE-SEM-002` | Duplicate priority among enabled rules | Error |
|
||||
| `PE-SEM-003` | No rules, or no enabled rules | Error |
|
||||
| `PE-SEM-004` | Blank target attribute | Error |
|
||||
| `PE-SEM-005` | Target attribute absent from the approved list | Error |
|
||||
| `PE-SEM-006` | Rule references a disabled data source | Error |
|
||||
| `PE-SEM-007` | `memberOf` / `notMemberOf` with no group or role IDs | Error |
|
||||
| `PE-SEM-008` | `in` / `notIn` with no `values` | Error |
|
||||
| `PE-SEM-009` | `isNull` / `isNotNull` carrying a comparison value | Error |
|
||||
| `PE-SEM-010` | Persona not in the declared catalogue | Error |
|
||||
| `PE-SEM-011` | `Unclassified` used as a rule persona | Error |
|
||||
| `PE-SEM-012` | Nesting deeper than `maxConditionDepth` | Error |
|
||||
| `PE-SEM-013` | `maxConditionDepth` outside 1–10 | Error |
|
||||
| `PE-SEM-014` | Condition mode differs from an explicitly pinned global mode | Warning |
|
||||
| `PE-SEM-015` | Unsupported property name | Error |
|
||||
| `PE-SEM-016` | Invalid regular expression | Error |
|
||||
|
||||
Several of these are also enforced by the schema. The overlap is deliberate: layer 2 can be bypassed
|
||||
with `-SchemaPath`, and V-5a showed an unparseable schema passes silently. Anything that can
|
||||
misclassify a privileged account is checked twice.
|
||||
|
||||
### Safety — `PE-SAF` (VR-003)
|
||||
|
||||
| Code | Condition | Severity |
|
||||
| --- | --- | --- |
|
||||
| `PE-SAF-001` | Blank target attribute | Error enforcing, Warning in preview |
|
||||
| `PE-SAF-002` | Approved list contains a non-extension attribute | Error |
|
||||
| `PE-SAF-003` | Enabled rules need data the data sources do not provide | Error |
|
||||
| `PE-SAF-004` | `configVersion` lower than the deployed version | Error enforcing, Warning in preview |
|
||||
| `PE-SAF-005` | Rules removed or reordered with no version change | Error enforcing, Warning in preview |
|
||||
| `PE-SAF-006` | Tracing enabled without acknowledgement | Error |
|
||||
| `PE-SAF-007` | Save would overwrite an existing configuration with no backup | Error |
|
||||
|
||||
`PE-SAF-004` and `PE-SAF-005` need `-PreviousConfigPath`. Without it they are **skipped**, and an
|
||||
`Information` finding says so — silence would be read as approval.
|
||||
|
||||
## Escalation (VR-005)
|
||||
|
||||
`Error` blocks execution and saving. `Warning` blocks only under `-TreatWarningsAsErrors`.
|
||||
`Information` never blocks. Passing `-TreatWarningsAsErrors` does not change a finding's severity;
|
||||
it changes the caller's tolerance for it.
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
# Logging
|
||||
|
||||
Structured audit output: what is emitted, where it goes, and what may never appear in it.
|
||||
|
||||
The serialized contract is [audit-record.md](../specs/001-persona-engine/contracts/audit-record.md).
|
||||
This document covers the operational side.
|
||||
|
||||
## Format and transport
|
||||
|
||||
Newline-delimited JSON (OTD-006). One record per line, UTF-8 without BOM, appended.
|
||||
|
||||
Every record passes through a single sink, `Write-PersonaAuditRecord`. Adding a transport — an
|
||||
approved logging platform, an event hub, a different file layout — is a change to that one function.
|
||||
If call sites wrote their own output, each new transport would mean auditing every call site again,
|
||||
and the one that got missed would be silent.
|
||||
|
||||
| `logging.destination` | Behaviour |
|
||||
| --- | --- |
|
||||
| `file` | Appends NDJSON to `logging.path` |
|
||||
| `stream` | Emits the record object on the PowerShell **Information** stream |
|
||||
| `both` | Both |
|
||||
| `none` | Nothing |
|
||||
|
||||
`logging.path` is optional. When it (and `-OutputPath`) are unset, `file`/`both` write to
|
||||
`<current-directory>/logs/persona-engine-audit.ndjson`, created on first write.
|
||||
|
||||
`stream` uses the Information stream rather than the success stream deliberately. Audit records on
|
||||
the success stream would be indistinguishable from a function's return value — the run loop returns
|
||||
its outcome there — and mixing the two turns one object into an array of several thousand. Capture
|
||||
them with `-InformationVariable`, or redirect with `6>`.
|
||||
|
||||
### Sink failure never ends a run
|
||||
|
||||
A full disk or a locked file is an operational problem with the sink, not a reason to abandon a
|
||||
classification run mid-population and leave the directory half-reconciled. The failure surfaces as a
|
||||
warning, **once** per run, and processing continues.
|
||||
|
||||
Once, not once per user: a run over five thousand accounts with a locked log file should warn once,
|
||||
or the warning that matters is buried in the noise it generates.
|
||||
|
||||
## Results CSV
|
||||
|
||||
Alongside the NDJSON audit log, every summary — interim and final — (re)writes a plain CSV listing
|
||||
every account processed so far: `AccountObjectId`, `UserPrincipalName`, `PersonaStatus` (the assigned
|
||||
persona for `Matched` accounts, otherwise `Unclassified` or `EvaluationError`), `CompanyName`, and
|
||||
`Department` (as retrieved from the directory, blank when absent). It is overwritten in full each
|
||||
time, not appended, so it always reflects the whole run to that point rather than only the accounts
|
||||
since the last summary.
|
||||
|
||||
It is written next to the audit log — same directory as `logging.path` — under `logging.resultsFileName`
|
||||
(default `results.csv`). Export failure never ends a run, for the same reason a sink failure doesn't:
|
||||
a locked file or full disk is an operational problem, not a reason to abandon a classification run
|
||||
mid-population.
|
||||
|
||||
## Record types
|
||||
|
||||
| Type | When | Carries |
|
||||
| --- | --- | --- |
|
||||
| `RunStart` | Once, after mode is determined | Config path, target attribute, rule counts, whether tracing is on |
|
||||
| `UserEvent` | Once per processed account | The full decision |
|
||||
| `Summary` | Every `summaryInterval` accounts, and once at the end | Counters, per-rule match counts, reconciliation result |
|
||||
| `RunComplete` | Once, in a `finally` block | Final counters, timing, exit code |
|
||||
| `EngineDefect` | Reconciliation failure, or threshold breach | What went wrong and by how much |
|
||||
|
||||
`RunComplete` is written even on a fatal error. A run that died at account 400 of 5,000 leaves a
|
||||
record saying exactly that — which is what lets an operator tell "the engine stopped early" from "the
|
||||
engine never started", two very different incidents that produce identical evidence if the record is
|
||||
written only on success.
|
||||
|
||||
## The common envelope
|
||||
|
||||
Every record, every type:
|
||||
|
||||
`timestamp` · `recordType` · `runId` · `engineVersion` · `configVersion` · `configurationHash` · `mode`
|
||||
|
||||
These appear **first** in each record, so a truncated line still identifies the run that produced it.
|
||||
|
||||
`runId` comes from `-CorrelationId` or is generated, and is constant for the run (NFR-005).
|
||||
`configurationHash` is the SHA-256 of the configuration file bytes — two files differing only in
|
||||
whitespace are different configurations for audit purposes, and the hash must be reproducible from
|
||||
the artifact on disk.
|
||||
|
||||
## `UserEvent`
|
||||
|
||||
100% carry `runId`, `userPrincipalName`, and `accountObjectId` (SC-006). 100% of `Matched` records
|
||||
carry `matchedRuleId`. `evaluationErrorReason` is non-null exactly when `outcome` is
|
||||
`EvaluationError`.
|
||||
|
||||
`previousValue` is present **only** on `Updated` records, captured at write time. On any other action
|
||||
there is nothing that was replaced, and a populated `previousValue` would imply otherwise to a
|
||||
rollback tool reading these records later. Without it, OTD-010 rollback is impossible retroactively —
|
||||
no future run can reconstruct what a value used to be.
|
||||
|
||||
## What may never appear
|
||||
|
||||
Access tokens, `Authorization` headers, client secrets, certificates, credentials, and full Graph
|
||||
responses.
|
||||
|
||||
The guarantee is structural rather than filtered. `New-PersonaAuditRecord` accepts only named, typed
|
||||
values from the decision result and the counters — there is no pass-through of an arbitrary object,
|
||||
so there is nothing for a secret to ride in on, even if a caller attaches a token to the decision
|
||||
result.
|
||||
|
||||
## Approved for logs
|
||||
|
||||
User principal name, account object ID, matched rule ID, stored and calculated persona values, run
|
||||
ID, configuration version and hash, per-rule match counts, timing.
|
||||
|
||||
Runtime records naturally contain real UPNs and Object IDs. **No such value may ever be committed to
|
||||
this repository** (SC-013) — the sanitization scan enforces that on every build.
|
||||
|
||||
## Condition tracing
|
||||
|
||||
`conditionTrace` is added to a `UserEvent` only when **both** gates are open: the decision result was
|
||||
built with tracing, and the record was asked to include it. It carries the per-rule result
|
||||
(`True` / `False` / `Unknown`) and priority.
|
||||
|
||||
Tracing widens what the log contains beyond the approved set, so it requires
|
||||
`logging.acknowledgeConditionTracing` in the same configuration (`PE-SAF-006`). Tracing never changes
|
||||
a decision — if it could, a debug run would stop being evidence about the real one.
|
||||
|
||||
## Querying
|
||||
|
||||
```powershell
|
||||
# Everything from one run
|
||||
Get-Content <LOG-OUTPUT-PATH> | ForEach-Object { $_ | ConvertFrom-Json } |
|
||||
Where-Object runId -eq '<RUN-ID>'
|
||||
|
||||
# Accounts a run changed, with what it replaced
|
||||
Get-Content <LOG-OUTPUT-PATH> | ForEach-Object { $_ | ConvertFrom-Json } |
|
||||
Where-Object { $_.recordType -eq 'UserEvent' -and $_.action -eq 'Updated' } |
|
||||
Select-Object userPrincipalName, previousValue, calculatedPersona, matchedRuleId
|
||||
|
||||
# Accounts that could not be evaluated, and why
|
||||
Get-Content <LOG-OUTPUT-PATH> | ForEach-Object { $_ | ConvertFrom-Json } |
|
||||
Where-Object outcome -eq 'EvaluationError' |
|
||||
Select-Object userPrincipalName, evaluationErrorReason
|
||||
|
||||
# Which rule set produced a given decision
|
||||
Get-Content <LOG-OUTPUT-PATH> | ForEach-Object { $_ | ConvertFrom-Json } |
|
||||
Where-Object recordType -eq 'RunStart' |
|
||||
Select-Object runId, configVersion, configurationHash, mode
|
||||
```
|
||||
|
||||
## Retention
|
||||
|
||||
Not set by this engine. Records contain UPNs and Object IDs, so retention is governed by the
|
||||
organization's identity-data policy rather than by anything in this repository. Decide it before the
|
||||
first enforcing run, not after.
|
||||
@@ -0,0 +1,172 @@
|
||||
# Operations runbook
|
||||
|
||||
What to do when the engine is running, and what to do when it should not be.
|
||||
|
||||
## Before any run
|
||||
|
||||
1. Validate the configuration. It costs seconds and needs no tenant.
|
||||
|
||||
```bash
|
||||
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
|
||||
```
|
||||
|
||||
2. Run the rules against synthetic fixtures. This shows what the rule set *does* before it sees a
|
||||
real account.
|
||||
|
||||
```bash
|
||||
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath <fixtures-dir> -ValidateOnly -NonInteractive
|
||||
```
|
||||
|
||||
3. Preview a single user before previewing the tenant.
|
||||
|
||||
```bash
|
||||
pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf -Verbose
|
||||
```
|
||||
|
||||
Never skip step 3. A rule set that behaves correctly against fixtures can still request a property
|
||||
your tenant does not populate, and finding that out on one account is cheaper than on fifty thousand.
|
||||
|
||||
## Reading a run
|
||||
|
||||
Per-user lines appear immediately, one per account, colour-coded by action:
|
||||
|
||||
| Action | Meaning |
|
||||
| --- | --- |
|
||||
| `Unchanged` | Calculated value already matches the stored value. Nothing to do. |
|
||||
| `WouldUpdate` | A change is proposed. Preview mode, or the per-user gate refused. |
|
||||
| `Updated` | The attribute was written. |
|
||||
| `UpdateFailed` | The write was attempted and rejected. Stored value is untouched. |
|
||||
| `Skipped` | No write is possible: `EvaluationError`, or a blank or unapproved target. |
|
||||
|
||||
`Skipped` on every account almost always means the target attribute is blank or unapproved — check
|
||||
the header line and `PE-SAF-001`.
|
||||
|
||||
A summary appears every `summaryInterval` accounts and once at the end, listing **every** rule
|
||||
including disabled and zero-match ones. A rule that never fired and a rule that is not in the
|
||||
configuration look identical if zero-match rules are omitted, and that distinction is usually what
|
||||
you are looking for. Its header shows elapsed wall-clock time since the run started.
|
||||
|
||||
Each summary also (re)writes `logging.resultsFileName` (default `results.csv`, next to the audit log)
|
||||
with one row per account processed so far — Object ID, UPN, assigned persona/status, company name,
|
||||
and department — for an operator who wants the current population breakdown without parsing NDJSON.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning | First thing to check |
|
||||
| --- | --- | --- |
|
||||
| 0 | Success | — |
|
||||
| 1 | Configuration validation failed | The findings printed above it; no connection was attempted |
|
||||
| 2 | Authentication or authorization failed | Scopes, consent, and whether the account can sign in |
|
||||
| 3 | User enumeration failed | Graph availability. **No accounts were processed** — a partial population is never used |
|
||||
| 4 | `evaluationErrorThreshold` exceeded | Group or role endpoint health. Nothing was changed |
|
||||
| 5 | Reconciliation failed | **An engine defect.** Open an issue with the `EngineDefect` record |
|
||||
| 6 | Unexpected fatal error | The message and `-Verbose` stack trace |
|
||||
|
||||
Exit 5 is never a data condition. Outcomes are assigned by the engine, exactly one per account, so if
|
||||
`Processed` does not equal `Matched + Unclassified + EvaluationError` the engine lost a user or
|
||||
double-counted one. Report it rather than re-running.
|
||||
|
||||
Exit 4 means the population was classified from data that could not be trusted. Stored values were
|
||||
preserved, so nothing is damaged — but do not draw conclusions from the run.
|
||||
|
||||
## Kill switch
|
||||
|
||||
In increasing order of severity. Pick the lowest one that addresses the problem.
|
||||
|
||||
### 1. Stop writing — immediate, no deployment
|
||||
|
||||
Add `-WhatIf` to the invocation. Reads, evaluation, output, and audit records continue unchanged;
|
||||
zero write requests are constructed.
|
||||
|
||||
### 2. Stop classifying — one configuration change
|
||||
|
||||
Set `enabled: false` on every rule and deploy.
|
||||
|
||||
> Every account becomes `Unclassified`. **In an enforcing run that proposes clearing every stored
|
||||
> persona.** Combine with `-WhatIf`, or use option 1 instead, unless clearing is what you want.
|
||||
|
||||
### 3. Stop running — Stage B only
|
||||
|
||||
Disable the Automation schedule. Nothing is in flight; the next run simply does not start.
|
||||
|
||||
### 4. Remove the capability — the one that holds if the code is the problem
|
||||
|
||||
Revoke `User.ReadWrite.All` from the execution identity. The engine keeps running and every write
|
||||
becomes `UpdateFailed`, which is loud, logged, and harmless.
|
||||
|
||||
### 5. Remove the path — for a suspected defect in the write path
|
||||
|
||||
Remove the write deployment stage from the release pipeline so no build can restore write capability
|
||||
by accident.
|
||||
|
||||
Options 1 through 3 rely on the engine behaving correctly. Options 4 and 5 do not, which is why they
|
||||
exist.
|
||||
|
||||
## Rollback (OTD-010)
|
||||
|
||||
Every `Updated` audit record carries `previousValue`, captured **before** the PATCH. That is what
|
||||
makes rollback possible; reading the value back afterwards would return the new one.
|
||||
|
||||
To roll back a run:
|
||||
|
||||
1. Find the run's records by `runId`.
|
||||
2. Select records where `recordType` is `UserEvent` and `action` is `Updated`.
|
||||
3. For each, write `previousValue` back to `accountObjectId`.
|
||||
|
||||
```powershell
|
||||
# Reads the NDJSON audit file and lists what a rollback would restore.
|
||||
# Review this output before writing anything back.
|
||||
Get-Content <LOG-OUTPUT-PATH> |
|
||||
ForEach-Object { $_ | ConvertFrom-Json } |
|
||||
Where-Object { $_.runId -eq '<RUN-ID>' -and $_.recordType -eq 'UserEvent' -and $_.action -eq 'Updated' } |
|
||||
Select-Object accountObjectId, userPrincipalName, previousValue, calculatedPersona
|
||||
```
|
||||
|
||||
> A rollback is itself a directory write and is subject to the same V-4 gate and the same test-account
|
||||
> restriction as any enforcement run. A rollback tool is **not implemented in v1** — the data needed
|
||||
> to build one is captured, deliberately, because it cannot be reconstructed retroactively.
|
||||
|
||||
If a rollback is genuinely intended at the configuration level, publish it as a **new higher
|
||||
version** rather than reusing the old number. Two different rule sets sharing one `configVersion`
|
||||
makes the audit trail unable to tell them apart (`PE-SAF-004`).
|
||||
|
||||
## Common situations
|
||||
|
||||
**Every account is `EvaluationError`.** A required data source is disabled or unreachable. Check
|
||||
`dataSources.groups.enabled` and `dataSources.roles.enabled` against what the rules need — `PE-SAF-003`
|
||||
catches this at validation time, so a run reaching this state usually means validation was bypassed.
|
||||
|
||||
**Every account is `Unclassified`.** Either every rule is disabled, or no rule matches. The summary
|
||||
table distinguishes these: disabled rules are dimmed, zero-match enabled rules show `0`.
|
||||
|
||||
**The run is slow.** Check the cache hit ratio via `-Verbose`. Membership lookups dominate: one
|
||||
account needing both direct and transitive facets plus roles is three requests. Narrowing rules to a
|
||||
single membership mode roughly halves that.
|
||||
|
||||
**A write failed with 403.** Non-retryable by design — retrying would hide a configuration or
|
||||
authorization defect behind a timeout. Check that the identity holds `User.ReadWrite.All` and that
|
||||
the target attribute exists on the application registration.
|
||||
|
||||
**Audit file sink warnings.** The sink warns once per run and processing continues. A locked or full
|
||||
log file is an operational problem with the sink, not a reason to abandon a run mid-population and
|
||||
leave the directory half-reconciled.
|
||||
|
||||
## Concurrency
|
||||
|
||||
Two concurrent enforcing runs against the same tenant would race on the same attributes. Until the
|
||||
OTD-009 run-start concurrency check is implemented (T120, Stage B), **the schedule is the lock**: do
|
||||
not start a manual enforcing run while a scheduled one may be in flight.
|
||||
|
||||
Preview runs are read-only and safe to run concurrently.
|
||||
|
||||
## What to attach to a bug report
|
||||
|
||||
- The exit code
|
||||
- The `RunComplete` record for the run — it carries the counters, timing, and exit code even when the
|
||||
run died early
|
||||
- Any `EngineDefect` record
|
||||
- The `configVersion` and `configurationHash` from any record
|
||||
- The rule set, sanitized
|
||||
|
||||
Do **not** attach raw audit records containing real UPNs or Object IDs to anything that leaves the
|
||||
organization. They are approved for internal logs, not for public issue trackers.
|
||||
@@ -0,0 +1,513 @@
|
||||
# Rule authoring guide
|
||||
|
||||
A hands-on manual for writing the `rules` array in `persona-engine.json` — every condition type,
|
||||
every operator, worked examples, and the interactive editor.
|
||||
|
||||
This document teaches by example. [ConfigurationReference.md](ConfigurationReference.md) is the
|
||||
terse field-by-field reference and the finding-code list; [BusinessRules.md](BusinessRules.md) is
|
||||
about judgement — ordering, priority bands, when to trust a naming convention. Read this one first if
|
||||
you have never written a rule before.
|
||||
|
||||
---
|
||||
|
||||
## Anatomy of a rule
|
||||
|
||||
Every rule has the same shape: identity fields, a target persona, and a condition tree called
|
||||
`match`.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "RULE-0040-SERVICE",
|
||||
"name": "Service accounts",
|
||||
"description": "Non-human accounts identified by naming convention and group membership.",
|
||||
"enabled": true,
|
||||
"priority": 40,
|
||||
"persona": "Service-Account",
|
||||
"match": {
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" },
|
||||
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000b0"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `id`, `name`, `description`, `enabled`, `priority`, `persona`, `match` are all required.
|
||||
- `priority` decides evaluation order — lower runs first — and the first rule whose `match`
|
||||
evaluates `True` wins. See [BusinessRules.md](BusinessRules.md#the-model) for why ordering, not
|
||||
exclusion logic, is how you keep one rule from stepping on another.
|
||||
- `match` is always a **condition group** (an `all`/`any` node), never a bare condition — even a
|
||||
rule with a single test needs a one-item `conditions` array inside a group.
|
||||
- Optional metadata — `tags`, `owner`, `changeReference`, `effectiveDate`, `notes`, `testCases` — is
|
||||
never evaluated. `effectiveDate` in particular is a label, not a schedule (Principle I:
|
||||
determinism).
|
||||
|
||||
---
|
||||
|
||||
## Condition types
|
||||
|
||||
Every leaf condition has a `type`. It decides which other fields are required and where the engine
|
||||
looks for the answer.
|
||||
|
||||
| `type` | Answers | Required fields | Data source |
|
||||
| --- | --- | --- | --- |
|
||||
| `property` | What value does this account have? | `property`, `operator`, plus `value`/`values` depending on operator | The normalized user record — directory properties and extension properties |
|
||||
| `membership` | Is this account in one of these groups? | `operator` (`memberOf`/`notMemberOf`), `groupObjectIds` | Group membership, direct or transitive |
|
||||
| `role` | Does this account hold one of these directory roles? | `operator` (`memberOf`/`notMemberOf`), `roleIds` | Directory role assignments |
|
||||
|
||||
`property` is the default if `type` is omitted, but write it explicitly — a rule set is read far
|
||||
more often than it is written, and an implicit type makes every reader re-derive it.
|
||||
|
||||
### `property`
|
||||
|
||||
```json
|
||||
{ "type": "property", "property": "Department", "operator": "equals", "value": "Finance" }
|
||||
```
|
||||
|
||||
`property` names one of the [supported intrinsic properties](#supported-properties) or an extension
|
||||
property. The engine reads it from the normalized record, never from a raw Graph response.
|
||||
|
||||
### `membership`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "membership",
|
||||
"operator": "memberOf",
|
||||
"membershipMode": "transitive",
|
||||
"groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"]
|
||||
}
|
||||
```
|
||||
|
||||
Always identify groups **by Object ID**, never by display name — names are mutable, IDs are not
|
||||
(RE-009). `membershipMode` is optional per condition; see [Direct vs. transitive](#direct-vs-transitive-membership)
|
||||
below.
|
||||
|
||||
### `role`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "role",
|
||||
"operator": "memberOf",
|
||||
"roleIds": ["<TIER0-ROLE-TEMPLATE-ID>"]
|
||||
}
|
||||
```
|
||||
|
||||
`roleIds` takes directory role **template** IDs — the ID that is stable across tenants, not the
|
||||
tenant-specific role assignment ID. There is no membership mode for roles; a role is held or it is
|
||||
not.
|
||||
|
||||
---
|
||||
|
||||
## Operators
|
||||
|
||||
Every operator in one place, with what it needs and how it compares.
|
||||
|
||||
### String comparisons (`property` only)
|
||||
|
||||
All string comparisons are **case-insensitive** (RE-006) and compare against the property's value
|
||||
coerced to a string. A missing or `null` property compares as an empty string (FR-012) — it never
|
||||
throws and never produces `Unknown`.
|
||||
|
||||
| Operator | Meaning | Example |
|
||||
| --- | --- | --- |
|
||||
| `equals` | Exact match | `{ "type": "property", "property": "UserType", "operator": "equals", "value": "Guest" }` |
|
||||
| `notEquals` | Exact non-match | `{ "type": "property", "property": "CompanyName", "operator": "notEquals", "value": "Contoso" }` |
|
||||
| `contains` | Substring present | `{ "type": "property", "property": "JobTitle", "operator": "contains", "value": "intern" }` |
|
||||
| `notContains` | Substring absent | `{ "type": "property", "property": "DisplayName", "operator": "notContains", "value": "test" }` |
|
||||
| `startsWith` | Prefix match | `{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" }` |
|
||||
| `endsWith` | Suffix match | `{ "type": "property", "property": "UserPrincipalName", "operator": "endsWith", "value": "@vendor.example.com" }` |
|
||||
|
||||
### Pattern match (`property` only)
|
||||
|
||||
| Operator | Meaning | Example |
|
||||
| --- | --- | --- |
|
||||
| `matchesRegex` | .NET regex, matched case-insensitively | `{ "type": "property", "property": "UserPrincipalName", "operator": "matchesRegex", "value": "^svc-[a-z0-9]+-\\d{3}@" }` |
|
||||
|
||||
The pattern is compiled at **validation time**, not evaluation time — an invalid pattern is a
|
||||
`PE-SEM-016` finding that blocks saving, never a surprise mid-run. Prefer `startsWith` /
|
||||
`contains` when they say what you mean; reach for `matchesRegex` only when the naming convention
|
||||
genuinely needs a pattern (fixed-width suffixes, alternation, anchoring).
|
||||
|
||||
### Set membership (`property` only)
|
||||
|
||||
| Operator | Meaning | Example |
|
||||
| --- | --- | --- |
|
||||
| `in` | Value equals one of a list | `{ "type": "property", "property": "AccountObjectId", "operator": "in", "values": ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"] }` |
|
||||
| `notIn` | Value equals none of a list | `{ "type": "property", "property": "Department", "operator": "notIn", "values": ["Finance", "Legal"] }` |
|
||||
|
||||
`in`/`notIn` require `values` (plural, an array) rather than `value`. Each candidate is compared
|
||||
case-insensitively, same as `equals`. Prefer `in` over a chain of `any`-grouped `equals` conditions
|
||||
— it says "one list" instead of making a reader count `equals` clauses to notice they are mutually
|
||||
exclusive alternatives.
|
||||
|
||||
### Null tests (`property` only)
|
||||
|
||||
| Operator | Meaning | Example |
|
||||
| --- | --- | --- |
|
||||
| `isNull` | Property is absent, `null`, or empty string | `{ "type": "property", "property": "CompanyName", "operator": "isNull" }` |
|
||||
| `isNotNull` | Property has a non-empty value | `{ "type": "property", "property": "Department", "operator": "isNotNull" }` |
|
||||
|
||||
`isNull`/`isNotNull` must **not** carry `value` or `values` — the schema and `PE-SEM-009` both reject
|
||||
it, because a value sitting on a presence check is either a typo or a misunderstanding, and either
|
||||
way it would silently be ignored if allowed through.
|
||||
|
||||
Both `$null` and `""` count as null. A directory clears an attribute to an empty string as often as
|
||||
it leaves it entirely unset, and a rule author asking "is this unset" means both.
|
||||
|
||||
### Membership and role (`membership` / `role` only)
|
||||
|
||||
| Operator | Meaning | Example |
|
||||
| --- | --- | --- |
|
||||
| `memberOf` | Account is in at least one listed group/role | `{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-…"] }` |
|
||||
| `notMemberOf` | Account is in none of the listed groups/roles | `{ "type": "membership", "operator": "notMemberOf", "groupObjectIds": ["00000000-…"] }` |
|
||||
|
||||
**Read [Unknown is not false](BusinessRules.md#unknown-is-not-false) before writing `notMemberOf`.**
|
||||
If the membership lookup fails, the condition evaluates `Unknown`, not `True` — a `notMemberOf`
|
||||
never becomes satisfied just because the engine could not check. This is deliberate and is the
|
||||
single most important safety behaviour in the engine.
|
||||
|
||||
### Reserved
|
||||
|
||||
`caseSensitive` is accepted by the schema on any condition but is **not implemented in v1** — every
|
||||
comparison is case-insensitive regardless of what you set it to. It exists so a future version that
|
||||
adds case sensitivity does not need a breaking schema change. Do not set it expecting an effect
|
||||
today.
|
||||
|
||||
---
|
||||
|
||||
## Supported properties
|
||||
|
||||
| Property | Type as compared |
|
||||
| --- | --- |
|
||||
| `AccountObjectId` | String (GUID) |
|
||||
| `UserPrincipalName` | String |
|
||||
| `DisplayName` | String |
|
||||
| `UserType` | String — typically `Member` or `Guest` |
|
||||
| `AccountEnabled` | Boolean, compared as the string `"True"` or `"False"` |
|
||||
| `CompanyName` | String |
|
||||
| `JobTitle` | String |
|
||||
| `Department` | String |
|
||||
|
||||
Plus any directory **extension property**, addressed by its full name:
|
||||
`extension_<32-hex-app-id>_<name>`.
|
||||
|
||||
```json
|
||||
{ "type": "property", "property": "extension_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4_costCenter", "operator": "equals", "value": "1042" }
|
||||
```
|
||||
|
||||
Anything not on this list, and not shaped like an extension property, is a `PE-SEM-015` validation
|
||||
error rather than a silent non-match — an unsupported property is never retrieved, so the condition
|
||||
would otherwise compare against a value that is permanently absent and quietly never fire.
|
||||
|
||||
`AccountEnabled` is a boolean at the source but is compared as text, so match it with `equals` and
|
||||
the literal string `"True"` or `"False"` — not `isNull`/`isNotNull`, which test for absence and would
|
||||
never fire on a boolean the directory always populates.
|
||||
|
||||
---
|
||||
|
||||
## Direct vs. transitive membership
|
||||
|
||||
```json
|
||||
{ "type": "membership", "operator": "memberOf", "membershipMode": "direct", "groupObjectIds": ["…"] }
|
||||
{ "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["…"] }
|
||||
```
|
||||
|
||||
- `direct` — the account is a member of the named group itself.
|
||||
- `transitive` — the account is a member through any chain of nested groups.
|
||||
|
||||
`membershipMode` is per-condition (RE-007); mixing both in one rule set — or one rule — is fully
|
||||
supported. Omit it to fall back to `engine.defaultMembershipMode` (itself defaulting to `direct`).
|
||||
|
||||
Use `transitive` for role-holding groups that other groups nest into, which describes most Tier 0
|
||||
groups. Use `direct` when membership is explicitly and individually managed, and nesting into the
|
||||
group would be a mistake you want the engine to ignore.
|
||||
|
||||
Each facet — direct groups, transitive groups, directory roles — is retrieved independently and
|
||||
fails independently. A transitive lookup timing out does not affect a `direct` condition in the same
|
||||
rule; it becomes `Unknown` only for the conditions that actually needed it.
|
||||
|
||||
**Do not set `dataSources.groups.membershipMode` globally unless you specifically want to forbid the
|
||||
other mode.** Leaving it unset means "any mode is acceptable, decide per condition"; setting it turns
|
||||
every condition using the other mode into a `PE-SEM-014` warning.
|
||||
|
||||
---
|
||||
|
||||
## Combining conditions: `all` and `any`
|
||||
|
||||
Every condition tree, at every level, is a group:
|
||||
|
||||
```json
|
||||
{ "operator": "all", "conditions": [ /* … */ ] }
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```json
|
||||
{ "operator": "any", "conditions": [ /* … */ ] }
|
||||
```
|
||||
|
||||
`conditions` holds a mix of leaf conditions and nested groups — a nested group is just an entry in
|
||||
the array that itself has `operator` and `conditions` instead of `type`.
|
||||
|
||||
### Simple: all conditions must hold
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" },
|
||||
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000b0"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Simple: any condition is enough
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "any",
|
||||
"conditions": [
|
||||
{ "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"] },
|
||||
{ "type": "role", "operator": "memberOf", "roleIds": ["<TIER0-ROLE-TEMPLATE-ID>"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Nested: "in the test group, OR named like a test account while disabled"
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "any",
|
||||
"conditions": [
|
||||
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000c0"] },
|
||||
{
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "test-" },
|
||||
{ "type": "property", "property": "AccountEnabled", "operator": "equals", "value": "False" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Read the nested group as one unit: it is "named like a test account **and** disabled", offered as
|
||||
one alternative alongside plain group membership. Nesting composes exactly the way parentheses do in
|
||||
any boolean expression.
|
||||
|
||||
**Keep nesting to three levels or fewer.** The hard ceiling is `maxConditionDepth` (default 5,
|
||||
configurable up to 10), and exceeding it fails the rule safe — every account it reaches becomes
|
||||
`EvaluationError` rather than silently truncating the tree. But depth is also a readability cost
|
||||
before it is ever a technical one: a rule that seems to need four or five levels is usually two
|
||||
rules at different priorities instead. See [Nesting](BusinessRules.md#nesting) for why.
|
||||
|
||||
---
|
||||
|
||||
## How `Unknown` moves through a tree
|
||||
|
||||
Every condition and every group returns one of three results: `True`, `False`, or `Unknown`.
|
||||
`Unknown` means the data needed to answer could not be retrieved — not "we don't know the value," but
|
||||
"the lookup itself failed."
|
||||
|
||||
| Group | If it contains… | Result |
|
||||
| --- | --- | --- |
|
||||
| `all` | any `False` | `False`, regardless of any `Unknown` sibling |
|
||||
| `all` | only `True`, plus at least one `Unknown` | `Unknown` |
|
||||
| `any` | any `True` | `True`, regardless of any `Unknown` sibling |
|
||||
| `any` | only `False`, plus at least one `Unknown` | `Unknown` |
|
||||
|
||||
A definite result always wins over an `Unknown` sibling — an `all` group with one `False` condition
|
||||
cannot match no matter what else is unknown, so there is no reason to degrade that to an error. Only
|
||||
when nothing definite decided the group does an `Unknown` anywhere in it propagate.
|
||||
|
||||
An `Unknown` at a rule's **root** `match` makes the account `EvaluationError`: nothing is written, and
|
||||
the previously stored persona is preserved. This is why `notMemberOf` guarding a privileged
|
||||
classification is safe even when a lookup occasionally fails — the account falls into
|
||||
`EvaluationError`, never into a false demotion. Full mechanics: [ConfigurationReference.md](ConfigurationReference.md#tri-state-evaluation).
|
||||
|
||||
---
|
||||
|
||||
## Worked recipes
|
||||
|
||||
Complete, runnable condition trees for situations that come up constantly. Combine, don't
|
||||
copy-paste blindly — read [BusinessRules.md](BusinessRules.md) for when each pattern is and isn't
|
||||
appropriate.
|
||||
|
||||
**Identify a fixed set of accounts by Object ID** (break-glass, service owners — anything where a
|
||||
name would be the wrong signal):
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "any",
|
||||
"conditions": [
|
||||
{ "type": "property", "property": "AccountObjectId", "operator": "in",
|
||||
"values": ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Two independent signals for a consequential classification** (naming convention alone is a habit,
|
||||
not a control):
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" },
|
||||
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000b0"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Privileged group OR the equivalent directory role** (a group nested for one identity model, a role
|
||||
assignment for another — either should count):
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "any",
|
||||
"conditions": [
|
||||
{ "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"] },
|
||||
{ "type": "role", "operator": "memberOf", "roleIds": ["<TIER0-ROLE-TEMPLATE-ID>"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**External by company name, but not your own organization** (`isNotNull` first, so a blank
|
||||
`CompanyName` does not fall through as "external"):
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{ "type": "property", "property": "CompanyName", "operator": "isNotNull" },
|
||||
{ "type": "property", "property": "CompanyName", "operator": "notEquals", "value": "<ORGANIZATION-NAME>" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Internal by domain suffix instead of company name:**
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{ "type": "property", "property": "UserPrincipalName", "operator": "endsWith", "value": "@<PRIMARY-DOMAIN>" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Exclude disabled accounts from an otherwise broad rule:**
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{ "type": "property", "property": "Department", "operator": "equals", "value": "Finance" },
|
||||
{ "type": "property", "property": "AccountEnabled", "operator": "equals", "value": "True" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Match against a custom directory extension attribute** (e.g. an HR-fed employment type):
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{ "type": "property", "property": "extension_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4_employmentType",
|
||||
"operator": "in", "values": ["Contractor", "Vendor"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Default / catch-all rule** (lowest priority in the rule set, so every more specific rule wins
|
||||
first):
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "all",
|
||||
"conditions": [
|
||||
{ "type": "property", "property": "UserType", "operator": "equals", "value": "Member" },
|
||||
{ "type": "property", "property": "Department", "operator": "isNotNull" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The shipped [`config/persona-engine.example.json`](../config/persona-engine.example.json) is a
|
||||
complete rule set built from these same patterns end to end, in priority order — read it alongside
|
||||
this guide.
|
||||
|
||||
---
|
||||
|
||||
## Building a rule with the interactive editor
|
||||
|
||||
Everything above can be hand-written as JSON, or built through
|
||||
[`Edit-PersonaEngineConfig.ps1`](../Edit-PersonaEngineConfig.ps1)'s menu, which won't let you save a
|
||||
structurally invalid tree.
|
||||
|
||||
```bash
|
||||
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json
|
||||
```
|
||||
|
||||
From the main menu:
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `L` | List rules — ID, name, priority, enabled state |
|
||||
| `A` | Add a rule — prompts for identity fields, then walks you through building `match` node by node |
|
||||
| `E` | Edit a rule — change a top-level field, or add/edit/remove a condition or nested group |
|
||||
| `T` | Toggle a rule enabled/disabled |
|
||||
| `P` | Change a rule's priority |
|
||||
| `D` | Delete a rule |
|
||||
| `V` | Re-validate the in-memory document |
|
||||
| `R` | Run the rule set against synthetic fixtures (`-TestDataPath`) |
|
||||
| `S` | Save — blocked while any `Error` finding is outstanding |
|
||||
| `Q` | Quit |
|
||||
|
||||
When building a condition, the editor prompts `[P]roperty [M]embership [R]ole` for the type, then
|
||||
lists the operators valid for whatever you chose next — you cannot accidentally pair `memberOf` with
|
||||
a `property` condition, because the editor only offers the combinations the schema allows.
|
||||
|
||||
For scripted use, add `-NonInteractive -ValidateOnly` to validate a file with no menu at all — see
|
||||
[Changing a rule set](BusinessRules.md#changing-a-rule-set) for the full validate-preview-deploy
|
||||
sequence.
|
||||
|
||||
---
|
||||
|
||||
## Validate what you wrote
|
||||
|
||||
```bash
|
||||
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
|
||||
```
|
||||
|
||||
Exit `0` means all four validation layers passed — syntax, schema, semantic, safety. A non-zero exit
|
||||
prints the findings that blocked it; codes and their meaning are in
|
||||
[ConfigurationReference.md](ConfigurationReference.md#validation-layers).
|
||||
|
||||
Then run the rule against fixtures before it ever sees a real account:
|
||||
|
||||
```bash
|
||||
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath <fixtures-dir> -ValidateOnly -NonInteractive
|
||||
```
|
||||
|
||||
See [Testing a rule set without a tenant](BusinessRules.md#testing-a-rule-set-without-a-tenant) for
|
||||
what a good fixture directory looks like.
|
||||
|
||||
---
|
||||
|
||||
## Cheat sheet
|
||||
|
||||
| Operator | `type` | Needs | Compares |
|
||||
| --- | --- | --- | --- |
|
||||
| `equals` / `notEquals` | property | `value` | Case-insensitive exact |
|
||||
| `contains` / `notContains` | property | `value` | Case-insensitive substring |
|
||||
| `startsWith` / `endsWith` | property | `value` | Case-insensitive prefix/suffix |
|
||||
| `matchesRegex` | property | `value` (a pattern) | Case-insensitive regex, validated at save time |
|
||||
| `in` / `notIn` | property | `values` (array) | Case-insensitive membership in a list |
|
||||
| `isNull` / `isNotNull` | property | *(neither `value` nor `values`)* | Absent, `null`, or empty string |
|
||||
| `memberOf` / `notMemberOf` | membership | `groupObjectIds`, optional `membershipMode` | Group membership, direct or transitive |
|
||||
| `memberOf` / `notMemberOf` | role | `roleIds` | Directory role assignment |
|
||||
|
||||
Group operators: `all` (every child must hold) and `any` (at least one child must hold), nestable to
|
||||
`maxConditionDepth`.
|
||||
@@ -0,0 +1,168 @@
|
||||
# Security model
|
||||
|
||||
What this engine is permitted to do, what it is not, and where the gap between those two is held
|
||||
open by testing rather than by the platform.
|
||||
|
||||
## The central trade-off (OTD-003)
|
||||
|
||||
**Microsoft Graph application permissions have no per-property write scope.** An identity granted
|
||||
`User.ReadWrite.All` can write *any* writable property on *any* user object. It cannot be narrowed to
|
||||
one extension attribute.
|
||||
|
||||
This is not a limitation to be worked around. It is a fact about the platform, recorded here so that
|
||||
nobody later assumes the directory is enforcing something it is not.
|
||||
|
||||
The consequence: **the only thing standing between this engine and every writable user property is
|
||||
the code in this repository, and the tests that hold it to that.** Every control below exists because
|
||||
the directory will not refuse a malformed request on our behalf.
|
||||
|
||||
## The six compensating controls
|
||||
|
||||
All six are mandatory. Each is testable, and each is tested.
|
||||
|
||||
| # | Control | Where it lives | Proof |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | The persistence layer accepts only the configured target attribute | `New-PersonaWriteBody` throws for any other name | `WriteBodyRejection.Tests.ps1` |
|
||||
| 2 | The target must appear in `approvedWritableAttributes` | `Resolve-TargetAttribute` and `New-PersonaWriteBody`, checked twice | `WriteBodyRejection.Tests.ps1` |
|
||||
| 3 | Validation rejects every other attribute | `PE-SAF-002`, layer 4 | `Safety.Tests.ps1` |
|
||||
| 4 | One dedicated function builds the request body, and it is the only one | `New-PersonaWriteBody` returns a hashtable whose `Count` is exactly 1 | `WriteBody.Tests.ps1` |
|
||||
| 5 | Tests inspect the captured request body | Every body issued during a full enforcing run is asserted to have one key | `WriteBody.Tests.ps1` |
|
||||
| 6 | Code owners and branch policies gate persistence changes | Repository configuration, outside this codebase | Branch protection on `src/Persistence/` |
|
||||
|
||||
Control 4 is the load-bearing one. A single construction site makes SC-005 a property of one testable
|
||||
function rather than a convention every future call site has to remember. `WriteBody.Tests.ps1`
|
||||
includes a scan asserting that no other file under `src/` builds a PATCH body.
|
||||
|
||||
Control 2 is deliberately redundant. Validation runs once at startup against the file; the write
|
||||
builder checks again on every write against the values actually in hand — so a configuration object
|
||||
mutated mid-run still cannot widen the blast radius.
|
||||
|
||||
### Why comparison is ordinal here and case-insensitive elsewhere
|
||||
|
||||
Rule matching is case-insensitive (RE-006), because a rule author should not have to match directory
|
||||
casing. Attribute approval is **ordinal and case-sensitive**, because extension property names are
|
||||
case-sensitive in Graph: `extension_<id>_Persona` and `extension_<id>_persona` are two different
|
||||
attributes, and approving one does not approve the other.
|
||||
|
||||
Change detection is also ordinal (FR-015). A stored `employee` against a calculated `Employee` is a
|
||||
real difference worth correcting, not a formatting quirk.
|
||||
|
||||
## Permissions
|
||||
|
||||
### Stage A — local, delegated (current)
|
||||
|
||||
```powershell
|
||||
Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'
|
||||
```
|
||||
|
||||
Read-only. Sufficient for every preview run and for closing V-1 (read) and V-3.
|
||||
|
||||
The engine requests only the scopes the enabled rules actually need: a configuration with no role
|
||||
conditions never asks for `RoleManagement.Read.Directory`, and a configuration with no membership
|
||||
conditions never asks for `GroupMember.Read.All`. Least privilege applies to data as well as to
|
||||
permissions — properties nothing references are not even added to `$select`.
|
||||
|
||||
**Stage A3 (delegated write) adds `User.ReadWrite.All` and targets purpose-created test accounts
|
||||
only.** Under delegated authentication the write runs *as the operator*, which makes the compensating
|
||||
controls more important rather than less: the directory sees the operator's own permissions, not a
|
||||
narrowed service identity.
|
||||
|
||||
> **Never sign in with a standing privileged account for a write run.** A Global Administrator
|
||||
> session invalidates V-3 as evidence and removes every practical limit on what a defect could reach.
|
||||
|
||||
### Stage B — Azure Automation, application permissions (deferred)
|
||||
|
||||
`User.Read.All`, `GroupMember.Read.All`, `RoleManagement.Read.Directory`, and — for enforcement —
|
||||
`User.ReadWrite.All`, granted to a **managed identity**. No client secret, ever, in source control or
|
||||
in a runbook parameter.
|
||||
|
||||
Deferred, not waived: no Automation account is available. V-3b and V-5b remain open.
|
||||
|
||||
## The persona attribute (OTD-001)
|
||||
|
||||
A **directory (schema) extension property**, registered on an application registration and addressable
|
||||
as `extension_<appId>_<name>`.
|
||||
|
||||
Two alternatives were rejected for concrete reasons:
|
||||
|
||||
- **`extensionAttribute1..15`** — unavailable for cloud writes on objects that are, or ever were,
|
||||
synchronized from on-premises, and on Exchange-originated objects. A classification engine that
|
||||
silently cannot write to a subset of the population is worse than one that cannot write at all.
|
||||
- **Custom security attributes** — not exposed to the dynamic group membership engine, which defeats
|
||||
the purpose: the persona exists so that Conditional Access can be targeted through dynamic groups.
|
||||
|
||||
`PE-SAF-002` rejects any approved attribute that is not shaped like a directory extension property.
|
||||
Built-in attributes such as `department` or `jobTitle` are excluded **even when the operator holds
|
||||
permission to write them** — they are authoritative in the sync source or in HR, and this engine does
|
||||
not own them.
|
||||
|
||||
## Data handling
|
||||
|
||||
**Approved for logs**: user principal name, account object ID, matched rule ID, stored and calculated
|
||||
persona values, run ID, configuration version and hash.
|
||||
|
||||
**Never logged, under any setting**: access tokens, `Authorization` headers, client secrets,
|
||||
certificates, credentials, or full Graph responses.
|
||||
|
||||
The guarantee is structural rather than filtered. `New-PersonaAuditRecord` accepts only named, typed
|
||||
values from the decision result and the counters — there is no pass-through of an arbitrary object,
|
||||
so there is nothing for a secret to ride in on. `AuditRedaction.Tests.ps1` asserts this holds even
|
||||
when a caller actively attaches a token to the decision result.
|
||||
|
||||
### Condition tracing
|
||||
|
||||
`logging.traceConditionValues` writes evaluated attribute values into audit records, widening what
|
||||
the log contains beyond the approved set. It requires `logging.acknowledgeConditionTracing` in the
|
||||
same configuration, or validation fails with `PE-SAF-006`.
|
||||
|
||||
The acknowledgement lives in the configuration rather than in a command-line switch on purpose: a
|
||||
flag passed at a console is invisible to review, while a field in the configuration appears in the
|
||||
diff of the change that enables tracing, next to the person who approved it.
|
||||
|
||||
## The no-write control
|
||||
|
||||
`-WhatIf` is the only approved no-write control.
|
||||
|
||||
`-Debug` does **not** imply read-only. A `-Debug` run without `-WhatIf` writes, and
|
||||
`ShouldProcessGate.Tests.ps1` asserts that it does — because an operator who believed otherwise would
|
||||
reach for `-Debug` as a safety measure and get an enforcing run. The same holds for `-Verbose`.
|
||||
|
||||
`ShouldProcessGate.Tests.ps1` also asserts that the entry script declares no `-Preview`, `-NoWrite`,
|
||||
`-ReadOnly`, or `-DryRun` parameter, and never reads `$WhatIfPreference`.
|
||||
|
||||
## Verification gates
|
||||
|
||||
| Item | Status | Blocks |
|
||||
| --- | --- | --- |
|
||||
| V-1 read half | Open — needs a tenant | Confidence in the read path across origin types |
|
||||
| V-1 write half | Open — needs test accounts | Enforcement |
|
||||
| V-2 dynamic group + CA | Open | Declaring the persona useful |
|
||||
| V-3 non-privileged `-WhatIf` run | Open — needs a tenant | Stage A2 sign-off |
|
||||
| V-3b managed-identity scopes | Deferred | Stage B |
|
||||
| **V-4 security sign-off on these controls** | **Open** | **All enforcement (T101)** |
|
||||
| V-5a `Test-Json` behaviour | **Closed** — see [V-5a.md](../specs/001-persona-engine/verification/V-5a.md) | Layer 2 implementation |
|
||||
| V-5b `Test-Json` in Automation | Deferred | Stage B |
|
||||
|
||||
**V-4 gates enforcement.** No write run against anything other than purpose-created test accounts
|
||||
until it is recorded in `specs/001-persona-engine/verification/V-4.md`.
|
||||
|
||||
## Kill switch
|
||||
|
||||
In increasing order of severity — see [OperationsRunbook.md](OperationsRunbook.md) for the procedure:
|
||||
|
||||
1. Run with `-WhatIf`.
|
||||
2. Set every rule to `enabled: false` and deploy.
|
||||
3. Disable the Automation schedule (Stage B).
|
||||
4. Revoke `User.ReadWrite.All` from the execution identity.
|
||||
5. Remove the write deployment stage.
|
||||
|
||||
Steps 4 and 5 are the ones that hold if the code itself is the problem.
|
||||
|
||||
## 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 any secret may appear in any tracked file.
|
||||
Placeholders only.
|
||||
|
||||
Runtime records naturally contain real UPNs and Object IDs — approved for logs — but no such value is
|
||||
ever committed.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Contract: Structured Audit Records
|
||||
|
||||
Serialized form of the audit trail (FR-022, NFR-005, Principle V). Format is newline-delimited JSON,
|
||||
one record per line (OTD-006). All emission goes through a single `Write-PersonaAuditRecord` sink so
|
||||
a future transport can be added without touching call sites.
|
||||
|
||||
## Common envelope
|
||||
|
||||
Every record carries:
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `timestamp` | string (ISO 8601 UTC) | |
|
||||
| `recordType` | string | `RunStart`, `UserEvent`, `Summary`, `RunComplete`, `EngineDefect` |
|
||||
| `runId` | string (GUID) | Constant for the run (NFR-005) |
|
||||
| `engineVersion` | string | |
|
||||
| `configVersion` | string | |
|
||||
| `configurationHash` | string | SHA-256 of the configuration file |
|
||||
| `mode` | string | `Preview` or `Enforce` |
|
||||
|
||||
## `UserEvent`
|
||||
|
||||
Emitted once per processed user. 100% of these records carry `runId`, `userPrincipalName`, and
|
||||
`accountObjectId` (SC-006).
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-08-20T09:14:02.187Z",
|
||||
"recordType": "UserEvent",
|
||||
"runId": "<RUN-ID>",
|
||||
"engineVersion": "1.0.0",
|
||||
"configVersion": "1.4.0",
|
||||
"configurationHash": "<SHA256>",
|
||||
"mode": "Preview",
|
||||
"accountObjectId": "<ACCOUNT-OBJECT-ID>",
|
||||
"userPrincipalName": "<USER>@<PRIMARY-DOMAIN>",
|
||||
"outcome": "Matched",
|
||||
"matchedRuleId": "RULE-0100-TIER0",
|
||||
"storedPersona": "Employee",
|
||||
"calculatedPersona": "Tier0-Admin",
|
||||
"previousValue": "Employee",
|
||||
"action": "WouldUpdate",
|
||||
"rulesEvaluated": 4,
|
||||
"durationMs": 38,
|
||||
"evaluationErrorReason": null
|
||||
}
|
||||
```
|
||||
|
||||
**Field requirements**
|
||||
|
||||
| Field | Requirement |
|
||||
| --- | --- |
|
||||
| `outcome` | Exactly one of `Matched`, `Unclassified`, `EvaluationError` (SC-001) |
|
||||
| `matchedRuleId` | Non-null on every `Matched` record (SC-006) |
|
||||
| `previousValue` | **Captured at write time on every `Updated` record.** This is what makes OTD-010 rollback possible; omitting it in v1 makes rollback impossible retroactively |
|
||||
| `evaluationErrorReason` | Non-null exactly when `outcome` is `EvaluationError` |
|
||||
|
||||
**Prohibited fields**: access tokens, `Authorization` headers, secrets, and full Graph responses
|
||||
MUST NEVER appear in any record.
|
||||
|
||||
**Condition tracing**: a `conditionTrace` array may be added **only** under `-Debug`
|
||||
(`logging.traceConditionValues`). It contains diagnostic condition-level values and is therefore
|
||||
gated by acknowledgement (VR-003).
|
||||
|
||||
## `Summary`
|
||||
|
||||
Emitted every `summaryInterval` users and once at completion.
|
||||
|
||||
```json
|
||||
{
|
||||
"recordType": "Summary",
|
||||
"runId": "<RUN-ID>",
|
||||
"summaryType": "Interim",
|
||||
"processed": 250,
|
||||
"matched": 231,
|
||||
"unclassified": 14,
|
||||
"evaluationError": 5,
|
||||
"unchanged": 220,
|
||||
"wouldUpdate": 11,
|
||||
"updated": 0,
|
||||
"updateFailed": 0,
|
||||
"reconciliationPassed": true,
|
||||
"ruleCounts": [
|
||||
{ "ruleId": "RULE-0100-TIER0", "name": "Tier 0 administrators", "enabled": true, "matches": 3 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`reconciliationPassed` is `processed == matched + unclassified + evaluationError` (FR-021, SC-007).
|
||||
`ruleCounts` lists **all** business rules, including disabled and zero-match rules — an absent rule
|
||||
is indistinguishable from a rule that never fired, and operators need that distinction.
|
||||
|
||||
## `RunComplete`
|
||||
|
||||
```json
|
||||
{
|
||||
"recordType": "RunComplete",
|
||||
"runId": "<RUN-ID>",
|
||||
"startedUtc": "2026-08-20T09:00:00.000Z",
|
||||
"completedUtc": "2026-08-20T09:12:44.913Z",
|
||||
"durationMs": 764913,
|
||||
"processed": 4820,
|
||||
"matched": 4611,
|
||||
"unclassified": 190,
|
||||
"evaluationError": 19,
|
||||
"unchanged": 4400,
|
||||
"wouldUpdate": 211,
|
||||
"updated": 0,
|
||||
"updateFailed": 0,
|
||||
"reconciliationPassed": true,
|
||||
"exitCode": 0
|
||||
}
|
||||
```
|
||||
|
||||
## `EngineDefect`
|
||||
|
||||
Emitted when reconciliation fails (FR-021) or an internal invariant is violated. Severity is always
|
||||
`Error`. A failed reconciliation is a defect in the engine, not a property of the data, and is
|
||||
reported as such rather than being folded into ordinary counters.
|
||||
|
||||
## Sanitization (SC-013)
|
||||
|
||||
Committed artifacts — this contract, examples, fixtures, tests, and documentation — use placeholders
|
||||
only: `<ORGANIZATION-NAME>`, `<PRIMARY-DOMAIN>`, `<TENANT-ID>`, `<ACCOUNT-OBJECT-ID>`,
|
||||
`<GROUP-OBJECT-ID>`, `<APPROVED-PERSONA-ATTRIBUTE-NAME>`, `<AUTOMATION-ACCOUNT-NAME>`,
|
||||
`<LOG-OUTPUT-PATH>`, `<RUN-ID>`. Runtime records naturally contain real UPNs and Object IDs — which
|
||||
are approved for logs — but no such value may ever be committed to this repository.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Contract: `Edit-PersonaEngineConfig.ps1`
|
||||
|
||||
Configuration validation, interactive editing, synthetic rule testing, and pipeline enforcement
|
||||
(FR-023 – FR-026).
|
||||
|
||||
## Signature
|
||||
|
||||
```powershell
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[Parameter(Mandatory)][string] $ConfigPath,
|
||||
[Parameter()][switch] $ValidateOnly,
|
||||
[Parameter()][switch] $NonInteractive,
|
||||
[Parameter()][string] $SchemaPath,
|
||||
[Parameter()][string] $OutputPath,
|
||||
[Parameter()][switch] $TreatWarningsAsErrors,
|
||||
[Parameter()][string] $TestDataPath
|
||||
)
|
||||
```
|
||||
|
||||
## Parameter contract
|
||||
|
||||
| Parameter | Behaviour |
|
||||
| --- | --- |
|
||||
| `-ConfigPath` | Required. Configuration to validate or edit. |
|
||||
| `-ValidateOnly` | Validate and report; never enter the editor. |
|
||||
| `-NonInteractive` | Pipeline mode. **MUST NOT prompt and MUST NOT hang** (SC-010). Returns an exit code. |
|
||||
| `-SchemaPath` | Override the shipped schema. |
|
||||
| `-OutputPath` | Save-As target; leaves the input file untouched. |
|
||||
| `-TreatWarningsAsErrors` | Escalates `Warning` findings to blocking (VR-005). |
|
||||
| `-TestDataPath` | Synthetic sample users for offline rule testing (FR-025). No tenant connectivity. |
|
||||
|
||||
## Validation layers (VR-001, ordered, fail-fast between layers)
|
||||
|
||||
| Layer | Mechanism | Example findings |
|
||||
| --- | --- | --- |
|
||||
| 1. Syntax | `ConvertFrom-Json` | Malformed JSON |
|
||||
| 2. Schema | `Test-Json -SchemaFile` (draft-07, OTD-005) | Missing required field, wrong type, bad enum |
|
||||
| 3. Semantic | PowerShell checks | Every condition in VR-002 |
|
||||
| 4. Safety | PowerShell checks | Every condition in VR-003 |
|
||||
|
||||
A layer that produces `Error` findings stops the sequence — running semantic checks over a
|
||||
structurally invalid document yields noise, not signal.
|
||||
|
||||
### Layer 2 error-handling requirement
|
||||
|
||||
`Test-Json` reports schema failure by writing errors rather than returning `$false` in several
|
||||
PowerShell versions. The wrapper MUST invoke it with `-ErrorAction SilentlyContinue -ErrorVariable`
|
||||
and convert collected errors into `ValidationFinding` objects, so layer 2 emits the same structured
|
||||
shape as every other layer (VR-004).
|
||||
|
||||
## Finding contract
|
||||
|
||||
Every finding carries `Severity`, `Code`, `Location` (JSON path or rule ID), `Description`,
|
||||
`SuggestedResolution`, and `Layer`. Finding codes are stable and namespaced by layer:
|
||||
|
||||
```text
|
||||
PE-SYN-nnn syntax
|
||||
PE-SCH-nnn schema
|
||||
PE-SEM-nnn semantic (one code per VR-002 condition)
|
||||
PE-SAF-nnn safety (one code per VR-003 condition)
|
||||
```
|
||||
|
||||
Stability matters: pipelines and runbooks will match on these codes.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Condition |
|
||||
| --- | --- |
|
||||
| `0` | Valid; no blocking findings |
|
||||
| `1` | One or more `Error` findings |
|
||||
| `2` | `Warning` findings present with `-TreatWarningsAsErrors` |
|
||||
| `3` | Configuration file not found or unreadable |
|
||||
| `4` | Schema file not found or itself invalid |
|
||||
|
||||
## Interactive editor commands (FR-023, FR-027 – FR-030)
|
||||
|
||||
The interactive loop (entered when neither `-ValidateOnly` nor `-NonInteractive` is set) supports:
|
||||
|
||||
| Command | Behaviour |
|
||||
| --- | --- |
|
||||
| List rules | Show every rule's priority, ID, persona, and enabled state. |
|
||||
| Toggle a rule | Flip `enabled` on an existing rule. |
|
||||
| Change a priority | Set a new numeric priority on an existing rule. |
|
||||
| **Add a rule** | Prompt for every RE-001 field (`id`, `name`, `description`, `priority`, `persona`, `enabled`, and optional fields) and for the condition tree — nested `all`/`any` groups and, per leaf condition, the property/membership source, operator, and comparison value. Reject on the spot if the `id` or `priority` collides with an existing rule (FR-027). |
|
||||
| **Edit a rule** | Select an existing rule by `id`; change any top-level field and/or the condition tree — add, edit, remove, or renest conditions and groups within `MaxConditionDepth` (FR-028). |
|
||||
| **Delete a rule** | Select an existing rule by `id`; show its `id`, `name`, and `priority` and require explicit confirmation before removing it (FR-029). |
|
||||
| Re-validate | Run all four validation layers against the in-memory document, including any unsaved add/edit/delete, and print findings without saving. |
|
||||
| Run rule test | Evaluate the in-memory document (including unsaved structural edits) against `-TestDataPath` fixtures. |
|
||||
| Save | Re-validate, then persist per the save contract below. |
|
||||
| Quit | Warn if there are unsaved changes (including structural edits) before discarding them. |
|
||||
|
||||
All structural edits (add/edit/delete) are applied to the in-memory document only. They are never
|
||||
written to `-ConfigPath` (or `-OutputPath`) until a `Save` re-validates the full document and that
|
||||
validation passes — the same rule that governs field-level edits (FR-030). A depth violation
|
||||
introduced by an add or edit is reported immediately using the same finding the runtime validator
|
||||
would produce, rather than deferred to the next save or re-validate.
|
||||
|
||||
## Save contract (FR-026)
|
||||
|
||||
1. Re-validate the edited document in full.
|
||||
2. Block the save on any `Error` finding.
|
||||
3. Write a timestamped backup — or require `-OutputPath` — before replacing an existing file.
|
||||
4. Overwriting the only valid configuration without a backup is a safety finding (VR-003), not
|
||||
merely a warning.
|
||||
|
||||
## Invariants (test-asserted)
|
||||
|
||||
| Invariant | Assertion |
|
||||
| --- | --- |
|
||||
| Non-interactive never prompts | Runs to completion with stdin closed; no prompt, no hang (SC-010) |
|
||||
| Every VR-002/VR-003 condition detected | One test per condition, each asserting code, severity, and location (SC-009) |
|
||||
| Offline | Full validation and synthetic rule testing complete with no network access (SC-008) |
|
||||
@@ -0,0 +1,93 @@
|
||||
# Contract: `Invoke-PersonaEngine.ps1`
|
||||
|
||||
The engine entry point. Retrieval, evaluation, reporting, and controlled persistence.
|
||||
|
||||
## Signature
|
||||
|
||||
```powershell
|
||||
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
|
||||
param(
|
||||
[Parameter()][string] $ConfigPath = './config/persona-engine.json',
|
||||
[Parameter()][guid] $UserObjectId,
|
||||
[Parameter()][string] $OutputPath,
|
||||
[Parameter()][guid] $CorrelationId
|
||||
)
|
||||
```
|
||||
|
||||
`SupportsShouldProcess` supplies `-WhatIf` and `-Confirm`. `-Verbose` and `-Debug` are common
|
||||
parameters and are **not** declared.
|
||||
|
||||
## Parameter contract
|
||||
|
||||
| Parameter | Required | Behaviour |
|
||||
| --- | --- | --- |
|
||||
| `-ConfigPath` | No | Path to the JSON configuration. Defaults to `./config/persona-engine.json`, resolved against the current directory, when omitted. Validated through all four layers before any connection is made (FR-002). |
|
||||
| `-WhatIf` | No | **The approved no-write control.** Reads, evaluation, comparison, console output, summaries, and audit records all behave identically to enforcement; zero write requests are issued (FR-017, SC-004). |
|
||||
| `-UserObjectId` | No | Single-user execution for validation. Skips enumeration; retrieves one user. |
|
||||
| `-OutputPath` | No | Overrides the configured audit output path where permitted. |
|
||||
| `-CorrelationId` | No | Supplied run identifier. Generated when absent. Appears on every audit record. |
|
||||
| `-Verbose` | No | Operational detail. **MUST NOT** alter write behaviour. |
|
||||
| `-Debug` | No | Enables condition-value tracing (Principle V). **MUST NOT** imply read-only — a `-Debug` run without `-WhatIf` writes. |
|
||||
|
||||
## Mode determination
|
||||
|
||||
```text
|
||||
$PSCmdlet.ShouldProcess() returns $false -> Preview mode -> no write request constructed or sent
|
||||
$PSCmdlet.ShouldProcess() returns $true -> Enforce mode -> write permitted, subject to FR-016
|
||||
```
|
||||
|
||||
Mode MUST be derived from `ShouldProcess` alone. A separate boolean "preview" flag is prohibited —
|
||||
two sources of truth for the write gate is precisely the defect class Principle III exists to
|
||||
prevent.
|
||||
|
||||
## Write gate (FR-016)
|
||||
|
||||
A write is issued only when **all** hold:
|
||||
|
||||
1. Evaluation completed successfully (`Outcome != EvaluationError`).
|
||||
2. `CalculatedPersona != StoredPersona` (ordinal comparison, case-sensitive for change detection).
|
||||
3. The target attribute is non-blank and present in `approvedWritableAttributes`.
|
||||
4. `ShouldProcess` returned `$true` for this user.
|
||||
|
||||
Failing any of these yields `Unchanged`, `WouldUpdate`, or `Skipped` — never a silent write.
|
||||
|
||||
## Output contract
|
||||
|
||||
- **Per user, immediately after evaluation** (FR-018, SC-012): one console line carrying UPN,
|
||||
Account Object ID, outcome, matched rule ID, stored value, calculated value, and action.
|
||||
- **Every `summaryInterval` users** (FR-019): a table of all business rules with match counts, plus
|
||||
outcome totals, elapsed wall-clock time since the run started, and a reconciliation check.
|
||||
- **At completion**: a final summary regardless of interval, including when the interval is `0`
|
||||
(FR-020).
|
||||
- **Every summary, interim and final**: `logging.resultsFileName` (default `results.csv`, written
|
||||
next to the audit log) is overwritten with one row per account processed so far — Account Object
|
||||
ID, UPN, the assigned persona or outcome status, company name, and department.
|
||||
- **Reconciliation** at every summary: `Processed = Matched + Unclassified + EvaluationError`
|
||||
(FR-021). A mismatch is logged as an engine defect, at `Error` severity.
|
||||
- **Audit records**: see [audit-record.md](audit-record.md).
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Condition |
|
||||
| --- | --- |
|
||||
| `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 |
|
||||
|
||||
Every code MUST be reachable and returned for its documented condition (SC-011). A per-user
|
||||
`EvaluationError` does **not** by itself terminate the run; the final status reports the affected
|
||||
count and applies `evaluationErrorThreshold` when configured.
|
||||
|
||||
## Invariants (test-asserted)
|
||||
|
||||
| Invariant | Assertion |
|
||||
| --- | --- |
|
||||
| Zero writes under `-WhatIf` | The write adapter is mocked; call count is `0` over a full synthetic population (SC-004) |
|
||||
| Single-attribute body | Every captured request body has exactly one key, equal to `engine.targetAttribute` (SC-005) |
|
||||
| Idempotence | Second consecutive run over unchanged input issues zero writes (SC-002) |
|
||||
| Determinism | Same fixture set, shuffled input order, identical results (SC-003) |
|
||||
| Exactly one outcome | Every processed user appears in exactly one outcome bucket (SC-001) |
|
||||
@@ -0,0 +1,124 @@
|
||||
# Contract: Directory Data Provider
|
||||
|
||||
The only component permitted to talk to Microsoft Graph. Implements OTD-002, OTD-004, and OTD-007.
|
||||
Everything below the normalization boundary is invisible to the rule engine (Principle IV).
|
||||
|
||||
**Transport**: `Invoke-MgGraphRequest` from `Microsoft.Graph.Authentication` (OTD-004). No
|
||||
resource-specific SDK modules.
|
||||
|
||||
## Authentication (FR-003)
|
||||
|
||||
| Function | Environment | Mechanism |
|
||||
| --- | --- | --- |
|
||||
| `Connect-PersonaGraphManagedIdentity` | Azure Automation | `Connect-MgGraph -Identity` |
|
||||
| `Connect-PersonaGraphInteractive` | Local development | `Connect-MgGraph -Scopes <read scopes>` |
|
||||
|
||||
Both return an opaque connection handle. No token, header, or secret is ever returned to a caller,
|
||||
logged, or written to an audit record.
|
||||
|
||||
## Permissions (OTD-002)
|
||||
|
||||
| Function | Application permission |
|
||||
| --- | --- |
|
||||
| `Get-PersonaUsers` | `User.Read.All` (or `User.ReadWrite.All` for the enforcement identity) |
|
||||
| `Get-PersonaGroupMembership` | `GroupMember.Read.All` |
|
||||
| `Get-PersonaDirectoryRoles` | `RoleManagement.Read.Directory` |
|
||||
| `Set-UserPersonaAttribute` | `User.ReadWrite.All` |
|
||||
|
||||
`Directory.Read.All` is prohibited — materially broader than the three read permissions combined.
|
||||
|
||||
## Read operations
|
||||
|
||||
### `Get-PersonaUsers`
|
||||
|
||||
```text
|
||||
GET /v1.0/users?$select=<fields>&$top=999
|
||||
```
|
||||
|
||||
- `$select` carries the FR-005 baseline (`id`, `userPrincipalName`, `displayName`, `userType`,
|
||||
`accountEnabled`, `companyName`, `jobTitle`, `department`) plus the configured target attribute and
|
||||
any property referenced by an enabled rule. Unused properties are not requested.
|
||||
- The persona directory extension is selected by its full name,
|
||||
`extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>`.
|
||||
- Pagination follows `@odata.nextLink` until absent (FR-004). A truncated enumeration MUST raise —
|
||||
never return a partial population as if complete.
|
||||
- `-UserObjectId` switches to `GET /v1.0/users/{id}?$select=...`.
|
||||
|
||||
### `Get-PersonaGroupMembership`
|
||||
|
||||
```text
|
||||
GET /v1.0/users/{id}/memberOf # direct
|
||||
GET /v1.0/users/{id}/transitiveMemberOf # transitive
|
||||
```
|
||||
|
||||
Mode comes from the condition, falling back to `engine.defaultMembershipMode` (RE-007).
|
||||
|
||||
**Return contract**: always a `MembershipRecord`. On failure it returns a record with
|
||||
`RetrievalSucceeded = $false` and a `FailureReason` — it MUST NOT return an empty list, and MUST NOT
|
||||
throw past the per-user boundary. This single behaviour is what makes FR-013 work: unknown membership
|
||||
becomes `EvaluationError`, never a false non-match.
|
||||
|
||||
### `Get-PersonaDirectoryRoles`
|
||||
|
||||
```text
|
||||
GET /v1.0/roleManagement/directory/roleAssignments?$filter=principalId eq '<id>'
|
||||
```
|
||||
|
||||
Eligible (PIM) assignments are out of scope for v1 unless authorization is confirmed.
|
||||
|
||||
### Caching
|
||||
|
||||
Group and role data reusable across users is cached for the run's lifetime (NFR-002). The cache is
|
||||
keyed by group or role Object ID and is **never** persisted between runs — a stale cache would make
|
||||
results depend on run history, breaking Principle I.
|
||||
|
||||
## Write operation
|
||||
|
||||
### `New-PersonaWriteBody`
|
||||
|
||||
The **only** function permitted to construct a write body (OTD-003 control 3).
|
||||
|
||||
```powershell
|
||||
# Returns exactly one key.
|
||||
@{ "<engine.targetAttribute>" = "<CalculatedPersona>" }
|
||||
```
|
||||
|
||||
Contract:
|
||||
|
||||
- Throws if the attribute name is not `engine.targetAttribute`.
|
||||
- Throws if the attribute is absent from `approvedWritableAttributes`.
|
||||
- Returns a hashtable whose `Count` is exactly `1`. Tests assert on this directly (SC-005).
|
||||
|
||||
### `Set-UserPersonaAttribute`
|
||||
|
||||
```text
|
||||
PATCH /v1.0/users/{id}
|
||||
Content-Type: application/json
|
||||
|
||||
<body from New-PersonaWriteBody>
|
||||
```
|
||||
|
||||
- Callable **only** when `ShouldProcess` returned `$true`. Under `-WhatIf` this function is not
|
||||
reached — the caller does not construct a request at all (SC-004). Preview mode is an absence of a
|
||||
call, not a suppressed call.
|
||||
- A failure returns `UpdateFailed` for that user and does not terminate the run.
|
||||
|
||||
## Retry policy (OTD-007)
|
||||
|
||||
| Aspect | Value |
|
||||
| --- | --- |
|
||||
| Retryable | 429, 500, 502, 503, 504, transport timeout |
|
||||
| Never retried | 400, 401, 403, 404, 409 |
|
||||
| `Retry-After` | Honoured when present; overrides computed backoff |
|
||||
| Attempts | Max 5 |
|
||||
| Backoff | Exponential from 1s, full jitter, per-delay cap 60s |
|
||||
| Logging | Attempt number, status code, and delay on every retry |
|
||||
|
||||
Exhausted retries on **required** data produce `EvaluationError` for the affected user (FR-013).
|
||||
Exhausted retries during enumeration are fatal (exit code `3`).
|
||||
|
||||
## Prohibited in this layer
|
||||
|
||||
- Logging tokens, `Authorization` headers, or full response bodies (Principle V).
|
||||
- Returning raw Graph objects past `ConvertTo-Persona*Record`.
|
||||
- Any reference to rule, persona, or condition concepts — this layer moves data, it does not decide.
|
||||
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://example.invalid/persona-engine.schema.json",
|
||||
"title": "Persona Engine Configuration",
|
||||
"description": "Draft-07 by decision OTD-005: validated with the built-in Test-Json -SchemaFile cmdlet, whose validator reliably supports draft-04/06/07 only. Do not introduce 2019-09 or 2020-12 constructs. This schema is validation layer 2 of 4; semantic rules (VR-002) and safety rules (VR-003) are enforced in PowerShell, not here.",
|
||||
"type": "object",
|
||||
"required": ["configVersion", "engine", "dataSources", "personas", "rules"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"configVersion": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$",
|
||||
"description": "Semantic version of this configuration. A downgrade is a safety violation (VR-003)."
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"owner": { "type": "string" },
|
||||
"changeReference": { "type": "string" },
|
||||
"description": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"engine": {
|
||||
"type": "object",
|
||||
"required": ["targetAttribute", "approvedWritableAttributes"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"targetAttribute": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "The single attribute the engine may write. Must also appear in approvedWritableAttributes (semantic layer). Example placeholder: extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>"
|
||||
},
|
||||
"approvedWritableAttributes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"maxConditionDepth": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 10,
|
||||
"default": 5,
|
||||
"description": "RE-004. The ceiling of 10 is a hard limit; the configured value may be lower."
|
||||
},
|
||||
"summaryInterval": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"default": 25,
|
||||
"description": "FR-020. Zero suppresses interim summaries; a final summary is always produced."
|
||||
},
|
||||
"defaultMembershipMode": {
|
||||
"type": "string",
|
||||
"enum": ["direct", "transitive"],
|
||||
"default": "direct"
|
||||
},
|
||||
"evaluationErrorThreshold": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Optional. Count of EvaluationError results above which the run reports failure."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dataSources": {
|
||||
"type": "object",
|
||||
"required": ["groups", "roles"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"groups": {
|
||||
"type": "object",
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"membershipMode": { "type": "string", "enum": ["direct", "transitive"] }
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"type": "object",
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"includeEligible": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Out of scope for v1 unless authorization is confirmed and the provider is implemented."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"destination": {
|
||||
"type": "string",
|
||||
"enum": ["file", "stream", "both"],
|
||||
"default": "both",
|
||||
"description": "OTD-006. Additional transports are added behind the sink function, not by widening this enum without a version change."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "NDJSON output file for 'file'/'both' destinations. Defaults to <current-directory>/logs/persona-engine-audit.ndjson when unset."
|
||||
},
|
||||
"resultsFileName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"default": "results.csv",
|
||||
"description": "Per-account results CSV (AccountObjectId, UserPrincipalName, persona/status), written alongside the audit log and overwritten on every summary."
|
||||
},
|
||||
"traceConditionValues": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Diagnostic only. Enabling this without explicit acknowledgement is a safety finding (VR-003)."
|
||||
},
|
||||
"acknowledgeConditionTracing": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Explicit acknowledgement that condition-value tracing writes evaluated attribute values into audit records. Required by VR-003 whenever traceConditionValues is true. Kept in the configuration rather than passed as a command-line flag so the acknowledgement is reviewable in the change that enables tracing."
|
||||
}
|
||||
}
|
||||
},
|
||||
"personas": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"not": { "enum": ["EvaluationError"] }
|
||||
},
|
||||
"description": "Defined persona catalogue. EvaluationError is an execution result and must never be declared as a persona."
|
||||
},
|
||||
"rules": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/definitions/rule" }
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"rule": {
|
||||
"type": "object",
|
||||
"required": ["id", "name", "description", "enabled", "priority", "persona", "match"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"description": { "type": "string", "minLength": 1 },
|
||||
"enabled": { "type": "boolean" },
|
||||
"priority": { "type": "integer", "minimum": 0 },
|
||||
"persona": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"not": { "enum": ["Unclassified", "EvaluationError"] },
|
||||
"description": "Unclassified is a processing result, not a rule outcome (VR-002)."
|
||||
},
|
||||
"match": { "$ref": "#/definitions/conditionGroup" },
|
||||
"tags": { "type": "array", "items": { "type": "string" } },
|
||||
"owner": { "type": "string" },
|
||||
"changeReference": { "type": "string" },
|
||||
"effectiveDate": {
|
||||
"type": "string",
|
||||
"format": "date",
|
||||
"description": "Metadata only in v1. It must not gate evaluation — a date-dependent decision would break determinism (Principle I)."
|
||||
},
|
||||
"notes": { "type": "string" },
|
||||
"testCases": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name", "expectedMatch"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"expectedMatch": { "type": "boolean" },
|
||||
"user": { "type": "object" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"conditionGroup": {
|
||||
"type": "object",
|
||||
"required": ["operator", "conditions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"operator": { "type": "string", "enum": ["all", "any"] },
|
||||
"conditions": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{ "$ref": "#/definitions/conditionGroup" },
|
||||
{ "$ref": "#/definitions/condition" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"condition": {
|
||||
"type": "object",
|
||||
"required": ["type", "operator"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["property", "membership", "role"] },
|
||||
"property": { "type": "string", "minLength": 1 },
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"equals", "notEquals", "contains", "notContains",
|
||||
"startsWith", "endsWith", "matchesRegex",
|
||||
"in", "notIn", "isNull", "isNotNull",
|
||||
"memberOf", "notMemberOf"
|
||||
]
|
||||
},
|
||||
"value": { "type": "string" },
|
||||
"values": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"groupObjectIds": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "$ref": "#/definitions/guid" }
|
||||
},
|
||||
"roleIds": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"membershipMode": { "type": "string", "enum": ["direct", "transitive"] },
|
||||
"caseSensitive": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Reserved. Condition-level case sensitivity is out of scope for v1; the schema accepts the key so a later version does not require a breaking change."
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": { "properties": { "type": { "const": "property" } }, "required": ["type"] },
|
||||
"then": { "required": ["property"] }
|
||||
},
|
||||
{
|
||||
"if": { "properties": { "type": { "const": "membership" } }, "required": ["type"] },
|
||||
"then": { "required": ["groupObjectIds"] }
|
||||
},
|
||||
{
|
||||
"if": { "properties": { "type": { "const": "role" } }, "required": ["type"] },
|
||||
"then": { "required": ["roleIds"] }
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": { "operator": { "enum": ["in", "notIn"] } },
|
||||
"required": ["operator"]
|
||||
},
|
||||
"then": { "required": ["values"] }
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": { "operator": { "enum": ["isNull", "isNotNull"] } },
|
||||
"required": ["operator"]
|
||||
},
|
||||
"then": {
|
||||
"allOf": [
|
||||
{ "not": { "required": ["value"] } },
|
||||
{ "not": { "required": ["values"] } }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"guid": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
# Data Model: Persona Engine
|
||||
|
||||
**Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md)
|
||||
|
||||
Normalized in-memory contracts. These are the objects the rule engine sees. Per Principle IV the
|
||||
rule engine MUST NOT receive raw directory responses — normalization is the boundary.
|
||||
|
||||
All types are plain `PSCustomObject` shapes. Field types are PowerShell types.
|
||||
|
||||
---
|
||||
|
||||
## UserRecord
|
||||
|
||||
Produced by `ConvertTo-PersonaUserRecord`. Consumed by the rule engine, presentation, and audit.
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `AccountObjectId` | `string` (GUID) | Yes | Immutable identity key. Approved for logs. |
|
||||
| `UserPrincipalName` | `string` | Yes | Approved for logs. |
|
||||
| `DisplayName` | `string` | No | Diagnostics only. |
|
||||
| `UserType` | `string` | No | `Member` / `Guest`. |
|
||||
| `AccountEnabled` | `bool` | Yes | Disabled accounts remain in scope (FR-011). |
|
||||
| `Properties` | `hashtable` | Yes | Case-insensitive map of evaluable property name → value. Populated from FR-005 selection plus any property a rule references. Absent property returns `$null`. |
|
||||
| `StoredPersona` | `string` | No | Current value of the target attribute; `$null` when unset. |
|
||||
| `Membership` | `MembershipRecord` | Yes | Never `$null`; an unattempted lookup is represented by an empty record with `RetrievalSucceeded = $true` and `Mode = 'None'`. |
|
||||
|
||||
**Validation rules**
|
||||
|
||||
- `AccountObjectId` and `UserPrincipalName` MUST be non-empty; a record failing this is an
|
||||
upstream defect and MUST raise, not silently skip.
|
||||
- `Properties` lookups are case-insensitive (RE-006).
|
||||
- A `$null` or absent value in `Properties` is treated as empty for ordinary string comparisons and
|
||||
MUST NOT fail evaluation (FR-012).
|
||||
|
||||
---
|
||||
|
||||
## MembershipRecord
|
||||
|
||||
Produced by `ConvertTo-PersonaMembershipRecord`. This type carries the most safety-critical fields
|
||||
in the model.
|
||||
|
||||
**Revised 2026-08-20 during implementation.** The original design held a single `Mode` field
|
||||
(`Direct` / `Transitive` / `None`) alongside one `GroupObjectIds` set. That cannot satisfy RE-007,
|
||||
which makes membership mode a **per-condition** choice: a rule set may legitimately ask for
|
||||
transitive membership in one rule and direct membership in another, and a single-mode record can
|
||||
only answer one of them — every user became an `EvaluationError` on the other. The defect was
|
||||
caught by running the shipped example configuration, which mixes both modes, against the fixtures.
|
||||
The record now holds three independently-retrieved facets.
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `DirectGroupObjectIds` | `string[]` | Yes | May be empty. |
|
||||
| `DirectRetrieved` | `bool` | Yes | **`$false` means "unknown", never "not a member".** |
|
||||
| `DirectFailureReason` | `string` | No | Populated only when `DirectRetrieved` is `$false`. |
|
||||
| `TransitiveGroupObjectIds` | `string[]` | Yes | May be empty. |
|
||||
| `TransitiveRetrieved` | `bool` | Yes | Same semantics as `DirectRetrieved`. |
|
||||
| `TransitiveFailureReason` | `string` | No | |
|
||||
| `DirectoryRoleIds` | `string[]` | Yes | May be empty. |
|
||||
| `RolesRetrieved` | `bool` | Yes | Same semantics. |
|
||||
| `RolesFailureReason` | `string` | No | |
|
||||
|
||||
**Validation rules**
|
||||
|
||||
- Every `*Retrieved` flag defaults to `$false`. A condition MUST read the flag for the facet it
|
||||
actually queries, and a `$false` MUST yield `Unknown`, propagating to `EvaluationError` (FR-013).
|
||||
- Facets are independent: a failed transitive lookup MUST NOT make direct-membership conditions
|
||||
unevaluable. Collapsing them would turn one slow endpoint into a tenant-wide outage.
|
||||
- An empty identifier collection with its facet `Retrieved = $true` is a legitimate "member of
|
||||
nothing" and evaluates normally.
|
||||
- A facet MUST NOT be both retrieved and carry a failure reason; the constructor throws.
|
||||
- Mode selection is exact. A condition asking for transitive membership MUST NOT be answered from
|
||||
direct data (false negatives on nested groups) and vice versa (false positives).
|
||||
|
||||
---
|
||||
|
||||
## BusinessRule
|
||||
|
||||
Deserialized from configuration. Never constructed in source (Principle II).
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `Id` | `string` | Yes | Unique across the rule set (VR-002). |
|
||||
| `Name` | `string` | Yes | |
|
||||
| `Description` | `string` | Yes | |
|
||||
| `Enabled` | `bool` | Yes | Disabled rules are skipped and excluded from the enabled count. |
|
||||
| `Priority` | `int` | Yes | Unique; lower evaluates first (RE-002). |
|
||||
| `Persona` | `string` | Yes | MUST be a defined persona; MUST NOT be `Unclassified` or `EvaluationError` (VR-002). |
|
||||
| `Match` | `ConditionGroup` | Yes | Root condition group. |
|
||||
| `Tags` | `string[]` | No | |
|
||||
| `Owner` | `string` | No | |
|
||||
| `ChangeReference` | `string` | No | |
|
||||
| `EffectiveDate` | `string` | No | Metadata only in v1 — MUST NOT gate evaluation, as a date-dependent decision would break Principle I. |
|
||||
| `Notes` | `string` | No | |
|
||||
| `TestCases` | `object[]` | No | Consumed by the editor's synthetic testing (FR-025). |
|
||||
|
||||
---
|
||||
|
||||
## ConditionGroup / Condition
|
||||
|
||||
Recursive structure bounded by the configured depth (RE-004: default 5, min 1, ceiling 10).
|
||||
|
||||
**ConditionGroup**
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `Operator` | `string` | Yes | `all` or `any`. |
|
||||
| `Conditions` | `(Condition\|ConditionGroup)[]` | Yes | MUST be non-empty. |
|
||||
|
||||
**Condition (leaf)**
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `Type` | `string` | Yes | `property`, `membership`, or `role`. |
|
||||
| `Property` | `string` | For `property` | MUST be a supported property name (VR-002). |
|
||||
| `Operator` | `string` | Yes | One of RE-005. |
|
||||
| `Value` | `string` | Conditional | Required for comparison operators; MUST be absent for `isNull` / `isNotNull` (VR-002). |
|
||||
| `Values` | `string[]` | Conditional | Required for `in` / `notIn`. |
|
||||
| `GroupObjectIds` | `string[]` | For `membership` | MUST be non-empty (VR-002). |
|
||||
| `RoleIds` | `string[]` | For `role` | MUST be non-empty. |
|
||||
| `MembershipMode` | `string` | No | `direct` or `transitive`; defaults to the engine setting (RE-007). |
|
||||
|
||||
**Evaluation result values**: every condition evaluates to `True`, `False`, or **`Unknown`**.
|
||||
`Unknown` is what makes FR-013 expressible.
|
||||
|
||||
**Propagation rules** (these are the whole safety argument — implement exactly):
|
||||
|
||||
| Group | Contains `Unknown` | Result |
|
||||
| --- | --- | --- |
|
||||
| `all` | plus any `False` | `False` — a definite non-match wins; the unknown cannot rescue it |
|
||||
| `all` | plus only `True` | `Unknown` |
|
||||
| `any` | plus any `True` | `True` — a definite match wins |
|
||||
| `any` | plus only `False` | `Unknown` |
|
||||
|
||||
An `Unknown` at the rule root yields `EvaluationError` for that user.
|
||||
|
||||
---
|
||||
|
||||
## PersonaDecisionResult
|
||||
|
||||
Produced by `Resolve-UserPersona`. The engine's authoritative per-user output.
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `AccountObjectId` | `string` | Yes | |
|
||||
| `UserPrincipalName` | `string` | Yes | |
|
||||
| `Outcome` | `string` | Yes | `Matched`, `Unclassified`, or `EvaluationError` — exactly one (SC-001). |
|
||||
| `MatchedRuleId` | `string` | When `Matched` | `$null` otherwise. |
|
||||
| `CalculatedPersona` | `string` | Yes | The persona, `Unclassified`, or `$null` when `EvaluationError`. |
|
||||
| `StoredPersona` | `string` | No | Copied from the `UserRecord`. |
|
||||
| `Action` | `string` | Yes | `Unchanged`, `WouldUpdate`, `Updated`, `UpdateFailed`, or `Skipped`. |
|
||||
| `EvaluationErrorReason` | `string` | When `EvaluationError` | |
|
||||
| `RulesEvaluated` | `int` | Yes | Count until first match or exhaustion. |
|
||||
| `DurationMs` | `int` | Yes | Per-user timing (NFR-002). |
|
||||
| `ConditionTrace` | `object[]` | No | Populated only under `-Debug` (Principle V). |
|
||||
|
||||
**State transitions for `Action`**
|
||||
|
||||
```text
|
||||
EvaluationError ─────────────────────────────► Skipped (FR-014, no write ever)
|
||||
Calculated == Stored ────────────────────────► Unchanged
|
||||
Calculated != Stored, preview mode ──────────► WouldUpdate (FR-017, no request issued)
|
||||
Calculated != Stored, enforce, write ok ─────► Updated
|
||||
Calculated != Stored, enforce, write fails ──► UpdateFailed
|
||||
```
|
||||
|
||||
`Unclassified` follows the same comparison path as any other calculated value — it is a legitimate
|
||||
value to write if the configuration approves it, and is reported distinctly either way.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
The complete ordered decision process. Structure is normative in
|
||||
[contracts/persona-engine.schema.json](contracts/persona-engine.schema.json).
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `configVersion` | `string` | Yes | Downgrade is a safety violation (VR-003). |
|
||||
| `engine.targetAttribute` | `string` | Yes | MUST appear in `approvedWritableAttributes`. |
|
||||
| `engine.approvedWritableAttributes` | `string[]` | Yes | MUST be non-empty. |
|
||||
| `engine.maxConditionDepth` | `int` | No | Default 5, min 1, max 10 (RE-004). |
|
||||
| `engine.summaryInterval` | `int` | No | Default 25; `0` suppresses interim summaries (FR-020). |
|
||||
| `engine.defaultMembershipMode` | `string` | No | `direct` or `transitive`. |
|
||||
| `dataSources.groups.enabled` | `bool` | Yes | Enabled group rules with this `false` is a safety violation (VR-003). |
|
||||
| `dataSources.roles.enabled` | `bool` | Yes | |
|
||||
| `personas` | `string[]` | Yes | Defined persona catalogue. `EvaluationError` MUST NOT appear. |
|
||||
| `logging.*` | `object` | No | Destination and path (OTD-006). |
|
||||
| `rules` | `BusinessRule[]` | Yes | MUST contain at least one enabled rule (VR-002). |
|
||||
|
||||
**Derived at load**: `ConfigurationHash` (SHA-256 of the canonical file bytes) — recorded on every
|
||||
run record (NFR-005).
|
||||
|
||||
---
|
||||
|
||||
## ValidationFinding
|
||||
|
||||
Produced by all four validation layers (VR-004).
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `Severity` | `string` | Yes | `Error`, `Warning`, `Information`. |
|
||||
| `Code` | `string` | Yes | Stable finding code, e.g. `PE-SEM-012`. |
|
||||
| `Location` | `string` | Yes | JSON path or rule ID. |
|
||||
| `Description` | `string` | Yes | |
|
||||
| `SuggestedResolution` | `string` | Yes | |
|
||||
| `Layer` | `string` | Yes | `Syntax`, `Schema`, `Semantic`, `Safety`. |
|
||||
|
||||
`Error` blocks execution and saving; `Warning` blocks only under `-TreatWarningsAsErrors` (VR-005).
|
||||
|
||||
---
|
||||
|
||||
## RunRecord
|
||||
|
||||
One per execution. See [contracts/audit-record.md](contracts/audit-record.md) for the serialized
|
||||
form.
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `RunId` | `string` (GUID) | Yes | Supplied via `-CorrelationId` or generated. |
|
||||
| `StartedUtc` / `CompletedUtc` | `datetime` | Yes | |
|
||||
| `Mode` | `string` | Yes | `Preview` or `Enforce`. |
|
||||
| `EngineVersion` | `string` | Yes | |
|
||||
| `ConfigVersion` / `ConfigurationHash` | `string` | Yes | |
|
||||
| `Processed` / `Matched` / `Unclassified` / `EvaluationError` | `int` | Yes | Reconciliation: `Processed = Matched + Unclassified + EvaluationError` (FR-021). |
|
||||
| `Unchanged` / `WouldUpdate` / `Updated` / `UpdateFailed` | `int` | Yes | |
|
||||
| `ExitCode` | `int` | Yes | Per the CLI contract. |
|
||||
|
||||
A failed reconciliation MUST be logged as an engine defect, not merely reported.
|
||||
@@ -0,0 +1,202 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,213 @@
|
||||
# Quickstart: Persona Engine Validation
|
||||
|
||||
**Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md)
|
||||
|
||||
Runnable scenarios that prove the feature works. Ordered by the build sequence in
|
||||
[plan.md](plan.md) — each stage is validatable before the next exists. Scenarios 1–3 require no
|
||||
tenant, no credentials, and no network.
|
||||
|
||||
Structural details live in [data-model.md](data-model.md) and [contracts/](contracts/); this guide
|
||||
does not repeat them.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Current constraint**: no Azure Automation account. Everything runs locally on PowerShell 7 with
|
||||
user accounts and delegated authentication. Scenario 5 is deferred; Scenarios 1–4 and 6 are all
|
||||
available now.
|
||||
|
||||
| Scenario | Stage | Requirement |
|
||||
| --- | --- | --- |
|
||||
| 1–3 (offline) | A1 | PowerShell 7.4, Pester 5.x, PSScriptAnalyzer. No tenant, no network. |
|
||||
| 4 (read-only) | A2 | An app registration with admin consent for the three delegated scopes, and a **non-privileged** user account to sign in with. Closes V-1 (read), V-3. |
|
||||
| 5 (automation) | B | **Deferred** — no Automation account available. Closes V-3b, V-5b when it lands. |
|
||||
| 6 (enforcement) | A3 | Delegated `User.ReadWrite.All`, **written security sign-off (V-4)**, reviewed `-WhatIf` evidence, and **purpose-created test accounts** as the write targets. |
|
||||
|
||||
### Local connection
|
||||
|
||||
```powershell
|
||||
Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'
|
||||
```
|
||||
|
||||
Sign in as an ordinary user account, not a Global Administrator — see the caution in Scenario 4.
|
||||
|
||||
---
|
||||
|
||||
## Scenario 1 — Rule engine determinism, offline
|
||||
|
||||
Proves SC-001, SC-003, SC-008 and the `Unknown` propagation table in
|
||||
[data-model.md](data-model.md).
|
||||
|
||||
```bash
|
||||
pwsh -NoProfile -Command "Invoke-Pester ./tests/RuleEngine -Output Detailed"
|
||||
```
|
||||
|
||||
**Expected**: all pass with no network access. Specifically:
|
||||
|
||||
- Every synthetic user yields exactly one outcome.
|
||||
- Shuffling fixture order changes nothing.
|
||||
- A rule matching at priority 10 wins over one matching at 20, and evaluation stops.
|
||||
- A `MembershipRecord` with `RetrievalSucceeded = $false` yields `EvaluationError`, never a
|
||||
non-match.
|
||||
- Depth beyond `maxConditionDepth` is rejected rather than silently truncated.
|
||||
|
||||
**Disconnect the network and re-run.** Identical results, or SC-008 is not met.
|
||||
|
||||
---
|
||||
|
||||
## Scenario 2 — Configuration validation
|
||||
|
||||
Proves SC-009, SC-010 and the four-layer ordering.
|
||||
|
||||
```bash
|
||||
pwsh -NoProfile -File ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive
|
||||
```
|
||||
|
||||
**Expected**: exit code `0`, no findings.
|
||||
|
||||
Then run the invalid-configuration corpus in `tests/TestData/InvalidConfigs/` — one file per VR-002
|
||||
and VR-003 condition:
|
||||
|
||||
```bash
|
||||
pwsh -NoProfile -Command "Invoke-Pester ./tests/Configuration -Output Detailed"
|
||||
```
|
||||
|
||||
**Expected**: each file produces its documented finding code, severity, and location; blocking
|
||||
findings return a non-zero exit code with **no prompt and no hang** (SC-010).
|
||||
|
||||
---
|
||||
|
||||
## Scenario 3 — Safety invariants
|
||||
|
||||
Proves SC-004 and SC-005 with the write adapter mocked. This suite is the reason the persistence
|
||||
adapter is built last.
|
||||
|
||||
```bash
|
||||
pwsh -NoProfile -Command "Invoke-Pester ./tests/Safety -Output Detailed"
|
||||
```
|
||||
|
||||
**Expected**:
|
||||
|
||||
- Full synthetic population under `-WhatIf`: write adapter call count is exactly `0` — asserted, not
|
||||
inspected (SC-004).
|
||||
- Every captured request body has exactly one key, equal to `engine.targetAttribute` (SC-005).
|
||||
- `New-PersonaWriteBody` throws for any other attribute name and for an attribute absent from
|
||||
`approvedWritableAttributes`.
|
||||
- A `-Debug` run **without** `-WhatIf` still reaches the write path — `-Debug` is not a safety
|
||||
control.
|
||||
|
||||
---
|
||||
|
||||
## Scenario 4 — Read-only tenant preview (User Story 1)
|
||||
|
||||
The first connected run. Uses a read-only identity, so it is safe by construction rather than by
|
||||
correct behaviour.
|
||||
|
||||
> **Sign in as a non-privileged account.** V-3 asks whether the three scopes are *sufficient*.
|
||||
> A Global Administrator answers yes regardless — the scope narrows the token, but the account's
|
||||
> directory roles still grant broad read access, so the run succeeds whether or not the permission
|
||||
> set is correct. Running this as GA produces a green result that means nothing.
|
||||
|
||||
```bash
|
||||
pwsh -NoProfile -File ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf -Verbose
|
||||
```
|
||||
|
||||
**Expected**:
|
||||
|
||||
- A result line appears for every in-scope user, visible before the next user is processed (SC-012).
|
||||
- Differences report as `WouldUpdate` with stored value, calculated value, and matched rule ID.
|
||||
- Interim summaries at the configured interval; a final summary always; reconciliation passes at
|
||||
every summary (SC-007).
|
||||
- Zero write requests — confirm independently in the Entra sign-in and audit logs, not only from
|
||||
console output.
|
||||
|
||||
Single-user check first, before the full population:
|
||||
|
||||
```bash
|
||||
pwsh -NoProfile -File ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf
|
||||
```
|
||||
|
||||
**Also close V-1 here**: run the single-user check against a cloud-only user, a currently-synced
|
||||
user, and a formerly-synced user. Confirm the persona extension **reads** on all three. The write
|
||||
half of V-1 is closed in Scenario 6.
|
||||
|
||||
**Idempotence check** (SC-002): run twice unchanged. The second run reports the same counts and zero
|
||||
additional proposed changes.
|
||||
|
||||
---
|
||||
|
||||
## Scenario 5 — Azure Automation PowerShell 7 *(deferred — Stage B)*
|
||||
|
||||
**Not runnable in the current stage.** No Automation account is available. Recorded here so it is
|
||||
not lost, and so the Stage B entry cost stays visible.
|
||||
|
||||
Proves NFR-001, NFR-008 and closes V-3b and V-5b.
|
||||
|
||||
1. Import the module and publish the runbook with the schedule **disabled**.
|
||||
2. Run the runbook with `-WhatIf` using the managed identity.
|
||||
3. Record the runtime's exact PowerShell version.
|
||||
4. Confirm `Test-Json -SchemaFile` behaves as observed locally in V-5a — its error-reporting
|
||||
behaviour varies by version, and the layer-2 wrapper depends on it (OTD-005). **Run this first**;
|
||||
it is the cheapest item with the highest chance of surprising you.
|
||||
5. Confirm the three read scopes work as *application* permissions on the managed identity (V-3b).
|
||||
|
||||
**Expected**: the run completes with only `Microsoft.Graph.Authentication` imported, and output
|
||||
matches the equivalent local `-WhatIf` run.
|
||||
|
||||
**Until this scenario passes, v1 is not complete.** The Definition of Done requires an Automation
|
||||
PowerShell 7 run. Stage A completion is a real milestone and worth claiming — but it is not v1.
|
||||
|
||||
---
|
||||
|
||||
## Scenario 6 — Enforcement (User Story 8)
|
||||
|
||||
**Gated.** Do not run until all of these hold:
|
||||
|
||||
- [ ] V-4 security sign-off on the OTD-003 compensating controls, in writing
|
||||
- [ ] `-WhatIf` impact evidence from Scenario 4 reviewed and approved
|
||||
- [ ] Scenarios 1–5 passing
|
||||
- [ ] Kill switch and rollback procedure documented
|
||||
- [ ] OTD-001 – OTD-005 closed
|
||||
|
||||
```bash
|
||||
pwsh -NoProfile -File ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json
|
||||
```
|
||||
|
||||
**Expected**:
|
||||
|
||||
- Only changed values are written; unchanged users produce no request (SC-002).
|
||||
- Every write body contains exactly one attribute (SC-005).
|
||||
- Every `Updated` audit record carries `previousValue` — without it, rollback is impossible
|
||||
retroactively (OTD-010).
|
||||
- `EvaluationError` users are skipped with their stored persona intact (FR-014).
|
||||
|
||||
**Closes V-1 (write half)** and **V-2**: confirm the dynamic membership group built on the persona
|
||||
extension populates, and that a Conditional Access policy assigned to that group applies.
|
||||
|
||||
---
|
||||
|
||||
## Verification item coverage
|
||||
|
||||
| Item | Closed by | Available now? |
|
||||
| --- | --- | --- |
|
||||
| V-1 | Scenario 4 (read) + Scenario 6 (write) | Yes |
|
||||
| V-2 | Scenario 6 | Yes |
|
||||
| V-3 | Scenario 4, as a non-privileged account | Yes |
|
||||
| V-3b | Scenario 5 | **No — Stage B** |
|
||||
| V-4 | Out-of-band security review — **gate on Scenario 6** | Yes (a conversation, not a tenant) |
|
||||
| V-4a | Investigation; no scenario | Yes |
|
||||
| V-5a | Scenario 2, behaviour pinned in a unit test | Yes |
|
||||
| V-5b | Scenario 5 | **No — Stage B** |
|
||||
|
||||
## Exit code check (SC-011)
|
||||
|
||||
Every documented exit code must be reachable. Cover them deliberately rather than incidentally:
|
||||
|
||||
| Code | How to trigger |
|
||||
| --- | --- |
|
||||
| `0` | Scenario 4 |
|
||||
| `1` | Any invalid configuration from Scenario 2 |
|
||||
| `2` | Run with an unauthorized or expired identity |
|
||||
| `3` | Fault injection on enumeration |
|
||||
| `4` | Fault injection on a required data provider |
|
||||
| `5` | Fault injection on the counter path (reconciliation defect) |
|
||||
| `6` | Fault injection on an unhandled engine path |
|
||||
@@ -0,0 +1,322 @@
|
||||
# Phase 0 Research: Persona Engine
|
||||
|
||||
**Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md)
|
||||
|
||||
Resolves the Clarification Register in [spec.md](spec.md). OTD-001 through OTD-005 are
|
||||
**persistence-blocking** and are decided here. OTD-006 through OTD-010 receive provisional decisions
|
||||
sufficient to plan implementation.
|
||||
|
||||
Every decision below that depends on a tenant-specific fact carries a **verification item (V-n)**.
|
||||
Per the constitution, attribute-level write authorization "MUST be verified, never assumed" — the
|
||||
decisions state what the product documentation says, and the V-items state what the team must prove
|
||||
in its own tenant before enforcement is enabled.
|
||||
|
||||
---
|
||||
|
||||
## OTD-001 — Persona attribute mechanism
|
||||
|
||||
**Decision**: Store the persona in a **directory (schema) extension** single-valued string property
|
||||
on the `user` resource, registered against a dedicated application registration in the tenant. The
|
||||
property is referenced as `extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>`.
|
||||
|
||||
**Rationale**:
|
||||
|
||||
- **Writable via Graph for cloud-mastered users** with an ordinary `PATCH /users/{id}`, and readable
|
||||
via `$select` on the extension property name.
|
||||
- **Consumable by Conditional Access.** CA assigns policy by user and group, not by user attribute,
|
||||
so the consumption path is: persona attribute → dynamic membership group rule → CA assignment.
|
||||
Dynamic membership rules support custom extension properties in the form
|
||||
`user.extension_<appId>_<propertyName>`, provided the property is **single-valued** and the
|
||||
extension belongs to an application in the tenant. Both conditions hold here.
|
||||
- **Not population-dependent.** Unlike `extensionAttributeN`, it does not fail on accounts with an
|
||||
external origin (see rejected alternative A).
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
| Alternative | Verdict | Reason |
|
||||
| --- | --- | --- |
|
||||
| **A. `onPremisesExtensionAttributes.extensionAttributeN`** (extensionAttribute1–15) | **Rejected** | Updates via Graph succeed only for objects that have always been mastered in Entra. Accounts that were ever synced from on-premises AD — or that originated in Exchange Online — fail with *"Unable to update the specified properties for objects that have originated within an external service."* In a hybrid or formerly-hybrid tenant this produces write failures determined by an account's history rather than by its rule match, which is a direct hazard to Principle I (deterministic) and Principle III (fail-safe). Remediation would require an Exchange Online PowerShell write path — a second persistence mechanism and a second permission surface. |
|
||||
| **B. Directory (schema) extension** | **Selected** | See rationale above. |
|
||||
| **C. Custom security attribute** | **Rejected as primary; retained as the security-first alternative** | This is the *only* mechanism offering genuine attribute-scoped authorization (see OTD-003), which makes it attractive. But custom security attributes are **not exposed to the dynamic group evaluation engine** and cannot be referenced in dynamic membership rules, so they cannot drive the CA consumption path that motivates the persona value. They are also not returned by default and require a separate permission and role. Choosing C trades the feature's primary downstream use for a stronger write boundary. |
|
||||
|
||||
**Consequences**: the compensating controls in OTD-003 become mandatory, because alternative B has
|
||||
no attribute-scoped authorization.
|
||||
|
||||
**Verification items**:
|
||||
|
||||
- **V-1** — Register the extension application and property in a non-production tenant or an
|
||||
isolated attribute name; confirm read via `$select` and write via `PATCH` for: a cloud-only user, a
|
||||
currently-synced user, and a formerly-synced user. Directory extensions are not on-premises-mastered
|
||||
properties, so all three are expected to succeed — but this must be proven, not assumed, because the
|
||||
whole reason A was rejected is an origin-dependent write restriction.
|
||||
- **V-2** — Confirm a dynamic membership group rule referencing the extension property evaluates and
|
||||
populates as expected, and that a CA policy assigned to that group applies.
|
||||
|
||||
---
|
||||
|
||||
## OTD-002 — Least-privilege Microsoft Graph permissions
|
||||
|
||||
**Decision**: application (managed identity) permissions, granted only as each capability is enabled:
|
||||
|
||||
| Capability | Permission | Notes |
|
||||
| --- | --- | --- |
|
||||
| Read users and the persona extension | `User.Read.All` | Extension property returned via `$select`. |
|
||||
| Read group membership (`memberOf` conditions) | `GroupMember.Read.All` | Sufficient for `/users/{id}/memberOf` and `/transitiveMemberOf`. Narrower than `Group.Read.All`. |
|
||||
| Read directory role assignments | `RoleManagement.Read.Directory` | For role-based conditions. |
|
||||
| Write the persona attribute (enforcement only) | `User.ReadWrite.All` | **Supersedes** `User.Read.All`; grant only to the enforcement identity, and only after security approval of the OTD-003 controls. |
|
||||
|
||||
Application permissions are the Stage B (Automation, managed identity) form. Stage A uses the same
|
||||
four as **delegated** scopes on an interactive connection.
|
||||
|
||||
**Rationale**: each permission maps to exactly one enabled capability, so a tenant that disables
|
||||
group or role conditions grants strictly less. `Directory.Read.All` is deliberately **rejected** — it
|
||||
is materially broader than the three read permissions combined and would grant visibility well
|
||||
outside the enumerated data sources.
|
||||
|
||||
**Local (Stage A) equivalent — delegated scopes.** With no Automation account, the same three
|
||||
capabilities are requested as delegated scopes on an interactive connection:
|
||||
|
||||
```powershell
|
||||
Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'
|
||||
```
|
||||
|
||||
These delegated scopes require one-time admin consent for the app registration used locally; after
|
||||
that, an ordinary user account can hold them. **Effective access is the intersection of the granted
|
||||
scope and the signed-in user's directory roles** — which is precisely why V-3 must be run as a
|
||||
non-privileged account (see below).
|
||||
|
||||
**Alternatives considered**: `Directory.ReadWrite.All` (rejected — grossly over-broad);
|
||||
`User.ManageIdentities.All` (not applicable); delegated-only operation (rejected — unattended
|
||||
Automation requires application permissions; delegated remains the local development path per FR-003).
|
||||
|
||||
**Verification items**:
|
||||
|
||||
- **V-3** — During the read-only pilot, grant only the three read permissions and confirm every
|
||||
enabled rule evaluates without a permission error. Any `EvaluationError` attributable to
|
||||
authorization identifies a missing-but-required permission and must be resolved before enforcement.
|
||||
**Stage A method**: connect with the three delegated scopes above while signed in as an **ordinary,
|
||||
non-privileged user account**. Running this as a Global Administrator invalidates the test — the
|
||||
scope narrows the token, but the account's directory roles still grant broad read access, so the
|
||||
run would succeed regardless of whether the three permissions are actually sufficient. This item is
|
||||
closeable now and does not need Automation.
|
||||
- **V-3b** *(Stage B, deferred)* — Repeat as **application** permissions on the managed identity.
|
||||
Delegated and application authorization are evaluated differently, so a passing V-3 is strong
|
||||
evidence but not proof for the unattended path.
|
||||
|
||||
---
|
||||
|
||||
## OTD-003 — Can write authorization be restricted to the single target attribute?
|
||||
|
||||
**Decision**: **No — not for the mechanism selected in OTD-001.** Microsoft Graph application
|
||||
permissions have no per-property scope: `User.ReadWrite.All` authorizes writes to every writable
|
||||
property of every user in the tenant. There is no supported way to grant "write only
|
||||
`extension_<app>_<persona>`".
|
||||
|
||||
Therefore the compensating controls are **mandatory and testable**, not advisory:
|
||||
|
||||
1. `Set-UserPersonaAttribute` accepts only the configured target attribute; any other name is a
|
||||
terminating error.
|
||||
2. The target attribute MUST appear in `approvedWritableAttributes`; validation rejects all others
|
||||
(VR-002).
|
||||
3. A single dedicated function, `New-PersonaWriteBody`, constructs the request body, and it emits a
|
||||
hashtable containing exactly one key.
|
||||
4. Unit and integration tests assert on the **request body**, not on observed behaviour (SC-005).
|
||||
5. Code owners gate every change to persistence, `approvedWritableAttributes`, and the target
|
||||
attribute.
|
||||
6. Directory audit logs are monitored for property writes by the engine's service principal other
|
||||
than the target attribute.
|
||||
|
||||
**The one mechanism that *would* satisfy attribute-level authorization**: custom security attributes
|
||||
(OTD-001 alternative C). Their assignment is governed by attribute sets: a principal is granted
|
||||
`Attribute Assignment Administrator` **scoped to a specific attribute set**, plus the separate
|
||||
`CustomSecAttributeAssignment.ReadWrite.All` permission — and notably, Global Administrator does not
|
||||
hold this access by default. That is a real, enforced boundary rather than a compensating control.
|
||||
It was rejected only because custom security attributes cannot feed dynamic groups (OTD-001).
|
||||
|
||||
**Stage A makes these controls *more* important, not less.** Local delegated writes run as the
|
||||
signed-in operator, whose directory roles are typically far broader than the eventual service
|
||||
principal's. During Stage A3 the compensating controls are the **only** thing standing between the
|
||||
engine and an unintended property write, because the authorization boundary is effectively "whatever
|
||||
the operator can do." Two additional Stage A rules follow:
|
||||
|
||||
- Stage A3 writes target **purpose-created test accounts only**. The baseline's read-only-during-
|
||||
early-development assumption continues to hold for the general population.
|
||||
- Never sign in with a standing privileged account for a write run. Elevate for the session, and
|
||||
expect the directory audit log to attribute the write to the operator rather than to a service
|
||||
principal — which is exactly why Stage A3 evidence does not substitute for Stage B evidence.
|
||||
|
||||
**This trade-off requires explicit security sign-off.** The decision record for security review is:
|
||||
*accept tenant-wide user-write permission plus six compensating controls, in exchange for a persona
|
||||
value that Conditional Access can actually consume.*
|
||||
|
||||
**Verification items**:
|
||||
|
||||
- **V-4** — Confirm with the security owner, in writing, that the compensating-control set is
|
||||
accepted in place of attribute-scoped authorization. This is a **gate before enforcement**, per the
|
||||
constitution's Definition of Done.
|
||||
- **V-4a** — Investigate whether an Administrative Unit-scoped role assignment can narrow the
|
||||
enforcement identity's write scope to a subset of the user population. This narrows *which users*,
|
||||
never *which attribute*, so it is a partial mitigation at best; do not present it as closing OTD-003.
|
||||
|
||||
---
|
||||
|
||||
## OTD-004 — Directory access approach
|
||||
|
||||
**Decision**: **Direct REST via `Invoke-MgGraphRequest`**, with `Microsoft.Graph.Authentication` as
|
||||
the only runtime module. No resource-specific SDK modules (`Microsoft.Graph.Users`,
|
||||
`Microsoft.Graph.Groups`, etc.).
|
||||
|
||||
**Rationale**:
|
||||
|
||||
- **Explicit request bodies.** The constitution requires the write payload to contain exactly one
|
||||
attribute and requires tests to inspect that body. `Invoke-MgGraphRequest -Method PATCH -Body` makes
|
||||
the body a first-class, assertable value. SDK cmdlets construct bodies internally from parameter
|
||||
binding, which makes SC-005 far harder to prove.
|
||||
- **Dynamic extension properties.** The persona property name is configuration-driven and unknown at
|
||||
authoring time. Passing an arbitrary `extension_<appId>_<name>` key is natural in a hashtable body
|
||||
and awkward through typed cmdlet parameters.
|
||||
- **Automation footprint.** One small module to import instead of the SDK's large module set, which
|
||||
reduces cold-start time, import failures, and version drift in the Automation PS7 environment
|
||||
(NFR-008).
|
||||
- Managed-identity and interactive token acquisition are still handled by `Connect-MgGraph`, so
|
||||
nothing is reimplemented.
|
||||
|
||||
**Alternatives considered**: full Graph SDK cmdlets (rejected — heavy, opaque bodies, version drift);
|
||||
raw `Invoke-RestMethod` with hand-rolled token acquisition (rejected — reimplements managed-identity
|
||||
token handling and refresh for no benefit).
|
||||
|
||||
**Consequence**: pagination (`@odata.nextLink`), throttling, and error shaping are the engine's
|
||||
responsibility. They are handled once, in the data-provider layer — see OTD-007 and
|
||||
[contracts/graph-data-provider.md](contracts/graph-data-provider.md).
|
||||
|
||||
---
|
||||
|
||||
## OTD-005 — JSON Schema validation approach
|
||||
|
||||
**Decision**: the built-in **`Test-Json -SchemaFile`** cmdlet, with the schema authored to
|
||||
**JSON Schema draft-07**.
|
||||
|
||||
**Rationale**: `Test-Json` ships with PowerShell 6.1+ and therefore needs no module import in either
|
||||
the local or Automation PS7 environment — the strongest possible answer to NFR-008 and OTD-005's
|
||||
"compatible locally and in automation" requirement. Its underlying validator is the Newtonsoft JSON
|
||||
Schema implementation, whose reliable coverage is draft-04/06/07; **draft 2019-09 and 2020-12
|
||||
constructs must not be used** in the schema.
|
||||
|
||||
**Implementation notes**:
|
||||
|
||||
- `Test-Json` signals failure by writing errors rather than simply returning `$false` in several
|
||||
PowerShell versions. `Test-PersonaConfiguration` MUST wrap it with
|
||||
`-ErrorAction SilentlyContinue -ErrorVariable` and translate the collected errors into
|
||||
`Validation Finding` objects (VR-004), so that layer 2 produces structured findings like every other
|
||||
layer.
|
||||
- Schema validation is layer 2 of four. It cannot express the semantic rules in VR-002 (duplicate
|
||||
priorities, depth limits, cross-field constraints), which is why layers 3 and 4 exist as PowerShell
|
||||
checks. Do not attempt to push semantic rules into the schema.
|
||||
|
||||
**Alternatives considered**: bundling a third-party schema library (rejected — an extra Automation
|
||||
dependency for capability the platform already provides); hand-written structural validation only
|
||||
(rejected — VR-001 mandates a schema layer, and a schema is also the editor's contract).
|
||||
|
||||
**Verification items**:
|
||||
|
||||
- **V-5a** *(Stage A, closeable now)* — Execute `Test-Json -SchemaFile` against the draft-07 schema
|
||||
on the local PowerShell 7.4 workstation. Record the exact behaviour on failure: whether it returns
|
||||
`$false`, writes a non-terminating error, or throws. The layer-2 wrapper is built against **this
|
||||
observed behaviour**, and the observation is pinned in a unit test so a runtime change is caught
|
||||
rather than discovered.
|
||||
- **V-5b** *(Stage B, deferred)* — Repeat inside the Azure Automation PowerShell 7 runtime and record
|
||||
its exact PowerShell version. If the behaviour differs from V-5a, the wrapper handles both — do not
|
||||
assume parity. This is the single highest-value item to run on day one of Stage B.
|
||||
|
||||
---
|
||||
|
||||
## Non-blocking decisions (OTD-006 – OTD-010)
|
||||
|
||||
These do not block persistence. They are decided far enough to implement v1 without rework.
|
||||
|
||||
### OTD-006 — Structured log destination and transport
|
||||
|
||||
**Decision**: newline-delimited JSON (one audit record per line) written to a configurable file path,
|
||||
plus the Automation output stream. Emission goes through a single `Write-PersonaAuditRecord` sink
|
||||
function so a Log Analytics or Event Hub transport can be added later without touching call sites.
|
||||
Log Analytics ingestion is **deferred**, not designed out.
|
||||
|
||||
**Rationale**: NDJSON is append-safe, streamable, trivially ingestible later, and needs no
|
||||
dependency. The sink indirection is what keeps the deferral cheap.
|
||||
|
||||
### OTD-007 — Retry policy
|
||||
|
||||
**Decision**: bounded exponential backoff with full jitter in the data-provider layer.
|
||||
|
||||
- **Retryable**: HTTP 429, 500, 502, 503, 504, and transport-level timeouts.
|
||||
- **Never retried**: 400, 401, 403, 404, 409 — these are configuration, authorization, or logic
|
||||
defects and retrying masks them.
|
||||
- **`Retry-After` honoured** whenever present; it overrides the computed backoff.
|
||||
- **Max 5 attempts**, base delay 1s, exponential with full jitter, per-delay cap 60s.
|
||||
- Every retry logs attempt number, status code, and delay. Exhausting retries on **required** data
|
||||
yields `EvaluationError` for the affected user (FR-013) — never a silent non-match.
|
||||
|
||||
**Rationale**: satisfies NFR-003 while keeping Principle III intact: the failure mode of exhausted
|
||||
retries is preserve-and-report, not assume-false.
|
||||
|
||||
### OTD-008 — Full versus incremental processing
|
||||
|
||||
**Decision**: v1 performs **full enumeration** with pagination. Delta query is deferred and out of
|
||||
scope for v1 (already recorded in spec Out of Scope). Revisit only when pilot timings justify it.
|
||||
|
||||
### OTD-009 — Schedule and concurrency lock
|
||||
|
||||
**Decision**: hourly is the candidate cadence, deployment-configurable, and the schedule ships
|
||||
**disabled** (per the release pipeline). Concurrency control for v1 is the Automation account's own
|
||||
job behaviour plus a run-start check that fails fast if another job for the same runbook is running.
|
||||
A durable distributed lock is deferred.
|
||||
|
||||
**Stage A status**: not applicable. With no Automation account there is no schedule and no
|
||||
concurrency surface — runs are manual and serial by construction. Nothing here needs building until
|
||||
Stage B, and nothing in Stages A1–A3 depends on it.
|
||||
|
||||
**Rationale**: overlapping runs are idempotent by construction (Principle III) — the harm is wasted
|
||||
Graph quota and confusing audit output, not incorrect data — so a lightweight check is proportionate
|
||||
for v1.
|
||||
|
||||
### OTD-010 — Rollback
|
||||
|
||||
**Decision**: rollback is driven by the audit trail. Every write record carries the **previous
|
||||
value**, the calculated value, the matched rule ID, and the run ID (NFR-005), which makes a run
|
||||
reversible by replaying prior values from its audit output. A replay utility is deferred to v1.1; the
|
||||
**record shape that makes it possible is v1** and is fixed in
|
||||
[contracts/audit-record.md](contracts/audit-record.md).
|
||||
|
||||
**Rationale**: the cheap, decisive part is capturing the previous value at write time. Miss that in
|
||||
v1 and rollback becomes impossible retroactively.
|
||||
|
||||
---
|
||||
|
||||
## Verification checklist
|
||||
|
||||
| ID | Item | Stage | Blocks |
|
||||
| --- | --- | --- | --- |
|
||||
| V-1 | Extension property read/write proven for cloud-only, synced, and formerly-synced users | A2 (read) / A3 (write) | Persistence implementation |
|
||||
| V-2 | Dynamic group rule on the extension property populates; CA assignment applies | A3 | Downstream value of the feature |
|
||||
| V-3 | Delegated pilot completes with only the three scopes, signed in as a **non-privileged** account | A2 | Enforcement |
|
||||
| V-3b | Same, as application permissions on the managed identity | **B — deferred** | Unattended enforcement |
|
||||
| V-4 | Written security sign-off on compensating controls in place of attribute-scoped write | Out-of-band | **Enforcement (constitution gate)** |
|
||||
| V-4a | Administrative Unit scoping investigated as partial mitigation | Any | Nothing (informational) |
|
||||
| V-5a | `Test-Json -SchemaFile` failure behaviour observed and pinned locally | A1 | Layer-2 wrapper implementation |
|
||||
| V-5b | Same, confirmed in the Automation runtime, with PS version recorded | **B — deferred** | Configuration validation sign-off |
|
||||
|
||||
**Closeable in the current stage**: V-1 (read half), V-3, V-4a, V-5a — and V-4, which needs a
|
||||
conversation rather than a tenant. **Deferred with Automation**: V-3b, V-5b.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Manage rules for dynamic membership groups in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity/users/groups-dynamic-membership)
|
||||
- [Creating dynamic groups using custom security attributes](https://learn.microsoft.com/en-us/answers/questions/5763638/creating-dynamic-groups-using-custom-security-attr)
|
||||
- [Conditional Access: Users, Groups, Agents, and Workload Identities](https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-users-groups)
|
||||
- [onPremisesExtensionAttributes resource type](https://learn.microsoft.com/en-us/graph/api/resources/onpremisesextensionattributes?view=graph-rest-1.0)
|
||||
- [Update user — Microsoft Graph v1.0](https://learn.microsoft.com/en-us/graph/api/user-update?view=graph-rest-1.0)
|
||||
- [Why is it not possible to update extension attributes of former hybrid users via Graph API?](https://learn.microsoft.com/en-us/answers/questions/1850101/why-is-it-not-possible-to-update-extension-attribu)
|
||||
- [Add custom data to resources using extensions](https://learn.microsoft.com/en-us/graph/extensibility-overview)
|
||||
- [What are custom security attributes in Microsoft Entra ID?](https://learn.microsoft.com/en-us/entra/fundamentals/custom-security-attributes-overview)
|
||||
- [Manage access to custom security attributes in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/fundamentals/custom-security-attributes-manage)
|
||||
- [Assign, update, list, or remove custom security attributes for a user](https://learn.microsoft.com/en-us/entra/identity/users/users-custom-security-attributes)
|
||||
@@ -84,7 +84,7 @@ An operator watching a run sees each user's result appear immediately, receives
|
||||
|
||||
### User Story 5 - Validate and edit configuration safely (Priority: P2)
|
||||
|
||||
A configuration owner validates a configuration file, edits rules interactively, tests rules against synthetic sample users, compares against another configuration, and saves only after validation passes — with a backup taken first.
|
||||
A configuration owner validates a configuration file, adds, edits, and deletes rules interactively — including each rule's condition tree — tests rules against synthetic sample users, compares against another configuration, and saves only after validation passes — with a backup taken first.
|
||||
|
||||
**Why this priority**: Makes the configuration-driven model usable and safe in practice, but the engine can be exercised with a hand-authored file first.
|
||||
|
||||
@@ -96,6 +96,13 @@ A configuration owner validates a configuration file, edits rules interactively,
|
||||
2. **Given** an edit session with unsaved valid changes, **When** the file is saved over an existing configuration, **Then** a timestamped backup or save-as output is produced first.
|
||||
3. **Given** synthetic sample users are supplied, **When** rules are tested, **Then** the resulting persona for each sample is reported without any tenant connection.
|
||||
4. **Given** a valid configuration, **When** validation runs, **Then** no Error findings are produced and the file is accepted.
|
||||
5. **Given** an interactive edit session, **When** the configuration owner adds a new rule, **Then** they are prompted for every required field (`id`, `name`, `description`, `priority`, `persona`, `enabled`) and for the rule's condition tree — including nested `all`/`any` groups and, for each leaf condition, the property or membership source, operator, and comparison value — and the new rule is appended to the in-memory document without being written to disk until save.
|
||||
6. **Given** an interactive edit session, **When** the configuration owner attempts to add a rule using an `id` or `priority` already present in the document, **Then** the attempt is rejected before the rule is added, with a message naming the conflicting rule.
|
||||
7. **Given** an interactive edit session, **When** the configuration owner selects an existing rule to edit, **Then** they can change any of its top-level fields and its condition tree — adding, editing, removing, or renesting conditions and `all`/`any` groups within the configured depth limit — and the change is held in memory, unsaved, until the session validates and saves.
|
||||
8. **Given** an interactive edit session, **When** the configuration owner deletes a rule, **Then** they are shown the rule's `id`, `name`, and `priority` and asked to confirm before it is removed from the in-memory document.
|
||||
9. **Given** a rule add, edit, or delete has been made in an edit session, **When** the session re-validates or saves, **Then** the same four validation layers (VR-001) run over the modified document exactly as they would over a hand-edited file, and any resulting Error finding blocks the save.
|
||||
10. **Given** an edit session with unsaved add, edit, or delete changes, **When** the operator quits without saving, **Then** they are warned that unsaved changes will be lost and the file on disk is unchanged.
|
||||
11. **Given** a rule add, edit, or delete would exceed the configured maximum condition nesting depth, **When** the change is applied, **Then** it is rejected at edit time with the same finding the runtime validator would produce, rather than deferred to the next save.
|
||||
|
||||
---
|
||||
|
||||
@@ -219,6 +226,10 @@ Requirement identifiers are carried forward unchanged from the Phase 0 baseline
|
||||
- **FR-024** — Non-interactive validation: the configuration tool MUST support non-interactive validation and return a non-zero exit code on failure.
|
||||
- **FR-025** — Configuration test data: the editor MUST support testing rules against synthetic sample users without tenant connectivity.
|
||||
- **FR-026** — Backup before save: interactive edits MUST be validated before save and SHOULD create a timestamped backup or save-as output before replacing an existing configuration.
|
||||
- **FR-027** — Add a rule: the interactive editor MUST support adding a new rule, prompting for every field in RE-001 and for the rule's condition tree (nested `all`/`any` groups and leaf conditions), and MUST reject an `id` or `priority` that collides with an existing rule before the rule is added.
|
||||
- **FR-028** — Edit a rule: the interactive editor MUST support editing any field of an existing rule, including full editing of its condition tree — adding, changing, removing, and renesting conditions and groups within the configured depth limit (RE-004).
|
||||
- **FR-029** — Delete a rule: the interactive editor MUST support deleting an existing rule, and MUST require the operator to confirm against the rule's `id`, `name`, and `priority` before removal.
|
||||
- **FR-030** — Uniform validation for structural edits: a rule added, edited, or deleted in the interactive editor MUST be subject to the same four validation layers (VR-001) as a hand-edited file, at re-validation and at save; edits are held only in memory until save succeeds (FR-026).
|
||||
|
||||
### Rule Engine Requirements
|
||||
|
||||
@@ -310,20 +321,22 @@ Candidate business classifications — **not** hard-coded engine behaviour: `Gue
|
||||
|
||||
Open items carried from the baseline (§24). These are implementation research items to be resolved in `plan.md` / `research.md` or an ADR — **not** unanswered business requirements. OTD-001 through OTD-005 must be closed before persistence implementation.
|
||||
|
||||
**Updated 2026-08-20 (T114): OTD-001 through OTD-007 and OTD-010 are resolved in [research.md](research.md). The persistence gate is lifted for Stage A3 test accounts, and remains closed for the general population until V-4 security sign-off is recorded.**
|
||||
|
||||
| ID | Item | Status |
|
||||
| --- | --- | --- |
|
||||
| OTD-001 | Exact persona attribute mechanism — data type, read/update method, discoverability, Conditional Access compatibility | [NEEDS CLARIFICATION] Blocks persistence |
|
||||
| OTD-002 | Exact least-privilege directory permissions for users, groups, roles, and the selected attribute | [NEEDS CLARIFICATION] Blocks persistence |
|
||||
| OTD-003 | Whether write authorization can be restricted to the individual target attribute; if not, compensating controls plus security approval | [NEEDS CLARIFICATION] Blocks persistence |
|
||||
| OTD-004 | Directory access approach — SDK cmdlets, direct REST, or a controlled combination | [NEEDS CLARIFICATION] Blocks persistence |
|
||||
| OTD-005 | JSON Schema validation approach compatible with PowerShell 7 locally and in automation | [NEEDS CLARIFICATION] Blocks persistence |
|
||||
| OTD-006 | Structured-log destination and transport | Open |
|
||||
| OTD-007 | Retry policy — retryable status codes, max attempts, backoff, jitter, logging | Open |
|
||||
| OTD-008 | Full versus incremental processing roadmap | Open |
|
||||
| OTD-009 | Production schedule and concurrency lock to prevent overlapping runs | Open |
|
||||
| OTD-010 | Rollback implementation — pre-change audit values or another approved mechanism | Open |
|
||||
| OTD-001 | Exact persona attribute mechanism — data type, read/update method, discoverability, Conditional Access compatibility | **Resolved** — directory (schema) extension property on an app registration, addressable as `user.extension_<appId>_<name>` in dynamic group rules. `extensionAttributeN` rejected (unavailable for cloud writes on ever-synced and Exchange-originated objects); custom security attributes rejected (not exposed to the dynamic group engine). See research.md OTD-001. |
|
||||
| OTD-002 | Exact least-privilege directory permissions for users, groups, roles, and the selected attribute | **Resolved** — `User.Read.All`, `GroupMember.Read.All`, `RoleManagement.Read.Directory`; `User.ReadWrite.All` for enforcement only. Scopes are requested per enabled-rule need, not unconditionally. See research.md OTD-002. |
|
||||
| OTD-003 | Whether write authorization can be restricted to the individual target attribute; if not, compensating controls plus security approval | **Resolved: it cannot.** Graph application permissions have no per-property write scope. Six compensating controls are mandatory and implemented; see [docs/SecurityModel.md](../../docs/SecurityModel.md). **Security approval (V-4) is still outstanding and gates enforcement.** |
|
||||
| OTD-004 | Directory access approach — SDK cmdlets, direct REST, or a controlled combination | **Resolved** — direct REST via `Invoke-MgGraphRequest`, so request bodies are explicit values that tests can assert on. This is what makes SC-005 provable. Only `Microsoft.Graph.Authentication` is a runtime dependency. |
|
||||
| OTD-005 | JSON Schema validation approach compatible with PowerShell 7 locally and in automation | **Resolved locally** — `Test-Json -SchemaFile`, draft-07 only. Failure behaviour observed and pinned in [V-5a](verification/V-5a.md); note that an unparseable schema returns `$true`. **V-5b (Automation runtime) remains open.** |
|
||||
| OTD-006 | Structured-log destination and transport | **Resolved** — NDJSON through a single sink, `Write-PersonaAuditRecord`. Additional transports are added behind that function, not by widening call sites. See [docs/Logging.md](../../docs/Logging.md). |
|
||||
| OTD-007 | Retry policy — retryable status codes, max attempts, backoff, jitter, logging | **Resolved** — retry 429/500/502/503/504 and status-less transport failures; never 400/401/403/404/409; honour `Retry-After`; max 5 attempts; exponential backoff with full jitter capped at 60s. See research.md OTD-007. |
|
||||
| OTD-008 | Full versus incremental processing roadmap | Open — full enumeration only in v1. |
|
||||
| OTD-009 | Production schedule and concurrency lock to prevent overlapping runs | Open — deferred with Stage B (T120). Until then the schedule is the lock; see [docs/OperationsRunbook.md](../../docs/OperationsRunbook.md). |
|
||||
| OTD-010 | Rollback implementation — pre-change audit values or another approved mechanism | **Data captured; tool not built.** `previousValue` is recorded at write time on every `Updated` record, which is the part that cannot be reconstructed retroactively. The rollback tool itself is out of scope for v1. |
|
||||
|
||||
**Mandatory research item**: determine the selected persona attribute mechanism and document the exact authorization boundary for updating it. If individual-attribute authorization is unavailable, document compensating controls before implementation approval.
|
||||
**Mandatory research item**: determine the selected persona attribute mechanism and document the exact authorization boundary for updating it. If individual-attribute authorization is unavailable, document compensating controls before implementation approval. — **Done.** The mechanism is a directory extension property; the authorization boundary is *the whole user object*, because Graph offers nothing narrower; the compensating controls are documented in [docs/SecurityModel.md](../../docs/SecurityModel.md) and each one is test-enforced. Implementation approval for enforcement still requires the V-4 sign-off.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
---
|
||||
|
||||
description: "Task list for Persona Engine implementation"
|
||||
---
|
||||
|
||||
# Tasks: Persona Engine
|
||||
|
||||
**Input**: Design documents from `/specs/001-persona-engine/`
|
||||
|
||||
**Prerequisites**: [plan.md](plan.md), [spec.md](spec.md), [research.md](research.md),
|
||||
[data-model.md](data-model.md), [contracts/](contracts/), [quickstart.md](quickstart.md)
|
||||
|
||||
**Tests**: **Included and mandatory.** Not an optional TDD preference here — SC-004, SC-005, SC-008,
|
||||
and SC-009 are written as test assertions, and constitution Principle IV requires the rule engine to
|
||||
pass offline Pester tests before any Graph integration exists.
|
||||
|
||||
**Organization**: Grouped by user story. Story phases are ordered by the constitution's
|
||||
**non-negotiable build order**, not strictly by priority — see the note below.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
|
||||
- **[Story]**: US1–US9, mapping to the user stories in spec.md
|
||||
- Every task names an exact file path
|
||||
|
||||
## Path Conventions
|
||||
|
||||
Single PowerShell module at repository root: `src/`, `tests/`, `config/`, `docs/`, `pipelines/`,
|
||||
per the Project Structure section of [plan.md](plan.md).
|
||||
|
||||
## Implementation status — 2026-08-21
|
||||
|
||||
**117 of 129 tasks complete.** All twelve remaining tasks require something this workstation does not
|
||||
have: a tenant connection (T055, T056, T101–T103) or an Azure Automation account (Phase 13,
|
||||
T115–T121). Nothing offline-implementable is outstanding.
|
||||
|
||||
T122–T129 (interactive add/edit/delete for rules, FR-027–FR-030) landed after the initial Phase 6
|
||||
build, which had shipped with toggle/priority editing only. 400 offline Pester tests now pass (up
|
||||
from 354); see [traceability.md](traceability.md) for FR-027–FR-030's implementation and test
|
||||
mapping.
|
||||
|
||||
```
|
||||
400 offline Pester tests PASS
|
||||
Engine purity (Principle IV) PASS
|
||||
Sanitization (SC-013) PASS
|
||||
Graph module loaded during tests False (SC-008 holds)
|
||||
```
|
||||
|
||||
Three deviations from the task list as written, each made while implementing and each for a reason
|
||||
worth recording:
|
||||
|
||||
**T033 does not live in `Resolve-UserPersona`.** The task named that file, but `evaluationErrorThreshold`
|
||||
is run-level state and the rule engine is pure — a counter there would break Principle IV. It is
|
||||
implemented in `New-PersonaRunCounter` and applied in the run loop, which is what "and the run summary
|
||||
path" in the task description points at.
|
||||
|
||||
**A new `src/Engine/` layer exists** holding `Invoke-PersonaEngineRun`, which was not in the planned
|
||||
structure. `Invoke-PersonaEngine.ps1` imports the manifest, which requires `Microsoft.Graph.Authentication`;
|
||||
a run loop living only inside that script could not be executed on a machine without the Graph SDK,
|
||||
so SC-004 — zero writes under `-WhatIf` across a full population — could not be proven at all. The
|
||||
entry script is now a thin wrapper and the loop is testable. What ships and what is tested are the
|
||||
same code.
|
||||
|
||||
**T057's corpus is generated, not hand-written.** `tests/TestData/InvalidConfigs/New-InvalidConfigCorpus.ps1`
|
||||
produces 23 fixtures, each a valid configuration with exactly one defect. The generated files are
|
||||
committed so a reviewer sees the fixture in the diff rather than a script that produces it.
|
||||
|
||||
## Ordering note (read before starting)
|
||||
|
||||
Constitution Principle IV fixes the build order: pure rule engine first, persistence last. This
|
||||
**overrides** strict priority ordering, so the P1 stories are sequenced US2 → US3 → US1 rather than
|
||||
US1 first. US1 (preview) is the headline MVP story but cannot be built before the engine it previews.
|
||||
|
||||
Stage labels refer to the Delivery Staging table in [plan.md](plan.md): **A1** offline, **A2**
|
||||
delegated read-only, **A3** delegated write to test accounts, **B** Azure Automation (deferred — no
|
||||
Automation account available).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure) — Stage A1
|
||||
|
||||
**Purpose**: Repository scaffolding and tooling. No behaviour.
|
||||
|
||||
- [X] T001 Create the directory tree (`src/{Configuration,Authentication,DataProviders,Normalization,RuleEngine,Persistence,Presentation,Audit}`, `tests/{Unit,RuleEngine,Configuration,Integration,Safety,TestData}`, `config/`, `docs/`, `pipelines/`) per the Project Structure section of specs/001-persona-engine/plan.md
|
||||
- [X] T002 Create the module manifest PersonaEngine.psd1 with `PowerShellVersion = '7.2'` and `RequiredModules = @('Microsoft.Graph.Authentication')`
|
||||
- [X] T003 Create the module loader PersonaEngine.psm1 that dot-sources every `src/**/*.ps1` and exports only public functions
|
||||
- [X] T004 [P] Add PSScriptAnalyzer settings in PSScriptAnalyzerSettings.psd1 enabling PSUseShouldProcessForStateChangingFunctions and PSAvoidUsingPlainTextForPassword
|
||||
- [X] T005 [P] Add Pester configuration in tests/PesterConfiguration.ps1 with separate tags for Offline, Integration, and Safety suites
|
||||
- [X] T006 [P] Copy specs/001-persona-engine/contracts/persona-engine.schema.json to config/persona-engine.schema.json as the shipped schema
|
||||
- [X] T007 [P] Create the placeholder-only sample configuration config/persona-engine.example.json using `<APPROVED-PERSONA-ATTRIBUTE-NAME>` and `<GROUP-OBJECT-ID>` tokens only
|
||||
- [X] T008 [P] Write the sanitization scan script tests/Test-Sanitization.ps1 that fails on real-looking GUIDs, domains, UPNs, or secrets in any tracked file (SC-013)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites) — Stage A1
|
||||
|
||||
**Purpose**: The normalized contracts and guardrails every story depends on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work begins until this phase is complete.
|
||||
|
||||
- [X] T009 Implement `New-PersonaUserRecord` in src/Normalization/New-PersonaUserRecord.ps1 producing the UserRecord shape in data-model.md, with case-insensitive `Properties` lookup and mandatory `AccountObjectId`/`UserPrincipalName`
|
||||
- [X] T010 Implement `New-PersonaMembershipRecord` in src/Normalization/New-PersonaMembershipRecord.ps1 with three independently-retrieved facets (direct, transitive, roles), every `*Retrieved` flag defaulting to `$false` so an unset record is never mistaken for "not a member" (revised during implementation — see data-model.md)
|
||||
- [X] T011 [P] Implement `New-PersonaValidationFinding` in src/Configuration/New-PersonaValidationFinding.ps1 emitting Severity, Code, Location, Description, SuggestedResolution, Layer (VR-004)
|
||||
- [X] T012 [P] Create synthetic fixtures in tests/TestData/Users/ covering enabled, disabled, guest, null-property, and missing-property accounts — obviously fictional, placeholders only
|
||||
- [X] T013 [P] Create synthetic membership fixtures in tests/TestData/Memberships/ including a partial-failure record (one facet failed) and a total-failure record
|
||||
- [X] T014 [P] Write Pester contract tests for both record types in tests/Unit/RecordContracts.Tests.ps1
|
||||
- [X] T015 Write the engine-purity CI check tests/Test-EnginePurity.ps1 asserting no file under src/RuleEngine/ references Graph, `Connect-`, `Invoke-MgGraphRequest`, `Write-Host`, or any function from src/Authentication|DataProviders|Persistence|Presentation (Principle IV)
|
||||
|
||||
**Checkpoint**: normalized contracts exist and purity is enforced by CI. Story work can begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 — Define classification rules without changing code (Priority: P1) 🎯 Engine core
|
||||
|
||||
**Goal**: Ordered, JSON-defined rules evaluate against normalized records with no code changes and no
|
||||
tenant.
|
||||
|
||||
**Independent Test**: Author a multi-rule configuration, evaluate it against synthetic fixtures
|
||||
offline with the network disconnected, and confirm the expected persona for each fixture.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
> Write these first and confirm they fail before implementing.
|
||||
|
||||
- [X] T016 [P] [US2] Operator matrix tests for all thirteen RE-005 operators in tests/RuleEngine/Operators.Tests.ps1, including case-insensitivity (RE-006) and null-as-empty (FR-012)
|
||||
- [X] T017 [P] [US2] Ordering tests in tests/RuleEngine/Ordering.Tests.ps1 asserting ascending priority evaluation, first-match stop, and that a priority-10 match beats a priority-20 match
|
||||
- [X] T018 [P] [US2] Composition tests in tests/RuleEngine/Composition.Tests.ps1 for nested `all`/`any` within the depth limit, and rejection beyond `maxConditionDepth` and the hard ceiling of 10 (RE-004)
|
||||
- [X] T019 [P] [US2] Determinism tests in tests/RuleEngine/Determinism.Tests.ps1 asserting identical results across shuffled fixture order and repeated runs (SC-003)
|
||||
- [X] T020 [P] [US2] Unclassified test in tests/RuleEngine/Unclassified.Tests.ps1 asserting that zero matches with all rules evaluating successfully yields `Unclassified` (FR-010)
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T021 [US2] Implement `Import-PersonaConfiguration` in src/Configuration/Import-PersonaConfiguration.ps1 converting JSON to BusinessRule/ConditionGroup/Condition objects and computing the SHA-256 `ConfigurationHash`
|
||||
- [X] T022 [US2] Implement `Test-PersonaCondition` in src/RuleEngine/Test-PersonaCondition.ps1 covering all RE-005 operators, with regex patterns validated before execution (RE-006)
|
||||
- [X] T023 [US2] Implement `Test-PersonaConditionGroup` in src/RuleEngine/Test-PersonaConditionGroup.ps1 with `all`/`any` composition and depth enforcement
|
||||
- [X] T024 [US2] Implement `Test-PersonaRule` in src/RuleEngine/Test-PersonaRule.ps1 evaluating a rule's root condition group
|
||||
- [X] T025 [US2] Implement `Resolve-UserPersona` in src/RuleEngine/Resolve-UserPersona.ps1 with priority sort, first-match stop, `Unclassified` fallback, `RulesEvaluated`, and `DurationMs`
|
||||
- [X] T026 [US2] Implement membership and role condition types in src/RuleEngine/Test-PersonaCondition.ps1 reading only from the MembershipRecord, honouring per-condition `direct`/`transitive` mode (RE-007)
|
||||
|
||||
**Checkpoint**: the rule engine classifies synthetic users offline. Disconnect the network and re-run
|
||||
tests/RuleEngine — SC-008 holds or the phase is not done.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 3 — Preserve existing values when evaluation cannot be trusted (Priority: P1)
|
||||
|
||||
**Goal**: Unretrievable required data yields `EvaluationError`, preserves the stored persona, and
|
||||
never becomes a silent non-match.
|
||||
|
||||
**Independent Test**: Inject a group-lookup failure for one synthetic user; that user receives
|
||||
`EvaluationError`, no write is attempted, and the run continues.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T027 [P] [US3] Tri-state propagation tests in tests/RuleEngine/UnknownPropagation.Tests.ps1 covering all four rows of the propagation table in data-model.md
|
||||
- [X] T028 [P] [US3] Preservation tests in tests/RuleEngine/EvaluationError.Tests.ps1 asserting stored persona is retained, `Action` is `Skipped`, and processing continues to the next user (FR-014)
|
||||
- [X] T029 [P] [US3] Regression test in tests/RuleEngine/UnknownNotFalse.Tests.ps1 asserting a failed membership lookup never satisfies `notMemberOf` — the specific misclassification hazard FR-013 exists to prevent
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T030 [US3] Extend condition evaluation in src/RuleEngine/Test-PersonaCondition.ps1 to return `True`/`False`/`Unknown` instead of a boolean
|
||||
- [X] T031 [US3] Implement the propagation rules in src/RuleEngine/Test-PersonaConditionGroup.ps1: `all` with any `False` is `False`; `all` with only `True` plus `Unknown` is `Unknown`; `any` with any `True` is `True`; `any` with only `False` plus `Unknown` is `Unknown`
|
||||
- [X] T032 [US3] Map a root-level `Unknown` to the `EvaluationError` outcome with `EvaluationErrorReason` in src/RuleEngine/Resolve-UserPersona.ps1
|
||||
- [X] T033 [US3] Add the `evaluationErrorThreshold` counter and final-status effect in src/RuleEngine/Resolve-UserPersona.ps1 and the run summary path
|
||||
|
||||
**Checkpoint**: unknown data degrades to preserve-and-report. Principle III is satisfied in the pure
|
||||
engine, before any tenant exists.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 1 — Preview classification without changing the directory (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Every in-scope user is retrieved, evaluated, and reported with stored value, calculated
|
||||
value, and matched rule — with zero directory writes.
|
||||
|
||||
**Independent Test**: Run with `-WhatIf` against the tenant using a delegated read-only connection;
|
||||
verify a result appears for every account and the write adapter receives zero calls.
|
||||
|
||||
**Stage**: A2. Requires an app registration with admin consent for the three delegated scopes.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T034 [P] [US1] Zero-write safety test in tests/Safety/WhatIfZeroWrites.Tests.ps1 mocking the write adapter and asserting call count is exactly 0 across the full synthetic population (SC-004)
|
||||
- [X] T035 [P] [US1] Mode-derivation test in tests/Safety/ShouldProcessGate.Tests.ps1 asserting `-Debug` without `-WhatIf` still reaches the write path — `-Debug` is not a safety control
|
||||
- [X] T036 [P] [US1] Pagination test in tests/Unit/Pagination.Tests.ps1 asserting `@odata.nextLink` is followed to exhaustion and a truncated enumeration raises rather than returning a partial population
|
||||
- [X] T037 [P] [US1] Idempotence test in tests/Safety/Idempotence.Tests.ps1 asserting a second consecutive run over unchanged input proposes zero changes (SC-002)
|
||||
- [X] T038 [P] [US1] Exactly-one-outcome test in tests/Unit/OutcomeExclusivity.Tests.ps1 asserting every processed user lands in exactly one bucket (SC-001)
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T039 [P] [US1] Implement `Connect-PersonaGraphInteractive` in src/Authentication/Connect-PersonaGraphInteractive.ps1 requesting only `User.Read.All`, `GroupMember.Read.All`, `RoleManagement.Read.Directory`, returning an opaque handle that never exposes a token
|
||||
- [X] T040 [P] [US1] Implement the retry helper `Invoke-PersonaGraphRequest` in src/DataProviders/Invoke-PersonaGraphRequest.ps1 per the OTD-007 table: retry 429/500/502/503/504/timeout, never 400/401/403/404/409, honour `Retry-After`, max 5 attempts, exponential backoff with full jitter capped at 60s
|
||||
- [X] T041 [US1] Implement `Get-PersonaUsers` in src/DataProviders/Get-PersonaUsers.ps1 with `$select` built from FR-005 plus rule-referenced properties plus the target attribute, `$top=999`, and full `@odata.nextLink` pagination
|
||||
- [X] T042 [US1] Add single-user retrieval to src/DataProviders/Get-PersonaUsers.ps1 for the `-UserObjectId` path
|
||||
- [X] T043 [US1] Implement `Get-PersonaGroupMembership` in src/DataProviders/Get-PersonaGroupMembership.ps1 selecting `memberOf` or `transitiveMemberOf` by mode, returning a MembershipRecord with `RetrievalSucceeded = $false` on failure rather than throwing or returning an empty list
|
||||
- [X] T044 [US1] Implement `Get-PersonaDirectoryRoles` in src/DataProviders/Get-PersonaDirectoryRoles.ps1 via `/roleManagement/directory/roleAssignments`
|
||||
- [X] T045 [US1] Implement the run-scoped group/role cache in src/DataProviders/PersonaDataCache.ps1, never persisted between runs
|
||||
- [X] T046 [US1] Implement `ConvertTo-PersonaUserRecord` in src/Normalization/ConvertTo-PersonaUserRecord.ps1 mapping raw Graph responses to UserRecord, including the persona extension property
|
||||
- [X] T047 [US1] Implement `ConvertTo-PersonaMembershipRecord` in src/Normalization/ConvertTo-PersonaMembershipRecord.ps1
|
||||
- [X] T048 [US1] Implement `Compare-PersonaValue` in src/Persistence/Compare-PersonaValue.ps1 performing ordinal comparison of stored versus calculated (FR-015)
|
||||
- [X] T049 [US1] Implement `Write-UserPersonaResult` in src/Presentation/Write-UserPersonaResult.ps1 emitting one line per user immediately with UPN, Account Object ID, outcome, matched rule ID, stored value, calculated value, and action (FR-018, SC-012)
|
||||
- [X] T050 [US1] Create Invoke-PersonaEngine.ps1 with `CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')` and the parameter set from contracts/cli-invoke-persona-engine.md
|
||||
- [X] T051 [US1] Derive execution mode solely from `$PSCmdlet.ShouldProcess()` in Invoke-PersonaEngine.ps1 — no separate preview boolean, per the contract's prohibition on two sources of truth for the write gate
|
||||
- [X] T052 [US1] Implement the `WouldUpdate` reporting path in Invoke-PersonaEngine.ps1 so preview reports intended changes without constructing a request (FR-017)
|
||||
- [X] T053 [US1] Implement exit codes 0–6 in Invoke-PersonaEngine.ps1 per the contract table
|
||||
- [X] T054 [US1] Record per-user and total duration in Invoke-PersonaEngine.ps1 to establish the NFR-002 baseline no target yet exists for
|
||||
- [ ] T055 [US1] Run quickstart Scenario 4 as a **non-privileged** account and record results in specs/001-persona-engine/verification/V-3.md — a Global Administrator run invalidates this item
|
||||
- [ ] T056 [US1] Close the V-1 read half against a cloud-only, a currently-synced, and a formerly-synced account; record in specs/001-persona-engine/verification/V-1.md
|
||||
|
||||
**Checkpoint**: 🎯 **MVP complete.** Full classification visibility against the live tenant with zero
|
||||
write risk. Everything after this point adds observability, safety tooling, or enforcement.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 5 — Validate and edit configuration safely (Priority: P2)
|
||||
|
||||
**Goal**: Four-layer validation with structured findings, plus an interactive editor that cannot save
|
||||
an invalid configuration.
|
||||
|
||||
**Independent Test**: Run the invalid-configuration corpus and confirm each file produces its
|
||||
documented finding code, severity, and location.
|
||||
|
||||
### Tests for User Story 5
|
||||
|
||||
- [X] T057 [P] [US5] Build the invalid-configuration corpus in tests/TestData/InvalidConfigs/ — one file per VR-002 and VR-003 condition
|
||||
- [X] T058 [P] [US5] Semantic validation tests in tests/Configuration/Semantic.Tests.ps1 asserting code, severity, and location for every VR-002 condition (SC-009)
|
||||
- [X] T059 [P] [US5] Safety validation tests in tests/Configuration/Safety.Tests.ps1 asserting every VR-003 condition
|
||||
- [X] T060 [P] [US5] Layer-ordering test in tests/Configuration/LayerOrdering.Tests.ps1 asserting a structurally invalid document stops before semantic checks run
|
||||
|
||||
### Implementation for User Story 5
|
||||
|
||||
- [X] T061 [US5] Close V-5a: observe and record whether `Test-Json -SchemaFile` returns `$false`, writes a non-terminating error, or throws on this PowerShell build; record in specs/001-persona-engine/verification/V-5a.md and pin the observation in tests/Configuration/TestJsonBehaviour.Tests.ps1
|
||||
- [X] T062 [US5] Implement validation layer 1 (syntax) in src/Configuration/Test-PersonaConfiguration.ps1
|
||||
- [X] T063 [US5] Implement validation layer 2 (schema) in src/Configuration/Test-PersonaConfiguration.ps1 wrapping `Test-Json -SchemaFile` with `-ErrorAction SilentlyContinue -ErrorVariable` and converting collected errors into ValidationFinding objects per the V-5a observation
|
||||
- [X] T064 [US5] Implement validation layer 3 (semantic) in src/Configuration/Test-PersonaConfigurationSemantic.ps1 covering every VR-002 condition with stable `PE-SEM-nnn` codes
|
||||
- [X] T065 [US5] Implement validation layer 4 (safety) in src/Configuration/Test-PersonaConfigurationSafety.ps1 covering every VR-003 condition with stable `PE-SAF-nnn` codes
|
||||
- [X] T066 [US5] Implement `Resolve-TargetAttribute` in src/Configuration/Resolve-TargetAttribute.ps1 rejecting any attribute absent from `approvedWritableAttributes`
|
||||
- [X] T067 [US5] Create Edit-PersonaEngineConfig.ps1 with the parameter set from contracts/cli-edit-persona-engine-config.md
|
||||
- [X] T068 [US5] Implement the interactive editor loop in Edit-PersonaEngineConfig.ps1 with re-validation before save
|
||||
- [X] T069 [US5] Implement the timestamped backup and Save-As path in Edit-PersonaEngineConfig.ps1 (FR-026)
|
||||
- [X] T070 [US5] Implement synthetic rule testing via `-TestDataPath` in Edit-PersonaEngineConfig.ps1, reusing the rule engine with no tenant connectivity (FR-025)
|
||||
|
||||
### Rule CRUD in the interactive editor (US5 addition — FR-027–FR-030)
|
||||
|
||||
Added after the initial Phase 6 build. The editor originally shipped deliberately scoped to
|
||||
toggle/priority only (see the `Invoke-InteractiveEditor` comment header); spec.md now requires
|
||||
add/edit/delete, including full condition-tree editing, so that scope is reversed here rather than
|
||||
worked around.
|
||||
|
||||
#### Tests for rule CRUD
|
||||
|
||||
- [X] T122 [P] [US5] Add-rule tests in tests/Configuration/EditorAddRule.Tests.ps1 covering the full field/condition-tree prompt sequence and rejection of a duplicate `id` or `priority` before the rule is added (FR-027)
|
||||
- [X] T123 [P] [US5] Edit-rule tests in tests/Configuration/EditorEditRule.Tests.ps1 covering top-level field changes and condition-tree add/edit/remove/renest operations within `MaxConditionDepth` (FR-028)
|
||||
- [X] T124 [P] [US5] Delete-rule tests in tests/Configuration/EditorDeleteRule.Tests.ps1 covering the `id`/`name`/`priority` confirmation prompt and removal, including a declined confirmation leaving the rule in place (FR-029)
|
||||
- [X] T125 [P] [US5] Structural-edit validation tests in tests/Configuration/EditorStructuralEdits.Tests.ps1 asserting add/edit/delete changes stay in memory until save, re-validate through all four layers on `[V]`/`[S]`, and that a depth-limit violation is reported immediately at edit time rather than deferred (FR-030)
|
||||
|
||||
#### Implementation for rule CRUD
|
||||
|
||||
- [X] T126 [US5] Implement rule addition (prompt for every RE-001 field plus the condition tree, reject a colliding `id`/`priority`) in Edit-PersonaEngineConfig.ps1 and wire the `[A]` command into the interactive loop (FR-027)
|
||||
- [X] T127 [US5] Implement rule editing (top-level fields plus add/edit/remove/renest on the condition tree) in Edit-PersonaEngineConfig.ps1 and wire the `[E]` command into the interactive loop (FR-028)
|
||||
- [X] T128 [US5] Implement rule deletion with an `id`/`name`/`priority` confirmation prompt in Edit-PersonaEngineConfig.ps1 and wire the `[D]` command into the interactive loop (FR-029)
|
||||
- [X] T129 [US5] Route add/edit/delete through the existing `$dirty` tracking and `Invoke-ConfigurationValidation` re-validation path in Edit-PersonaEngineConfig.ps1, and surface a depth-limit finding at edit time using the same check the runtime validator uses (FR-030)
|
||||
|
||||
**Checkpoint**: invalid configurations cannot reach the engine or overwrite a good file, and rules can be added, edited, and deleted entirely from the editor.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 6 — Block invalid configuration in a pipeline (Priority: P2)
|
||||
|
||||
**Goal**: Non-interactive validation that returns exit codes and never prompts.
|
||||
|
||||
**Independent Test**: Run `-ValidateOnly -NonInteractive` against an invalid file with stdin closed;
|
||||
confirm a non-zero exit code, no prompt, and no hang.
|
||||
|
||||
**Depends on**: US5's validator (T062–T065). If pipeline gating is needed sooner than the interactive
|
||||
editor, build T062–T065 and T071–T073 first and defer T067–T070.
|
||||
|
||||
### Tests for User Story 6
|
||||
|
||||
- [X] T071 [P] [US6] Non-interactive test in tests/Configuration/NonInteractive.Tests.ps1 running with stdin closed and asserting no prompt and no hang (SC-010)
|
||||
- [X] T072 [P] [US6] Exit-code test in tests/Configuration/ExitCodes.Tests.ps1 covering codes 0–4 from the editor contract
|
||||
|
||||
### Implementation for User Story 6
|
||||
|
||||
- [X] T073 [US6] Implement `-NonInteractive` and `-ValidateOnly` short-circuits in Edit-PersonaEngineConfig.ps1 that never call a prompting cmdlet
|
||||
- [X] T074 [US6] Implement `-TreatWarningsAsErrors` escalation in Edit-PersonaEngineConfig.ps1 (VR-005)
|
||||
- [X] T075 [US6] Implement the editor exit codes 0–4 in Edit-PersonaEngineConfig.ps1
|
||||
- [X] T076 [US6] Add the validation stage to pipelines/validate.yml invoking sanitization, PSScriptAnalyzer, engine purity, schema validation, and the offline Pester suites
|
||||
|
||||
**Checkpoint**: CI blocks a bad configuration before it can reach a tenant.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: User Story 4 — Observe progress and reconcile results (Priority: P2)
|
||||
|
||||
**Goal**: Interim and final summaries with a reconciliation check that treats a mismatch as an engine
|
||||
defect.
|
||||
|
||||
**Independent Test**: Run over a fixture population with `summaryInterval` set to 5, then to 0;
|
||||
confirm interim summaries appear at the interval, are suppressed at 0, and a final summary appears in
|
||||
both cases.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [X] T077 [P] [US4] Interval-semantics tests in tests/Unit/SummaryInterval.Tests.ps1 covering default 25, a custom interval, and the `0` case that still produces a final summary (FR-020)
|
||||
- [X] T078 [P] [US4] Reconciliation tests in tests/Unit/Reconciliation.Tests.ps1 asserting `Processed = Matched + Unclassified + EvaluationError` at every summary and that a forced mismatch raises an engine defect (SC-007)
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T079 [US4] Implement `Write-PersonaSummary` in src/Presentation/Write-PersonaSummary.ps1 rendering all business rules with match counts — including disabled and zero-match rules, so an absent rule is distinguishable from one that never fired
|
||||
- [X] T080 [US4] Implement interval-triggered interim summaries in Invoke-PersonaEngine.ps1 (FR-019)
|
||||
- [X] T081 [US4] Implement `Test-PersonaReconciliation` in src/Presentation/Test-PersonaReconciliation.ps1 and invoke it at every summary (FR-021)
|
||||
- [X] T082 [US4] Emit an `EngineDefect` record and exit code 5 on reconciliation failure in Invoke-PersonaEngine.ps1
|
||||
|
||||
**Checkpoint**: operators can watch a long run and trust the counters.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: User Story 7 — Audit any classification decision (Priority: P2)
|
||||
|
||||
**Goal**: Structured, audit-friendly records that explain every decision and make rollback possible.
|
||||
|
||||
**Independent Test**: Run over fixtures and confirm 100% of user events carry run ID, UPN, and
|
||||
Account Object ID, and 100% of matched results carry a rule ID.
|
||||
|
||||
### Tests for User Story 7
|
||||
|
||||
- [X] T083 [P] [US7] Completeness tests in tests/Unit/AuditCompleteness.Tests.ps1 asserting SC-006 across every record
|
||||
- [X] T084 [P] [US7] Redaction tests in tests/Unit/AuditRedaction.Tests.ps1 asserting no token, `Authorization` header, secret, or raw Graph response can appear in any record
|
||||
- [X] T085 [P] [US7] Schema tests in tests/Unit/AuditRecordShape.Tests.ps1 validating each record type against contracts/audit-record.md
|
||||
|
||||
### Implementation for User Story 7
|
||||
|
||||
- [X] T086 [US7] Implement `New-PersonaAuditRecord` in src/Audit/New-PersonaAuditRecord.ps1 building the common envelope plus each `recordType`
|
||||
- [X] T087 [US7] Implement the single sink `Write-PersonaAuditRecord` in src/Audit/Write-PersonaAuditRecord.ps1 emitting NDJSON to file and/or stream per `logging.destination`, as the only emission point so a future transport needs no call-site changes
|
||||
- [X] T088 [US7] Implement `Export-PersonaRunReport` in src/Audit/Export-PersonaRunReport.ps1 producing the `RunComplete` record with all counters and the exit code
|
||||
- [X] T089 [US7] Wire the run ID from `-CorrelationId` or a generated GUID through every record in Invoke-PersonaEngine.ps1 (NFR-005)
|
||||
- [X] T090 [US7] Record `configVersion` and `configurationHash` on every record in src/Audit/New-PersonaAuditRecord.ps1
|
||||
|
||||
**Checkpoint**: every decision is explainable after the fact.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: User Story 9 — Trace the values behind every rule decision (Priority: P3)
|
||||
|
||||
**Goal**: Condition-level diagnostic tracing, available only when explicitly enabled.
|
||||
|
||||
**Independent Test**: Run one user with `-Debug` and confirm a condition trace appears; run without
|
||||
`-Debug` and confirm no condition values are emitted anywhere.
|
||||
|
||||
### Tests for User Story 9
|
||||
|
||||
- [X] T091 [P] [US9] Gating tests in tests/Unit/ConditionTrace.Tests.ps1 asserting `conditionTrace` is absent without `-Debug` and present with it
|
||||
- [X] T092 [P] [US9] Acknowledgement test in tests/Configuration/TraceAcknowledgement.Tests.ps1 asserting `traceConditionValues` without explicit acknowledgement produces a VR-003 safety finding
|
||||
|
||||
### Implementation for User Story 9
|
||||
|
||||
- [X] T093 [US9] Populate `ConditionTrace` on the decision result in src/RuleEngine/Resolve-UserPersona.ps1, built only when tracing is active
|
||||
- [X] T094 [US9] Add the `conditionTrace` array to user events in src/Audit/New-PersonaAuditRecord.ps1, gated on `-Debug` or `logging.traceConditionValues`
|
||||
|
||||
**Checkpoint**: rule authors can debug a decision without loosening default logging.
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: User Story 8 — Enforce changes in production (Priority: P3) 🔒 Gated
|
||||
|
||||
**Goal**: Write the calculated persona, only when changed, only the approved attribute.
|
||||
|
||||
**Independent Test**: Against test accounts, confirm only changed values are written, every request
|
||||
body has exactly one key, and every `Updated` record carries `previousValue`.
|
||||
|
||||
**⚠️ Do not begin the tenant-facing tasks (T101–T103) until**: V-4 security sign-off is recorded,
|
||||
`-WhatIf` impact evidence from Scenario 4 is reviewed, and Phases 1–10 pass. Tasks T095–T100 are
|
||||
offline and may proceed at any time.
|
||||
|
||||
### Tests for User Story 8
|
||||
|
||||
- [X] T095 [P] [US8] Single-attribute body test in tests/Safety/WriteBody.Tests.ps1 asserting every captured body has exactly one key equal to `engine.targetAttribute` (SC-005)
|
||||
- [X] T096 [P] [US8] Rejection tests in tests/Safety/WriteBodyRejection.Tests.ps1 asserting `New-PersonaWriteBody` throws for any other attribute name and for an attribute absent from `approvedWritableAttributes`
|
||||
- [X] T097 [P] [US8] Write-gate tests in tests/Safety/WriteGate.Tests.ps1 covering all four FR-016 conditions, including that an `EvaluationError` user is never written
|
||||
|
||||
### Implementation for User Story 8
|
||||
|
||||
- [X] T098 [US8] Implement `New-PersonaWriteBody` in src/Persistence/New-PersonaWriteBody.ps1 as the only function permitted to construct a write body, returning a hashtable whose `Count` is exactly 1
|
||||
- [X] T099 [US8] Implement `Set-UserPersonaAttribute` in src/Persistence/Set-UserPersonaAttribute.ps1 issuing `PATCH /v1.0/users/{id}`, reachable only when `ShouldProcess` returned true
|
||||
- [X] T100 [US8] Capture `previousValue` at write time into the audit record in src/Persistence/Set-UserPersonaAttribute.ps1 — missing this in v1 makes OTD-010 rollback impossible retroactively
|
||||
- [ ] T101 [US8] Record the V-4 security sign-off in specs/001-persona-engine/verification/V-4.md before any enforcement run
|
||||
- [ ] T102 [US8] Close the V-1 write half against purpose-created test accounts of each origin type; append to specs/001-persona-engine/verification/V-1.md
|
||||
- [ ] T103 [US8] Close V-2 by building a dynamic membership group on the persona extension and confirming a CA policy assigned to it applies; record in specs/001-persona-engine/verification/V-2.md
|
||||
|
||||
**Checkpoint**: enforcement works against test accounts with every safety invariant proven.
|
||||
|
||||
---
|
||||
|
||||
## Phase 12: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T104 [P] Add comment-based help to every public function across src/ (NFR-004)
|
||||
- [X] T105 [P] Write docs/Architecture.md describing the adapter boundaries and why the rule engine is pure
|
||||
- [X] T106 [P] Write docs/ConfigurationReference.md documenting every schema field and finding code
|
||||
- [X] T107 [P] Write docs/SecurityModel.md recording the OTD-003 trade-off, the six compensating controls, and the V-4 sign-off
|
||||
- [X] T108 [P] Write docs/OperationsRunbook.md including the kill switch and the rollback procedure
|
||||
- [X] T109 [P] Write docs/BusinessRules.md and docs/Logging.md
|
||||
- [X] T110 [P] Add the test stage to pipelines/test.yml publishing Pester results
|
||||
- [X] T111 Build the FR/NFR traceability matrix in specs/001-persona-engine/traceability.md mapping every requirement ID to its implementing task and test
|
||||
- [X] T112 Verify every exit code 0–6 is reachable via fault injection in tests/Unit/ExitCodes.Tests.ps1 (SC-011)
|
||||
- [X] T113 Run tests/Test-Sanitization.ps1 across all tracked files and record the result in specs/001-persona-engine/verification/sanitization.md (SC-013)
|
||||
- [X] T114 Update specs/001-persona-engine/spec.md Clarification Register to mark OTD-001 through OTD-005 resolved, citing research.md
|
||||
|
||||
---
|
||||
|
||||
## Phase 13: Stage B — Azure Automation (DEFERRED)
|
||||
|
||||
**Blocked**: no Automation account is available. Listed so the remaining entry cost stays visible and
|
||||
nothing is lost. Nothing in Phases 1–12 depends on these.
|
||||
|
||||
- [ ] T115 Implement `Connect-PersonaGraphManagedIdentity` in src/Authentication/Connect-PersonaGraphManagedIdentity.ps1 returning the same handle shape as the interactive path
|
||||
- [ ] T116 Close V-5b: confirm `Test-Json -SchemaFile` behaviour in the Automation runtime and record its exact PowerShell version in specs/001-persona-engine/verification/V-5b.md — run this first, it is the cheapest item most likely to surprise
|
||||
- [ ] T117 Close V-3b: confirm the three read scopes work as application permissions on the managed identity; record in specs/001-persona-engine/verification/V-3b.md
|
||||
- [ ] T118 Create the runbook wrapper pipelines/runbook/Invoke-PersonaEngineRunbook.ps1
|
||||
- [ ] T119 Build pipelines/release.yml deploying to Automation with the schedule shipped **disabled**, a `-WhatIf` validation stage, and an approval gate before enforcement
|
||||
- [ ] T120 Implement the run-start concurrency check in Invoke-PersonaEngine.ps1 per OTD-009
|
||||
- [ ] T121 Run quickstart Scenario 5 and record results in specs/001-persona-engine/verification/Scenario5.md — **this is what allows v1 to be declared complete**
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
```text
|
||||
Phase 1 (Setup)
|
||||
└─> Phase 2 (Foundational) ← blocks everything
|
||||
└─> Phase 3 US2 Rule engine ← blocks US3, US1
|
||||
└─> Phase 4 US3 Fail-safe ← blocks US1
|
||||
└─> Phase 5 US1 Preview 🎯 MVP
|
||||
├─> Phase 6 US5 Validation + editor
|
||||
│ └─> Phase 7 US6 Pipeline mode
|
||||
├─> Phase 8 US4 Summaries
|
||||
│ └─> Phase 9 US7 Audit
|
||||
│ └─> Phase 10 US9 Tracing
|
||||
└─> Phase 11 US8 Enforcement 🔒 (also gated on V-4)
|
||||
└─> Phase 12 Polish
|
||||
└─> Phase 13 Stage B (deferred)
|
||||
```
|
||||
|
||||
**Story independence, honestly stated**: US2 and US3 are genuinely independent of everything except
|
||||
the foundation. US1 depends on both — the constitution's build order makes that unavoidable, and
|
||||
pretending otherwise would produce a task list that cannot be executed in the mandated sequence.
|
||||
US4–US9 are independent of each other and may proceed in any order once US1 lands, except that US9
|
||||
reads the audit shape US7 defines.
|
||||
|
||||
## Parallel opportunities
|
||||
|
||||
| Phase | Parallel set |
|
||||
| --- | --- |
|
||||
| 1 | T004, T005, T006, T007, T008 |
|
||||
| 2 | T011, T012, T013, T014 |
|
||||
| 3 | T016–T020 (all tests, separate files) |
|
||||
| 4 | T027, T028, T029 |
|
||||
| 5 | T034–T038 (tests); then T039, T040 |
|
||||
| 6 | T057–T060; then T122–T125 |
|
||||
| 7 | T071, T072 |
|
||||
| 8 | T077, T078 |
|
||||
| 9 | T083, T084, T085 |
|
||||
| 10 | T091, T092 |
|
||||
| 11 | T095, T096, T097 |
|
||||
| 12 | T104–T110 |
|
||||
|
||||
After Phase 5, three tracks can run concurrently: validation/editor (Phases 6–7),
|
||||
observability (Phases 8–10), and the offline half of enforcement (T095–T100).
|
||||
|
||||
## Implementation strategy
|
||||
|
||||
**MVP = Phases 1–5** (T001–T056). Delivers complete classification visibility against the live tenant
|
||||
with zero write capability, closes V-1 (read) and V-3, and establishes the performance baseline. This
|
||||
is a genuinely useful deliverable on its own: it answers "what would this classify my tenant as?"
|
||||
without touching anything.
|
||||
|
||||
**Increment 2 = Phases 6–7**. Configuration safety and CI gating — the prerequisite for letting anyone
|
||||
other than the author edit rules.
|
||||
|
||||
**Increment 3 = Phases 8–10**. Observability, reconciliation, and audit. Required before enforcement
|
||||
is defensible, because the `-WhatIf` impact evidence the constitution demands is only as good as the
|
||||
output that produces it.
|
||||
|
||||
**Increment 4 = Phase 11**, behind the V-4 gate, against test accounts only.
|
||||
|
||||
**Stage B (Phase 13)** converts the result into an unattended service. Until T121 passes, the correct
|
||||
status to report is **"Stage A complete"**, not "v1 done" — the Definition of Done requires an
|
||||
Automation PowerShell 7 run.
|
||||
|
||||
## Task summary
|
||||
|
||||
| Phase | Story | Tasks | Count |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 Setup | — | T001–T008 | 8 |
|
||||
| 2 Foundational | — | T009–T015 | 7 |
|
||||
| 3 | US2 (P1) | T016–T026 | 11 |
|
||||
| 4 | US3 (P1) | T027–T033 | 7 |
|
||||
| 5 | US1 (P1) 🎯 | T034–T056 | 23 |
|
||||
| 6 | US5 (P2) | T057–T070, T122–T129 | 22 |
|
||||
| 7 | US6 (P2) | T071–T076 | 6 |
|
||||
| 8 | US4 (P2) | T077–T082 | 6 |
|
||||
| 9 | US7 (P2) | T083–T090 | 8 |
|
||||
| 10 | US9 (P3) | T091–T094 | 4 |
|
||||
| 11 | US8 (P3) 🔒 | T095–T103 | 9 |
|
||||
| 12 Polish | — | T104–T114 | 11 |
|
||||
| 13 Stage B | — | T115–T121 | 7 (deferred) |
|
||||
| **Total** | | | **129** |
|
||||
@@ -0,0 +1,119 @@
|
||||
# Requirements traceability
|
||||
|
||||
Every functional requirement, non-functional requirement, and success criterion in
|
||||
[spec.md](spec.md), mapped to the code that implements it and the test that holds it there.
|
||||
|
||||
A row with no test is a requirement nobody is checking. Those are listed explicitly at the bottom
|
||||
rather than left out, because an incomplete matrix that looks complete is worse than no matrix.
|
||||
|
||||
**Status as at 2026-08-21**: 400 offline tests passing; engine purity and sanitization gates passing;
|
||||
no tenant-dependent item verified. The increase from 354 is FR-027–FR-030 (rule add/edit/delete in
|
||||
the interactive editor), added to spec.md and implemented in the same change (T122–T129).
|
||||
|
||||
## Functional requirements
|
||||
|
||||
| ID | Requirement | Implementation | Test |
|
||||
| --- | --- | --- | --- |
|
||||
| FR-001 | Load JSON configuration | `Import-PersonaConfiguration` | `LayerOrdering.Tests.ps1` |
|
||||
| FR-002 | Validate before connecting | `Test-PersonaConfiguration`; entry script exits 1 before `Connect-` | `LayerOrdering.Tests.ps1`, `ExitCodes.Tests.ps1` |
|
||||
| FR-003 | Adapter-isolated authentication | `Connect-PersonaGraphInteractive` | Purity gate; `ShouldProcessGate.Tests.ps1` |
|
||||
| FR-004 | Enumerate all users with pagination | `Get-PersonaUsers` | `Pagination.Tests.ps1` |
|
||||
| FR-005 | Select only required properties | `Get-PersonaRequiredProperties` | `Pagination.Tests.ps1` |
|
||||
| FR-006 | Retrieve and cache related data | `Get-PersonaGroupMembership`, `Get-PersonaCachedMembership` | `Pagination.Tests.ps1`, `OutcomeExclusivity.Tests.ps1` |
|
||||
| FR-007 | Normalize before evaluation | `ConvertTo-PersonaUserRecord`, `ConvertTo-PersonaMembershipRecord` | `RecordContracts.Tests.ps1` |
|
||||
| FR-008 | Evaluate rules in priority order | `Resolve-UserPersona` | `Ordering.Tests.ps1` |
|
||||
| FR-009 | Stop at first match | `Resolve-UserPersona` | `Ordering.Tests.ps1` |
|
||||
| FR-010 | `Unclassified` when nothing matches | `Resolve-UserPersona` | `Unclassified.Tests.ps1` |
|
||||
| FR-011 | Disabled accounts stay in scope | `New-PersonaUserRecord` exposes `AccountEnabled` | `Operators.Tests.ps1` |
|
||||
| FR-012 | Null treated as empty | `Test-PersonaCondition` | `Operators.Tests.ps1` |
|
||||
| FR-013 | Unretrievable group data yields `EvaluationError` | Tri-state evaluation; facet retrieval flags | `UnknownNotFalse.Tests.ps1`, `UnknownPropagation.Tests.ps1` |
|
||||
| FR-014 | Preserve stored value on failure | `Compare-PersonaValue` sets `Skipped` first | `EvaluationError.Tests.ps1`, `WriteGate.Tests.ps1` |
|
||||
| FR-015 | Compare stored and calculated | `Compare-PersonaValue`, ordinal | `WriteGate.Tests.ps1` |
|
||||
| FR-016 | Write only changed values, four conditions | `Compare-PersonaValue` + run-loop gate | `WriteGate.Tests.ps1` |
|
||||
| FR-017 | Preview issues no write request | `Invoke-PersonaEngineRun` gate | `WhatIfZeroWrites.Tests.ps1` |
|
||||
| FR-018 | Immediate per-user output | `Write-UserPersonaResult` | Exercised by every run-loop suite |
|
||||
| FR-019 | Periodic summary | `Write-PersonaSummary`, interval check | `SummaryInterval.Tests.ps1` |
|
||||
| FR-020 | Interval semantics, final always shown | `Invoke-PersonaEngineRun` | `SummaryInterval.Tests.ps1` |
|
||||
| FR-021 | Reconciliation at every summary | `Test-PersonaReconciliation` | `Reconciliation.Tests.ps1` |
|
||||
| FR-022 | Structured audit records | `New-PersonaAuditRecord`, `Write-PersonaAuditRecord` | `AuditRecordShape.Tests.ps1` |
|
||||
| FR-023 | Configuration editor | `Edit-PersonaEngineConfig.ps1` | `ExitCodes.Tests.ps1` (editor) |
|
||||
| FR-024 | Non-interactive validation with exit codes | `-NonInteractive` short-circuit | `NonInteractive.Tests.ps1` |
|
||||
| FR-025 | Synthetic rule testing, no tenant | `Invoke-SyntheticRuleTest` | `NonInteractive.Tests.ps1` |
|
||||
| FR-026 | Validate and back up before save | `Save-PersonaConfiguration` | `Safety.Tests.ps1` (`PE-SAF-007`) |
|
||||
| FR-027 | Add a rule interactively | `Add-PersonaConfigRule`; `[A]` command in `Edit-PersonaEngineConfig.ps1` | `EditorAddRule.Tests.ps1`, `EditorStructuralEdits.Tests.ps1` |
|
||||
| FR-028 | Edit a rule interactively, including its condition tree | `Get/Add/Remove-PersonaConditionNode`, `Set-PersonaConditionLeaf`; `[E]` command | `EditorEditRule.Tests.ps1`, `EditorStructuralEdits.Tests.ps1` |
|
||||
| FR-029 | Delete a rule interactively, with confirmation | `Remove-PersonaConfigRule`; `[D]` command | `EditorDeleteRule.Tests.ps1`, `EditorStructuralEdits.Tests.ps1` |
|
||||
| FR-030 | Structural edits validated like a hand-edited file | `Test-PersonaCandidateEdit` in `Edit-PersonaEngineConfig.ps1` | `EditorStructuralEdits.Tests.ps1` |
|
||||
|
||||
## Rule engine requirements
|
||||
|
||||
| ID | Requirement | Implementation | Test |
|
||||
| --- | --- | --- | --- |
|
||||
| RE-001 | Required rule fields | Schema `definitions/rule` | `LayerOrdering.Tests.ps1` |
|
||||
| RE-002 | Unique priorities, lower first | `Resolve-UserPersona`; `PE-SEM-002` | `Ordering.Tests.ps1`, `Semantic.Tests.ps1` |
|
||||
| RE-003 | `all` / `any` with nesting | `Test-PersonaConditionGroup` | `Composition.Tests.ps1` |
|
||||
| RE-004 | Depth limit and hard ceiling | `Test-PersonaConditionGroup`; `PE-SEM-012`, `PE-SEM-013` | `Composition.Tests.ps1`, `Semantic.Tests.ps1` |
|
||||
| RE-005 | Thirteen operators | `Test-PersonaCondition` | `Operators.Tests.ps1` |
|
||||
| RE-006 | Case-insensitive; regex validated first | `Test-PersonaCondition`; `PE-SEM-016` | `Operators.Tests.ps1`, `Semantic.Tests.ps1` |
|
||||
| RE-007 | Per-condition membership mode | Three-facet `MembershipRecord` | `RecordContracts.Tests.ps1`, `UnknownPropagation.Tests.ps1` |
|
||||
| RE-008 | Combined identity sources | `Get-PersonaRequiredFacets` | `OutcomeExclusivity.Tests.ps1` |
|
||||
| RE-009 | Special accounts by Object ID | Example configuration; no hard-coded path | Purity gate |
|
||||
|
||||
## Validation requirements
|
||||
|
||||
| ID | Requirement | Implementation | Test |
|
||||
| --- | --- | --- | --- |
|
||||
| VR-001 | Four ordered layers, fail-fast | `Test-PersonaConfiguration` | `LayerOrdering.Tests.ps1` |
|
||||
| VR-002 | Sixteen semantic conditions | `Test-PersonaConfigurationSemantic` | `Semantic.Tests.ps1` — one test per code |
|
||||
| VR-003 | Seven safety conditions | `Test-PersonaConfigurationSafety` | `Safety.Tests.ps1`, `TraceAcknowledgement.Tests.ps1` |
|
||||
| VR-004 | Finding shape | `New-PersonaValidationFinding` | `Semantic.Tests.ps1`, `Safety.Tests.ps1`, `RecordContracts.Tests.ps1` |
|
||||
| VR-005 | Warnings block only on request | Editor exit-code mapping | `ExitCodes.Tests.ps1` (editor) |
|
||||
|
||||
## Non-functional requirements
|
||||
|
||||
| ID | Requirement | Implementation | Test |
|
||||
| --- | --- | --- | --- |
|
||||
| NFR-001 | PowerShell 7 | `#Requires -Version 7.2`; manifest floor | Runs on 7.6.5 |
|
||||
| NFR-002 | Caching, per-user and total duration | `New-PersonaDataCache`; stopwatch in `Resolve-UserPersona`; `RunComplete.durationMs` | `AuditCompleteness.Tests.ps1` — **no target set** |
|
||||
| NFR-003 | Pagination, bounded retry, backoff | `Invoke-PersonaGraphRequest` | `RetryPolicy.Tests.ps1`, `Pagination.Tests.ps1` |
|
||||
| NFR-004 | Comment-based help on public functions | Every function in `src/` | Manual review |
|
||||
| NFR-005 | Run ID, UPN, Object ID, config hash | `New-PersonaAuditContext` | `AuditCompleteness.Tests.ps1` |
|
||||
| NFR-006 | Least privilege, single attribute, no secrets | Six OTD-003 controls | `WriteBody.Tests.ps1`, `WriteBodyRejection.Tests.ps1`, sanitization gate |
|
||||
| NFR-007 | Engine and validation run without the platform | Purity; layer dot-sourcing | Purity gate; whole offline suite |
|
||||
| NFR-008 | No Windows PowerShell-only dependencies | `Microsoft.Graph.Authentication` only | **Unverified in Automation (V-5b)** |
|
||||
|
||||
## Success criteria
|
||||
|
||||
| ID | Criterion | Test | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| SC-001 | Exactly one outcome per user | `OutcomeExclusivity.Tests.ps1` | Passing |
|
||||
| SC-002 | Second run proposes zero changes | `Idempotence.Tests.ps1` | Passing |
|
||||
| SC-003 | Determinism across shuffled input | `Determinism.Tests.ps1` | Passing |
|
||||
| SC-004 | Zero writes under `-WhatIf` | `WhatIfZeroWrites.Tests.ps1` | Passing |
|
||||
| SC-005 | Single-attribute request body | `WriteBody.Tests.ps1` | Passing |
|
||||
| SC-006 | Audit completeness | `AuditCompleteness.Tests.ps1` | Passing |
|
||||
| SC-007 | Reconciliation, and its failure path | `Reconciliation.Tests.ps1` | Passing |
|
||||
| SC-008 | Everything runs offline | Whole offline suite; `validate.yml` gate 7 | Passing |
|
||||
| SC-009 | Every VR-002 and VR-003 condition detected | `Semantic.Tests.ps1`, `Safety.Tests.ps1` | Passing |
|
||||
| SC-010 | Non-interactive never prompts or hangs | `NonInteractive.Tests.ps1` | Passing |
|
||||
| SC-011 | Every exit code reachable | `ExitCodes.Tests.ps1` (both) | Passing |
|
||||
| SC-012 | Per-user output is immediate | `Write-UserPersonaResult` emits in `process` | Structural, not timed |
|
||||
| SC-013 | No tenant data committed | `Test-Sanitization.ps1` | Passing |
|
||||
|
||||
## Gaps, stated plainly
|
||||
|
||||
| Item | Why it is not covered | What would close it |
|
||||
| --- | --- | --- |
|
||||
| NFR-002 performance | No target exists until representative tenant testing | A timed run against a real population |
|
||||
| NFR-004 help coverage | Reviewed by eye, not asserted | A test parsing every exported function for a help block |
|
||||
| NFR-008 Automation compatibility | No Automation account available | V-5b (T116) |
|
||||
| SC-012 timing | Asserted structurally, not measured | A timed harness — low value against the cost |
|
||||
| V-1, V-2, V-3 | Require a tenant | Stage A2 and A3 runs |
|
||||
| V-4 | Requires a person | Written security sign-off |
|
||||
| Phase 13 (T115–T121) | Requires an Automation account | Stage B |
|
||||
|
||||
## How to keep this honest
|
||||
|
||||
When a requirement's implementation moves, this table moves with it. When a test is deleted, the row
|
||||
it backed becomes a gap and belongs in the gaps table, not silently in the main one. A matrix that is
|
||||
allowed to drift is worse than none, because it converts "we do not know" into "we checked".
|
||||
@@ -0,0 +1,57 @@
|
||||
# V-5a — `Test-Json -SchemaFile` failure behaviour (local PowerShell)
|
||||
|
||||
**Status**: CLOSED
|
||||
**Date**: 2026-08-20
|
||||
**Environment**: PowerShell 7.6.5, Windows 11, local workstation (Stage A1 — offline, no tenant)
|
||||
**Task**: T061
|
||||
**Pinned by**: [tests/Configuration/TestJsonBehaviour.Tests.ps1](../../../tests/Configuration/TestJsonBehaviour.Tests.ps1)
|
||||
|
||||
## Question
|
||||
|
||||
OTD-005 selected `Test-Json -SchemaFile` for layer 2 validation. The documented risk was that
|
||||
`Test-Json` reports schema failure inconsistently across PowerShell versions — returning `$false`,
|
||||
writing a non-terminating error, or throwing. Layer 2 cannot be written until the actual behaviour
|
||||
on the target build is observed rather than assumed.
|
||||
|
||||
## Observed behaviour
|
||||
|
||||
| Scenario | Return value | Error stream | Terminating? |
|
||||
| --- | --- | --- | --- |
|
||||
| Valid document | `$true` | empty | no |
|
||||
| Type mismatch (`"a": 123` against `"type": "string"`) | `$false` | 1 error: `The JSON is not valid with the schema: Value is "integer" but should be "string" at '/a'` | no |
|
||||
| Missing required property | `$false` | 1 error: `The JSON is not valid with the schema: Required properties ["a"] are not present at ''` | no |
|
||||
| **Schema file itself unparseable** | **`$true`** | 1 error: `Cannot parse the JSON schema.` | no |
|
||||
|
||||
Exception type on the error record is `System.Exception` in every failing case — there is no
|
||||
distinct exception type to branch on, so the wrapper must branch on the message text or, better, on
|
||||
error presence alone.
|
||||
|
||||
## Findings that shape the implementation
|
||||
|
||||
1. **Non-terminating, not throwing.** With the default `$ErrorActionPreference = 'Continue'` the
|
||||
cmdlet writes to the error stream and execution continues, returning `$false`. It does not throw.
|
||||
`-ErrorAction SilentlyContinue -ErrorVariable` is therefore sufficient to capture failures, as
|
||||
the editor contract requires.
|
||||
|
||||
2. **An unparseable schema returns `$true`.** This is the load-bearing observation. A wrapper that
|
||||
trusted the return value alone would report a configuration as schema-valid when the schema never
|
||||
ran. Layer 2 MUST treat "error variable is non-empty" as failure regardless of the return value,
|
||||
and MUST distinguish the `Cannot parse the JSON schema.` message so it can surface exit code 4
|
||||
(schema file not found or itself invalid) rather than exit code 1 (configuration invalid).
|
||||
|
||||
3. **One error per violating location, but not exhaustive.** Two independent property violations
|
||||
yield two error records, each with its own JSON pointer. Deeper or nested subschema failures may
|
||||
still be reported as a single error at the outermost failing location. The wrapper therefore
|
||||
emits one finding per collected error rather than assuming a single one, and the author may still
|
||||
need more than one validation pass to see everything. That residual limitation is documented
|
||||
rather than worked around — full violation reporting would require replacing `Test-Json` with a
|
||||
third-party validator, which OTD-005 rejected.
|
||||
|
||||
## Consequences recorded elsewhere
|
||||
|
||||
- Layer 2 implementation: [src/Configuration/Test-PersonaConfiguration.ps1](../../../src/Configuration/Test-PersonaConfiguration.ps1)
|
||||
- Regression pin: `tests/Configuration/TestJsonBehaviour.Tests.ps1` fails if a future PowerShell
|
||||
build changes any row of the table above.
|
||||
- **V-5b remains open**: this observation is for PowerShell 7.6.5 only. The Azure Automation runtime
|
||||
version is unverified, and finding 2 in particular is version-sensitive. Re-run this probe there
|
||||
before Stage B (T116).
|
||||
@@ -0,0 +1,76 @@
|
||||
# Sanitization scan result (SC-013)
|
||||
|
||||
**Status**: PASS
|
||||
**Date**: 2026-08-20
|
||||
**Task**: T113
|
||||
**Scanner**: [tests/Test-Sanitization.ps1](../../../tests/Test-Sanitization.ps1)
|
||||
**Files scanned**: 156
|
||||
|
||||
## What is scanned
|
||||
|
||||
`git ls-files --cached --others --exclude-standard` — tracked files **and** untracked files that are
|
||||
not gitignored.
|
||||
|
||||
The original scanner walked `git ls-files` alone, which covered only tracked files. That made the
|
||||
gate useless where it matters most: a leaked identifier in a file that has not been committed yet is
|
||||
precisely the one worth catching, and scanning only what is already in history means the scan passes
|
||||
right up until the commit that makes it too late. At the time this was found, the scan was covering
|
||||
34 of the repository's 156 files and none of the implementation written in this phase.
|
||||
|
||||
`--exclude-standard` keeps gitignored build output and local scratch files out, so the scan covers
|
||||
exactly what a commit would add.
|
||||
|
||||
## Patterns
|
||||
|
||||
| Pattern | Exemptions |
|
||||
| --- | --- |
|
||||
| GUIDs | Placeholder-shaped GUIDs (`00000000-0000-0000-0000-0000000000a0`); the module manifest's own `GUID =` identity line |
|
||||
| Email addresses and UPNs | RFC 2606 / RFC 6761 reserved domains: `example.com/net/org`, `.invalid`, `.test`, `.localhost` |
|
||||
| `onmicrosoft.com` domains | none |
|
||||
| JWT and bearer-token shapes | none |
|
||||
| Assigned secret, password, or key literals | none |
|
||||
| PEM private key blocks | none |
|
||||
|
||||
Two exemptions were added during this scan, both narrow and both for things that cannot be replaced
|
||||
with a placeholder:
|
||||
|
||||
**Reserved domains.** `alex.employee@example.invalid` is guaranteed by RFC to be unresolvable.
|
||||
Rejecting reserved domains would push fixtures toward addresses that merely *look* fake, which is
|
||||
worse — the difference between "obviously synthetic" and "probably nobody's" is the entire reason the
|
||||
reserved list exists.
|
||||
|
||||
**The module manifest GUID.** A PowerShell module manifest must carry a genuine unique GUID as its
|
||||
identity; it is what distinguishes this module from another of the same name. It identifies the
|
||||
module, not a tenant. The exemption is **line-level** (`^\s*GUID\s*=`), not file-level: exempting the
|
||||
whole manifest would let a real identifier land anywhere in it.
|
||||
|
||||
## Verification of the scanner itself
|
||||
|
||||
A negative control was run: a scratch file containing an email address on a real-world commercial
|
||||
domain and a randomly generated real-shaped GUID was added to the working tree **without** committing
|
||||
it. The scan failed with two findings and named both, by file and line. The file was then removed and
|
||||
the scan returned to PASS.
|
||||
|
||||
The offending values are described here rather than quoted, because quoting them would make this
|
||||
record itself a finding — which the scan promptly demonstrated when an earlier draft did exactly
|
||||
that. That is the control working.
|
||||
|
||||
Without a negative control, a scanner that had silently stopped matching would report the same green
|
||||
result as one that is working.
|
||||
|
||||
## Result
|
||||
|
||||
```
|
||||
Sanitization scan passed: no tenant data, credentials, or real identifiers found.
|
||||
```
|
||||
|
||||
## Standing obligations
|
||||
|
||||
This is a point-in-time result, not a property of the repository. The scan is **gate 1** of
|
||||
[pipelines/validate.yml](../../../pipelines/validate.yml) and runs before every other gate on every
|
||||
pull request — deliberately first, because a leaked identifier is a problem whether or not the code
|
||||
compiles, and every later gate prints file contents into build logs.
|
||||
|
||||
Runtime audit records legitimately contain real UPNs and Object IDs, which are approved for logs. No
|
||||
such value may ever be committed. When attaching evidence to a verification record (V-1, V-2, V-3),
|
||||
redact identifiers to placeholders first.
|
||||
@@ -0,0 +1,59 @@
|
||||
function Export-PersonaRunReport {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds the RunComplete record closing out a run (FR-022, NFR-005).
|
||||
|
||||
.DESCRIPTION
|
||||
The last record of every run, successful or not. It carries the final
|
||||
counters, the wall-clock span, and the exit code the process returned.
|
||||
|
||||
Emitted even on a fatal error. A run that died at user 400 of 5000 leaves a
|
||||
RunComplete saying exactly that, which is what lets an operator tell "the
|
||||
engine stopped early" from "the engine never started" - two very different
|
||||
incidents that produce identical evidence if the record is written only on
|
||||
success.
|
||||
|
||||
startedUtc and completedUtc are wall-clock, unlike per-user durations, which
|
||||
use a monotonic stopwatch. They are here for correlation with other systems'
|
||||
logs, never as an input to a decision.
|
||||
|
||||
.PARAMETER Context
|
||||
The audit context.
|
||||
|
||||
.PARAMETER Counters
|
||||
The final run counters.
|
||||
|
||||
.PARAMETER StartedUtc
|
||||
Run start timestamp.
|
||||
|
||||
.PARAMETER ExitCode
|
||||
The exit code the run will return (0 - 6).
|
||||
|
||||
.OUTPUTS
|
||||
An ordered dictionary ready for Write-PersonaAuditRecord.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Context,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Counters,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[datetime] $StartedUtc,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateRange(0, 6)]
|
||||
[int] $ExitCode
|
||||
)
|
||||
|
||||
$completed = [DateTime]::UtcNow
|
||||
|
||||
New-PersonaAuditRecord -Context $Context -RecordType 'RunComplete' -Counters $Counters -Properties @{
|
||||
startedUtc = $StartedUtc.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
completedUtc = $completed.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
durationMs = [int]($completed - $StartedUtc).TotalMilliseconds
|
||||
exitCode = $ExitCode
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
function New-PersonaAuditContext {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates the constant envelope shared by every audit record in a run.
|
||||
|
||||
.DESCRIPTION
|
||||
Run ID, engine version, configuration version, configuration hash, and mode
|
||||
are identical on every record (NFR-005). Building them once and carrying the
|
||||
context means no call site can emit a record missing them, and no call site
|
||||
can disagree about the mode.
|
||||
|
||||
.PARAMETER RunId
|
||||
The run identifier, from -CorrelationId or generated.
|
||||
|
||||
.PARAMETER EngineVersion
|
||||
Module version.
|
||||
|
||||
.PARAMETER Configuration
|
||||
The loaded configuration, source of configVersion and configurationHash.
|
||||
|
||||
.PARAMETER Mode
|
||||
Preview or Enforce, derived by the caller from ShouldProcess alone.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.AuditContext
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $RunId,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $EngineVersion,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Configuration,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('Preview', 'Enforce')]
|
||||
[string] $Mode
|
||||
)
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.AuditContext'
|
||||
RunId = $RunId
|
||||
EngineVersion = $EngineVersion
|
||||
ConfigVersion = [string]$Configuration.ConfigVersion
|
||||
ConfigurationHash = [string]$Configuration.ConfigurationHash
|
||||
Mode = $Mode
|
||||
}
|
||||
}
|
||||
|
||||
function New-PersonaAuditRecord {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds a structured audit record of the requested type (FR-022, NFR-005).
|
||||
|
||||
.DESCRIPTION
|
||||
Every record type shares the common envelope from contracts/audit-record.md
|
||||
and adds its own fields. One builder rather than five keeps the envelope in
|
||||
a single place, so a field added to it appears on every record type without
|
||||
five separate edits.
|
||||
|
||||
Prohibited content - tokens, Authorization headers, secrets, raw Graph
|
||||
responses - is not merely undocumented here, it is unreachable: this
|
||||
function accepts only named, typed values from the decision result and the
|
||||
counters. There is no pass-through of an arbitrary object, so there is
|
||||
nothing for a secret to ride in on (Principle V).
|
||||
|
||||
.PARAMETER Context
|
||||
The audit context from New-PersonaAuditContext.
|
||||
|
||||
.PARAMETER RecordType
|
||||
RunStart, UserEvent, Summary, RunComplete, or EngineDefect.
|
||||
|
||||
.PARAMETER Result
|
||||
For UserEvent: the PersonaDecisionResult.
|
||||
|
||||
.PARAMETER PreviousValue
|
||||
For UserEvent: the value captured at write time on an Updated record.
|
||||
|
||||
.PARAMETER Counters
|
||||
For Summary and RunComplete: the run counter object.
|
||||
|
||||
.PARAMETER IncludeTrace
|
||||
Emits conditionTrace on a UserEvent. Gated by the caller on -Debug or
|
||||
logging.traceConditionValues, never enabled by default (VR-003).
|
||||
|
||||
.PARAMETER Properties
|
||||
Additional fields for RunStart, RunComplete, and EngineDefect.
|
||||
|
||||
.OUTPUTS
|
||||
System.Collections.Specialized.OrderedDictionary - ordered so serialized
|
||||
records list their fields in the documented sequence on every run.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Context,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('RunStart', 'UserEvent', 'Summary', 'RunComplete', 'EngineDefect')]
|
||||
[string] $RecordType,
|
||||
|
||||
[object] $Result,
|
||||
|
||||
[AllowNull()]
|
||||
[AllowEmptyString()]
|
||||
[string] $PreviousValue,
|
||||
|
||||
[object] $Counters,
|
||||
|
||||
[switch] $IncludeTrace,
|
||||
|
||||
[hashtable] $Properties = @{}
|
||||
)
|
||||
|
||||
$record = [ordered]@{
|
||||
timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
recordType = $RecordType
|
||||
runId = $Context.RunId
|
||||
engineVersion = $Context.EngineVersion
|
||||
configVersion = $Context.ConfigVersion
|
||||
configurationHash = $Context.ConfigurationHash
|
||||
mode = $Context.Mode
|
||||
}
|
||||
|
||||
switch ($RecordType) {
|
||||
|
||||
'UserEvent' {
|
||||
if ($null -eq $Result) { throw 'A UserEvent record requires -Result.' }
|
||||
|
||||
$record['accountObjectId'] = [string]$Result.AccountObjectId
|
||||
$record['userPrincipalName'] = [string]$Result.UserPrincipalName
|
||||
$record['outcome'] = [string]$Result.Outcome
|
||||
$record['matchedRuleId'] = $Result.MatchedRuleId
|
||||
$record['storedPersona'] = $Result.StoredPersona
|
||||
$record['calculatedPersona'] = $Result.CalculatedPersona
|
||||
|
||||
# Present only on Updated. On any other action there is nothing that was
|
||||
# replaced, and a populated previousValue would imply otherwise to a
|
||||
# rollback tool reading these records later.
|
||||
$record['previousValue'] = ($Result.Action -eq 'Updated') ? $PreviousValue : $null
|
||||
|
||||
$record['action'] = [string]$Result.Action
|
||||
$record['rulesEvaluated'] = [int]$Result.RulesEvaluated
|
||||
$record['durationMs'] = [int]$Result.DurationMs
|
||||
$record['evaluationErrorReason'] = $Result.EvaluationErrorReason
|
||||
|
||||
if ($IncludeTrace -and $Result.ConditionTrace) {
|
||||
$record['conditionTrace'] = @(
|
||||
foreach ($entry in $Result.ConditionTrace) {
|
||||
[ordered]@{
|
||||
ruleId = [string]$entry.RuleId
|
||||
priority = [int]$entry.Priority
|
||||
result = [string]$entry.Result
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
'Summary' {
|
||||
if ($null -eq $Counters) { throw 'A Summary record requires -Counters.' }
|
||||
|
||||
$record['summaryType'] = $Properties.ContainsKey('summaryType') ? [string]$Properties['summaryType'] : 'Interim'
|
||||
Add-PersonaCounterField -Record $record -Counters $Counters
|
||||
$record['ruleCounts'] = @(
|
||||
foreach ($entry in $Counters.RuleCounts) {
|
||||
[ordered]@{
|
||||
ruleId = [string]$entry.RuleId
|
||||
name = [string]$entry.Name
|
||||
enabled = [bool]$entry.Enabled
|
||||
matches = [int]$entry.Matches
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
'RunComplete' {
|
||||
if ($null -eq $Counters) { throw 'A RunComplete record requires -Counters.' }
|
||||
|
||||
$record['startedUtc'] = $Properties['startedUtc']
|
||||
$record['completedUtc'] = $Properties['completedUtc']
|
||||
$record['durationMs'] = [int]$Properties['durationMs']
|
||||
Add-PersonaCounterField -Record $record -Counters $Counters
|
||||
$record['exitCode'] = [int]$Properties['exitCode']
|
||||
}
|
||||
|
||||
default {
|
||||
# RunStart and EngineDefect carry only the envelope plus whatever the
|
||||
# caller names explicitly.
|
||||
foreach ($key in $Properties.Keys) { $record[$key] = $Properties[$key] }
|
||||
}
|
||||
}
|
||||
|
||||
$record
|
||||
}
|
||||
|
||||
function Add-PersonaCounterField {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Adds the shared counter block to a Summary or RunComplete record.
|
||||
|
||||
.DESCRIPTION
|
||||
Summary and RunComplete carry the same counters. Sharing the block means the
|
||||
two record types cannot drift apart, which matters because reconciliation
|
||||
tooling reads both.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Record,
|
||||
[Parameter(Mandatory)] [object] $Counters
|
||||
)
|
||||
|
||||
$Record['processed'] = [int]$Counters.Processed
|
||||
$Record['matched'] = [int]$Counters.Matched
|
||||
$Record['unclassified'] = [int]$Counters.Unclassified
|
||||
$Record['evaluationError'] = [int]$Counters.EvaluationError
|
||||
$Record['unchanged'] = [int]$Counters.Unchanged
|
||||
$Record['wouldUpdate'] = [int]$Counters.WouldUpdate
|
||||
$Record['updated'] = [int]$Counters.Updated
|
||||
$Record['updateFailed'] = [int]$Counters.UpdateFailed
|
||||
$Record['skipped'] = [int]$Counters.Skipped
|
||||
$Record['reconciliationPassed'] = [bool](Test-PersonaReconciliation -Counters $Counters)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
function Write-PersonaAuditRecord {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
The single emission point for audit records (FR-022, OTD-006).
|
||||
|
||||
.DESCRIPTION
|
||||
Serializes one record as newline-delimited JSON and emits it to file, to the
|
||||
object stream, or both, per logging.destination.
|
||||
|
||||
Every audit record in the engine passes through here. That is the whole
|
||||
design: adding a transport - an approved logging platform, an event hub, a
|
||||
different file layout - is a change to this function and nothing else. If
|
||||
call sites wrote their own output, each new transport would mean auditing
|
||||
every call site again, and the one that got missed would be silent.
|
||||
|
||||
Emission failure never ends the run. A full disk or a locked file is an
|
||||
operational problem with the audit sink, not a reason to abandon a
|
||||
classification run mid-population and leave the directory in a half-reconciled
|
||||
state. The failure is surfaced as a warning, once, and processing continues.
|
||||
|
||||
.PARAMETER Record
|
||||
An ordered dictionary from New-PersonaAuditRecord.
|
||||
|
||||
.PARAMETER Destination
|
||||
file, stream, both, or none.
|
||||
|
||||
.PARAMETER Path
|
||||
Output file for the file and both destinations.
|
||||
|
||||
.PARAMETER State
|
||||
Optional sink state carrying the one-warning latch, so a failing sink warns
|
||||
once per run rather than once per user.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory, ValueFromPipeline)]
|
||||
[object] $Record,
|
||||
|
||||
[ValidateSet('file', 'stream', 'both', 'none')]
|
||||
[string] $Destination = 'stream',
|
||||
|
||||
[string] $Path,
|
||||
|
||||
[object] $State
|
||||
)
|
||||
|
||||
process {
|
||||
if ($Destination -eq 'none') { return }
|
||||
|
||||
if ($Destination -in @('stream', 'both')) {
|
||||
# The Information stream, not the success stream. Audit records emitted
|
||||
# onto the success stream would be indistinguishable from a function's
|
||||
# return value: the run loop returns its outcome there, and mixing the two
|
||||
# would turn one object into an array of several thousand.
|
||||
#
|
||||
# The record object is emitted, not a string, so a caller capturing it
|
||||
# with -InformationVariable can assert on fields without reparsing.
|
||||
Write-Information -MessageData $Record -Tags 'PersonaEngine.Audit'
|
||||
}
|
||||
|
||||
if ($Destination -in @('file', 'both')) {
|
||||
if (-not $Path) {
|
||||
Write-Warning 'logging.destination requests file output but no path is configured. No audit file was written.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
$line = $Record | ConvertTo-Json -Depth 16 -Compress
|
||||
|
||||
$directory = Split-Path -Parent $Path
|
||||
if ($directory -and -not (Test-Path -LiteralPath $directory)) {
|
||||
$null = New-Item -ItemType Directory -Path $directory -Force -WhatIf:$false -Confirm:$false
|
||||
}
|
||||
|
||||
# Append, one record per line. UTF-8 without BOM so the file is
|
||||
# machine-readable by any NDJSON consumer.
|
||||
#
|
||||
# -WhatIf:$false / -Confirm:$false pin this call regardless of any
|
||||
# ambient $WhatIfPreference in the caller's session (e.g. left set by
|
||||
# dot-sourcing an earlier -WhatIf run). Add-Content honours
|
||||
# ShouldProcess, and this sink is not optional under -WhatIf - the
|
||||
# audit trail is what makes preview mode auditable at all, so it must
|
||||
# write unconditionally, independent of anything the caller's scope
|
||||
# happens to have set.
|
||||
Add-Content -LiteralPath $Path -Value $line -Encoding utf8NoBOM -ErrorAction Stop -WhatIf:$false -Confirm:$false
|
||||
}
|
||||
catch {
|
||||
if ($null -ne $State -and $State.FileSinkFailed) { return }
|
||||
if ($null -ne $State) { $State.FileSinkFailed = $true }
|
||||
|
||||
Write-Warning "Audit file sink failed; the run continues without file output: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function New-PersonaAuditSinkState {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates the per-run sink state for Write-PersonaAuditRecord.
|
||||
|
||||
.DESCRIPTION
|
||||
Holds the latch that keeps a failing file sink from emitting one warning per
|
||||
user. A run over five thousand accounts with a locked log file should warn
|
||||
once, not five thousand times, or the warning that matters is buried in the
|
||||
noise it generates.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param()
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.AuditSinkState'
|
||||
FileSinkFailed = $false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
function Connect-PersonaGraphInteractive {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Establishes a delegated Microsoft Graph connection for local development.
|
||||
|
||||
.DESCRIPTION
|
||||
Stage A2 authentication (plan.md). Requests only the scopes the enabled
|
||||
rules require (OTD-002) — never Directory.Read.All, which is materially
|
||||
broader than the three read scopes combined.
|
||||
|
||||
Returns an opaque handle. No token, header, or secret is ever returned to a
|
||||
caller, logged, or written to an audit record (Principle V).
|
||||
|
||||
Note on V-3: effective access is the intersection of the requested scope and
|
||||
the signed-in account's directory roles. Signing in as a Global Administrator
|
||||
makes the least-privilege test meaningless, because the account's roles grant
|
||||
broad read regardless of the scope requested.
|
||||
|
||||
.PARAMETER IncludeWrite
|
||||
Adds User.ReadWrite.All. Enforcement only, and only after the V-4 security
|
||||
sign-off is recorded.
|
||||
|
||||
.PARAMETER TenantId
|
||||
Optional tenant hint.
|
||||
|
||||
.EXAMPLE
|
||||
Connect-PersonaGraphInteractive
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[switch] $IncludeWrite,
|
||||
[string] $TenantId,
|
||||
[switch] $IncludeGroups,
|
||||
[switch] $IncludeRoles
|
||||
)
|
||||
|
||||
$scopes = [System.Collections.Generic.List[string]]::new()
|
||||
$scopes.Add($IncludeWrite ? 'User.ReadWrite.All' : 'User.Read.All')
|
||||
if ($IncludeGroups) { $scopes.Add('GroupMember.Read.All') }
|
||||
if ($IncludeRoles) { $scopes.Add('RoleManagement.Read.Directory') }
|
||||
|
||||
$connectArgs = @{ Scopes = $scopes.ToArray(); NoWelcome = $true; ErrorAction = 'Stop' }
|
||||
if ($TenantId) { $connectArgs['TenantId'] = $TenantId }
|
||||
|
||||
Write-Verbose "Connecting to Microsoft Graph with scopes: $($scopes -join ', ')"
|
||||
Connect-MgGraph @connectArgs | Out-Null
|
||||
|
||||
$context = Get-MgContext
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.GraphConnection'
|
||||
AuthType = 'Delegated'
|
||||
Account = $context.Account
|
||||
TenantId = $context.TenantId
|
||||
Scopes = @($context.Scopes)
|
||||
WriteCapable = [bool]$IncludeWrite
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
function Add-PersonaConditionNode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Appends a condition or nested group to a group in a rule's condition tree (FR-028).
|
||||
|
||||
.DESCRIPTION
|
||||
Locates the group at ParentPath and appends Node to its `conditions`
|
||||
collection. Structural checks only - id/priority-style collisions do not apply
|
||||
to conditions, and nesting-depth enforcement is deliberately not duplicated
|
||||
here.
|
||||
|
||||
Depth is enforced by re-running the real validator (VR-001) against the whole
|
||||
candidate document after the edit, the same way a hand-edited file would be
|
||||
checked - see Edit-PersonaEngineConfig.ps1's candidate-edit wrapper. A second,
|
||||
local depth calculation here would risk drifting from Test-PersonaConditionGroup's
|
||||
engine semantics (RE-004), which count nesting by group levels, not by leaf
|
||||
conditions - the exact mismatch VR-002's own depth check is careful to avoid
|
||||
(see Test-PersonaConfigurationSemantic.ps1).
|
||||
|
||||
.PARAMETER Group
|
||||
The rule's `match` condition group (the root).
|
||||
|
||||
.PARAMETER ParentPath
|
||||
Path (see Get-PersonaConditionNode) to the group to append into. Empty selects
|
||||
the root.
|
||||
|
||||
.PARAMETER Node
|
||||
The condition or condition group to append.
|
||||
|
||||
.OUTPUTS
|
||||
System.Object - the same Group, mutated in place, returned for convenience.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([object])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Group,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
[int[]] $ParentPath,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Node
|
||||
)
|
||||
|
||||
$parent = Get-PersonaConditionNode -Group $Group -Path $ParentPath
|
||||
|
||||
if ($null -eq $parent.PSObject.Properties['conditions']) {
|
||||
throw 'The target node is a leaf condition, not a group. Choose a group (all/any) to add into.'
|
||||
}
|
||||
|
||||
$parent.conditions = @($parent.conditions) + $Node
|
||||
|
||||
$Group
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
function Add-PersonaConfigRule {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Appends a new rule to a rules collection, rejecting an id/priority collision (FR-027).
|
||||
|
||||
.DESCRIPTION
|
||||
Pure: takes the existing rules and a candidate rule, returns a new array. Never
|
||||
mutates the input collection, so a caller that discards the result on a
|
||||
validation failure has changed nothing.
|
||||
|
||||
The id/priority check is duplicated with PE-SEM-001/PE-SEM-002 by design - the
|
||||
interactive editor should not need a full validation round trip just to tell an
|
||||
operator they typed an id that already exists. Anything that reaches the
|
||||
semantic layer anyway (for example a collision introduced by two concurrent
|
||||
edits) is still caught there.
|
||||
|
||||
.PARAMETER Rules
|
||||
The rule collection to append to. Not mutated.
|
||||
|
||||
.PARAMETER Rule
|
||||
The candidate rule. Must carry non-blank `id` and a `priority`.
|
||||
|
||||
.OUTPUTS
|
||||
System.Object[] - the new rules collection, existing rules first.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([object[]])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
[object[]] $Rules,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Rule
|
||||
)
|
||||
|
||||
$id = [string]$Rule.id
|
||||
if ([string]::IsNullOrWhiteSpace($id)) {
|
||||
throw 'The new rule requires a non-blank id.'
|
||||
}
|
||||
|
||||
$existing = @($Rules)
|
||||
|
||||
$byId = @($existing | Where-Object { [string]$_.id -eq $id })
|
||||
if ($byId.Count -gt 0) {
|
||||
throw "Rule id '$id' already exists (priority $($byId[0].priority)). Rule ids must be unique (RE-002)."
|
||||
}
|
||||
|
||||
$priority = $Rule.priority
|
||||
$byPriority = @($existing | Where-Object { [int]$_.priority -eq [int]$priority })
|
||||
if ($byPriority.Count -gt 0) {
|
||||
throw "Priority $priority is already used by rule '$($byPriority[0].id)'. Priorities must be unique (RE-002)."
|
||||
}
|
||||
|
||||
, ($existing + $Rule)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
function Get-PersonaConditionNode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Navigates a rule's condition tree to the node at a path (FR-028).
|
||||
|
||||
.DESCRIPTION
|
||||
A path is a sequence of zero-based indices into successive `.conditions`
|
||||
collections, root first. An empty path returns the root group itself.
|
||||
|
||||
The returned node is the same object reference that lives inside the tree -
|
||||
deliberately. `ConvertFrom-Json` produces `PSCustomObject`/array trees of
|
||||
reference types, so a caller that sets a property on the returned leaf (for
|
||||
example `$node.value = 'x'`) mutates the tree in place. Editing a leaf's
|
||||
fields is therefore a plain property assignment, not a separate setter
|
||||
function; see Set-PersonaConditionLeaf for the one case (switching a
|
||||
condition's operator/type) where stale fields must also be cleared.
|
||||
|
||||
.PARAMETER Group
|
||||
The rule's `match` condition group (the root).
|
||||
|
||||
.PARAMETER Path
|
||||
Zero-based indices from the root, one per nesting level. Empty selects the root.
|
||||
|
||||
.OUTPUTS
|
||||
System.Object - the condition or condition group at that path.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([object])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Group,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
[int[]] $Path
|
||||
)
|
||||
|
||||
$node = $Group
|
||||
|
||||
foreach ($index in $Path) {
|
||||
if ($null -eq $node.PSObject.Properties['conditions'] -or $null -eq $node.conditions) {
|
||||
throw "Path segment $index does not resolve: the node above it is a leaf condition, not a group."
|
||||
}
|
||||
|
||||
$children = @($node.conditions)
|
||||
if ($index -lt 0 -or $index -ge $children.Count) {
|
||||
throw "Path segment $index is out of range - this group has $($children.Count) condition(s)."
|
||||
}
|
||||
|
||||
$node = $children[$index]
|
||||
}
|
||||
|
||||
$node
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
function Get-PersonaRequiredFacets {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Determines which membership facets the enabled rules actually need.
|
||||
|
||||
.DESCRIPTION
|
||||
Least privilege applied to data retrieval: a configuration with no role
|
||||
conditions never calls the role endpoint, so a tenant where role reads are
|
||||
unavailable can still run property-only rules.
|
||||
|
||||
Walks every enabled rule's condition tree. A membership condition resolves
|
||||
to the direct or transitive facet by its own membershipMode, falling back to
|
||||
the configured default (RE-007).
|
||||
|
||||
.PARAMETER Rules
|
||||
The business rule collection.
|
||||
|
||||
.PARAMETER DefaultMembershipMode
|
||||
Mode for conditions that do not specify one.
|
||||
|
||||
.OUTPUTS
|
||||
A hashtable with Direct, Transitive, and Roles boolean keys.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([hashtable])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
[object[]] $Rules,
|
||||
|
||||
[ValidateSet('Direct', 'Transitive')]
|
||||
[string] $DefaultMembershipMode = 'Direct'
|
||||
)
|
||||
|
||||
$need = @{ Direct = $false; Transitive = $false; Roles = $false }
|
||||
|
||||
function Test-Node {
|
||||
param($Node)
|
||||
if ($null -eq $Node) { return }
|
||||
|
||||
if ($Node.PSObject.Properties['conditions'] -and $Node.conditions) {
|
||||
foreach ($child in $Node.conditions) { Test-Node $child }
|
||||
return
|
||||
}
|
||||
|
||||
$type = [string]$Node.type
|
||||
|
||||
if ($type -eq 'role') {
|
||||
$need.Roles = $true
|
||||
return
|
||||
}
|
||||
|
||||
if ($type -eq 'membership') {
|
||||
$mode = $Node.PSObject.Properties['membershipMode'] -and $Node.membershipMode `
|
||||
? [string]$Node.membershipMode `
|
||||
: $DefaultMembershipMode
|
||||
|
||||
if ($mode -ieq 'transitive') { $need.Transitive = $true } else { $need.Direct = $true }
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rule in @($Rules)) {
|
||||
if (-not $rule.enabled) { continue }
|
||||
Test-Node $rule.match
|
||||
}
|
||||
|
||||
$need
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
function Import-PersonaConfiguration {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Loads a JSON configuration file into the object shape the engine consumes.
|
||||
|
||||
.DESCRIPTION
|
||||
Layer 1 of validation (syntax) happens implicitly here: malformed JSON
|
||||
throws. Layers 2-4 are Test-PersonaConfiguration's responsibility, and the
|
||||
caller runs them before using the result (FR-002).
|
||||
|
||||
Computes ConfigurationHash as the SHA-256 of the file bytes, which is
|
||||
recorded on every audit record (NFR-005) so a run can be tied to the exact
|
||||
configuration that produced it.
|
||||
|
||||
.PARAMETER Path
|
||||
Path to the JSON configuration file.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.Configuration
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $Path
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||
throw "Configuration file not found: '$Path'."
|
||||
}
|
||||
|
||||
$raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
|
||||
|
||||
try {
|
||||
$document = $raw | ConvertFrom-Json -Depth 32 -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
throw "Configuration is not valid JSON: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Hash the file bytes rather than the parsed object: two files that differ only
|
||||
# in whitespace are different configurations for audit purposes, and the hash
|
||||
# must be reproducible from the artifact on disk.
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$bytes = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $Path).ProviderPath)
|
||||
$hash = [System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
finally {
|
||||
$sha.Dispose()
|
||||
}
|
||||
|
||||
$engine = $document.engine
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.Configuration'
|
||||
ConfigVersion = [string]$document.configVersion
|
||||
ConfigurationHash = $hash
|
||||
SourcePath = (Resolve-Path -LiteralPath $Path).ProviderPath
|
||||
|
||||
TargetAttribute = [string]$engine.targetAttribute
|
||||
ApprovedWritableAttributes = @($engine.approvedWritableAttributes)
|
||||
MaxConditionDepth = ($null -ne $engine.maxConditionDepth) ? [int]$engine.maxConditionDepth : 5
|
||||
SummaryInterval = ($null -ne $engine.summaryInterval) ? [int]$engine.summaryInterval : 25
|
||||
DefaultMembershipMode = $engine.defaultMembershipMode ? (Get-Culture).TextInfo.ToTitleCase([string]$engine.defaultMembershipMode) : 'Direct'
|
||||
EvaluationErrorThreshold = ($null -ne $engine.evaluationErrorThreshold) ? [int]$engine.evaluationErrorThreshold : $null
|
||||
|
||||
DataSources = $document.dataSources
|
||||
Logging = $document.logging
|
||||
Personas = @($document.personas)
|
||||
Rules = @($document.rules)
|
||||
|
||||
Raw = $document
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
function New-PersonaValidationFinding {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a structured validation finding (VR-004).
|
||||
|
||||
.DESCRIPTION
|
||||
Every one of the four validation layers emits this shape, so a caller —
|
||||
console, pipeline, or editor — handles findings uniformly regardless of
|
||||
which layer produced them.
|
||||
|
||||
Finding codes are stable and namespaced by layer, because pipelines and
|
||||
runbooks will match on them:
|
||||
|
||||
PE-SYN-nnn syntax
|
||||
PE-SCH-nnn schema
|
||||
PE-SEM-nnn semantic
|
||||
PE-SAF-nnn safety
|
||||
|
||||
.PARAMETER Severity
|
||||
Error blocks execution and saving. Warning blocks only under
|
||||
-TreatWarningsAsErrors. Information never blocks (VR-005).
|
||||
|
||||
.PARAMETER Code
|
||||
Stable finding code, e.g. PE-SEM-012.
|
||||
|
||||
.PARAMETER Location
|
||||
JSON path or rule ID identifying where the problem is.
|
||||
|
||||
.PARAMETER SuggestedResolution
|
||||
What the author should do. Required — a finding without a resolution just
|
||||
tells someone they are wrong.
|
||||
|
||||
.EXAMPLE
|
||||
New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-002' -Location 'rules[3].priority' -Description 'Duplicate priority 30.' -SuggestedResolution 'Assign a unique priority.'
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('Error', 'Warning', 'Information')]
|
||||
[string] $Severity,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^PE-(SYN|SCH|SEM|SAF)-\d{3}$')]
|
||||
[string] $Code,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $Location,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $Description,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $SuggestedResolution
|
||||
)
|
||||
|
||||
# The layer is derivable from the code, so it cannot drift out of agreement
|
||||
# with it.
|
||||
$layer = switch -Regex ($Code) {
|
||||
'^PE-SYN-' { 'Syntax' }
|
||||
'^PE-SCH-' { 'Schema' }
|
||||
'^PE-SEM-' { 'Semantic' }
|
||||
'^PE-SAF-' { 'Safety' }
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.ValidationFinding'
|
||||
Severity = $Severity
|
||||
Code = $Code
|
||||
Location = $Location
|
||||
Description = $Description
|
||||
SuggestedResolution = $SuggestedResolution
|
||||
Layer = $layer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
function Remove-PersonaConditionNode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Removes a condition or nested group from a rule's condition tree (FR-028).
|
||||
|
||||
.DESCRIPTION
|
||||
Two structural rules are enforced here rather than left to schema validation,
|
||||
because both would otherwise produce a document the schema calls invalid with
|
||||
no indication which editor action caused it:
|
||||
|
||||
- The root group itself cannot be removed - a rule's `match` is required
|
||||
(RE-001). Delete the rule instead (Remove-PersonaConfigRule).
|
||||
- A group's last remaining condition cannot be removed - an empty
|
||||
`conditions` array violates the schema's `minItems: 1` on conditionGroup.
|
||||
Remove the parent group instead, or add a replacement first.
|
||||
|
||||
.PARAMETER Group
|
||||
The rule's `match` condition group (the root).
|
||||
|
||||
.PARAMETER Path
|
||||
Path (see Get-PersonaConditionNode) to the node to remove. Must not be empty.
|
||||
|
||||
.OUTPUTS
|
||||
System.Object - the same Group, mutated in place, returned for convenience.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([object])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Group,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
[int[]] $Path
|
||||
)
|
||||
|
||||
if (@($Path).Count -eq 0) {
|
||||
throw "The root condition group cannot be removed this way - delete the rule instead."
|
||||
}
|
||||
|
||||
# Special-cased at length 1 because PowerShell's range operator treats 0..-1 as
|
||||
# the two-element descending range (0, -1), not an empty range - the naive slice
|
||||
# would silently pick up the wrong parent for a top-level path.
|
||||
$parentPath = (@($Path).Count -eq 1) ? @() : @($Path)[0..($Path.Count - 2)]
|
||||
$index = @($Path)[-1]
|
||||
|
||||
$parent = Get-PersonaConditionNode -Group $Group -Path $parentPath
|
||||
$children = @($parent.conditions)
|
||||
|
||||
if ($index -lt 0 -or $index -ge $children.Count) {
|
||||
throw "Path segment $index is out of range - this group has $($children.Count) condition(s)."
|
||||
}
|
||||
|
||||
if ($children.Count -eq 1) {
|
||||
throw 'This is the only condition in its group. A group requires at least one condition - remove the group itself instead, or add a replacement first.'
|
||||
}
|
||||
|
||||
# Removed by position, not by value or reference - two structurally identical
|
||||
# sibling conditions are otherwise indistinguishable.
|
||||
$remaining = [System.Collections.Generic.List[object]]::new()
|
||||
for ($i = 0; $i -lt $children.Count; $i++) {
|
||||
if ($i -ne $index) { $remaining.Add($children[$i]) }
|
||||
}
|
||||
$parent.conditions = $remaining.ToArray()
|
||||
|
||||
$Group
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
function Remove-PersonaConfigRule {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Removes a rule from a rules collection by id (FR-029).
|
||||
|
||||
.DESCRIPTION
|
||||
Pure: takes the existing rules and an id, returns a new array with that rule
|
||||
absent. Throws if no rule carries the id, because a silent no-op would let an
|
||||
operator believe a delete happened when it did not.
|
||||
|
||||
.PARAMETER Rules
|
||||
The rule collection to remove from. Not mutated.
|
||||
|
||||
.PARAMETER RuleId
|
||||
The id of the rule to remove.
|
||||
|
||||
.OUTPUTS
|
||||
System.Object[] - the remaining rules.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([object[]])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
[object[]] $Rules,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $RuleId
|
||||
)
|
||||
|
||||
$existing = @($Rules)
|
||||
$match = @($existing | Where-Object { [string]$_.id -eq $RuleId })
|
||||
|
||||
if ($match.Count -eq 0) {
|
||||
throw "No rule with id '$RuleId'."
|
||||
}
|
||||
|
||||
, @($existing | Where-Object { [string]$_.id -ne $RuleId })
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
function Resolve-TargetAttribute {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Resolves the attribute the engine is permitted to write (NFR-006).
|
||||
|
||||
.DESCRIPTION
|
||||
Returns the configured target attribute only when it is non-blank and
|
||||
present in approvedWritableAttributes. Anything else throws.
|
||||
|
||||
Throwing rather than returning $null is the point. A caller that treated a
|
||||
null return as "no writes this run" would be indistinguishable from a caller
|
||||
that forgot to check, and the second one writes to whatever name it was
|
||||
holding. There is no safe fallback value for an attribute name, so there is
|
||||
no fallback.
|
||||
|
||||
Comparison against the approved list is ORDINAL. Extension property names
|
||||
are case-sensitive in Graph: extension_<id>_Persona and
|
||||
extension_<id>_persona are two different attributes, and approving one does
|
||||
not approve the other. Rule matching is case-insensitive (RE-006); attribute
|
||||
approval is not, and the difference is deliberate.
|
||||
|
||||
.PARAMETER Configuration
|
||||
The loaded configuration.
|
||||
|
||||
.EXAMPLE
|
||||
$target = Resolve-TargetAttribute -Configuration $config
|
||||
|
||||
.OUTPUTS
|
||||
System.String
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Configuration
|
||||
)
|
||||
|
||||
$target = [string]$Configuration.TargetAttribute
|
||||
$approved = @($Configuration.ApprovedWritableAttributes)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($target)) {
|
||||
throw 'engine.targetAttribute is blank. There is no attribute to compare against or write.'
|
||||
}
|
||||
|
||||
if ($target -cnotin $approved) {
|
||||
throw "engine.targetAttribute '$target' is not present in engine.approvedWritableAttributes. Comparison is ordinal: extension property names are case-sensitive, so a casing difference is a different attribute."
|
||||
}
|
||||
|
||||
$target
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
function Set-PersonaConditionLeaf {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Replaces a leaf condition's fields in place (FR-028).
|
||||
|
||||
.DESCRIPTION
|
||||
Every condition-specific field (`property`, `value`, `values`,
|
||||
`groupObjectIds`, `roleIds`, `membershipMode`) is cleared before Fields is
|
||||
applied, then only the keys in Fields are set. This is not tidiness - the
|
||||
schema's `additionalProperties: false` on `condition` (VR-001 layer 2) rejects
|
||||
a stray field outright, and switching, say, a condition from `equals` (which
|
||||
carries `value`) to `in` (which carries `values`) would otherwise leave the
|
||||
old `value` behind and fail schema validation for a reason the editor caused
|
||||
but did not explain.
|
||||
|
||||
`type` and `operator` are cleared and reset the same way, since a caller
|
||||
editing a condition typically passes both.
|
||||
|
||||
.PARAMETER Node
|
||||
The leaf condition to edit (from Get-PersonaConditionNode). Mutated in place.
|
||||
|
||||
.PARAMETER Fields
|
||||
The complete replacement field set for this leaf, for example:
|
||||
`@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }`.
|
||||
|
||||
.OUTPUTS
|
||||
System.Object - the same Node, mutated in place, returned for convenience.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([object])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Node,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[hashtable] $Fields
|
||||
)
|
||||
|
||||
if ($null -ne $Node.PSObject.Properties['conditions']) {
|
||||
throw 'The target node is a condition group, not a leaf condition.'
|
||||
}
|
||||
|
||||
$knownFields = @('type', 'property', 'operator', 'value', 'values', 'groupObjectIds', 'roleIds', 'membershipMode', 'caseSensitive')
|
||||
|
||||
foreach ($key in $knownFields) {
|
||||
if ($null -ne $Node.PSObject.Properties[$key]) {
|
||||
$Node.PSObject.Properties.Remove($key)
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($entry in $Fields.GetEnumerator()) {
|
||||
$Node | Add-Member -NotePropertyName $entry.Key -NotePropertyValue $entry.Value -Force
|
||||
}
|
||||
|
||||
$Node
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
function Test-PersonaConfiguration {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs all four validation layers over a configuration file (VR-001, FR-002).
|
||||
|
||||
.DESCRIPTION
|
||||
Layers run in order and fail fast between them:
|
||||
|
||||
1. Syntax ConvertFrom-Json PE-SYN-nnn
|
||||
2. Schema Test-Json -SchemaFile PE-SCH-nnn
|
||||
3. Semantic Test-PersonaConfigurationSemantic PE-SEM-nnn
|
||||
4. Safety Test-PersonaConfigurationSafety PE-SAF-nnn
|
||||
|
||||
A layer that produces Error findings stops the sequence. Running semantic
|
||||
checks over a structurally invalid document produces noise, not signal: every
|
||||
missing field yields a cascade of consequent errors, and the author has to
|
||||
guess which one is the actual cause.
|
||||
|
||||
Layer 2 error handling is driven by the V-5a observation
|
||||
(specs/001-persona-engine/verification/V-5a.md), which matters more than it
|
||||
looks. On PowerShell 7.6.5, Test-Json returns $true when the SCHEMA ITSELF is
|
||||
unparseable, writing the failure to the error stream instead. A wrapper that
|
||||
trusted the return value would report a configuration as schema-valid when
|
||||
the schema never ran. So this function treats a non-empty error variable as
|
||||
failure regardless of what was returned, and separates the "schema is broken"
|
||||
case from the "configuration is invalid" case, because they need different
|
||||
exit codes.
|
||||
|
||||
.PARAMETER Path
|
||||
Configuration file to validate.
|
||||
|
||||
.PARAMETER SchemaPath
|
||||
Schema override. Defaults to the shipped config/persona-engine.schema.json.
|
||||
|
||||
.PARAMETER PreviousConfigPath
|
||||
A previously deployed configuration, enabling the VR-003 comparison checks
|
||||
(version downgrade, undeclared rule deletion or reorder).
|
||||
|
||||
.PARAMETER EnforcementEnabled
|
||||
Whether this configuration will be used for an enforcing run. Raises the
|
||||
severity of several safety findings.
|
||||
|
||||
.PARAMETER SkipSafety
|
||||
Runs layers 1-3 only. Used by the editor while a document is mid-edit.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.ValidationResult
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $Path,
|
||||
|
||||
[string] $SchemaPath,
|
||||
|
||||
[string] $PreviousConfigPath,
|
||||
|
||||
[switch] $EnforcementEnabled,
|
||||
|
||||
[switch] $SkipSafety
|
||||
)
|
||||
|
||||
$findings = [System.Collections.Generic.List[object]]::new()
|
||||
$document = $null
|
||||
$stoppedAtLayer = $null
|
||||
$schemaUnusable = $false
|
||||
|
||||
# ---------------------------------------------------------------- Layer 1
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SYN-001' `
|
||||
-Location $Path `
|
||||
-Description "Configuration file not found or is not a file: '$Path'." `
|
||||
-SuggestedResolution 'Check the path. In a pipeline, confirm the file was checked out and the working directory is what you expect.'))
|
||||
|
||||
return New-PersonaValidationResult -Findings $findings -Document $null -StoppedAtLayer 'Syntax' -SchemaUnusable $false
|
||||
}
|
||||
|
||||
$raw = $null
|
||||
try {
|
||||
$raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SYN-002' `
|
||||
-Location $Path `
|
||||
-Description "Configuration file could not be read: $($_.Exception.Message)" `
|
||||
-SuggestedResolution 'Check file permissions and that no other process holds an exclusive lock.'))
|
||||
|
||||
return New-PersonaValidationResult -Findings $findings -Document $null -StoppedAtLayer 'Syntax' -SchemaUnusable $false
|
||||
}
|
||||
|
||||
try {
|
||||
$document = $raw | ConvertFrom-Json -Depth 32 -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SYN-003' `
|
||||
-Location $Path `
|
||||
-Description "Configuration is not valid JSON: $($_.Exception.Message)" `
|
||||
-SuggestedResolution 'Fix the JSON syntax. A trailing comma or an unquoted key is the usual cause.'))
|
||||
|
||||
return New-PersonaValidationResult -Findings $findings -Document $null -StoppedAtLayer 'Syntax' -SchemaUnusable $false
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- Layer 2
|
||||
|
||||
if (-not $SchemaPath) {
|
||||
$moduleRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
$SchemaPath = Join-Path $moduleRoot 'config/persona-engine.schema.json'
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $SchemaPath -PathType Leaf)) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SCH-002' `
|
||||
-Location $SchemaPath `
|
||||
-Description "Schema file not found: '$SchemaPath'." `
|
||||
-SuggestedResolution 'Supply -SchemaPath, or restore config/persona-engine.schema.json.'))
|
||||
|
||||
return New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer 'Schema' -SchemaUnusable $true
|
||||
}
|
||||
|
||||
$schemaErrors = $null
|
||||
$schemaOk = $raw | Test-Json -SchemaFile $SchemaPath -ErrorAction SilentlyContinue -ErrorVariable schemaErrors
|
||||
|
||||
foreach ($schemaError in @($schemaErrors)) {
|
||||
$message = [string]$schemaError.Exception.Message
|
||||
|
||||
# V-5a: this message arrives with a $true return value. Treating it as a
|
||||
# pass would validate every configuration against a schema that never ran.
|
||||
if ($message -match 'Cannot parse the JSON schema') {
|
||||
$schemaUnusable = $true
|
||||
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SCH-003' `
|
||||
-Location $SchemaPath `
|
||||
-Description "The schema file itself is not valid JSON Schema and could not be used: $message" `
|
||||
-SuggestedResolution 'Repair the schema file. Until it parses, no configuration can be schema-validated, and a passing result would be meaningless.'))
|
||||
continue
|
||||
}
|
||||
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SCH-001' `
|
||||
-Location (Get-PersonaSchemaErrorLocation -Message $message) `
|
||||
-Description $message `
|
||||
-SuggestedResolution 'Correct the document to match config/persona-engine.schema.json. Test-Json reports only the first violation per run, so re-validate after each fix.'))
|
||||
}
|
||||
|
||||
if ($schemaUnusable -or -not $schemaOk -or @($schemaErrors).Count -gt 0) {
|
||||
return New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer 'Schema' -SchemaUnusable $schemaUnusable
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- Layer 3
|
||||
|
||||
foreach ($finding in (Test-PersonaConfigurationSemantic -Document $document)) { $findings.Add($finding) }
|
||||
|
||||
if (@($findings | Where-Object Severity -EQ 'Error').Count -gt 0) {
|
||||
return New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer 'Semantic' -SchemaUnusable $false
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- Layer 4
|
||||
|
||||
if (-not $SkipSafety) {
|
||||
$safetyParams = @{ Document = $document; EnforcementEnabled = $EnforcementEnabled }
|
||||
if ($PreviousConfigPath) { $safetyParams['PreviousConfigPath'] = $PreviousConfigPath }
|
||||
|
||||
foreach ($finding in (Test-PersonaConfigurationSafety @safetyParams)) { $findings.Add($finding) }
|
||||
}
|
||||
|
||||
New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer $null -SchemaUnusable $false
|
||||
}
|
||||
|
||||
function New-PersonaValidationResult {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Wraps a finding collection into the shape every caller consumes.
|
||||
|
||||
.DESCRIPTION
|
||||
IsValid is computed here rather than by each caller, so console, pipeline,
|
||||
and editor cannot disagree about what "valid" means. Warnings never affect
|
||||
IsValid; escalation under -TreatWarningsAsErrors is the caller's decision
|
||||
(VR-005) and belongs where the exit code is chosen.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)] [AllowEmptyCollection()] [object] $Findings,
|
||||
[AllowNull()] [object] $Document,
|
||||
[AllowNull()] [string] $StoppedAtLayer,
|
||||
[bool] $SchemaUnusable
|
||||
)
|
||||
|
||||
$all = @($Findings)
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.ValidationResult'
|
||||
IsValid = (@($all | Where-Object Severity -EQ 'Error').Count -eq 0)
|
||||
Findings = $all
|
||||
ErrorCount = @($all | Where-Object Severity -EQ 'Error').Count
|
||||
WarningCount = @($all | Where-Object Severity -EQ 'Warning').Count
|
||||
Document = $Document
|
||||
StoppedAtLayer = $StoppedAtLayer
|
||||
SchemaUnusable = $SchemaUnusable
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PersonaSchemaErrorLocation {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extracts the JSON pointer from a Test-Json error message.
|
||||
|
||||
.DESCRIPTION
|
||||
Test-Json embeds the failing location in prose - "... at '/rules/3/priority'".
|
||||
VR-004 requires a location on every finding, so it is lifted out here rather
|
||||
than leaving the caller to read it out of the description. When no pointer is
|
||||
present the document root is reported, which is honest: the violation is
|
||||
somewhere in the document and the message says where in words.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Message)
|
||||
|
||||
if ($Message -match "at '([^']*)'") {
|
||||
$pointer = $Matches[1]
|
||||
return $pointer ? $pointer : '/'
|
||||
}
|
||||
|
||||
'/'
|
||||
}
|
||||
|
||||
function Write-PersonaValidationFinding {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Renders validation findings for a human reader.
|
||||
|
||||
.DESCRIPTION
|
||||
Grouped by severity, most serious first, with the suggested resolution on
|
||||
its own line. A finding without a visible resolution just tells someone they
|
||||
are wrong, which is why VR-004 makes the field mandatory and why it is
|
||||
printed rather than hidden behind a verbose switch.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Findings
|
||||
)
|
||||
|
||||
if (@($Findings).Count -eq 0) {
|
||||
Write-Host 'Validation passed with no findings.' -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
|
||||
foreach ($severity in @('Error', 'Warning', 'Information')) {
|
||||
$set = @($Findings | Where-Object Severity -EQ $severity)
|
||||
if ($set.Count -eq 0) { continue }
|
||||
|
||||
$colour = switch ($severity) { 'Error' { 'Red' } 'Warning' { 'Yellow' } default { 'Gray' } }
|
||||
|
||||
foreach ($finding in $set) {
|
||||
Write-Host ("[{0}] {1} {2}" -f $finding.Severity.ToUpperInvariant(), $finding.Code, $finding.Location) -ForegroundColor $colour
|
||||
Write-Host (" {0}" -f $finding.Description)
|
||||
Write-Host (" -> {0}" -f $finding.SuggestedResolution) -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
function Test-PersonaConfigurationSafety {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validation layer 4: safety checks (VR-003).
|
||||
|
||||
.DESCRIPTION
|
||||
Layer 3 asks "does this configuration make sense?". Layer 4 asks "what
|
||||
happens to the directory if we run it?" - a configuration can be perfectly
|
||||
coherent and still be dangerous.
|
||||
|
||||
PE-SAF-001 production-capable configuration with a blank target attribute
|
||||
PE-SAF-002 unsupported writable attribute in the approved list
|
||||
PE-SAF-003 enabled group rules while group retrieval is disabled
|
||||
PE-SAF-004 prohibited configuration version downgrade
|
||||
PE-SAF-005 rule deletion or reorder without a version change, enforcing
|
||||
PE-SAF-006 condition tracing enabled without explicit acknowledgement
|
||||
PE-SAF-007 save path overwrites the only valid configuration with no backup
|
||||
|
||||
PE-SAF-004 and PE-SAF-005 need a baseline to compare against and are skipped
|
||||
when -PreviousConfigPath is absent. Skipped, not passed: a check that cannot
|
||||
run has not been satisfied, and an Information finding says so rather than
|
||||
leaving silence to be read as approval.
|
||||
|
||||
.PARAMETER Document
|
||||
The parsed configuration document.
|
||||
|
||||
.PARAMETER PreviousConfigPath
|
||||
The currently deployed configuration, for the comparison checks.
|
||||
|
||||
.PARAMETER EnforcementEnabled
|
||||
Whether this configuration will drive an enforcing run. Several findings are
|
||||
Errors under enforcement and Warnings in preview, because the same
|
||||
configuration carries very different risk in the two modes.
|
||||
|
||||
.PARAMETER SavePath
|
||||
Intended save target, for PE-SAF-007.
|
||||
|
||||
.PARAMETER BackupPlanned
|
||||
A timestamped backup or Save-As will be taken, satisfying PE-SAF-007.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.ValidationFinding objects.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Document,
|
||||
|
||||
[string] $PreviousConfigPath,
|
||||
|
||||
[switch] $EnforcementEnabled,
|
||||
|
||||
[string] $SavePath,
|
||||
|
||||
[switch] $BackupPlanned
|
||||
)
|
||||
|
||||
$findings = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
$engine = $Document.engine
|
||||
$target = [string]$engine.targetAttribute
|
||||
$approved = @($engine.approvedWritableAttributes)
|
||||
$rules = @($Document.rules)
|
||||
|
||||
# ------------------------------------------------- PE-SAF-001
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($target)) {
|
||||
$severity = $EnforcementEnabled ? 'Error' : 'Warning'
|
||||
|
||||
$findings.Add((New-PersonaValidationFinding -Severity $severity -Code 'PE-SAF-001' `
|
||||
-Location 'engine.targetAttribute' `
|
||||
-Description 'The target attribute is blank. In enforce mode there is no attribute to write and every changed result becomes Skipped, so the run reports success while changing nothing.' `
|
||||
-SuggestedResolution 'Set engine.targetAttribute before running with enforcement.'))
|
||||
}
|
||||
|
||||
# ------------------------------------------------- PE-SAF-002
|
||||
|
||||
foreach ($attribute in $approved) {
|
||||
if (Test-PersonaWritableAttributeShape -Name $attribute) { continue }
|
||||
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-002' `
|
||||
-Location 'engine.approvedWritableAttributes' `
|
||||
-Description "'$attribute' is not a supported writable attribute. Only directory (schema) extension properties may be written by this engine (OTD-001); built-in attributes such as department or jobTitle are authoritative elsewhere and writing them would overwrite another system's data." `
|
||||
-SuggestedResolution 'Remove the entry, or replace it with a directory extension property named extension_<APP-ID>_<NAME>.'))
|
||||
}
|
||||
|
||||
# ------------------------------------------------- PE-SAF-003
|
||||
|
||||
$groupsEnabled = [bool]$Document.dataSources.groups.enabled
|
||||
$rolesEnabled = [bool]$Document.dataSources.roles.enabled
|
||||
|
||||
$needs = Get-PersonaRequiredFacets -Rules $rules -DefaultMembershipMode (
|
||||
$engine.defaultMembershipMode ? (Get-Culture).TextInfo.ToTitleCase([string]$engine.defaultMembershipMode) : 'Direct')
|
||||
|
||||
if (($needs.Direct -or $needs.Transitive) -and -not $groupsEnabled) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-003' `
|
||||
-Location 'dataSources.groups.enabled' `
|
||||
-Description 'Enabled rules contain group membership conditions while group retrieval is disabled. Every account those rules reach becomes EvaluationError, so the run preserves stored values and classifies nobody - a silent no-op that still reports success.' `
|
||||
-SuggestedResolution 'Enable dataSources.groups, or disable the rules that depend on membership.'))
|
||||
}
|
||||
|
||||
if ($needs.Roles -and -not $rolesEnabled) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-003' `
|
||||
-Location 'dataSources.roles.enabled' `
|
||||
-Description 'Enabled rules contain directory role conditions while role retrieval is disabled. Tier 0 rules are the usual casualty, and an account that should have matched a privileged rule falls through to a lower-privilege persona or to EvaluationError.' `
|
||||
-SuggestedResolution 'Enable dataSources.roles, or disable the rules that depend on role assignments.'))
|
||||
}
|
||||
|
||||
# ------------------------------------------------- PE-SAF-006
|
||||
|
||||
$logging = $Document.logging
|
||||
$tracing = $logging -and $logging.PSObject.Properties['traceConditionValues'] -and [bool]$logging.traceConditionValues
|
||||
$acknowledged = $logging -and $logging.PSObject.Properties['acknowledgeConditionTracing'] -and [bool]$logging.acknowledgeConditionTracing
|
||||
|
||||
if ($tracing -and -not $acknowledged) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-006' `
|
||||
-Location 'logging.traceConditionValues' `
|
||||
-Description 'Condition-value tracing is enabled without acknowledgement. Tracing writes evaluated attribute values into audit records, widening what the log contains beyond the UPN and Object ID that are approved by default (Principle V).' `
|
||||
-SuggestedResolution 'Set logging.acknowledgeConditionTracing to true in the same change that enables tracing, so the decision is visible in review, or disable tracing.'))
|
||||
}
|
||||
|
||||
# ------------------------------------------------- PE-SAF-004 / PE-SAF-005
|
||||
|
||||
if (-not $PreviousConfigPath) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Information -Code 'PE-SAF-004' `
|
||||
-Location 'configVersion' `
|
||||
-Description 'No previous configuration was supplied, so the version-downgrade and rule-drift checks did not run. This is not a pass - the checks were skipped.' `
|
||||
-SuggestedResolution 'Pass -PreviousConfigPath pointing at the currently deployed configuration to enable the comparison checks. In CI, this is the copy from the deployed branch.'))
|
||||
}
|
||||
elseif (-not (Test-Path -LiteralPath $PreviousConfigPath -PathType Leaf)) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Warning -Code 'PE-SAF-004' `
|
||||
-Location $PreviousConfigPath `
|
||||
-Description 'The previous configuration path was supplied but does not exist. The comparison checks did not run.' `
|
||||
-SuggestedResolution 'Correct the path, or omit it deliberately if this is the first deployment.'))
|
||||
}
|
||||
else {
|
||||
foreach ($finding in (Compare-PersonaConfigurationVersion -Document $Document -PreviousConfigPath $PreviousConfigPath -EnforcementEnabled:$EnforcementEnabled)) {
|
||||
$findings.Add($finding)
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------- PE-SAF-007
|
||||
|
||||
if ($SavePath -and -not $BackupPlanned -and (Test-Path -LiteralPath $SavePath -PathType Leaf)) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-007' `
|
||||
-Location $SavePath `
|
||||
-Description 'The save would overwrite an existing configuration with no backup. If the replacement turns out to be wrong, the only known-good copy is gone and there is nothing to roll back to.' `
|
||||
-SuggestedResolution 'Allow the timestamped backup, or supply -OutputPath to save alongside the original (FR-026).'))
|
||||
}
|
||||
|
||||
$findings
|
||||
}
|
||||
|
||||
function Compare-PersonaConfigurationVersion {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Compares a candidate configuration against the deployed one (PE-SAF-004, PE-SAF-005).
|
||||
|
||||
.DESCRIPTION
|
||||
Two questions, both about change control rather than correctness:
|
||||
|
||||
Did the version go backwards? A downgrade means an older rule set is about
|
||||
to replace a newer one, and audit records would carry a configVersion that
|
||||
has already been superseded - so two different rule sets share one version
|
||||
number and no record can tell them apart.
|
||||
|
||||
Did rules disappear or change order without the version changing? Deleting
|
||||
a rule silently reclassifies everyone it used to match; reordering does the
|
||||
same for anyone matched by an overtaken rule. Neither is wrong in itself,
|
||||
but doing it under an unchanged version number makes the change invisible
|
||||
in the audit trail.
|
||||
|
||||
Under enforcement these are Errors, because the consequence is a directory
|
||||
write. In preview they are Warnings: the same drift is worth seeing but costs
|
||||
nothing yet.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Document,
|
||||
[Parameter(Mandatory)] [string] $PreviousConfigPath,
|
||||
[switch] $EnforcementEnabled
|
||||
)
|
||||
|
||||
$findings = [System.Collections.Generic.List[object]]::new()
|
||||
$severity = $EnforcementEnabled ? 'Error' : 'Warning'
|
||||
|
||||
$previous = $null
|
||||
try {
|
||||
$previous = Get-Content -LiteralPath $PreviousConfigPath -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 32 -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Warning -Code 'PE-SAF-004' `
|
||||
-Location $PreviousConfigPath `
|
||||
-Description "The previous configuration could not be parsed, so the comparison checks did not run: $($_.Exception.Message)" `
|
||||
-SuggestedResolution 'Point -PreviousConfigPath at a valid configuration, or omit it.'))
|
||||
return $findings
|
||||
}
|
||||
|
||||
$currentVersion = $null
|
||||
$previousVersion = $null
|
||||
$parsedBoth = [version]::TryParse([string]$Document.configVersion, [ref] $currentVersion) -and
|
||||
[version]::TryParse([string]$previous.configVersion, [ref] $previousVersion)
|
||||
|
||||
if ($parsedBoth -and $currentVersion -lt $previousVersion) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity $severity -Code 'PE-SAF-004' `
|
||||
-Location 'configVersion' `
|
||||
-Description "configVersion $currentVersion is lower than the deployed version $previousVersion. Audit records would report a version that has already been superseded, making two different rule sets indistinguishable in the log." `
|
||||
-SuggestedResolution 'Raise configVersion above the deployed version. If a rollback is genuinely intended, publish it as a new higher version rather than reusing the old number.'))
|
||||
}
|
||||
|
||||
if ($parsedBoth -and $currentVersion -ne $previousVersion) {
|
||||
# The version moved, so drift is declared. Nothing further to report.
|
||||
return $findings
|
||||
}
|
||||
|
||||
$currentIds = @($Document.rules | ForEach-Object { [string]$_.id })
|
||||
$previousIds = @($previous.rules | ForEach-Object { [string]$_.id })
|
||||
|
||||
$removed = @($previousIds | Where-Object { $_ -notin $currentIds })
|
||||
if ($removed.Count -gt 0) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity $severity -Code 'PE-SAF-005' `
|
||||
-Location 'rules' `
|
||||
-Description "Rules removed without a configVersion change: $($removed -join ', '). Every account these rules matched will be reclassified by a later rule, or become Unclassified, with nothing in the audit trail marking the change." `
|
||||
-SuggestedResolution 'Raise configVersion so the change is declared, or disable the rules instead of deleting them so the audit trail keeps reporting zero matches against them.'))
|
||||
}
|
||||
|
||||
$currentOrder = @($Document.rules | Sort-Object -Property @{ Expression = { [int]$_.priority } } | ForEach-Object { [string]$_.id })
|
||||
$previousOrder = @($previous.rules | Sort-Object -Property @{ Expression = { [int]$_.priority } } | ForEach-Object { [string]$_.id })
|
||||
$shared = @($currentOrder | Where-Object { $_ -in $previousIds })
|
||||
$sharedPrevious = @($previousOrder | Where-Object { $_ -in $currentIds })
|
||||
|
||||
if (($shared -join '>') -ne ($sharedPrevious -join '>')) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity $severity -Code 'PE-SAF-005' `
|
||||
-Location 'rules[*].priority' `
|
||||
-Description 'Rule evaluation order changed without a configVersion change. First match wins (FR-009), so a reorder silently reassigns every account matched by more than one rule.' `
|
||||
-SuggestedResolution 'Raise configVersion so the reorder is declared and traceable in audit records.'))
|
||||
}
|
||||
|
||||
$findings
|
||||
}
|
||||
|
||||
function Test-PersonaWritableAttributeShape {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Reports whether an attribute name is shaped like a writable extension property.
|
||||
|
||||
.DESCRIPTION
|
||||
OTD-001 selected directory (schema) extension properties as the persona
|
||||
store. Only those may be written. Built-in attributes are deliberately
|
||||
excluded even when the operator holds permission to write them: they are
|
||||
authoritative in HR or in the sync source, and this engine is not their owner.
|
||||
|
||||
The placeholder form is accepted so the committed example configuration
|
||||
passes its own validator without carrying a real application ID (SC-013).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([bool])]
|
||||
param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Name)
|
||||
|
||||
if ($Name -match '^extension_[0-9a-fA-F]{32}_[A-Za-z0-9]+$') { return $true }
|
||||
if ($Name -match '^extension_<[^>]+>_<?[^>]+>?$') { return $true }
|
||||
|
||||
$false
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
function Test-PersonaConfigurationSemantic {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validation layer 3: semantic checks (VR-002).
|
||||
|
||||
.DESCRIPTION
|
||||
Every condition VR-002 names, one stable code each. Codes are part of the
|
||||
contract - pipelines and runbooks match on them - so a code is never reused
|
||||
for a different condition and never renumbered.
|
||||
|
||||
PE-SEM-001 duplicate rule IDs
|
||||
PE-SEM-002 duplicate priorities
|
||||
PE-SEM-003 no enabled rules
|
||||
PE-SEM-004 invalid or blank target attribute
|
||||
PE-SEM-005 target attribute absent from the approved writable list
|
||||
PE-SEM-006 reference to an unavailable data source
|
||||
PE-SEM-007 memberOf/notMemberOf without group Object IDs
|
||||
PE-SEM-008 in/notIn without values
|
||||
PE-SEM-009 isNull/isNotNull carrying a comparison value
|
||||
PE-SEM-010 undefined or prohibited persona value
|
||||
PE-SEM-011 Unclassified used as an ordinary rule persona
|
||||
PE-SEM-012 condition depth over the configured maximum
|
||||
PE-SEM-013 configured maximum over the hard ceiling of 10
|
||||
PE-SEM-014 membership mode not enabled globally
|
||||
PE-SEM-015 unsupported property name
|
||||
PE-SEM-016 invalid regular expression
|
||||
|
||||
Several of these are also expressible in JSON Schema and some are already
|
||||
caught there. They are repeated here deliberately: layer 2 can be bypassed
|
||||
with -SchemaPath, and V-5a showed that an unparseable schema silently passes
|
||||
on this build. A rule that can misclassify a privileged account should not
|
||||
depend on one layer alone.
|
||||
|
||||
.PARAMETER Document
|
||||
The parsed configuration document.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.ValidationFinding objects. Empty when the document is sound.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Document
|
||||
)
|
||||
|
||||
$findings = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
$engine = $Document.engine
|
||||
$rules = @($Document.rules)
|
||||
$personas = @($Document.personas)
|
||||
|
||||
# ---------------------------------------------------- target attribute
|
||||
|
||||
$target = [string]$engine.targetAttribute
|
||||
$approved = @($engine.approvedWritableAttributes)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($target)) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-004' `
|
||||
-Location 'engine.targetAttribute' `
|
||||
-Description 'The target attribute is blank. The engine has no attribute to compare against or write.' `
|
||||
-SuggestedResolution 'Set engine.targetAttribute to the approved persona extension property name.'))
|
||||
}
|
||||
elseif ($target -cnotin $approved) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-005' `
|
||||
-Location 'engine.targetAttribute' `
|
||||
-Description "The target attribute '$target' does not appear in engine.approvedWritableAttributes. Comparison is ordinal, so a casing difference counts as absent." `
|
||||
-SuggestedResolution 'Add the exact attribute name to approvedWritableAttributes, or correct the target attribute. Extension property names are case-sensitive in Graph.'))
|
||||
}
|
||||
|
||||
# ---------------------------------------------------- depth ceiling
|
||||
|
||||
$maxDepth = ($null -ne $engine.maxConditionDepth) ? [int]$engine.maxConditionDepth : 5
|
||||
|
||||
if ($maxDepth -gt 10) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-013' `
|
||||
-Location 'engine.maxConditionDepth' `
|
||||
-Description "maxConditionDepth is $maxDepth, above the hard ceiling of 10 (RE-004)." `
|
||||
-SuggestedResolution 'Lower maxConditionDepth to 10 or less. A rule needing deeper nesting is better split into two rules with distinct priorities.'))
|
||||
}
|
||||
|
||||
if ($maxDepth -lt 1) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-013' `
|
||||
-Location 'engine.maxConditionDepth' `
|
||||
-Description "maxConditionDepth is $maxDepth, below the minimum of 1 (RE-004). No rule could be evaluated." `
|
||||
-SuggestedResolution 'Set maxConditionDepth to at least 1.'))
|
||||
}
|
||||
|
||||
# ---------------------------------------------------- data sources
|
||||
|
||||
$groupsEnabled = [bool]$Document.dataSources.groups.enabled
|
||||
$rolesEnabled = [bool]$Document.dataSources.roles.enabled
|
||||
# Left null when the configuration does not pin a mode. Absent means "any mode is
|
||||
# acceptable", which is the normal case: RE-007 makes mode a per-condition
|
||||
# choice, and the facets are retrieved independently. Pinning it globally is a
|
||||
# deliberate restriction, and only then is a per-condition override worth
|
||||
# flagging.
|
||||
$globalMode = $Document.dataSources.groups.PSObject.Properties['membershipMode'] `
|
||||
? [string]$Document.dataSources.groups.membershipMode : $null
|
||||
$defaultMode = $engine.defaultMembershipMode ? [string]$engine.defaultMembershipMode : 'direct'
|
||||
|
||||
# ---------------------------------------------------- rules
|
||||
|
||||
if ($rules.Count -eq 0) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-003' `
|
||||
-Location 'rules' `
|
||||
-Description 'The configuration contains no rules.' `
|
||||
-SuggestedResolution 'Add at least one enabled rule. A run with no rules classifies every account as Unclassified.'))
|
||||
}
|
||||
elseif (@($rules | Where-Object { $_.enabled }).Count -eq 0) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-003' `
|
||||
-Location 'rules' `
|
||||
-Description 'Every rule in the configuration is disabled. The run would classify every account as Unclassified and, in enforce mode, propose clearing every stored persona.' `
|
||||
-SuggestedResolution 'Enable at least one rule, or do not deploy this configuration.'))
|
||||
}
|
||||
|
||||
foreach ($group in ($rules | Group-Object -Property { [string]$_.id } | Where-Object Count -GT 1)) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-001' `
|
||||
-Location "rules[id=$($group.Name)]" `
|
||||
-Description "Rule ID '$($group.Name)' is used by $($group.Count) rules. Rule IDs appear in audit records and are how a decision is traced back to its rule." `
|
||||
-SuggestedResolution 'Give each rule a unique ID.'))
|
||||
}
|
||||
|
||||
foreach ($group in ($rules | Where-Object { $_.enabled } | Group-Object -Property { [int]$_.priority } | Where-Object Count -GT 1)) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-002' `
|
||||
-Location "rules[priority=$($group.Name)]" `
|
||||
-Description "Priority $($group.Name) is shared by $($group.Count) enabled rules: $(($group.Group | ForEach-Object { [string]$_.id }) -join ', '). Evaluation order between them is not defined by the configuration (RE-002)." `
|
||||
-SuggestedResolution 'Assign a unique priority to each enabled rule. The engine breaks ties by rule ID so results stay deterministic, but the resulting order is an accident rather than a decision.'))
|
||||
}
|
||||
|
||||
foreach ($rule in $rules) {
|
||||
$ruleId = [string]$rule.id
|
||||
$persona = [string]$rule.persona
|
||||
|
||||
if ($persona -ieq 'Unclassified') {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-011' `
|
||||
-Location "rules[$ruleId].persona" `
|
||||
-Description 'Unclassified is a processing result, not a rule outcome (FR-010). A rule that assigns it makes "no rule matched" indistinguishable from "this rule matched".' `
|
||||
-SuggestedResolution 'Remove the rule, or give it a real persona. Accounts matching no rule already receive Unclassified.'))
|
||||
}
|
||||
elseif ($persona -ieq 'EvaluationError') {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-010' `
|
||||
-Location "rules[$ruleId].persona" `
|
||||
-Description 'EvaluationError is an execution result and must never be assigned by a rule.' `
|
||||
-SuggestedResolution 'Give the rule a persona from the personas catalogue.'))
|
||||
}
|
||||
elseif ($persona -and $persona -notin $personas) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-010' `
|
||||
-Location "rules[$ruleId].persona" `
|
||||
-Description "Persona '$persona' is not declared in the personas catalogue. The catalogue is what stops a typo from writing a new persona value into the directory." `
|
||||
-SuggestedResolution "Add '$persona' to the personas array, or correct the spelling."))
|
||||
}
|
||||
|
||||
if ($null -eq $rule.match) { continue }
|
||||
|
||||
$context = @{
|
||||
RuleId = $ruleId
|
||||
MaxDepth = $maxDepth
|
||||
GroupsEnabled = $groupsEnabled
|
||||
RolesEnabled = $rolesEnabled
|
||||
GlobalMode = $globalMode
|
||||
DefaultMode = $defaultMode
|
||||
Findings = $findings
|
||||
}
|
||||
|
||||
Test-PersonaSemanticNode -Node $rule.match -Depth 1 -PathText "rules[$ruleId].match" -Context $context
|
||||
}
|
||||
|
||||
$findings
|
||||
}
|
||||
|
||||
function Test-PersonaSemanticNode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Recursively validates one condition group or condition.
|
||||
|
||||
.DESCRIPTION
|
||||
Depth is counted the same way the engine counts it, so the validator and the
|
||||
runtime agree about what "too deep" means. A validator with its own depth
|
||||
arithmetic would eventually pass a configuration the engine rejects at run
|
||||
time, against a live tenant, which is the worst place to discover it.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Node,
|
||||
[Parameter(Mandatory)] [int] $Depth,
|
||||
[Parameter(Mandatory)] [string] $PathText,
|
||||
[Parameter(Mandatory)] [hashtable] $Context
|
||||
)
|
||||
|
||||
$findings = $Context.Findings
|
||||
|
||||
if ($Depth -gt $Context.MaxDepth) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-012' `
|
||||
-Location $PathText `
|
||||
-Description "Condition nesting reaches depth $Depth, above the configured maximum of $($Context.MaxDepth) (RE-004). The engine returns Unknown beyond the limit, which becomes EvaluationError for every account this rule reaches." `
|
||||
-SuggestedResolution 'Flatten the condition tree, or raise engine.maxConditionDepth up to the ceiling of 10.'))
|
||||
return
|
||||
}
|
||||
|
||||
# A group: recurse and stop. Groups carry no operator-level semantics of their own.
|
||||
if ($Node.PSObject.Properties['conditions'] -and $Node.conditions) {
|
||||
$index = 0
|
||||
foreach ($child in $Node.conditions) {
|
||||
Test-PersonaSemanticNode -Node $child -Depth ($Depth + 1) -PathText "$PathText.conditions[$index]" -Context $Context
|
||||
$index++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
$type = [string]$Node.type
|
||||
$operator = [string]$Node.operator
|
||||
$hasValue = $Node.PSObject.Properties['value'] -and $null -ne $Node.value
|
||||
|
||||
# @($Node.values) on an absent property yields @($null) - a one-element array -
|
||||
# which would make an empty list look populated and a populated one look no
|
||||
# different. Every array read here goes through the null filter for that reason.
|
||||
$values = @($Node.values | Where-Object { $null -ne $_ })
|
||||
$groupIds = @($Node.groupObjectIds | Where-Object { $null -ne $_ })
|
||||
$roleIds = @($Node.roleIds | Where-Object { $null -ne $_ })
|
||||
|
||||
switch ($operator) {
|
||||
|
||||
{ $_ -in @('memberOf', 'notMemberOf') } {
|
||||
if ($type -eq 'role') {
|
||||
if ($roleIds.Count -eq 0) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-007' `
|
||||
-Location $PathText `
|
||||
-Description "A role condition using '$operator' carries no roleIds. It can never evaluate to a meaningful result." `
|
||||
-SuggestedResolution 'Add at least one role template ID to roleIds.'))
|
||||
}
|
||||
|
||||
if (-not $Context.RolesEnabled) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-006' `
|
||||
-Location $PathText `
|
||||
-Description 'This rule requires directory role data, but dataSources.roles.enabled is false. The role facet is never retrieved, so every account reaching this rule becomes EvaluationError (FR-013).' `
|
||||
-SuggestedResolution 'Enable dataSources.roles, or remove the role conditions.'))
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($groupIds.Count -eq 0) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-007' `
|
||||
-Location $PathText `
|
||||
-Description "A membership condition using '$operator' carries no groupObjectIds." `
|
||||
-SuggestedResolution 'Add at least one group Object ID to groupObjectIds. Object IDs are used rather than names because names are mutable (RE-009).'))
|
||||
}
|
||||
|
||||
if (-not $Context.GroupsEnabled) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-006' `
|
||||
-Location $PathText `
|
||||
-Description 'This rule requires group membership data, but dataSources.groups.enabled is false. Every account reaching this rule becomes EvaluationError (FR-013).' `
|
||||
-SuggestedResolution 'Enable dataSources.groups, or remove the membership conditions.'))
|
||||
}
|
||||
|
||||
$mode = ($Node.PSObject.Properties['membershipMode'] -and $Node.membershipMode) ? [string]$Node.membershipMode : $Context.DefaultMode
|
||||
|
||||
if ($Context.GroupsEnabled -and $mode -ine $Context.GlobalMode -and $Context.GlobalMode) {
|
||||
# Not an error. The three facets are retrieved independently, so a
|
||||
# per-condition mode differing from the global one is served
|
||||
# correctly - but it is worth flagging, because it usually means
|
||||
# the author did not realise the global setting was there.
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Warning -Code 'PE-SEM-014' `
|
||||
-Location $PathText `
|
||||
-Description "This condition requests '$mode' membership while dataSources.groups.membershipMode is '$($Context.GlobalMode)'. Both facets will be retrieved, at the cost of an extra call per account." `
|
||||
-SuggestedResolution "Confirm '$mode' is intended here. If every rule wants the same mode, set it globally and drop the per-condition override."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{ $_ -in @('in', 'notIn') } {
|
||||
if ($values.Count -eq 0) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-008' `
|
||||
-Location $PathText `
|
||||
-Description "Operator '$operator' requires a values array, which is absent or empty. An empty set matches nothing and would silently never fire." `
|
||||
-SuggestedResolution 'Populate values, or use equals/notEquals for a single comparison.'))
|
||||
}
|
||||
}
|
||||
|
||||
{ $_ -in @('isNull', 'isNotNull') } {
|
||||
if ($hasValue -or $values.Count -gt 0) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-009' `
|
||||
-Location $PathText `
|
||||
-Description "Operator '$operator' tests for presence and ignores any comparison value. A value here is silently discarded, so the rule does not do what it appears to do." `
|
||||
-SuggestedResolution 'Remove value/values, or switch to equals if a comparison was intended.'))
|
||||
}
|
||||
}
|
||||
|
||||
'matchesRegex' {
|
||||
if (-not $hasValue) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-016' `
|
||||
-Location $PathText `
|
||||
-Description 'matchesRegex requires a pattern in value.' `
|
||||
-SuggestedResolution 'Supply a regular expression in the value field.'))
|
||||
}
|
||||
else {
|
||||
try {
|
||||
# Compiling proves the pattern parses. Validating here rather than
|
||||
# at run time means a bad pattern fails a pipeline, not a
|
||||
# production run in which every account becomes EvaluationError.
|
||||
$null = [System.Text.RegularExpressions.Regex]::new([string]$Node.value)
|
||||
}
|
||||
catch {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-016' `
|
||||
-Location $PathText `
|
||||
-Description "Invalid regular expression: $($_.Exception.Message)" `
|
||||
-SuggestedResolution 'Correct the pattern. Remember that JSON requires backslashes to be escaped, so \d is written \\d.'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($type -eq 'property') {
|
||||
$property = [string]$Node.property
|
||||
|
||||
if (-not (Test-PersonaSupportedProperty -Name $property)) {
|
||||
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-015' `
|
||||
-Location $PathText `
|
||||
-Description "Property '$property' is not a supported property name. Unsupported properties are never retrieved, so the condition would compare against a permanently absent value." `
|
||||
-SuggestedResolution 'Use one of AccountObjectId, UserPrincipalName, DisplayName, UserType, AccountEnabled, CompanyName, JobTitle, Department, or an extension property named extension_<APP-ID>_<NAME>.'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Test-PersonaSupportedProperty {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Reports whether a property name can actually be retrieved and evaluated.
|
||||
|
||||
.DESCRIPTION
|
||||
The FR-005 baseline plus directory extension properties. Extension names are
|
||||
accepted on shape alone - extension_<32 hex>_<name> - because the set of
|
||||
registered extensions is tenant-specific and cannot be known offline, and
|
||||
SC-008 requires validation to run with no tenant.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([bool])]
|
||||
param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Name)
|
||||
|
||||
$supported = @(
|
||||
'AccountObjectId', 'UserPrincipalName', 'DisplayName', 'UserType',
|
||||
'AccountEnabled', 'CompanyName', 'JobTitle', 'Department'
|
||||
)
|
||||
|
||||
if ($Name -in $supported) { return $true }
|
||||
if ($Name -match '^extension_[0-9a-fA-F]{32}_[A-Za-z0-9]+$') { return $true }
|
||||
|
||||
# The placeholder form used in committed examples (SC-013) must validate, or the
|
||||
# shipped example configuration could never pass its own validator.
|
||||
if ($Name -match '^extension_<[^>]+>_<?[^>]+>?$') { return $true }
|
||||
|
||||
$false
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
function Get-PersonaDirectoryRoles {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Retrieves active directory role assignments for one user.
|
||||
|
||||
.DESCRIPTION
|
||||
Uses the unified role management endpoint, which returns roleDefinitionId
|
||||
values (role template IDs) — stable across tenants, unlike role instance IDs,
|
||||
so a configuration written against them is portable.
|
||||
|
||||
Eligible (PIM) assignments are out of scope for v1 unless authorization is
|
||||
confirmed and a provider is implemented (spec Out of Scope).
|
||||
|
||||
Throws on failure. The caller (Get-PersonaGroupMembership) contains the
|
||||
failure into the Roles facet so it never becomes a false non-match.
|
||||
|
||||
.PARAMETER UserObjectId
|
||||
The principal to query.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string[]])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $UserObjectId
|
||||
)
|
||||
|
||||
$uri = "/v1.0/roleManagement/directory/roleAssignments?`$filter=principalId eq '$UserObjectId'"
|
||||
$ids = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
while ($uri) {
|
||||
$response = Invoke-PersonaGraphRequest -Uri $uri
|
||||
|
||||
if ($null -eq $response -or -not $response.ContainsKey('value')) {
|
||||
throw 'Role assignment endpoint returned an unexpected response shape.'
|
||||
}
|
||||
|
||||
foreach ($assignment in $response['value']) {
|
||||
if ($assignment['roleDefinitionId']) { $ids.Add([string]$assignment['roleDefinitionId']) }
|
||||
}
|
||||
|
||||
$uri = $response.ContainsKey('@odata.nextLink') ? $response['@odata.nextLink'] : $null
|
||||
}
|
||||
|
||||
, $ids.ToArray()
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
function Get-PersonaGroupMembership {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Retrieves group membership and directory roles for one user.
|
||||
|
||||
.DESCRIPTION
|
||||
Returns a MembershipRecord — ALWAYS. On failure it returns a record with the
|
||||
affected facet unretrieved and a FailureReason. It never returns an empty
|
||||
list on failure and never throws past the per-user boundary.
|
||||
|
||||
That single behaviour is what makes FR-013 work: unknown membership becomes
|
||||
EvaluationError, never a false non-match. If this function ever throws or
|
||||
returns empty on error, a transient Graph outage silently reclassifies
|
||||
privileged accounts.
|
||||
|
||||
Fetches only the facets the configuration actually needs, and each one
|
||||
independently, so a failure in one does not make the others unevaluable.
|
||||
|
||||
.PARAMETER UserObjectId
|
||||
The user to query.
|
||||
|
||||
.PARAMETER NeedDirect
|
||||
Fetch direct group membership.
|
||||
|
||||
.PARAMETER NeedTransitive
|
||||
Fetch transitive group membership.
|
||||
|
||||
.PARAMETER NeedRoles
|
||||
Fetch directory role assignments.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $UserObjectId,
|
||||
|
||||
[switch] $NeedDirect,
|
||||
[switch] $NeedTransitive,
|
||||
[switch] $NeedRoles
|
||||
)
|
||||
|
||||
$direct = @()
|
||||
$transitive = @()
|
||||
$roles = @()
|
||||
$directOk = $false
|
||||
$transitiveOk = $false
|
||||
$rolesOk = $false
|
||||
$directErr = $null
|
||||
$transitiveErr = $null
|
||||
$rolesErr = $null
|
||||
|
||||
if ($NeedDirect) {
|
||||
try {
|
||||
$direct = Get-PersonaGroupIdPage -Uri "/v1.0/users/$UserObjectId/memberOf?`$select=id&`$top=999"
|
||||
$directOk = $true
|
||||
}
|
||||
catch {
|
||||
$directErr = $_.Exception.Message
|
||||
Write-Verbose "Direct membership lookup failed for $UserObjectId : $directErr"
|
||||
}
|
||||
}
|
||||
|
||||
if ($NeedTransitive) {
|
||||
try {
|
||||
$transitive = Get-PersonaGroupIdPage -Uri "/v1.0/users/$UserObjectId/transitiveMemberOf?`$select=id&`$top=999"
|
||||
$transitiveOk = $true
|
||||
}
|
||||
catch {
|
||||
$transitiveErr = $_.Exception.Message
|
||||
Write-Verbose "Transitive membership lookup failed for $UserObjectId : $transitiveErr"
|
||||
}
|
||||
}
|
||||
|
||||
if ($NeedRoles) {
|
||||
try {
|
||||
$roles = Get-PersonaDirectoryRoles -UserObjectId $UserObjectId
|
||||
$rolesOk = $true
|
||||
}
|
||||
catch {
|
||||
$rolesErr = $_.Exception.Message
|
||||
Write-Verbose "Role lookup failed for $UserObjectId : $rolesErr"
|
||||
}
|
||||
}
|
||||
|
||||
$params = @{
|
||||
DirectGroupObjectIds = $direct
|
||||
TransitiveGroupObjectIds = $transitive
|
||||
DirectoryRoleIds = $roles
|
||||
}
|
||||
if ($directOk) { $params['DirectRetrieved'] = $true } elseif ($NeedDirect) { $params['DirectFailureReason'] = $directErr }
|
||||
if ($transitiveOk) { $params['TransitiveRetrieved'] = $true } elseif ($NeedTransitive) { $params['TransitiveFailureReason'] = $transitiveErr }
|
||||
if ($rolesOk) { $params['RolesRetrieved'] = $true } elseif ($NeedRoles) { $params['RolesFailureReason'] = $rolesErr }
|
||||
|
||||
New-PersonaMembershipRecord @params
|
||||
}
|
||||
|
||||
function Get-PersonaGroupIdPage {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Collects group Object IDs across all pages of a membership endpoint.
|
||||
|
||||
.DESCRIPTION
|
||||
memberOf returns directory objects of mixed type. Only group IDs are
|
||||
collected; administrative units and other object types are ignored.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string[]])]
|
||||
param([Parameter(Mandatory)] [string] $Uri)
|
||||
|
||||
$ids = [System.Collections.Generic.List[string]]::new()
|
||||
$next = $Uri
|
||||
|
||||
while ($next) {
|
||||
$response = Invoke-PersonaGraphRequest -Uri $next
|
||||
|
||||
if ($null -eq $response -or -not $response.ContainsKey('value')) {
|
||||
throw 'Membership endpoint returned an unexpected response shape.'
|
||||
}
|
||||
|
||||
foreach ($item in $response['value']) {
|
||||
$type = $item['@odata.type']
|
||||
if ($type -and $type -ne '#microsoft.graph.group') { continue }
|
||||
if ($item['id']) { $ids.Add([string]$item['id']) }
|
||||
}
|
||||
|
||||
$next = $response.ContainsKey('@odata.nextLink') ? $response['@odata.nextLink'] : $null
|
||||
}
|
||||
|
||||
, $ids.ToArray()
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
function Get-PersonaUsers {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Retrieves in-scope user objects, following pagination to exhaustion.
|
||||
|
||||
.DESCRIPTION
|
||||
Requests only the properties enabled rules actually need (FR-005) plus the
|
||||
configured target attribute and the operational fields required for logging.
|
||||
|
||||
Pagination follows @odata.nextLink until absent (FR-004). A truncated
|
||||
enumeration raises rather than returning a partial population — silently
|
||||
classifying half a tenant is worse than failing.
|
||||
|
||||
Emits raw Graph objects. Normalization is ConvertTo-PersonaUserRecord's job;
|
||||
the rule engine never sees what this returns.
|
||||
|
||||
.PARAMETER SelectProperties
|
||||
Property names for $select.
|
||||
|
||||
.PARAMETER UserObjectId
|
||||
Retrieves a single user instead of enumerating (the -UserObjectId path).
|
||||
|
||||
.PARAMETER PageSize
|
||||
$top value. Graph caps user enumeration at 999.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string[]] $SelectProperties,
|
||||
|
||||
[string] $UserObjectId,
|
||||
|
||||
[ValidateRange(1, 999)]
|
||||
[int] $PageSize = 999
|
||||
)
|
||||
|
||||
$select = ($SelectProperties | Select-Object -Unique) -join ','
|
||||
|
||||
if ($UserObjectId) {
|
||||
Write-Verbose "Retrieving single user $UserObjectId"
|
||||
return @(Invoke-PersonaGraphRequest -Uri "/v1.0/users/$UserObjectId`?`$select=$select")
|
||||
}
|
||||
|
||||
$uri = "/v1.0/users?`$select=$select&`$top=$PageSize"
|
||||
$page = 0
|
||||
|
||||
while ($uri) {
|
||||
$page++
|
||||
Write-Verbose "Retrieving user page $page"
|
||||
|
||||
$response = Invoke-PersonaGraphRequest -Uri $uri
|
||||
|
||||
if ($null -eq $response -or -not $response.ContainsKey('value')) {
|
||||
throw "User enumeration returned an unexpected response shape on page $page. Refusing to continue with a partial population."
|
||||
}
|
||||
|
||||
foreach ($user in $response['value']) { $user }
|
||||
|
||||
$uri = $response.ContainsKey('@odata.nextLink') ? $response['@odata.nextLink'] : $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PersonaRequiredProperties {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds the $select list from the configuration and its enabled rules.
|
||||
|
||||
.DESCRIPTION
|
||||
The FR-005 baseline plus the target attribute plus every property an enabled
|
||||
rule references. Properties nothing references are not requested — least
|
||||
privilege applies to data as well as permissions.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string[]])]
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Configuration
|
||||
)
|
||||
|
||||
# Graph property names, which differ in casing from the normalized record.
|
||||
$baseline = @(
|
||||
'id', 'userPrincipalName', 'displayName', 'userType',
|
||||
'accountEnabled', 'companyName', 'jobTitle', 'department'
|
||||
)
|
||||
|
||||
$properties = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($p in $baseline) { $null = $properties.Add($p) }
|
||||
|
||||
if ($Configuration.TargetAttribute) { $null = $properties.Add($Configuration.TargetAttribute) }
|
||||
|
||||
foreach ($name in (Get-PersonaReferencedProperties -Rules $Configuration.Rules)) {
|
||||
# Intrinsics are already covered by the baseline under their Graph names.
|
||||
if ($name -in @('AccountObjectId', 'UserPrincipalName', 'DisplayName', 'UserType', 'AccountEnabled')) { continue }
|
||||
$null = $properties.Add((Get-PersonaGraphPropertyName $name))
|
||||
}
|
||||
|
||||
, @($properties)
|
||||
}
|
||||
|
||||
function Get-PersonaReferencedProperties {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Walks enabled rules and collects every referenced property name.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string[]])]
|
||||
param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Rules)
|
||||
|
||||
$found = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
|
||||
function Walk {
|
||||
param($Node)
|
||||
if ($null -eq $Node) { return }
|
||||
|
||||
if ($Node.PSObject.Properties['conditions'] -and $Node.conditions) {
|
||||
foreach ($child in $Node.conditions) { Walk $child }
|
||||
return
|
||||
}
|
||||
|
||||
if ($Node.PSObject.Properties['property'] -and $Node.property) {
|
||||
$null = $found.Add([string]$Node.property)
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rule in @($Rules)) {
|
||||
if (-not $rule.enabled) { continue }
|
||||
Walk $rule.match
|
||||
}
|
||||
|
||||
, @($found)
|
||||
}
|
||||
|
||||
function Get-PersonaGraphPropertyName {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Maps a normalized property name to its Graph equivalent.
|
||||
|
||||
.DESCRIPTION
|
||||
Extension property names pass through unchanged — they are already in Graph
|
||||
form and are case-sensitive, unlike the built-in properties.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param([Parameter(Mandatory, Position = 0)] [string] $Name)
|
||||
|
||||
if ($Name -like 'extension_*') { return $Name }
|
||||
|
||||
# camelCase the first letter; Graph built-ins are camelCase.
|
||||
if ($Name.Length -gt 0) {
|
||||
return $Name.Substring(0, 1).ToLowerInvariant() + $Name.Substring(1)
|
||||
}
|
||||
|
||||
$Name
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
function Invoke-PersonaGraphRequest {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Issues a Microsoft Graph request with the retry policy from OTD-007.
|
||||
|
||||
.DESCRIPTION
|
||||
The single point at which the engine touches Graph. Direct REST via
|
||||
Invoke-MgGraphRequest (OTD-004), so request bodies are explicit values that
|
||||
tests can assert on — which is what makes SC-005 provable.
|
||||
|
||||
Retry policy:
|
||||
Retryable 429, 500, 502, 503, 504, transport timeout
|
||||
Never 400, 401, 403, 404, 409 (configuration, authorization, or
|
||||
logic defects — retrying masks them)
|
||||
Retry-After honoured when present; overrides computed backoff
|
||||
Attempts max 5, exponential from 1s, full jitter, per-delay cap 60s
|
||||
|
||||
Never logs tokens, Authorization headers, or full response bodies.
|
||||
|
||||
.PARAMETER Uri
|
||||
Absolute or Graph-relative URI.
|
||||
|
||||
.PARAMETER Method
|
||||
HTTP method. Defaults to GET.
|
||||
|
||||
.PARAMETER Body
|
||||
Request body. Passed through unchanged so the caller controls exactly what
|
||||
is sent.
|
||||
|
||||
.PARAMETER MaxAttempts
|
||||
Maximum attempts including the first. Default 5.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Uri,
|
||||
|
||||
[ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')]
|
||||
[string] $Method = 'GET',
|
||||
|
||||
[object] $Body,
|
||||
|
||||
[ValidateRange(1, 10)]
|
||||
[int] $MaxAttempts = 5,
|
||||
|
||||
[int] $BaseDelayMs = 1000,
|
||||
|
||||
[int] $MaxDelayMs = 60000
|
||||
)
|
||||
|
||||
$retryableStatus = @(429, 500, 502, 503, 504)
|
||||
$attempt = 0
|
||||
|
||||
while ($true) {
|
||||
$attempt++
|
||||
|
||||
try {
|
||||
$params = @{ Uri = $Uri; Method = $Method; ErrorAction = 'Stop' }
|
||||
if ($null -ne $Body) {
|
||||
$params['Body'] = ($Body -is [string]) ? $Body : ($Body | ConvertTo-Json -Depth 16 -Compress)
|
||||
$params['ContentType'] = 'application/json'
|
||||
}
|
||||
|
||||
return Invoke-MgGraphRequest @params
|
||||
}
|
||||
catch {
|
||||
$status = Get-PersonaGraphStatusCode -ErrorRecord $_
|
||||
$isRetryable = ($null -eq $status) -or ($status -in $retryableStatus)
|
||||
|
||||
if (-not $isRetryable) {
|
||||
# A definite client-side failure. Retrying would hide a
|
||||
# configuration or authorization defect behind a timeout.
|
||||
throw
|
||||
}
|
||||
|
||||
if ($attempt -ge $MaxAttempts) {
|
||||
throw "Graph request failed after $attempt attempt(s) (last status: $status): $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$retryAfter = Get-PersonaRetryAfterMs -ErrorRecord $_
|
||||
|
||||
if ($null -ne $retryAfter) {
|
||||
$delay = [Math]::Min($retryAfter, $MaxDelayMs)
|
||||
}
|
||||
else {
|
||||
# Exponential with full jitter: a uniform draw from [0, backoff]
|
||||
# rather than backoff itself, so concurrent callers do not retry in
|
||||
# lockstep and re-create the throttling they are backing off from.
|
||||
$backoff = [Math]::Min($BaseDelayMs * [Math]::Pow(2, $attempt - 1), $MaxDelayMs)
|
||||
$delay = Get-Random -Minimum 0 -Maximum ([int]$backoff)
|
||||
}
|
||||
|
||||
Write-Verbose "Graph request attempt $attempt failed with status $status; retrying in $delay ms."
|
||||
Start-Sleep -Milliseconds $delay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PersonaGraphStatusCode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extracts an HTTP status code from a Graph error record. Internal helper.
|
||||
|
||||
.DESCRIPTION
|
||||
Tries the structured properties first, then falls back to the message text.
|
||||
|
||||
The message fallback is not cosmetic. Invoke-MgGraphRequest does not always
|
||||
surface a Response object, and several of its failure paths put the status
|
||||
only in prose: "Response status code does not indicate success: 403
|
||||
(Forbidden)." Without the fallback those failures return $null, which the
|
||||
retry policy treats as a transport error and retries - so a single 403 becomes
|
||||
five requests per account, hammering a tenant that is already refusing and
|
||||
turning an instant authorization failure into a long, expensive one.
|
||||
|
||||
A genuine transport failure still returns $null and is still retried. The
|
||||
distinction being drawn is "no status exists" versus "the status was not in
|
||||
the property I looked at first".
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)] $ErrorRecord)
|
||||
|
||||
$response = $ErrorRecord.Exception.PSObject.Properties['Response']
|
||||
if ($response -and $response.Value) {
|
||||
$code = $response.Value.PSObject.Properties['StatusCode']
|
||||
if ($code -and $code.Value) { return [int]$code.Value }
|
||||
}
|
||||
|
||||
if ($ErrorRecord.Exception.PSObject.Properties['StatusCode']) {
|
||||
return [int]$ErrorRecord.Exception.StatusCode
|
||||
}
|
||||
|
||||
$message = [string]$ErrorRecord.Exception.Message
|
||||
|
||||
foreach ($pattern in @(
|
||||
'status code does not indicate success:\s*(\d{3})'
|
||||
'status(?:\s*code)?[\s:=]+(\d{3})'
|
||||
'HTTP\s+(\d{3})'
|
||||
'\((\d{3})\)'
|
||||
)) {
|
||||
if ($message -match $pattern) {
|
||||
$parsed = [int]$Matches[1]
|
||||
# Only real HTTP error codes. A three-digit number elsewhere in a message
|
||||
# is not a status, and guessing one would suppress a legitimate retry.
|
||||
if ($parsed -ge 400 -and $parsed -le 599) { return $parsed }
|
||||
}
|
||||
}
|
||||
|
||||
# No status anywhere: a transport failure. Retryable.
|
||||
return $null
|
||||
}
|
||||
|
||||
function Test-PersonaEnumerationRecoverable {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
True when a Graph 400 during user enumeration is plausibly caused by the
|
||||
target attribute not existing in this tenant, and safe to retry without it.
|
||||
|
||||
.DESCRIPTION
|
||||
A dev tenant with no app registration has no persona extension property, and
|
||||
requesting it in $select then fails with 400. The obvious approach - reading
|
||||
the offending property name out of the error - does not hold up in practice:
|
||||
Graph often returns a 400 with an empty body, and Invoke-MgGraphRequest then
|
||||
reports only "Response status code does not indicate success: BadRequest
|
||||
(Bad Request)." with no property name and not even a numeric status in the
|
||||
text (Get-PersonaGraphStatusCode still resolves it, from the structured
|
||||
Response.StatusCode rather than the message).
|
||||
|
||||
So this checks the only two facts actually available: the status was 400,
|
||||
and the target attribute was one of the properties requested. That is not
|
||||
proof the attribute is the cause, but confirming it is impossible from what
|
||||
Graph sends back, and the retry this justifies is cheap, read-only, and
|
||||
confined to preview mode - if the attribute was not the problem, the retry
|
||||
fails too and the original error still surfaces untouched.
|
||||
|
||||
.PARAMETER ErrorRecord
|
||||
The error caught from Get-PersonaUsers.
|
||||
|
||||
.PARAMETER TargetAttribute
|
||||
The configured target attribute name.
|
||||
|
||||
.PARAMETER SelectProperties
|
||||
The $select list the failing request used.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([bool])]
|
||||
param(
|
||||
[Parameter(Mandatory)] $ErrorRecord,
|
||||
[Parameter(Mandatory)] [string] $TargetAttribute,
|
||||
[Parameter(Mandatory)] [AllowEmptyCollection()] [string[]] $SelectProperties
|
||||
)
|
||||
|
||||
((Get-PersonaGraphStatusCode -ErrorRecord $ErrorRecord) -eq 400) -and ($SelectProperties -ccontains $TargetAttribute)
|
||||
}
|
||||
|
||||
function Get-PersonaRetryAfterMs {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Reads a Retry-After header, in milliseconds, if present. Internal helper.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)] $ErrorRecord)
|
||||
|
||||
try {
|
||||
$headers = $ErrorRecord.Exception.Response.Headers
|
||||
if (-not $headers) { return $null }
|
||||
|
||||
$value = $null
|
||||
if ($headers.PSObject.Properties['RetryAfter'] -and $headers.RetryAfter.Delta) {
|
||||
$value = [int]$headers.RetryAfter.Delta.TotalSeconds
|
||||
}
|
||||
|
||||
if ($null -ne $value -and $value -gt 0) { return $value * 1000 }
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
function New-PersonaDataCache {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a run-scoped cache for group and role lookups (NFR-002, FR-006).
|
||||
|
||||
.DESCRIPTION
|
||||
The cache exists for exactly one run and is discarded with it. It is never
|
||||
written to disk and never reused across runs — a stale membership record
|
||||
surviving into a later run would reclassify accounts from data nobody
|
||||
checked, which is the same failure mode FR-013 guards against, arriving by a
|
||||
different route.
|
||||
|
||||
Scope is deliberately narrow. Only lookups that are stable for the duration
|
||||
of a single run are cached: a user's membership facets, keyed by Object ID.
|
||||
Nothing derived from a rule evaluation is cached, so the cache can never
|
||||
change a decision — only how many times the same question is asked.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.DataCache
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param()
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.DataCache'
|
||||
Membership = [System.Collections.Generic.Dictionary[string, object]]::new(
|
||||
[System.StringComparer]::OrdinalIgnoreCase)
|
||||
Hits = 0
|
||||
Misses = 0
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PersonaCachedMembership {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns a cached MembershipRecord, or retrieves and caches one.
|
||||
|
||||
.DESCRIPTION
|
||||
A failed lookup is cached alongside a successful one. That is intentional:
|
||||
retrying the same failing endpoint once per rule would multiply load on an
|
||||
endpoint already in trouble, and a user's classification must not depend on
|
||||
which attempt happened to succeed. One answer per user per run, whatever it
|
||||
was.
|
||||
|
||||
.PARAMETER Cache
|
||||
The run-scoped cache from New-PersonaDataCache. When omitted, the retrieval
|
||||
runs uncached — the offline test path uses this.
|
||||
|
||||
.PARAMETER UserObjectId
|
||||
The user to resolve.
|
||||
|
||||
.PARAMETER NeedDirect
|
||||
Direct group membership is required by at least one enabled rule.
|
||||
|
||||
.PARAMETER NeedTransitive
|
||||
Transitive group membership is required by at least one enabled rule.
|
||||
|
||||
.PARAMETER NeedRoles
|
||||
Directory role assignments are required by at least one enabled rule.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[AllowNull()]
|
||||
[object] $Cache,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $UserObjectId,
|
||||
|
||||
[switch] $NeedDirect,
|
||||
[switch] $NeedTransitive,
|
||||
[switch] $NeedRoles
|
||||
)
|
||||
|
||||
if ($null -eq $Cache) {
|
||||
return Get-PersonaGroupMembership -UserObjectId $UserObjectId `
|
||||
-NeedDirect:$NeedDirect -NeedTransitive:$NeedTransitive -NeedRoles:$NeedRoles
|
||||
}
|
||||
|
||||
# The key includes the requested facets. A record fetched for direct membership
|
||||
# only cannot answer a transitive question, and returning it would present an
|
||||
# unretrieved facet as though it had been checked.
|
||||
$key = '{0}|{1}{2}{3}' -f $UserObjectId, [int]$NeedDirect.IsPresent, [int]$NeedTransitive.IsPresent, [int]$NeedRoles.IsPresent
|
||||
|
||||
$existing = $null
|
||||
if ($Cache.Membership.TryGetValue($key, [ref] $existing)) {
|
||||
$Cache.Hits++
|
||||
return $existing
|
||||
}
|
||||
|
||||
$Cache.Misses++
|
||||
$record = Get-PersonaGroupMembership -UserObjectId $UserObjectId `
|
||||
-NeedDirect:$NeedDirect -NeedTransitive:$NeedTransitive -NeedRoles:$NeedRoles
|
||||
|
||||
$Cache.Membership[$key] = $record
|
||||
$record
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
function Invoke-PersonaEngineRun {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
The classification run loop: retrieve, evaluate, compare, report, persist.
|
||||
|
||||
.DESCRIPTION
|
||||
Lives in the module rather than in Invoke-PersonaEngine.ps1 so it can be
|
||||
exercised offline with mocked data providers. That is not a testing
|
||||
convenience - SC-004 requires proof that a -WhatIf run issues zero writes
|
||||
across a full population, and a loop that only exists inside an entry script
|
||||
needing a live tenant cannot be proven at all. What ships and what is tested
|
||||
are the same code.
|
||||
|
||||
The write gate is supplied by the caller as a scriptblock, not re-derived
|
||||
here. Invoke-PersonaEngine.ps1 passes one that closes over
|
||||
$PSCmdlet.ShouldProcess, so there is still exactly one origin for the
|
||||
decision (Principle III); this function does not know what -WhatIf is and
|
||||
cannot accidentally disagree with it.
|
||||
|
||||
Per-user failures are contained. A membership lookup that fails yields
|
||||
EvaluationError for that account and the run continues; a write that fails
|
||||
yields UpdateFailed and the run continues. Only enumeration and
|
||||
authentication failures end a run, because those affect the whole population
|
||||
rather than one account.
|
||||
|
||||
.PARAMETER Configuration
|
||||
The loaded configuration.
|
||||
|
||||
.PARAMETER TargetAttribute
|
||||
The resolved target attribute.
|
||||
|
||||
.PARAMETER Context
|
||||
The audit context.
|
||||
|
||||
.PARAMETER AuditParameters
|
||||
Splat for Write-PersonaAuditRecord.
|
||||
|
||||
.PARAMETER IsEnforcing
|
||||
Whether the run-level gate returned true. Controls the Action assigned by
|
||||
Compare-PersonaValue; the per-user gate below still applies.
|
||||
|
||||
.PARAMETER ShouldProcessGate
|
||||
Scriptblock taking (upn, description) and returning a boolean. Called once
|
||||
per user that would otherwise be written. Defaults to a gate that always
|
||||
refuses - the safe default, so a caller that forgets to supply one previews
|
||||
rather than writes.
|
||||
|
||||
.PARAMETER UserObjectId
|
||||
Single-user run.
|
||||
|
||||
.PARAMETER Tracing
|
||||
Include ConditionTrace on results and audit records.
|
||||
|
||||
.PARAMETER StartedUtc
|
||||
Run start timestamp, threaded down to Write-PersonaSummary so each summary
|
||||
can show elapsed wall-clock time. Optional; a caller that omits it just gets
|
||||
summaries without an elapsed figure.
|
||||
|
||||
.PARAMETER ResultsPath
|
||||
Destination for the per-account results CSV. When supplied, it is
|
||||
(re)written after every summary - interim and final. Optional; a caller
|
||||
that omits it skips the CSV export entirely.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.RunOutcome carrying the counters and the exit code.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Configuration,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $TargetAttribute,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Context,
|
||||
|
||||
[hashtable] $AuditParameters = @{ Destination = 'none' },
|
||||
|
||||
[switch] $IsEnforcing,
|
||||
|
||||
[scriptblock] $ShouldProcessGate = { param($Target, $Description) $false },
|
||||
|
||||
[string] $UserObjectId,
|
||||
|
||||
[switch] $Tracing,
|
||||
|
||||
[Nullable[datetime]] $StartedUtc,
|
||||
|
||||
[string] $ResultsPath
|
||||
)
|
||||
|
||||
$EXIT_OK = 0
|
||||
$EXIT_ENUMERATION = 3
|
||||
$EXIT_DATA = 4
|
||||
$EXIT_RECONCILIATION = 5
|
||||
|
||||
$exitCode = $EXIT_OK
|
||||
|
||||
$facets = Get-PersonaRequiredFacets -Rules $Configuration.Rules -DefaultMembershipMode $Configuration.DefaultMembershipMode
|
||||
$needMembership = [bool]($facets.Direct -or $facets.Transitive -or $facets.Roles)
|
||||
|
||||
$selectProperties = Get-PersonaRequiredProperties -Configuration $Configuration
|
||||
$cache = New-PersonaDataCache
|
||||
$counters = New-PersonaRunCounter -Rules $Configuration.Rules
|
||||
|
||||
$users = $null
|
||||
try {
|
||||
$users = $UserObjectId `
|
||||
? @(Get-PersonaUsers -SelectProperties $selectProperties -UserObjectId $UserObjectId) `
|
||||
: @(Get-PersonaUsers -SelectProperties $selectProperties)
|
||||
}
|
||||
catch {
|
||||
if ((-not $IsEnforcing) -and (Test-PersonaEnumerationRecoverable -ErrorRecord $_ -TargetAttribute $TargetAttribute -SelectProperties $selectProperties)) {
|
||||
# Dev/test tenants often have no app registration yet, so the persona
|
||||
# extension property was never created. Preview mode never writes
|
||||
# regardless of what StoredPersona holds, so treating the attribute as
|
||||
# absent (null) here is safe and lets development proceed without one.
|
||||
# Enforcement still fails loudly - IsEnforcing gates this precisely
|
||||
# because writing to an attribute that does not exist must never be
|
||||
# silently tolerated.
|
||||
Write-Warning "Graph rejected the request with a 400 while '$TargetAttribute' was in the requested properties. Retrying without it and treating it as null for this What-If run - if the retry also fails, the underlying error will be reported."
|
||||
|
||||
$fallbackProperties = @($selectProperties | Where-Object { $_ -cne $TargetAttribute })
|
||||
|
||||
try {
|
||||
$users = $UserObjectId `
|
||||
? @(Get-PersonaUsers -SelectProperties $fallbackProperties -UserObjectId $UserObjectId) `
|
||||
: @(Get-PersonaUsers -SelectProperties $fallbackProperties)
|
||||
}
|
||||
catch {
|
||||
return New-PersonaRunOutcome -Counters $counters -ExitCode $EXIT_ENUMERATION -FailureReason $_.Exception.Message
|
||||
}
|
||||
}
|
||||
else {
|
||||
# A partial population is worse than none: half a tenant classified looks
|
||||
# like a successful run to everything downstream.
|
||||
return New-PersonaRunOutcome -Counters $counters -ExitCode $EXIT_ENUMERATION -FailureReason $_.Exception.Message
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($graphUser in $users) {
|
||||
|
||||
$membership = $null
|
||||
if ($needMembership) {
|
||||
$membership = Get-PersonaCachedMembership -Cache $cache `
|
||||
-UserObjectId ([string](Get-PersonaMemberValue -Item $graphUser -Key 'id')) `
|
||||
-NeedDirect:([bool]$facets.Direct) `
|
||||
-NeedTransitive:([bool]$facets.Transitive) `
|
||||
-NeedRoles:([bool]$facets.Roles)
|
||||
}
|
||||
|
||||
$record = ConvertTo-PersonaUserRecord -GraphUser $graphUser -TargetAttribute $TargetAttribute -Membership $membership
|
||||
|
||||
$result = Resolve-UserPersona -UserRecord $record -Rules $Configuration.Rules `
|
||||
-MaxDepth $Configuration.MaxConditionDepth `
|
||||
-DefaultMembershipMode $Configuration.DefaultMembershipMode `
|
||||
-IncludeTrace:$Tracing
|
||||
|
||||
$result = Compare-PersonaValue -Result $result -IsEnforcing:$IsEnforcing `
|
||||
-TargetAttribute $TargetAttribute -ApprovedWritableAttributes $Configuration.ApprovedWritableAttributes
|
||||
|
||||
$previousValue = $null
|
||||
|
||||
# The only branch from which a write is reachable. Compare-PersonaValue has
|
||||
# already applied FR-016 conditions 1-3; the gate below is condition 4.
|
||||
if ($result.Action -eq 'Updated') {
|
||||
|
||||
$description = "Set '$TargetAttribute' to '$($result.CalculatedPersona)'"
|
||||
|
||||
if (& $ShouldProcessGate $result.UserPrincipalName $description) {
|
||||
|
||||
# Captured before the PATCH, never read back afterwards - a read-back
|
||||
# returns the new value, and OTD-010 rollback needs the old one.
|
||||
$previousValue = [string]$result.StoredPersona
|
||||
|
||||
$write = Set-UserPersonaAttribute `
|
||||
-UserObjectId $result.AccountObjectId `
|
||||
-AttributeName $TargetAttribute `
|
||||
-Value ([string]$result.CalculatedPersona) `
|
||||
-PreviousValue $previousValue `
|
||||
-TargetAttribute $TargetAttribute `
|
||||
-ApprovedWritableAttributes $Configuration.ApprovedWritableAttributes `
|
||||
-Confirmed
|
||||
|
||||
if (-not $write.Succeeded) {
|
||||
$result.Action = 'UpdateFailed'
|
||||
$previousValue = $null
|
||||
}
|
||||
}
|
||||
else {
|
||||
$result.Action = 'WouldUpdate'
|
||||
}
|
||||
}
|
||||
|
||||
Write-UserPersonaResult -Result $result
|
||||
|
||||
Add-PersonaRunResult -Counters $counters -Result $result -UserRecord $record
|
||||
|
||||
New-PersonaAuditRecord -Context $Context -RecordType 'UserEvent' -Result $result `
|
||||
-PreviousValue $previousValue -IncludeTrace:$Tracing |
|
||||
Write-PersonaAuditRecord @AuditParameters
|
||||
|
||||
if ($Configuration.SummaryInterval -gt 0 -and ($counters.Processed % $Configuration.SummaryInterval) -eq 0) {
|
||||
|
||||
Write-PersonaSummary -Counters $counters -SummaryType 'Interim' -Mode $Context.Mode -StartedUtc $StartedUtc
|
||||
|
||||
if ($ResultsPath) { Export-PersonaResultsCsv -Counters $counters -Path $ResultsPath }
|
||||
|
||||
New-PersonaAuditRecord -Context $Context -RecordType 'Summary' -Counters $counters `
|
||||
-Properties @{ summaryType = 'Interim' } | Write-PersonaAuditRecord @AuditParameters
|
||||
|
||||
if (-not (Test-PersonaReconciliation -Counters $counters)) {
|
||||
$exitCode = $EXIT_RECONCILIATION
|
||||
|
||||
New-PersonaAuditRecord -Context $Context -RecordType 'EngineDefect' `
|
||||
-Properties (Get-PersonaReconciliationDetail -Counters $counters) |
|
||||
Write-PersonaAuditRecord @AuditParameters
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Always emitted, whatever the interval - including 0 (FR-020).
|
||||
Write-PersonaSummary -Counters $counters -SummaryType 'Final' -Mode $Context.Mode -StartedUtc $StartedUtc
|
||||
|
||||
if ($ResultsPath) { Export-PersonaResultsCsv -Counters $counters -Path $ResultsPath }
|
||||
|
||||
New-PersonaAuditRecord -Context $Context -RecordType 'Summary' -Counters $counters `
|
||||
-Properties @{ summaryType = 'Final' } | Write-PersonaAuditRecord @AuditParameters
|
||||
|
||||
if (-not (Test-PersonaReconciliation -Counters $counters)) {
|
||||
$exitCode = $EXIT_RECONCILIATION
|
||||
|
||||
New-PersonaAuditRecord -Context $Context -RecordType 'EngineDefect' `
|
||||
-Properties (Get-PersonaReconciliationDetail -Counters $counters) |
|
||||
Write-PersonaAuditRecord @AuditParameters
|
||||
}
|
||||
elseif ($null -ne $Configuration.EvaluationErrorThreshold -and
|
||||
$counters.EvaluationError -gt $Configuration.EvaluationErrorThreshold) {
|
||||
|
||||
# Past this count the population was classified from data that could not be
|
||||
# trusted. Stored values were preserved (FR-014), so nothing is damaged - but
|
||||
# reporting success would invite someone to draw conclusions from the run.
|
||||
$exitCode = $EXIT_DATA
|
||||
|
||||
New-PersonaAuditRecord -Context $Context -RecordType 'EngineDefect' -Properties @{
|
||||
severity = 'Error'
|
||||
defect = 'EvaluationErrorThresholdExceeded'
|
||||
evaluationError = [int]$counters.EvaluationError
|
||||
threshold = [int]$Configuration.EvaluationErrorThreshold
|
||||
processed = [int]$counters.Processed
|
||||
description = 'Too many accounts could not be evaluated from trusted data. Stored personas were preserved; no classification conclusion should be drawn from this run.'
|
||||
} | Write-PersonaAuditRecord @AuditParameters
|
||||
}
|
||||
|
||||
New-PersonaRunOutcome -Counters $counters -ExitCode $exitCode
|
||||
}
|
||||
|
||||
function New-PersonaRunOutcome {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Wraps the run result for the entry script.
|
||||
|
||||
.DESCRIPTION
|
||||
Carries the counters and the exit code together, so the caller cannot report
|
||||
an exit code that disagrees with the numbers it prints.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Counters,
|
||||
[Parameter(Mandatory)] [int] $ExitCode,
|
||||
[string] $FailureReason
|
||||
)
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.RunOutcome'
|
||||
Counters = $Counters
|
||||
ExitCode = $ExitCode
|
||||
FailureReason = $FailureReason
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
function ConvertTo-PersonaMembershipRecord {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts raw membership responses into a normalized MembershipRecord.
|
||||
|
||||
.DESCRIPTION
|
||||
The membership half of the normalization boundary (Principle IV). Callers
|
||||
that already hold raw Graph collections — the offline replay path, and any
|
||||
future provider that fetches membership in bulk rather than per user — use
|
||||
this instead of reaching for New-PersonaMembershipRecord directly, so the
|
||||
filtering rules live in one place.
|
||||
|
||||
Two filtering rules are applied and are the reason this function exists
|
||||
rather than a straight constructor call:
|
||||
|
||||
1. memberOf and transitiveMemberOf return directory objects of mixed type.
|
||||
Only `#microsoft.graph.group` entries become group Object IDs;
|
||||
administrative units and directory roles arriving on that endpoint are
|
||||
discarded. An administrative unit ID treated as a group ID would never
|
||||
match, which reads as "not a member" — a false non-match, the exact
|
||||
outcome FR-013 exists to prevent.
|
||||
|
||||
2. Role assignments are reduced to their roleDefinitionId (the role template
|
||||
ID), which is stable across tenants. Assignment instance IDs are not, so
|
||||
a configuration written against them would not survive a tenant move.
|
||||
|
||||
Retrieval status is supplied by the caller, never inferred from an empty
|
||||
collection. An empty list means "checked, member of nothing"; only an unset
|
||||
flag means "unknown".
|
||||
|
||||
.PARAMETER DirectMemberOf
|
||||
Raw objects from the memberOf endpoint. Omit when not retrieved.
|
||||
|
||||
.PARAMETER TransitiveMemberOf
|
||||
Raw objects from the transitiveMemberOf endpoint. Omit when not retrieved.
|
||||
|
||||
.PARAMETER RoleAssignments
|
||||
Raw objects from the roleAssignments endpoint. Omit when not retrieved.
|
||||
|
||||
.PARAMETER DirectRetrieved
|
||||
The direct lookup completed.
|
||||
|
||||
.PARAMETER TransitiveRetrieved
|
||||
The transitive lookup completed.
|
||||
|
||||
.PARAMETER RolesRetrieved
|
||||
The role lookup completed.
|
||||
|
||||
.PARAMETER DirectFailureReason
|
||||
Sanitized reason the direct lookup failed.
|
||||
|
||||
.PARAMETER TransitiveFailureReason
|
||||
Sanitized reason the transitive lookup failed.
|
||||
|
||||
.PARAMETER RolesFailureReason
|
||||
Sanitized reason the role lookup failed.
|
||||
|
||||
.EXAMPLE
|
||||
ConvertTo-PersonaMembershipRecord -DirectMemberOf $raw -DirectRetrieved
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.MembershipRecord
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[AllowNull()]
|
||||
[object[]] $DirectMemberOf,
|
||||
|
||||
[AllowNull()]
|
||||
[object[]] $TransitiveMemberOf,
|
||||
|
||||
[AllowNull()]
|
||||
[object[]] $RoleAssignments,
|
||||
|
||||
[switch] $DirectRetrieved,
|
||||
[switch] $TransitiveRetrieved,
|
||||
[switch] $RolesRetrieved,
|
||||
|
||||
[string] $DirectFailureReason,
|
||||
[string] $TransitiveFailureReason,
|
||||
[string] $RolesFailureReason
|
||||
)
|
||||
|
||||
$params = @{
|
||||
DirectGroupObjectIds = ConvertTo-PersonaGroupIdList -Objects $DirectMemberOf
|
||||
TransitiveGroupObjectIds = ConvertTo-PersonaGroupIdList -Objects $TransitiveMemberOf
|
||||
DirectoryRoleIds = ConvertTo-PersonaRoleIdList -Objects $RoleAssignments
|
||||
}
|
||||
|
||||
if ($DirectRetrieved) { $params['DirectRetrieved'] = $true }
|
||||
if ($TransitiveRetrieved) { $params['TransitiveRetrieved'] = $true }
|
||||
if ($RolesRetrieved) { $params['RolesRetrieved'] = $true }
|
||||
|
||||
if ($DirectFailureReason) { $params['DirectFailureReason'] = $DirectFailureReason }
|
||||
if ($TransitiveFailureReason) { $params['TransitiveFailureReason'] = $TransitiveFailureReason }
|
||||
if ($RolesFailureReason) { $params['RolesFailureReason'] = $RolesFailureReason }
|
||||
|
||||
New-PersonaMembershipRecord @params
|
||||
}
|
||||
|
||||
function ConvertTo-PersonaGroupIdList {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extracts group Object IDs from a mixed directory-object collection.
|
||||
|
||||
.DESCRIPTION
|
||||
Entries carrying an @odata.type other than #microsoft.graph.group are
|
||||
discarded. An entry with no @odata.type is kept: the membership endpoints
|
||||
omit the annotation when the collection is homogeneous, and discarding those
|
||||
would silently empty the list.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string[]])]
|
||||
param([AllowNull()] [object[]] $Objects)
|
||||
|
||||
$ids = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
foreach ($item in @($Objects)) {
|
||||
if ($null -eq $item) { continue }
|
||||
|
||||
$type = Get-PersonaMemberValue -Item $item -Key '@odata.type'
|
||||
if ($type -and $type -ne '#microsoft.graph.group') { continue }
|
||||
|
||||
$id = Get-PersonaMemberValue -Item $item -Key 'id'
|
||||
if ($id) { $ids.Add([string]$id) }
|
||||
}
|
||||
|
||||
, $ids.ToArray()
|
||||
}
|
||||
|
||||
function ConvertTo-PersonaRoleIdList {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extracts role template IDs from a role-assignment collection.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string[]])]
|
||||
param([AllowNull()] [object[]] $Objects)
|
||||
|
||||
$ids = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
foreach ($item in @($Objects)) {
|
||||
if ($null -eq $item) { continue }
|
||||
|
||||
$id = Get-PersonaMemberValue -Item $item -Key 'roleDefinitionId'
|
||||
if ($id) { $ids.Add([string]$id) }
|
||||
}
|
||||
|
||||
, $ids.ToArray()
|
||||
}
|
||||
|
||||
function Get-PersonaMemberValue {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Reads a key from either a hashtable or an object.
|
||||
|
||||
.DESCRIPTION
|
||||
Invoke-MgGraphRequest returns hashtables; fixtures loaded from JSON arrive as
|
||||
PSCustomObjects. Both shapes reach normalization, so both are handled here
|
||||
rather than forcing every call site to know which it has.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Item,
|
||||
[Parameter(Mandatory)] [string] $Key
|
||||
)
|
||||
|
||||
if ($Item -is [System.Collections.IDictionary]) {
|
||||
return $Item.Contains($Key) ? $Item[$Key] : $null
|
||||
}
|
||||
|
||||
$prop = $Item.PSObject.Properties[$Key]
|
||||
$prop ? $prop.Value : $null
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
function ConvertTo-PersonaUserRecord {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts a raw Graph user object into a normalized UserRecord.
|
||||
|
||||
.DESCRIPTION
|
||||
The normalization boundary (Principle IV). Everything downstream of this
|
||||
function is testable offline with synthetic data, because nothing downstream
|
||||
knows Graph exists.
|
||||
|
||||
Graph returns hashtables from Invoke-MgGraphRequest, with camelCase keys and
|
||||
the persona value under its full extension property name.
|
||||
|
||||
.PARAMETER GraphUser
|
||||
The raw object from Get-PersonaUsers.
|
||||
|
||||
.PARAMETER TargetAttribute
|
||||
Name of the persona attribute, read into StoredPersona.
|
||||
|
||||
.PARAMETER Membership
|
||||
Optional MembershipRecord. Omitted when no rule needs membership data.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory, ValueFromPipeline)]
|
||||
[object] $GraphUser,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $TargetAttribute,
|
||||
|
||||
[object] $Membership
|
||||
)
|
||||
|
||||
process {
|
||||
$get = {
|
||||
param($key)
|
||||
if ($GraphUser -is [System.Collections.IDictionary]) {
|
||||
return $GraphUser.Contains($key) ? $GraphUser[$key] : $null
|
||||
}
|
||||
$prop = $GraphUser.PSObject.Properties[$key]
|
||||
return $prop ? $prop.Value : $null
|
||||
}
|
||||
|
||||
$id = & $get 'id'
|
||||
$upn = & $get 'userPrincipalName'
|
||||
|
||||
if (-not $id -or -not $upn) {
|
||||
# An identity-less record is an upstream defect, not a user to skip.
|
||||
# Skipping would silently shrink the population and still reconcile.
|
||||
throw "Graph user object is missing 'id' or 'userPrincipalName'; cannot normalize."
|
||||
}
|
||||
|
||||
$properties = @{
|
||||
CompanyName = & $get 'companyName'
|
||||
JobTitle = & $get 'jobTitle'
|
||||
Department = & $get 'department'
|
||||
}
|
||||
|
||||
# Any additional selected property, including extension attributes, is
|
||||
# carried through so a rule can reference it without a code change.
|
||||
$keys = ($GraphUser -is [System.Collections.IDictionary]) ? $GraphUser.Keys : $GraphUser.PSObject.Properties.Name
|
||||
foreach ($key in $keys) {
|
||||
if ($key -like '@odata*') { continue }
|
||||
if (-not $properties.ContainsKey($key)) { $properties[$key] = & $get $key }
|
||||
}
|
||||
|
||||
$enabled = & $get 'accountEnabled'
|
||||
|
||||
New-PersonaUserRecord `
|
||||
-AccountObjectId ([string]$id) `
|
||||
-UserPrincipalName ([string]$upn) `
|
||||
-DisplayName ([string](& $get 'displayName')) `
|
||||
-UserType ([string](& $get 'userType')) `
|
||||
-AccountEnabled ($null -eq $enabled ? $true : [bool]$enabled) `
|
||||
-Properties $properties `
|
||||
-StoredPersona ([string](& $get $TargetAttribute)) `
|
||||
-Membership $Membership
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
function New-PersonaMembershipRecord {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a normalized MembershipRecord.
|
||||
|
||||
.DESCRIPTION
|
||||
Holds three independently-retrieved facets, each with its own retrieval
|
||||
status: direct group membership, transitive group membership, and directory
|
||||
role assignments.
|
||||
|
||||
Three facets rather than one "mode" because RE-007 makes membership mode a
|
||||
per-condition choice. A single rule set may legitimately ask for transitive
|
||||
membership in one rule and direct membership in another, so a record
|
||||
carrying only one mode cannot answer both — every user would become an
|
||||
EvaluationError on whichever question the record could not serve.
|
||||
|
||||
Independent statuses also mean a failure is contained: if the transitive
|
||||
lookup times out but the direct lookup succeeded, only conditions that need
|
||||
transitive data become Unknown. Collapsing them into one flag would turn one
|
||||
slow endpoint into a tenant-wide outage.
|
||||
|
||||
Every Retrieved flag defaults to $false. An unset flag means "unknown",
|
||||
never "not a member" — so a forgotten flag degrades to EvaluationError
|
||||
(FR-013) instead of silently misclassifying a privileged account.
|
||||
|
||||
.PARAMETER DirectGroupObjectIds
|
||||
Groups the user is a direct member of.
|
||||
|
||||
.PARAMETER TransitiveGroupObjectIds
|
||||
Groups the user is a transitive member of.
|
||||
|
||||
.PARAMETER DirectoryRoleIds
|
||||
Directory roles assigned to the user.
|
||||
|
||||
.PARAMETER DirectRetrieved
|
||||
Set only when the direct membership lookup genuinely completed.
|
||||
|
||||
.PARAMETER TransitiveRetrieved
|
||||
Set only when the transitive membership lookup genuinely completed.
|
||||
|
||||
.PARAMETER RolesRetrieved
|
||||
Set only when the role lookup genuinely completed.
|
||||
|
||||
.EXAMPLE
|
||||
New-PersonaMembershipRecord -DirectGroupObjectIds $ids -DirectRetrieved
|
||||
|
||||
.EXAMPLE
|
||||
New-PersonaMembershipRecord -TransitiveFailureReason 'Graph 503 after 5 attempts'
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[string[]] $DirectGroupObjectIds = @(),
|
||||
[string[]] $TransitiveGroupObjectIds = @(),
|
||||
[string[]] $DirectoryRoleIds = @(),
|
||||
|
||||
[switch] $DirectRetrieved,
|
||||
[switch] $TransitiveRetrieved,
|
||||
[switch] $RolesRetrieved,
|
||||
|
||||
[string] $DirectFailureReason,
|
||||
[string] $TransitiveFailureReason,
|
||||
[string] $RolesFailureReason,
|
||||
|
||||
# Marks every facet as retrieved. Convenience for tests and for the common
|
||||
# production case where all required lookups succeeded.
|
||||
[switch] $AllRetrieved
|
||||
)
|
||||
|
||||
foreach ($facet in @(
|
||||
@{ Name = 'Direct'; Retrieved = $DirectRetrieved; Reason = $DirectFailureReason }
|
||||
@{ Name = 'Transitive'; Retrieved = $TransitiveRetrieved; Reason = $TransitiveFailureReason }
|
||||
@{ Name = 'Roles'; Retrieved = $RolesRetrieved; Reason = $RolesFailureReason }
|
||||
)) {
|
||||
# A success claim alongside a failure reason is a caller bug, not a state
|
||||
# to interpret. Fail loudly rather than guess which was meant.
|
||||
if (($facet.Retrieved -or $AllRetrieved) -and $facet.Reason) {
|
||||
throw "Membership facet '$($facet.Name)' cannot be both retrieved and carry a failure reason."
|
||||
}
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.MembershipRecord'
|
||||
|
||||
DirectGroupObjectIds = @($DirectGroupObjectIds)
|
||||
DirectRetrieved = [bool]($DirectRetrieved -or $AllRetrieved)
|
||||
DirectFailureReason = $DirectFailureReason
|
||||
|
||||
TransitiveGroupObjectIds = @($TransitiveGroupObjectIds)
|
||||
TransitiveRetrieved = [bool]($TransitiveRetrieved -or $AllRetrieved)
|
||||
TransitiveFailureReason = $TransitiveFailureReason
|
||||
|
||||
DirectoryRoleIds = @($DirectoryRoleIds)
|
||||
RolesRetrieved = [bool]($RolesRetrieved -or $AllRetrieved)
|
||||
RolesFailureReason = $RolesFailureReason
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
function New-PersonaUserRecord {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a normalized UserRecord — the only user shape the rule engine sees.
|
||||
|
||||
.DESCRIPTION
|
||||
Constitution Principle IV: the rule engine must never receive a raw directory
|
||||
response. This function is that boundary. Everything downstream of it is
|
||||
testable offline with synthetic data.
|
||||
|
||||
Properties are stored in a case-insensitive dictionary so rule authors need
|
||||
not match directory casing (RE-006). An absent property returns $null, which
|
||||
ordinary string comparisons treat as empty (FR-012).
|
||||
|
||||
.PARAMETER AccountObjectId
|
||||
Immutable directory Object ID. Required; approved for logs.
|
||||
|
||||
.PARAMETER UserPrincipalName
|
||||
Required; approved for logs.
|
||||
|
||||
.PARAMETER Properties
|
||||
Evaluable property values. Copied into a case-insensitive dictionary.
|
||||
|
||||
.PARAMETER Membership
|
||||
A MembershipRecord. When omitted, an empty record with every facet marked
|
||||
unretrieved is used, so any membership condition evaluated against it yields
|
||||
Unknown rather than a false non-match. A rule set with no membership
|
||||
conditions never consults it.
|
||||
|
||||
.EXAMPLE
|
||||
New-PersonaUserRecord -AccountObjectId $id -UserPrincipalName $upn -Properties @{ Department = 'Finance' }
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $AccountObjectId,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $UserPrincipalName,
|
||||
|
||||
[string] $DisplayName,
|
||||
|
||||
[string] $UserType,
|
||||
|
||||
[bool] $AccountEnabled = $true,
|
||||
|
||||
[hashtable] $Properties = @{},
|
||||
|
||||
[AllowNull()]
|
||||
[string] $StoredPersona,
|
||||
|
||||
[AllowNull()]
|
||||
[pscustomobject] $Membership
|
||||
)
|
||||
|
||||
# StringComparer::OrdinalIgnoreCase gives case-insensitive lookup without the
|
||||
# cost of normalizing every key on every read.
|
||||
$bag = [System.Collections.Generic.Dictionary[string, object]]::new(
|
||||
[System.StringComparer]::OrdinalIgnoreCase)
|
||||
|
||||
foreach ($key in $Properties.Keys) {
|
||||
$bag[[string]$key] = $Properties[$key]
|
||||
}
|
||||
|
||||
# Intrinsic fields are also addressable as properties so a rule can target
|
||||
# UserPrincipalName or AccountEnabled without a separate condition type.
|
||||
# Explicit entries in -Properties win, so a caller can override for testing.
|
||||
foreach ($intrinsic in @(
|
||||
@{ Name = 'AccountObjectId'; Value = $AccountObjectId }
|
||||
@{ Name = 'UserPrincipalName'; Value = $UserPrincipalName }
|
||||
@{ Name = 'DisplayName'; Value = $DisplayName }
|
||||
@{ Name = 'UserType'; Value = $UserType }
|
||||
@{ Name = 'AccountEnabled'; Value = $AccountEnabled }
|
||||
)) {
|
||||
if (-not $bag.ContainsKey($intrinsic.Name)) {
|
||||
$bag[$intrinsic.Name] = $intrinsic.Value
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $Membership) {
|
||||
# Unretrieved, not empty: an absent lookup is unknown, never "member of
|
||||
# nothing". The safe default is the one that produces EvaluationError.
|
||||
$Membership = New-PersonaMembershipRecord
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.UserRecord'
|
||||
AccountObjectId = $AccountObjectId
|
||||
UserPrincipalName = $UserPrincipalName
|
||||
DisplayName = $DisplayName
|
||||
UserType = $UserType
|
||||
AccountEnabled = $AccountEnabled
|
||||
Properties = $bag
|
||||
StoredPersona = $StoredPersona
|
||||
Membership = $Membership
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
function Compare-PersonaValue {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Decides the action for a decision result by comparing stored and calculated
|
||||
values (FR-015, FR-016).
|
||||
|
||||
.DESCRIPTION
|
||||
Sets Action on the result and returns it. The state machine (data-model.md):
|
||||
|
||||
EvaluationError -> Skipped no write, ever
|
||||
Calculated == Stored -> Unchanged
|
||||
Calculated != Stored, preview mode -> WouldUpdate no request built
|
||||
Calculated != Stored, enforce mode -> Updated / UpdateFailed
|
||||
|
||||
Comparison is ORDINAL and case-sensitive, unlike rule evaluation. A stored
|
||||
value of 'employee' against a calculated 'Employee' is a real difference
|
||||
worth correcting, and treating it as equal would leave the directory
|
||||
permanently inconsistent with the rule set. Rule matching stays
|
||||
case-insensitive (RE-006); only change detection is exact.
|
||||
|
||||
.PARAMETER Result
|
||||
A PersonaDecisionResult.
|
||||
|
||||
.PARAMETER IsEnforcing
|
||||
Whether the caller's ShouldProcess gate returned true.
|
||||
|
||||
.PARAMETER TargetAttribute
|
||||
The configured target attribute.
|
||||
|
||||
.PARAMETER ApprovedWritableAttributes
|
||||
The approved list. A target absent from it can never be written.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Result,
|
||||
|
||||
[switch] $IsEnforcing,
|
||||
|
||||
[string] $TargetAttribute,
|
||||
|
||||
[string[]] $ApprovedWritableAttributes = @()
|
||||
)
|
||||
|
||||
if ($Result.Outcome -eq 'EvaluationError') {
|
||||
# FR-014: preserve the stored value. No write is attempted, and this is the
|
||||
# only branch that must never be reachable by any later condition.
|
||||
$Result.Action = 'Skipped'
|
||||
return $Result
|
||||
}
|
||||
|
||||
$stored = [string]$Result.StoredPersona
|
||||
$calculated = [string]$Result.CalculatedPersona
|
||||
|
||||
if ([string]::Equals($stored, $calculated, [System.StringComparison]::Ordinal)) {
|
||||
$Result.Action = 'Unchanged'
|
||||
return $Result
|
||||
}
|
||||
|
||||
# A target that is blank or unapproved can never be written, whatever the mode.
|
||||
$targetValid = $TargetAttribute -and ($TargetAttribute -in $ApprovedWritableAttributes)
|
||||
if (-not $targetValid) {
|
||||
$Result.Action = 'Skipped'
|
||||
return $Result
|
||||
}
|
||||
|
||||
$Result.Action = $IsEnforcing ? 'Updated' : 'WouldUpdate'
|
||||
return $Result
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
function New-PersonaWriteBody {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds the PATCH body for a persona write — the only function permitted to
|
||||
do so (SC-005, NFR-006).
|
||||
|
||||
.DESCRIPTION
|
||||
Returns a hashtable whose Count is exactly 1. Nothing else in the codebase
|
||||
constructs a directory write body, so the single-attribute guarantee is a
|
||||
property of one testable function rather than a convention every call site
|
||||
must remember.
|
||||
|
||||
Two rejections are enforced here, both throwing rather than returning a
|
||||
corrected body. A caller that asked to write the wrong attribute has a
|
||||
defect; quietly substituting the right one would hide it until the day the
|
||||
substitution was also wrong.
|
||||
|
||||
1. AttributeName must equal the configured target attribute.
|
||||
2. The target attribute must appear in approvedWritableAttributes.
|
||||
|
||||
The second check is deliberately redundant with configuration validation.
|
||||
Validation runs once at startup against the file; this runs on every write
|
||||
against the values actually in hand, so a configuration object mutated
|
||||
mid-run still cannot widen the blast radius.
|
||||
|
||||
.PARAMETER AttributeName
|
||||
The attribute to write. Must equal TargetAttribute.
|
||||
|
||||
.PARAMETER Value
|
||||
The calculated persona. May be an empty string to clear the attribute; may
|
||||
not be $null, which Graph would interpret as a removal the engine never
|
||||
intends to request implicitly.
|
||||
|
||||
.PARAMETER TargetAttribute
|
||||
The configured target attribute.
|
||||
|
||||
.PARAMETER ApprovedWritableAttributes
|
||||
The approved list from configuration.
|
||||
|
||||
.EXAMPLE
|
||||
New-PersonaWriteBody -AttributeName $t -Value 'Employee' -TargetAttribute $t -ApprovedWritableAttributes @($t)
|
||||
|
||||
.OUTPUTS
|
||||
System.Collections.Hashtable with exactly one key.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([hashtable])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $AttributeName,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyString()]
|
||||
[string] $Value,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $TargetAttribute,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
[string[]] $ApprovedWritableAttributes
|
||||
)
|
||||
|
||||
# Ordinal comparison: extension property names are case-sensitive in Graph, and
|
||||
# a case difference here means the caller is not writing the attribute the
|
||||
# configuration approved.
|
||||
if (-not [string]::Equals($AttributeName, $TargetAttribute, [System.StringComparison]::Ordinal)) {
|
||||
throw "Refusing to build a write body for '$AttributeName': only the configured target attribute may be written."
|
||||
}
|
||||
|
||||
if ($TargetAttribute -cnotin $ApprovedWritableAttributes) {
|
||||
throw "Refusing to build a write body for '$TargetAttribute': the attribute is not present in approvedWritableAttributes."
|
||||
}
|
||||
|
||||
@{ $AttributeName = $Value }
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
function Set-UserPersonaAttribute {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Writes the calculated persona to one user (FR-016).
|
||||
|
||||
.DESCRIPTION
|
||||
Issues PATCH /v1.0/users/{id} with a body built by New-PersonaWriteBody.
|
||||
|
||||
This function is reachable only when the caller's ShouldProcess gate has
|
||||
already returned true. It does not re-derive the mode and does not own a
|
||||
preview flag of its own — a second source of truth for the write gate is
|
||||
the defect class Principle III exists to prevent. What it does own is the
|
||||
refusal to proceed without an explicit -Confirmed switch, so a call that
|
||||
skipped the gate entirely fails loudly instead of writing.
|
||||
|
||||
previousValue is captured here, at write time, from the value the engine
|
||||
actually observed before the PATCH. Reading it back afterwards would return
|
||||
the new value; deriving it from the decision result would record what the
|
||||
engine believed rather than what it replaced. Without it, OTD-010 rollback
|
||||
is impossible retroactively — no later run can reconstruct what a value used
|
||||
to be.
|
||||
|
||||
.PARAMETER UserObjectId
|
||||
The user to update.
|
||||
|
||||
.PARAMETER AttributeName
|
||||
The attribute to write. Validated against the target by New-PersonaWriteBody.
|
||||
|
||||
.PARAMETER Value
|
||||
The calculated persona.
|
||||
|
||||
.PARAMETER PreviousValue
|
||||
The stored value observed before the write, recorded for rollback.
|
||||
|
||||
.PARAMETER TargetAttribute
|
||||
The configured target attribute.
|
||||
|
||||
.PARAMETER ApprovedWritableAttributes
|
||||
The approved list from configuration.
|
||||
|
||||
.PARAMETER Confirmed
|
||||
Asserts that the caller's ShouldProcess gate returned true. Required.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.WriteResult
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $UserObjectId,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $AttributeName,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyString()]
|
||||
[string] $Value,
|
||||
|
||||
[AllowNull()]
|
||||
[AllowEmptyString()]
|
||||
[string] $PreviousValue,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string] $TargetAttribute,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
[string[]] $ApprovedWritableAttributes,
|
||||
|
||||
[switch] $Confirmed
|
||||
)
|
||||
|
||||
if (-not $Confirmed) {
|
||||
# Not a guess about intent. A caller that reached here without the gate has
|
||||
# a control-flow defect, and the only safe response is to refuse.
|
||||
throw 'Set-UserPersonaAttribute was called without a confirmed ShouldProcess gate. No write was attempted.'
|
||||
}
|
||||
|
||||
$body = New-PersonaWriteBody -AttributeName $AttributeName -Value $Value `
|
||||
-TargetAttribute $TargetAttribute -ApprovedWritableAttributes $ApprovedWritableAttributes
|
||||
|
||||
$succeeded = $false
|
||||
$failureReason = $null
|
||||
|
||||
try {
|
||||
$null = Invoke-PersonaGraphRequest -Uri "/v1.0/users/$UserObjectId" -Method 'PATCH' -Body $body
|
||||
$succeeded = $true
|
||||
}
|
||||
catch {
|
||||
# A failed write is a per-user outcome, not a run-ending one. The run
|
||||
# continues and the count surfaces in the summary; the stored value is
|
||||
# untouched because the PATCH did not land.
|
||||
$failureReason = $_.Exception.Message
|
||||
Write-Verbose "Write failed for $UserObjectId : $failureReason"
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.WriteResult'
|
||||
AccountObjectId = $UserObjectId
|
||||
AttributeName = $AttributeName
|
||||
Value = $Value
|
||||
PreviousValue = $PreviousValue
|
||||
Succeeded = $succeeded
|
||||
FailureReason = $failureReason
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
function Export-PersonaResultsCsv {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Writes the per-account results CSV (object ID, UPN, persona/status).
|
||||
|
||||
.DESCRIPTION
|
||||
Called after every summary, interim and final, and overwrites the file each
|
||||
time. Counters.Results accumulates for the whole run, so the file on disk
|
||||
always lists every account processed so far, not just those since the last
|
||||
summary.
|
||||
|
||||
Export failure never ends the run, matching Write-PersonaAuditRecord's
|
||||
failure handling for the same reason: a locked file or a full disk is an
|
||||
operational problem with the export, not a reason to abandon a
|
||||
classification run mid-population.
|
||||
|
||||
.PARAMETER Counters
|
||||
The run counter set.
|
||||
|
||||
.PARAMETER Path
|
||||
Destination CSV file. The parent directory is created if it does not exist.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Counters,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Path
|
||||
)
|
||||
|
||||
if ($Counters.Results.Count -eq 0) { return }
|
||||
|
||||
try {
|
||||
$directory = Split-Path -Parent $Path
|
||||
if ($directory -and -not (Test-Path -LiteralPath $directory)) {
|
||||
$null = New-Item -ItemType Directory -Path $directory -Force -WhatIf:$false -Confirm:$false
|
||||
}
|
||||
|
||||
# -WhatIf:$false / -Confirm:$false pin this write regardless of any ambient
|
||||
# $WhatIfPreference in the caller's session, the same reason
|
||||
# Write-PersonaAuditRecord pins its own sink writes: this is a report, not a
|
||||
# directory mutation, and must not silently no-op under -WhatIf.
|
||||
$Counters.Results | Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding utf8NoBOM -Force -WhatIf:$false -Confirm:$false
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Results CSV export failed; the run continues without it: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
function New-PersonaRunCounter {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates the run counter set used by summaries and reconciliation (FR-019 - FR-021).
|
||||
|
||||
.DESCRIPTION
|
||||
Holds two independent tallies that must never be conflated:
|
||||
|
||||
Outcome buckets Matched, Unclassified, EvaluationError - what the engine
|
||||
decided. Mutually exclusive, and their sum must equal
|
||||
Processed (SC-001, FR-021).
|
||||
|
||||
Action buckets Unchanged, WouldUpdate, Updated, UpdateFailed, Skipped -
|
||||
what happened to the directory. Also mutually exclusive,
|
||||
but they do NOT reconcile against Processed, because a
|
||||
user can be Matched and Unchanged at the same time.
|
||||
|
||||
Reconciliation checks the outcome buckets only. Checking the action buckets
|
||||
instead would pass on a run that lost users, because Skipped absorbs
|
||||
anything unexplained.
|
||||
|
||||
RuleCounts is seeded from the full rule set, including disabled rules, at
|
||||
construction. Seeding at construction rather than on first match is what
|
||||
makes a zero-match rule distinguishable from an absent one - an operator
|
||||
asking "did RULE-0030 fire?" gets "no, zero matches" rather than silence.
|
||||
|
||||
Results accumulates one row per processed account (AccountObjectId,
|
||||
UserPrincipalName, persona/status) for Export-PersonaResultsCsv. It grows
|
||||
for the life of the run, not just since the last summary, so the CSV a
|
||||
summary writes always reflects every account processed so far.
|
||||
|
||||
.PARAMETER Rules
|
||||
The business rule collection, used to seed RuleCounts.
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.RunCounter
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
[object[]] $Rules
|
||||
)
|
||||
|
||||
$ruleCounts = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
foreach ($rule in (@($Rules) | Sort-Object -Property @{ Expression = { [int]$_.priority } }, @{ Expression = { [string]$_.id } })) {
|
||||
$ruleCounts.Add([pscustomobject]@{
|
||||
RuleId = [string]$rule.id
|
||||
Name = [string]$rule.name
|
||||
Priority = [int]$rule.priority
|
||||
Enabled = [bool]$rule.enabled
|
||||
Persona = [string]$rule.persona
|
||||
Matches = 0
|
||||
})
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.RunCounter'
|
||||
|
||||
Processed = 0
|
||||
|
||||
Matched = 0
|
||||
Unclassified = 0
|
||||
EvaluationError = 0
|
||||
|
||||
Unchanged = 0
|
||||
WouldUpdate = 0
|
||||
Updated = 0
|
||||
UpdateFailed = 0
|
||||
Skipped = 0
|
||||
|
||||
RuleCounts = $ruleCounts
|
||||
Results = [System.Collections.Generic.List[object]]::new()
|
||||
}
|
||||
}
|
||||
|
||||
function Add-PersonaRunResult {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Records one decision result into the run counters.
|
||||
|
||||
.DESCRIPTION
|
||||
The only function that increments counters. A single entry point is what
|
||||
makes reconciliation meaningful: if call sites incremented directly, a
|
||||
missed increment would look identical to a lost user, and the reconciliation
|
||||
check would be reporting on its own bookkeeping rather than on the run.
|
||||
|
||||
Processed increments exactly once per result, before the outcome switch, so
|
||||
an unrecognized outcome shows up as a reconciliation failure rather than
|
||||
being quietly dropped.
|
||||
|
||||
.PARAMETER Counters
|
||||
The run counter set.
|
||||
|
||||
.PARAMETER Result
|
||||
A PersonaDecisionResult with both Outcome and Action populated.
|
||||
|
||||
.PARAMETER UserRecord
|
||||
The normalized UserRecord the decision was made from. Optional, and used
|
||||
only to carry CompanyName/Department onto the results CSV row - it is never
|
||||
written to counters or audit records, so this does not widen what a
|
||||
PersonaDecisionResult itself carries.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Counters,
|
||||
[Parameter(Mandatory)] [object] $Result,
|
||||
[object] $UserRecord
|
||||
)
|
||||
|
||||
$Counters.Processed++
|
||||
|
||||
switch ([string]$Result.Outcome) {
|
||||
'Matched' {
|
||||
$Counters.Matched++
|
||||
|
||||
$entry = $Counters.RuleCounts | Where-Object { $_.RuleId -eq [string]$Result.MatchedRuleId } | Select-Object -First 1
|
||||
if ($entry) { $entry.Matches++ }
|
||||
}
|
||||
'Unclassified' { $Counters.Unclassified++ }
|
||||
'EvaluationError' { $Counters.EvaluationError++ }
|
||||
}
|
||||
|
||||
switch ([string]$Result.Action) {
|
||||
'Unchanged' { $Counters.Unchanged++ }
|
||||
'WouldUpdate' { $Counters.WouldUpdate++ }
|
||||
'Updated' { $Counters.Updated++ }
|
||||
'UpdateFailed' { $Counters.UpdateFailed++ }
|
||||
'Skipped' { $Counters.Skipped++ }
|
||||
}
|
||||
|
||||
# TryGetValue rather than the indexer: Properties is a case-insensitive
|
||||
# Dictionary, whose indexer throws on a missing key rather than returning
|
||||
# $null. CompanyName/Department are always present when UserRecord comes from
|
||||
# ConvertTo-PersonaUserRecord, but this stays safe for any other caller too.
|
||||
$companyName = $null
|
||||
$department = $null
|
||||
if ($UserRecord) {
|
||||
$null = $UserRecord.Properties.TryGetValue('CompanyName', [ref]$companyName)
|
||||
$null = $UserRecord.Properties.TryGetValue('Department', [ref]$department)
|
||||
}
|
||||
|
||||
# Matched carries the assigned persona; Unclassified/EvaluationError carry
|
||||
# their outcome name, since CalculatedPersona is 'Unclassified' or $null there.
|
||||
$Counters.Results.Add([pscustomobject]@{
|
||||
AccountObjectId = [string]$Result.AccountObjectId
|
||||
UserPrincipalName = [string]$Result.UserPrincipalName
|
||||
PersonaStatus = [string]$Result.Outcome -eq 'Matched' ? [string]$Result.CalculatedPersona : [string]$Result.Outcome
|
||||
CompanyName = [string]$companyName
|
||||
Department = [string]$department
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
function Test-PersonaReconciliation {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Verifies Processed = Matched + Unclassified + EvaluationError (FR-021, SC-007).
|
||||
|
||||
.DESCRIPTION
|
||||
Run at every summary and once at completion. Returns $true when the outcome
|
||||
buckets account for every processed user.
|
||||
|
||||
A mismatch is not a data condition and is never reported as one. Outcomes are
|
||||
assigned by the engine, exactly one per user (SC-001), so if the totals do
|
||||
not add up the engine lost a user or double-counted one. That is a defect in
|
||||
this codebase, and the caller emits an EngineDefect record and exit code 5
|
||||
rather than folding the discrepancy into an ordinary counter where it would
|
||||
be invisible.
|
||||
|
||||
Deliberately checks only the outcome buckets. The action buckets - Unchanged,
|
||||
WouldUpdate, Updated, UpdateFailed, Skipped - also sum to Processed in a
|
||||
correct run, but Skipped is a catch-all that would absorb a lost user and let
|
||||
the check pass on a broken run.
|
||||
|
||||
.PARAMETER Counters
|
||||
The run counter set.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([bool])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Counters
|
||||
)
|
||||
|
||||
$sum = [int]$Counters.Matched + [int]$Counters.Unclassified + [int]$Counters.EvaluationError
|
||||
[int]$Counters.Processed -eq $sum
|
||||
}
|
||||
|
||||
function Get-PersonaReconciliationDetail {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Describes a reconciliation failure precisely enough to debug it.
|
||||
|
||||
.DESCRIPTION
|
||||
Emitted onto the EngineDefect record. Carries the expected total, the actual
|
||||
total, and the difference, because "reconciliation failed" alone does not
|
||||
tell a maintainer whether users were lost or double-counted - and the sign of
|
||||
the difference does.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([hashtable])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Counters
|
||||
)
|
||||
|
||||
$sum = [int]$Counters.Matched + [int]$Counters.Unclassified + [int]$Counters.EvaluationError
|
||||
|
||||
@{
|
||||
severity = 'Error'
|
||||
defect = 'ReconciliationFailure'
|
||||
processed = [int]$Counters.Processed
|
||||
outcomeTotal = $sum
|
||||
difference = [int]$Counters.Processed - $sum
|
||||
matched = [int]$Counters.Matched
|
||||
unclassified = [int]$Counters.Unclassified
|
||||
evaluationError = [int]$Counters.EvaluationError
|
||||
description = 'Processed does not equal Matched + Unclassified + EvaluationError. Every processed user must land in exactly one outcome bucket (SC-001); a mismatch is an engine defect, not a property of the data.'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
function Write-PersonaSummary {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Renders the rule-match table, outcome totals, and reconciliation result
|
||||
(FR-019, FR-020, FR-021).
|
||||
|
||||
.DESCRIPTION
|
||||
Emitted every summaryInterval users and once at completion.
|
||||
|
||||
Every business rule appears, including disabled rules and rules with zero
|
||||
matches. A rule that never fired and a rule that is not in the configuration
|
||||
look identical if zero-match rules are omitted, and the difference is exactly
|
||||
what an operator investigating "why did nobody get classified as Tier0" needs
|
||||
to see.
|
||||
|
||||
Reconciliation is displayed on every summary, not only when it fails. A check
|
||||
that is only visible when broken gives an operator no reason to believe it
|
||||
ran at all.
|
||||
|
||||
.PARAMETER Counters
|
||||
The run counter set.
|
||||
|
||||
.PARAMETER SummaryType
|
||||
Interim or Final. Final is emitted regardless of interval, including when
|
||||
the interval is 0 (FR-020).
|
||||
|
||||
.PARAMETER Mode
|
||||
Preview or Enforce, shown in the header so a screenshot of a summary is
|
||||
self-describing.
|
||||
|
||||
.PARAMETER StartedUtc
|
||||
Run start timestamp. When supplied, the header shows elapsed wall-clock
|
||||
time since the run began. Optional so callers that only care about counts
|
||||
are not forced to thread a clock through.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Counters,
|
||||
|
||||
[ValidateSet('Interim', 'Final')]
|
||||
[string] $SummaryType = 'Interim',
|
||||
|
||||
[ValidateSet('Preview', 'Enforce')]
|
||||
[string] $Mode = 'Preview',
|
||||
|
||||
[Nullable[datetime]] $StartedUtc
|
||||
)
|
||||
|
||||
$reconciled = Test-PersonaReconciliation -Counters $Counters
|
||||
|
||||
# $StartedUtc arrives here already unwrapped to a plain [datetime] - PowerShell
|
||||
# collapses [Nullable[datetime]] to DateTime (or $null) at the call boundary, so
|
||||
# a null check is used rather than .Value / .HasValue.
|
||||
$elapsed = ($null -ne $StartedUtc) ? ('{0:hh\:mm\:ss}' -f ([DateTime]::UtcNow - $StartedUtc)) : $null
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ('=' * 100) -ForegroundColor DarkGray
|
||||
Write-Host (
|
||||
$elapsed `
|
||||
? ("{0} summary - mode: {1} - elapsed: {2} - processed: {3}" -f $SummaryType, $Mode, $elapsed, $Counters.Processed) `
|
||||
: ("{0} summary - mode: {1} - processed: {2}" -f $SummaryType, $Mode, $Counters.Processed)
|
||||
) -ForegroundColor Cyan
|
||||
Write-Host ('=' * 100) -ForegroundColor DarkGray
|
||||
|
||||
Write-Host ('{0,-28} {1,-40} {2,-9} {3,10} {4,8}' -f 'Rule ID', 'Name', 'Priority', 'Enabled', 'Matches') -ForegroundColor DarkGray
|
||||
|
||||
foreach ($entry in $Counters.RuleCounts) {
|
||||
# A disabled rule is dimmed rather than hidden: it is part of the
|
||||
# configuration and its absence from the output would read as a deletion.
|
||||
$colour = if (-not $entry.Enabled) { 'DarkGray' } elseif ($entry.Matches -gt 0) { 'Green' } else { 'Gray' }
|
||||
|
||||
Write-Host ('{0,-28} {1,-40} {2,-9} {3,10} {4,8}' -f
|
||||
$entry.RuleId,
|
||||
($entry.Name.Length -gt 40 ? $entry.Name.Substring(0, 37) + '...' : $entry.Name),
|
||||
$entry.Priority,
|
||||
$entry.Enabled,
|
||||
$entry.Matches) -ForegroundColor $colour
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ('Outcomes Matched: {0} Unclassified: {1} EvaluationError: {2}' -f
|
||||
$Counters.Matched, $Counters.Unclassified, $Counters.EvaluationError)
|
||||
Write-Host ('Actions Unchanged: {0} WouldUpdate: {1} Updated: {2} UpdateFailed: {3} Skipped: {4}' -f
|
||||
$Counters.Unchanged, $Counters.WouldUpdate, $Counters.Updated, $Counters.UpdateFailed, $Counters.Skipped)
|
||||
|
||||
if ($reconciled) {
|
||||
Write-Host ('Reconciliation PASS {0} = {1} + {2} + {3}' -f
|
||||
$Counters.Processed, $Counters.Matched, $Counters.Unclassified, $Counters.EvaluationError) -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host ('Reconciliation FAIL {0} != {1} + {2} + {3} - this is an engine defect (FR-021)' -f
|
||||
$Counters.Processed, $Counters.Matched, $Counters.Unclassified, $Counters.EvaluationError) -ForegroundColor Red
|
||||
}
|
||||
|
||||
Write-Host ('=' * 100) -ForegroundColor DarkGray
|
||||
Write-Host ''
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
function Write-UserPersonaResult {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Displays one user's result immediately after evaluation (FR-018, SC-012).
|
||||
|
||||
.DESCRIPTION
|
||||
Emitted per user as it is processed, not batched at the end, so an operator
|
||||
watching a long run sees progress and can stop early if the impact looks
|
||||
wrong. That per-user visibility is the whole point of a preview run.
|
||||
|
||||
Carries UPN and Account Object ID, which are approved for logs. Never emits
|
||||
tokens, headers, or raw responses (Principle V).
|
||||
|
||||
.PARAMETER Result
|
||||
A PersonaDecisionResult with Action already set by Compare-PersonaValue.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory, ValueFromPipeline)]
|
||||
[object] $Result
|
||||
)
|
||||
|
||||
process {
|
||||
$colour = switch ($Result.Action) {
|
||||
'Updated' { 'Green' }
|
||||
'WouldUpdate' { 'Yellow' }
|
||||
'UpdateFailed' { 'Red' }
|
||||
'Skipped' { 'Red' }
|
||||
default { 'Gray' }
|
||||
}
|
||||
|
||||
$detail = switch ($Result.Outcome) {
|
||||
'Matched' { "$($Result.CalculatedPersona) [$($Result.MatchedRuleId)]" }
|
||||
'Unclassified' { 'Unclassified' }
|
||||
'EvaluationError' { "EvaluationError - $($Result.EvaluationErrorReason)" }
|
||||
}
|
||||
|
||||
$change = switch ($Result.Action) {
|
||||
'Unchanged' { '=' }
|
||||
'WouldUpdate' { "'$($Result.StoredPersona)' -> '$($Result.CalculatedPersona)'" }
|
||||
'Updated' { "'$($Result.StoredPersona)' -> '$($Result.CalculatedPersona)'" }
|
||||
'UpdateFailed' { "write failed; '$($Result.StoredPersona)' retained" }
|
||||
'Skipped' { "'$($Result.StoredPersona)' retained" }
|
||||
default { '' }
|
||||
}
|
||||
|
||||
$line = '{0,-14} {1,-45} {2,-40} {3}' -f $Result.Action, $Result.UserPrincipalName, $detail, $change
|
||||
|
||||
Write-Host $line -ForegroundColor $colour
|
||||
Write-Verbose " ObjectId=$($Result.AccountObjectId) RulesEvaluated=$($Result.RulesEvaluated) DurationMs=$($Result.DurationMs)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
function Resolve-UserPersona {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Produces the authoritative persona decision for one user.
|
||||
|
||||
.DESCRIPTION
|
||||
The engine's core. Pure: it takes a normalized record and a rule set and
|
||||
returns a decision. No Graph, no authentication, no console, no filesystem,
|
||||
no clock (constitution Principle IV, enforced by tests/Test-EnginePurity.ps1).
|
||||
|
||||
Evaluation order (FR-008, FR-009):
|
||||
1. Discard disabled rules.
|
||||
2. Sort by ascending priority — lower evaluates first (RE-002).
|
||||
3. Evaluate in order and STOP at the first True.
|
||||
|
||||
Outcomes are mutually exclusive (SC-001):
|
||||
Matched a rule returned True
|
||||
Unclassified every enabled rule returned False (FR-010)
|
||||
EvaluationError any rule returned Unknown before a match was found
|
||||
|
||||
The EvaluationError rule is subtle and deliberate: an Unknown encountered
|
||||
at priority 30 stops evaluation even though a lower-priority rule might
|
||||
have matched. Continuing would risk assigning a persona from priority 900
|
||||
when the account may in truth have matched at 30 — precisely the
|
||||
privilege-downgrade misclassification FR-013 exists to prevent. Preserving
|
||||
the stored value is the only safe answer.
|
||||
|
||||
Timing uses a monotonic stopwatch rather than the wall clock, so no
|
||||
wall-clock value can influence a decision.
|
||||
|
||||
.PARAMETER UserRecord
|
||||
Normalized record from New-PersonaUserRecord.
|
||||
|
||||
.PARAMETER Rules
|
||||
The business rule collection.
|
||||
|
||||
.PARAMETER MaxDepth
|
||||
Maximum condition nesting depth (RE-004).
|
||||
|
||||
.PARAMETER DefaultMembershipMode
|
||||
Membership mode for conditions that do not specify one (RE-007).
|
||||
|
||||
.PARAMETER IncludeTrace
|
||||
Populates ConditionTrace with per-rule diagnostic results. Off by default;
|
||||
the caller gates this behind -Debug (Principle V).
|
||||
|
||||
.OUTPUTS
|
||||
PersonaEngine.PersonaDecisionResult
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([pscustomobject])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $UserRecord,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
[object[]] $Rules,
|
||||
|
||||
[ValidateRange(1, 10)]
|
||||
[int] $MaxDepth = 5,
|
||||
|
||||
[ValidateSet('Direct', 'Transitive')]
|
||||
[string] $DefaultMembershipMode = 'Direct',
|
||||
|
||||
[switch] $IncludeTrace
|
||||
)
|
||||
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
$outcome = 'Unclassified'
|
||||
$matchedRuleId = $null
|
||||
$calculatedPersona = 'Unclassified'
|
||||
$errorReason = $null
|
||||
$rulesEvaluated = 0
|
||||
$trace = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
# Sort by priority, then by Id. The Id tiebreak matters: duplicate priorities
|
||||
# are a validation error, but if one reaches the engine the result must still
|
||||
# be the same on every run and every host (SC-003) rather than depending on
|
||||
# collection order.
|
||||
$ordered = @($Rules) |
|
||||
Where-Object { $_.enabled } |
|
||||
Sort-Object -Property @{ Expression = { [int]$_.priority } }, @{ Expression = { [string]$_.id } }
|
||||
|
||||
foreach ($rule in $ordered) {
|
||||
$rulesEvaluated++
|
||||
|
||||
$result = Test-PersonaRule -Rule $rule -UserRecord $UserRecord `
|
||||
-MaxDepth $MaxDepth -DefaultMembershipMode $DefaultMembershipMode
|
||||
|
||||
if ($IncludeTrace) {
|
||||
$trace.Add([pscustomobject]@{
|
||||
RuleId = [string]$rule.id
|
||||
Priority = [int]$rule.priority
|
||||
Result = $result
|
||||
})
|
||||
}
|
||||
|
||||
if ($result -eq 'True') {
|
||||
$outcome = 'Matched'
|
||||
$matchedRuleId = [string]$rule.id
|
||||
$calculatedPersona = [string]$rule.persona
|
||||
break
|
||||
}
|
||||
|
||||
if ($result -eq 'Unknown') {
|
||||
$outcome = 'EvaluationError'
|
||||
$calculatedPersona = $null
|
||||
$errorReason = "Rule '$([string]$rule.id)' could not be evaluated: required data was unavailable or the condition could not be interpreted."
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
$stopwatch.Stop()
|
||||
|
||||
[pscustomobject]@{
|
||||
PSTypeName = 'PersonaEngine.PersonaDecisionResult'
|
||||
AccountObjectId = $UserRecord.AccountObjectId
|
||||
UserPrincipalName = $UserRecord.UserPrincipalName
|
||||
Outcome = $outcome
|
||||
MatchedRuleId = $matchedRuleId
|
||||
CalculatedPersona = $calculatedPersona
|
||||
StoredPersona = $UserRecord.StoredPersona
|
||||
Action = 'Pending' # set by the comparison stage (Compare-PersonaValue)
|
||||
EvaluationErrorReason = $errorReason
|
||||
RulesEvaluated = $rulesEvaluated
|
||||
DurationMs = [int]$stopwatch.ElapsedMilliseconds
|
||||
ConditionTrace = $IncludeTrace ? $trace.ToArray() : $null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
function Test-PersonaCondition {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Evaluates one leaf condition against a normalized user record.
|
||||
|
||||
.DESCRIPTION
|
||||
Returns 'True', 'False', or 'Unknown' — never a boolean.
|
||||
|
||||
The tri-state is the whole safety argument (FR-013). A boolean return has
|
||||
no way to distinguish "the user is not in that group" from "we could not
|
||||
find out", and collapsing the second into the first is exactly how an
|
||||
unavailable data source misclassifies a privileged account as an ordinary
|
||||
user. 'Unknown' propagates upward and ultimately produces EvaluationError,
|
||||
which preserves the stored persona.
|
||||
|
||||
Null handling (FR-012): for ordinary string comparisons an absent or null
|
||||
property is treated as an empty string and never fails evaluation. The
|
||||
isNull / isNotNull operators exist for intentional null matching, and treat
|
||||
both $null and the empty string as null.
|
||||
|
||||
Comparison is case-insensitive (RE-006).
|
||||
|
||||
.PARAMETER Condition
|
||||
A leaf condition object with Type, Operator, and the operands its operator
|
||||
requires.
|
||||
|
||||
.PARAMETER UserRecord
|
||||
The normalized record produced by New-PersonaUserRecord.
|
||||
|
||||
.PARAMETER DefaultMembershipMode
|
||||
Membership mode used when the condition does not specify one (RE-007).
|
||||
|
||||
.OUTPUTS
|
||||
System.String — 'True', 'False', or 'Unknown'.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Condition,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[object] $UserRecord,
|
||||
|
||||
[ValidateSet('Direct', 'Transitive')]
|
||||
[string] $DefaultMembershipMode = 'Direct'
|
||||
)
|
||||
|
||||
$operator = [string]$Condition.operator
|
||||
$type = if ($Condition.type) { [string]$Condition.type } else { 'property' }
|
||||
|
||||
switch ($type) {
|
||||
|
||||
{ $_ -in @('membership', 'role') } {
|
||||
return Test-PersonaMembershipCondition -Condition $Condition -UserRecord $UserRecord -DefaultMembershipMode $DefaultMembershipMode
|
||||
}
|
||||
|
||||
'property' {
|
||||
$name = [string]$Condition.property
|
||||
$raw = $UserRecord.Properties[$name]
|
||||
|
||||
# Intentional null matching happens before the empty-string coercion,
|
||||
# otherwise isNull could never be true.
|
||||
switch ($operator) {
|
||||
'isNull' { return (Test-PersonaValueIsNull $raw) ? 'True' : 'False' }
|
||||
'isNotNull' { return (Test-PersonaValueIsNull $raw) ? 'False' : 'True' }
|
||||
}
|
||||
|
||||
# FR-012: null and absent both compare as empty.
|
||||
$value = if ($null -eq $raw) { '' } else { [string]$raw }
|
||||
|
||||
switch ($operator) {
|
||||
'equals' { return (Test-PersonaStringEquals $value ([string]$Condition.value)) ? 'True' : 'False' }
|
||||
'notEquals' { return (Test-PersonaStringEquals $value ([string]$Condition.value)) ? 'False' : 'True' }
|
||||
|
||||
'contains' { return ($value.ToLowerInvariant().Contains(([string]$Condition.value).ToLowerInvariant())) ? 'True' : 'False' }
|
||||
'notContains' { return ($value.ToLowerInvariant().Contains(([string]$Condition.value).ToLowerInvariant())) ? 'False' : 'True' }
|
||||
|
||||
'startsWith' { return ($value.StartsWith([string]$Condition.value, [System.StringComparison]::OrdinalIgnoreCase)) ? 'True' : 'False' }
|
||||
'endsWith' { return ($value.EndsWith([string]$Condition.value, [System.StringComparison]::OrdinalIgnoreCase)) ? 'True' : 'False' }
|
||||
|
||||
'matchesRegex' {
|
||||
$pattern = [string]$Condition.value
|
||||
|
||||
# RE-006: validate before execution. An invalid pattern is a
|
||||
# configuration defect, and treating it as a non-match would
|
||||
# hide the defect behind a plausible-looking result.
|
||||
try {
|
||||
$regex = [regex]::new($pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||
}
|
||||
catch {
|
||||
return 'Unknown'
|
||||
}
|
||||
|
||||
return ($regex.IsMatch($value)) ? 'True' : 'False'
|
||||
}
|
||||
|
||||
'in' {
|
||||
foreach ($candidate in @($Condition.values)) {
|
||||
if (Test-PersonaStringEquals $value ([string]$candidate)) { return 'True' }
|
||||
}
|
||||
return 'False'
|
||||
}
|
||||
|
||||
'notIn' {
|
||||
foreach ($candidate in @($Condition.values)) {
|
||||
if (Test-PersonaStringEquals $value ([string]$candidate)) { return 'False' }
|
||||
}
|
||||
return 'True'
|
||||
}
|
||||
|
||||
default {
|
||||
# An unsupported operator reaching evaluation means validation
|
||||
# let it through. Unknown preserves the stored value rather than
|
||||
# inventing a decision from a condition nobody can interpret.
|
||||
return 'Unknown'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default { return 'Unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
function Test-PersonaMembershipCondition {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Evaluates a membership or directory-role condition. Internal helper.
|
||||
|
||||
.DESCRIPTION
|
||||
Selects the facet of the membership record that answers the question the
|
||||
condition actually asks — direct groups, transitive groups, or directory
|
||||
roles — and returns 'Unknown' if that specific facet was not retrieved
|
||||
(FR-013).
|
||||
|
||||
Facet selection is deliberately exact. Transitive membership is a superset
|
||||
of direct, so answering a direct question from transitive data would produce
|
||||
false positives, and answering a transitive question from direct data would
|
||||
produce false negatives. Neither is acceptable when the answer decides
|
||||
whether an account is classified as an administrator.
|
||||
|
||||
Because each facet carries its own retrieval status, a failure in one does
|
||||
not contaminate the others: a transitive lookup that times out leaves
|
||||
direct-membership conditions fully evaluable.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[Parameter(Mandatory)] [object] $Condition,
|
||||
[Parameter(Mandatory)] [object] $UserRecord,
|
||||
[string] $DefaultMembershipMode = 'Direct'
|
||||
)
|
||||
|
||||
$membership = $UserRecord.Membership
|
||||
if ($null -eq $membership) { return 'Unknown' }
|
||||
|
||||
$isRole = ([string]$Condition.type -eq 'role')
|
||||
|
||||
if ($isRole) {
|
||||
if (-not $membership.RolesRetrieved) { return 'Unknown' }
|
||||
$haystack = @($membership.DirectoryRoleIds)
|
||||
$needles = @($Condition.roleIds)
|
||||
}
|
||||
else {
|
||||
$requestedMode = if ($Condition.membershipMode) { [string]$Condition.membershipMode } else { $DefaultMembershipMode }
|
||||
|
||||
switch ($requestedMode.ToLowerInvariant()) {
|
||||
'transitive' {
|
||||
if (-not $membership.TransitiveRetrieved) { return 'Unknown' }
|
||||
$haystack = @($membership.TransitiveGroupObjectIds)
|
||||
}
|
||||
'direct' {
|
||||
if (-not $membership.DirectRetrieved) { return 'Unknown' }
|
||||
$haystack = @($membership.DirectGroupObjectIds)
|
||||
}
|
||||
default { return 'Unknown' }
|
||||
}
|
||||
|
||||
$needles = @($Condition.groupObjectIds)
|
||||
}
|
||||
|
||||
$found = $false
|
||||
foreach ($needle in $needles) {
|
||||
foreach ($held in $haystack) {
|
||||
if (Test-PersonaStringEquals ([string]$held) ([string]$needle)) {
|
||||
$found = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($found) { break }
|
||||
}
|
||||
|
||||
switch ([string]$Condition.operator) {
|
||||
'memberOf' { return $found ? 'True' : 'False' }
|
||||
'notMemberOf' { return $found ? 'False' : 'True' }
|
||||
default { return 'Unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
function Test-PersonaStringEquals {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Case-insensitive ordinal string comparison (RE-006). Internal helper.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([bool])]
|
||||
param(
|
||||
[AllowNull()] [string] $Left,
|
||||
[AllowNull()] [string] $Right
|
||||
)
|
||||
|
||||
[string]::Equals($Left, $Right, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
function Test-PersonaValueIsNull {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Determines whether a property value counts as null. Internal helper.
|
||||
|
||||
.DESCRIPTION
|
||||
Both $null and the empty string count. A directory routinely returns an
|
||||
empty string for a cleared attribute, and a rule author asking "is this
|
||||
unset" means the same thing in both cases.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([bool])]
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[AllowNull()]
|
||||
[object] $Value
|
||||
)
|
||||
|
||||
if ($null -eq $Value) { return $true }
|
||||
return [string]::IsNullOrEmpty([string]$Value)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
function Test-PersonaConditionGroup {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Evaluates an all/any condition group, propagating Unknown correctly.
|
||||
|
||||
.DESCRIPTION
|
||||
The propagation table (data-model.md) is the safety argument in four rows:
|
||||
|
||||
all + any False -> False a definite non-match wins
|
||||
all + only True and Unknown -> Unknown cannot confirm
|
||||
any + any True -> True a definite match wins
|
||||
any + only False and Unknown -> Unknown cannot rule out
|
||||
|
||||
The two "definite wins" rows matter as much as the two Unknown rows. If an
|
||||
'all' group already contains a False, the result is False regardless of any
|
||||
unknown sibling — the rule cannot match either way, so degrading to
|
||||
EvaluationError there would produce spurious errors and mask real ones.
|
||||
|
||||
Depth is bounded (RE-004). The root group is depth 1.
|
||||
|
||||
.PARAMETER Group
|
||||
A condition group with operator 'all' or 'any' and a conditions collection.
|
||||
|
||||
.PARAMETER UserRecord
|
||||
The normalized record to evaluate against.
|
||||
|
||||
.PARAMETER MaxDepth
|
||||
Maximum nesting depth. Default 5, hard ceiling 10.
|
||||
|
||||
.PARAMETER CurrentDepth
|
||||
Internal recursion counter. Callers leave this at its default.
|
||||
|
||||
.OUTPUTS
|
||||
System.String — 'True', 'False', or 'Unknown'.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Group,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[object] $UserRecord,
|
||||
|
||||
[ValidateRange(1, 10)]
|
||||
[int] $MaxDepth = 5,
|
||||
|
||||
[ValidateSet('Direct', 'Transitive')]
|
||||
[string] $DefaultMembershipMode = 'Direct',
|
||||
|
||||
[int] $CurrentDepth = 1
|
||||
)
|
||||
|
||||
# Exceeding the depth limit is a configuration defect that validation should
|
||||
# have caught. Unknown rather than silent truncation: a truncated condition
|
||||
# tree evaluates a rule the author did not write.
|
||||
if ($CurrentDepth -gt $MaxDepth) { return 'Unknown' }
|
||||
|
||||
$operator = ([string]$Group.operator).ToLowerInvariant()
|
||||
if ($operator -notin @('all', 'any')) { return 'Unknown' }
|
||||
|
||||
$children = @($Group.conditions)
|
||||
if ($children.Count -eq 0) { return 'Unknown' }
|
||||
|
||||
$sawUnknown = $false
|
||||
|
||||
foreach ($child in $children) {
|
||||
|
||||
# A child is a group when it carries its own conditions collection.
|
||||
$isGroup = $null -ne $child.PSObject.Properties['conditions'] -and $null -ne $child.conditions
|
||||
|
||||
$result = if ($isGroup) {
|
||||
Test-PersonaConditionGroup -Group $child -UserRecord $UserRecord `
|
||||
-MaxDepth $MaxDepth -DefaultMembershipMode $DefaultMembershipMode `
|
||||
-CurrentDepth ($CurrentDepth + 1)
|
||||
}
|
||||
else {
|
||||
Test-PersonaCondition -Condition $child -UserRecord $UserRecord `
|
||||
-DefaultMembershipMode $DefaultMembershipMode
|
||||
}
|
||||
|
||||
switch ($result) {
|
||||
'Unknown' { $sawUnknown = $true }
|
||||
|
||||
'False' {
|
||||
# Short-circuit only on the definite result that decides the group.
|
||||
if ($operator -eq 'all') { return 'False' }
|
||||
}
|
||||
|
||||
'True' {
|
||||
if ($operator -eq 'any') { return 'True' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# No definite result decided the group. If anything was unknown, the group is
|
||||
# unknown; otherwise every child agreed with the group's identity element.
|
||||
if ($sawUnknown) { return 'Unknown' }
|
||||
|
||||
return ($operator -eq 'all') ? 'True' : 'False'
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
function Test-PersonaRule {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Evaluates a single business rule's root condition group against a user.
|
||||
|
||||
.DESCRIPTION
|
||||
Returns 'True', 'False', or 'Unknown'. Disabled rules are not evaluated
|
||||
here — Resolve-UserPersona filters them out before evaluation so they are
|
||||
excluded from the enabled rule count as well as from the result.
|
||||
|
||||
.PARAMETER Rule
|
||||
A business rule with a match condition group.
|
||||
|
||||
.PARAMETER UserRecord
|
||||
The normalized record to evaluate against.
|
||||
|
||||
.OUTPUTS
|
||||
System.String — 'True', 'False', or 'Unknown'.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object] $Rule,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[object] $UserRecord,
|
||||
|
||||
[ValidateRange(1, 10)]
|
||||
[int] $MaxDepth = 5,
|
||||
|
||||
[ValidateSet('Direct', 'Transitive')]
|
||||
[string] $DefaultMembershipMode = 'Direct'
|
||||
)
|
||||
|
||||
if ($null -eq $Rule.match) { return 'Unknown' }
|
||||
|
||||
Test-PersonaConditionGroup -Group $Rule.match -UserRecord $UserRecord `
|
||||
-MaxDepth $MaxDepth -DefaultMembershipMode $DefaultMembershipMode
|
||||
}
|
||||
Reference in New Issue
Block a user