Skip to main content

Multi-Tenancy

Ticku is a shared multi-tenant SaaS: a single deployment (one app, one PostgreSQL database, one Azure Blob account) serves many independent tenants. A tenant is one customer organization. Every tenant's data lives in the same tables, separated by a tenantId column and an isolation layer that makes cross-tenant access impossible from ordinary application code.

:::info Terminology "Tenant" is Ticku's customer organization. It is unrelated to an Azure AD tenant (a Microsoft Entra directory), which is referenced separately as entraDirectoryId — used for both SSO and email-to-ticket. :::

The Tenant

Each Tenant row carries:

FieldPurpose
slugPath-based routing key — the tenant is addressable at /t/{slug}
emailDomainCompany email domain (e.g. acme.com), unique. Resolves which tenant a self-registering user belongs to
firstAdminEmailThe designated first admin. When this address signs up, it is auto-promoted to an active admin and default projects are seeded — bootstrapping a brand-new tenant
entraDirectoryIdThe customer's Azure AD directory id (tid claim). When set, Azure-SSO logins whose tid matches are auto-approved
statusLifecycle gate — login/API is allowed only when ACTIVE

Tenant lifecycle

Tenants are never hard-deleted — every child model's tenantId foreign key is RESTRICT, and suspension (not deletion) is the way to block access. Data stays intact and the action is reversible.

Users are global identities

A user is not owned by a tenant. User is a single global identity (unique email) that can belong to several tenants at once through Membership rows. This is what lets a consultant work in their own company's tenant and in a client's tenant with the same login.

  • Home membership (isHome = true) — the tenant resolved from the user's email domain, where they are a first-class member.
  • Guest membership (isHome = false, has invitedById) — a tenant they were invited into.
  • User.tenantId records only the home tenant, and is nullable: a consultant can be invited into a client's tenant before their own company has registered on Ticku, so they legitimately have no home tenant yet. They get a homeless guest membership and are adopted into their real tenant automatically the first time they sign in after it exists.

Per-tenant role and isAdmin live on Membership, not on User — a user can be an admin in their own tenant and a read-only member in a client's. (The old global User.role / User.isAdmin were dropped; see the v2.0.0 release note.)

The active tenant

The user picks which of their tenants they're working in via the tenant switcher. The server resolves the active tenant on every request:

  • The /t/{slug} segment is forwarded as an x-tenant-slug header; a ticku_tenant cookie remembers the choice for unprefixed in-app links.
  • Neither is trusted — the server only ever selects from the caller's own ACTIVE memberships. An unusable value silently falls back to home.
  • If every membership is suspended, the user is treated as unauthenticated (suspend = block login for that tenant).

Isolation: the tenant-scoped Prisma client

Data isolation does not rely on developers remembering to add where: { tenantId }. Instead, once the active tenant is known, the request gets a tenant-scoped Prisma client (ctx.tenantDb) that injects tenantId automatically:

  • Reads / updates / deletestenantId is merged into every where, so a query simply cannot see another tenant's rows (even a by-id lookup).
  • CreatestenantId is forced onto the data.
  • Immutable — any attempt to change tenantId in an update payload is stripped as a second line of defense.

Only models with a direct tenantId column are auto-scoped (the TENANT_MODELS list). Join tables and child rows (e.g. ProjectMember, TicketAssignee) are scoped transitively — the repository validates the already-scoped parent.

:::note User is scoped differently Because a user is global, it is not filtered by its tenantId column. Instead the scoped client injects a membership relation filter — a user is visible in the active tenant only if they hold an ACTIVE membership in it. :::

Procedure tiers

tRPC procedures encode the isolation contract:

ProcedureGuarantee
publicProcedureNo auth (login, self-serve registration)
protectedProcedureAuthenticated user
tenantProcedureAuthenticated and a resolved active tenant — exposes ctx.tenantDb (the scoped client). Every tenant-model query must go through this
adminProceduretenantProcedure + the tenant-admin permission — the admin panel
platformAdminProcedureisPlatformAdmin. Cross-tenant by design, so it uses the raw client, not ctx.tenantDb

A CI guard (scripts/check-tenant-scoping.ts) fails the build if a tenant-model query reaches for the raw client instead of ctx.tenantDb, so the isolation boundary can't quietly erode.

Platform admin

User.isPlatformAdmin is a tenant-less super-role. It operates across tenants: create, approve, suspend, and list tenants, and grant/revoke platform-admin. It is distinct from a membership's isAdmin, which only unlocks the admin panel within one tenant. See Tenants & Platform Admin.

How a tenant comes to exist

  1. Manual provisioning — a platform admin creates the tenant directly (lands ACTIVE). See Tenant Provisioning.
  2. Self-serve registration — someone registers their organization at /register-organization; after email-OTP verification the tenant is created PENDING_APPROVAL and a platform admin approves it. See Onboarding & Registration.

Once a tenant is ACTIVE, users at its emailDomain can sign up and are routed into it automatically.