Open Sourcing a Single-Tenant version of a Multi-Tenant SaaS

I have a community management platform app called notalone - I have posted bits and pieces of it over the last year. It provides an SSO auth for Discourse, using the Users service as the identity provider, and helps you manage access to and monetize a community.

Outseta does a similar thing with Discord servers.

One thing I have been really intrigued by is this hybrid open-source/SaaS model that companies like Discourse and Ghost have used.

How do they have hosted, multi-tenant SaaS apps and also a single tenant open source app? Surely this would require two different data models. Do they have two sets of business logic as a result?

After a lot of research, I never got a definitive answer to how these companies manage a single-tenant open source app alongside a multi-tenant SaaS. They could be using one single-tenant data model and host multiple instances of their open source app. They could have two data models and an abstraction layer that checks which data model exists before executing any database operations. The former is tricky on the infrastructure, the latter is tricky in terms of writing two versions of code for any database operation.

Side note - how does the Anvil team accomplish this? I know that, unless we have an isolated database, our data tables are actually views on shared databases. Seems like they’re doing the latter method - at least for database operations. Maybe the former method for everything else?

For my own project, I decided to keep it simple - one multi-tenant data model with some tricks to make it seem like the open source version is single-tenant.

The purpose, for me, was not to make it super easy for an open source user to re-create my multi-tenant SaaS with just a few clicks. They’d have to write at least a few dozen lines of code, which is enough friction for me. I’m sure the above-mentioned companies, and even Anvil, have had this conversation internally about how much friction is optimal to balance open source friendliness and competing hosting providers.

I won’t go into all the details about how I set up my multi-tenant app, but basically, here are my main tables:
Users - self explanatory.
Usermap - mapping between a user and tenant. One row per combination, as a user can be a part of multiple different tenants (the analogy of facebook). Each row is associated with multiple roles.
Tenants - one row per tenant
Roles - each role is associated with a tenant (or not, if it is a global role not related to a tenant). Each role is associated with multiple permissions.
Permissions - Simple list of permissions - just names.

This data model is similar to the one described here but adapted for tenant-specific roles.

My open source app uses hash routing from anvil_extras, so I have a startup module with the following redirects:

import anvil.server
from anvil_extras import routing
from .Router import Router
from .Static import Static
from .Global import Global

@routing.redirect(path="app", priority=20, condition=lambda: Global.user is None)
def redirect_no_user():
    print('redirect_no_user')
    return "sign"


@routing.redirect(path="app", priority=18, condition=lambda: Global.get_s('tenant') is None and Global.user is not None)
def redirect_no_tenant():
    print('redirect_no_tenant')
    Global.tenant = anvil.server.call('get_tenant_single')
    if Global.get_s('tenant') is None:
        Global.tenant = anvil.server.call('create_tenant_single')

    try:
        Global.tenant_id = Global.tenant.get_id()
    except Exception:
        Global.tenant_id = Global.tenant['id']

    return routing.get_url_hash()

hash, pattern, dict = routing.get_url_components()

routing.set_url_hash(hash)

routing.launch()

Here’s where my SaaS app comes in. I have a separate Anvil app using this open source app as a dependency. I have additional forms and server modules that cover additional functionality (creating new tenants) and tenant-level payment webhooks etc.

The only sort-of-duplicated code is the startup module. I have different redirects.

from anvil_extras import routing
from notalone_oss.Router import Router
from notalone_oss.Static import Static
from notalone_oss.Global import Global
from .StaticLaunch import StaticLaunch  # new form


@routing.redirect(path="app", priority=20, condition=lambda: Global.user is None)
@routing.redirect(path="launch", priority=20, condition=lambda: Global.user is None)
def redirect_no_user():
    return "sign"


@routing.redirect(path="app", priority=20, condition=lambda: Global.get_s('tenant_id') is None)
def redirect_no_tenant():
    return "launch"

hash, pattern, dict = routing.get_url_components()

# Loads the template form
routing.set_url_hash(hash)

routing.launch()

So far, this is working for me and is a relatively simple (emphasis on relatively) way to maintain a single-tenant and multi-tenant version of the same underlying app.

Thoughts?

3 Likes

I don’t have any thoughts on the “how” but I wanted to thank you for raising this topic. I might want to try to do something similar at some point.

1 Like