updated docs, removed testing files, fixed logging

This commit is contained in:
2026-08-24 10:01:21 -04:00
parent 01d08e635c
commit a54ff3c8f2
80 changed files with 587 additions and 8381 deletions
+513
View File
@@ -0,0 +1,513 @@
# Rule authoring guide
A hands-on manual for writing the `rules` array in `persona-engine.json` — every condition type,
every operator, worked examples, and the interactive editor.
This document teaches by example. [ConfigurationReference.md](ConfigurationReference.md) is the
terse field-by-field reference and the finding-code list; [BusinessRules.md](BusinessRules.md) is
about judgement — ordering, priority bands, when to trust a naming convention. Read this one first if
you have never written a rule before.
---
## Anatomy of a rule
Every rule has the same shape: identity fields, a target persona, and a condition tree called
`match`.
```json
{
"id": "RULE-0040-SERVICE",
"name": "Service accounts",
"description": "Non-human accounts identified by naming convention and group membership.",
"enabled": true,
"priority": 40,
"persona": "Service-Account",
"match": {
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" },
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000b0"] }
]
}
}
```
- `id`, `name`, `description`, `enabled`, `priority`, `persona`, `match` are all required.
- `priority` decides evaluation order — lower runs first — and the first rule whose `match`
evaluates `True` wins. See [BusinessRules.md](BusinessRules.md#the-model) for why ordering, not
exclusion logic, is how you keep one rule from stepping on another.
- `match` is always a **condition group** (an `all`/`any` node), never a bare condition — even a
rule with a single test needs a one-item `conditions` array inside a group.
- Optional metadata — `tags`, `owner`, `changeReference`, `effectiveDate`, `notes`, `testCases` — is
never evaluated. `effectiveDate` in particular is a label, not a schedule (Principle I:
determinism).
---
## Condition types
Every leaf condition has a `type`. It decides which other fields are required and where the engine
looks for the answer.
| `type` | Answers | Required fields | Data source |
| --- | --- | --- | --- |
| `property` | What value does this account have? | `property`, `operator`, plus `value`/`values` depending on operator | The normalized user record — directory properties and extension properties |
| `membership` | Is this account in one of these groups? | `operator` (`memberOf`/`notMemberOf`), `groupObjectIds` | Group membership, direct or transitive |
| `role` | Does this account hold one of these directory roles? | `operator` (`memberOf`/`notMemberOf`), `roleIds` | Directory role assignments |
`property` is the default if `type` is omitted, but write it explicitly — a rule set is read far
more often than it is written, and an implicit type makes every reader re-derive it.
### `property`
```json
{ "type": "property", "property": "Department", "operator": "equals", "value": "Finance" }
```
`property` names one of the [supported intrinsic properties](#supported-properties) or an extension
property. The engine reads it from the normalized record, never from a raw Graph response.
### `membership`
```json
{
"type": "membership",
"operator": "memberOf",
"membershipMode": "transitive",
"groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"]
}
```
Always identify groups **by Object ID**, never by display name — names are mutable, IDs are not
(RE-009). `membershipMode` is optional per condition; see [Direct vs. transitive](#direct-vs-transitive-membership)
below.
### `role`
```json
{
"type": "role",
"operator": "memberOf",
"roleIds": ["<TIER0-ROLE-TEMPLATE-ID>"]
}
```
`roleIds` takes directory role **template** IDs — the ID that is stable across tenants, not the
tenant-specific role assignment ID. There is no membership mode for roles; a role is held or it is
not.
---
## Operators
Every operator in one place, with what it needs and how it compares.
### String comparisons (`property` only)
All string comparisons are **case-insensitive** (RE-006) and compare against the property's value
coerced to a string. A missing or `null` property compares as an empty string (FR-012) — it never
throws and never produces `Unknown`.
| Operator | Meaning | Example |
| --- | --- | --- |
| `equals` | Exact match | `{ "type": "property", "property": "UserType", "operator": "equals", "value": "Guest" }` |
| `notEquals` | Exact non-match | `{ "type": "property", "property": "CompanyName", "operator": "notEquals", "value": "Contoso" }` |
| `contains` | Substring present | `{ "type": "property", "property": "JobTitle", "operator": "contains", "value": "intern" }` |
| `notContains` | Substring absent | `{ "type": "property", "property": "DisplayName", "operator": "notContains", "value": "test" }` |
| `startsWith` | Prefix match | `{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" }` |
| `endsWith` | Suffix match | `{ "type": "property", "property": "UserPrincipalName", "operator": "endsWith", "value": "@vendor.example.com" }` |
### Pattern match (`property` only)
| Operator | Meaning | Example |
| --- | --- | --- |
| `matchesRegex` | .NET regex, matched case-insensitively | `{ "type": "property", "property": "UserPrincipalName", "operator": "matchesRegex", "value": "^svc-[a-z0-9]+-\\d{3}@" }` |
The pattern is compiled at **validation time**, not evaluation time — an invalid pattern is a
`PE-SEM-016` finding that blocks saving, never a surprise mid-run. Prefer `startsWith` /
`contains` when they say what you mean; reach for `matchesRegex` only when the naming convention
genuinely needs a pattern (fixed-width suffixes, alternation, anchoring).
### Set membership (`property` only)
| Operator | Meaning | Example |
| --- | --- | --- |
| `in` | Value equals one of a list | `{ "type": "property", "property": "AccountObjectId", "operator": "in", "values": ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"] }` |
| `notIn` | Value equals none of a list | `{ "type": "property", "property": "Department", "operator": "notIn", "values": ["Finance", "Legal"] }` |
`in`/`notIn` require `values` (plural, an array) rather than `value`. Each candidate is compared
case-insensitively, same as `equals`. Prefer `in` over a chain of `any`-grouped `equals` conditions
— it says "one list" instead of making a reader count `equals` clauses to notice they are mutually
exclusive alternatives.
### Null tests (`property` only)
| Operator | Meaning | Example |
| --- | --- | --- |
| `isNull` | Property is absent, `null`, or empty string | `{ "type": "property", "property": "CompanyName", "operator": "isNull" }` |
| `isNotNull` | Property has a non-empty value | `{ "type": "property", "property": "Department", "operator": "isNotNull" }` |
`isNull`/`isNotNull` must **not** carry `value` or `values` — the schema and `PE-SEM-009` both reject
it, because a value sitting on a presence check is either a typo or a misunderstanding, and either
way it would silently be ignored if allowed through.
Both `$null` and `""` count as null. A directory clears an attribute to an empty string as often as
it leaves it entirely unset, and a rule author asking "is this unset" means both.
### Membership and role (`membership` / `role` only)
| Operator | Meaning | Example |
| --- | --- | --- |
| `memberOf` | Account is in at least one listed group/role | `{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-…"] }` |
| `notMemberOf` | Account is in none of the listed groups/roles | `{ "type": "membership", "operator": "notMemberOf", "groupObjectIds": ["00000000-…"] }` |
**Read [Unknown is not false](BusinessRules.md#unknown-is-not-false) before writing `notMemberOf`.**
If the membership lookup fails, the condition evaluates `Unknown`, not `True` — a `notMemberOf`
never becomes satisfied just because the engine could not check. This is deliberate and is the
single most important safety behaviour in the engine.
### Reserved
`caseSensitive` is accepted by the schema on any condition but is **not implemented in v1** — every
comparison is case-insensitive regardless of what you set it to. It exists so a future version that
adds case sensitivity does not need a breaking schema change. Do not set it expecting an effect
today.
---
## Supported properties
| Property | Type as compared |
| --- | --- |
| `AccountObjectId` | String (GUID) |
| `UserPrincipalName` | String |
| `DisplayName` | String |
| `UserType` | String — typically `Member` or `Guest` |
| `AccountEnabled` | Boolean, compared as the string `"True"` or `"False"` |
| `CompanyName` | String |
| `JobTitle` | String |
| `Department` | String |
Plus any directory **extension property**, addressed by its full name:
`extension_<32-hex-app-id>_<name>`.
```json
{ "type": "property", "property": "extension_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4_costCenter", "operator": "equals", "value": "1042" }
```
Anything not on this list, and not shaped like an extension property, is a `PE-SEM-015` validation
error rather than a silent non-match — an unsupported property is never retrieved, so the condition
would otherwise compare against a value that is permanently absent and quietly never fire.
`AccountEnabled` is a boolean at the source but is compared as text, so match it with `equals` and
the literal string `"True"` or `"False"` — not `isNull`/`isNotNull`, which test for absence and would
never fire on a boolean the directory always populates.
---
## Direct vs. transitive membership
```json
{ "type": "membership", "operator": "memberOf", "membershipMode": "direct", "groupObjectIds": ["…"] }
{ "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["…"] }
```
- `direct` — the account is a member of the named group itself.
- `transitive` — the account is a member through any chain of nested groups.
`membershipMode` is per-condition (RE-007); mixing both in one rule set — or one rule — is fully
supported. Omit it to fall back to `engine.defaultMembershipMode` (itself defaulting to `direct`).
Use `transitive` for role-holding groups that other groups nest into, which describes most Tier 0
groups. Use `direct` when membership is explicitly and individually managed, and nesting into the
group would be a mistake you want the engine to ignore.
Each facet — direct groups, transitive groups, directory roles — is retrieved independently and
fails independently. A transitive lookup timing out does not affect a `direct` condition in the same
rule; it becomes `Unknown` only for the conditions that actually needed it.
**Do not set `dataSources.groups.membershipMode` globally unless you specifically want to forbid the
other mode.** Leaving it unset means "any mode is acceptable, decide per condition"; setting it turns
every condition using the other mode into a `PE-SEM-014` warning.
---
## Combining conditions: `all` and `any`
Every condition tree, at every level, is a group:
```json
{ "operator": "all", "conditions": [ /* */ ] }
```
or
```json
{ "operator": "any", "conditions": [ /* */ ] }
```
`conditions` holds a mix of leaf conditions and nested groups — a nested group is just an entry in
the array that itself has `operator` and `conditions` instead of `type`.
### Simple: all conditions must hold
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" },
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000b0"] }
]
}
```
### Simple: any condition is enough
```json
{
"operator": "any",
"conditions": [
{ "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"] },
{ "type": "role", "operator": "memberOf", "roleIds": ["<TIER0-ROLE-TEMPLATE-ID>"] }
]
}
```
### Nested: "in the test group, OR named like a test account while disabled"
```json
{
"operator": "any",
"conditions": [
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000c0"] },
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "test-" },
{ "type": "property", "property": "AccountEnabled", "operator": "equals", "value": "False" }
]
}
]
}
```
Read the nested group as one unit: it is "named like a test account **and** disabled", offered as
one alternative alongside plain group membership. Nesting composes exactly the way parentheses do in
any boolean expression.
**Keep nesting to three levels or fewer.** The hard ceiling is `maxConditionDepth` (default 5,
configurable up to 10), and exceeding it fails the rule safe — every account it reaches becomes
`EvaluationError` rather than silently truncating the tree. But depth is also a readability cost
before it is ever a technical one: a rule that seems to need four or five levels is usually two
rules at different priorities instead. See [Nesting](BusinessRules.md#nesting) for why.
---
## How `Unknown` moves through a tree
Every condition and every group returns one of three results: `True`, `False`, or `Unknown`.
`Unknown` means the data needed to answer could not be retrieved — not "we don't know the value," but
"the lookup itself failed."
| Group | If it contains… | Result |
| --- | --- | --- |
| `all` | any `False` | `False`, regardless of any `Unknown` sibling |
| `all` | only `True`, plus at least one `Unknown` | `Unknown` |
| `any` | any `True` | `True`, regardless of any `Unknown` sibling |
| `any` | only `False`, plus at least one `Unknown` | `Unknown` |
A definite result always wins over an `Unknown` sibling — an `all` group with one `False` condition
cannot match no matter what else is unknown, so there is no reason to degrade that to an error. Only
when nothing definite decided the group does an `Unknown` anywhere in it propagate.
An `Unknown` at a rule's **root** `match` makes the account `EvaluationError`: nothing is written, and
the previously stored persona is preserved. This is why `notMemberOf` guarding a privileged
classification is safe even when a lookup occasionally fails — the account falls into
`EvaluationError`, never into a false demotion. Full mechanics: [ConfigurationReference.md](ConfigurationReference.md#tri-state-evaluation).
---
## Worked recipes
Complete, runnable condition trees for situations that come up constantly. Combine, don't
copy-paste blindly — read [BusinessRules.md](BusinessRules.md) for when each pattern is and isn't
appropriate.
**Identify a fixed set of accounts by Object ID** (break-glass, service owners — anything where a
name would be the wrong signal):
```json
{
"operator": "any",
"conditions": [
{ "type": "property", "property": "AccountObjectId", "operator": "in",
"values": ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"] }
]
}
```
**Two independent signals for a consequential classification** (naming convention alone is a habit,
not a control):
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" },
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000b0"] }
]
}
```
**Privileged group OR the equivalent directory role** (a group nested for one identity model, a role
assignment for another — either should count):
```json
{
"operator": "any",
"conditions": [
{ "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"] },
{ "type": "role", "operator": "memberOf", "roleIds": ["<TIER0-ROLE-TEMPLATE-ID>"] }
]
}
```
**External by company name, but not your own organization** (`isNotNull` first, so a blank
`CompanyName` does not fall through as "external"):
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "CompanyName", "operator": "isNotNull" },
{ "type": "property", "property": "CompanyName", "operator": "notEquals", "value": "<ORGANIZATION-NAME>" }
]
}
```
**Internal by domain suffix instead of company name:**
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "endsWith", "value": "@<PRIMARY-DOMAIN>" }
]
}
```
**Exclude disabled accounts from an otherwise broad rule:**
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "Department", "operator": "equals", "value": "Finance" },
{ "type": "property", "property": "AccountEnabled", "operator": "equals", "value": "True" }
]
}
```
**Match against a custom directory extension attribute** (e.g. an HR-fed employment type):
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "extension_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4_employmentType",
"operator": "in", "values": ["Contractor", "Vendor"] }
]
}
```
**Default / catch-all rule** (lowest priority in the rule set, so every more specific rule wins
first):
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserType", "operator": "equals", "value": "Member" },
{ "type": "property", "property": "Department", "operator": "isNotNull" }
]
}
```
The shipped [`config/persona-engine.example.json`](../config/persona-engine.example.json) is a
complete rule set built from these same patterns end to end, in priority order — read it alongside
this guide.
---
## Building a rule with the interactive editor
Everything above can be hand-written as JSON, or built through
[`Edit-PersonaEngineConfig.ps1`](../Edit-PersonaEngineConfig.ps1)'s menu, which won't let you save a
structurally invalid tree.
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json
```
From the main menu:
| Key | Action |
| --- | --- |
| `L` | List rules — ID, name, priority, enabled state |
| `A` | Add a rule — prompts for identity fields, then walks you through building `match` node by node |
| `E` | Edit a rule — change a top-level field, or add/edit/remove a condition or nested group |
| `T` | Toggle a rule enabled/disabled |
| `P` | Change a rule's priority |
| `D` | Delete a rule |
| `V` | Re-validate the in-memory document |
| `R` | Run the rule set against synthetic fixtures (`-TestDataPath`) |
| `S` | Save — blocked while any `Error` finding is outstanding |
| `Q` | Quit |
When building a condition, the editor prompts `[P]roperty [M]embership [R]ole` for the type, then
lists the operators valid for whatever you chose next — you cannot accidentally pair `memberOf` with
a `property` condition, because the editor only offers the combinations the schema allows.
For scripted use, add `-NonInteractive -ValidateOnly` to validate a file with no menu at all — see
[Changing a rule set](BusinessRules.md#changing-a-rule-set) for the full validate-preview-deploy
sequence.
---
## Validate what you wrote
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
```
Exit `0` means all four validation layers passed — syntax, schema, semantic, safety. A non-zero exit
prints the findings that blocked it; codes and their meaning are in
[ConfigurationReference.md](ConfigurationReference.md#validation-layers).
Then run the rule against fixtures before it ever sees a real account:
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath <fixtures-dir> -ValidateOnly -NonInteractive
```
See [Testing a rule set without a tenant](BusinessRules.md#testing-a-rule-set-without-a-tenant) for
what a good fixture directory looks like.
---
## Cheat sheet
| Operator | `type` | Needs | Compares |
| --- | --- | --- | --- |
| `equals` / `notEquals` | property | `value` | Case-insensitive exact |
| `contains` / `notContains` | property | `value` | Case-insensitive substring |
| `startsWith` / `endsWith` | property | `value` | Case-insensitive prefix/suffix |
| `matchesRegex` | property | `value` (a pattern) | Case-insensitive regex, validated at save time |
| `in` / `notIn` | property | `values` (array) | Case-insensitive membership in a list |
| `isNull` / `isNotNull` | property | *(neither `value` nor `values`)* | Absent, `null`, or empty string |
| `memberOf` / `notMemberOf` | membership | `groupObjectIds`, optional `membershipMode` | Group membership, direct or transitive |
| `memberOf` / `notMemberOf` | role | `roleIds` | Directory role assignment |
Group operators: `all` (every child must hold) and `any` (at least one child must hold), nestable to
`maxConditionDepth`.