navree
Documentation

Workspaces and access

Models scoped to the current workspace with #[model(workspace)], models as access-control subjects with policies, list filters and record checks from the same rules, and unique and exists validation rules that query models.

On this page

Two questions come up for every record a request touches: does it belong to the request's workspace, and may this user act on it? The ORM answers both in the model, so every query and write follows the same rules without each handler remembering them.

Workspace scoping

Featureworkspace In a multi-tenant app built with workspaces, most records belong to one workspace. Mark the model workspace, with a workspace_id column:

Rust

workspace = "team_id" names another column. From then on, in a workspace request (one where CurrentWorkspace or the InWorkspace guard entered the workspace on the unit of work):

Rust
  • Reads get workspace_id = <current> as a global scope: queries, finds, counts, pages, plucks, mass updates and deletes, existence and IN subqueries, eager loads and route bindings (another workspace's record is a 404).
  • Writes go through a behavior every workspace-scoped model has, which runs in quiet operations too: a new record gets the current workspace (creating one without a current workspace fails, and one set to another workspace is refused); a stored record's workspace never changes; saving, deleting or restoring another workspace's record answers ModelNotFound, a 404. Mass updates can't set the workspace column.

The current workspace is the one a task runs with (navree::orm::workspace::scope), else the one the executor's unit of work entered (uow.workspace(); Workspaces::enter enters one for jobs and commands), else the current unit of work's. With none, a query of a workspace-scoped model fails: it never falls back to every workspace. System work names one or opts out explicitly:

Rust

The scoping is the same on every isolation strategy. On PostgreSQL with row isolation, row-level security reads the navree.workspace setting of the request's transaction, which every ORM statement runs in, so the database enforces the same boundary underneath; with schema isolation the search_path does; and a WorkspaceUow (database isolation) is an executor like any other.

Access control

Featureaccess With navree-access, a model can be a subject: the thing rules are about. Mark it subject and give it a policy, the rules about it:

Rust

The subject's name is the struct's (subject = "SupportTicket" renames it) and its permission prefix is the table (permission_prefix = "support"); policy implies subject. Register it once in its module, with the model:

Rust

The policy's rules then join every user's rules (before the app's abilities function, which can still override them), and routes check them:

Rust

#[can(Update, Ticket, from = "ticket")] loads the record by route key (with global scopes, so inside the current workspace, locked FOR UPDATE where the database supports it, in the unit of work), checks the ability, and hands it to the handler as Authorized<Ticket, Update>: a 404 when there's no such ticket, a 403 when the rules refuse. Accessible<Ticket, Read> turns the same rules into one SQL condition: scope.query() (the AccessibleQuery trait, navree::orm::access) starts a query of the readable tickets, and scope.apply(query) narrows one you have.

Outside routes, check with the ability directly:

Rust

A list filter and a record check are compiled from the same rules, last match wins, so a record is listed exactly when a check on it would pass; counts and pages of the query respect the filter. Rule conditions name model columns, and their values are bound with the column's type (UUIDs, dates, decimals, enums as stored). navree make:model tickets Ticket --policy --controller writes a model, a policy and a controller wired this way.

Validation rules

Featurevalidation Two rules query models while a request is validated: unique (no record has the value) and exists (a record has it). They are navree-validation AsyncRules, checked by ValidAsync after the input's other rules pass:

Rust

The rules query through the model in the request's unit of work, so its global scopes hold: on a workspace-scoped model a value is unique, or exists, within the current workspace. unique counts soft-deleted records too, since a unique index still covers their rows (without_trashed() leaves them out); exists doesn't, since a deleted record is gone (with_trashed() counts it). ignore(key) skips one record, ignore_path_param("deal") the one the route names (by route key), where_(..) narrows the query, and query(|q| ..) changes it freely. A failure is a 400 with errors.<field> like every validation error.