Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Policy, auth, tenancy

Three layers decide what a request may do. Authentication decides who is signed in. Tenancy limits a signed-in user to the rows of the tenant they act for. Policy — the resource’s ResourceDef::policy — decides what that user may do with each resource and record.

Policy

A policy answers one Ability at a time. The default policy is Deny, so a new resource exposes nothing until you allow it:

#![allow(unused)]
fn main() {
use tablo::prelude::*;

impl Resource for UserResource {
    // …
    fn declare() -> ResourceDef<Self> {
        ResourceDef::new()
            // …
            .policy(|cx: &Cx, ability: Ability<'_, User>| match ability {
                Ability::ViewAny | Ability::View(_) => true,
                Ability::Create | Ability::DeleteAny | Ability::Delete(_) => is_admin(cx),
                Ability::Update(user) => !user.sso_managed,
            })
    }
}
}
AbilityAsked by
ViewAnythe list and its live refresh, the export, related tables, relationship options
View(record)the detail page, the edit page and POST, deletes, each exported row, each relationship option, each row’s actions
Createthe create page and POST, the Create button
Update(record)the edit page and POST, the row’s Edit action
DeleteAnythe delete and bulk-delete POSTs, the Delete action and the bulk column
Delete(record)each record a delete removes, the row’s Delete action and checkbox

Handlers ask the same abilities that decide which buttons render, so a hidden action is also a refused request. A denied request answers 403.

  • The list asks ViewAny only. A policy is Rust code that cannot run in the database, and filtering rows after pagination would leave pages short. Rows a user must not see on the list belong out of query(); see Resources.
  • A record is viewed before it is written. The edit and delete handlers ask View together with Update or Delete, and a delete asks DeleteAny before any record loads.
  • Writes are checked against the stored row. The update and delete handlers load the record inside the write’s transaction and ask the policy about that row, not the submitted id. A bulk delete fails as a whole if any selected record is refused.
  • Relationship options require ViewAny and View from the related resource’s policy.

Building a policy

Allow, Deny, ReadOnly (ViewAny and View) and when(predicate) are policies, and each combines with another through and and or. when allows every ability while a predicate on the request holds, which suits a rule about the user or the tenant that several resources share. The rule reads the app’s own user type, Staff here (see Your own user table):

#![allow(unused)]
fn main() {
pub fn editors_only(cx: &Cx) -> bool {
    tablo::auth::user::<Staff>(cx).is_some_and(|staff| staff.editor)
}

impl Resource for PostResource {
    // …
    fn declare() -> ResourceDef<Self> {
        ResourceDef::new()
            // …
            .policy(ReadOnly.or(when(editors_only)))
    }
}

impl Resource for AuthorResource {
    // …
    fn declare() -> ResourceDef<Self> {
        ResourceDef::new()
            // …
            .policy(when(editors_only))
    }
}
}

A closure |cx: &Cx, ability: Ability<'_, M>| -> bool is a policy too, as in the first example, and so is any type that implements Policy<M>.

In your own code

can::<R>(cx, ability) asks R’s policy, for a page or route that renders or writes R’s records itself. can_list::<R>(cx) answers whether the request may open R’s list: the panel’s sign-in, R’s tenant, and ViewAny, the checks the list handler makes. A dashboard that links to lists checks it before rendering each link.

A page, route or shard the app serves under a panel calls auth::guard(cx)? to require the panel’s signed-in user exactly as the panel’s own pages do. A shard needs it: Topcoat serves shards at its runtime path, where no page guard runs. A form route verifies its CSRF token with csrf::verify.

Authentication

Authentication is on by default. The built-in login checks an email and password against the AdminUser table and keeps sessions in the AuthSession table; register both models with Toasty and create a user with a hashed password:

#![allow(unused)]
fn main() {
pub fn auth_models() {
    let _ = toasty::models!(
        crate::Book,
        tablo_core::auth::AdminUser,
        tablo_core::auth::AuthSession
    );
}
}
#![allow(unused)]
fn main() {
pub fn admin_hash() -> Result<String> {
    let password_hash = tablo_core::auth::hash_password("secret")?; // Argon2id
    Ok(password_hash)
}
}

Your first panel shows the complete setup.

What a request gets

RequestSigned in with panel accessSigned in without panel accessNot signed in
GET of a panel pagethe page403redirect to /admin/login?next=…
any other panel requesthandled403401
/_topcoat/runtimehandled403401

After login, the user returns to next, which must be a same-origin path. A user has panel access when their AdminUser.active is true. The gate covers only the panel prefix and /_topcoat/runtime; routes elsewhere, including your app’s own routes and directories served with Panel::serve_dir, are public. On a router with several panels, /_topcoat/runtime answers 401 without a session only when every panel is gated.

  • Login verifies Argon2id hashes. An unknown email costs the same work as a wrong password, and every failure shows the same message.
  • Sessions are rows in AuthSession, identified by a hash of the cookie’s token. A session lasts seven days from login, is replaced on each login and deleted on logout. Each successful login also deletes up to 500 expired sessions. The row records the prefix of the panel that signed the user in, so an existing auth_session table needs a migration: add a non-null panel column and backfill it to the panel’s prefix (for example ALTER TABLE auth_session ADD COLUMN panel TEXT NOT NULL DEFAULT '/admin'), then drop the default when every row names its panel. Renaming a panel’s prefix invalidates the sessions it issued.
  • Revoking. Call auth::revoke_sessions_for_user(cx, user_id) when a user’s password changes or their account is deactivated; otherwise existing sessions stay valid until they expire. On a panel’s request it revokes the sessions that panel issued; outside any panel, the user’s sessions on every panel, so a reset flow for a user with two panels revokes from outside a panel or once per panel.
  • Several panels. Each panel has its own Auth and its own login page, and a session belongs to the panel that signed the user in: the session row records the panel’s prefix. Another panel’s gate treats the request as anonymous, and auth::user answers None there. One browser holds one session, so signing in to a second panel ends the first.

Read the signed-in user in your own code as your user type: auth::user::<AdminUser>(cx) returns Option<&AdminUser>, and auth::require_user::<AdminUser>(cx)? answers the request as the table above when nobody is signed in. auth::signed_in(cx) answers whether anyone is.

Your own user table

Implement PanelUser for the type the panel signs in, and Authenticator to load it:

#![allow(unused)]
fn main() {
use tablo::{Membership, PanelUser, auth::{Authenticator, verify_password}};

pub struct SignedStaff {
    pub staff: Staff, // your model
    pub workspaces: Vec<Membership>,
}

impl PanelUser for SignedStaff {
    fn user_id(&self) -> String {
        self.staff.id.to_string()
    }
    fn display_name(&self) -> &str {
        &self.staff.name
    }
    fn can_access_panel(&self) -> bool {
        self.staff.active
    }
    fn tenants(&self) -> &[Membership] {
        &self.workspaces
    }
}

pub struct StaffAuth;

impl Authenticator for StaffAuth {
    type User = SignedStaff;

    async fn verify(&self, cx: &Cx, login: &str, password: &str) -> Result<Option<SignedStaff>> {
        let staff = find_staff_by_email(cx, login).await?;
        if !verify_password(password, staff.as_ref().map(|s| s.password_hash.as_str())) {
            return Ok(None);
        }
        // … load the memberships and return Some(SignedStaff { .. })
        todo!()
    }

    async fn find_by_id(&self, _cx: &Cx, _id: &str) -> Result<Option<SignedStaff>> {
        // … the same user and memberships, by `user_id`
        todo!()
    }
}

pub async fn find_staff_by_email(_cx: &Cx, _login: &str) -> Result<Option<Staff>> {
    todo!()
}
}
#![allow(unused)]
fn main() {
pub fn staff_auth_panel() -> Panel {
    Panel::new("admin").auth(Auth::custom(StaffAuth))
}
}
  • The user type is yours. It need not be a model: the showcase’s SignedStaff is a row plus the workspaces it holds a seat in. auth::user::<SignedStaff>(cx) reads it back, fields and all, and answers None on a panel whose authenticator loads another type.
  • verify checks credentials. Return Ok(None) for every credential failure. verify_password checks an Argon2id hash and, given None for an unknown account, does the same work, so response times do not reveal which accounts exist.
  • find_by_id reloads the user on every request, so deactivating a user or removing a membership takes effect immediately.

Sessions stay in AuthSession, so register it; the provided AdminUser is needed only by PasswordAuth.

Turning it off

#![allow(unused)]
fn main() {
pub fn open_panel() -> Panel {
    Panel::new("admin").auth(Auth::disabled())
}
}

Every panel route is then public and the login routes are not registered. Use it for public demos and tests only.

Tenancy

A tenant-owned resource shows each user only the rows of the tenant they act for. A user may act for each tenant PanelUser::tenants lists, as a Membership { tenant, name }; the provided AdminUser belongs to none, so a tenanted app signs in its own user table.

  • The request’s tenant is the membership the user selected, else their first, read with tenant_id(cx); membership(cx) returns the whole Membership. A user with no membership has no tenant.
  • Switching. With two or more memberships, the top bar shows a tenant switcher. It posts to {prefix}/tenant, which stores the choice on the session and returns to the panel’s home page. A tenant the user is not a member of answers 403, and a stored choice applies only while the membership lasts. The session table records the choice, so an existing auth_session table needs a nullable tenant column, typed as Toasty stores a Uuid on your database. One browser acts for one tenant at a time, in every tab.
  • Overrides. No request header sets the tenant. Server code such as a middleware or a test can, by inserting a Tenant request extension or putting Tenant(id) on the Cx.

Declare the resource’s tenancy with the column its rows carry their tenant in:

#![allow(unused)]
fn main() {
impl Resource for PostResource {
    type Model = Post; // has `tenant_id: uuid::Uuid`
    // …

    fn declare() -> ResourceDef<Self> {
        ResourceDef::new()
            // …
            .tenancy(Tenancy::column(Post::fields().tenant_id()))
    }
}
}

That one declaration does two things:

  • The gate. Every handler of the resource answers 403 when the request has no tenant.
  • The scope. Every loader — list, export, detail, edit, delete, bulk delete, relationship options, related tables — adds tenant_id = <request tenant> to the resource’s query. A create sets the tenant column itself, so the record form leaves it out.

The column is a Uuid or Option<Uuid> field of the model, named by its lens, and it can have any name. Do not repeat the filter in query. Mounting the panel refuses a Tenancy::column lens that is not one field of the model.

A row that inherits its tenant. A comment has no tenant column of its own; it belongs to a post that does. Tenancy::via names the tenant through the relation, and the framework gates and filters exactly as it does for a column:

#![allow(unused)]
fn main() {
impl Resource for CommentResource {
    // …
    fn declare() -> ResourceDef<Self> {
        ResourceDef::new()
            // …
            .tenancy(Tenancy::via(Comment::fields().post().tenant_id()))
    }
}
}

Nothing is stamped on create: a comment’s tenant is its post’s. The lens starts at a belongs_to relation, and the form declares that relation’s foreign key (post_id) as a relationship field over a tenant-scoped resource, so a submitted post must be one of the request tenant’s posts. Mounting the panel refuses a form that writes the key any other way, or a lens that does not start at a belongs_to.

A deliberately cross-tenant resource — a super-admin view — declares no tenancy and filters in query(). That gives up both the gate and the scope.

In your own code, load rows with scoped_query::<PostResource>(cx)?. It applies the tenant scope and answers 403 when the request has no tenant. PostResource::query(cx) does not apply the tenant scope.

Foreign keys are re-checked inside the write. A relationship field’s key must be one of the related resource’s records for the request: in its tenant-scoped query and allowed by its View. The create and edit handlers check it when they validate the form, and again inside the write’s transaction, so a related record deleted, moved to another tenant or hidden in between refuses the write with the same field error. A create_record or update_record the panel’s create and edit POSTs call needs no check of its own.