SoConnective

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 payloadFetchScopedById helper. If an id belongs to another tenant, the helper returns null — 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_isolation policy governs every scoped table, keyed on the Postgres session GUCs app.current_tenant and app.is_platform.
  • The policy is fail-closed: rows are visible only when app.is_platform is on or tenant_id = app.current_tenant. With no GUC set, zero rows are returned.
  • The same predicate is applied to USING and WITH 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-By is 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

ControlMechanismLayer
Active accountServer-side user.activeTenant, no browser storageApp
List scopingwhere[tenant][equals]=<activeTenant> appendedApp
By-id scopingpayloadFetchScopedById returns null cross-tenantApp
Membership constraintMulti-tenant plugin AND-appends user's tenantsPlugin
Account switchMembership validated before activeTenant changesApp
Master untouchableCMS hooks; promotion is DB-onlyApp/DB
Platform blindingAccounts hidden from nav + URL; admins hidden from UsersApp
Service accountPAYLOAD_SERVICE_KEY, server-only, invite opsApp
Row-Level SecurityForced RLS, tenant_isolation on 24 tables (armed)DB
De-identificationfs_session, X-Powered-By off, security headersApp
Audit logImmutable audit-logs, service-key writes, admin-only viewApp/DB
Previous
Architecture — Overview