139 lines
7.4 KiB
Markdown
139 lines
7.4 KiB
Markdown
|
|
# Architecture
|
||
|
|
|
||
|
|
How Persona Engine is put together, and why the boundaries sit where they do.
|
||
|
|
|
||
|
|
## The one structural rule
|
||
|
|
|
||
|
|
The rule engine is pure. It takes a normalized record and a rule set, and returns a decision. It has
|
||
|
|
no knowledge of Microsoft Graph, no authentication, no console, no filesystem, and no clock.
|
||
|
|
|
||
|
|
Everything else follows from that. It is constitution Principle IV, it is enforced by
|
||
|
|
[`tests/Test-EnginePurity.ps1`](../tests/Test-EnginePurity.ps1) on every build, and it is the reason
|
||
|
|
354 tests can run on a laptop with no tenant, no credentials, and no network.
|
||
|
|
|
||
|
|
The purity check is a tokenizer pass, not a text search: it parses each file under `src/RuleEngine/`
|
||
|
|
with the PowerShell AST parser and inspects only the code tokens. An earlier text-matching version
|
||
|
|
flagged a comment that merely *mentioned* a persistence function, which is the kind of false positive
|
||
|
|
that gets a check disabled.
|
||
|
|
|
||
|
|
## Layers
|
||
|
|
|
||
|
|
Loaded in this order by [`PersonaEngine.psm1`](../PersonaEngine.psm1). Order matters only for
|
||
|
|
readability — PowerShell resolves function names at call time — but the dependency direction is real
|
||
|
|
and one-way.
|
||
|
|
|
||
|
|
| Layer | Responsibility | May depend on |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| `Normalization` | Convert raw directory objects into `UserRecord` and `MembershipRecord` | nothing |
|
||
|
|
| `Configuration` | Load, validate, and resolve configuration | Normalization |
|
||
|
|
| `RuleEngine` | Evaluate conditions, groups, rules; produce a decision | Normalization only |
|
||
|
|
| `Authentication` | Acquire a Graph connection | nothing in this module |
|
||
|
|
| `DataProviders` | Retrieve users, membership, roles; retry policy | Normalization |
|
||
|
|
| `Persistence` | Compare values; build and issue the write | DataProviders |
|
||
|
|
| `Presentation` | Per-user output, summaries, reconciliation | nothing |
|
||
|
|
| `Engine` | The run loop that composes all of the above | everything |
|
||
|
|
| `Audit` | Build and emit structured records | Presentation |
|
||
|
|
|
||
|
|
The arrow never points into `RuleEngine`. Nothing in `RuleEngine` may reference anything from
|
||
|
|
`Authentication`, `DataProviders`, `Persistence`, or `Presentation`.
|
||
|
|
|
||
|
|
## The normalization boundary
|
||
|
|
|
||
|
|
`ConvertTo-PersonaUserRecord` and `ConvertTo-PersonaMembershipRecord` are the only places where a raw
|
||
|
|
Graph shape becomes an engine shape. Downstream of them, nothing knows Graph exists.
|
||
|
|
|
||
|
|
Two consequences worth stating plainly:
|
||
|
|
|
||
|
|
**Fixtures are real inputs.** A synthetic `UserRecord` built in a test is indistinguishable to the
|
||
|
|
engine from one built from a live tenant response. That is what makes the offline suite evidence
|
||
|
|
rather than a rehearsal.
|
||
|
|
|
||
|
|
**Filtering rules live in one place.** `memberOf` returns directory objects of mixed type.
|
||
|
|
Administrative units arriving on that endpoint are discarded during normalization, not by each
|
||
|
|
caller. An administrative unit ID treated as a group ID would never match — which reads as "not a
|
||
|
|
member", a false non-match, the exact outcome FR-013 exists to prevent.
|
||
|
|
|
||
|
|
## The MembershipRecord shape
|
||
|
|
|
||
|
|
Three independently-retrieved facets — direct groups, transitive groups, directory roles — each with
|
||
|
|
its own retrieval flag and failure reason.
|
||
|
|
|
||
|
|
This started as a single record with one `Mode` field, and running the code against the example
|
||
|
|
configuration is what proved it wrong: RE-007 makes membership mode a **per-condition** choice, so a
|
||
|
|
rule set may legitimately ask for transitive membership in one rule and direct in another. A record
|
||
|
|
carrying only one mode cannot answer both, and seven of nine fixtures came back as `EvaluationError`.
|
||
|
|
The engine was right; the contract was wrong.
|
||
|
|
|
||
|
|
Independent facets also contain failure. If the transitive lookup times out but the direct lookup
|
||
|
|
succeeded, only conditions needing transitive data become `Unknown`. One collapsed flag would turn
|
||
|
|
one slow endpoint into a tenant-wide outage.
|
||
|
|
|
||
|
|
Every `*Retrieved` flag defaults to `$false`. An unset flag means *unknown*, never *not a member*, so
|
||
|
|
a forgotten flag degrades to `EvaluationError` rather than silently misclassifying a privileged
|
||
|
|
account.
|
||
|
|
|
||
|
|
## Tri-state evaluation
|
||
|
|
|
||
|
|
Conditions return `'True'`, `'False'`, or `'Unknown'` — not a boolean. `Unknown` propagates through
|
||
|
|
condition groups by the table in [data-model.md](../specs/001-persona-engine/data-model.md), and an
|
||
|
|
`Unknown` reaching a rule's root becomes `EvaluationError` for that account.
|
||
|
|
|
||
|
|
An `Unknown` at priority 30 stops evaluation even though a lower-priority rule might have matched.
|
||
|
|
Continuing would risk assigning a persona from priority 900 when the account may in truth have
|
||
|
|
matched at 30 — a privilege downgrade drawn from data nobody could read. Preserving the stored value
|
||
|
|
is the only safe answer.
|
||
|
|
|
||
|
|
## The write gate has one origin
|
||
|
|
|
||
|
|
Mode is derived from `$PSCmdlet.ShouldProcess()` and nothing else. There is no `-Preview` switch, no
|
||
|
|
configuration key that suppresses writes, and no reading of `$WhatIfPreference`. Two sources of truth
|
||
|
|
for a write gate is the defect class Principle III exists to prevent: the day they disagree, one of
|
||
|
|
them is wrong and the directory finds out first.
|
||
|
|
|
||
|
|
`Invoke-PersonaEngine.ps1` owns the `ShouldProcess` call and passes the result down to
|
||
|
|
`Invoke-PersonaEngineRun` as a scriptblock. The run loop never learns what `-WhatIf` is, so it cannot
|
||
|
|
disagree with it — and it defaults to a gate that refuses, so a caller that forgets to supply one
|
||
|
|
previews rather than writes.
|
||
|
|
|
||
|
|
## Why the run loop is a module function
|
||
|
|
|
||
|
|
`Invoke-PersonaEngine.ps1` is a thin wrapper: parameter binding, module import, `ShouldProcess`,
|
||
|
|
exit code. The loop itself is `Invoke-PersonaEngineRun` in `src/Engine/`.
|
||
|
|
|
||
|
|
That split exists because SC-004 requires proof that a `-WhatIf` run issues zero writes across a full
|
||
|
|
population. A loop that only exists inside an entry script — one that imports a manifest requiring
|
||
|
|
the Graph SDK — cannot be exercised without a tenant, so the claim could not be tested. What ships
|
||
|
|
and what is tested are now the same code.
|
||
|
|
|
||
|
|
## Single emission point for audit
|
||
|
|
|
||
|
|
Every audit record passes through `Write-PersonaAuditRecord`. Adding a transport is a change to that
|
||
|
|
one function. If call sites wrote their own output, each new transport would mean auditing every call
|
||
|
|
site again, and the one that got missed would be silent.
|
||
|
|
|
||
|
|
Records go to the **Information** stream, not the success stream. Audit records on the success stream
|
||
|
|
would be indistinguishable from a function's return value — the run loop returns its outcome there,
|
||
|
|
and mixing the two turns one object into an array of several thousand.
|
||
|
|
|
||
|
|
## Failure containment
|
||
|
|
|
||
|
|
| Failure | Scope | Outcome |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| Configuration invalid | Run | Exit 1, before any connection is attempted |
|
||
|
|
| Authentication fails | Run | Exit 2 |
|
||
|
|
| Enumeration truncated | Run | Exit 3 — a partial population is never processed |
|
||
|
|
| One membership lookup fails | One facet, one user | `EvaluationError`, stored value preserved |
|
||
|
|
| Too many `EvaluationError` | Run status only | Exit 4; no value was changed |
|
||
|
|
| Counters disagree | Run | Exit 5, `EngineDefect` record |
|
||
|
|
| One write fails | One user | `UpdateFailed`, run continues |
|
||
|
|
|
||
|
|
The dividing line: anything affecting the whole population ends the run; anything affecting one
|
||
|
|
account is contained and reported.
|
||
|
|
|
||
|
|
## Related
|
||
|
|
|
||
|
|
- [SecurityModel.md](SecurityModel.md) — the OTD-003 trade-off and its compensating controls
|
||
|
|
- [ConfigurationReference.md](ConfigurationReference.md) — every schema field and finding code
|
||
|
|
- [OperationsRunbook.md](OperationsRunbook.md) — kill switch and rollback
|
||
|
|
- [Logging.md](Logging.md) — record types and what they may contain
|