Skip to main content

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.js is 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/:id was using Leg.findById(req.params.id) and so returned a leg from any tenant whose id happened to be guessed. Fixed by switching to Leg.findOne({ _id, parentCompany }). The corresponding update path (updateById) was switched to findOneAndUpdate with the same filter as a defensive change.

Items deferred to follow-up audits (tracked in the pre-sale plan):

  • addAccessoryToLeg in src/controllers/legs/functions.js is dead code (exported but not wired to any route) and uses findById. 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.params across all controllers should be re-run before launch and any matches hardened the same way.