Enforce tenant isolation in the data layer instead of per route #6

Merged
denis_ireri merged 3 commits from fix/enforced-tenant-isolation into main 2026-10-12 08:58:32 +00:00
Contributor

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 one businessId throws TenantScopeError. $in, $exists and 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 crossTenant lists every one.
  • scopedPopulate: populated branch, product and staff names are restricted to the business too.
  • Every flagged query site now names its business, or is exempted with a reason.
  • Not guarded, by design: Business (the tenant itself, looked up by the session's id), UserSession, MpesaCallbackLog, StockLock.

Testing

  • npm test: 76 tests pass. New tests/isolation.test.ts:
    • every model with a businessId field carries the guard (a new model without it fails the suite);
    • the guard refuses unscoped queries of every kind, and the exemption needs a reason and ends with its callback;
    • route sweep: business A sends business B's ids to every read route (nothing of B's comes back) and to every write route (all refused, B's data unchanged).
  • Mutation check: removing one route's business filter makes the suite fail with the guard's error.
  • Test-side database inspections go through db(), an explicit test exemption.
  • Running app: every read endpoint called as owner, manager and cashier answered as expected, with no server errors. Seed, backfill and identity-migration scripts still run.
  • Production build passes.

No migration or deploy step.

🤖 Generated with Claude Code

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 one `businessId` throws `TenantScopeError`. `$in`, `$exists` and 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 crossTenant` lists every one. - **`scopedPopulate`**: populated branch, product and staff names are restricted to the business too. - Every flagged query site now names its business, or is exempted with a reason. - Not guarded, by design: Business (the tenant itself, looked up by the session's id), UserSession, MpesaCallbackLog, StockLock. ## Testing - `npm test`: 76 tests pass. New `tests/isolation.test.ts`: - every model with a `businessId` field carries the guard (a new model without it fails the suite); - the guard refuses unscoped queries of every kind, and the exemption needs a reason and ends with its callback; - route sweep: business A sends business B's ids to every read route (nothing of B's comes back) and to every write route (all refused, B's data unchanged). - Mutation check: removing one route's business filter makes the suite fail with the guard's error. - Test-side database inspections go through `db()`, an explicit test exemption. - Running app: every read endpoint called as owner, manager and cashier answered as expected, with no server errors. Seed, backfill and identity-migration scripts still run. - Production build passes. No migration or deploy step. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
denis_ireri added 2 commits 2026-10-12 08:55:28 +00:00
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>
denis_ireri added 1 commit 2026-10-12 08:56:09 +00:00
denis_ireri merged commit db9387c01a into main 2026-10-12 08:58:32 +00:00
denis_ireri deleted branch fix/enforced-tenant-isolation 2026-10-12 08:58:46 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Ezekiel_Nyangweso/ERP#6