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:
@@ -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({
|
||||
|
||||
@@ -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,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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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 });
|
||||
}
|
||||
|
||||
@@ -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,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 });
|
||||
|
||||
Reference in New Issue
Block a user