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
Companyrow is the tenant root. Itsidis the tenant key. - Tenant-scoped tables (
Employee,User,PayrollRun, and most others) carry their owncompanyIdcolumn pointing back to that company. - Child tables that have no
companyIdof their own (for exampleAttendanceor a payroll entry component) are reached through a to-one relation up to a parent that does —Attendance→employee→companyId. - 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 level | Meaning | Result |
|---|---|---|
full | The filter provably pins the tenant (a companyId, a compound-unique key containing it, or a relation path that reaches it). | Always allowed. |
fk | Filtered 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. |
none | No 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.
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
LoginAuditrow with the actionimpersonatetagged with the operator's user id inimpersonatedBy, 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:
- Cancels the live Stripe subscription first, so a deleted tenant is never charged again (a failure here aborts before anything is deleted).
- 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.
- 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.
- Leaves a single
company_eraseaudit row (withcompanyIdset 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
companyIdtenant key, withCompanyas the tenant root. - A person is one platform-wide account; a
Userrow 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.