Skip to content
← Writing

Multi-Tenant SaaS on AdonisJS 7: Making the Safe Path the Only Path

· Christian Zanchetta

The bug that keeps SaaS founders awake

Imagine shipping a new CRM feature on Monday.

By Friday, a customer reports that one record in their workspace contains data belonging to somebody else. You search the logs. The query looks harmless. The endpoint is authenticated. The user has the right permission.

The missing piece is a tenant filter.

This is one of the most dangerous classes of bugs in a multi-tenant application, because the code can look perfectly normal. A developer writes a query, a test passes, and the application still has a path through which one workspace can see another workspace’s data.

The uncomfortable truth is that tenant isolation is usually treated as a discipline problem:

Remember to add where tenant_id = ... to every query.

That does not scale. People forget. New resources get added. Background jobs and alternative transports appear. The original author leaves the team.

FisServer started from a different question:

What if the safe path were the path the architecture made easiest to follow?

This article walks through the foundation built around that question: an AdonisJS 7 and PostgreSQL backend with tenant-scoped repositories, role-based permissions, generated resources, auditing, entitlements, a React admin surface, and an MCP server that reuses the same security model.

In this article

Multi-tenancy is more than a column

Adding tenant_id to a table is the easy part. The hard part is making sure the value is respected everywhere a record can be read or written.

There are at least four recurring failure modes:

  1. A list query forgets the tenant filter.
  2. A write trusts tenantId from the request body.
  3. A role manager edits their own role and silently grants themselves a stronger permission.
  4. A new resource emits no audit events, because its author forgot to wire them.

These bugs share a cause: important security properties are distributed across many call sites. The more places a developer has to remember the rule, the less reliable the rule becomes.

FisServer therefore treats a few properties as invariants. They are not the only security controls in the application, but they are the ones the rest of the design is built around:

  • tenant-scoped reads and writes go through one repository base class;
  • writes inherit audit logging and actor tracking;
  • a user cannot create or assign authority they do not already hold.

The goal is not to claim that application-level enforcement makes every possible mistake impossible. The goal is to make the dangerous path explicit, narrow and testable.

One resource, one predictable path

The architecture is deliberately repetitive. A resource moves through the same layers from PostgreSQL to the browser:

PostgreSQL migration
  -> generated database schema
  -> Lucid model
  -> Vine validator
  -> tenant-scoped repository
  -> transformer
  -> policy and permission middleware
  -> API/admin controller
  -> route
  -> React/Inertia page

Each layer has one job. Migrations describe the real database. The generated schema provides the TypeScript shape. Validators decide which input is acceptable. Repositories own persistence. Transformers whitelist what leaves the server. Policies protect record-level decisions, while route middleware protects resource-level actions.

That separation makes the project easier to explain and easier to extend. When a new field is added, there is a known sequence of changes. When a new resource is added, the generator creates the same sequence of files.

Tenant isolation by construction

The center of the design is TenantScopedRepository. Controllers and services never call Model.query() directly for tenant-scoped tables. They ask the repository for a query that has already been narrowed to the active tenant.

The important part is small:

query(): ModelQueryBuilderContract<Model, InstanceType<Model>> {
  const query = this.model.query() as ModelQueryBuilderContract<
    Model,
    InstanceType<Model>
  >

  if (TenantContext.isUnscoped()) {
    return query
  }

  return query.where('tenant_id', TenantContext.currentOrFail().id)
}

A normal request has a tenant context, so find, findOrFail and all all inherit the filter. A record belonging to another tenant is not returned as a forbidden record — it is not found at all through the scoped repository. That avoids both data leakage and the unnecessary confirmation that a given identifier exists.

Writes get the same treatment. The repository applies the active tenant after the validated payload has been prepared:

protected writeAttributes(
  payload: Payload,
  action: 'create' | 'update'
): Partial<ModelAttributes<InstanceType<Model>>> {
  const attributes: Record<string, unknown> = { ...this.prepare(payload) }

  // Applied after the payload, never from it: a `tenantId` arriving in a
  // request must never decide which tenant a record lands in.
  if (action === 'create') {
    Object.assign(attributes, this.tenantAttributes())
  }

  if (this.tracksActor) {
    const actor = this.actorId()

    if (action === 'create') {
      attributes.createdBy = actor
    }

    attributes.updatedBy = actor
  }

  return attributes as Partial<ModelAttributes<InstanceType<Model>>>
}

The order matters. A client may send a tenantId in the JSON body, but the request does not get to decide where the record is created. The active tenant does.

This is tested directly. The functional suite verifies that a created record is stamped with the caller’s tenant, that a foreign tenantId in the payload is ignored, and that an update cannot move a record between tenants.

There is one deliberate exception: superadmin administration and tenant provisioning can operate outside a tenant context. That code is not silently unscoped; it is a separate administrative path, protected by tenant policies and an explicit superadmin boundary.

RBAC with a ceiling on privilege

Role-based access control is easy to implement badly. A permission table and a role editor are not enough if the role editor lets a user add permissions they do not hold themselves.

FisServer defines its permission catalog in TypeScript, not in the database:

export const PERMISSION_CATALOG = {
  tenants: ['read', 'create', 'update', 'delete'],
  users: ['read', 'create', 'update', 'delete', 'assignRole'],
  roles: ['read', 'create', 'update', 'delete'],
  billing: ['read', 'update'],
  audit: ['read'],
  webhooks: ['read', 'create', 'update', 'delete'],
  fieldDefinitions: ['read', 'create', 'update', 'delete'],
  products: ['read', 'create', 'update', 'delete'],
  companies: ['read', 'create', 'update', 'delete'],
  contacts: ['read', 'create', 'update', 'delete'],
  deals: ['read', 'create', 'update', 'delete'],
  activities: ['read', 'create', 'update', 'delete'],
} as const satisfies Record<string, readonly string[]>

export type PermissionSlug = {
  [R in keyof PermissionCatalog]:
    `${R & string}.${PermissionCatalog[R][number]}`
}[keyof PermissionCatalog]

The permissions table is a projection of this file, refreshed by a permissions:sync command; rows that disappear from the catalog are removed from the table and from every role on the next sync. The database stays useful for assignment and querying, but the catalog is the source of truth for valid slugs. A typo such as deals.detete becomes a type error instead of a permission that quietly fails at runtime.

Notice users.assignRole, which is deliberately separate from users.update. Editing somebody’s name and email is a much smaller capability than changing which role they wear — a role that can itself grant every other permission, including that one.

The second protection is behavioral. Assigning a role requires both the relevant capability and a subset check: the role being assigned cannot contain a permission the caller does not already hold. Editing a role applies the same ceiling.

That closes a subtle escalation path. Suppose an editor is granted roles.update. Without the ceiling, they could edit the role they currently wear, add deals.delete, and immediately become more powerful. With the ceiling, they can rearrange permissions they already hold, but cannot manufacture new authority.

The superadmin is intentionally separate. It is not a role holding every row in the permission table; it is an explicit administrative boundary represented by is_superadmin.

A generator that creates a safe starting point

node ace make:resource Product is not just a shortcut for creating a model and a controller. The command scaffolds the complete resource path:

migration
model
repository
validator
policy
transformer
API controller
admin controller
React page
permission entries
routes

The generator’s value is consistency. A generated resource starts tenant-scoped, permission-gated and transformer-backed. That does not remove the need for review — the developer still defines the migration columns, the validation rules and the product-specific behavior. It removes a large class of omissions from the first draft.

The command also stops short, on purpose, of deciding which product module owns the resource. That is a product decision, not a naming convention. After generation, the developer adds the resource to app/modules/definitions.ts, where its entitlement, menu, search and MCP exposure are decided deliberately.

The distinction is the point: automation handles repeated wiring, humans decide business boundaries.

Audit logging inherited by future resources

Audit logging is most valuable when it is boring. If every controller has to remember to call an audit service after a write, the trail will grow incomplete as the codebase grows.

In FisServer, store, update and destroy live in the repository base class. A resource repository inherits the behavior automatically:

async store(payload: Payload): Promise<InstanceType<Model>> {
  const record = (await this.model.create(
    this.writeAttributes(payload, 'create')
  )) as InstanceType<Model>

  if (this.audited) {
    await audit.record({
      action: 'created',
      resource: {
        type: this.auditResourceType,
        id: record.$primaryKeyValue as number,
      },
      changes: audit.creationChanges(record),
    })
  }

  await emitResourceEvent(
    this.auditResourceType,
    'created',
    record,
    TenantContext.currentId()
  )

  return record
}

Updates capture pending changes before saving, so the audit entry describes what changed rather than merely recording that an update happened. Actor fields are stamped alongside the write wherever the table supports them.

The same base operation also emits resource events for the webhook layer. Which resources actually publish remains an explicit module decision, but the event boundary is available consistently.

This is the same architectural idea as tenant isolation: true by inheritance, not by memory.

The tests describe the boundaries

The test suite is not only checking whether a page renders. Its most useful tests describe what must never happen.

The tenant isolation tests create two workspaces, put a record in each, and authenticate as a user from only one of them. The expected result is intentionally strict: the user sees one record, cannot fetch the foreign record by id, and cannot create a record in the other workspace by including its id in the request body.

The generated-resource tests repeat that contract against the output of make:resource. This matters because a generator can produce files successfully while still producing an unsafe resource. The test asks the more valuable question: does a generated resource already behave like a tenant-scoped, permission-gated resource?

The role tests do the same for authorization. They prove that an editor who can update roles still cannot add deals.delete when that permission is not already theirs, that a tenant admin can assign permissions they do hold, and that the built-in admin role cannot be edited into a different shape.

The MCP tests extend the same negative cases to a second transport. A tool call without a bearer token is refused. A user without deals.read cannot list deals. A valid create call stamps both the tenant and the actor.

There is also a route coverage test that reads the routes the application actually registered and holds them against the module registry: every page named there is a route that exists, every route is gated or states why it is not, and nothing sold reaches a request without passing the entitlement check.

The point is not to accumulate tests for the sake of a number. It is to make the security contract executable at every boundary.

Adding a resource without starting over

Suppose the next feature is a Subscription resource. The intended workflow is not to invent a new architecture for it.

node ace make:resource Subscription

The generated files provide the skeleton. You then review the migration columns, write the Vine validation rules, add any resource-specific relationships, and decide which module owns it. If the resource belongs to a sellable module, its identity flows into navigation, search, custom-field discovery, entitlements and, where appropriate, MCP tools.

One distinction is worth stating explicitly: a fixed column is not the same thing as a tenant-defined field. If every customer has the same renewalDate concept, add a migration and a validator rule. If each workspace needs its own field, such as billingContactCode, use the custom-field system instead. That is what keeps every customer configuration request from becoming a new database migration.

The new resource still needs domain review. A generator cannot know whether a subscription may be deleted, whether it should emit a webhook, or whether it needs a special policy. It can make sure the boring, security-sensitive plumbing is already in place before that review begins.

Modules and entitlements make the backend sellable

A reusable SaaS foundation needs more than technical isolation. It needs a place where product packaging can live.

FisServer keeps module definitions in one registry. A module owns resources and describes whether they appear in the menu, support custom fields, or publish webhooks. The installation ships with core, catalog and CRM modules — abridged here to two:

export const MODULES = [
  // core: tenants, users, roles, audit, webhooks, custom fields, billing.
  // Always present, never sold, never switched off.
  {
    slug: 'catalog',
    name: 'Catalogue',
    resources: [
      {
        slug: 'products',
        label: 'Products',
        customFields: true,
        published: true,
        menu: {
          order: 30,
          href: '/admin/products',
          icon: '📦',
          description: 'View, create and edit your product catalogue',
        },
      },
    ],
  },
  {
    slug: 'crm',
    name: 'CRM',
    resources: [
      // each with its own `menu` block, omitted here
      { slug: 'companies', label: 'Companies', customFields: true, published: true },
      { slug: 'contacts', label: 'Contacts', customFields: true, published: true },
      { slug: 'deals', label: 'Deals', customFields: true, published: true },
      { slug: 'activities', label: 'Activities', customFields: true, published: true },
    ],
  },
] as const satisfies readonly ModuleDefinition[]

The registry is not a magic generator for every route. Some resources have special behavior — deals have a board, most have a CSV export — and their routes stay written out. What the registry gives the rest of the application is a shared vocabulary: navigation, search, custom-field discovery, dashboard cards and MCP tool availability all refer to the same module and resource identity.

A compile-time assertion keeps the registry honest, too. The resources marked customFields must be exactly the declared custom-field entities, or the build fails there rather than at runtime inside a validator.

Entitlements then become a product concern. A workspace can have a module active, read-only, or absent, and the middleware returns a decision that distinguishes the three:

export type EntitlementDecision =
  | { allowed: true }
  | { allowed: false; reason: 'not_entitled' | 'read_only'; module: string }

That is the difference between “this role lacks the permission,” “this workspace does not have the module,” and “this workspace may read but not write” — the foundation you need for tiers, add-ons and controlled rollout.

The unglamorous parts

Production readiness is also a collection of small decisions:

  • rate limiting is persisted in PostgreSQL rather than living only in process memory, so limits survive a restart and hold across instances;
  • webhook destinations are validated before delivery and re-checked at dispatch time, since a hostname that resolved to a public address at registration can resolve to a private one later — loopback, private ranges, and the 169.254.0.0/16 link-local range where cloud instance credentials live;
  • custom fields are defined per tenant and validated at runtime, so customers can extend forms without a migration for every new field;
  • actor tracking distinguishes who created a record from who last changed it;
  • email flows such as invitations and password resets are testable locally through Mailpit;
  • audit entries are read-only by design and pruned on a schedule, because a trail somebody can rewrite by hand is worth very little.

None of these makes a landing page headline on its own. Together, they are what makes a foundation still useful after the first demo.

MCP is another transport, not another security model

The MCP endpoint exposes CRM tools to clients such as Claude or Cursor. The interesting design decision is not adding an MCP server. It is making MCP reuse the same tenant context, actor context, permissions, validation and repositories as the HTTP surfaces.

A token belonging to a user at Acme can list Acme deals but not Globex deals. A token without deals.read gets a permission error. A tool that creates a deal persists it with the caller’s tenant and actor.

The test expresses the contract directly:

const response = await client
  .post('/mcp')
  .header('accept', 'application/json, text/event-stream')
  .bearerToken(token)
  .json({
    jsonrpc: '2.0',
    id: 1,
    method: 'tools/call',
    params: {
      name: 'list_deals',
      arguments: {},
    },
  })

response.assertStatus(200)

The transport is different. The security boundary is not.

That distinction matters as AI clients become another interface to business data. Adding a transport should never mean rebuilding authorization from scratch.

Why the controllers stay deliberately parallel

One design choice may look unfashionable: the API and admin controllers are similar, and they are not aggressively merged.

The API controller returns JSON. The admin controller redirects through Inertia and carries flash messages for the React interface. Their repositories, validators and domain services are shared, but their transport-specific behavior stays visible.

This is a case where a little duplication is cheaper than an abstraction that hides the contract. Once a helper starts returning a conditional union of JSON and Inertia responses, the code may look DRY while becoming harder for TypeScript and route tooling to reason about.

The rule is simple: share domain logic, keep transport boundaries readable.

Closing

The central idea is not “add a tenant filter everywhere.” It is to design the application so that a resource has exactly one safe path through the system:

context -> repository -> policy -> transformer -> transport

The repository makes tenant scoping and auditing inherited behavior. The permission ceiling makes role administration safe to delegate. The generator makes the whole path repeatable. The module registry gives the foundation a product model. MCP reuses the same boundaries for an emerging interface.

Starting from a blank AdonisJS application is entirely possible. It is also where teams spend the same weeks over and over: deciding how tenant context reaches every request, preventing client-controlled tenant assignment, building role administration, adding audit trails, generating consistent CRUD, supporting custom fields without schema churn, exposing data to API and AI clients safely, and testing the negative cases that matter most.

FisServer packages those decisions as a starting point. It is not a finished product for every industry, and it does not remove product-specific work. It is the layer that lets a team spend its time on what customers will actually pay for.

The strongest fit is a team building a multi-tenant B2B SaaS on PostgreSQL and AdonisJS that wants a serious foundation without rebuilding tenancy, RBAC, auditing and admin plumbing before the first domain feature.

The thing behind the article

FisServer

Every pattern above is from a working AdonisJS 7 backend: tenant isolation enforced in the repository layer, type-safe RBAC, an audit trail every future resource inherits, module entitlements and a native MCP server. $179 for the first 50 on the list, $249 after.