Demo environment. Penrose Analytics is fictional, the demo people are shared, and the workspace resets nightly.

Gatehouse

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:

  1. 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.
  2. Otherwise the role matrix may grant the action.
  3. Otherwise an allow rule whose condition is exactly true may grant it. An allow that is unknown, false or does not parse grants nothing.
  4. Otherwise the answer is no, with the reason no matching rule.
How a policy decision is made

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.

Actionowneradminmember
report:readyesyesno
report:createyesyesyes
report:updateyesyesno
report:deleteyesyesno
report:exportyesyesno
member:readyesyesyes
member:inviteyesyesno
member:updateyesyesno
member:removeyesyesno
sso:readyesyesno
sso:manageyesyesno
scim:manageyesyesno
audit:readyesyesno
billing:readyesyesno
billing:manageyesnono

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.

FormMeaning
a || bOr. Loosest binding.
a && bAnd.
a == b, a != bEqual and not equal. Strings, numbers and booleans compare with their own kind and case-sensitively.
a < b, a <= b, a > b, a >= bOrder, for two numbers or two strings.
a in [x, y]True if the value is one of the list.
!aNot. Binds tighter than a comparison, so negate one with parentheses: !(a == b).
( … )Grouping.

Functions

FunctionWhat it does
lowerlower(text): the text in lower case.
startsWithstartsWith(text, prefix): true if the text starts with the prefix.
endsWithendsWith(text, suffix): true if the text ends with the suffix.
containscontains(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.

FactTypeWhat it is
user.idstringThe person's user id.
user.rolestringTheir role in this workspace: owner, admin or member.
user.teamstringTheir team, from the SSO department attribute or SCIM.
resource.typestringWhat is being acted on: report, member, sso, scim, audit, billing.
resource.idstringThe resource's id (for a member, that person's user id).
resource.teamstringThe team the resource belongs to.
resource.ownerstringThe user id of the resource's owner.
resource.sensitivitystringpublic, internal or restricted.
resource.rolestringFor a member resource: that member's role.
context.mfabooleanWhether this session proved a second factor.
context.authMethodstringHow the session was established: password, passkey, saml…
context.hournumberHour 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.

RuleEffectApplies toCondition
abac.report.team-match
Members can read reports that belong to their own team.
allow
report:read
resource.team == user.team
abac.report.public
Everyone in the workspace can read public reports.
allow
report:read
resource.sensitivity == 'public'
abac.report.own-edit
People can edit and delete reports they own.
allow
report:update
report:delete
resource.owner == user.id
abac.report.restricted-needs-mfa
Restricted reports need a session that proved a second factor.
deny
report:read
report:update
report:delete
report:export
resource.sensitivity == 'restricted' && !context.mfa
abac.report.export-needs-mfa
Exporting anything that is not public needs a second factor.
deny
report:export
resource.sensitivity != 'public' && !context.mfa
abac.member.protect-owner
Only an owner can change or remove an owner.
deny
member:update
member:remove
resource.role == 'owner' && user.role != 'owner'
abac.member.no-self-change
Nobody changes their own role.
deny
member:update
resource.id == user.id
abac.settings.needs-mfa
Changing how people get in needs a second factor.
deny
sso:manage
scim:manage
billing:manage
member: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.mfa

Limits

A rule is a sentence, not a program, and the limits are enforced before parsing begins.

LimitValue
Characters of source500
Tokens160
Characters in one string200
Nesting depth16
Syntax tree nodes120

A rule is parsed on every decision, with no cache: rule counts are tiny, and a cache would need invalidating whenever a rule changed.