Skip to content

Multitenancy¤

This module implements multitenancy, meaning that your application can be used by a number of independent subjects (tenants, for example companies) without interfering with each other.

Getting started¤

To set up an application with multi-tenant web interface, create an application with a web server and initialize asab.web.tenant.TenantService. Tenant service automatically tries to install tenant context wrapper to your web handlers, which enables you to access the request's tenant context using asab.contextvars.Tenant.get().

import asab
import asab.web
import asab.web.tenant
import asab.contextvars

class MyApplication(asab.Application):
    def __init__(self):
        super().__init__()

        # Initialize web module
        asab.web.create_web_server(self)

        # Initialize tenant service in strict mode...
        self.TenantService = asab.web.tenant.TenantService(self)

        # ...OR in non-strict mode
        self.TenantService = asab.web.tenant.TenantService(self, strict=False)

Note

If your app has more than one web container, you will need to call TenantService.install(web_container) to apply the tenant context wrapper.

Tenant module offers two modes of tenant awareness: strict and non-strict.

Strict mode¤

In strict mode, the tenant context is mandatory for every request and all endpoint paths must start with the /{tenant} path parameter, no exceptions allowed. This mode is useful for applications that operate on tenants as business objects.

import asab
import asab.web
import asab.web.tenant
import asab.contextvars

class NotesApplication(asab.Application):
    def __init__(self):
        super().__init__()
        web = asab.web.create_web_server(self)
        tenant_svc = asab.web.tenant.TenantService(self)

        web.add_get("/{tenant}/notes", self.list_notes)  # Tenant parameter required as the first path component

    async def list_notes(self, request):
        tenant = asab.contextvars.Tenant.get()
        print("Requesting notes for tenant {!r}...".format(tenant))

Non-strict mode¤

In non-strict mode, the tenant context is by default mandatory for every endpoint, but the parameter is usually provided in the URL query. It can also be in the path, but it is not allowed to be the first path component. Non-strict mode also allows to define endpoints that do not require tenant context at all, using the @allow_no_tenant decorator.

Mandatory tenant in query¤

Define your endpoint path without the tenant path parameter and the handler will require tenant to be present in the URL query. Requests without that parameter will result in aiohttp.web.HTTPNotFound (HTTP 404).

import asab
import asab.web
import asab.web.tenant
import asab.contextvars

class NotesApplication(asab.Application):
    def __init__(self):
        super().__init__()
        web = asab.web.create_web_server(self)
        tenant_svc = asab.web.tenant.TenantService(self)

        web.add_get("/notes", self.list_notes)  # No tenant parameter in path!

    async def list_notes(self, request):
        tenant = asab.contextvars.Tenant.get()
        print("Requesting notes for tenant {!r}...".format(tenant))

Mandatory tenant in path¤

If tenant context is mandatory for your endpoint, it is recommended to require the tenant parameter in the URL path, such as:

import asab
import asab.web
import asab.web.tenant
import asab.contextvars

class NotesApplication(asab.Application):
    def __init__(self):
        super().__init__()
        web = asab.web.create_web_server(self)
        tenant_svc = asab.web.tenant.TenantService(self)

        web.add_get("/notes/{tenant}", self.list_notes)  # Tenant parameter required in path

    async def list_notes(self, request):
        tenant = asab.contextvars.Tenant.get()
        print("Requesting notes for tenant {!r}...".format(tenant))

Optional tenant in query¤

When the tenant context is optional for your endpoint (or when the endpoint does not use tenants at all), define its path without the tenant parameter in path and decorate the method handler with @asab.web.tenant.allow_no_tenant. Requests without the tenant parameter will have their Tenant context set to None.

Note that the decorator is intended to be used only sparsely for exceptions.

import asab
import asab.web
import asab.web.tenant
import asab.contextvars

class NotesApplication(asab.Application):
    def __init__(self):
        super().__init__()
        web = asab.web.create_web_server(self)
        tenant_svc = asab.web.tenant.TenantService(self)

        web.add_get("/notes", self.list_notes)  # No tenant parameter in path!

    @asab.web.tenant.allow_no_tenant  # Allow requests with undefined tenant!
    async def list_notes(self, request):
        tenant = asab.contextvars.Tenant.get()
        if tenant is None:
            print("Requesting notes without any tenant. Not sure what to do...")
        else:
            print("Requesting notes for tenant {!r}...".format(tenant))

Working with known tenants¤

When you provide tenant_url or tenant ids in the configuration, TenantService will make the set of known tenants available through its Tenants property. You can also make use of the TenantService.is_tenant_known(tenant) method.

Note

If you only want to use the service to access known tenants and do not need the web middleware, initialize TenantService with set_up_web_wrapper argument set to False.

Configuration¤

The asab.web.tenant module is configured in the [tenants] section with the following options:

Option Type Meaning
ids List of strings (Optional) Known tenant IDs.
tenant_url URL (Optional) Location of a JSON array of known tenant IDs.

Reference¤

asab.web.tenant.TenantService ¤

Bases: Service

Provides set of known tenants and tenant extraction for web requests.

Source code in asab/web/tenant/service.py
class TenantService(Service):
	"""
	Provides set of known tenants and tenant extraction for web requests.
	"""

	def __init__(
		self,
		app,
		service_name: str = "asab.TenantService",
		auto_install_web_wrapper: bool = True,
		strict: bool = True,
	):
		"""
		Initialize and register a new TenantService.

		Args:
			app: ASAB application.
			service_name: ASAB service identifier.
			auto_install_web_wrapper: Whether to automatically install tenant context wrapper to WebContainer.
			strict:
				If True, tenant is required as the first path component for all web handlers
				and @allow_no_tenant decorator cannot be used.
				If False, tenant is required either in path (any position except the first)
				or as a query parameter or @allow_no_tenant decorator must be present.
		"""
		super().__init__(app, service_name)
		auth_svc = self.App.get_service("asab.AuthService")
		if auth_svc is not None:
			raise RuntimeError("Please initialize TenantService before AuthService.")

		self.Strict = strict
		self.Providers: typing.List[TenantProviderABC] = []  # Must be a list to be deterministic
		self._IsReady = False
		self._prepare_providers()

		if auto_install_web_wrapper:
			self._try_auto_install()

		self.App.PubSub.subscribe("Application.tick/300!", self._every_five_minutes)


	async def initialize(self, app):
		if len(self.Providers) == 0:
			L.error(
				"TenantService requires at least one provider. "
				"Specify either `tenant_url` or `ids` in the [tenants] config section."
			)

		await self.update_tenants()


	@property
	def Tenants(self) -> typing.Set[str]:
		"""
		DEPRECATED. Get the set of known tenant IDs.

		Deprecated since v25.01: Use coroutine `get_tenants()` instead.
		"""
		raise AttributeError("Property `Tenants` has been removed. Use coroutine `get_tenants()` instead.")


	def _prepare_providers(self):
		if Config.get("tenants", "ids", fallback=None):
			from .providers import StaticTenantProvider
			self.Providers.append(StaticTenantProvider(self.App, self, Config["tenants"]))

		if Config.get("tenants", "tenant_url", fallback=None):
			from .providers import WebTenantProvider
			self.Providers.append(WebTenantProvider(self.App, self, Config["tenants"]))

		if Config.get("tenants", "zk_path", fallback=None):
			from .providers import ZookeeperTenantProvider
			self.Providers.append(ZookeeperTenantProvider(self.App, self, Config["tenants"]))


	async def _every_five_minutes(self, message_type=None):
		await self.update_tenants()


	async def update_tenants(self):
		"""
		Update all tenant providers.
		"""
		tasks = [provider.update() for provider in self.Providers]
		await asyncio.gather(*tasks)


	async def get_tenants(self) -> typing.Set[str]:
		"""
		Get the set of known tenant IDs.

		Returns:
			The set of known tenant IDs.
		"""
		await self.update_tenants()
		tenants = set()
		for provider in self.Providers:
			tenants |= await provider.get_tenants()

		return tenants


	async def is_tenant_known(self, tenant: str) -> bool:
		"""
		Check if the tenant is among known tenants.

		Args:
			tenant: Tenant ID to check.

		Returns:
			Whether the tenant is known.
		"""
		if tenant is None:
			return False
		if len(self.Providers) == 0:
			L.warning("No tenant provider registered.")
			return False
		for provider in self.Providers:
			if await provider.is_tenant_known(tenant):
				return True

		# Tenant not found; try to update tenants and try again
		await self.update_tenants()
		for provider in self.Providers:
			if await provider.is_tenant_known(tenant):
				return True

		return False


	def install(self, web_container, strict: bool = None):
		"""
		Apply tenant context wrappers to all web handlers in the web container.

		Args:
			web_container: Web container to add tenant context to.
			strict: If True, tenant is required as the first path component for all routes in the container.
		"""
		web_service = self.App.get_service("asab.WebService")
		if strict is None:
			strict = self.Strict

		# Check that the middleware has not been installed yet
		for middleware in web_container.WebApp.on_startup:
			if isinstance(middleware, TenantWebWrapperInstaller):
				if len(web_service.Containers) == 1:
					raise RuntimeError(
						"WebContainer has tenant middleware installed already. "
						"You don't need to call `TenantService.install()` in applications with a single WebContainer; "
						"it is called automatically at init time."
					)
				else:
					raise RuntimeError("WebContainer has tenant middleware installed already.")

		web_container.WebApp.on_startup.append(TenantWebWrapperInstaller(self, strict=strict))


	def is_ready(self) -> bool:
		"""
		Check if all tenant providers are ready.

		Returns:
			bool: Are all tenant providers ready?
		"""
		self.check_ready()
		return self._IsReady


	def check_ready(self):
		"""
		Check and update tenant service ready status.
		"""
		if len(self.Providers) == 0:
			return

		# Check if all providers are ready
		is_ready_now = False
		for provider in self.Providers:
			if not provider.is_ready():
				break
		else:
			is_ready_now = True

		if self._IsReady == is_ready_now:
			return

		# Ready status changed
		if is_ready_now:
			L.log(LOG_NOTICE, "is ready.")
			self.App.PubSub.publish("Tenants.ready!", self)
		else:
			L.log(LOG_NOTICE, "is NOT ready.")
			self.App.PubSub.publish("Tenants.not_ready!", self)

		self._IsReady = is_ready_now


	def get_web_wrapper_position(self, web_container) -> typing.Optional[int]:
		"""
		Check if tenant web wrapper is installed in container and where.

		Args:
			web_container: Web container to inspect.

		Returns:
			typing.Optional[int]: The index at which the wrapper is located, or `None` if it is not installed.
		"""
		for i, obj in enumerate(web_container.WebApp.on_startup):
			if isinstance(obj, TenantWebWrapperInstaller):
				return i
		return None


	def _try_auto_install(self):
		"""
		If there is exactly one web container, install tenant middleware on it.
		"""
		web_service = self.App.get_service("asab.WebService")
		if web_service is None:
			return
		if len(web_service.Containers) != 1:
			return
		web_container = web_service.WebContainer

		self.install(web_container)
		L.debug("WebContainer tenant wrapper will be installed automatically.")

Tenants property ¤

DEPRECATED. Get the set of known tenant IDs.

Deprecated since v25.01: Use coroutine get_tenants() instead.

__init__(app, service_name='asab.TenantService', auto_install_web_wrapper=True, strict=True) ¤

Initialize and register a new TenantService.

Parameters:

Name Type Description Default
app

ASAB application.

required
service_name str

ASAB service identifier.

'asab.TenantService'
auto_install_web_wrapper bool

Whether to automatically install tenant context wrapper to WebContainer.

True
strict bool

If True, tenant is required as the first path component for all web handlers and @allow_no_tenant decorator cannot be used. If False, tenant is required either in path (any position except the first) or as a query parameter or @allow_no_tenant decorator must be present.

True
Source code in asab/web/tenant/service.py
def __init__(
	self,
	app,
	service_name: str = "asab.TenantService",
	auto_install_web_wrapper: bool = True,
	strict: bool = True,
):
	"""
	Initialize and register a new TenantService.

	Args:
		app: ASAB application.
		service_name: ASAB service identifier.
		auto_install_web_wrapper: Whether to automatically install tenant context wrapper to WebContainer.
		strict:
			If True, tenant is required as the first path component for all web handlers
			and @allow_no_tenant decorator cannot be used.
			If False, tenant is required either in path (any position except the first)
			or as a query parameter or @allow_no_tenant decorator must be present.
	"""
	super().__init__(app, service_name)
	auth_svc = self.App.get_service("asab.AuthService")
	if auth_svc is not None:
		raise RuntimeError("Please initialize TenantService before AuthService.")

	self.Strict = strict
	self.Providers: typing.List[TenantProviderABC] = []  # Must be a list to be deterministic
	self._IsReady = False
	self._prepare_providers()

	if auto_install_web_wrapper:
		self._try_auto_install()

	self.App.PubSub.subscribe("Application.tick/300!", self._every_five_minutes)

check_ready() ¤

Check and update tenant service ready status.

Source code in asab/web/tenant/service.py
def check_ready(self):
	"""
	Check and update tenant service ready status.
	"""
	if len(self.Providers) == 0:
		return

	# Check if all providers are ready
	is_ready_now = False
	for provider in self.Providers:
		if not provider.is_ready():
			break
	else:
		is_ready_now = True

	if self._IsReady == is_ready_now:
		return

	# Ready status changed
	if is_ready_now:
		L.log(LOG_NOTICE, "is ready.")
		self.App.PubSub.publish("Tenants.ready!", self)
	else:
		L.log(LOG_NOTICE, "is NOT ready.")
		self.App.PubSub.publish("Tenants.not_ready!", self)

	self._IsReady = is_ready_now

get_tenants() async ¤

Get the set of known tenant IDs.

Returns:

Type Description
Set[str]

The set of known tenant IDs.

Source code in asab/web/tenant/service.py
async def get_tenants(self) -> typing.Set[str]:
	"""
	Get the set of known tenant IDs.

	Returns:
		The set of known tenant IDs.
	"""
	await self.update_tenants()
	tenants = set()
	for provider in self.Providers:
		tenants |= await provider.get_tenants()

	return tenants

get_web_wrapper_position(web_container) ¤

Check if tenant web wrapper is installed in container and where.

Parameters:

Name Type Description Default
web_container

Web container to inspect.

required

Returns:

Type Description
Optional[int]

typing.Optional[int]: The index at which the wrapper is located, or None if it is not installed.

Source code in asab/web/tenant/service.py
def get_web_wrapper_position(self, web_container) -> typing.Optional[int]:
	"""
	Check if tenant web wrapper is installed in container and where.

	Args:
		web_container: Web container to inspect.

	Returns:
		typing.Optional[int]: The index at which the wrapper is located, or `None` if it is not installed.
	"""
	for i, obj in enumerate(web_container.WebApp.on_startup):
		if isinstance(obj, TenantWebWrapperInstaller):
			return i
	return None

install(web_container, strict=None) ¤

Apply tenant context wrappers to all web handlers in the web container.

Parameters:

Name Type Description Default
web_container

Web container to add tenant context to.

required
strict bool

If True, tenant is required as the first path component for all routes in the container.

None
Source code in asab/web/tenant/service.py
def install(self, web_container, strict: bool = None):
	"""
	Apply tenant context wrappers to all web handlers in the web container.

	Args:
		web_container: Web container to add tenant context to.
		strict: If True, tenant is required as the first path component for all routes in the container.
	"""
	web_service = self.App.get_service("asab.WebService")
	if strict is None:
		strict = self.Strict

	# Check that the middleware has not been installed yet
	for middleware in web_container.WebApp.on_startup:
		if isinstance(middleware, TenantWebWrapperInstaller):
			if len(web_service.Containers) == 1:
				raise RuntimeError(
					"WebContainer has tenant middleware installed already. "
					"You don't need to call `TenantService.install()` in applications with a single WebContainer; "
					"it is called automatically at init time."
				)
			else:
				raise RuntimeError("WebContainer has tenant middleware installed already.")

	web_container.WebApp.on_startup.append(TenantWebWrapperInstaller(self, strict=strict))

is_ready() ¤

Check if all tenant providers are ready.

Returns:

Name Type Description
bool bool

Are all tenant providers ready?

Source code in asab/web/tenant/service.py
def is_ready(self) -> bool:
	"""
	Check if all tenant providers are ready.

	Returns:
		bool: Are all tenant providers ready?
	"""
	self.check_ready()
	return self._IsReady

is_tenant_known(tenant) async ¤

Check if the tenant is among known tenants.

Parameters:

Name Type Description Default
tenant str

Tenant ID to check.

required

Returns:

Type Description
bool

Whether the tenant is known.

Source code in asab/web/tenant/service.py
async def is_tenant_known(self, tenant: str) -> bool:
	"""
	Check if the tenant is among known tenants.

	Args:
		tenant: Tenant ID to check.

	Returns:
		Whether the tenant is known.
	"""
	if tenant is None:
		return False
	if len(self.Providers) == 0:
		L.warning("No tenant provider registered.")
		return False
	for provider in self.Providers:
		if await provider.is_tenant_known(tenant):
			return True

	# Tenant not found; try to update tenants and try again
	await self.update_tenants()
	for provider in self.Providers:
		if await provider.is_tenant_known(tenant):
			return True

	return False

update_tenants() async ¤

Update all tenant providers.

Source code in asab/web/tenant/service.py
async def update_tenants(self):
	"""
	Update all tenant providers.
	"""
	tasks = [provider.update() for provider in self.Providers]
	await asyncio.gather(*tasks)

asab.web.tenant.allow_no_tenant(handler) ¤

Allow receiving requests without tenant parameter.

Parameters:

Name Type Description Default
handler

Web handler method

required

Returns:

Type Description

Wrapped web handler that allows requests with undefined tenant.

Examples:

>>> import asab.web.rest
>>> import asab.web.tenant
>>> import asab.contextvars
>>>
>>> @asab.web.tenant.allow_no_tenant
>>> async def info(self, request):
>>>     tenant = asab.contextvars.Tenant.get()
>>>     if tenant is None:
>>>             print("The request does not have a tenant and that's fine.")
>>>     else:
>>>             print("The request has tenant {!r}.".format(tenant))
Source code in asab/web/tenant/decorator.py
def allow_no_tenant(handler):
	"""
	Allow receiving requests without tenant parameter.

	Args:
		handler: Web handler method

	Returns:
		Wrapped web handler that allows requests with undefined tenant.

	Examples:
		>>> import asab.web.rest
		>>> import asab.web.tenant
		>>> import asab.contextvars
		>>>
		>>> @asab.web.tenant.allow_no_tenant
		>>> async def info(self, request):
		>>> 	tenant = asab.contextvars.Tenant.get()
		>>> 	if tenant is None:
		>>> 		print("The request does not have a tenant and that's fine.")
		>>> 	else:
		>>> 		print("The request has tenant {!r}.".format(tenant))
	"""
	handler.AllowNoTenant = True

	@functools.wraps(handler)
	async def _allow_no_tenant_wrapper(*args, **kwargs):
		return await handler(*args, **kwargs)

	return _allow_no_tenant_wrapper