SoConnective

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

LayerWhat it enforcesStatus
Active account (user.activeTenant)The server, not the client, picks the tenantLive
List scoping (where[tenant][equals])Listings return only the active account's rowsLive
By-id scoping (payloadFetchScopedById)Cross-tenant ids resolve to nullLive
Plugin membership constraintQueries bounded to the user's own tenantsLive
Account-switch validationActive account can only move within membershipsLive
Row-Level SecurityDatabase refuses cross-tenant reads/writesArmed (defense-in-depth)
Previous
Security Model