Files
personaEngine2/docs/ConfigurationReference.md
T
dave cdc6bb33d3 Implement Stage A: rule engine, validation, audit, and safety gates
Completes 109 of 121 tasks. Every remaining task needs a tenant connection
(T055, T056, T101-T103) or an Azure Automation account (T115-T121).

  354 offline Pester tests      PASS
  Engine purity (Principle IV)  PASS
  Sanitization (SC-013)         PASS  (156 files)
  Graph module loaded in tests  none  (SC-008 holds)

What landed
  - Four-layer configuration validation with stable finding codes, covering
    every VR-002 and VR-003 condition, plus a 23-fixture invalid-config corpus
  - Run loop, audit records (NDJSON through a single sink), summaries,
    reconciliation, and exit codes 0-6
  - Persistence behind a single write-body builder whose result always has
    exactly one key
  - Invoke-PersonaEngine.ps1 and Edit-PersonaEngineConfig.ps1
  - Six docs, two pipelines, traceability matrix, V-5a and sanitization records

Three deviations from tasks.md, each recorded in its status block

  T033 is not in Resolve-UserPersona. evaluationErrorThreshold is run-level
  state and the rule engine is pure; a counter there would break Principle IV.
  It lives in New-PersonaRunCounter and is applied in the run loop.

  A new src/Engine/ layer holds Invoke-PersonaEngineRun. The entry script
  imports the manifest, which requires Microsoft.Graph.Authentication, so a
  loop living only inside it could not run on a machine without the Graph SDK
  and SC-004 could not be proven at all. The entry script is now a thin
  wrapper and what ships is what is tested.

  The invalid-config corpus is generated by a committed script, with the
  generated fixtures committed too, so a reviewer sees the fixture in the diff.

Defects found by running the code, not by reading it

  Group and role ID lists were double-wrapped: @(Get-PersonaGroupIdPage ...)
  around a comma-returned array collapsed every membership list into one
  bogus space-joined entry. That is a silent false non-match, exactly what
  FR-013 exists to prevent.

  A 403 whose status appears only in the exception message parsed as $null,
  which the retry policy treats as a transport error - five requests per
  account against a tenant already refusing. Status extraction now falls back
  to the message text, bounded to 400-599.

  The sanitization scan walked tracked files only, so it covered 34 of 156
  files and none of this phase's code. It now scans untracked non-ignored
  files too, and a negative control confirms it catches a planted leak.

  Test-Json reports one error per violating location, not first-failure-only
  as the V-5a draft claimed. Record and pin corrected.

Enforcement remains blocked on the V-4 security sign-off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:48:19 -04:00

218 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 by
CI against its own schema — if the example the documentation points at could not pass, every reader's
first run would fail.
## 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 | — | NDJSON output file. One record per line. |
| `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 110 | 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.