In an earlier post I walked through the overall design of Apex Legal — the relational schema, the layered Node.js backend, and role-tailored dashboards. This post is the sequel nobody warned me about: what happens when you take that schema and make it multi-tenant and permission-driven. This is the story of the security pass that turned a single-org prototype into a platform where data from one law firm can never leak into another, and where every actor sees exactly what their function allows — the process I followed, the obstacles that nearly bit me, and what comes next.
The two axes of the problem
Legal practice management has an unusual property: the hard parts aren’t the CRUD. They’re the separation. Every feature had to be built against two different isolation axes at once:
- Tenant isolation — firm A must never see firm B’s cases, clients, or invoices, even if they share an app instance.
- Role segregation — within a firm, a billing analyst, a paralegal, an attorney, and an admin are all looking at the same case, but they need to see wildly different versions of it.
Most SaaS identity work solves one axis. This required both, simultaneously and by default.
Under California State Bar Rule 1.6 (Confidentiality of Information), multi-tenant data isolation and audit logging are not merely software architecture choices—they are mandatory legal safeguards for any firm utilizing digital case tools.
Phase 1 — Org-aware auth and tenant-scoped data
Every user now belongs to an org (a firm). Registration creates a firm and an org slug; login resolves the org; the JWT carries orgId. That part is standard. The interesting part is the data layer underneath it.
I use a single shared Prisma client, extended so that every query on a tenant model is automatically scoped to the org in the current request context (AsyncLocalStorage). Creates get orgId injected — including nested relation creates, which Prisma runs at the database layer and would otherwise bypass the hook. Reads merge orgId into the where clause. Updates and deletes verify the target row belongs to the tenant before touching it.
The guard that matters most is the boring one: if you hit a tenant model outside an org context, the client throws — “Tenant context required.” I made the failure mode loud by design. A forgotten context should produce a panic-inducing 500, never a silent cross-firm data leak.
Phase 2 — Case teams instead of assignment scalars
The original schema stored assignedAttorneyId, assignedParalegalId, and assignedInvestigatorId as scalar columns on cases. It worked until it didn’t: one attorney leads many cases, a case has changing support roles, and someone can transfer the lead. The relational answer is a join — CaseTeamMember with caseId, userId, function, and an isLead flag.
The migration was the scary part. I did it in two passes: first an additive migration that created the join table and backfilled rows from the scalars, then a destructive one that dropped the old columns and their foreign keys. I generated the SQL with prisma migrate diff against a shadow database, reviewed it, applied it, and verified the team rows survived before anything was deleted. To avoid breaking existing API consumers, the response still aliases assignedAttorney to whatever the current team lead is — a compatibility shim on top of real team semantics, including lead transfer inside a transaction.
Phase 3 — A code-defined permission matrix, and a new role: FINANCE
Roles were getting vague fast, so I formalized policy as a permission matrix in code — a flat set of permission constants (case.read, documents.privileged.read, client.pii.read, billing.manage, …) with per-role grant tables. Policy lives in the repository, is reviewed in PRs like code, is deterministic, and is trivially unit-testable. The only new role the entire system needed was FINANCE — and that’s where the real subtlety showed up.
FINANCE has org-wide read access to cases so it can do billing: time entries, court dates, case lists, invoice generation. But it must never see client PII, internal attorney notes, privileged documents, or the financial details on documents. That means the API doesn’t just gate — it sanitizes at the boundary. A finance user gets a case object with client reduced to a name, documents and notes stripped from counts, and description/charges removed entirely. Enforcement is layered: route-level role gate, service-level permission check, and response shaping — so even if one layer is bypassed, the data that shouldn’t exist never leaves the API.
Phase 4 — Rewiring every service, with an audit trail
The mechanical but essential part: moving eleven modules — cases, documents, notes, court dates, time entries, expense requests, clients, invoices, reports, dashboard, users — onto the matrix, one at a time. Every sensitive read is now audited (PRIVILEGED_DOC_VIEW, CLIENT_PII_VIEW), and every destructive or privileged action records an AccessAuditLog row with the org and actor (DOCUMENT_DELETE, CASE_TEAM_CHANGE, INVOICE_VOID, USER_ROLE_CHANGE). PII is tiered so only a lead attorney or admin receives client PII fields in responses; everyone else gets the same record, minus the sensitive columns.
Obstacles — and the bugs only end-to-end testing found
By now I had fifteen unit tests over the pure permission and sanitizer functions, and the TypeScript gate was clean. Confident, I wired up a browser-fluent curl “isolation matrix”: six accounts across two orgs, hitting every endpoint to verify cross-case 403s, cross-org 404s, and sanitized payloads. It immediately caught three bugs that no unit test could:
- File uploads broke the tenant context. Multer’s multipart parsing runs through its own async event loop, which detaches the request from the
AsyncLocalStoragecontext — so the handler ran without an org, and every upload blew up with “Tenant context required.” The fix: re-enter the tenant context explicitly inside the upload handler. This is a transport-level bug; it was invisible until a real HTTP request went through the middleware stack. - Multipart booleans arrive as strings.
isPrivileged=truein a form arrives at the API as the string"true", and Prisma’s type validation rejects it. One explicit coercion fixed it. - Route gates and service policy drifted. The service layer correctly granted FINANCE org-wide
case.read, but the case-list route’s role gate had never been updated to include FINANCE — so a legitimate billing user got a 403 for something policy explicitly allowed. The matrix caught it, and I also aligned_countparity between list and detail views.
The lesson I’ll actually carry forward: a permission matrix is only as good as the transport that carries it. Unit tests prove the policy is correct in the abstract; only exercising real requests across real roles and real orgs proves the system as a whole is isolated. Build the matrix, then go log in as each person.
What lies ahead
The code is right where the bugs were — in the repo, on a homelab server behind nginx-proxy-manager and a Cloudflare tunnel. Next up:
- CI. TypeScript, lint, unit tests, and the isolation matrix as an automated integration suite on every push instead of a shell script I run by hand.
- Real object storage. Uploads currently store metadata; production needs S3/R2 with signed URLs, and content verification beyond stub checksums.
- Deployment. Moving past development onto Render (API), Neon Postgres, and R2 — with proper secrets discipline instead of a homelab
.env. - Frontend wiring. The admin client needs the FINANCE navigation, the team editor, privileged-document flows, and an audit viewer that exposes those
AccessAuditLogrows instead of leaving them in the database. - Hardening and compliance. Rate limiting, session/refresh hardening, retention policies, and turning the audit ledger into reportable evidence.
The build so far has been a masterclass in why legal software is a discipline of its own: the CRUD was the easy part; the isolation is the product.
Turnkey Sovereign Legal Deployments in Southern California
At OC Computer Fix, we deploy, configure, and maintain fully sovereign, air-gapped legal practice management systems and private AI assistants for law firms throughout Orange County, Los Angeles, and San Diego. Eliminate SaaS data leakage, ensure strict California State Bar compliance, and retain total data ownership on dedicated hardware.
👉 Explore our California Legal AI & Practice Management Platform or Request Live Demo Access to Apex Legal.
Leave a Reply