// lab_build
Client Portal
Multi-tenant client portal with magic-link auth, scoped document exchange, and a status timeline driven by events.
Status
Reference build
Stack
Date
Jun 15, 2026
The Problem
A client portal is a multi-tenant system sold as a convenience feature. The convenience part ships. The multi-tenant part is usually a where tenant_id = ? clause that every future developer must remember to write. One forgotten clause is the difference between a portal and a data breach.
The failure modes this build exists to handle:
TENANT_DATA_LEAK— one client sees another client's records because isolation lives in query discipline instead of the database.EXPIRED_LINK_DEAD_END— a client clicks a week-old email link and lands on an error page with no way forward.FILE_ACCESS_WITHOUT_SCOPE— documents are reachable by URL to anyone holding the URL, indefinitely.STALE_STATUS_STATE— the status a client sees is an editable field someone forgot to update, so the portal reports "in review" three weeks after delivery.
The System
Four entities: tenants, users, documents, and events. Every table that holds tenant data carries a tenant_id column, and PostgreSQL row-level security enforces it below the application:
alter table documents enable row level security;
create policy tenant_isolation on documents
for all
using (tenant_id = (select auth.jwt() ->> 'tenant_id')::uuid);
The application still scopes every query. RLS is the floor, not the strategy — it is what stands when the application code is wrong. Why isolation belongs in the database is argued in multi-tenant data isolation.
Auth. Clients sign in with magic links: single-use, 15-minute expiry. An expired or consumed link never dead-ends. It routes to a re-request screen with the email pre-filled, one click from a fresh link. Portal users are occasional users; the flow assumes the link in front of them is old.
Documents. Files live in per-tenant storage prefixes. Downloads never expose storage paths: the client requests a document, the server checks tenant scope and the user's role within that tenant, then issues a signed URL valid for 60 seconds. A leaked URL is a 60-second problem, not a permanent one.
Status timeline. Status is not a column anyone edits. It is derived from an append-only events table — project.submitted, review.started, deliverable.posted. The timeline the client sees is the event history rendered directly, so it cannot drift from what happened: either the event exists or it does not.
The isolation fixture. The test suite seeds two tenants with mirrored data and runs every data-access path twice — once asserting tenant A sees its own rows, once asserting tenant A sees zero of tenant B's rows. A new query path without a two-tenant test does not pass review.
Constraint
The email inbox is the authentication channel. A compromised inbox is a compromised portal account. Short expiry and single-use links narrow the window; they do not remove it.
Design Decisions
RLS in the database, not filters in the ORM. Query-layer filtering fails open: forget the clause and everything leaks. RLS fails closed: forget the policy context and queries return nothing. An empty page is a bug. Another tenant's data is an incident. Tradeoff: policies make query plans harder to read and debugging slower — the two-tenant fixture exists partly to compensate.
Magic links, not passwords. Portal users log in a few times a quarter. Passwords for occasional users mean reset flows, and reset flows are magic links with extra steps and worse failure states. Tradeoff: auth security now equals inbox security, which is stated in the constraints above rather than hidden.
Short-lived signed URLs, not proxied downloads. Proxying every file through the app server gives per-request authorization but puts file throughput on the application. Signed URLs hand transfer to storage infrastructure and keep the authorization decision at issuance. Tradeoff: for its 60-second lifetime, the URL is a bearer token — anyone holding it can fetch the file.
Events, not an editable status field. An editable status is a claim; an event log is a record. Deriving status from events removes the failure mode where a human forgets to update the field. Tradeoff: correcting a mistaken event means appending a correcting event, not editing history. More modeling work, but the history stays honest.
// failure_modes_handled
Named first, then built against. The write-up above covers the mechanism for each.
TENANT_DATA_LEAK
EXPIRED_LINK_DEAD_END
FILE_ACCESS_WITHOUT_SCOPE
STALE_STATUS_STATE
// constraints
Constraint
Constraint
Constraint
// screens
Need a system like this behind your business? Same protocol, scoped to your failure modes.