Files
personaEngine2/docs/SecurityModel.md
T

169 lines
8.8 KiB
Markdown

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