ADR-0019: Authenticated, workspace-scoped request handling¶
- Status: Accepted
- Date: 2026-09-15
- Deciders: Jian (product owner)
Context¶
KAN-1225 (ADR-0014) put FORCE ROW LEVEL SECURITY on specs, layouts, and
workspace_members, gated on an app.workspace_id session variable that withWorkspaceScope
(packages/server/src/db/workspace-scope.ts) sets per transaction. KAN-1226 (ADR-0015) wired up
Better Auth: sessions, email+password / GitHub sign-in, and the organization plugin mapped onto
workspaces/workspace_members. Neither card connected the two: withWorkspaceScope existed but
was deliberately not wired into any route, and no authenticated tenant-scoped HTTP surface existed
yet.
KAN-1227 (SLICES.md V5, build-plan step 7) is that connection: the hosted, authenticated,
workspace-scoped equivalent of packages/cli's local spec-CRUD routes, against PgSpecRepository
+ RLS, reusing packages/spec's validator unchanged. Wiring it forced a few decisions worth
recording, one of them load-bearing for tenant isolation.
Decision¶
1. Route surface: a per-id collection, not a single implicit spec¶
packages/cli is a one-spec-per-process tool (ADR-0011), so its routes (GET/PUT /api/spec)
address a single implicit spec. A hosted workspace holds many, so the hosted routes
(packages/server/src/routes/specs.ts) are a collection addressed by id:
GET /api/specs— list the workspace's specs (SpecSummary[]).POST /api/specs— create one from{ name, source }; thesourceis validated with the sameparseSpecthe repository uses, so an invalid spec never reaches storage (422 otherwise).GET /api/specs/:id—loadWithLayoutfor that spec (404 if absent in this workspace).PUT /api/specs/:id—applyPatchAndSave(ops)(422 on an invalid mutation, 404 if absent).
Error bodies mirror the CLI's { success: false, error } / { success: false, errors } shapes so
the canvas's existing api.ts error handling works against either server (canvas wiring itself is
step 8, a later card). The SpecRepository interface and PgSpecRepository are reused verbatim;
PgSpecRepository's constructor was widened from DbClient to a DbExecutor (DbClient or a
transaction handle) so a route can construct it against the per-request withWorkspaceScope
transaction — the one change to existing code.
2. Per-request flow: resolve-and-authorize, then one scoped transaction¶
Every tenant-scoped route runs the same shape (resolveWorkspaceContext,
packages/server/src/auth/request-context.ts):
auth.api.getSession({ headers })→ 401 if there is no valid session.session.activeOrganizationId(theorganizationplugin's "active workspace" field on the session row) → 403 if null (no workspace selected).- Verify membership (see below), then run the actual operation inside
withWorkspaceScope(db, workspaceId, tx => …), so RLS scopes every query.
3. Membership is verified in the application layer — RLS does not do it (load-bearing)¶
The RLS policies on specs/layouts key only on a row's workspace_id equalling
app.workspace_id. They have no concept of "the current user" — only of the session variable. So
setting app.workspace_id straight from the client-controlled activeOrganizationId would let a
caller who pointed their session at an arbitrary workspace uuid read and write another tenant's
specs, with RLS allowing it because the ids match.
Therefore resolveWorkspaceContext proves membership before scoping any operation: it runs a
workspace_members read for the current user inside withWorkspaceScope(candidateWorkspaceId,
…), so RLS itself scopes that read to the candidate workspace and the row returns iff (this user,
this workspace) is a real membership. No membership → 403. This is the invariant the next
person adding a tenant-scoped route must preserve: RLS is the backstop against a query that
forgot to scope; it is not the authorization check that the requester belongs to the workspace.
Regression coverage is the "forged active workspace" test in
test/integration/routes/specs.test.ts, which points one user's session row at another workspace
directly in the database and asserts a 403.
The cost is one extra scoped transaction per request (the membership check, separate from the operation's own scope). Acceptable for now; folding the two into one transaction is a possible future optimization, not a correctness issue.
4. Workspace creation stays on Better Auth; the 0004/0005 bootstrap pair is NOT retired here¶
ADR-0018 noted the 0004 bootstrap-insert policy and 0005 member_count guard "could be retired"
once per-request scoping landed. They are not retired in KAN-1227, deliberately. Those exist
only because Better Auth's /organization/create inserts the creator's workspace_members row
through an unscoped connection (the same limitation that made ADR-0018's Option B infeasible).
Wiring the spec-CRUD routes does not touch that path — workspace creation still goes through Better
Auth's org-create, which sets activeOrganizationId on the session row directly (session has no
RLS), so this card relies on it working exactly as KAN-1226 left it. Retiring 0004/0005 requires
replacing the creation path with an authenticated route that does the creator-member insert
inside withWorkspaceScope — a separate, larger change with its own test rewrites, tracked as
KAN-1393, not smuggled into this card.
5. The RLS route tests now run in CI, as a non-superuser role¶
KAN-1227 also closes KAN-1388: the CI integration job provisions a postgres:18 service and a
non-superuser, non-BYPASSRLS role (app_test) that owns the tables (migrations run as it), so
the RLS/auth integration suites — which skipIf(!DATABASE_URL) and had been skipping in CI
entirely — run for real. A superuser silently bypasses FORCE ROW LEVEL SECURITY, which would make
every isolation assertion pass vacuously; owning the tables as a non-superuser is what makes the
coverage genuine. The DB-backed suites (packages/server/test/integration) run in their own
serialized vitest project, integration-db (fileParallelism: false), so they don't collide on the
shared Postgres (KAN-1389); all other integration files keep default file parallelism.
npm run test:integration runs both projects.
Consequences¶
- The canvas can be pointed at the hosted server for spec CRUD (the payoff of ADR-0005's reusable web app), pending the auth-aware client wiring in build-plan step 8.
- Tenant isolation for the spec surface now rests on two layers working together: the app-layer membership check (are you allowed into this workspace at all?) and RLS (once scoped, you cannot see or touch another workspace's rows). Both are exercised end-to-end in CI as of this card.
PgSpecRepositoryaccepting a transaction handle (DbExecutor) is the seam every future tenant-scoped route will build on; none should construct it against the bare pool for a request-path write.- The bootstrap-exception surface (0004/0005) remains until KAN-1393; ADR-0018's named residual race is likewise still open until then.