The pieces
- TenantContext: a request-scoped holder for the current tenant.
- TenantGuard: reads the tenant claim from the authenticated JWT and populates the context.
- Tenanted: an entity mixin that adds a tenant_id column.
- @RequireTenant: marks routes that must run inside a tenant.
Guarding tenant routes
TenantGuard runs after AuthGuard. A route marked @RequireTenant with no tenant claim is rejected with a 403, and so is a request whose tenant_id path parameter disagrees with the claim.
from austial import Controller, Get, UseGuards
from austial.auth import AuthGuard
from austial.tenancy import RequireTenant, TenantContext, TenantGuard
@Controller("/invoices")
@UseGuards(AuthGuard, TenantGuard)
@RequireTenant()
class InvoicesController:
@Get()
def list(self, ctx: TenantContext):
return {"tenant": ctx.current_id()}Deny by default
If every non-public route in your product is tenant-scoped, subclass the guard and set require_by_default = True. A forgotten @RequireTenant() can then no longer ship a route that runs without a tenant.
Explicit tenant filters, on purpose
Austial does not inject tenant filters into your queries. Product code writes the tenant condition itself, which keeps it visible in query reviews instead of hidden behind magic. Audit rows carry the tenant too, because the guard publishes it for the @Audited interceptor.
Frequently asked questions
How does Austial know the current tenant?
TenantGuard reads the tenant claim from the authenticated principal's JWT and stores it in the request-scoped TenantContext.
Does it filter database queries automatically?
No. The perimeter is enforced by the guard, while the persistence boundary stays explicit: your code adds the tenant condition to queries.
Does it work with the audit log?
Yes. The tenant id is published on the request, and the audit interceptor reads it so audit entries carry the tenant.