Building multi-tenant RBAC for a clinic SaaS with Next.js and MongoDB

How ClinicPro keeps clinics apart in one MongoDB database: a fail-closed Mongoose tenant plugin, all/read/self RBAC scopes, one guarded server-action pipeline and not-found-not-forbidden.

DSDilawar Singh 6 min read 1 views
Building multi-tenant RBAC for a clinic SaaS with Next.js and MongoDB

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 $lookup or $group from 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 _id alone, goes through a scopedPopulate helper that sets the option, because the parent query was already scoped.
  • It surfaces as a bug. The action error mapper logs TenantScopeError on 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 clinicId is in the filter, not that it belongs to the caller. The wrapper's resolved ctx covers that. A stricter design would inject the tenant from async context.
  • $or isn't treated as scoped. Only $and is searched, so a top-level $or needs a clinicId beside 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.

DS

Written by

Dilawar Singh

CTO, DEV3 Technologies · ClinicPro, Inventra & WorkPro

Dilawar Singh is the CTO of DEV3 Technologies. He leads the engineering of DEV3’s own products — ClinicPro, Inventra and WorkPro — which are built, sold and supported by the DEV3 team from Mohali, India.

Keep reading

All articles