Enforce tenant isolation in the data layer instead of per route #6
Reference in New Issue
Block a user
Delete Branch "fix/enforced-tenant-isolation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Every route had to remember to add the business filter to every query, and nothing stopped one that forgot. This moves isolation into the data layer: an unscoped query on business data now throws before it reaches the database.
The audit for this change found queries that had forgotten: the M-Pesa status re-check, the stock ledger's duplicate checks, the daily-inventory and branch-price upserts, a cash-sale replay lookup, several load-by-id lookups, and every
populate(). None was exploitable on its own (the ids came from the business's own records), but each was one mistake away from a leak.What changes
tenantGuard(src/lib/server/tenantGuard.ts): a Mongoose plugin on all ten business-scoped models (User, Branch, Product, BranchPricing, Transaction, DailyInventory, Expense, Purchase, StockMovement, StockTransfer). Any find / findOne / update / delete / count / distinct, or an aggregate whose first stage is not a$match, that does not pin exactly onebusinessIdthrowsTenantScopeError.$in,$existsand other operators do not count.crossTenant("reason", fn): the explicit, documented exemption for queries that must span businesses: login's email lookup, refresh-session and invite-token lookups, the M-Pesa callback, the platform console, and the maintenance scripts.git grep crossTenantlists every one.scopedPopulate: populated branch, product and staff names are restricted to the business too.Testing
npm test: 76 tests pass. Newtests/isolation.test.ts:businessIdfield carries the guard (a new model without it fails the suite);db(), an explicit test exemption.No migration or deploy step.
🤖 Generated with Claude Code
Every route had to remember to add the business filter to every query; nothing stopped one that forgot. The audit for this change found several that had: the M-Pesa status re-check, the stock ledger's duplicate checks, the daily-inventory and branch-price upserts, a cash-sale replay lookup, several load-by-id lookups, and every populate(). - tenantGuard: a Mongoose plugin on all ten business-scoped models. Any find/update/delete/count/distinct, or an aggregate whose first stage is not a $match, that does not pin exactly one businessId throws TenantScopeError before reaching the database ($in, $exists and other operators do not count). - crossTenant("reason", fn): the explicit, documented exemption for the queries that must span businesses - login's email lookup, refresh and invite-token lookups, the M-Pesa callback, the platform console and the maintenance scripts. git grep crossTenant lists them all. - scopedPopulate: populated branch, product and staff names are also restricted to the business. - Every flagged query site now names the business (or is exempted with a reason). - Tests: a check that every model with a businessId field carries the guard; the guard refusing unscoped queries of every kind; and a route sweep where business A sends business B's ids to every read route (nothing of B's comes back) and every write route (all refused, B's data unchanged). Test-side database inspections go through db(), an explicit test exemption. Removing one route's business filter makes the suite fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>