Features Apograph CMS on GitHub

Workspaces plugin

The tenancy boundary, and the guard the rest of the system borrows from it.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

A workspace is Apograph’s tenancy boundary. Every content row, every media asset and every membership belongs to exactly one, and a request that does not name one cannot reach any of them.

Install

npm install @apograph/workspaces-server @apograph/workspaces-admin

Register

// apps/server/src/plugins.ts
import { WorkspacesPlugin } from '@apograph/workspaces-server';

WorkspacesPlugin();
// apps/admin/src/plugins.ts
import { WorkspacesPlugin } from '@apograph/workspaces-admin';

WorkspacesPlugin();

It takes no configuration and reads no environment variables.

It must follow IdentityPlugin

Its memberships table references identity’s users. Registered first, a fresh apograph migrate fails with relation "users" does not exist — while an already-migrated database migrates happily, so the mistake ships and bites the next clean install.

What it owns

Workspaces, their memberships, and their content-access grants. A grant is what lets a workspace see a subset of the content types the app defines, rather than all of them.

The guard other plugins carry

WorkspaceGuard reads the X-Workspace-Id header, resolves it to a workspace the caller is a member of, and exposes it to handlers as @CurrentWorkspace(). Media, content, transfer and the copilot all carry it. It is the single implementation of “is this person allowed in this tenant”, which is why it lives here and is imported rather than reimplemented.

One route deliberately does not carry it

GET /media/assets/:id/raw is fetched by the browser itself — an <img src> cannot send a custom header — so it derives the workspace from the asset and checks membership against that. Any new route whose URL the browser loads directly needs the same treatment. See the media plugin.

Deleting a workspace

Three mechanisms clear a workspace’s rows, and which one applies is a property of what the rows are.

MechanismApplies to
Cascadememberships, workspace_content and the copilot’s tables — they carry a foreign key to workspaces, so Postgres removes them
RefuseContent entries. They are workspace-scoped by a plain uuid with no foreign key, and they are authored records
PurgeMedia assets and folders, and a token’s workspace bucket — pure scoping with no independent meaning

A workspace holding entries cannot be deleted at all. The request is refused with a 409 while any remain, rather than taking the content with it. Somebody deletes their content deliberately; the CMS never does it for them. So “delete this workspace” is a two-step job on a workspace that was ever used.

The purge runs in the same transaction as the delete. The blob half compares each asset’s recorded storage_provider against the one this process has configured, and refuses rather than silently skipping rows it cannot reach.

Four tables outlive the workspace

WorkspacePurger is implemented by the media plugin and by the API-token grants, and by nothing else. Rows that are workspace-scoped by a plain uuid in a plugin without a purger are left behind:

  • alarm_rules and alarm_findingsalarms
  • entry_accesssegments
  • segments.workspace_ids — the deleted id stays in the array

None of them is reachable afterwards: every read is scoped to a workspace the caller is a member of, and the workspace is gone. They are orphaned rows rather than a leak. But they are rows, and nothing prunes them — clean them up in SQL if you delete workspaces routinely.

A plugin that owns workspace-scoped tables should implement WorkspacePurger and register it. See writing a plugin.