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:
2026-10-12 11:52:35 +03:00
parent 21d530cd6d
commit 83ec5ecd36
43 changed files with 447 additions and 77 deletions
+5 -2
View File
@@ -9,6 +9,7 @@ import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs }
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
import { isPlatformAdmin } from "@/lib/server/platform";
import { objectId } from "@/lib/server/validation";
import { crossTenant } from "@/lib/server/tenantGuard";
import { getEnv } from "@/lib/server/env";
import { tenantAccessError } from "@/lib/server/subscription";
@@ -54,7 +55,9 @@ export async function POST(req: NextRequest) {
// several businesses, each with its own password.
const filter: Record<string, unknown> = { email: email.toLowerCase(), isActive: true };
if (businessId) filter.businessId = businessId;
const candidates = await User.find(filter).select("+passwordHash");
const candidates = await crossTenant("login looks an email up in every business", () =>
User.find(filter).select("+passwordHash").exec()
);
if (candidates.length === 0) {
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
}
@@ -149,7 +152,7 @@ export async function POST(req: NextRequest) {
deviceId,
});
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
const branch = user.branchId ? await Branch.findOne({ _id: user.branchId, businessId: user.businessId }).lean() : null;
const business = await Business.findById(user.businessId).lean();
const res = NextResponse.json({
+2 -2
View File
@@ -16,11 +16,11 @@ export async function GET(req: NextRequest) {
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const user = await User.findById(auth.sub).lean();
const user = await User.findOne({ _id: auth.sub, businessId: auth.businessId }).lean();
if (!user || !user.isActive) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
const branch = user.branchId ? await Branch.findOne({ _id: user.branchId, businessId: user.businessId }).lean() : null;
const business = await Business.findById(user.businessId).lean();
const tier = (business?.tier as TierId) ?? "STARTER";
+4 -1
View File
@@ -4,6 +4,7 @@ import { User } from "@/lib/server/models/User";
import { UserSession } from "@/lib/server/models/UserSession";
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
import { tenantAccessError } from "@/lib/server/subscription";
import { crossTenant } from "@/lib/server/tenantGuard";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
export const runtime = "nodejs";
@@ -24,7 +25,9 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Session expired. Please log in again." }, { status: 401 });
}
const user = await User.findById(session.userId);
const user = await crossTenant("a refresh session identifies its user before the business is known", () =>
User.findById(session.userId).exec()
);
if (!user || !user.isActive) {
await UserSession.deleteOne({ _id: session._id });
return NextResponse.json({ error: "Account no longer active" }, { status: 401 });
+1 -1
View File
@@ -94,7 +94,7 @@ export async function POST(req: NextRequest) {
await owner.save();
} catch (e) {
await Business.deleteOne({ _id: businessId }).catch(() => {});
await User.deleteOne({ _id: ownerId }).catch(() => {});
await User.deleteOne({ _id: ownerId, businessId }).catch(() => {});
const msg = (e as Error).message || "";
if (msg.includes("duplicate key")) {
return NextResponse.json({ error: "An account with this email or phone already exists." }, { status: 409 });
+1 -1
View File
@@ -106,7 +106,7 @@ export async function PUT(req: NextRequest) {
}
const row = await BranchPricing.findOneAndUpdate(
{ branchId, productId },
{ businessId: auth.businessId, branchId, productId },
{ businessId: auth.businessId, unitPrice, updatedBy: auth.sub },
{ upsert: true, new: true, setDefaultsOnInsert: true }
).lean();
+1 -1
View File
@@ -106,7 +106,7 @@ export async function POST(req: NextRequest) {
{ status: 201 }
);
} catch (err: any) {
await Branch.deleteOne({ _id: branch._id }).catch(() => {});
await Branch.deleteOne({ _id: branch._id, businessId }).catch(() => {});
if (err?.code === 11000) {
return NextResponse.json({ error: "Someone in your business already uses this phone number or email." }, { status: 409 });
}
+3 -2
View File
@@ -5,6 +5,7 @@ import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense";
import { Branch } from "@/lib/server/models/Branch";
import { objectId } from "@/lib/server/validation";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { scopedPopulate } from "@/lib/server/tenantGuard";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -36,8 +37,8 @@ export async function GET(req: NextRequest) {
}
const expenses = await Expense.find(filter)
.populate("branchId", "branchName")
.populate("recordedBy", "name")
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
.sort({ createdAt: -1 })
.limit(500)
.lean();
+1 -1
View File
@@ -43,7 +43,7 @@ export async function POST(req: NextRequest) {
const date = parsed.data.date ?? todayNairobi();
const row = await DailyInventory.findOneAndUpdate(
{ branchId, productId: parsed.data.productId, date },
{ businessId: auth.businessId, branchId, productId: parsed.data.productId, date },
{ morningDispatched: parsed.data.morningDispatched, recordedBy: auth.sub, businessId: auth.businessId },
{ upsert: true, new: true, setDefaultsOnInsert: true }
);
+1 -1
View File
@@ -42,7 +42,7 @@ export async function POST(req: NextRequest) {
const date = parsed.data.date ?? todayNairobi();
const row = await DailyInventory.findOneAndUpdate(
{ branchId, productId: parsed.data.productId, date },
{ businessId: auth.businessId, branchId, productId: parsed.data.productId, date },
{ eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth.sub, businessId: auth.businessId },
{ upsert: true, new: true, setDefaultsOnInsert: true }
);
+4 -1
View File
@@ -5,13 +5,16 @@ import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User";
import { Business } from "@/lib/server/models/Business";
import { hashInviteToken } from "@/lib/server/invites";
import { crossTenant } from "@/lib/server/tenantGuard";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
async function findInvitee(token: string) {
if (!token || token.length > 100) return null;
const user = await User.findOne({ inviteTokenHash: hashInviteToken(token), isActive: true });
const user = await crossTenant("an invite token identifies its account before sign-in", () =>
User.findOne({ inviteTokenHash: hashInviteToken(token), isActive: true }).exec()
);
if (!user || !user.inviteExpiresAt || user.inviteExpiresAt.getTime() < Date.now()) return null;
return user;
}
+4 -1
View File
@@ -3,6 +3,7 @@ import { connectDb } from "@/lib/server/db";
import { isMpesaEnabled } from "@/lib/server/env";
import { Transaction } from "@/lib/server/models/Transaction";
import { recordSaleMovement } from "@/lib/server/stock";
import { crossTenant } from "@/lib/server/tenantGuard";
import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog";
export const runtime = "nodejs";
@@ -69,7 +70,9 @@ export async function POST(req: NextRequest) {
mpesaReceiptNumber = receiptItem ? String(receiptItem.Value) : null;
}
const txn = await Transaction.findOne({ "mpesaDetails.checkoutRequestId": checkoutRequestId });
const txn = await crossTenant("the payment provider's callback is matched by checkout id", () =>
Transaction.findOne({ "mpesaDetails.checkoutRequestId": checkoutRequestId }).exec()
);
if (!txn) {
await MpesaCallbackLog.create({
@@ -26,7 +26,7 @@ export async function GET(req: NextRequest, { params }: { params: { transactionI
const result = await queryStkStatus(txn.mpesaDetails.checkoutRequestId);
const code = Number(result.ResultCode);
if (!Number.isNaN(code) && code !== 1032) {
const fresh = await Transaction.findOne({ _id: txn._id, paymentStatus: "PENDING" });
const fresh = await Transaction.findOne({ _id: txn._id, businessId: auth.businessId, paymentStatus: "PENDING" });
if (fresh) {
fresh.paymentStatus = code === 0 ? "SUCCESS" : "FAILED";
fresh.mpesaDetails.resultCode = code;
+16 -12
View File
@@ -4,6 +4,7 @@ import { Business } from "@/lib/server/models/Business";
import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch";
import { requirePlatformAdmin } from "@/lib/server/platform";
import { crossTenant } from "@/lib/server/tenantGuard";
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
export const runtime = "nodejs";
@@ -23,23 +24,26 @@ export async function GET(req: NextRequest) {
const businesses = await Business.find({}).sort({ createdAt: -1 }).lean();
// Usage counts per business in two grouped queries instead of N+1.
const [userCounts, branchCounts] = await Promise.all([
User.aggregate<{ _id: unknown; count: number }>([
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
]),
Branch.aggregate<{ _id: unknown; count: number }>([
{ $match: { isActive: { $ne: false } } },
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
]),
]);
const [userCounts, branchCounts] = await crossTenant("the platform console counts every business", () =>
Promise.all([
User.aggregate<{ _id: unknown; count: number }>([{ $group: { _id: "$businessId", count: { $sum: 1 } } }]).exec(),
Branch.aggregate<{ _id: unknown; count: number }>([
{ $match: { isActive: { $ne: false } } },
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
]).exec(),
])
);
const userMap = new Map(userCounts.map((u) => [String(u._id), u.count]));
const branchMap = new Map(branchCounts.map((b) => [String(b._id), b.count]));
// Owner contact lookup.
const ownerIds = businesses.map((b) => b.ownerId);
const owners = await User.find({ _id: { $in: ownerIds } })
.select("name email phoneNumber")
.lean();
const owners = await crossTenant("the platform console lists every business's owner", () =>
User.find({ _id: { $in: ownerIds } })
.select("name email phoneNumber")
.lean()
.exec()
);
const ownerMap = new Map(owners.map((o) => [String(o._id), o]));
const rows = businesses.map((b) => {
+3 -2
View File
@@ -7,6 +7,7 @@ import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { objectId } from "@/lib/server/validation";
import { recordMovement } from "@/lib/server/stock";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { scopedPopulate } from "@/lib/server/tenantGuard";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -36,8 +37,8 @@ export async function GET(req: NextRequest) {
}
const purchases = await Purchase.find(filter)
.populate("branchId", "branchName")
.populate("recordedBy", "name")
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
.sort({ createdAt: -1 })
.limit(500)
.lean();
+1 -1
View File
@@ -64,7 +64,7 @@ export async function POST(req: NextRequest) {
const lockedBranch = branchId;
try {
const quantity = await withStockLock(auth.businessId, lockedBranch, data.productId, async () => {
const already = await StockMovement.exists({ type: "ADJUSTMENT", refId });
const already = await StockMovement.exists({ type: "ADJUSTMENT", refId, businessId: auth.businessId });
if (!already) {
const before = await stockOf(auth.businessId, lockedBranch, data.productId);
const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity;
+4 -3
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { StockMovement } from "@/lib/server/models/StockMovement";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { scopedPopulate } from "@/lib/server/tenantGuard";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -23,9 +24,9 @@ export async function GET(req: NextRequest) {
const movements = await StockMovement.find(filter)
.sort({ createdAt: -1 })
.limit(limit)
.populate("branchId", "branchName")
.populate("productId", "label unit")
.populate("recordedBy", "name")
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
.populate(scopedPopulate("productId", "label unit", auth.businessId))
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
.lean();
return NextResponse.json({ movements });
}
+5 -4
View File
@@ -5,6 +5,7 @@ import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { Product } from "@/lib/server/models/Product";
import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { scopedPopulate } from "@/lib/server/tenantGuard";
import { objectId } from "@/lib/server/validation";
import { recordMovement, stockOf, StockBusyError, withStockLock } from "@/lib/server/stock";
@@ -27,10 +28,10 @@ export async function GET(req: NextRequest) {
const transfers = await StockTransfer.find(filter)
.sort({ createdAt: -1 })
.limit(100)
.populate("fromBranchId", "branchName")
.populate("toBranchId", "branchName")
.populate("productId", "label unit")
.populate("recordedBy", "name")
.populate(scopedPopulate("fromBranchId", "branchName", auth.businessId))
.populate(scopedPopulate("toBranchId", "branchName", auth.businessId))
.populate(scopedPopulate("productId", "label unit", auth.businessId))
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
.lean();
return NextResponse.json({ transfers });
}
+2 -4
View File
@@ -167,10 +167,8 @@ export async function POST(req: NextRequest) {
} catch (err: unknown) {
if ((err as { code?: number })?.code !== 11000) throw err;
// Already recorded by an earlier attempt: reuse it.
const existing = await Transaction.findOne({ clientUuid: `${sale.clientUuid}:${index}` });
if (!existing || existing.businessId.toString() !== auth.businessId) {
return reject("This sale id is already in use.");
}
const existing = await Transaction.findOne({ clientUuid: `${sale.clientUuid}:${index}`, businessId: auth.businessId });
if (!existing) return reject("This sale id is already in use.");
txn = existing;
}
// Idempotent, and also repairs a line whose stock movement was not
+2 -1
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { authorize, branchParam } from "@/lib/server/auth";
import { scopedPopulate } from "@/lib/server/tenantGuard";
import { startOfTodayNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs";
@@ -26,7 +27,7 @@ export async function GET(req: NextRequest) {
const transactions = await Transaction.find(filter)
.sort({ createdAt: -1 })
.limit(limit)
.populate("branchId", "branchName")
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
.lean();
return NextResponse.json({ transactions });
+3
View File
@@ -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);
+3
View File
@@ -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);
+3
View File
@@ -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);
+5
View File
@@ -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);
+3
View File
@@ -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);
+3
View File
@@ -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);
+3
View File
@@ -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);
+3
View File
@@ -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);
+3
View File
@@ -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);
+3 -1
View File
@@ -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 };
+7 -3
View File
@@ -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,
+114
View File
@@ -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 } };
}