Skip to content
← All work

Client Management Platform

A multi-tenant portal that two client companies run their week on

Ongoing

lines of TypeScript
15,000
roles across two tiers
6
database per tenant
1:1

No screenshots. Every real view of this holds two clients' company names, devices and open security findings, and none of that belongs on a public page.

Two companies log in to this every day. Each sees its own devices, its own tickets, its own security findings, and nothing belonging to the other one. That sounds like the easy part right up until you write the query that forgets its tenant filter and cheerfully returns everybody’s rows to whoever asked.

So I decided not to rely on remembering, which is the only part of this design I’d call opinionated: every client company gets its own database file, created the moment the company is added and deleted along with it, so that a query missing its tenant filter cannot cross a boundary that does not exist inside the same file in the first place.

The cost lands at the seams. Anything that has to report across all tenants at once gets more awkward, and I pay that happily. Isolation you can see beats isolation you have to keep remembering to maintain.

The permission check that did two jobs

There was one flag guarding administrative work, and two unrelated powers hung off it: seeing every client company, and creating user accounts.

Fine, while I was the only person with staff access.

It stopped being fine the week somebody needed to see across all clients without being able to mint logins. One flag couldn’t say that. Not without going back through every call site that had ever asked it and working out which of the two things each one had actually meant. I split it into two questions instead, which is where the six roles come from, and I wrote up what I took from it because I think the mistake generalises.

Sessions

Login is two-step, with TOTP required for anyone holding owner, staff or company-admin access. The token you get after the first factor is not a session and won’t open anything.

Every token also carries a session version. Change a password and the stored version increments, which invalidates every token already in circulation without the server keeping any list of live sessions at all. One integer in the users table. That’s the whole mechanism, and it means a stolen token dies the moment its owner does the thing anyone does on suspecting it was stolen.