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>
51 lines
2.0 KiB
TypeScript
51 lines
2.0 KiB
TypeScript
import dotenv from "dotenv";
|
|
import path from "path";
|
|
dotenv.config({ path: path.resolve(__dirname, "../.env.local") });
|
|
|
|
import mongoose from "mongoose";
|
|
import { connectDb } from "../src/lib/server/db";
|
|
import { User } from "../src/lib/server/models/User";
|
|
import { platformAdminEmails } from "../src/lib/server/platform";
|
|
import { crossTenant } from "../src/lib/server/tenantGuard";
|
|
|
|
/**
|
|
* One-off, safe to re-run. Moves user identity from platform-wide to
|
|
* per-business uniqueness:
|
|
*
|
|
* 1. Marks today's platform operators. Until now an operator was anyone
|
|
* whose email was in PLATFORM_ADMIN_EMAILS; emails were unique, so
|
|
* that was one account each. From now on emails are only unique per
|
|
* business, so access also needs the platformAdmin flag on the account.
|
|
* Run this BEFORE dropping the old index, while each listed email
|
|
* still matches exactly one account.
|
|
* 2. Syncs the User indexes: drops the old unique `email_1` and
|
|
* `phoneNumber_1`, creates unique (businessId, email) and
|
|
* (businessId, phoneNumber), and a plain email lookup index.
|
|
*
|
|
* npm run migrate:identity
|
|
*/
|
|
async function main() {
|
|
await connectDb();
|
|
|
|
for (const email of platformAdminEmails()) {
|
|
const accounts = await User.find({ email }).select("_id").lean();
|
|
if (accounts.length === 1) {
|
|
await User.updateOne({ _id: accounts[0]._id }, { $set: { platformAdmin: true } });
|
|
console.log(`Platform operator: ${email}`);
|
|
} else if (accounts.length === 0) {
|
|
console.warn(`No account for ${email} - create it with npm run seed.`);
|
|
} else {
|
|
console.warn(`${email} has ${accounts.length} accounts - set platformAdmin on the right one by hand.`);
|
|
}
|
|
}
|
|
|
|
const dropped = await User.syncIndexes();
|
|
console.log(`User indexes synced. Dropped: ${dropped.length ? dropped.join(", ") : "none"}.`);
|
|
await mongoose.disconnect();
|
|
}
|
|
|
|
crossTenant("identity migration spans every business", main).catch((err) => {
|
|
console.error("migration failed:", err.message);
|
|
process.exit(1);
|
|
});
|