138 lines
6.7 KiB
Markdown
138 lines
6.7 KiB
Markdown
|
|
# Business rules
|
|||
|
|
|
|||
|
|
How to write, order, and change the rules that decide what an account is.
|
|||
|
|
|
|||
|
|
Field-by-field syntax is in [ConfigurationReference.md](ConfigurationReference.md). This document is
|
|||
|
|
about judgement.
|
|||
|
|
|
|||
|
|
## The model
|
|||
|
|
|
|||
|
|
Rules are evaluated in ascending `priority`. The first rule that returns `True` wins, and evaluation
|
|||
|
|
stops. An account matching no enabled rule is `Unclassified`. An account whose evaluation hits data
|
|||
|
|
that could not be retrieved is `EvaluationError`, and its stored value is preserved.
|
|||
|
|
|
|||
|
|
Three consequences worth internalising before writing a rule:
|
|||
|
|
|
|||
|
|
**Order is meaning.** A rule at priority 900 only ever sees accounts that failed every rule above it.
|
|||
|
|
Changing a priority silently reclassifies every account matched by more than one rule, which is why
|
|||
|
|
`PE-SAF-005` blocks a reorder without a `configVersion` change.
|
|||
|
|
|
|||
|
|
**A rule cannot express "and not the previous ones".** It does not need to. First-match already
|
|||
|
|
excludes them. Adding explicit exclusions duplicates the ordering in two places, and the day they
|
|||
|
|
disagree the ordering wins silently.
|
|||
|
|
|
|||
|
|
**Determinism is absolute.** The same account and the same configuration always produce the same
|
|||
|
|
persona. Nothing time-dependent, random, or order-dependent may enter a decision — `effectiveDate` is
|
|||
|
|
metadata for exactly this reason, and the engine's per-user timing uses a monotonic stopwatch rather
|
|||
|
|
than the wall clock so no clock value can reach a decision.
|
|||
|
|
|
|||
|
|
## Priority bands
|
|||
|
|
|
|||
|
|
A convention, not enforced, but it makes the intent of a rule set legible at a glance:
|
|||
|
|
|
|||
|
|
| Band | Purpose | Examples |
|
|||
|
|
| --- | --- | --- |
|
|||
|
|
| 1–99 | Accounts that must never be reclassified by anything | Emergency access, Tier 0 |
|
|||
|
|
| 100–199 | Directory facts that are definitional | Guest, external |
|
|||
|
|
| 200–499 | Non-human accounts | Service, shared functional, room devices |
|
|||
|
|
| 500–799 | Population subsets | Contractor, student, restricted |
|
|||
|
|
| 800–999 | Defaults | Employee |
|
|||
|
|
|
|||
|
|
Leave gaps. Renumbering to insert a rule is a reorder, and a reorder is a `PE-SAF-005` finding.
|
|||
|
|
|
|||
|
|
## Writing a rule that holds up
|
|||
|
|
|
|||
|
|
**Identify special accounts by Object ID, never by name.** Display names and UPNs change; Object IDs
|
|||
|
|
do not. RE-009 exists because a break-glass account renamed during an incident must not silently stop
|
|||
|
|
being a break-glass account.
|
|||
|
|
|
|||
|
|
**Require two independent signals for a consequential classification.** The example configuration's
|
|||
|
|
service-account rule requires both a naming convention *and* group membership, so a person whose UPN
|
|||
|
|
happens to start with `svc-` is not classified as a service account.
|
|||
|
|
|
|||
|
|
**Prefer group membership to string matching for anything privileged.** A group is administered,
|
|||
|
|
auditable, and has an owner. A naming convention is a habit.
|
|||
|
|
|
|||
|
|
**Give every rule a description that says why it exists**, not what it does — the conditions already
|
|||
|
|
say what it does. A rule nobody can explain cannot be safely changed, which is why `description` is
|
|||
|
|
required.
|
|||
|
|
|
|||
|
|
## Membership mode
|
|||
|
|
|
|||
|
|
`direct` asks whether the account is a member of the named group itself. `transitive` asks whether it
|
|||
|
|
is a member through any chain of nesting.
|
|||
|
|
|
|||
|
|
Mode is a per-condition choice (RE-007). The three facets — direct, transitive, roles — are retrieved
|
|||
|
|
independently, so mixing modes in one rule set is fully supported. It costs one extra request per
|
|||
|
|
account for each additional facet.
|
|||
|
|
|
|||
|
|
Use `transitive` when the group is a role-holding group that other groups nest into — which is most
|
|||
|
|
Tier 0 groups. Use `direct` when membership is explicitly managed and nesting would be a mistake.
|
|||
|
|
|
|||
|
|
**Do not pin `dataSources.groups.membershipMode` unless you mean to restrict.** Absent means "any mode
|
|||
|
|
is acceptable". Pinning it turns every per-condition override into a `PE-SEM-014` warning, which
|
|||
|
|
trains people to ignore warnings.
|
|||
|
|
|
|||
|
|
## Unknown is not false
|
|||
|
|
|
|||
|
|
If a membership lookup fails, the condition is `Unknown`, not `False`. A `notMemberOf` condition
|
|||
|
|
therefore does **not** become satisfied when the lookup fails.
|
|||
|
|
|
|||
|
|
This is the single most important behaviour in the engine. Without it, a transient Graph outage would
|
|||
|
|
make every privileged account look like a non-member of its Tier 0 group, and a single run would
|
|||
|
|
quietly demote the entire administrative population. `UnknownNotFalse.Tests.ps1` exists solely to
|
|||
|
|
prevent that regression.
|
|||
|
|
|
|||
|
|
The cost is that a failed lookup produces `EvaluationError` rather than a classification. That is the
|
|||
|
|
correct trade: preserving a possibly-stale value is recoverable, and writing a confidently wrong one
|
|||
|
|
is not.
|
|||
|
|
|
|||
|
|
## Nesting
|
|||
|
|
|
|||
|
|
`all` and `any` groups nest to `maxConditionDepth` (default 5, ceiling 10). Beyond the limit the
|
|||
|
|
engine returns `Unknown`, which becomes `EvaluationError` for every account the rule reaches — so a
|
|||
|
|
too-deep rule fails safe rather than silently.
|
|||
|
|
|
|||
|
|
A rule needing more than three levels is usually two rules with different priorities. Depth is
|
|||
|
|
expensive to read and the ordering you would express with nesting is already available for free.
|
|||
|
|
|
|||
|
|
## Changing a rule set
|
|||
|
|
|
|||
|
|
1. Edit the configuration.
|
|||
|
|
2. Validate against the deployed copy so the drift checks actually run:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -PreviousConfigPath ./deployed/persona-engine.json -ValidateOnly -NonInteractive
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
3. Run the rules against synthetic fixtures.
|
|||
|
|
4. Preview against one account, then against the tenant with `-WhatIf`.
|
|||
|
|
5. Compare the summary's per-rule match counts against the previous run. A rule whose count moved
|
|||
|
|
sharply is either the change you made or a change you did not intend.
|
|||
|
|
6. Raise `configVersion`.
|
|||
|
|
|
|||
|
|
Step 5 is the one people skip. The summary lists every rule including zero-match ones precisely so
|
|||
|
|
that a rule which *stopped* firing is visible, and a rule that stopped firing is the usual signature
|
|||
|
|
of an accidental reorder.
|
|||
|
|
|
|||
|
|
## Disabling versus deleting
|
|||
|
|
|
|||
|
|
Disable rather than delete. A disabled rule still appears in every summary with a zero count, so the
|
|||
|
|
audit trail keeps reporting on it and an operator can see it was deliberately turned off. A deleted
|
|||
|
|
rule is indistinguishable from one that never existed, which is why `PE-SAF-005` flags a deletion
|
|||
|
|
without a version change.
|
|||
|
|
|
|||
|
|
## Testing a rule set without a tenant
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData -ValidateOnly -NonInteractive
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
This runs the real engine against the synthetic fixtures in `tests/TestData/`. The fixtures include
|
|||
|
|
accounts with null and absent properties, mixed casing, a guest, a disabled account, and — most
|
|||
|
|
usefully — two accounts whose membership lookups failed, so `EvaluationError` behaviour is visible
|
|||
|
|
before it happens against a real directory.
|
|||
|
|
|
|||
|
|
Add fixtures for the cases your rule set actually cares about. A fixture that reproduces a real edge
|
|||
|
|
case, sanitized, is worth more than any amount of reasoning about what the engine will probably do.
|