All posts

Multi-Tenant Analytics: Isolating Sites Properly

August 8, 20268 min read

Ownership columns do not survive contact

The first version of multi-tenancy is usually a user_id on the sites table. It works until the first person asks to share a site with a colleague, and then every query needs rewriting.

Start with a membership table instead:

CREATE TABLE site_members (
  id         SERIAL PRIMARY KEY,
  site_id    VARCHAR(64) NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
  user_id    INTEGER     NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  role       VARCHAR(20) NOT NULL DEFAULT 'viewer'
                         CHECK (role IN ('owner','admin','viewer')),
  UNIQUE (site_id, user_id)
);

A single ownership column is just this table with a cardinality restriction you will later regret.

Enforce in middleware

Scattering the check across route handlers guarantees one gets missed. Enforce it once:

router.use(authMiddleware);
router.use('/:siteId', validateSiteId, authorizeSiteAccess);

authorizeSiteAccess resolves the site, checks membership, attaches the role, and rejects otherwise. Every route below inherits it. A new endpoint is protected by default rather than by remembering.

The layers people forget

Two paths routinely bypass the middleware because they do not look like routes.

The AI/tool layer. Tools receive a ctx with a site id. That id must be set by the caller from an authorised session — never taken from model input. A model that can pass an arbitrary site id is a model that can read another tenant's data.

The SQL editor. A valid SELECT against a permitted table still reads every tenant's rows unless the predicate is enforced. Filtered views per session, or predicate injection into the parsed AST — never string concatenation.

Roles worth having

Three cover almost everything: owner (billing, deletion), admin (settings, integrations, invites), viewer (read). Resist adding more until a real request forces it; permission systems grow faster than the need for them.

Compare with an ordering helper rather than equality checks:

roleAtLeast(req.userRole, 'admin')

role === 'admin' quietly excludes owners, which is a bug that surfaces as a confused support ticket.

Deletion has to cascade

ON DELETE CASCADE on every table carrying a site_id. When someone deletes a site, the events, sessions, goals, keywords, integrations and memberships must all go.

Orphaned rows in an analytics store are not just untidy — they show up in aggregates that no longer have a site to belong to, and produce totals nobody can explain.

Try InsightsTrack free

Open-source, self-hosted, privacy-first analytics — explore the live demo, no install required.

Open live dashboard