Chapter 04 โ Multi-tenancy & data security: the vault
Multi-tenant means many unrelated companies share one running system and one database, each seeing only its own data. Get it right and hosting stays cheap forever. Get it wrong and Business A reads Business B's invoices โ for a billing product, that's the ballgame. This chapter is how FoxyInvoice's isolation works, and โ more important โ how it's proven every time the tests run.
The threat model, honestly
Solo developers imagine hackers. The realistic threat is your own future self at 2 a.m. writing a query that forgets the tenant filter. Every mechanism below exists to make that mistake impossible to write, or impossible to ship. Security here is less about firewalls and more about making the wrong code hard and the breach loud.
Identity: who are you
Three ways in, one result โ a signed ticket:
- Email + password, hashed with Argon2id (memory-hard; a leaked hash is expensive to crack). Passwords are never stored, logged, or emailed.
- Google SSO โ OAuth handshake, automatic workspace provisioning. Honesty footnote: the Google OAuth client lives in a legacy-named cloud project that predates the product; it's the live sign-in identity for both sites โ a rename is eventual.
- Either way, login mints a short-lived JWT access token (claims: user id, tenant id, permission list) plus a rotating refresh token. The browser shows the JWT on every API call; the server verifies the signature โ no session table lookup on the hot path.
Isolation: the three locks
flowchart TD
JWT["JWT: tenantId + userId
+ permissions"] --> CUR["CurrentUserService
(per request)"]
CUR --> CACHE["Per-tenant model cache key
(filters compile per tenant)"]
CUR --> FILTER["Global query filters:
every SELECT scoped to tenantId"]
CUR --> STAMP["TenantSaveChangesInterceptor:
every INSERT stamped with tenantId"]
FILTER --> DB[(Postgres)]
STAMP --> DB
TESTS["TenantIsolationTests
(two tenants, zero leaks,
run in CI)"] -.proves.-> DB
- Global query filters. The ORM (EF Core) attaches
WHERE TenantId = currentto every query on tenant-scoped entities โ automatically. Application code writes plaindb.Invoices.ToList()and cannot forget the filter, because the filter isn't in the application code. (A per-tenant model-cache key keeps the compiled filters correct when tenants interleave.) - The stamp. On save, an interceptor writes the caller's tenant onto every new row. You can't insert into someone else's workspace even by trying โ the framework overwrites you.
- The proof. Integration tests log in as two tenants, create overlapping data, and assert zero cross-visibility. Isolation that isn't tested is a vibe. These tests run in CI on every push.
The escape hatches are deliberate and rare: background jobs (reminders,
radars) use IgnoreQueryFilters() with explicit tenant handling โ each
one a conscious, commented decision.
Authorization: what may you do
RBAC โ roles map to permission strings (invoice:create,
tenant:manage_settings, โฆ). The same list drives three layers: route
guards in the SPA, element-level hiding in templates, and โ the one
that actually matters โ HasPermission checks on the API. The UI is
courtesy; the server is law. Custom role builder for workspaces that
want finer grain.
Scoped access: the share token
For "show this invoice to my client," there's a fourth lock: a 32-byte unguessable token in the URL is the authorization โ no account for the client, scoped to one document, expiring, revocable. Quote acceptance (Chapter 12) rides the same mechanism.
The rest of the vault checklist
- Audit trail โ an interceptor snapshots before/after JSON for changed entities: who, what, when, from which IP. Subtle bug worth stealing: if a save fails, the interceptor must drain its pending audit buffer, or the next successful save writes audit rows for changes that never committed.
- Backups โ nightly
pg_dump, gzip, size-checked (a 200-byte backup is a failed backup), copied off the box (S3 + email). A backup you've never restored is a hope, not a backup. - No card data, ever โ Stripe holds payment methods; we store ids. PCI compliance by not playing.
- GDPR posture โ "export everything I own" produces a full JSON dump (we built it as a feature and test it with it); deletion disables users immediately and hard-purges after a 30-day grace.
- Secrets โ generated by script, living only in
.envfiles on the host, caught by a gitleaks gate if they ever try to enter the repo.
Security posture: honest about the stage we're at
Isolation, tests, backups, and no-card-data are the controls we treat as non-negotiable at any size โ the 80/20 that protects what actually matters for a billing product. Beyond that spine, the security program matures the same way everything else in this series does: gated by real usage rather than built speculatively ahead of it. The next layer โ deeper account-takeover hardening, broader defense-in-depth โ scales with the stakes, and grows in the open, in this repo.
Recap. Signed identity, filters you can't forget, stamps you can't spoof, tests that prove it, permissions enforced where it counts, and scoped tokens for sharing. The vault is mostly removing ways to be wrong.
Next: Chapter 05 โ The domain: invoices, tax, and money math โ the state machine, the nexus-aware tax engine, and why totals are recomputed server-side every single time.