Skip to content

Contrib

If you are not familiar with the concept of multi-tenancy, have a look at the previous section and have a read.

We all have suffered from concepts of design and undeerstanding in fact what is multi-tenancy and how to imlpement the right solution.

The real answer here is that there is no real one only working solution for multi-tenancy applications. Everything depends of your needs but there are approaches to the problem.

  1. Shared schemas - The data of all users are shared within the same schema and filtered by common IDs or whatever that is unique to the platform. This is not so great for GDPR (Europe) or similar in different countries.
  2. Shared database, different Schemas - The user's data is split by different schemas but live on the same database.
  3. Different databases - The user's data or any data live on different databases.

Edgy to simplify your life of development offers an out-of-the-box solution for multi-tenancy using the second approach, shared database, different schemas as this is more common to be applied for majority of the applications.

The contrib of Edgy is not related to its core, although it uses components from it for obvious reasons, it works as possible alternative that can be used by you but it is not mandatory to do it as you can have your own design.

Heavily inspired by Django Tenants and from the same author of Django Tenants URL and Edgy (you didn't see this one coming, did you? 😜), Edgy offers one non-core working solution for multi-tenancy.

Warning

Edgy supports one database migrations only which internally uses alembic for it. As of now, there are no support for multi-tenancy templates for migrations so that would need to be manually added and managed by you.

What does this non-core multi-tenancy brings?

  • Models to manage the tenants and the links between the tenants and users.
  • Automatic schema generation up the creation of a tenant in the tenants table.
  • Settings object needed to use the module

Brief explanation

The module works as an independent application inside Edgy but using the obvious core components.

The module uses a settings file that inherits from the main settings module of Edgy which means you would only needed to override the needed values and use it.

Every model that required to be applied on a tenant (schema) level, you require to inherit from the TenantModel and pass the is_tenant=True parameter in the Meta class.

To use this module, you will need to have your EDGY_SETTINGS_MODULE set as well. More on this in the TenancySettings.

More on this in the example provided.

Imports

All the needed imports are located inside the multi_tenancy module in contrib:

from edgy import Registry
from edgy.contrib.multi_tenancy import TenantModel, TenancySettings
from edgy.contrib.multi_tenancy.models import TenantMixin, DomainMixin, TenantUserMixin

TenantModel

This is the base of all models that require the table to be created inside the newly created schema.

The TenantModel already inherits from the base edgy.Model which means it will apply all the needed core functionalities but introduces a new internam metaclass required for the is_tenant attribute.

import edgy
from edgy.contrib.multi_tenancy import TenantModel

database = edgy.Database("<YOUR-CONNECTION-STRING>")
registry = edgy.Registry(database=database)


class User(TenantModel):
    """
    A `users` table that should be created in the `shared` schema
    (or public) and in the subsequent new schemas.
    """

    name: str = edgy.CharField(max_length=255)
    email: str = edgy.CharField(max_length=255)

    class Meta:
        registry = registry
        is_tenant = True

This is how you must declare a model that you want it to be in your multi-tenant schemas using this particular module. This is mandatory.

TenancySettings

Now, this object is extremely important. This settings object inherits from the default settings and adds the extra needed attributes that are used by the provided model mixins.

Now there are two ways that you can approach this.

  1. You have your own settings with the following properties added:

    • auto_create_schema: bool = True - Used by the Tenant model.
    • auto_drop_schema: bool = False - Used by the Tenant model.
    • tenant_schema_default: str = "public" - Used by the Domain model.
    • tenant_model: Optional[str] = None - Used by the TenantUser model.
    • domain: Any = os.getenv("DOMAIN") - Used by the Tenant model.
    • domain_name: str = "localhost" - Used by the Domain model.
    • auth_user_model: Optional[str] = None - Used by the TenantUser model.
  2. You inherit the TenancySettings object and override the values needed and use it as your EDGY_SETTINGS_MODULE.

    from edgy.contrib.multi_tenancy import TenancySettings
    

Choose whatver it suits your needs better 🔥.

Model Mixins

Edgy contrib uses specifically tailored models designed to run some operations for you like the Tenant creating the schemas when a record is added or dropping them when it is removed.

These are model mixins and the reason why it is called mixins it is because they are abstract and must be inherited by your own models.

Tip

By default, the contrib model mixins have the meta flag is_tenant set to False because in theory these are the ones that will be managing all your application tenants. Unless you specifically specify to be True, they will be ignored from every schema besides the main shared or public.

Tenant

This is the main model that manages all the tenants in the system using the Edgy contrib module.

When a new tenant is created, upon the save of the record, it will create the schema with the provided name in the creation of that same record.

Fields

  • schema_name - Unique for the new schema. Mandatory.
  • domain_url - Which domain URL the schema should be associated. Not mandatory.
  • tenant_name - Unique name of the tenant. Mandatory.
  • tenant_uuid - Unique UUID (auto generated if not provided) of the tenant.

    Default: uuid.uuid4()

  • paid_until - If the tenant is on a possible paid plan/trial. Not mandatory.

  • on_trial - Flag if the tenant is on a possible trial period.

    Default: True

  • created_on - The date of the creation of the tenant. If nothing is provided, it will automatically generate it.

    Default: datetime.date()

How to use it

The way the TenantMixin should be used it is very simple.

import edgy
from edgy.contrib.multi_tenancy.models import TenantMixin

database = edgy.Database("<YOUR-CONNECTION-STRING>")
registry = edgy.Registry(database=database)


class Tenant(TenantMixin):
    """
    Inherits all the fields from the `TenantMixin`.
    """

    class Meta:
        registry = registry

Domain

This is a simple model that can be used but it is not mandatory. Usually when referring to multi-tenancy means different domains (or subdomains) for specific users and those domains are also associated with a specific tenant.

The domain table it is the place where that information is stored.

Fields

  • domain - Unique domain for the specific associated tenant. Mandatory.
  • tenant - The foreign key associated with the newly created tenant (or existing tenant). Mandatory.
  • is_primary - Flag indicating if the domain of the tenant is the primary or othwerise.

    Default: True

How to use it

The way the DomainMixin should be used it is very simple.

import edgy
from edgy.contrib.multi_tenancy.models import DomainMixin, TenantMixin

database = edgy.Database("<YOUR-CONNECTION-STRING>")
registry = edgy.Registry(database=database)


class Tenant(TenantMixin):
    """
    Inherits all the fields from the `TenantMixin`.
    """

    class Meta:
        registry = registry


class Domain(DomainMixin):
    """
    Inherits all the fields from the `DomainMixin`.
    """

    class Meta:
        registry = registry

TenantUser

Now this is a special table. This table was initially created and designed for the Django Tenants URL approach and aimed to help solving the multi-tenancy on a path level, meaning, instead of checking for subdomains for a tenant, it would look at the URL path and validate the user from there.

This is sometimes referred and sub-folder. Django Tenants recently decided to also solve that problem natively and the same author of Edgy and Django Tenants URL offer to donate the package to the main package since it does solve that problem already.

For that reason, it was decided to also provide the same level of support in this contrib approach as this is a wide use case for a lot of companies with specific levels of security and infrastructure designs.

Fields

  • user - Foreign key to the user. This is where the settings.auth_user_model is used. Mandatory.
  • tenant - Foreign key to the tenant. This is where the settings.tenant_model is used. Mandatory.
  • is_active - Flag indicating if the tenant associated with the user in the TenantUser model is active or not.

    Default: False * created_on - Date of the creation of the record. Automatically generates if nothing is provided.

How to use it

The way the DomainMixin should be used it is very simple.

import edgy
from edgy.contrib.multi_tenancy.models import DomainMixin, TenantMixin, TenantUserMixin

database = edgy.Database("<YOUR-CONNECTION-STRING>")
registry = edgy.Registry(database=database)


class Tenant(TenantMixin):
    """
    Inherits all the fields from the `TenantMixin`.
    """

    class Meta:
        registry = registry


class Domain(DomainMixin):
    """
    Inherits all the fields from the `DomainMixin`.
    """

    class Meta:
        registry = registry


class TenantUser(TenantUserMixin):
    """
    Inherits all the fields from the `TenantUserMixin`.
    """

    class Meta:
        registry = registry

Example

Well with all the models and explanations covered, is time to create a practical example where all of this is applied, this way it will make more sense to understand what is what and how everything works together 🔥.

For this example we will be using Esmerald and Esmerald middleware with Edgy. We will be also be creating:

All of this will come together and in the end an Esmerald API with middleware and an endpoint will be the final result.

Create the initial models

Let us create the initial models where we will be storing tenant information among other things.

models.py
import edgy
from edgy.contrib.multi_tenancy import TenantModel
from edgy.contrib.multi_tenancy.models import DomainMixin, TenantMixin, TenantUserMixin

database = edgy.Database("<YOUR-CONNECTION-STRING>")
registry = edgy.Registry(database=database)


class Tenant(TenantMixin):
    """
    Inherits all the fields from the `TenantMixin`.
    """

    class Meta:
        registry = registry


class Domain(DomainMixin):
    """
    Inherits all the fields from the `DomainMixin`.
    """

    class Meta:
        registry = registry


class TenantUser(TenantUserMixin):
    """
    Inherits all the fields from the `TenantUserMixin`.
    """

    class Meta:
        registry = registry


class User(TenantModel):
    """
    The model responsible for users across all schemas.
    What we can also refer as a `system user`.

    We don't want this table to be across all new schemas
    created, just the default (or shared) so `is_tenant = False`
    needs to be set.
    """

    name: str = edgy.CharField(max_length=255)
    email: str = edgy.EmailField(max_length=255)

    class Meta:
        registry = registry
        is_tenant = False


class HubUser(User):
    """
    This is a schema level type of user.
    This model it is the one that will be used
    on a `schema` level type of user.

    Very useful we want to have multi-tenancy applications
    where each user has specific accesses.
    """

    name: str = edgy.CharField(max_length=255)
    email: str = edgy.EmailField(max_length=255)

    class Meta:
        registry = registry
        is_tenant = True


class Item(TenantModel):
    """
    General item that should be across all
    the schemas and public inclusively.
    """

    sku: str = edgy.CharField(max_length=255)

    class Meta:
        registry = registry
        is_tenant = True

So, so far some models were created just for this purpose. You will notice that two different user models were created and that is intentional.

The main reason for those two different models it is because we might want to have speific users for specific schemas for different application access level purposes as well as the system users where the tenant is checked and mapped.

Create the TenancySettings

With all the models defined, we can now create our TenancySettings objects and make it available to be used by Edgy.

The settings can be stored in a location like myapp/configs/edgy/settings.py

settings.py
from edgy.contrib.multi_tenancy.settings import TenancySettings


class EdgySettings(TenancySettings):
    tenant_model: str = "Tenant"
    """
    The Tenant model created
    """
    auth_user_model: str = "User"
    """
    The `user` table created. Not the `HubUser`!
    """

Make the settings globally available to Edgy.

$ export EDGY_SETTINGS_MODULE=myapp.configs.edgy.settings.EdgySettings

Exporting as an environment variable will make sure Edgy will use your settings instead of the default one. You don't need to worry about the default settings as the TenancySettings inherits all the default settings from Edgy.

Create the middleware

Now this is where the things start to get exciting. Let us create the middleware that will check for the tenant and automatically set the tenant for the user.

Danger

The middleware won't be secure enough for production purposes. Don't use it directly like this. Make sure you have your own security checks in place!

The middleware will be very simple as there is no reason to complicate for this example.

The TenantMiddleware will be only reading from a given header tenant and match that tenant against a TenantUser. If that tenant user exists, then sets the global application tenant to the found one, else ignores it.

Because we won't be implementing any authentication system in this example where Esmerald has a lot of examples that can be checked in the docs, we will be also passing an email in the header just to run some queries against.

Simple right?

middleware.py
from typing import Any, Coroutine

from esmerald import Request
from esmerald.protocols.middleware import MiddlewareProtocol
from lilya.types import ASGIApp, Receive, Scope, Send
from myapp.models import Tenant, TenantUser, User

from edgy import ObjectNotFound
from edgy.core.db import with_tenant


class TenantMiddleware(MiddlewareProtocol):
    def __init__(self, app: "ASGIApp"):
        super().__init__(app)
        self.app = app

    async def __call__(
        self, scope: Scope, receive: Receive, send: Send
    ) -> Coroutine[Any, Any, None]:
        """
        The middleware reads the `tenant` and `email` from the headers
        and uses it to run the queries against the database records.

        If there is a relationship between `User` and `Tenant` in the
        `TenantUser`, it will use the `with_tenant` to set the global
        tenant for the user calling the APIs.
        """
        request = Request(scope=scope, receive=receive, send=send)

        schema = request.headers.get("tenant", None)
        email = request.headers.get("email", None)

        try:
            tenant = await Tenant.query.get(schema_name=schema)
            user = await User.query.get(email=email)

            # Raises ObjectNotFound if there is no relation.
            await TenantUser.query.get(tenant=tenant, user=user)
            tenant = tenant.schema_name
        except ObjectNotFound:
            tenant = None

        with with_tenant(tenant):
            await self.app(scope, receive, send)

As mentioned in the comments of the middleware, it reads the tenant and email from the headers and uses it to run the queries against the database records and if there is a relationship between User and Tenant in the TenantUser, it will use the with_tenant to set the global tenant for the user calling the APIs.

Create some mock data

Not it is time to create some mock data to use it later.

mock_data.py
from myapp.models import HubUser, Product, Tenant, TenantUser, User

from edgy import Database

database = Database("<YOUR-CONNECTION-STRING>")


async def create_data():
    """
    Creates mock data
    """
    # Global users
    john = await User.query.create(name="John Doe", email="john.doe@esmerald.dev")
    edgy = await User.query.create(name="Edgy", email="edgy@esmerald.dev")

    # Tenant
    edgy_tenant = await Tenant.query.create(schema_name="edgy", tenant_name="edgy")

    # HubUser - A user specific inside the edgy schema
    edgy_schema_user = await HubUser.query.using(schema=edgy_tenant.schema_name).create(
        name="edgy", email="edgy@esmerald.dev"
    )

    await TenantUser.query.create(user=edgy, tenant=edgy_tenant)

    # Products for Edgy HubUser specific
    for i in range(10):
        await Product.query.using(schema=edgy_tenant.schema_name).create(
            name=f"Product-{i}", user=edgy_schema_user
        )

    # Products for the John without a tenant associated
    for i in range(25):
        await Product.query.create(name=f"Product-{i}", user=john)


# Start the db
await database.connect()

# Run the create_data
await create_data()

# Close the database connection
await database.disconnect()

What is happening

In fact it is very simple:

  1. Creates two global users (no schema associated).
  2. Creates a Tenant for edgy. As mentioned above, when a record is created, it will automatically generate the schema and the corresponding tables using the schema_name provided on save.
  3. Creates a HubUser (remember that table? The one that only exists inside each generated schema?) using the newly edgy generated schema.
  4. Creates a relation TenantUser between the global user edgy and the newly tenant.
  5. Adds products on a schema level for the edgy user specific.
  6. Adds products to the global user (no schema associated) John.

Create the API

Now it is time to create the Esmerald API that will only read the products associated with the user that it is querying it.

api.py
from typing import List

from esmerald import get
from myapp.models import Product

import edgy


@get("/products")
async def products() -> List[Product]:
    """
    Returns the products associated to a tenant or
    all the "shared" products if tenant is None.

    The tenant was set in the `TenantMiddleware` which
    means that there is no need to use the `using` anymore.
    """
    products = await Product.query.all()
    return products

The application

Now it is time to actually assemble the whole application and plug the middleware.

api.py
from typing import List

from esmerald import Esmerald, Gateway, get
from myapp.middleware import TenantMiddleware
from myapp.models import Product

import edgy

database = edgy.Database("<TOUR-CONNECTION-STRING>")
models = edgy.Registry(database=database)


@get("/products")
async def products() -> List[Product]:
    """
    Returns the products associated to a tenant or
    all the "shared" products if tenant is None.

    The tenant was set in the `TenantMiddleware` which
    means that there is no need to use the `using` anymore.
    """
    products = await Product.query.all()
    return products


app = models.asgi(
    Esmerald(
        routes=[Gateway(handler=products)],
        middleware=[TenantMiddleware],
    )
)

And this should be it! We now have everything we want and need to start querying our products from the database. Let us do it then!

Run the queries

Let us use the httpx package since it is extremely useful and simple to use. Feel free to choose any client you prefer.

api.py
import httpx

# Query the products for the `Edgy` user from the `edgy` schema
# by passing the tenant and email header.
async with httpx.AsyncClient() as client:
    response = await client.get(
        "/products", headers={"tenant": "edgy", "email": "edgy@esmerald.dev"}
    )
    assert response.status_code == 200
    assert len(response.json()) == 10  # total inserted in the `edgy` schema.

# Query the shared database, so no tenant or email associated
# In the headers.
async with httpx.AsyncClient() as client:
    response = await client.get("/products")
    assert response.status_code == 200
    assert len(response.json()) == 25  # total inserted in the `shared` database.

And it should be pretty much it 😁. Give it a try with your own models, schemas and data. Have fun with this out-of-the-box multi-tenancy approach.

Notes

As mentioned before, Edgy does not suppor yet multi-tenancy migrations templates. Although the migration system uses alembic under the hood, the multi-tenant migrations must be managed by you.

The contrib upon the creation of a tenant, it will generate the tables and schema for you based on what is already created in the public schema but if there is any change to be applied that must be carefully managed by you from there on.