Multi-Tenancy
Every operation in Saaslivery is scoped to a workspace_id. There are zero exceptions.
Multiple workspaces — large enterprises, small startups, solo users — run on the same infrastructure. A workspace must never see, receive, or affect another workspace's data.
How workspace identity flows
The platform's auth middleware runs before any app code. It reads the session, extracts the workspace, and sets ctx.workspace_id:
# Platform middleware — runs on every request
async def enforce_workspace(req, res, ctx):
session = await resolve_session(req)
ctx.workspace_id = session.workspace_id # read-only after this
ctx.user = session.user
Apps read ctx.workspace_id — they never set it. The platform makes it immutable after initialization.
Isolation at every layer
1. Requests
ctx.workspace_id is set by platform middleware before any handler runs. Apps cannot bypass it.
2. Database
Every table includes a workspace_id column. The platform provides a scoped query helper:
# Apps use ctx.db — a ScopedDB already bound to the workspace
async def list_requests(req: Request, res: Response, ctx: Context):
requests = await ctx.db.find("vacations_requests")
# Only returns rows where workspace_id matches ctx.workspace_id
When you drop to raw SQL via db.raw, the scoping is your job: every query must carry WHERE workspace_id = ... explicitly.
-- Every app table follows this pattern
CREATE TABLE vacations_requests (
id UUID PRIMARY KEY,
workspace_id TEXT NOT NULL,
employee_id TEXT NOT NULL,
start_date DATE NOT NULL,
status TEXT NOT NULL
);
CREATE INDEX idx_vacations_requests_workspace
ON vacations_requests(workspace_id);
3. Events
The event bus only delivers events within the same workspace:
await req.app.emit("core.vacations.request.approved", {
"workspace_id": ctx.workspace_id, # platform overwrites this from ctx
"data": { ... },
})
The platform overwrites workspace_id on every emitted event — apps cannot spoof it. Listeners in workspace A never receive events from workspace B.
4. Metering
Usage metering is auto-stamped with workspace_id:
await req.app.meter("minute", quantity=12)
# Platform internally: meter("minute", quantity=12, workspace_id=ctx.workspace_id)
5. WebSockets
Connections are scoped to workspace + user. A message in one workspace's chat is only delivered to that workspace's connected users.
6. File storage
All files are namespaced by workspace:
storage/
├── ws_facebook_abc123/
│ ├── files/
│ └── signatures/
├── ws_amazon_def456/
│ ├── files/
│ └── signatures/
The platform's file API enforces the workspace prefix.
What apps cannot do
| Action | Enforcement |
|---|---|
Set or override ctx.workspace_id |
Immutable after platform middleware sets it |
| Emit events to another workspace | Event bus overwrites workspace_id from ctx |
| Query data from another workspace | ctx.db enforces workspace_id on all queries |
| Meter usage to another workspace | Meter API injects workspace_id from ctx |
| Access another workspace's files | Storage API enforces workspace path prefix |
| Send WebSocket messages across workspaces | Connection pool is workspace-scoped |
What the platform guarantees
ctx.workspace_idis set before any app code runsctx.workspace_idcannot be modified by app code- All cross-cutting concerns are workspace-scoped — events, metering, storage, WebSockets, queries
- Third-party apps get the same isolation as core apps
Testing isolation
Workspace identity comes from the authenticated session, so isolation tests are written by logging in as members of different workspaces and asserting that data created in one is invisible in the other:
async def test_workspace_isolation(self):
# Log in as a member of workspace A and create data
self.login_as("owner@workspace-a.test", "password")
_, res, _ = self.run_async(self.earth.POST(
"/vacations", subdomain="api", headers=self.api_headers(),
body={"start": "2026-04-01"},
))
# Log in as a member of workspace B and list
self.login_as("owner@workspace-b.test", "password")
_, res, _ = self.run_async(self.earth.GET(
"/vacations", subdomain="api", headers=self.api_headers(),
))
assert res.json.data == []