Skip to content

Tenant Isolation

Craft Easy is multi-tenant by default. Every tenant-scoped document carries a tenant_id, every query is silently rewritten to match the caller's tenant, and every write inherits the tenant from the request. The application code rarely touches tenant_id directly — the framework enforces the boundary.

This page explains how isolation works, how to turn it off for shared data, and how system users bypass it.

The master switch

Multi-tenancy is toggled centrally:

class Settings(CraftEasySettings):
    MULTI_TENANT_ENABLED: bool = True

When MULTI_TENANT_ENABLED=False, tenant filtering and feature gating short-circuit. Everything else (the tenant_id column on documents, the admin routes, the quota checks) still exists — but the automatic query-rewrite and feature check are bypassed. Single-tenant deployments can safely leave this off.

Resolving the caller's tenant

Every authenticated request carries a token. The token includes:

  • scope — one of system, partner, tenant
  • tenant_id — the tenant the caller belongs to (for scope="tenant" and scope="partner" users)

craft_easy.core.tenant.get_tenant_for_request(request) reads these fields and loads the Tenant document once per request, caching it on request.state._resolved_tenant. Tenant-scoped code paths (hooks, guards, aggregation helpers) all go through this function, so the DB lookup happens at most once even if twenty guards touch the tenant on the same request.

System users always resolve to None — they operate outside any tenant.

Automatic filtering on tenant-scoped models

A model opts into tenant scoping via its TenantConfig:

from beanie import Document
from craft_easy.core.crud import TenantConfig

class Product(Document):
    name: str
    price: Decimal
    tenant_id: PydanticObjectId  # populated automatically on create

    class TenantConfig:
        tenant_scoped = True

With tenant_scoped=True, Craft Easy rewrites every CRUD query to attach {"tenant_id": <caller_tenant>}. Callers cannot see — or even enumerate — documents belonging to other tenants. Creating a Product through the CRUD layer copies the caller's tenant_id onto the document before insert; explicit tenant_id values from the request body are ignored to prevent tenant-spoofing.

Models that hold shared reference data (like a global list of country codes) set tenant_scoped=False. Those documents live outside the tenant boundary and are visible to every caller. System models such as Tenant, Partner, and TenantAgreement are also not tenant-scoped — they describe tenants, so they cannot belong to one.

Write-time enforcement

Two checks run on every write to a tenant-scoped collection:

  1. check_tenant_enabled — raises 403 if the caller's tenant has is_enabled=False. A disabled tenant can log in (auth is outside the tenant boundary) but cannot mutate any resource.
  2. check_tenant_feature — raises 403 if the route guards a feature that the tenant's enabled_features list does not include. See Agreements for how features are granted through agreements.

Both functions are available as FastAPI dependencies and as direct calls from custom hooks:

from craft_easy.core.tenant import check_tenant_enabled, check_tenant_feature

@router.post("/invoices", dependencies=[Depends(check_tenant_enabled)])
async def create_invoice(request: Request, body: InvoiceCreate):
    await check_tenant_feature(request, "invoicing")
    ...

Per-tenant configuration

A Tenant document carries settings that vary by tenant:

Field Purpose
enabled_features Feature allow-list — gates routes, tabs, background jobs
default_currency, enabled_currencies Currencies accepted for invoices and payments
max_users, max_storage_mb Quotas enforced on create (users) and on upload (storage)
branding Display name, logo, colors, custom domain, custom CSS — see the branding endpoints on TenantRouter
partner_id The managing Partner, if any
parent_tenant_id The parent tenant, if this is a sub-tenant

Every field is read by the framework — you do not need to call anything to apply it. For example, creating a User while the tenant is at max_users fails with 403 via check_user_quota.

The system-user escape hatch

Two callers bypass tenant scoping entirely, even when MULTI_TENANT_ENABLED=True:

  1. Users with scope="system" — the superuser tier. Typically a handful of human operators plus a bootstrap account.
  2. Tokens with is_system_user=True — service accounts and batch workers.

System users can read and write any tenant's data. Use them sparingly: audit logs record the actor but not a tenant, so every system-user action shows up in every tenant's log view. For cross-tenant reporting, prefer aggregation helpers that keep the actor tenant-bound.

Aggregating across tenants

Sometimes a tenant legitimately needs to see across its sub-tenants — a parent company rolling up sales from regional subsidiaries, say. craft_easy.core.tenant provides three helpers for this:

from craft_easy.core.tenant import (
    aggregate_across_sub_tenants,
    sum_field_per_sub_tenant,
    count_per_sub_tenant,
)

# Sum order amounts per tenant under the caller
totals = await sum_field_per_sub_tenant(
    Order,
    parent_tenant_id=caller.tenant_id,
    field="amount",
    match={"status": "completed"},
)
# -> {"65ab...": 12500.0, "65ac...": 3400.0}

# Custom pipeline across the hierarchy
rows = await aggregate_across_sub_tenants(
    Order,
    parent_tenant_id=caller.tenant_id,
    pipeline=[{"$group": {"_id": "$status", "total": {"$sum": "$amount"}}}],
)

All three prepend a $match stage that restricts results to the parent tenant plus its descendants (identified via the materialized tenant_path) and filter out soft-deleted documents. A tenant can only see into tenants it owns — the hierarchy is the permission boundary.

What is stored where

Layer Lives where Scope
Tenant document tenants collection Global (system-scope)
Tenant-scoped data (orders, invoices, etc.) Per-model collection, filtered by tenant_id Tenant
System reference data Per-model collection, no tenant_id Global
User sessions, tokens sessions, refresh_tokens Global, but tagged with tenant for access
Audit log audit_log, every entry tagged with tenant_id Tenant

Isolation is enforced on the data plane (queries) and on the write plane (hooks, quotas). Authentication itself is global — a user logs in once and picks a tenant context — but from the moment the token is issued, every subsequent request is tethered to exactly one tenant, unless the caller is a system user.

Read Organization Hierarchy for the internal structure inside a tenant, and Partners for the system-level wrapper that sits above tenants.