navree
Documentation

Data isolation

Keep each workspace's data apart by row, by schema or by database.

On this page

Every workspace's data must stay its own: a query in Acme never returns Globex's deals, and a request can't write into another workspace. Navree offers three strategies, chosen with workspace.isolation. The workspace tables themselves (workspaces, workspace_members, workspace_invitations, and the organization and partition tables) always live in the app's main database; the strategy decides where your workspace-owned data lives.

StrategyWhere data livesEnforced byDatabases
row (default)Shared tables with a workspace_id columnWorkspaceRepo scoping, route record checks, and row-level security on PostgreSQLSQLite, PostgreSQL, MySQL (scoping only)
schemaOne PostgreSQL schema per workspaceThe transaction's search_pathPostgreSQL
databaseOne database per workspaceA separate connection poolSQLite, PostgreSQL

Row isolation fits most apps: one schema to migrate, cross-workspace reporting with plain SQL, and a new workspace adds nothing but rows. Schema and database isolation put a harder wall between workspaces, for data residency, per-workspace backups or customers who ask for it, at the price of migrating every workspace.

Row isolation

Workspace-owned tables carry the workspace's id, and every query filters by it.

Scoping queries

Mark the entity, then read and write through WorkspaceRepo:

src/deals/entity.rs

A different column name goes in the trait: impl WorkspaceScoped for deal::Entity { const WORKSPACE_COLUMN: &'static str = "team_id"; }.

WorkspaceRepo::<E>::Does
select(workspace)E::find() limited to the workspace's rows
scope(workspace, select)Adds the workspace filter to any Select<E>
find(&uow, workspace, id), find_or_failReads one record of the workspace; another workspace's record is not found (a 404 from a handler)
insert(&uow, workspace, model)Sets the workspace column, then inserts
update(&uow, workspace, model)Updates a record of the workspace; the workspace column can't change
delete(&uow, workspace, model)Deletes a record of the workspace

Writes go through Repo, so the audit trail and other change observers see them. CurrentWorkspace::scope(select) is the same filter for the request's workspace:

Rust

Records checked by route

A route that checks a record, #[can(Update, Deal, from = "id")], loads it by primary key before the handler runs. Register the entity as workspace-scoped in its module, and the InWorkspace guard first confirms the record belongs to the current workspace; a record of another workspace is a 404, whatever the member's role:

src/deals/mod.rs

Row-level security on PostgreSQL

On PostgreSQL, the database enforces the scope too. Every workspace request sets the setting navree.workspace to the workspace's id for its transaction (set_config(.., true), so it ends with the transaction), next to the navree.actor and navree.request_id settings navree-db sets. A row-level security policy compares each row's column with it. RlsPolicy writes the SQL; run it in the table's migration:

src/deals/migration.rs

The SQL it runs (rls_policy_sql("deals") returns the same string):

SQL
  • USING hides other workspaces' rows from reads, updates and deletes; WITH CHECK refuses to write a row into another workspace.
  • FORCE applies the policy to the table's owner, usually the role the app connects as.
  • Without the setting, no row is visible. A transaction that hasn't entered a workspace sees an empty table.
  • PostgreSQL superusers and roles with BYPASSRLS skip every policy. Connect the app as an ordinary role, and keep a BYPASSRLS role for maintenance across workspaces.
  • RlsPolicy::new("deals").column("team_id") uses another column; drop_sql() removes the policy in down.

Setting navree.workspace begins the request's transaction when the workspace is resolved, before any query the route runs. Jobs and commands set it with Workspaces::enter:

Rust

SQLite and MySQL have no row-level security, and Workspaces::enter sets nothing there. On them, row isolation is the scoping alone: a query that leaves out the workspace filter sees every workspace's rows, so read and write workspace data through WorkspaceRepo.

On SQLite, enter and the request do nothing to the transaction: WorkspaceRepo and the route record checks are the scope.

Organizations and partitions

Two more settings widen and narrow the policy when organizations or partitions are on:

SettingSet byPolicy optionEffect
navree.organizationCurrentOrganization, InOrganization, Organizations::enter.organizations()Reads also see the rows of every workspace attached to the organization
navree.partitionCurrentPartition and PartitionScope when the request names a partition, Partitions::enter.partitioned()While it is set, only that partition's rows are visible and writable
Rust

.organizations() adds a second policy, for SELECT only:

SQL

PostgreSQL combines the two policies with OR for reads, and only the workspace policy applies to inserts, updates and deletes: an organization request reads across its workspaces, and changes rows only in a workspace it has also resolved. The read policy covers the whole organization; a member limited to some of its workspaces is narrowed by OrgRepo with CurrentOrganization::workspace_ids(). With .partitioned() as well, the partition condition narrows both policies. drop_sql() drops both.

In code, OrgRepo::<E> reads across workspaces and PartitionRepo::<E> narrows WorkspaceRepo to partitions, on every database.

Migrations

Row isolation uses ordinary migrations (m.migrations(..)): one set of tables for every workspace.

Schema isolation

Featurepostgres Each workspace gets its own PostgreSQL schema, workspace_<id> (the prefix is workspace.schema_prefix, the id has no hyphens). Every workspace request sets the transaction's search_path to "workspace_<id>", public, so unqualified table names find the workspace's tables first and the shared ones (users, workspaces) in public.

config/app.toml

The app refuses to boot with schema isolation on any other database.

The tables in each workspace's schema come from workspace migrations, registered with workspace_migrations instead of migrations:

src/notes.rs
  • Creating a workspace creates its schema and runs every workspace migration in it, in the same transaction.
  • Each schema records its own migrations in its own navree_migrations table.
  • New workspace migrations reach existing workspaces with navree workspace:migrate (every workspace) or navree workspace:migrate acme globex (some). Run it after navree migrate when you deploy.
  • Deleting a workspace drops its schema (DROP SCHEMA ... CASCADE) in the same transaction.

Handlers write their queries without any workspace filter; WorkspaceUow (or the request's Uow, which is the same unit of work here) already points at the workspace's schema:

Rust

Jobs and commands call Workspaces::enter(&uow, id), which sets the same search_path.

Database isolation

Each workspace gets its own database, named by a URL template where {id} is the workspace's id without hyphens:

config/app.toml
  • WorkspaceDatabases (exported by the module) opens one connection pool per workspace on first use, with the pool settings of the database section, and keeps it for the life of the process.
  • The first time a process opens a workspace's database, it runs the pending workspace migrations (workspace_migrations, as above). navree workspace:migrate runs them for every workspace.
  • A SQLite file and its directory are created on first use. A PostgreSQL database must exist before first use: create it when the workspace is created, for example in a WorkspaceCreated listener.
  • Deleting a workspace closes its pool once the transaction commits and leaves the database in place, so you decide when the data goes.

WorkspaceUow

WorkspaceUow is the unit of work for the current workspace's data under every strategy: the request's own Uow under row and schema isolation, and a unit of work on the workspace's database under database isolation. It dereferences to Uow, so Repo, WorkspaceRepo and paginate take it:

Rust

Under database isolation, the workspace's unit of work opens on first use and commits with the request: when the response status is below 400, after the handler; otherwise it rolls back. It commits separately from the main database's unit of work, so a change that spans both (a workspace row and a central row) is two commits.

In a job or command, take the pool and run your own unit of work:

Rust

databases.url(workspace_id) returns the workspace's URL, and databases.forget(workspace_id) closes its pool.

Choosing

RowSchemaDatabase
Migrationsm.migrations(..), run oncem.workspace_migrations(..), run per workspacem.workspace_migrations(..), run per workspace
QueriesFilter with WorkspaceRepoPlain queriesPlain queries
Cross-workspace reportsPlain SQLUNION across schemasOne query per database
Cost of one more workspaceIts rowsA schema and its tablesA database and a connection pool
Backup or delete one workspaceDelete by workspace_idDump or drop one schemaDump or drop one database

Testing

Row isolation runs on SQLite, so tests need nothing special: create two workspaces and check that one can't see or change the other's records.

Rust

An isolation suite writes those tests for every route of a module, from its route table and a model factory, under every isolation strategy:

Rust

Database isolation also runs on SQLite. Point the template at a temporary directory so each test gets its own files:

Rust

Row-level security and schema isolation need PostgreSQL; run those tests against a disposable server, connecting as a role that isn't a superuser.