Security
Tenancy & Data Isolation
Why this matters
SoConnective runs the Platform, every agency, and every sub-account from one deployment and one database. The entire product rests on a single property: a user can only ever see and touch the accounts they belong to. This page is the deep dive on how that property is enforced — at the application layer, by the multi-tenant plugin, and by the database.
The controls are layered on purpose. App-layer scoping is the live, primary control; the plugin constraint and Row-Level Security exist so that a single mistake in one layer cannot, by itself, leak data across tenants.
The active account
The user's active account is stored server-side on user.activeTenant. There is deliberately no browser storage involved — no localStorage, no client cookie that names the tenant, no client state that selects it.
This matters for two reasons:
- Correctness/security — the client cannot tamper with which account it is acting on; the server is the single source of truth.
- Durability — it is immune to Intelligent Tracking Prevention (ITP), storage clearing, and consent gating, which can silently wipe browser state. The active account survives all of them because it never lived in the browser.
App-layer scoping
Every read path is scoped to activeTenant. There are two shapes of read, and each has its own enforcement.
List reads
List queries append a tenant filter to the Payload query:
where[tenant][equals]=<activeTenant>
A listing therefore can only ever return rows belonging to the active account. There is no code path that lists a collection without this scoping.
By-id reads
Fetching a single record by id goes through a dedicated helper, payloadFetchScopedById. The helper checks the record's tenant against the active account: if the id belongs to a different tenant, it returns null.
The effect is that a cross-tenant id is indistinguishable from one that does not exist — there is no "forbidden vs not-found" oracle to probe, and no path to load another tenant's record by guessing or leaking its id.
The plugin's membership constraint
On top of app-layer scoping, the Payload multi-tenant plugin AND-appends a membership constraint to queries, server-side. Every query is additionally restricted to the set of tenants the requesting user is actually a member of.
This is the second independent layer. Even in a hypothetical where app-layer scoping were bypassed or misconfigured for a given path, the plugin constraint still bounds the result set to the user's own memberships — a user can never reach a tenant they do not belong to, regardless of what activeTenant claims.
Account-switch validation
Changing the active account is a guarded write, not a free assignment. Before activeTenant is updated, membership is validated: the target account must be one the user already belongs to. An attempt to switch into a non-member account is rejected.
Combined with the fact that activeTenant only ever lives server-side, this means the active account can only ever move between accounts within the user's own membership set — there is no way to point it at an arbitrary tenant.
How RLS layers on top
The controls above are the live isolation today. PostgreSQL Row-Level Security adds a database-level backstop beneath them.
RLS is enabled and forced on all 24 tenant-scoped tables, with a single fail-closed tenant_isolation policy keyed on the Postgres session GUCs app.current_tenant and app.is_platform. The same predicate is applied to both USING (reads) and WITH CHECK (writes): a row is visible or writable only when app.is_platform is on, or tenant_id = app.current_tenant. With no GUC set, the policy returns zero rows.
This has been proven against a non-superuser app_user role: no GUC yields 0 rows, a tenant GUC yields only that tenant's rows, the platform GUC yields all rows, and a cross-tenant INSERT is blocked by WITH CHECK.
RLS is currently armed but not the live control: the application connects to Postgres as a superuser, which bypasses RLS even when forced. So today, app-layer scoping is the enforcing control and RLS is defense-in-depth. The cutover — plumbing the per-request GUCs and pointing DATABASE_URI at the non-superuser app_user role — is a deliberate, documented maintenance-window step. See ADR 0009 — Row-Level Security and ADR 0011 — RLS cutover runbook.
The layers, end to end
| Layer | What it enforces | Status |
|---|---|---|
Active account (user.activeTenant) | The server, not the client, picks the tenant | Live |
List scoping (where[tenant][equals]) | Listings return only the active account's rows | Live |
By-id scoping (payloadFetchScopedById) | Cross-tenant ids resolve to null | Live |
| Plugin membership constraint | Queries bounded to the user's own tenants | Live |
| Account-switch validation | Active account can only move within memberships | Live |
| Row-Level Security | Database refuses cross-tenant reads/writes | Armed (defense-in-depth) |