Appearance
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:
- Every tenant-scoped route has the tenant's ID in the URL:
/tenants/:tenantId/entries TenantGuardreads:tenantIdfrom the request params- It looks up the current user in the
tenant_admins,super_admins, andtenant_userstables to determine their role for that tenant - It builds a CASL
AppAbilityobject scoped to thattenantId - 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):
- Explicit chooser (
idFromTenantChooser) — set when a user selects a tenant from the/choose-tenantscreen - Subdomain (
idFromHost) — extracted from*.tim.ovhsubdomains for dedicated tenant deployments - 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.