Policy language
What a person may do is decided in one place by a role matrix and a set of rules. The rules are text stored per workspace, written in a small language of our own that cannot run code. This page is generated from the same tables the engine reads, so it cannot drift. You can try any rule on the policy tester (sign in as a demo person first).
How a decision is made
Every guarded page and route asks can(person, action, resource, context). The engine evaluates every rule that names the action, then decides in a fixed order:
- A deny rule that fires wins, always. A deny fires on anything but false, so a fact that is missing makes it fire, and so does a deny rule that does not parse.
- Otherwise the role matrix may grant the action.
- Otherwise an allow rule whose condition is exactly true may grant it. An allow that is unknown, false or does not parse grants nothing.
- Otherwise the answer is no, with the reason
no matching rule.
The verdict names the deciding rule (ALLOW — rule abac.report.team-match, DENY — no matching rule) and lists every rule that was examined with the facts it read.
Role matrix
The baseline: what a role may do before any attribute is looked at. It is short on purpose. Anything that depends on whose report it is, which team someone is in or how they signed in is a rule, so the matrix stays readable. Members can read the member list and create reports; reading, editing and deleting reports comes from rules, never from the role alone.
| Action | owner | admin | member |
|---|---|---|---|
report:read | yes | yes | no |
report:create | yes | yes | yes |
report:update | yes | yes | no |
report:delete | yes | yes | no |
report:export | yes | yes | no |
member:read | yes | yes | yes |
member:invite | yes | yes | no |
member:update | yes | yes | no |
member:remove | yes | yes | no |
sso:read | yes | yes | no |
sso:manage | yes | yes | no |
scim:manage | yes | yes | no |
audit:read | yes | yes | no |
billing:read | yes | yes | no |
billing:manage | yes | no | no |
Actions are resource:verb and only these pairs exist, so a typo in a rule is caught by a test instead of never matching: report (read, create, update, delete, export); member (read, invite, update, remove); sso (read, manage); scim (manage); audit (read); billing (read, manage).
The language
A condition is one expression that comes out true, false or unknown. Literals are ‘strings’ (single or double quotes, with \\, \' and \" as the only escapes), numbers, true, false and null, and lists in square brackets.
| Form | Meaning |
|---|---|
a || b | Or. Loosest binding. |
a && b | And. |
a == b, a != b | Equal and not equal. Strings, numbers and booleans compare with their own kind and case-sensitively. |
a < b, a <= b, a > b, a >= b | Order, for two numbers or two strings. |
a in [x, y] | True if the value is one of the list. |
!a | Not. Binds tighter than a comparison, so negate one with parentheses: !(a == b). |
( … ) | Grouping. |
Functions
| Function | What it does |
|---|---|
lower | lower(text): the text in lower case. |
startsWith | startsWith(text, prefix): true if the text starts with the prefix. |
endsWith | endsWith(text, suffix): true if the text ends with the suffix. |
contains | contains(text or list, value): true if the value is inside. |
There is no eval, no property lookup outside the three scopes below, and no way to call anything else. The reasoning, and what was considered instead (CEL, Rego, JSON Logic, JavaScript in a sandbox), is ADR-0002 in the repository's docs/adr folder.
Facts a rule can read
Twelve facts in three scopes. A rule that names anything else is reported when it is checked, instead of quietly never matching.
| Fact | Type | What it is |
|---|---|---|
user.id | string | The person's user id. |
user.role | string | Their role in this workspace: owner, admin or member. |
user.team | string | Their team, from the SSO department attribute or SCIM. |
resource.type | string | What is being acted on: report, member, sso, scim, audit, billing. |
resource.id | string | The resource's id (for a member, that person's user id). |
resource.team | string | The team the resource belongs to. |
resource.owner | string | The user id of the resource's owner. |
resource.sensitivity | string | public, internal or restricted. |
resource.role | string | For a member resource: that member's role. |
context.mfa | boolean | Whether this session proved a second factor. |
context.authMethod | string | How the session was established: password, passkey, saml… |
context.hour | number | Hour of the day in UTC, 0 to 23. |
Unknown, and why it fails closed
A fact that is not set, or two values that cannot be compared (a string against <), make a condition unknown rather than false, and unknown flows through &&, || and ! by the usual three-valued rules: false && unknown is false, true || unknown is true, everything else involving unknown is unknown. The evaluator never throws on a well-formed rule.
What unknown means is the engine's decision: a deny fires on it, an allow does not grant on it. So a person whose team is not known is refused by a deny that reads the team, and is not let in by an allow that reads it.
Default rules
Every workspace starts with these. They are rows in the policies table, so a workspace can differ from another; in this sample they are read-only in the interface.
| Rule | Effect | Applies to | Condition |
|---|---|---|---|
abac.report.team-matchMembers can read reports that belong to their own team. | allow | report:read | resource.team == user.team |
abac.report.publicEveryone in the workspace can read public reports. | allow | report:read | resource.sensitivity == 'public' |
abac.report.own-editPeople can edit and delete reports they own. | allow | report:updatereport:delete | resource.owner == user.id |
abac.report.restricted-needs-mfaRestricted reports need a session that proved a second factor. | deny | report:readreport:updatereport:deletereport:export | resource.sensitivity == 'restricted' && !context.mfa |
abac.report.export-needs-mfaExporting anything that is not public needs a second factor. | deny | report:export | resource.sensitivity != 'public' && !context.mfa |
abac.member.protect-ownerOnly an owner can change or remove an owner. | deny | member:updatemember:remove | resource.role == 'owner' && user.role != 'owner' |
abac.member.no-self-changeNobody changes their own role. | deny | member:update | resource.id == user.id |
abac.settings.needs-mfaChanging how people get in needs a second factor. | deny | sso:managescim:managebilling:managemember:invite | !context.mfa |
For example, the rule that stops a restricted report being read without a second factor:
effect: deny
actions: report:read, report:update, report:delete, report:export
condition: resource.sensitivity == 'restricted' && !context.mfaLimits
A rule is a sentence, not a program, and the limits are enforced before parsing begins.
| Limit | Value |
|---|---|
| Characters of source | 500 |
| Tokens | 160 |
| Characters in one string | 200 |
| Nesting depth | 16 |
| Syntax tree nodes | 120 |
A rule is parsed on every decision, with no cache: rule counts are tiny, and a cache would need invalidating whenever a rule changed.