Multitenancy is a software architecture where a single app can server multiple different users or organizations whose data is kept private from the other users of the system. One way to do this is have a totally separate database for each user, but that has a high operations overhead. The most common way is to store all user data in a single database and use foreign keys to keep data private.
This guide shows you a good way to implement a multitenant Blitz app.
We recommend implementing the data model as described by Andrew Culver of Bullet Train.
Organization
is the "God" model which owns everything for an
accountOrganization
has many User
s through Membership
organizationId
to indicate who
owns it.User
can have access to multiple Organization
sMembership
instead of directly to the user. See the Bullet
Train blog post linked above for more explanation on this.The prisma schema looks like this:
model Organization {
id Int @id @default(autoincrement())
name String
role GlobalRole
membership Membership[]
}
// The owners of the SaaS (you) can have a SUPERADMIN role to access all data
enum GlobalRole {
SUPERADMIN
CUSTOMER
}
model Membership {
id Int @id @default(autoincrement())
role MembershipRole
organization Organization @relation(fields: [organizationId], references: [id])
organizationId Int
user User? @relation(fields: [userId], references: [id])
userId Int?
// When the user joins, we will clear out the name and email and set the user.
invitedName String?
invitedEmail String?
@@unique([organizationId, invitedEmail])
}
enum MembershipRole {
OWNER
ADMIN
USER
}
model User {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String?
email String @unique
memberships Membership[]
}
Then you will need to update your signup
mutation to also create an
organization and membership at the same time as you create the user. Like
this:
const user = await db.user.create({
data: {
// ...
memberships: {
create: {
role: "OWNER",
organization: {
create: {
name: organizationName,
},
},
},
},
},
})
The above data model allows a single user to be in multiple organizations. So how can we track which organization a user is currently accessing or modifying?
The best way is to only let a user access one organization at a time. And then provide a menu in the UI that let's them switch which organization they are accessing.
To do that, first add an orgId
field to the
session PublicData
.
Update types.ts
like this:
+import { GlobalRole, MembershipRole, Organization } from "db"
declare module "blitz" {
export interface Ctx extends DefaultCtx {
session: SessionContext
}
export interface Session {
isAuthorized: SimpleRolesIsAuthorized<Role>
PublicData: {
userId: User["id"]
+ roles: Array<MembershipRole & GlobalRole>
+ orgId: Organization["id"]
}
}
}
and then update all places where you call ctx.session.$create()
. You
will need to add orgId
and update roles
.
It will look something like this:
await session.$create({
userId: user.id,
roles: [user.role, user.memberships[0].role],
orgId: user.memberships[0].organizationId,
})
Then you can use ctx.session.orgId
in queries and mutations to filter
your queries based on the current organization.
You must filter all your queries by organizationId
to ensure one user
cannot see another user's private data.
import db from "db"
// If you accept only the `id` as input
const project = await db.project.findFirst({
where: {
id: input.id,
organizationId: ctx.session.orgId,
},
})
// If you accept `where` as input
const projects = await db.project.findMany({
where: {
...input.where,
organizationId: ctx.session.orgId,
},
})
When creating new entities, make sure you attach them to the current organization. Here's an example of how to do that:
import { resolver } from "blitz"
import db from "db"
import * as z from "zod"
const CreateProject = z
.object({
name: z.string(),
})
.nonstrict()
export default resolver.pipe(
resolver.zod(CreateProject),
resolver.authorize(),
async (input, ctx) => {
const project = await db.project.create({
data: {
...input,
organizationId: ctx.session.orgId,
},
})
return project
}
)
You must also filter update and delete mutations by organizationId
to
ensure another user's data can't be changed.
Here's an example where a mutation accepts id
that could be the id of an
entity belonging to a different organization. You could first make a
db.project.findFirst()
query for that id and then manually verify that
organizationId
is correct. But the easier way shown here is by adding
organizationId
to the db.update
where
input. This update call will
fail if the organizationId doesn't match.
import { resolver } from "blitz"
import db from "db"
import * as z from "zod"
const UpdateProject = z
.object({
id: z.number(),
name: z.string(),
})
.nonstrict()
export default resolver.pipe(
resolver.zod(UpdateProject),
resolver.authorize(),
async ({ id, ...data }, ctx) => {
const project = await db.project.update({
where: {
id,
// Filter by organizationId
organizationId: ctx.session.orgId,
},
data,
})
return project
}
)
You can do more advanced things like calling ctx.session.$authorize()
inside an if/else
import { resolver } from "blitz"
import db, { GlobalRole, MembershipRole } from "db"
import * as z from "zod"
const UpdateProject = z
.object({
id: z.number(),
organizationId: z.number(),
name: z.string(),
})
.nonstrict()
export default resolver.pipe(
resolver.zod(UpdateProject),
// Ensure all users are logged in
resolver.authorize(),
async ({ id, organizationId, ...data }, ctx) => {
// if organizationId doesn't match current organization
if (organizationId !== ctx.session.orgId) {
// Require SUPERADMIN role
ctx.session.$authorize(GlobalRole.SUPERADMIN)
} else if (!ctx.session.accessibleProjects.includes(id)) {
// If user doesn't have specific access to this project,
// require them to be a project manager
ctx.session.$authorize(MembershipRole.PROJECT_MANAGER)
}
const project = await db.project.update({
where: {
id,
organizationId,
},
data,
})
return project
}
)
Here's some advanced utilities that allow you to do authorization in a way that allows SUPERADMINs to access all organizations but only permits regular users to access the organization they are currently logged in to.
// app/orders/queries/getOrder.ts
import { NotFoundError, resolver } from "blitz"
import db from "db"
import * as z from "zod"
import {
enforceAdminOrProctorIfNotCurrentOrganization,
setDefaultOrganizationId,
} from "app/core/utils"
const GetOrder = z.object({
id: z.number(),
organizationId: z.number().optional(),
})
export default resolver.pipe(
resolver.zod(GetOrder),
// Ensure user is logged in
resolver.authorize(),
// Set input.organizationId to the current organization if one is not set
// This allows SUPERADMINs to pass in a specific organizationId
setDefaultOrganizationId,
// But now we need to enforce input.organizationId matches
// session.orgId unless user is a SUPERADMIN
enforceSuperAdminIfNotCurrentOrganization,
async ({ id, organizationId }) => {
const order = await db.getOrder({
where: {
id,
// Now we can safely use organizationId to filter queries
organizationId,
},
})
if (!order) throw new NotFoundError()
return order
}
)
// app/core/utils.ts
import { Ctx } from "blitz"
import { Prisma, GloblRole } from "db"
export default function assert(
condition: any,
message: string
): asserts condition {
if (!condition) throw new Error(message)
}
export const setDefaultOrganizationId = <T extends Record<any, any>>(
input: T,
{ session }: Ctx
): T & { organizationId: Prisma.IntNullableFilter | number } => {
assert(
session.orgId,
"Missing session.orgId in setDefaultOrganizationId"
)
if (input.organizationId) {
// Pass through the input
return input as T & { organizationId: number }
} else if (session.roles?.includes(GloblRole.SUPERADMIN)) {
// Allow viewing any organization
return { ...input, organizationId: { not: 0 } }
} else {
// Set organizationId to session.orgId
return { ...input, organizationId: session.orgId }
}
}
export const enforceSuperAdminIfNotCurrentOrganization = <
T extends Record<any, any>
>(
input: T,
ctx: Ctx
): T => {
assert(ctx.session.orgId, "missing session.orgId")
assert(input.organizationId, "missing input.organizationId")
if (input.organizationId !== ctx.session.orgId) {
ctx.session.$authorize(GloblRole.SUPERADMIN)
}
return input
}
If you want to do more advanced authorization, check out Blitz Guard.