Files
personaEngine2/docs/SecurityModel.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

9.0 KiB

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)

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

tests/Test-Sanitization.ps1 scans every tracked file on every build. Runtime records naturally contain real UPNs and Object IDs — approved for logs — but no such value is ever committed.