Enforce tenant isolation in the data layer instead of per route
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>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||
|
||||
export interface IBranch extends Document {
|
||||
@@ -30,5 +31,7 @@ const branchSchema = new Schema<IBranch>(
|
||||
/** Filter for branches that are in use. */
|
||||
export const ACTIVE_BRANCH = { isActive: { $ne: false } } as const;
|
||||
|
||||
branchSchema.plugin(tenantGuard);
|
||||
|
||||
export const Branch =
|
||||
(models.Branch as Model<IBranch>) || model<IBranch>("Branch", branchSchema);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||
|
||||
export interface IDailyInventory extends Document {
|
||||
@@ -40,6 +41,8 @@ dailyInventorySchema.index({ branchId: 1, date: 1 });
|
||||
// One ledger row per branch+product+day.
|
||||
dailyInventorySchema.index({ branchId: 1, productId: 1, date: 1 }, { unique: true });
|
||||
|
||||
dailyInventorySchema.plugin(tenantGuard);
|
||||
|
||||
export const DailyInventory =
|
||||
(models.DailyInventory as Model<IDailyInventory>) ||
|
||||
model<IDailyInventory>("DailyInventory", dailyInventorySchema);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||
|
||||
// Business expense categories. Kept as a constrained string set so the UI
|
||||
@@ -46,5 +47,7 @@ expenseSchema.index({ category: 1 });
|
||||
|
||||
// `models.Expense` guard avoids OverwriteModelError on serverless warm
|
||||
// re-invocations where the module is evaluated more than once.
|
||||
expenseSchema.plugin(tenantGuard);
|
||||
|
||||
export const Expense =
|
||||
(models.Expense as Model<IExpense>) || model<IExpense>("Expense", expenseSchema);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||
|
||||
export interface IProduct extends Document {
|
||||
@@ -32,6 +33,8 @@ const productSchema = new Schema<IProduct>(
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
productSchema.plugin(tenantGuard);
|
||||
|
||||
export const Product =
|
||||
(models.Product as Model<IProduct>) || model<IProduct>("Product", productSchema);
|
||||
|
||||
@@ -57,6 +60,8 @@ const branchPricingSchema = new Schema<IBranchPricing>(
|
||||
);
|
||||
branchPricingSchema.index({ branchId: 1, productId: 1 }, { unique: true });
|
||||
|
||||
branchPricingSchema.plugin(tenantGuard);
|
||||
|
||||
export const BranchPricing =
|
||||
(models.BranchPricing as Model<IBranchPricing>) ||
|
||||
model<IBranchPricing>("BranchPricing", branchPricingSchema);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||
|
||||
// A recorded stock purchase from a supplier. Completing a purchase is what
|
||||
@@ -40,5 +41,7 @@ const purchaseSchema = new Schema<IPurchase>(
|
||||
purchaseSchema.index({ branchId: 1, createdAt: -1 });
|
||||
purchaseSchema.index({ productId: 1 });
|
||||
|
||||
purchaseSchema.plugin(tenantGuard);
|
||||
|
||||
export const Purchase =
|
||||
(models.Purchase as Model<IPurchase>) || model<IPurchase>("Purchase", purchaseSchema);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||
|
||||
export const MOVEMENT_TYPES = ["SALE", "PURCHASE", "TRANSFER_OUT", "TRANSFER_IN", "ADJUSTMENT"] as const;
|
||||
@@ -46,6 +47,8 @@ stockMovementSchema.index({ type: 1, refId: 1 }, { unique: true });
|
||||
stockMovementSchema.index({ businessId: 1, branchId: 1, productId: 1 });
|
||||
stockMovementSchema.index({ businessId: 1, branchId: 1, createdAt: -1 });
|
||||
|
||||
stockMovementSchema.plugin(tenantGuard);
|
||||
|
||||
export const StockMovement =
|
||||
(models.StockMovement as Model<IStockMovement>) ||
|
||||
model<IStockMovement>("StockMovement", stockMovementSchema);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||
|
||||
export interface IStockTransfer extends Document {
|
||||
@@ -35,6 +36,8 @@ const stockTransferSchema = new Schema<IStockTransfer>(
|
||||
stockTransferSchema.index({ businessId: 1, clientUuid: 1 }, { unique: true });
|
||||
stockTransferSchema.index({ businessId: 1, createdAt: -1 });
|
||||
|
||||
stockTransferSchema.plugin(tenantGuard);
|
||||
|
||||
export const StockTransfer =
|
||||
(models.StockTransfer as Model<IStockTransfer>) ||
|
||||
model<IStockTransfer>("StockTransfer", stockTransferSchema);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||
|
||||
export type PaymentMethod = "CASH" | "MPESA";
|
||||
@@ -105,6 +106,8 @@ transactionSchema.index({ businessId: 1, createdAt: -1 });
|
||||
transactionSchema.index({ paymentStatus: 1 });
|
||||
transactionSchema.index({ businessId: 1, saleId: 1 });
|
||||
|
||||
transactionSchema.plugin(tenantGuard);
|
||||
|
||||
export const Transaction =
|
||||
(models.Transaction as Model<ITransaction>) ||
|
||||
model<ITransaction>("Transaction", transactionSchema);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
@@ -80,5 +81,7 @@ userSchema.methods.comparePassword = async function (password: string): Promise<
|
||||
return bcrypt.compare(password, this.passwordHash);
|
||||
};
|
||||
|
||||
userSchema.plugin(tenantGuard);
|
||||
|
||||
export const User =
|
||||
(models.User as Model<IUser>) || model<IUser>("User", userSchema);
|
||||
|
||||
@@ -48,7 +48,9 @@ export async function requirePlatformAdmin(
|
||||
): Promise<{ userId: string; email: string } | null> {
|
||||
const auth = getAuth(req);
|
||||
if (!auth) return null;
|
||||
const user = await User.findById(auth.sub).select("email isActive platformAdmin").lean();
|
||||
const user = await User.findOne({ _id: auth.sub, businessId: auth.businessId })
|
||||
.select("email isActive platformAdmin")
|
||||
.lean();
|
||||
if (!user || !user.isActive) return null;
|
||||
if (!isPlatformAdmin(user)) return null;
|
||||
return { userId: auth.sub, email: user.email };
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||
import { Product } from "@/lib/server/models/Product";
|
||||
import { Purchase } from "@/lib/server/models/Purchase";
|
||||
import { StockLock } from "@/lib/server/models/StockLock";
|
||||
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||
import type { ITransaction } from "@/lib/server/models/Transaction";
|
||||
|
||||
type Id = Types.ObjectId | string;
|
||||
@@ -26,7 +27,7 @@ export interface MovementInput {
|
||||
*/
|
||||
export async function recordMovement(m: MovementInput): Promise<void> {
|
||||
await StockMovement.updateOne(
|
||||
{ type: m.type, refId: m.refId },
|
||||
{ type: m.type, refId: m.refId, businessId: m.businessId },
|
||||
{
|
||||
$setOnInsert: {
|
||||
businessId: m.businessId,
|
||||
@@ -155,11 +156,14 @@ export async function getStockLevels(businessId: Id, branchId?: Id | null): Prom
|
||||
export async function backfillPurchaseMovements(businessId?: Id): Promise<number> {
|
||||
const filter: Record<string, unknown> = { productId: { $ne: null } };
|
||||
if (businessId) filter.businessId = new mongoose.Types.ObjectId(businessId.toString());
|
||||
const purchases = businessId
|
||||
? await Purchase.find(filter).lean()
|
||||
: await crossTenant("the backfill runs over every business", () => Purchase.find(filter).lean().exec());
|
||||
|
||||
let added = 0;
|
||||
for await (const p of Purchase.find(filter).lean().cursor()) {
|
||||
for (const p of purchases) {
|
||||
const refId = p._id.toString();
|
||||
if (await StockMovement.exists({ type: "PURCHASE", refId })) continue;
|
||||
if (await StockMovement.exists({ type: "PURCHASE", refId, businessId: p.businessId })) continue;
|
||||
await recordMovement({
|
||||
businessId: p.businessId,
|
||||
branchId: p.branchId,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { AsyncLocalStorage } from "async_hooks";
|
||||
import { Types, type MongooseQueryMiddleware, type Schema } from "mongoose";
|
||||
|
||||
/**
|
||||
* Tenant isolation, enforced instead of remembered.
|
||||
*
|
||||
* Every model that holds one business's data is guarded: a query against
|
||||
* it that does not name exactly one business (`businessId: <id>`) throws
|
||||
* TenantScopeError before it reaches the database. A route that forgets
|
||||
* the business filter therefore fails loudly in development and in the
|
||||
* test suite, instead of quietly reading or changing another business's
|
||||
* data in production.
|
||||
*
|
||||
* The few queries that legitimately span businesses (login looking an
|
||||
* email up everywhere, an invite token, the payment provider's callback,
|
||||
* the platform console, maintenance scripts) must say so explicitly:
|
||||
*
|
||||
* await crossTenant("login looks the email up in every business", () => User.find(...));
|
||||
*
|
||||
* Guarded: find, findOne (and findById / exists), findOneAndUpdate /
|
||||
* Delete / Replace, updateOne / updateMany, replaceOne, deleteOne /
|
||||
* deleteMany, countDocuments, distinct, and aggregate (its first stage
|
||||
* must be a $match naming the business). populate() runs a find on the
|
||||
* referenced model, so populate through `scopedPopulate` below.
|
||||
*
|
||||
* Not guarded, by design: Business (the tenant itself, always looked up by
|
||||
* the id from the session), UserSession (keyed by the signed-in user),
|
||||
* MpesaCallbackLog (provider audit log), StockLock (key already contains
|
||||
* the business). Document saves and inserts are covered by `businessId`
|
||||
* being a required field.
|
||||
*/
|
||||
|
||||
export class TenantScopeError extends Error {
|
||||
constructor(model: string, op: string) {
|
||||
super(
|
||||
`Tenant isolation: ${model}.${op} must filter by a single businessId ` +
|
||||
`(or run inside crossTenant("why", ...) if it really spans businesses).`
|
||||
);
|
||||
this.name = "TenantScopeError";
|
||||
}
|
||||
}
|
||||
|
||||
const crossTenantReason = new AsyncLocalStorage<string>();
|
||||
|
||||
/**
|
||||
* Run `fn`, whose queries deliberately span businesses. `reason` documents
|
||||
* why. The result is awaited inside the scope, so a lazy Mongoose query
|
||||
* returned by `fn` executes while the exemption applies.
|
||||
*/
|
||||
export function crossTenant<T>(reason: string, fn: () => PromiseLike<T>): Promise<T> {
|
||||
if (!reason.trim()) throw new Error("crossTenant needs a reason");
|
||||
return crossTenantReason.run(reason, async () => await fn());
|
||||
}
|
||||
|
||||
/** True when the filter pins exactly one business. */
|
||||
export function namesOneBusiness(filter: unknown): boolean {
|
||||
if (!filter || typeof filter !== "object") return false;
|
||||
const f = filter as Record<string, unknown>;
|
||||
const v = f.businessId;
|
||||
if (typeof v === "string" || v instanceof Types.ObjectId) return true;
|
||||
if (v && typeof v === "object" && "$eq" in (v as object)) {
|
||||
const eq = (v as { $eq: unknown }).$eq;
|
||||
return typeof eq === "string" || eq instanceof Types.ObjectId;
|
||||
}
|
||||
return Array.isArray(f.$and) && f.$and.some(namesOneBusiness);
|
||||
}
|
||||
|
||||
const QUERY_OPS: MongooseQueryMiddleware[] = [
|
||||
"find",
|
||||
"findOne",
|
||||
"findOneAndUpdate",
|
||||
"findOneAndDelete",
|
||||
"findOneAndReplace",
|
||||
"updateOne",
|
||||
"updateMany",
|
||||
"replaceOne",
|
||||
"deleteOne",
|
||||
"deleteMany",
|
||||
"countDocuments",
|
||||
"distinct",
|
||||
];
|
||||
|
||||
const guardedSchemas = new WeakSet<Schema>();
|
||||
|
||||
/** Whether a schema has the guard (the test suite checks every business-scoped model does). */
|
||||
export function isTenantGuarded(schema: Schema): boolean {
|
||||
return guardedSchemas.has(schema);
|
||||
}
|
||||
|
||||
/** Mongoose plugin: apply to every schema with a `businessId` path. */
|
||||
export function tenantGuard(schema: Schema): void {
|
||||
guardedSchemas.add(schema);
|
||||
|
||||
schema.pre(QUERY_OPS, function (this: any) {
|
||||
if (crossTenantReason.getStore()) return;
|
||||
if (!namesOneBusiness(this.getFilter())) throw new TenantScopeError(this.model.modelName, this.op);
|
||||
});
|
||||
|
||||
schema.pre("aggregate", function (this: any) {
|
||||
if (crossTenantReason.getStore()) return;
|
||||
const first = this.pipeline()[0];
|
||||
if (!first || !namesOneBusiness(first.$match)) {
|
||||
throw new TenantScopeError(this._model?.modelName ?? "Model", "aggregate");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* populate() options that keep the referenced documents inside the
|
||||
* business too (populate queries the referenced model, which is guarded).
|
||||
*/
|
||||
export function scopedPopulate(path: string, select: string, businessId: string | Types.ObjectId) {
|
||||
return { path, select, match: { businessId } };
|
||||
}
|
||||
Reference in New Issue
Block a user