๐ŸฆŠ FoxyInvoice

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:

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
  1. Global query filters. The ORM (EF Core) attaches WHERE TenantId = current to every query on tenant-scoped entities โ€” automatically. Application code writes plain db.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.)
  2. 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.
  3. 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

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.