navree
Documentation

Multi-tenant SaaS

Workspace-level multi-tenancy: resolving the workspace, checking membership, scoping every query, and choosing row, schema or database isolation.

On this page

Introduction

A multi-tenant SaaS serves many customers from one deployment, and each customer must only ever see its own data. In Navree the tenant is a workspace: an organization, a team or a client account, with members, roles and invitations. Workspaces are part of the framework: WorkspaceModule is in the default navree features, so a new app can be multi-tenant from its first commit.

Multi-tenancy by workspaceA request names its workspace, for example with the header X-Workspace: acme. Navree resolves the workspace from the header, the path, the subdomain or a token claim, and answers 400 or 404 when it can't. It then checks that the user is a member, and answers 403 to anyone else; the member's role adds its permissions. The request's unit of work is scoped to the workspace by setting navree.workspace on its transaction. The configured isolation decides where the data lives: rows with a workspace_id column guarded by PostgreSQL row-level security, a schema per workspace, or a database per workspace.RequestX-Workspace: acmeResolve the workspace400 or 404 if it can'tCheck membership403 for anyone elseScoped unit of worksets navree.workspaceRowrow-level securitySchemaone per workspaceDatabaseone per workspaceworkspace.isolation
Multi-tenancy by workspace
  • request

This page shows how the pieces fit into a SaaS backend: how a request finds its workspace, how queries stay inside it, and how to choose an isolation strategy. The Workspaces section documents the module itself in full.

The request path

Every request to a workspace route goes through the same steps before your handler runs:

  1. Authenticate

    The user is identified from the bearer token or the session, as on any route.

  2. Resolve the workspace

    The resolvers run in the configured order until one finds a workspace slug or id: the X-Workspace header, a path segment (/api/w/acme/...), a subdomain (acme.app.example.com) or a claim of the bearer token. A request that names no workspace is a 400, and an unknown workspace a 404.

  3. Check membership

    The user must be a member of that workspace. Anyone else gets a 403, so knowing a slug never gives access to its data.

  4. Scope the unit of work

    The request's unit of work is tied to the workspace. On PostgreSQL its transaction sets navree.workspace, which row-level security policies check, and under schema isolation it sets the search_path to the workspace's schema.

  5. Apply the member's role

    The user's abilities are rebuilt for this workspace: your abilities function runs with the member's workspace role and its permissions, so #[permissions] and #[can] on the route decide with the role the user has in this workspace, not in another one.

The InWorkspace guard runs these steps before the route's access check, and the CurrentWorkspace extractor hands the result to the handler. Each failure has the error shape every Navree response uses:

403 Forbidden

Setting it up

Featureworkspace WorkspaceModule comes with the workspace feature, which is on by default. Add it to the app root after the database and auth modules:

src/app.rs

It is global, and contributes the tables workspaces, workspace_members and workspace_invitations (the workspace migration set, after auth), the services Workspaces, Members and Invitations, and routes for managing workspaces, members and invitations under /api/workspaces and POST /api/invitations/accept. Set workspace.routes = false to write your own.

config/app.toml

See Workspaces for every key.

Resolving the workspace

Pick the resolvers that match how clients address a workspace:

ResolverExampleGood for
headerX-Workspace: acmeAPI clients and a BFF: the web app sends the workspace it shows
path/api/w/acme/projectsURLs that name the workspace, shareable links
subdomainacme.app.example.comA branded URL per customer; set workspace.base_domain
claima workspace claim in the bearer JWTTokens issued for one workspace

A resolver finds a slug or an id, and the first one that finds something wins. The path resolver reads the segment after path_prefix, so it applies to routes whose path contains it, such as a controller mounted at /w/{workspace}/projects; for those, InWorkspace::from_path("workspace") reads the route parameter directly. Keep the list short: every resolver you add is another way to name a workspace that your clients and your tests have to agree on. Resolution covers each resolver and its settings.

Scoping every query

With row isolation, every tenant table has a workspace_id column. The rule is simple: no query on a tenant table runs without the current workspace. Navree helps you keep it in four layers.

Mark the entity. An entity that implements WorkspaceScoped belongs to a workspace, through its workspace_id column:

src/modules/projects/entity.rs

Query through WorkspaceRepo. It is Repo with the workspace built in: reads filter by workspace_id, inserts set it, and updates and deletes only touch rows of that workspace. A record of another workspace is a 404, as if it didn't exist.

src/modules/projects/service.rs

Guard the controller. InWorkspace::guard() resolves the workspace and checks membership before the route's access check, so the access rules see the member's role. Handlers take CurrentWorkspace for the workspace and WorkspaceUow for the unit of work that holds its data:

src/modules/projects/controller.rs

m.workspace_scoped::<Project>() makes #[can(.., from = "id")] check that the record belongs to the current workspace before the access rule runs, so asking for another workspace's project is a 404 before the service is called. WorkspaceUow derefs to Uow: under row and schema isolation it is the request's own unit of work, already scoped, and under database isolation it is a unit of work on the workspace's database, committed with the request. Handlers written with it work under every strategy.

Let PostgreSQL enforce it. A missed filter is a data leak, so on PostgreSQL add a row-level security policy to each tenant table in a migration. Rows are then visible and writable only while the transaction's navree.workspace setting holds their workspace id, which every workspace request sets:

src/modules/projects/migrations/m20261026_100000_projects_rls.rs

The policy is forced, so it binds the role the app connects as. A query that forgot its filter returns nothing instead of everyone's rows. Maintenance that spans workspaces runs as a database role with BYPASSRLS.

Outside a request

Jobs and commands have no request to resolve a workspace from. Put the workspace id in the job's payload, and scope the job's unit of work with Workspaces::enter before you query:

src/modules/projects/jobs/archive.rs

On PostgreSQL, enter sets navree.workspace (and, under schema isolation, the search_path) for the transaction, exactly as a workspace request does. Under database isolation, open the workspace's own database with WorkspaceDatabases::db(id) instead.

Events need the same care: put workspace_id in every event about tenant data, so queued listeners and other services know which workspace to enter. The module's own events (WorkspaceCreated, MemberAdded, MemberRoleChanged and the rest) carry it.

Isolation strategies

workspace.isolation decides how one workspace's data is kept apart from another's. The handlers and services stay the same under all three; what changes is where the rows live.

row (default)schemadatabase
HowOne set of tables with a workspace_id column, filtered by WorkspaceRepo and by PostgreSQL row-level securityOne PostgreSQL schema per workspace; each request's transaction sets the search_pathOne database per workspace, from workspace.database_url with {id} in it
New workspaceA rowA schema, created and migrated in the same transactionA database, connected and migrated on first use
MigrationsOnceOnce per workspaceOnce per workspace
Cross-workspace reportsOne queryA query per schemaA query per database
Backup or restore one workspaceFilter by workspace_idDump one schemaDump one database
Noisy neighborsShare every table and indexShare the serverSeparate databases, on separate servers if you like
ScaleMillions of small workspacesHundreds to a few thousandTens to hundreds of large ones

How to choose:

  • Start with row. It is the cheapest to run and to migrate, and with row-level security the database enforces the boundary, not only your code. Most SaaS products never need more.
  • Choose schema when customers ask for stronger separation, per-customer exports are routine, or you want to restore one customer without touching others, and the number of workspaces stays in the thousands.
  • Choose database for a few large customers who need their own database: data residency in a region, dedicated capacity, or a contract that says so. Each workspace's database can live on another server.

Tables that every workspace shares (users, workspaces, plans) stay in the app's database under every strategy. Under row isolation, tenant tables are ordinary migrations (m.migrations(..)). Under schema and database isolation, register them as workspace migrations instead, so they are created in each workspace's schema or database: when a workspace is created (schema), when its database is first opened (database), and by workspace:migrate after a deploy:

src/modules/projects/mod.rs

Isolation covers each strategy's settings and how to move between them.

Roles and permissions per workspace

A user can own one workspace and be a viewer in another. Each membership has a role, and the role's permissions apply only inside its workspace:

RoleBuilt-in permissions
ownerall.*: everything in the workspace, including deleting it and transferring it
adminworkspace.read, workspace.update, workspace.members.*, workspace.invitations.*
memberworkspace.read, workspace.members.read
viewerworkspace.read, workspace.members.read

member and viewer differ only in what your app grants them. Add your features' permissions to the built-in roles, and define roles of your own, in configuration:

config/app.toml

The permissions feed access control: on a route guarded by InWorkspace, your abilities function runs with the member's workspace role and that role's permissions, so routes use the same attributes as anywhere else. #[permissions("billing.manage")] lets in owners and members with the billing role in the request's workspace, and nobody else. Inside a handler, ws.role(), ws.has_permission("invoices.read") and ws.authorize("billing.manage")? answer the same questions in code. Members and roles and Invitations cover membership in full.

Billing per workspace

In most SaaS products the customer is the workspace, not the user. Billing bills workspaces when you register the workspace as the billable type:

src/modules/settings/mod.rs

Checkout, the customer portal and subscription checks then take the current workspace, plans can charge per seat with the member count as the quantity, and the Subscribed extractor answers 402 on routes the workspace's plan doesn't include. Feature flags and settings can be scoped per workspace the same way, so one customer can try a feature before everyone else (Feature flags).

Workspaces across the stack

The current workspace follows the work wherever it goes:

  • Real time. Broadcast tenant changes on a channel per workspace (private-workspace.{id}) and authorize it by membership, so a browser only hears about its own workspace. See Broadcasting.
  • Cache. Put the workspace id in every cache key for tenant data.
  • Files. Store tenant uploads under a directory per workspace on your storage disk.
  • Services. In a microservice setup, the workspace travels with every message, and the receiving service scopes its unit of work the same way.

Testing

Every multi-tenant app needs one test above all others: a member of one workspace can't read or change another workspace's data. Create two workspaces, sign in as a member of the first, and try the second's records:

tests/isolation.rs

Run it on PostgreSQL in CI as well as SQLite: row-level security only exists on PostgreSQL, and that is the layer that catches a missed filter.