# 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__Persona` and `extension__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__`. 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.