Skip to main content

Why records are narrowed next to RBAC

The problem​

Method-level authorization answers the wrong question for most business rules. "May this caller edit persons?" is rarely the requirement; "may this caller edit the persons of the head office?" is. Expressing that with method-level roles means inventing a role per data slice (HeadOfficeEditor, BranchEditor, …): roles that multiply with the org chart and the entity types, and that stop describing a job at all.

The obvious alternative — a rule row per user and record — replaces that with two new problems: the rule table grows as users × records, and it drifts from reality every time the organization changes.

The approach​

Keep the coarse gate and add a narrowing layer that the runtime applies for the caller:

  • Organizational grants are edges, not rows. A <principal> --hasScope--> <scope> edge means "for every action this principal holds, and every record reachable from that scope". Because it rides the existing hierarchy, moving a person between units or a unit between regions needs no ACL change.
  • Explicit rules carry the exceptions — a single record, or a scope that must be forbidden even though the hierarchy grants it. They live in access_acl, keyed by principal, action and target.
  • The verdict is computed in SQL at query time, as a filter on the query itself. There is no materialized rule table to invalidate, so a new rule takes effect on the next read — but the verdict itself reads the materialized graph (access.effectiveScope for a record's ancestors, access.effectiveRole for a role or unit principal), so a change to the hierarchy still needs CALL access_pathRefresh() before the narrowing reflects it.
  • The guard is declared on the table (acl: {…} in the schema) and implemented once in the runtime's generic CRUD, so every path — reads, writes and dropdowns, which are reads — obeys it without the handler doing anything.

The two layers answer different questions, and the narrow one only ever subtracts from the wide one:

Decisions and trade-offs​

Deny wins. An explicit deny must be able to override an organizational grant. The alternative ("most specific wins", or a precedence ladder) requires every reader to re-implement the ladder in their head; predictability was chosen over expressiveness. The guarantee is about a record that participates in a scope: a record-targeted deny on a record that participates in none is not applied, because the unscoped term of the filter short-circuits — the same property, below, that makes opting in safe.

Opt-in per table, tolerant of existing data. In scoped mode a record that participates in no scope is not narrowed at all and falls back to RBAC alone. That is what makes switching the guard on safe: a table with no acl spec behaves exactly as before, and a guarded table changes nothing until a grant exists. explicit mode is the opposite and worth choosing deliberately — the unscoped fallback is not built, so every record is denied until a rule admits it.

Filter before paging, and hide reads. find is filtered as part of the query, so the total counts only readable records — filtering after paging would render phantom pages. A denied single-record read is reported as not found rather than forbidden, so the response cannot be used to probe for rows the caller may not see.

A record's own rules die with it. Deleting a guarded record releases the rules that name it. The alternative was learned the hard way: the rules hold foreign keys to the resource, so a delete that removed the entity row first and then failed on a rule left a resource-only ghost — and since a resource is matched by name, that entity could never be created again under its own name.

The editor is a grid, not a form per rule. A scope × verb matrix of tri-state cells (allow / deny / no rule) matches the shape of the decision an administrator is making, and a "no rule" cell removes the rule, which is how an implicit grant is narrowed back. A row per rule would turn one decision into a dozen forms.

What was rejected​

  • ACL checks inside each business handler — easy to forget, impossible to audit centrally, and every new handler becomes a chance to leak records.
  • A rule row per user and record — rule explosion plus drift from the org chart.
  • A materialized "effective access" table — it would be stale by design in a system whose hierarchy changes; the ACL is a filter, not a cache.
  • Enforcing it in the browser — the UI hides what a caller may not do; only the server refuses it.