Forgotten tenant filter
One missing WHERE clause is enough to leak data across workspaces.
AdonisJS 7 · PostgreSQL · React · MCP
Multi-tenancy, RBAC, CRUD generation, auditing and testing — from a blank AdonisJS project to a reusable SaaS backend foundation.
Built once. Reused across every resource.
The problem
The hard part is not building features. The hard part is making it difficult to leak data, grant too much access, or forget audit trail coverage when the next resource ships.
One missing WHERE clause is enough to leak data across workspaces.
Permissions that are easy to assign manually become easy to abuse.
If logging is optional, it will eventually be incomplete.
Feature access bolted on after the fact tends to become inconsistent fast.
The goal is simple: make the safe path the only realistic path.
Architecture
From database schema to UI, every layer is shaped around the same tenant-aware contract.
Controllers and services do not query tenant-scoped tables directly. They go through a repository, and the repository applies the tenant filter before handing the query builder back — so scope is a property of the layer rather than of each call site.
export abstract class TenantScopedRepository<Model, Payload> {
/** Every read enters here. Controllers never touch Model.query(). */
query() {
const query = this.model.query()
if (TenantContext.isUnscoped()) {
return query // the superadmin, deliberately
}
return query.where('tenant_id', TenantContext.currentOrFail().id)
}
}This is the base class every resource extends, not a one-off snippet written for the page.
Scaffolding
node ace make:resource generates the full resource stack with tenant scoping, validation, permissions, audit hooks, routes, pages, and the related wiring.
The table, the columns, and the Lucid model over them.
VineJS validation on input, per-record authorisation on access.
Tenant-scoped queries in, a whitelisted response shape out.
A REST controller and an Inertia one, both registered and gated.
List and form pages, with permissions reflected in the UI.
The new resource appears in the catalog and in the module registry.
The point is not speed alone. The point is removing the chance that the next resource ships half-finished.
Isolation
Isolation lives in the repository layer, so the client never decides tenant scope.
protected writeAttributes(payload: Payload, action: 'create' | 'update') {
const attributes = { ...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())
}
return attributes
}Reads are filtered by the layer. Writes are stamped by it: the active tenant is applied after the validated payload, never taken from it, so a tenantId supplied by a client does not determine where a record lands.
That does not make mistakes unthinkable — a repository can still be bypassed by someone determined to. It makes the safe path the default one, and the unsafe path something you have to go out of your way to write.
True by construction, not by discipline.
Permissions
The permission catalog is typed, centralized, and designed to prevent silent privilege creep.
export const PERMISSION_CATALOG = {
tenants: ['read', 'create', 'update', 'delete'],
// assignRole is deliberately separate from update: editing a colleague's
// name is a much smaller capability than changing which role they hold.
users: ['read', 'create', 'update', 'delete', 'assignRole'],
// Read-only by design. Entries are appended by the application, never
// edited by hand.
audit: ['read'],
} as const
router.get('/companies', [CompaniesController, 'index']).use(can('companies.read'))Permission slugs are not free-form strings. They are part of the contract.
Role editing is constrained by the permissions of the current actor, so the system refuses self-elevation by design. Assigning a role is its own permission, separate from editing a user, because handing someone a role can hand them everything that role carries.
Each workspace narrows its own roles without affecting any other workspace.
Audit
Create, update, and delete already emit audit entries from the shared base layer, together with the actor, the changed fields and where the request came from.
A resource added next year inherits traceability without anyone remembering to wire it up.
async store(payload: Payload) {
const record = await this.model.create(this.writeAttributes(payload, 'create'))
if (this.audited) {
await audit.record({ action: 'created', resource: /* … */ })
}
await emitResourceEvent(this.auditResourceType, 'created', record)
return record
}Entitlements
Modules, entitlements, and feature visibility all come from one registry.
Core, catalog, and CRM modules can be active, read-only, or absent. That drives navigation, routes, permissions, and available actions from a single source of truth.
Full read and write access.
Visible, but constrained. Data can still be read and exported.
Hidden entirely from the product surface — routes, menu and permissions alike.
export const MODULES = [
{ slug: 'core', name: 'Core', core: true, resources: [/* … */] },
{
slug: 'crm',
name: 'CRM',
resources: [
{
slug: 'deals',
label: 'Deals',
customFields: true,
published: true,
menu: { href: '/deals', icon: 'kanban', order: 30, description: 'Your pipeline' },
},
],
},
] as constThis is what makes the foundation reusable for SaaS pricing tiers, internal editions, or client-specific deployments. A workspace gains or loses a module as a single record, and the surfaces follow.
A test reads the routes the application actually registered and holds them against this registry, so a module cannot claim a page that has no route, and a sold route cannot skip the entitlement check.
AI clients
Claude, Cursor, and other MCP clients can access the CRM through the same tenant isolation, permissions, and audit rules used by the REST API.
{
"mcpServers": {
"acme-crm": {
"type": "http",
"url": "https://api.acme.com/mcp",
"headers": { "Authorization": "Bearer oat_…" }
}
}
}The result is an AI-ready backend that does not create a second, weaker security model just for tools. The tool list a client sees is derived from the modules that workspace holds, and a personal API token carries exactly the permissions of the person who created it.
Point Claude, ChatGPT or Cursor at the endpoint and the CRM becomes something a person can talk to — in whatever language they already work in.
Summarise our pipeline by stage and flag the deals that have gone quiet.
get_deal_pipelinelist_activities
Add Acme Srl as a company, with Marco Bianchi as its contact.
create_companycreate_contact
Move the Rossi deal to negotiation and log the call I just had.
update_dealcreate_activity
Which products have not been attached to a deal this quarter?
list_productslist_deals
The same request from a sales viewer returns the pipeline and refuses the two write calls, because the token carries their permissions and the tools check them one by one. Every write that does go through lands in the audit trail with the actor attached, exactly as it would from the admin panel.
One backend.
One permission model.
One audit trail.
Foundations
Survives restarts and does not disappear with process memory.
Outbound requests are constrained instead of trusted by default.
Custom data can be added without forcing a migration every time.
Tenant setup does not depend on half-finished intermediate state.
Scope
It is a real SaaS backend foundation with real modules, real constraints, and real generated resources. Concretely, that means file uploads, billing and subscriptions, teams within a tenant, and host-based subdomain routing are not part of it — the tenant slug is built to carry subdomains, but today the tenant resolves from the authenticated user.
Pricing
Buy the source, use it as the base for your own SaaS, and avoid rebuilding the same multi-tenant primitives from scratch.
Early access
$179$249
Regular price $249, paid once. Limited early access pricing for the first 50 buyers.
Includes a commercial license for personal and client projects, with no resale of the source. Read the license.
FAQ
The architecture write-up and this page quote the source directly, and every snippet here is real. Early buyers get repository access immediately on purchase.
A foundation: generated resources, tenant isolation, RBAC, audit and module-driven entitlements, with a test suite that covers them.
No. The CRM is the reference domain, included to show the foundation carrying real resources. The foundation is built to be reused for other products.
Yes — it removes weeks of repeated infrastructure work, and lowers the odds of getting tenant isolation wrong while doing it.
If you are building a B2B SaaS and do not want to re-learn multi-tenancy, RBAC, auditing, and generated CRUD the hard way, FisServer gives you the foundation.