navree
Documentation

Workspaces

Multi-tenancy at the workspace level, built in: workspaces, members, roles, invitations and data isolation.

On this page

A workspace is the unit your customers sign up as: a team, a company, an organization. Each workspace has members with roles, invites people by email and keeps its data apart from every other workspace's. WorkspaceModule gives every Navree app this structure out of the box: the tables, the services, the request extractor that finds the current workspace and checks membership, roles that feed access control, and three ways to isolate data.

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

Setting up

Featureworkspace The workspace feature of the navree facade brings the navree-workspace crate. It is part of the default full feature, so a new app has it already. Add WorkspaceModule to your app root:

src/main.rs

WorkspaceModule is global. It reads the workspace configuration section (from_config() and the plain WorkspaceModule both do; WorkspaceModule::for_root(WorkspaceConfig { .. }) takes the options in code) and needs DatabaseModule. The rest is optional, and each piece turns on a part of the module:

ModuleWhat it adds
AuthModule (or any authenticator)Signed-in users. Every workspace route needs one.
AccessModuleThe route checks of the built-in routes, and #[roles], #[permissions] and #[can] on your workspace routes. Required while workspace.routes is on.
EventsModuleThe events the module emits.
MailModule (with ViewModule)The invitation mail. Without it, invitations are created but not mailed.

Users are identified by the id of the request's principal, so the module works with navree-auth's users table or with your own user store.

The tables

The migration set workspace runs after auth and creates three tables, plus the tables of organizations and partitions, which stay empty until you turn those on:

TableColumns
workspacesid, name, slug (unique), owner_id, organization_id, timezone, country, kind (live or demo), created_at, updated_at
workspace_membersid, workspace_id (cascades on delete), user_id, role, partition_scope, created_at, updated_at; one row per workspace and user
workspace_invitationsid, workspace_id (cascades on delete), email, role, token_hash (unique), invited_by, expires_at, accepted_at, accepted_by, created_at

User ids are not foreign keys. Invitation tokens are stored as SHA-256 digests. Tables of your own that reference workspaces run after the set: MigrationSet::new("deals").after(["workspace"]).

A first workspace route

Here is a deals module whose routes work inside the current workspace. The InWorkspace guard finds the workspace, checks that the signed-in user belongs to it and evaluates the route's permissions against their role there:

src/deals/controller.rs

The deals table has a workspace_id column, and the entity says so in one line:

src/deals/entity.rs

WorkspaceRepo scopes every read to the workspace and sets the column on insert. Give members the permission in configuration:

config/app.toml

A request names its workspace with the X-Workspace header (or a subdomain, a path prefix or a token claim, see resolving the workspace):

HTTP

How a workspace request runs

The diagram above is the path of every workspace request. Authentication runs first, so a guest gets a 401. An identifier that is a UUID is looked up by id, anything else by slug. Once the membership is confirmed, the member's abilities inside the workspace are built from their role (see members and roles) and the isolation settings are applied to the request's unit of work (see data isolation). Then the route's #[roles], #[permissions] and #[can] checks run, then the handler.

Errors use Navree's JSON shape:

403 Forbidden

The guard (InWorkspace::guard()) resolves the workspace and checks the membership before the route's access checks. The CurrentWorkspace extractor does the same on its own when a route has no guard, but extractors run after the route's checks, so use the guard on any route that declares #[roles], #[permissions] or #[can].

Configuration

Every key of the workspace section is optional:

config/app.toml

The workspace.demo keys are described with demo workspaces. Environment variables follow the usual mapping (APP_WORKSPACE__HEADER=X-Team). Lists also accept a comma-separated string: APP_WORKSPACE__RESOLVERS=subdomain,header. Invalid values fail the boot and name the key.

The services

WorkspaceModule exports three services, four more for organizations (Organizations, OrgMembers, AccessRequests) and partitions (Partitions), and DemoWorkspaces for demo workspaces. Writes take the unit of work to run in (the request's Uow, or one from Db::uow in jobs and commands), go through Repo so the audit trail records them, and emit their events once the unit of work commits.

ServiceMethods
Workspacescreate(&uow, owner, NewWorkspace), rename, set_time_zone(&uow, id, timezone, country), for_user(user), find, find_by_slug, resolve(slug_or_id), membership, transfer_ownership, delete, enter, migrate, all
Memberslist(workspace), find, add(&uow, workspace, user, role), change_role, remove
Invitationsinvite(&uow, workspace, email, role, invited_by), accept(&uow, token, user), find_by_token, pending, revoke, prune_expired
DemoWorkspacescreate(&uow, owner, NewWorkspace), reset(id), reset_all, all, seeders, mode, options

Creating a workspace makes its creator the owner:

Rust

The slug comes from the name (Acme Inc. becomes acme-inc, then acme-inc-2 when that is taken), or from NewWorkspace::named("Acme").slug("acme"). A slug is lower-case letters, digits and single hyphens, at most 63 characters, and never a UUID. A taken slug is a 400 with errors.slug = ["slug has already been taken"]; renaming keeps the slug.

The routes

Unless workspace.routes is false, the module mounts these routes under the API prefix. {workspace} is a slug or an id, and every route needs a signed-in user:

RouteWho
GET /api/workspacesThe caller's workspaces, each with their role (and the demos when workspace.demo.guest_role is set)
POST /api/workspaces {name, slug?, timezone?, country?}Anyone; 201, the caller becomes the owner
GET /api/workspaces/{workspace}Members
PATCH /api/workspaces/{workspace} {name}workspace.update
PUT /api/workspaces/{workspace}/time-zone {timezone, country}workspace.update
DELETE /api/workspaces/{workspace}workspace.delete
POST /api/workspaces/{workspace}/transfer {user_id}workspace.transfer
POST /api/workspaces/{workspace}/leaveMembers; 409 for the owner
GET, POST /api/workspaces/{workspace}/membersworkspace.members.read, workspace.members.create
PATCH, DELETE /api/workspaces/{workspace}/members/{user}workspace.members.update, workspace.members.delete
GET, POST /api/workspaces/{workspace}/invitationsworkspace.invitations.read, workspace.invitations.create
DELETE /api/workspaces/{workspace}/invitations/{invitation}workspace.invitations.delete
POST /api/invitations/accept {token}The invited user

A workspace comes back as:

JSON

timezone is an IANA zone, stored under its current name (Asia/Calcutta is stored as Asia/Kolkata), and country an ISO 3166-1 alpha-2 code; both are optional, and PUT .../time-zone replaces both (null clears one). An unknown zone is a 400 on timezone, an unknown country a 400 on country. They give the workspace's requests and per-workspace schedules their zone: see time zones.

kind is live or demo. Lists are {"data": [...]}. The routes are in the OpenAPI document under the Workspaces tag. Set workspace.routes = false to write your own with the services.

Events

With EventsModule in the app, every change emits an event once its unit of work commits; a rolled-back change emits nothing. They live in navree::workspace::events:

EventNamePayload
WorkspaceCreatedworkspace.createdworkspace_id, name, slug, owner_id
WorkspaceDeletedworkspace.deletedworkspace_id, slug
OwnershipTransferredworkspace.ownership_transferredworkspace_id, from, to
MemberAddedworkspace.member_addedworkspace_id, user_id, role
MemberRoleChangedworkspace.member_role_changedworkspace_id, user_id, from, to
MemberRemovedworkspace.member_removedworkspace_id, user_id, role
InvitationSentworkspace.invitation_sentworkspace_id, invitation_id, email, role, invited_by, expires_at
InvitationAcceptedworkspace.invitation_acceptedworkspace_id, invitation_id, user_id, role
DemoWorkspaceSeededworkspace.demo_seededworkspace_id, slug, seeders, reset

A listener that seeds every new workspace:

src/onboarding.rs

Testing

Act as a user with acting_as and name the workspace with the header. app is the root function from src/main.rs above:

src/tests.rs

Each test gets its own SQLite file, so workspaces never leak between tests.