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>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
# 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.
|
||||
|
||||
[`tests/Test-Sanitization.ps1`](../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.
|
||||
Reference in New Issue
Block a user