Volca is a SaaS boilerplate that has support for a project based multi-tenancy where users can start a subscription and collaborate with their teammates with minimal effort.
Features
Authentication
Secure and reliable authentication out of the box
Subscriptions
Enable your users to subscribe to your service
TypeScript
The backend, frontend and infrastructure is written in TypeScript, so you only have to know one language!
CI/CD
Deploy with confidence from the start with a solid CI/CD setup
Secure
Built with best practice security
Multi Tenancy
Enable your users to create projects and invite their team
Logging
Find and remedy production issues quickly
Open Source
Our core functionality is available open source
Serverless
Focus on creating a great product instead of managing servers
Dark Mode
Yes, it's a feature 😎
Community
Private community where the Volca team will help you build your SaaS
GitHub
Access to a private GitHub repository for collaboration and access to updates
Multitenancy
Multitenancy is a software architecture where a single instance of an application supports multiple tenants. A tenant is defined as a group of users that share a set of privileges in the application, such as performing operations on some resources in an API.
The opposite of multi-tenancy is single tenancy or multi-instance. With this pattern every user group would have its own instance of the application.
Here are some of the benefits of a multi-tenant application:
- Cost - One of the major benefits of multi tenant applications will be the cost. This is simply because multiple tenants share the same instance of the application. This is due to there being a baseline of cost when running servers or databases. Running 4 separate instances of smaller databases would cost more than running a larger instance that would serve all 4 tenants. It’s comparable to having a set of people driving individual cars instead of bunching them up and driving them on a bus.
- Releases - When it comes to releases it is much more simple to release a new version of the service by deploying it to a single instance instead of multiple instances.
- Data - Having all the different tenant data in a single database makes it easier to manage the application by being able to run queries across the data of multiple tenants.
And some of the drawbacks:
- Complexity - There is additional complexity to running a multi tenant application since we need to implement functionality for isolating the different tenants from each other in the application.
- Security - Since the tenants data is hosted in the same database, we need to make sure that they cannot access each other. This would not be an issue if each tenant had a separate instance of the application.
Deciding the database schema
One of the most important things with your multi tenancy setup will be how we design the database. In the diagram below I have created Many to Many relationship between users and tenants to allow a user to own multiple tenants but also be a part of other tenants.
Setting up the project
Let’s start setting up a multi tenant API with Koa and Knex
$ npm init -y
$ npm install @koa/router koa koa-bodyparser typescript uuid
$ npm install --save-dev @types/koa @types/koa__router @types/koa-bodyparser @types/uuid
With dependencies installed, let’s set up a server to host our routes.
import Koa, { Context, Next } from "koa";
import Router from "@koa/router";
import body from "koa-bodyparser";
import { v4 as uuid } from "uuid";
const app = new Koa();
const router = new Router();
router.use(body());
app.listen(3000, () => {
console.log("Server listening on port 3000");
});
Setting up a mock database
In the schema above we assume that a relational database is used, but in reality any database can be used, even though a relational database makes it easier to map the relationships between tenants and users.
In this article we will create a simple mock object to use instead of the database connection.
const db = {
users: {
create: ({
id,
first_name,
last_name,
}: {
id: string;
first_name: string;
last_name: string;
}) => {
console.log("User created!");
},
find: (id: string) => ({
id: "06acfb69-ce87-4aa6-a945-8047d4a05cd2",
first_name: "John",
last_name: "Doe",
}),
},
tenants: {
create: ({ id, name }: { id: string; name: string }) => {
console.log("Tenant created!");
},
find: (id: string) => ({
id: "7b190e2e-8071-4d45-8196-57f5c0baa4e7",
name: "Volca",
}),
},
tenantAdmins: {
create: ({
id,
tenant_id,
user_id,
}: {
id: string;
tenant_id: string;
user_id: string;
}) => {
console.log("Tenant admin created!");
},
find: (user_id: string, tenant_id: string) => ({
id: "6758e69a-5f17-47eb-923e-e0a0ceef13e0",
user_id: "06acfb69-ce87-4aa6-a945-8047d4a05cd2",
tenant_id: "7b190e2e-8071-4d45-8196-57f5c0baa4e7",
}),
},
};
Setting up the endpoints
With the server set up and the database schema created, we can now set up our first endpoint for creating tenants.
type CreateTenantRequest = {
name: string;
adminId: string;
};
router.post("/tenants", async ({ request, response }: Context) => {
if (!request.body) {
response.status = 401;
return;
}
const { name, adminId } = <CreateTenantRequest>request.body;
const tenantId = uuid();
db.tenants.create({ id: tenantId, name });
db.tenantAdmins.create({ id: uuid(), user_id: adminId, tenant_id: tenantId });
response.status = 200;
});
Now that we are able to create new tenants, let’s set up an endpoint for fetching resources for that specific tenant.
router.get("/:tenantId/resource", (ctx: Context) => {
const { tenantId } = ctx.params;
ctx.status = 200;
ctx.body = {
message: `This is a resource from the tenant with id ${tenantId}`,
};
});
Since the route is scoped to a tenant, we can now fetch data from our database that is based on the tenants ID. However, this endpoint is open to anyone and any other tenant could fetch information from it. Let’s fix that by adding a middleware to check that the user has access to the tenant.
This code snippet assumes that some mechanism for authentication has already been implemented to know what user is calling the service. Checkout this article for an introduction on how to create authentication with JSON web tokens.
const tenantRouter = new Router();
tenantRouter.use(body());
export const tenantAuthorizationMiddleware = async (
ctx: Context,
next: Next
) => {
const tenantId = ctx.path.split("/")[1];
const user = { id: "06acfb69-ce87-4aa6-a945-8047d4a05cd2" }; // make sure to implement a previous middleware to authenticate the user and attach it to the context
const tenant = db.tenantAdmins.find(user.id, tenantId);
if (!tenant) {
ctx.status = 404;
return;
}
next();
};
Now, if we add the resource endpoint to the tenant router, we will get a 404 response if we don’t have access to that project. We are sending a 404 instead of a forbidden to not disclose information as to if the tenant exists or not.
Next steps
Now we have a minimal setup for an application with support for multiple tenants. You can extend the functionality hidden behind the tenant middleware to add more specific functionality for the different tenants while keeping them separated.
We could also extend the functionality of the tenant_users
table and introduce different roles that have access to perform specific actions in the application. For example an admin role that’s allowed to perform any action and a read-only role that is only allowed to view information.