Tenant Isolation
If there is one rule Dashify will never break, it is this: no organisation's data may ever appear on another organisation's screen. Not by accident, not by misconfiguration, not by a clever attacker, not because a developer was tired on a Friday afternoon.
The Multi-Tenancy page introduced the principle. This page is the deep dive on the mechanism.
The shared-schema problem
Every document in the database carries a tenantId — the id of the organisation it belongs to. To respect tenant isolation, every database query must include tenantId: <currentTenantId> in its filter.
The naive way is for developers to write that filter by hand:
"Always remember to add
tenantIdto every query, please."
That is doomed. There are thousands of queries in a real platform. A single forgotten filter is a data leak. We need a defence that does not depend on anyone remembering anything.
Async-Local-Storage carries the tenant
When a request arrives at the API, a tiny middleware runs first. It reads the user from the session, plucks user.tenantId, and stores it in async-local-storage for the duration of this request.
Async-local-storage is a Node.js feature that gives you a "context" attached to the current request that survives across await calls. From any code in any file that runs while this request is being handled, you can read the current tenant id without having to thread it through function arguments.
Crucially, async-local-storage is per-request. Two simultaneous requests from two different tenants each see their own tenant id, even though they are running on the same Node process at the same time.
The Mongoose plugin
Every Mongoose schema in Dashify has the same plugin attached at definition time. The plugin hooks into every query operation — find, findOne, count, update, delete, all of them — and does one thing: it reads the current tenant id from async-local-storage and adds it to the query filter.
So if a developer writes:
"Find all open work items."
The query that actually runs is:
"Find all open work items where tenantId equals the current tenant."
The developer never wrote the second clause. The plugin added it.
What the developer sees
The developer's mental model is simple: every query is automatically scoped to the current tenant. They can write business logic without ever thinking about tenancy, and the platform handles it.
The developer wrote four words. Mongoose ran six.
The escape hatch
Some queries must be cross-tenant. The SuperAdmin viewing the list of all organisations. A platform-wide health check counting users across every tenant. A backup script. These cases are real and they are rare.
For them, there is a single escape function — runWithoutTenantScope(() => ...) — that temporarily disables the plugin for the code inside it. Three things make this safe:
- The function name is honest. It is named "without tenant scope" so it cannot be invoked accidentally.
- It is grep-able. Any developer can find every place it is used in seconds.
- It is audit-logged. Every cross-tenant operation writes an audit log entry.
In practice, fewer than ten places in the codebase use the escape hatch. They are all in the SuperAdmin module, all reviewed, all logged.
Tests that watch the wall
Forgetting to add the plugin to a new schema would defeat the whole system. Two safeguards prevent that:
Convention. Every schema is created by a defineModel factory that adds the plugin automatically. Skipping the factory is unusual enough to draw a code review comment.
Integration tests. A test suite seeds two tenants, logs in as a user from tenant A, runs every important read endpoint, and asserts that no document belonging to tenant B ever appears in the response. If the suite fails, the build fails.
What a leak would look like (and why it would be caught fast)
Suppose a developer writes a brand new query through a brand new helper that bypasses Mongoose entirely (perhaps by talking to the MongoDB driver directly). The plugin does not run. The query is not scoped.
Three things still catch it:
- The integration test suite — if the new endpoint is reachable, the test will see another tenant's data come back and fail.
- The audit log — every read of sensitive data writes an audit entry. A user reading documents whose tenant id does not match their own would stand out immediately.
- Code review — bypassing Mongoose for a database read would draw an immediate question.
No defence is perfect. But the cost of an accident is high enough that there is reason to be careful, and the layers compound.
Cross-tenant data flows that are allowed
A few things in Dashify are intentionally cross-tenant. They are documented here so the rule does not feel mysterious.
- The marketing-tier shared chat (when enabled) — a SuperAdmin can create channels visible across tenants. The Chat models distinguish "tenant channels" from "global channels" and the read path is membership-based, not tenant-based.
- Package definitions — every tenant reads from the same
packagescollection because packages are platform-wide. - System notifications sent by the SuperAdmin can target multiple tenants.
These exceptions are explicit, narrow, and visible.
Key takeaways
- Tenant isolation is the most important rule in Dashify.
- The tenant id rides on async-local-storage for the duration of every request.
- A Mongoose plugin automatically injects
tenantIdinto every query, so developers never have to remember to. - The few cross-tenant queries use a clearly-named, grep-able, audit-logged escape hatch.
- Integration tests verify the wall is intact on every build.