Files
denis_ireri 83ec5ecd36 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>
2026-10-12 11:52:35 +03:00

140 lines
4.2 KiB
TypeScript

import { randomUUID } from "crypto";
import mongoose from "mongoose";
import { NextRequest } from "next/server";
import { Business } from "@/lib/server/models/Business";
import { Branch } from "@/lib/server/models/Branch";
import { User, UserRole } from "@/lib/server/models/User";
import { Product } from "@/lib/server/models/Product";
import { signAccessToken } from "@/lib/server/jwt";
import { ACCESS_COOKIE } from "@/lib/server/auth";
import { crossTenant } from "@/lib/server/tenantGuard";
let phoneSeq = 0;
function nextPhone(): string {
phoneSeq += 1;
return "2547" + String(10_000_000 + phoneSeq).slice(-8);
}
export interface Actor {
id: string;
businessId: string;
role: UserRole;
branchId: string | null;
email: string;
}
export interface Tenant {
businessId: string;
owner: Actor;
branchA: string;
branchB: string;
managerA: Actor;
cashierA: Actor;
cashierB: Actor;
water20: string;
water5: string;
}
async function makeUser(
businessId: string,
role: UserRole,
branchId: string | null,
label: string
): Promise<Actor> {
const email = `${label}-${randomUUID().slice(0, 8)}@aquaflow.test`;
const user = await User.create({
businessId,
name: label,
role,
branchId,
phoneNumber: nextPhone(),
email,
passwordHash: "$2a$12$abcdefghijklmnopqrstuuJ0dBz7m6Y8yCjvC7vQ7hZqXbY8yCjvC", // never used to log in
});
return { id: user._id.toString(), businessId, role, branchId, email };
}
/** A complete tenant: owner, two branches, a manager, a cashier per branch, two products. */
export async function makeTenant(name = "Tenant"): Promise<Tenant> {
const business = await Business.create({
name,
ownerId: new mongoose.Types.ObjectId(),
tier: "ENTERPRISE",
subscriptionStatus: "ACTIVE",
});
const businessId = business._id.toString();
const owner = await makeUser(businessId, "SUPER_ADMIN", null, name + "-owner");
business.ownerId = new mongoose.Types.ObjectId(owner.id);
await business.save();
const a = await Branch.create({ businessId, branchName: name + " A", locationCity: "Nairobi" });
const b = await Branch.create({ businessId, branchName: name + " B", locationCity: "Thika" });
const managerA = await makeUser(businessId, "BRANCH_MANAGER", a._id.toString(), name + "-mgrA");
const cashierA = await makeUser(businessId, "DELIVERY", a._id.toString(), name + "-cashA");
const cashierB = await makeUser(businessId, "DELIVERY", b._id.toString(), name + "-cashB");
const [p20, p5] = await Product.insertMany([
{ businessId, label: "20L Jerrycan", sizeLiters: 20, unitPrice: 200, sortOrder: 1 },
{ businessId, label: "5L Jerrycan", sizeLiters: 5, unitPrice: 60, sortOrder: 2 },
]);
return {
businessId,
owner,
branchA: a._id.toString(),
branchB: b._id.toString(),
managerA,
cashierA,
cashierB,
water20: p20._id.toString(),
water5: p5._id.toString(),
};
}
export function tokenFor(actor: Actor, deviceId = "00000000-0000-4000-8000-000000000001"): string {
return signAccessToken({
sub: actor.id,
businessId: actor.businessId,
role: actor.role,
branchId: actor.branchId,
deviceId,
});
}
export function req(
method: string,
path: string,
opts: { as?: Actor; body?: unknown; cookies?: Record<string, string> } = {}
): NextRequest {
const cookies: Record<string, string> = { ...(opts.cookies ?? {}) };
if (opts.as) cookies[ACCESS_COOKIE] = tokenFor(opts.as);
const cookieHeader = Object.entries(cookies)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
return new NextRequest(new URL(path, "http://localhost"), {
method,
headers: {
"content-type": "application/json",
...(cookieHeader ? { cookie: cookieHeader } : {}),
},
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
});
}
export function params<T extends Record<string, string>>(p: T) {
return { params: p };
}
export async function json<T = any>(res: Response): Promise<{ status: number; body: T }> {
return { status: res.status, body: (await res.json()) as T };
}
export function uuid(): string {
return randomUUID();
}
/** Tests may inspect and arrange any business's data directly. */
export function db<T>(fn: () => PromiseLike<T>): Promise<T> {
return crossTenant("test inspects the database directly", fn);
}