Security
Security Model
Overview
SoConnective is multi-tenant by design: a single deployment serves the Platform, many agencies, and the sub-accounts beneath them. The security model exists to make one guarantee structural rather than aspirational — a user can only ever see and touch the accounts they belong to, and the Platform is invisible to everyone below it.
That guarantee is enforced in depth: at the application layer (the live control), reinforced by the Payload multi-tenant plugin, and backstopped by PostgreSQL Row-Level Security. No single forgotten filter can breach it.
Tenancy & data isolation
The active account lives server-side
The user's currently active account is stored on user.activeTenant — server-side, never in the browser. There is no localStorage, no cookie value, no client state that selects the tenant. This is deliberate: it is immune to Intelligent Tracking Prevention (ITP), browser storage clearing, and consent banners, and it cannot be tampered with from the client.
App-layer scoping (the live control)
Every data path is scoped to activeTenant:
- List reads append
where[tenant][equals]=<activeTenant>to the query, so a listing can only ever return rows for the active account. - By-id reads go through a
payloadFetchScopedByIdhelper. If an id belongs to another tenant, the helper returnsnull— a cross-tenant id is indistinguishable from a non-existent one.
The plugin's membership constraint
On top of the app-layer scoping, the Payload multi-tenant plugin AND-appends a membership constraint server-side: every query is additionally restricted to the set of tenants the requesting user actually belongs to. Even if app-layer scoping were bypassed, a user could never reach a tenant outside their memberships.
Account-switch validation
Switching the active account is itself a guarded operation. Before activeTenant is changed, membership is validated — a user cannot switch into an account they do not belong to. The server-side active account can only ever move between accounts the user is already a member of.
For the full deep dive, see tenancy & data isolation.
Master untouchable
Platform admins are made structurally bulletproof by CMS hooks, so the master tier cannot be dismantled — by anyone, including itself:
- A platform admin cannot be deleted.
- A platform admin cannot lose the super-admin flag.
- A platform admin cannot be assigned a restricting role (not by another user, not by themselves).
- Promotion to platform admin is DB-only — there is no application path to mint a new platform admin, removing it as an attack surface.
Platform blinding
The Platform account is invisible from the tiers beneath it:
- The Platform account does not appear to agencies or sub-accounts.
- Sub-accounts have no access to "Accounts" at all — the navigation entry is hidden and the direct URL is blocked.
- Platform admins do not appear in the Users lists of agencies or sub-accounts.
The result: from inside an agency or sub-account, the platform — and the people who run it — leave no trace.
Service account
A small set of privileged operations must run without an authenticated user — most notably invite flows, which happen before the invitee has a session. These use a server-only Payload API key, PAYLOAD_SERVICE_KEY, scoped exclusively to those privileged, unauthenticated invite operations. The service key never reaches the browser.
Row-Level Security (RLS)
PostgreSQL Row-Level Security is enabled and forced on all 24 tenant-scoped tables, providing a database-level backstop independent of app code.
- A single
tenant_isolationpolicy governs every scoped table, keyed on the Postgres session GUCsapp.current_tenantandapp.is_platform. - The policy is fail-closed: rows are visible only when
app.is_platformis on ortenant_id = app.current_tenant. With no GUC set, zero rows are returned. - The same predicate is applied to
USINGandWITH CHECK, so reads and writes are both constrained — a cross-tenant write is rejected, not silently allowed.
RLS has been proven against a non-superuser app_user role (NOSUPERUSER, NOBYPASSRLS): with no GUC it returns 0 rows; with a tenant GUC it returns only that tenant's rows with zero leakage; with the platform GUC it returns all rows; and a cross-tenant INSERT is blocked by WITH CHECK.
Armed, not yet the live control
RLS is armed. The live application currently connects to Postgres as a superuser, which bypasses RLS even when forced — so today the app-layer scoping is the live control and RLS is defense-in-depth. The cutover (per- request GUC plumbing plus switching DATABASE_URI to the non-superuser app_user role) is a documented maintenance-window step. See ADR 0011 — RLS cutover runbook and ADR 0009 — Row-Level Security.
Stack de-identification
The platform does not advertise what it is built on:
- The session cookie is the neutral
fs_session(not a framework-default name). X-Powered-Byis disabled.- Baseline security headers are set:
X-Content-Type-Options: nosniff, frame-options, a referrer policy, and a permissions policy.
Audit log
An immutable, tenant-scoped audit-logs collection records who did what: actor, actorType (ai · user · system), action, target, a human summary, and structured metadata. Writes go through the service key with an explicit tenant (forgery-proof), create is restricted to the platform service account, and updates and deletes are disabled — the log is append-only. Every AI Operator tool action and every marketplace install is logged, and the trail is surfaced on an admin-only Activity page. See the audit log for details.
Summary of controls
| Control | Mechanism | Layer |
|---|---|---|
| Active account | Server-side user.activeTenant, no browser storage | App |
| List scoping | where[tenant][equals]=<activeTenant> appended | App |
| By-id scoping | payloadFetchScopedById returns null cross-tenant | App |
| Membership constraint | Multi-tenant plugin AND-appends user's tenants | Plugin |
| Account switch | Membership validated before activeTenant changes | App |
| Master untouchable | CMS hooks; promotion is DB-only | App/DB |
| Platform blinding | Accounts hidden from nav + URL; admins hidden from Users | App |
| Service account | PAYLOAD_SERVICE_KEY, server-only, invite ops | App |
| Row-Level Security | Forced RLS, tenant_isolation on 24 tables (armed) | DB |
| De-identification | fs_session, X-Powered-By off, security headers | App |
| Audit log | Immutable audit-logs, service-key writes, admin-only view | App/DB |