Technical · 3 of 8

Tenant isolation

Row-level security on every table, keyed by org_id and project_id, with the role checks as security-definer functions the policies call.

How a row decides who may see it

Every table carries org_id, and the project-scoped ones also carry project_id, denormalized so a policy is one indexed check. The policies call a small set of functions — lt_me(), lt_my_staff_orgs(), lt_my_projects(), lt_has_org_role(org, roles…), lt_is_platform_admin() — which run as security definer so they can read the membership tables the caller cannot.

Staff (employees and agents) see their organization's projects. Outside parties — clients, subcontractors, vendors, partners — see only projects they were explicitly granted, never the staff directory or the firm's finances. Views are declared security_invoker so a view cannot read around the policy on its base tables.

Files follow the same rule

A storage object has no org_id, so the storage policy asks the database which record references the path — lt_evidence_ref(name) returns the project and organization that own it — and applies the same visibility as that record. An upload nothing references is visible to nobody but the uploader and platform staff.

A child cannot leave its project
A guard on nine child tables refuses a row whose org_id differs from its project's org_id, so moving a project moves its record and nothing is stranded.
Privilege does not escalate
There is no client write path to platform_admins. An org admin cannot grant themselves a role on another organization's project.
Proven, not described
The suites run the real migrations in an in-process Postgres and query as each person with RLS active. No network, no mocks.
Where to verify
  • · db/002, db/005, db/052, db/058, db/060, db/075
  • · test/tenancy.test.mjs, test/external-access.test.mjs, test/evidence-access.test.mjs, test/project-org-guard.test.mjs

File names refer to the codebase. A customer under agreement can be walked through any of them.