Tenancy and security

Source: docs/architecture/tenancy-and-security.md
On this page

Every organization's data lives in shared tables, and the database itself decides which rows a request may see: row-level security is forced on every organization-scoped table, the application connects with identities that cannot bypass it, and the request host, not the session cookie, decides which organization the request acts for. Inside an organization, brands are a second partition with their own policy leg. Secrets are envelope-encrypted and never returned, identifying names are masked at display, and sign-in is per organization.

Two tenancy tiers#

An organization is one customer. A brand is a workspace partition inside an organization, not a separate tenant: every fact or asset table carries both an organization id and a brand id, organization-wide administration reads across brands, and brand-scoped reporting does not.

  • Membership belongs to the organization; a member's viewable brands decide which brands they can switch into. Brand authorization is decided in the application; the brand policy leg is the safety net under it.
  • Ingest is brand-bound. A single-brand organization pins its brand automatically; a multi-brand organization with no pinned brand, or a client-supplied brand that disagrees with the server's, is refused. A marketplace shop belongs to exactly one brand. See Multi-brand workspaces.
  • A super admin holds no membership in any organization. The platform console names the organization it is acting on in the URL path, and cross-tenant platform work is an explicit super-admin path, not a switch an organization can flip.

How isolation works#

flowchart TB
  REQ["Request on an org host"]
  HOST["Host lookup<br/>exact host to one org id"]
  PROC["orgProcedure / brandProcedure<br/>wraps the whole resolver"]
  PIN["One transaction<br/>org and brand pinned locally"]
  PG[("Postgres<br/>FORCE RLS · non-bypass identity")]
  REQ --> HOST --> PROC --> PIN --> PG
  1. The host resolves the organization. Host lookup happens before any tenant scope exists, so it runs through fixed-shape database functions that exchange one exact host string for one organization id and cannot list, search or read anything else. On an organization's subdomain or verified custom domain the host wins over the session cookie; an unknown host resolves to nothing; only on a platform host does the session's organization stand. Absolute URLs are built from the request host.
  2. The resolver runs inside one pinned transaction. orgProcedure and brandProcedure open a transaction, set the organization and brand as transaction-local settings on that transaction's single connection, and run the entire resolver on it. A setting made outside a transaction evaporates under an autocommitting pool, which is why the scope is a transaction and not a session call. A failed mutation rolls back because the wrapper turns tRPC's error envelope back into a throw inside the transaction. Resolvers that need network I/O use orchestrated variants with short explicit database phases, so no tenant-pinned connection is held across a network call.
  3. The database applies the policy. Organization-only tables require the row's organization to equal the pinned one; brand-partitioned tables add a brand leg where an unpinned brand means the organization-wide rollup. Every such table has ENABLE and FORCE ROW LEVEL SECURITY, so the table owner is not exempt. An unset setting resolves to null and the policy denies: a query with no context returns zero rows, not every row.
  4. The identity cannot bypass it. Each runtime (web, worker, backup) connects as its own role, provisioned without superuser or bypass rights. Privileges start from a full revoke and are re-granted from a declared manifest in code; a runtime writes only to tables its own role is granted and hands any other write to the owning runtime through the outbox. Applications assert their own identity at boot and refuse to start on a wrong or privileged role.

Tables deliberately outside row-level security each carry a threat-modelled entry in a checked-in exceptions file. Work that genuinely crosses a tenant boundary goes through fixed-shape database functions granted to one role, so the boundary is a small enumerated surface rather than a broad grant.

Secrets, masking and sample data#

  • Envelope encryption. Connector credentials, directory bind passwords and channel secrets are encrypted with AES-256-GCM under a versioned key; each write is stamped with its key version and each read uses that envelope's own key. A rotation keeps every prior key until a tested rewrap proves no row still uses it. A secret is never logged, never returned by an API (a settings card shows only that a value is stored), and never included in an organization archive.
  • Store verbatim, mask at display. Ingest keeps exactly what the source returned, so a number can always be reconciled. Buyer-identifying fields render masked by default and are revealed only by explicit action. An organization declares the names to mask and which roles see them masked; one masking posture is resolved per request and carried to every surface: dashboards, the assistant, exports, the inbox and external channels. See Identity masking.
  • Sample data is flagged. Every fact table and every brand carries an is_sample flag, so seeded demo rows never mix into a real KPI and seeding one brand never touches another.

Sign-in and request protection#

  • Methods. Password, single-use magic link, WebAuthn passkeys and TOTP two-factor for every account. Per organization, a super admin can turn on LDAP/Active Directory login (bind fails closed, encrypted transport by default, no automatic account creation, super-admin accounts refused) or OpenID Connect login. Setup is in Set up LDAP/AD login.
  • Sessions. Sessions last 14 days with a rolling 24-hour refresh; cookies are httpOnly and secure outside local development.
  • Rate limiting. Every mutating, authentication and webhook endpoint is rate-limited. The client address for a limiter is taken from the connection peer or a configured proxy hop, never from a header a client can forge.
  • Input. Webhook signatures are validated before the body is parsed; user-supplied HTML is sanitized before rendering; every API input passes a named Zod schema.
  • Outbound notifications. A message leaves the platform only when the platform has configured the channel, the organization is allowed to use it and the person has opted in; in-app is the default. See Notifications.

What it reads and what it never reads#

A request reads only rows pinned to its organization, and within a brand scope only that brand's rows plus organization-wide rows. The data assistant never touches third-party market data for an organization that does not run Market Intelligence. An organization archive never carries secret material, and a restore does not bypass isolation or integrity checks; see Back up and restore an organization.

Limits#

  • Row-level security protects trusted application code from a missing or wrong filter. It is not containment against a stolen runtime credential: the tenant setting is caller-settable on a connection, so authorization in the application remains the primary control. Separating platform-administrator capability from the tenant web runtime is in progress.
  • Masking is a display layer, not anonymization: stored data stays verbatim so it can be reconciled.
  • OpenID Connect login and white-label custom domains are not yet proven against live providers; Project status grades each as of 24 September 2026.
  • The platform does not offer a switch that lets one organization read another's data.

Design intent#

Row-level security is enforced in the database, and forced

  • Decision. Every organization-scoped table has a policy with FORCE ROW LEVEL SECURITY; the absence of a tenant pin denies rather than allows.
  • Why. An application-only filter fails open the first time one query forgets its WHERE; a policy in the database fails closed for every query, including ones written later.
  • What it costs. Every new table needs a policy or a threat-modelled exception, and every read must run inside a pinned scope or it sees nothing.
  • How it is enforced. A policy-inventory check in the tenancy gate fails on any table missing coverage or carrying a stale entry; the same gate, run against a freshly migrated database, asserts that a read with no context and a read with the wrong organization both return zero rows.

The whole resolver runs inside the tenant scope

  • Decision. Tenant pinning wraps the entire resolver in one transaction, and any function that reaches an organization-scoped table opens its own scope rather than trusting its caller's.
  • Why. A pin set on a pooled connection outside a transaction is silently lost; under forced security the result is zero rows, which looks like "this organization has nothing" and, in an entitlement check, reads as the most permissive answer.
  • What it costs. Independent queries inside one pinned transaction run serially; parallel reads need the orchestrated procedure with an explicit phase per query. A scope pinned to one organization refuses work for another instead of re-pinning.
  • How it is enforced. Behavioural tests derive every scope-opening call site and prove its pin; a static audit covers every worker processor; a ratchet refuses the null-brand pin that disables the brand leg while the SQL still reads as brand-safe; a durable-job audit refuses publishing to the queue outside the outbox.

Runtime identities cannot bypass the policy

  • Decision. Web, worker and backup each connect as an exact, non-superuser, non-bypass role whose grants come from one declared, default-deny manifest.
  • Why. A bypass-capable role skips even forced policies while the SQL still looks correct, so every tenancy test passes on the wrong identity.
  • What it costs. Cross-runtime writes are handed over through the outbox instead of widening a grant; local development on a privileged connection proves nothing about policy and is treated as a warning, not evidence.
  • How it is enforced. Applications refuse to start on a wrong or privileged identity; the production migration compares live grants with the manifest and stops the deploy on drift; the tenancy gate seeds with one identity and proves with the deployed one, asserts that identity's posture first, and fails when fewer checks ran than its floor.

The host decides the tenant

  • Decision. One resolver derives the organization from the request host, and one helper builds every absolute URL from it.
  • Why. A session cookie is host-only and remembers the last organization used; an origin or organization taken from it or from configuration sends a request, a redirect or a data-subject erasure to the wrong tenant.
  • What it costs. Host lookup needs narrow pre-tenant database functions, and local development, where tenant hosts are off by default, does not exercise this path.
  • How it is enforced. Static audits refuse a session-organization read in admin, settings and API routes and a URL built from the configured platform origin, each with one documented exception; a switch-reload audit makes a tenancy switch repaint the whole document.

A brand is a partition, not a tenant

  • Decision. Brand-partitioned writers always set the brand, and every rollup key contains the brand.
  • Why. A row with no brand is visible under every brand through the organization-wide leg, so a rollup key without the brand becomes the sum of all brands shown inside each one.
  • What it costs. Every new rollup and every brand-scoped refresh carries the brand in its key and its delete predicate; nullable key columns need NULLS NOT DISTINCT.
  • How it is enforced. A brand-isolation leg of the tenancy gate and reconciliation assertions over brand-scoped rollups; ingest resolves its brand through one function that refuses rather than defaults.

Secrets and identities never leave by accident

  • Decision. Secrets are envelope-encrypted with versioned keys and never returned; identifying values are stored verbatim and masked at display under one posture per request.
  • Why. A secret in a response, a log or an archive is a breach even inside a trusted tenant; a surface that decides masking for itself shows a real name somewhere the organization chose to hide it.
  • What it costs. Key rotation keeps old keys until a rewrap proves them unused; a masked value can never be used as a key.
  • How it is enforced. The pre-commit security scan refuses hard-coded vendor keys, unsanitized HTML and unguarded compose secrets; an audit refuses unmasked buyer fields on display paths; a single-source audit refuses a second masking resolver, and leak tests cover each masked surface.