Multi-Tenant Query Isolation
Status: Living document. Updated 2026-04-23 after the pre-sale readiness audit. The cross-tenant regression test suite at
attunelogic-api/tests/security/tenantIsolation.test.jsis the source-of-truth checker; this document explains the policy.
Policyβ
Every Mongoose query on a tenant-scoped collection MUST filter by
parentCompany. There are no exceptions for "internal" lookups β even a
read whose result is not directly returned to the caller must filter by
tenant, because the result is often used to authorize a downstream action.
Tenant-scoped collections include (non-exhaustive): Job, Leg, Client,
Invoice, Schedule, Equipment, Driver, Technician, User,
Customer settings, Route, Approval.
The token-bound tenant id is available at:
const { parentCompany } = req.payload;
verifyParent middleware proves that req.payload.parentCompany matches
the HEADER_PARENT_COMPANY request header before the controller runs, so
this value is the authoritative tenant for the request.
Patternsβ
Lookup by idβ
Bad β returns any tenant's record:
const leg = await Leg.findById(req.params.id);
Good β scoped to the caller's tenant:
const { parentCompany } = req.payload;
const leg = await Leg.findOne({ _id: req.params.id, parentCompany });
Update by idβ
Bad β mutates any tenant's record:
await Leg.findByIdAndUpdate(req.params.id, req.body, { new: true });
Good:
await Leg.findOneAndUpdate(
{ _id: req.params.id, parentCompany },
req.body,
{ new: true },
);
Listingβ
Bad β leaks across tenants:
await Job.find({ status: "open" });
Good:
await Job.find({ status: "open", parentCompany });
Aggregationβ
The $match stage must include parentCompany and ideally be the very
first stage so MongoDB can use the compound index. Casting the value to
ObjectId is required when the schema field is an ObjectId.
const { Types } = require("mongoose");
await Leg.aggregate([
{ $match: { parentCompany: new Types.ObjectId(parentCompany) } },
// ... rest of pipeline ...
]);
Error responsesβ
When a record is not found within the caller's tenant, return 404 Not Found, not 403 Forbidden. Returning 403 leaks the existence of the id in another tenant.
if (!leg) {
return res.status(404).json({
status: "error",
code: 404,
message: "Leg not found",
});
}
Regression testβ
tests/security/tenantIsolation.test.js provisions two tenants and proves
that a token from tenant A cannot read tenant B's resources via the
getById endpoints for jobs, clients, and legs. Add a case to this
file every time a new tenant-scoped getById endpoint ships.
Why a focused suite instead of one assertion per endpoint:
- Listing endpoints are already well-tested by their feature suites.
- The single-record disclosure path is the highest-impact data-leak surface and the easiest to regress accidentally during a refactor.
Audit notes (2026-04-23)β
The first pass of the audit found one real cross-tenant leak:
GET /api/v1/legs/:idwas usingLeg.findById(req.params.id)and so returned a leg from any tenant whose id happened to be guessed. Fixed by switching toLeg.findOne({ _id, parentCompany }). The corresponding update path (updateById) was switched tofindOneAndUpdatewith the same filter as a defensive change.
Items deferred to follow-up audits (tracked in the pre-sale plan):
addAccessoryToLeginsrc/controllers/legs/functions.jsis dead code (exported but not wired to any route) and usesfindById. To be deleted as part of the Phase 2 stub triage rather than fixed in place.- A wider grep for
findById(req.params/findByIdAndUpdate(req.paramsacross all controllers should be re-run before launch and any matches hardened the same way.