In a shared-database SaaS, the bug that keeps you up at night is one missing clinicId filter. It usually arrives in a quick findById added during a late fix. In a clinic product, that means one practice can see another practice's patients. When we built ClinicPro, our multi-tenant clinic management platform on Next.js 15 and MongoDB, we set out to make that mistake hard to write and loud when it slips through. This post covers the layers we ended up with. The ClinicPro case study covers the product side.
One database, logical isolation
One ClinicPro installation serves many clinics from one MongoDB database. Each clinic has its own staff, branding, currency, language and patient portal. A separate database per tenant would make isolation simpler, but it makes self-hosted operations heavier, so we chose logical isolation instead.
Of the 39 Mongoose models, 24 are tenant-scoped and carry a clinicId. The other 15, such as Clinic, User, Plan, AuditLog and ExchangeRate, are global by nature. Every model is compiled through a defineModel(name, schema, { tenant }) helper. Tenant scoping is therefore declared once, in the model definition, and when tenant is true the helper attaches the plugin below.
A tenant plugin that fails closed
The plugin rejects any query on a tenant model that doesn't filter by clinicId. It hooks twelve query operations (find, count, update, delete, replace and distinct variants) and aggregate:
function hasClinicFilter(filter: Record<string, unknown> | undefined): boolean {
if (!filter) return false;
if (filter.clinicId !== undefined && filter.clinicId !== null) return true;
const and = filter.$and;
return Array.isArray(and) && and.some((f) => hasClinicFilter(f as Record<string, unknown>));
}
export function tenantPlugin(schema: Schema, opts: { modelName: string }): void {
for (const op of QUERY_OPS) {
schema.pre(op, function () {
const options = this.getOptions();
if (options.crossTenant) return;
if (!hasClinicFilter(this.getFilter() as Record<string, unknown>)) throw new TenantScopeError(opts.modelName, op);
});
}
schema.pre('aggregate', function () {
if (this.options.crossTenant) return;
const first = this.pipeline()[0] as { $match?: Record<string, unknown> } | undefined;
if (!first?.$match || !hasClinicFilter(first.$match)) throw new TenantScopeError(opts.modelName, 'aggregate');
});
}
- It fails closed. A bare
Patient.find({})throws before anything reaches MongoDB. For aggregates, the first stage must be a scoped$match. That stops a$lookupor$groupfrom running over the whole collection before scoping happens. - The escape hatch is explicit. Super Admin and system code pass
{ crossTenant: true }. A reviewer can grep for it, and nobody turns it on by accident. Populate, which looks documents up by_idalone, goes through ascopedPopulatehelper that sets the option, because the parent query was already scoped. - It surfaces as a bug. The action error mapper logs
TenantScopeErroron the server and shows the user a generic message.
An integration test checks the contract directly. Patient.find({}) and Patient.aggregate([{ $match: {} }]) must reject, and an explicit cross-tenant count must resolve.
Scoped RBAC: all, read and self
ClinicPro has 9 roles and 20 permission keys. A flat role-to-permission list wasn't expressive enough for clinical work: a nurse should be able to read an EMR but not sign it, and a patient should see only their own lab results. So each grant carries a scope. all gives full access within the clinic, read is read-only, and self covers only the signed-in person's own records. A nurse holds write_patient_emr: 'read', and a patient holds self on EMR, appointments, billing and lab results.
export function roleCan(role: Role, permission: Permission, required: Scope = 'all'): boolean {
const held = scopeOf(role, permission);
if (!held) return false;
if (required === "self") return true; // any grant includes your own records
if (required === "read") return held === "read" || held === "all";
return held === "all";
}
The rule that matters is that self never satisfies read. A patient's grant can't be stretched into browsing other people's records. The matrix lives in a client-safe types file. The UI calls can() to hide buttons and the server calls assertPermission() to enforce.
A self grant only gets the caller past the gate, so the handler still has to check ownership. Internal memos are an example. Every staff member holds manage_memos: 'self', and the memo actions then allow an edit only by the author or by someone with the full grant.
One guarded pipeline for every server action
Any exported function in a 'use server' file is a callable endpoint. We didn't want each action to remember auth, tenancy, permissions and validation on its own. Every mutation is built from a wrapper: tenantAction, adminAction, patientAction, publicAction, or installerAction for the setup wizard. Here is the staff pipeline (condensed; the audit object is abbreviated):
return async (raw) => {
try {
assertWritable();
await connectDB();
const ctx = await tenantOrThrow();
const perms = Array.isArray(cfg.permission) ? cfg.permission : [cfg.permission];
const allowed = perms.some((p) => {
try { assertPermission(ctx.user, p, cfg.scope); return true; } catch { return false; }
});
if (!allowed) throw new ForbiddenError();
const input = cfg.schema.parse(raw) as z.infer<S>;
const data = await cfg.handler(input, ctx);
if (cfg.audit) await audit({ clinicId: ctx.clinicId, /* actor, action, entity… */ });
runRevalidate(cfg.revalidate, input, data);
return { ok: true, data };
} catch (err) {
return toActionError(err);
}
};
The read-only check runs first. That is what lets the public demo run safely: it is read-only, with one-click demo logins for every role. After the permission check, zod parses the input. The handler then receives a ctx with clinicId already resolved, so scoping a query takes no effort. When a Super Admin "enters" a clinic, the audit log records the actor as SUPER_ADMIN→<slug>.
A wrapper only helps if everyone uses it, so npm run check:actions walks app/ and lib/ and finds every 'use server' file. It exits with an error if any export there isn't a const built from one of the wrappers. The check runs inside npm run verify, alongside the typecheck, lint and the test suite.
Not found, not forbidden
If clinic B asks for clinic A's invoice ID, the response should be identical to the one for an ID that doesn't exist. Returning "forbidden" would confirm that the record exists. Most services get this for free by loading with both keys:
const inv = await Invoice.findOne({ _id: id, clinicId: ctx.clinicId });
if (!inv) throw new NotFoundError('Invoice');
For documents loaded another way, assertSameTenant applies the same rule: a missing document and a foreign one both throw NotFoundError, and only Super Admin bypasses it.
Field-level encryption
Tenant isolation does nothing about a leaked backup. So the most sensitive fields are encrypted with AES-256-GCM: national ID, insurance policy number and medical notes on the patient, and examination and clinical notes in the EMR. They are stored as enc:v1:<iv>:<tag>:<ciphertext>, with a random 12-byte IV per write. A Mongoose setter and getter keep this transparent to the services:
export const encryptedString = {
type: String,
set: (v: string) => encryptField(v),
get: (v: string) => decryptField(v),
} as const;
Unprefixed values pass through unchanged, so legacy plaintext gets encrypted on its next save. A test reads the raw collection to confirm the stored value really is ciphertext.
Trade-offs and what we'd do differently
- The plugin checks presence, not correctness. It confirms that a
clinicIdis in the filter, not that it belongs to the caller. The wrapper's resolvedctxcovers that. A stricter design would inject the tenant from async context. $orisn't treated as scoped. Only$andis searched, so a top-level$orneeds aclinicIdbeside it.- The action check is lexical. It matches source lines, not an AST, so an unusual export style could get past it. Code review still matters.
- Encrypted fields can't be range-searched or sorted. Fields that fail to decrypt render as
[unreadable]. Next time we would design key rotation in from day one.
Conclusion
None of these layers is clever on its own. They work because they overlap. The plugin catches forgotten filters. The wrapper makes the scoped context the easy path, and the check script stops people from bypassing it. Not-found-not-forbidden stops anyone probing IDs across clinics. The behaviour is covered by 73 automated test cases across 23 files. ClinicPro's source is available on our marketplace. If you're building something similar, this is the kind of work our SaaS engineering team does.



