Tenant isolation
In short
Section titled “In short”On SpeculaPro SaaS, several institutions share one installation. Each institution is a tenant (you see it as your workspace). Their data must never mix.
Many systems rely on the application to remember to add “only this institution’s rows” to every database query. Forget once, and one customer sees another’s data. SpeculaPro adds a second lock: the database itself filters every query by tenant, using a Postgres feature called row-level security (RLS).
An analogy: a shared office building with a receptionist who checks badges is the application. RLS is when, in addition, every office door only opens for badges from that company. Even if the receptionist waves the wrong person through, they still can’t open the wrong door.
Analyst at Bank A Postgres │ "show open cases" ┌───────────────────────────────┐ ▼ │ cases │ SpeculaPro API ── sets tenant = Bank A ──▶│ Bank A case 101 ✔ visible │ │ Bank B case 207 ✘ hidden │ │ Bank A case 102 ✔ visible │ └───────────────────────────────┘How it works
Section titled “How it works”- Every row is labelled. Every table holding an institution’s data has a
tenant_idcolumn saying which tenant owns the row. That covers users, roles and teams, customers, accounts and counterparties, transactions, rules and their versions, hits, alerts, cases and their timelines, lists, suppressions, reports, sign-in sessions and the audit log. - Every table has a policy. Postgres is told: “only show, change or insert rows whose
tenant_idmatches the current tenant”. - Every request sets the current tenant. When the API handles a signed-in request, it opens a database transaction and sets the tenant for that transaction only (a setting called
app.tenant_id). When the transaction ends, the setting is gone. - No tenant set means no rows. A query run without a tenant returns nothing rather than everything.
- Writing into another tenant is refused. The same policy checks new and changed rows. An attempt to insert a row labelled with a different tenant fails with a
row-level securityerror; it is not silently re-labelled.
Because the setting lives only for one transaction, one request can never leak its tenant to the next request that reuses the same database connection.
Three database accounts, three levels of trust
Section titled “Three database accounts, three levels of trust”SpeculaPro connects to Postgres with different accounts for different jobs:
| Database account | Used by | Row-level security | Why |
|---|---|---|---|
speculapro_owner |
Schema migrations only | n/a (owns the tables) | Changing the schema is rare and done deliberately |
speculapro_app |
The web app and API (api role) |
Applies | Everything a person does goes through this account, so it is always filtered by tenant |
speculapro_system |
Background roles that process many tenants in bulk (decision, stream, persist, connector, analytics, worker), and every process for the installation’s own tables |
Bypassed | Writing a batch of events from many tenants at once can’t be done one tenant at a time |
Tables with no tenant
Section titled “Tables with no tenant”A few tables belong to the installation, not to any tenant: the licence, its usage figures and the
key that signs usage reports, the backtest runners’ heartbeats and the notification channel record.
With no tenant_id to filter on, a policy can’t protect them, so the app account is refused them
altogether: row-level security is switched on with no policy (which answers the app account no rows,
whatever tenant it sets), and its privileges on the tables are revoked as well, so neither guard
depends on the other. Every process reads the licence through the system account instead, on a
connection of its own (SP_DB_SYSTEM_URL) that requests from people and API keys never use.
Two more gaps of the same kind are closed:
- Monthly partitions. The largest tables are stored as one partition per month, and a partition is a table in its own right. A query that names a partition directly would not pass through the parent table’s policy, so every partition has row-level security switched on with no policy, and each new month’s partition gets it when it is created. The app account reads these tables only through the parent, where the tenant policy applies.
- Shared catalogues. Rule packs and jurisdiction definitions are the same for every tenant. The app account can read them and add a verified new version, but can’t change or delete one.
An automatic test fails the build if a new table is neither covered by a tenant policy nor on the explicit list of installation-wide tables.
Before you sign in: narrow, single-purpose lookups
Section titled “Before you sign in: narrow, single-purpose lookups”There is one awkward moment: when someone signs in, SpeculaPro doesn’t yet know their tenant, so RLS would hide their user record.
Instead of giving the app account a general way around RLS, the database offers a few single-purpose lookup functions: find a user by workspace and email, find a session by its token fingerprint, find an API key by its prefix, find a workspace for single sign-on, find a SCIM token by its prefix. Each returns only what that step needs. The app account can call these functions and nothing else that crosses tenants.
These functions are created by the database migrations. Self-hosters then allow the app to call them by running deploy/sql/grants.sql; see How to prepare the database.
Regions: a second boundary on a multi-region service
Section titled “Regions: a second boundary on a multi-region service”Where SpeculaPro is hosted in more than one region, each region is a separate installation with its own database, and a workspace is pinned to one of them. On top of the workspace boundary described above, a region refuses any workspace pinned to another region: on sign-in, on existing sessions, on API keys, and when sending webhooks and notifications. A copy of a workspace in the wrong region’s database can’t be signed in to and sends nothing. See Data residency and regions and Multi-region.
Self-hosted installations
Section titled “Self-hosted installations”A self-hosted installation normally has one tenant, but the same isolation stays switched on. Self-hosting is “SaaS with one tenant”, not a separate, less-tested configuration.
How this is tested
Section titled “How this is tested”The schema test suite creates a throwaway database with two tenants and, connecting as a non-superuser account like speculapro_app, checks that:
- with no tenant set, a query sees no customers;
- with tenant A set, it sees only tenant A’s customers;
- inserting a row labelled with tenant B, while tenant A is set, is refused.
How to check isolation on your own installation
Section titled “How to check isolation on your own installation”If you self-host, you can confirm these protections on your own database in a few minutes. You need psql and the connection details for the three database accounts. Run these on a staging copy if you can; they only read data, but the first one shows real customer counts.
-
Confirm only the system account bypasses row-level security.
Terminal window psql "$SP_DB_URL" -c "SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname LIKE 'speculapro%';"In the
rolbypassrlscolumn you should seet(true) forspeculapro_systemonly, androlsupershould bef(false) everywhere: a superuser also ignores row-level security. Ifspeculapro_appshowstin either column, isolation is switched off for the web app. Fix it as a superuser withALTER ROLE speculapro_app NOSUPERUSER NOBYPASSRLS;. -
Confirm the app account sees nothing without a tenant. Connect as
speculapro_appand count customers:Terminal window psql "postgres://speculapro_app:…@db-host/speculapro" -c "SELECT count(*) FROM customers;"The answer must be
0, even though the table has rows. -
Confirm the app account sees one tenant at a time. Still as
speculapro_app, set a workspace’s ID for one transaction:Terminal window psql "postgres://speculapro_app:…@db-host/speculapro" <<'SQL'BEGIN;SELECT set_config('app.tenant_id', '<workspace id>', true);SELECT count(*) FROM customers;ROLLBACK;SQLThe count is that workspace’s customers only. Workspace IDs are in the
tenantstable; read them with the owner account. -
Confirm the web app doesn’t use the system account for people’s requests. Check that
SP_DB_URLfor every process namesspeculapro_app.SP_DB_SYSTEM_URLnamesspeculapro_systemin every process,apiincluded: anapiprocess uses it only to read the licence. See Database roles. -
Confirm the app account can’t read the installation’s own tables or a partition directly. As
speculapro_app:Terminal window psql "postgres://speculapro_app:…@db-host/speculapro" -c "SELECT count(*) FROM license_installation;"The answer must be
permission denied for table license_installation. If it is a count, rundeploy/sql/grants.sqlagain (see How to prepare the database).