Skip to main content

Multi-Tenancy Explained

Multi tenancy is the architectural decision that shapes everything else in Dashify. This page explains, slowly, what it means, why it matters, and how the platform actually keeps every organisation's data separate.

The shopping mall metaphor

Imagine a shopping mall.

The mall is one building. It has one set of escalators, one parking lot, one heating system, one security team. Inside it, there are dozens of shops. Each shop is a separate business, different staff, different stock, different cash register, different customers. Shop A's customers cannot wander into Shop B's stockroom. Shop B cannot ring up a sale on Shop A's till. The mall management can see all the shops at once for maintenance, but the shopkeepers can only see their own shop.

Dashify is the mall. Every customer organisation is a shop. They share the infrastructure, the same database server, the same API, the same AI assistant, but they do not share data. Ever.

That is multi tenancy in one paragraph.

The three flavours of multi tenancy

There is more than one way to build multi tenancy. Three are common, and Dashify uses the third.

Database-per tenant. Each organisation gets its own database. Strong isolation but expensive to operate, every new tenant means a new database to monitor, back up, and migrate.

Schema-per tenant. Shared database, but each tenant gets its own set of tables. Less expensive than the first, but still a lot of moving parts.

Shared schema. One database, one set of collections, every document tagged with a tenantId foreign key. Cheap to operate, adding a tenant is just inserting a row in the organisations collection. The danger is obvious: a single buggy query that forgets the tenant filter could leak Org A's data to Org B.

Dashify uses the shared-schema approach because it scales linearly with tenants and keeps operations simple. To remove the danger, the platform makes "forget the tenant filter" impossible.

How the wall is built

The defence is layered. No single mechanism is asked to carry the whole weight.

Layer 1, authentication

Before any request can reach business logic, it has to prove who is making it. The cookie is verified, the session is loaded from Redis, and the user document is loaded from MongoDB. If any step fails, the request never goes further.

Layer 2, tenant middleware

Once we know who the user is, we know which tenant they belong to: user.tenantId. The tenant middleware reads it and stashes it in a special place called async-local-storage.

Async-local-storage (ALS for short) is a Node.js feature that lets you attach data to "the current request", including across await calls. It is conceptually like a global variable that is private to one request and disappears when the request ends. Used carefully, it makes the tenant id available anywhere in the code that handles this request, without anyone having to explicitly thread it through.

Layer 3, Mongoose plugin

Every Mongoose schema in Dashify has a single small plugin attached. The plugin hooks into every find, update, count, and delete operation, looks up the current tenant from async-local-storage, and adds { tenantId: <currentTenantId> } to the query filter. Always.

This is the magic. The developer who writes WorkItem.find({ status: 'open' }) does not see, type, or remember the tenant filter, but the database query that actually runs is WorkItem.find({ status: 'open', tenantId: <currentTenantId> }).

There is no way to "forget" the filter, because the developer never wrote it in the first place. It is added automatically, every time.

The few queries that are legitimately cross tenant (the SuperAdmin viewing all organisations, for example) use a special escape hatch, runWithoutTenantScope(() => …), which is grep-able, code-reviewed, and shows up in audit logs.

Layer 4, RBAC permission check

Even within a tenant, not every user can do everything. A regular user cannot delete an organisation; an org admin cannot create a new tenant. The RBAC layer (covered in detail in the Security section) sits on top of the tenant filter. So a request must pass both gates: "you belong to this tenant" and "your role allows this action".

Layer 5, audit logging

Anything that mutates state writes a row to the audit log: who did it, what they did, which tenant, which IP, when. If a wall is somehow breached, the audit log tells us exactly when and how.

The SuperAdmin tier

There is one person who can see across tenants: the SuperAdmin. The SuperAdmin role is reserved for the platform operator (you, the person running Dashify) and is used to manage tenants themselves, provisioning new organisations, configuring their packages, viewing global health.

SuperAdmin queries explicitly opt out of the tenant filter using the escape hatch mentioned above. There are not many of them, they are concentrated in a small set of routes under /superadmin, and every one of them is audit-logged.

In day to day operations the SuperAdmin tier is rarely touched.

The three tiers, on one diagram

A regular user sees only their own tenant, they don't even know other tenants exist. The org admin of a tenant manages users and settings inside that one tenant. The SuperAdmin stands above all of them and manages the platform itself.

The Security section has a dedicated page on this hierarchy, read it after this one.

What about packages and feature gating?

In addition to roles and permissions, every tenant subscribes to a package (think: Free, Standard, Pro, Enterprise). The package controls which menu items and which features are visible to that tenant. The Knowledge Base might be Standard-and-up; the AI assistant might be Pro-and-up; SCIM provisioning might be Enterprise-only.

Feature gating is enforced on both the client (menu items hidden) and the server (the matching API routes refuse the request). Trying to circumvent the gate by guessing API URLs hits the server side check and returns a 403.

What if a developer makes a mistake?

This is the most important question. A few things keep mistakes from becoming breaches:

  1. The tenant filter is automatic. A developer cannot forget it because they never wrote it.
  2. The escape hatch is loud. runWithoutTenantScope is grep-able, reviewed, and audit-logged when used.
  3. Integration tests spin up real MongoDB instances and assert that one tenant's queries cannot return another tenant's data.
  4. Code review requires a second pair of eyes for every change to the data layer.
  5. The audit log would expose any leak after the fact, even if it slipped through.

No defence is perfect. But every layer raises the cost of an accident, and the layers compound.

Key takeaways

  • Multi tenancy is one platform serving many organisations, with strict separation between them.
  • Dashify uses a shared-schema model, one database, every document tagged with tenantId.
  • The tenant filter is injected automatically by a Mongoose plugin reading from async-local-storage. Developers never write it by hand.
  • The platform has three tiers: SuperAdmin to Org Admin to User.
  • Feature gating by package is enforced both on the client (UI) and on the server (API).
  • Mistakes are made hard by automation, made loud by audit logs, and caught early by integration tests.