Skip to content

Multi-Tenancy Design

TT Time Tracker serves multiple independent businesses ("tenants") from a single deployment. This article explains how tenant isolation is achieved.

The data model

There is a single PostgreSQL database shared by all tenants. Isolation is application-level, not database-level (no separate schemas or row-level security policies).

Every business entity carries a tenantId foreign key:

Tenant
  └── Project       (tenantId)
  └── TaskList      (tenantId)
  └── Vehicle       (tenantId)
  └── TenantUser    (tenantId) ─── Entry   (tenantId + tenantUserId)
                                └── Invoice (tenantId)

A User is a global identity (one email → one User record). A TenantUser is that user's identity within a specific tenant — they can belong to multiple tenants with different names, roles, and settings in each.

How isolation is enforced

Isolation is enforced at the service layer by TenantGuard:

  1. Every tenant-scoped route has the tenant's ID in the URL: /tenants/:tenantId/entries
  2. TenantGuard reads :tenantId from the request params
  3. It looks up the current user in the tenant_admins, super_admins, and tenant_users tables to determine their role for that tenant
  4. It builds a CASL AppAbility object scoped to that tenantId
  5. Service methods filter all database queries with WHERE "tenantId" = $tenantId

A user who is not a member of the requested tenant receives 403 Forbidden — they cannot even confirm whether the tenant exists.

Super admin bypass

A super admin (super_admins table) receives manage: all ability, which satisfies every @CheckAbility check. This allows super admins to operate across all tenants without needing a TenantUser record in each one.

Tenant identity resolution (frontend)

The frontend resolves which tenant to operate in using this priority order (from src/stores/tenant.store.ts):

  1. Explicit chooser (idFromTenantChooser) — set when a user selects a tenant from the /choose-tenant screen
  2. Subdomain (idFromHost) — extracted from *.tim.ovh subdomains for dedicated tenant deployments
  3. Email domain (idFromUsername) — extracted from the user's email address for single-tenant setups

This allows the same codebase to be used for a fully multi-tenant SaaS deployment and a simpler single-tenant self-hosted setup.

Why not PostgreSQL Row Level Security?

RLS would provide stronger isolation guarantees but would require all queries to be executed as a tenant-specific PostgreSQL role or with a session variable set. This complicates connection pooling (PgBouncer in transaction mode doesn't support session variables) and Prisma's connection management. Application-level isolation with a well-tested guard is simpler to maintain and debug.

TT Time Tracker — Internal Documentation