514 lines
20 KiB
Markdown
514 lines
20 KiB
Markdown
|
|
# 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`.
|