navree
Documentation

Partitions

Split a workspace's data into named partitions, limit members to some of them, and scope every query to the partitions a request may touch.

On this page

A partition is a named slice of one workspace's data: a mail domain, a region, a brand, a business unit. Rows of partitioned tables carry a partition_id, and each member sees every partition or only the ones granted to them. The workspace stays the tenant, with one set of members, roles and settings; partitions decide which of its rows a member works with.

Partitions are off by default and are independent of organizations: use either, both or neither.

Turning them on

Featureworkspace Partitions are part of WorkspaceModule:

config/app.toml

From the environment: APP_WORKSPACE__PARTITIONS__ENABLED=true. Partitions have no subdomain resolver; listing subdomain fails the boot. While enabled is false, the partition routes aren't mounted and the services answer a 500 that names the setting.

The workspace migration set creates the tables whether the feature is on or not:

TableColumns
workspace_partitionsid, workspace_id (cascades on delete), name, slug (unique in the workspace), created_at, updated_at
partition_grantsid, workspace_id, member_id (cascades), partition_id (cascades), created_at: the partitions of an only member

It also adds workspace_members.partition_scope, all (the default) or only.

Creating partitions

The Partitions service manages a workspace's partitions:

Rust
MethodDoes
create(&uow, workspace, NewPartition)Creates a partition; the slug comes from the name (acme.co.uk becomes acme-co-uk) unless you set one; a taken slug is a 400 on slug
rename(&uow, workspace, id, name)Renames it; the slug stays
delete(&uow, workspace, id)Deletes it with its grants. Rows of your tables keep their partition_id: delete or move them first
list(workspace), visible(workspace, selector)The workspace's partitions by name, and the ones a selector sees
find(workspace, id), resolve(workspace, slug_or_id)Looks one up; resolve is a 404 when there is none
access(workspace, user)The PartitionSelector of a member; 404 for a non-member
grant(&uow, workspace, user, selector)Sets what a member sees: All, or Only partitions of this workspace (a 400 on partitions otherwise)
enter(&uow, id)Scopes a job's unit of work to one partition (see row-level security)

Who sees which partitions

  • A new member sees every partition (All) until you grant them Only some.
  • The workspace owner always sees every partition: limiting them is a 409.
  • A user who enters the workspace through its organization sees every partition.
  • A member limited to Only some partitions doesn't see rows without a partition.

Making a table partitioned

Give the table a partition_id column next to its workspace_id, and mark the entity with PartitionScoped on top of WorkspaceScoped:

src/contacts/entity.rs

Another column name goes in the trait: impl PartitionScoped for contact::Entity { const PARTITION_COLUMN: &'static str = "domain_id"; }. Then read and write through PartitionRepo, which is WorkspaceRepo narrowed to a PartitionSelector:

PartitionRepo::<E>::Does
select(workspace, selector)The workspace's rows the selector sees
scope(workspace, selector, select)Adds both filters to any Select<E>
find(&uow, workspace, selector, id), find_or_failOne record, if the selector sees it; else not found (a 404 from a handler)
insert(&uow, workspace, partition, model)Sets both columns, then inserts
update(&uow, workspace, selector, model)Updates a record the selector sees; moving it to another partition needs the selector to see that one too
delete(&uow, workspace, selector, model)Deletes a record the selector sees

The partitions of a request

Two extractors resolve the request's partitions inside its workspace. Both resolve the workspace first.

PartitionScope is what a list or a report needs: the partition the request names, if it names one, else every partition the member sees.

src/contacts/controller.rs

CurrentPartition requires the request to name a partition, as a write that must land in one does. It dereferences to the Partition row (partition.slug) and has id(), workspace_id() and resolver().

PartitionScope methodReturns
workspace_id()The workspace
selector()What this request touches: the named partition, else allowed()
allowed()Every partition the member sees, whatever the request names
current()The named partition, as a CurrentPartition
includes(id)Whether the request may touch partition id
scope(select)select limited to the workspace and selector()
SituationStatusmessage
CurrentPartition and no resolver found a partition400The request names no partition.
No partition with that slug or id in the workspace404Partition not found.
The member doesn't see that partition403You don't have access to this partition.

Both extractors also answer the workspace's errors (401, 400, 404, 403) first.

Row-level security on PostgreSQL

When a request names a partition, its transaction gets navree.partition, next to navree.workspace. RlsPolicy::partitioned() adds the partition to the workspace policy: while the setting is set, only that partition's rows are visible and writable.

Rust

The policy it creates (after the same ALTER TABLE lines as the workspace policy) checks the workspace and, when one is set, the partition:

SQL

A request that names no partition has no navree.partition, so the policy holds it to the workspace only: a member limited to some partitions is kept to them by PartitionScope and PartitionRepo, not by the database. RlsPolicy::partition_column("domain_id") uses another column. Jobs call partitions.enter(&uow, id) after workspaces.enter(&uow, workspace_id). Under database isolation the request doesn't set it.

The routes

With workspace.routes and workspace.partitions.enabled on, the workspace routes gain:

RoutePermissionAnswer
GET /api/workspaces/{workspace}/partitionsworkspace.partitions.readThe partitions the caller sees, {"data": [...]}
POST /api/workspaces/{workspace}/partitions {name, slug?}workspace.partitions.create201, the partition
PATCH /api/workspaces/{workspace}/partitions/{partition} {name}workspace.partitions.updateThe partition
DELETE /api/workspaces/{workspace}/partitions/{partition}workspace.partitions.delete204
GET /api/workspaces/{workspace}/members/{user}/partitionsworkspace.members.read{"user_id": "...", "partitions": "all"}
PUT /api/workspaces/{workspace}/members/{user}/partitions {partitions}workspace.members.updateThe same shape

{partition} is a slug or an id, and partitions is "all" or an array of partition ids. With partitions on, the built-in roles gain workspace.partitions.* (admin) and workspace.partitions.read (member, viewer); the WorkspacePartition subject has the prefix workspace.partitions. Creating, renaming and deleting partitions also takes seeing every partition (else a 403 with the code partition_manager_required), and a member limited to some partitions grants only those (else a 403, partition_grant_forbidden).

Events

EventNamePayload
PartitionCreatedworkspace.partition_createdworkspace_id, partition_id, name, slug
PartitionDeletedworkspace.partition_deletedworkspace_id, partition_id, slug
PartitionAccessChangedworkspace.partition_access_changedworkspace_id, user_id, partitions

Testing

Partitions run on SQLite. Create two partitions, limit a member to one and check both reads and writes:

Rust