170 lines
9.0 KiB
Markdown
170 lines
9.0 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.
|
||
|
|
|
||
|
|
[`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.
|