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.
- 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.
- Shared database, different Schemas - The user's data is split by different schemas but live on the same database.
- Different databases - The user's data or any data live on different databases.
Saffier 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 Saffier 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 Saffier (you didn't see this one coming, did you? 😜), Saffier offers one non-core working solution for multi-tenancy.
Warning
Saffier 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 Saffier but using the obvious core components.
The module uses a settings file that inherits from the main settings module of Saffier 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 SAFFIER_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 saffier.contrib.multi_tenancy import TenantModel. TenantRegistry, TenancySettings
from saffier.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 saffier.Model
which means it will apply all the needed
core functionalities but introduces a new internam metaclass
required for the is_tenant
attribute.
import saffier
from saffier.contrib.multi_tenancy import TenantModel, TenantRegistry
database = saffier.Database("<YOUR-CONNECTION-STRING>")
registry = TenantRegistry(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 = saffier.CharField(max_length=255)
email = saffier.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.
Did you notice that a TenantRegistry
was used? Nothing to worry about, it is nothing completely
new to you, it is just an inherited registry with extra properties specifically
created for this purpose 😁.
TenantRegistry¶
The TenantRegistry
as mentioned above, it is just an inherited registry with extra properties specifically
created for the purpose of useing the Saffier contrib module where it adds some extras to make this
integration easier, such as the tenant_models
object which is internally used to understand
which models should be generated upon the creation of a Tenant
automatically.
import saffier
from saffier.contrib.multi_tenancy import TenantRegistry
database = saffier.Database("<YOUR-CONNECTION-STRING>")
registry = TenantRegistry(database=database)
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.
-
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.
-
You inherit the
TenancySettings
object and override the values needed and use it as your SAFFIER_SETTINGS_MODULE.from saffier.contrib.multi_tenancy import TenancySettings
Choose whatver it suits your needs better 🔥.
Model Mixins¶
Saffier 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 Saffier 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 saffier
from saffier.contrib.multi_tenancy import TenantRegistry
from saffier.contrib.multi_tenancy.models import TenantMixin
database = saffier.Database("<YOUR-CONNECTION-STRING>")
registry = TenantRegistry(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 saffier
from saffier.contrib.multi_tenancy import TenantRegistry
from saffier.contrib.multi_tenancy.models import DomainMixin, TenantMixin
database = saffier.Database("<YOUR-CONNECTION-STRING>")
registry = TenantRegistry(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 Saffier 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 saffier
from saffier.contrib.multi_tenancy import TenantRegistry
from saffier.contrib.multi_tenancy.models import DomainMixin, TenantMixin, TenantUserMixin
database = saffier.Database("<YOUR-CONNECTION-STRING>")
registry = TenantRegistry(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 Saffier. 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.
import saffier
from saffier.contrib.multi_tenancy import TenantModel, TenantRegistry
from saffier.contrib.multi_tenancy.models import DomainMixin, TenantMixin, TenantUserMixin
database = saffier.Database("<YOUR-CONNECTION-STRING>")
registry = TenantRegistry(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 = saffier.CharField(max_length=255)
email = saffier.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 = saffier.CharField(max_length=255)
email = saffier.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 = saffier.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 Saffier.
The settings can be stored in a location like myapp/configs/saffier/settings.py
from saffier.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 Saffier.
$ export SAFFIER_SETTINGS_MODULE=myapp.configs.saffier.settings.EdgySettings
Exporting as an environment variable will make sure Saffier 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 Saffier.
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?
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 saffier import ObjectNotFound
from saffier.core.db import set_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 `set_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
set_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 set_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.
from myapp.models import HubUser, Product, Tenant, TenantUser, User
from saffier 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")
saffier = await User.query.create(name="Saffier", email="saffier@esmerald.dev")
# Tenant
edgy_tenant = await Tenant.query.create(schema_name="saffier", tenant_name="saffier")
# HubUser - A user specific inside the saffier schema
edgy_schema_user = await HubUser.query.using(edgy_tenant.schema_name).create(
name="saffier", email="saffier@esmerald.dev"
)
await TenantUser.query.create(user=saffier, tenant=edgy_tenant)
# Products for Saffier HubUser specific
for i in range(10):
await Product.query.using(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:
- Creates two global users (no schema associated).
- Creates a Tenant for
saffier
. As mentioned above, when a record is created, it will automatically generate theschema
and the corresponding tables using theschema_name
provided on save. - Creates a
HubUser
(remember that table? The one that only exists inside each generated schema?) using the newlysaffier
generated schema. - Creates a relation
TenantUser
between the global usersaffier
and the newlytenant
. - Adds products on a schema level for the
saffier
user specific. - 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.
from esmerald import JSONResponse, get
from myapp.models import Product
@get("/products")
async def get_products() -> JSONResponse:
"""
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()
products = [product.pk for product in products]
return JSONResponse(products)
The application¶
Now it is time to actually assemble the whole application and plug the middleware.
from typing import List
from esmerald import Esmerald, Gateway, JSONResponse, get
from myapp.middleware import TenantMiddleware
from myapp.models import Product
import saffier
database = saffier.Database("<TOUR-CONNECTION-STRING>")
models = saffier.Registry(database=database)
@get("/products")
async def get_products() -> JSONResponse:
"""
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()
products = [product.pk for product in products]
return JSONResponse(products)
app = Esmerald(
routes=[Gateway(handler=get_products)],
on_startup=[database.connect],
on_shutdown=[database.disconnect],
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.
import httpx
# Query the products for the `Saffier` user from the `saffier` schema
# by passing the tenant and email header.
async with httpx.AsyncClient() as client:
response = await client.get(
"/products", headers={"tenant": "saffier", "email": "saffier@esmerald.dev"}
)
assert response.status_code == 200
assert len(response.json()) == 10 # total inserted in the `saffier` 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, Saffier 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.