8.8 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:
- Run with
-WhatIf. - Set every rule to
enabled: falseand deploy. - Disable the Automation schedule (Stage B).
- Revoke
User.ReadWrite.Allfrom the execution identity. - 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.