83ec5ecd36
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>
57 lines
2.3 KiB
TypeScript
57 lines
2.3 KiB
TypeScript
// Platform (operator) access control. The platform operator is YOU — the
|
|
// person running this SaaS for many client businesses — not a client's
|
|
// business owner. Platform admins are identified by email via the
|
|
// PLATFORM_ADMIN_EMAILS env var (comma-separated). The JWT carries no
|
|
// email, so callers must re-read the User from the DB and pass its email
|
|
// here.
|
|
//
|
|
// We read process.env directly (not the zod-validated getEnv) so the
|
|
// platform check never couples to unrelated required env like the Daraja
|
|
// M-Pesa keys.
|
|
|
|
export function platformAdminEmails(): string[] {
|
|
const raw = process.env.PLATFORM_ADMIN_EMAILS ?? "";
|
|
return raw
|
|
.split(",")
|
|
.map((e) => e.trim().toLowerCase())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
export function isPlatformAdminEmail(email: string | null | undefined): boolean {
|
|
if (!email) return false;
|
|
return platformAdminEmails().includes(email.toLowerCase());
|
|
}
|
|
|
|
import type { NextRequest } from "next/server";
|
|
import { getAuth } from "@/lib/server/auth";
|
|
import { User } from "@/lib/server/models/User";
|
|
|
|
/**
|
|
* Whether this account is a platform operator. Two conditions, because
|
|
* emails are only unique within a business: any owner can create a staff
|
|
* account with any email in their own business, so an email match alone
|
|
* must never grant cross-tenant access. The account must also carry the
|
|
* platformAdmin flag, which the app never sets (seed/migration only).
|
|
* Removing an email from PLATFORM_ADMIN_EMAILS revokes access at once.
|
|
*/
|
|
export function isPlatformAdmin(user: { email?: string | null; platformAdmin?: boolean | null }): boolean {
|
|
return user.platformAdmin === true && isPlatformAdminEmail(user.email);
|
|
}
|
|
|
|
/**
|
|
* Resolve and authorize the current request as a platform operator.
|
|
* Returns the authenticated User's id/email on success, or null if the
|
|
* caller is not a platform admin. The DB must already be connected.
|
|
*/
|
|
export async function requirePlatformAdmin(
|
|
req: NextRequest
|
|
): Promise<{ userId: string; email: string } | null> {
|
|
const auth = getAuth(req);
|
|
if (!auth) return null;
|
|
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 };
|
|
} |