Skip to content

Roles and permissions

Authorization is Casbin-backed and organized into two planes that no single user can span, because a user holds exactly one role and a role belongs to exactly one plane.

Plane Roles What they do
Structural superadmin, admin Design the system: profiles, table definitions, scripts, validations, roles, and user assignments
Data guest, accountant, and every role an admin creates Work inside the tables the structural roles defined

The boundary is a Rust-level invariant, not a policy row: a structural role can never write row data, no matter what grants exist. This is checked in code, so no amount of grant editing can turn a structural role into one that posts data.

Role Kind Holds
superadmin structural Everything admin holds, plus backups and read access to all data, all journals, and all ECB conversion data
admin structural The seven structural areas: profile, table, script, validation, role, user, accounting — but no data access and no backups
guest data Nothing at all, until an admin grants it something or moves the user
accountant data Seeded for convenience but deliberately not built-in: it starts empty and this deployment’s admin decides what it may do

superadmin, admin, and guest are built-in: they cannot be renamed or removed through the API. accountant is seeded but removable like any other data role.

Why can an admin not take a backup? A dump contains every row. Backup therefore sits with superadmin alone, because admin does not read data.

Every “may X administer Y” question is answered by one rule:

superadmin (3) > admin (2) > every data role (1)

An actor may edit a role, edit its grants, or assign it to a user only when the target ranks strictly below the actor. Nobody may touch a role at or above their own authority:

  • an admin cannot mint another admin or edit superadmin;
  • an admin cannot promote a user past themselves;
  • a data role cannot administer another data role.

Because roles created at runtime are always data roles, an admin who takes a data role gives up being an admin — the two planes stay separate.

An object is a string with a fixed shape:

Shape Meaning
struct:<area> A structural area, granted only by the built-in policy, never through the API
data:<profile>/<table> One root table and its whole template family
data:<profile>/* Every table in a profile, including tables defined later
data:* Every table everywhere
journal:<profile> The accounting journal of one profile
journal:* Every profile’s journal
ecb:<profile> ECB conversion previews and evidence for one profile
ecb:* ECB conversion data everywhere

Objects match by prefix, so the * wildcards cover tables that do not exist yet. Actions match exactly, so a grant never widens into a neighboring action.

Plane Actions
Data read, insert, update, delete
Structural manage only
ECB data read only — conversion data is written by the server

An invoice header and its line-item children share one root table definition. A grant on the family root — for example data:acme/invoices — covers every child table, including children added by a later regeneration. Only family roots are grantable; naming a child directly is rejected.

The grant API enforces every rule that keeps the planes apart:

  • the object must be a data-plane object; structural areas cannot be granted;
  • the target role must rank below the actor;
  • a structural role may receive read on data but never a write action;
  • ECB objects accept read only;
  • the profile and table named must actually exist;
  • only family roots are grantable.

ListGrantableObjects returns the current menu: the three global objects, then per-profile data:<profile>/*, journal:<profile>, ecb:<profile>, and one object per root table. A grant on a profile’s tables never includes its journal or its ECB data — those are separate objects an admin grants deliberately.

A data role may inherit from another data role. Inherited grants count as the role’s own, so removing a parent removes what the child gained from it.

Inheritance may never cross planes: a data role inheriting a structural one would smuggle system permissions onto the data plane, so such an edge is refused when the role is created and ignored if it ever appears in storage.

ListUsers returns every account. AssignUserRole moves a user to another role and checks both ends of the change: the new role must rank below the actor, and the user being moved must not already hold a role at or above the actor’s authority.

A role cannot be removed while users hold it or other roles inherit from it; those refusals fail loudly instead of silently stripping someone’s access. Removing a role removes its grants, which cascade with it.

Every role or grant write rebuilds the in-memory policy from the database. The next authorization query sees the change immediately. The snapshot returned by login or GetAuthorization is computed from the same rebuilt policy.

Every authentication and authorization event is appended to an append-only auth_events table:

Event Meaning
login_succeeded A password was accepted and a token minted
login_failed A login was refused, with the reason in detail
password_claimed A bootstrap account was claimed
user_registered Someone self-registered
role_created, role_removed A data role was created or removed
permission_granted, permission_revoked A grant was added to or removed from a role
user_role_assigned A user was moved to a different role

The log records failures as well as successes — a run of refused logins is the only trace a password-guessing attempt leaves behind. On a failed login the record stores the identifier as it was presented, because that is all that is known about the caller.

Audit records deliberately have no foreign keys: the record must survive the deletion of the user it names. “Who removed this account, and when” is worthless if the record disappears with the account.

Writing an audit record never fails the request. If the insert errors, the server logs it loudly and the operation carries on, so a full disk or a broken connection cannot lock every user out.