Skip to main content

Multi-tenancy model

Operentra is a multi-tenant SaaS. Every company that signs up gets its own workspace — its employees, payroll, documents and settings — and no workspace can ever see another's data. This page explains how that isolation is enforced, and the narrow, audited paths where the platform operator is allowed to cross a workspace boundary.

One database, per-row scoping

Operentra does not give each workspace its own database or schema. Every workspace shares one PostgreSQL database, and rows are separated by a tenant key: a companyId column.

  • A Company row is the tenant root. Its id is the tenant key.
  • Tenant-scoped tables (Employee, User, PayrollRun, and most others) carry their own companyId column pointing back to that company.
  • Child tables that have no companyId of their own (for example Attendance or a payroll entry component) are reached through a to-one relation up to a parent that does — AttendanceemployeecompanyId.
  • Truly global reference data (such as the bank registry and the permission catalog) and platform-level tables have no path to any company, so they are shared and exempt from scoping.

Isolation therefore comes down to one rule: every query against tenant data must be pinned to a single companyId. Each request carries the caller's companyId in its JWT, and controllers derive the tenant from req.user.companyId — never from anything the client can freely choose.

The tenant guard

Relying on developers to remember a companyId filter on every one of hundreds of hand-written queries is fragile — one forgotten filter is a cross-tenant leak. Operentra turns that convention into a runtime guarantee with a tenant guard, a Prisma client extension defined in apps/api/src/prisma/tenant-guard.ts and installed in PrismaService.

The guard inspects the where clause of every query against a tenant-scoped model and classifies how strongly it is pinned to one tenant:

Scope levelMeaningResult
fullThe filter provably pins the tenant (a companyId, a compound-unique key containing it, or a relation path that reaches it).Always allowed.
fkFiltered only by a foreign key to a tenant parent (for example employeeId) without reaching companyId.Allowed on child models, but audit-logged. Not allowed on models that have their own companyId.
noneNo tenant scope at all — an enumeration vector.Treated as a violation.

The set of guarded models is derived automatically from the Prisma schema at startup (not hand-maintained), so any new model with a companyId — or a relation that reaches one — is covered the moment it is added.

Operations are split by risk. Bulk/enumeration operations (findMany, count, updateMany, deleteMany, and similar) are the dangerous class and raise a strong violation when unscoped. Single-row operations by primary key (findUnique, update, delete, upsert) are the normal "look-it-up-after-an-authorised-parent-check" pattern and are only audited.

Guard modes

The guard's behavior is set by the TENANT_GUARD environment variable, read once when the client is built:

  • off — the extension is not installed.
  • warn — log each unique violation once (the default; an audit posture).
  • strict — throw on strong violations. Used in CI and tests so a missing filter fails the build rather than shipping.

Every violation and bypass is recorded per call-site with a running count and written to logs/tenant-guard.jsonl, so a running deployment accumulates a reviewable record of anywhere the isolation convention was not met.

One person, many workspaces

Tenant isolation applies to data, not to people. A person on the platform is one account (internally an Identity): the sign-in email and password, stored once, platform-wide. Sign-in emails are globally unique — an email identifies exactly one account.

A User row is that account's membership in one company: its per-company roles and permissions, employee link, and active flag all live on the membership. The same account can hold memberships in several companies — for example full-time in one company and part-time in a sister company — under a single sign-in, with entirely different roles in each.

  • Signing in authenticates the account, then lands on the last-used company. Whenever the account has more than one membership, a company switcher appears in the top bar (admin and portal): pick another company and the session is re-minted for that membership.
  • Sessions are per membership, but the credential is one: a password reset or change signs the person out of every company at once, and the account lockout (5 failed attempts, 30 minutes) is account-level.
  • Memberships are created by invitation. Adding an employee sends an invitation whose activation link binds the sign-in email to exactly the invited address — a brand-new address sets a password, while an address that already has an account just proves its existing password and gains the new membership. Using the same personal email across group companies is what links their memberships into one account.
  • On a white-label custom domain, login is scoped to that company's membership.

Personal data splits the same way: the person's profile vs. each employer's copy. The account owns a single person profile (person_profiles, keyed by identity) — name, identity numbers, contact details, addresses, primary bank account — edited freely by the person in their My Account area (/profile, outside any workspace). No employer sees those edits automatically. When the person submits the profile to a chosen company (profile_submissions, one per company, employee-initiated), that company's HR reviews the diff and, on approval, the snapshot is copied into the company's own Employee row — the tenant-scoped, payroll-bearing verified copy that all workspace features run on. Each workspace therefore keeps an independent, approved copy inside its own tenant boundary, while the person's master data stays account-level — the same pattern as credentials (one account) vs. roles (per membership). The account also keeps a cross-company employment history (every membership with an employee record, current and ended), visible only to the person themselves.

Impersonation is pinned. Both workspace-admin "sign in as user" sessions and operator impersonation sessions are pinned to the company they were opened in: the switcher shows only that company and switching is refused, so an admin impersonating a multi-company person never sees or reaches the person's other workspaces. The pin survives token refresh.

Crossing the boundary on purpose

Some operations are legitimately cross-tenant: logging a user in by email before you know their company, resolving a password-reset token, or platform crons and operator screens that iterate across all workspaces. These are not leaks — but the guard would flag them.

For those, the code wraps the query in withoutTenantGuard(reason, fn):

return withoutTenantGuard('platform admin: tenant list', () =>
this.prisma.company.findMany({ /* ... */ }),
);

Each bypass carries a human-readable reason that is logged (once per reason), so the full surface of intentional cross-tenant access stays visible and auditable. The entire operator plane in apps/api/src/modules/platform/ is built on this — every cross-tenant read there passes through a wrapped call with a stated reason.

note

The bypass only disables the isolation guard. It is not a general authorization bypass — the routes that use it are still protected by the platform-admin guard, so only the operator can reach them.

Operator impersonation

When the operator needs to help a workspace, they can impersonate that workspace's admin from the operator console rather than asking for a password. Impersonation:

  • Targets the workspace's earliest active super-admin user.
  • Is refused for any workspace that is not active — a suspended tenant stays frozen for everyone, including the operator, who must reactivate it first.
  • Reuses the normal token-issuing path, and writes a LoginAudit row with the action impersonate tagged with the operator's user id in impersonatedBy, so the trail always shows who was driving the session.
  • Is pinned to the impersonated workspace — if the target person belongs to other companies too, the company switcher is restricted to this one and switching is refused (see One person, many workspaces).

Because issuing an impersonation session replaces the target membership's active session token, only one session per membership stays live.

Per-tenant data lifecycle

A workspace moves through a status on its Company row. Beyond the signup gates (pending_verification, pending_approval, rejected) and provisioning states, two operator actions govern a live tenant's data:

Suspend and reactivate

From the operator console the operator can suspend a workspace (status = suspended) and later reactivate it (status = active). While a company is suspended, sign-in is blocked for all of its users, refresh-token rotation is refused, and existing sessions are rejected by the auth layer — the workspace is frozen but its data is fully retained. The operator cannot suspend their own company from the platform panel.

Erase (company deletion / GDPR)

Erasing a workspace permanently deletes the company and every row it owns. It is deliberately hard to trigger by accident:

  • The workspace must already be suspended (two separate, deliberate steps).
  • A confirmation string must exactly match the company name.
  • The operator cannot erase their own company.

When those checks pass, erasure:

  1. Cancels the live Stripe subscription first, so a deleted tenant is never charged again (a failure here aborts before anything is deleted).
  2. Writes a final encrypted snapshot of the tenant's data to disk (a file only — no surviving database row), whose path is returned and audited. The operator deletes that file when any retention obligation genuinely ends.
  3. Deletes every tenant-owned row — including billing state, audit history and backup metadata — in one transaction, then flushes the tenant's uploaded files from every storage destination.
  4. Leaves a single company_erase audit row (with companyId set to null, since the company is gone) recording who did it, the row count, the snapshot path and its SHA-256 hash.

There is also a workspace-level GDPR surface inside the admin app (under /gdpr in the API, gated by the company.settings permission) that lets a workspace admin export their whole company's data, export a single employee's data, or erase an individual employee — all strictly scoped to their own companyId. That is distinct from operator erasure, which removes the entire tenant.

Summary

  • One shared PostgreSQL database; rows are partitioned by a companyId tenant key, with Company as the tenant root.
  • A person is one platform-wide account; a User row is that account's per-company membership. Multi-company people switch workspaces from the top bar, and impersonation sessions are pinned to a single company.
  • The tenant guard turns "always filter by companyId" from a convention into a runtime-enforced, audited guarantee.
  • Legitimate cross-tenant work goes through withoutTenantGuard(reason, fn), keeping the bypass surface visible.
  • The operator can impersonate an active workspace's admin, suspend and reactivate workspaces, and — only after suspension and explicit confirmation — erase a workspace, with an encrypted final snapshot kept for retention.