Skip to content

msflib-tenancy

Purpose

msflib-tenancy provides the Tenant table and the code that resolves "the" tenant for a request or a background job. Other modules point their tenant_id columns at tenant.id and stamp every scoped row with it.

Today the module is deliberately small. It runs the app as a single tenant: one default tenant is seeded at startup and resolved for every caller. It has no router, no provisioning flow, and no tenant-level roles. It is the foundation that lets a host app grow into several tenants without changing every table, and the seam where per-request tenant selection will plug in.

For how tenants, workspaces and accounts fit together, see Scopes, tenancy and workspaces. The short version is under Key concepts.

Install

[tool.poetry.dependencies]
msflib = { git = "https://github.com/msflib/fastapi.git", subdirectory = "core", rev = "core-v0.2.1" }
msflib-tenancy = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/tenancy", rev = "tenancy-v0.2.0" }

The package depends only on core. msflib-workspaces and msflib-workspace-config depend on it.

msflib.tenancy exports TenancySettings, TenantAction, TenantBase, TenantCreate, TenantRead, TenantStatus, TenantUpdate, resolve_default_tenant_id and reset_default_tenant_id_cache. It deliberately does not export the Tenant table class; import it fully qualified:

from msflib.tenancy.models.tenant import Tenant

get_tenant_dependencies lives in msflib.tenancy.deps, and resolve_scoped_tenant_id in msflib.tenancy.resolver.

Wiring into a host app

  1. Make sure the Tenant table exists (import Tenant before SQLModel.metadata.create_all, or include it in your migrations).
  2. Seed the default tenant at startup.
  3. Build get_current_tenant and pass it to every router or scope dependency that takes one.
from msflib.tenancy import TenantAction, resolve_default_tenant_id
from msflib.tenancy.deps import get_tenant_dependencies
from msflib.tenancy.models import TenantCreate, TenantUpdate
from msflib.tenancy.models.tenant import Tenant

tenancy_settings = settings.scope("TENANCY")  # settings is your host settings object
tenant_action = TenantAction[Tenant, TenantCreate, TenantUpdate]()

# At startup, once the tables exist:
with Session(engine) as session:
    tenant_action.ensure_default_tenant(session, settings=tenancy_settings)

# At app construction:
tenant_deps = get_tenant_dependencies(session_dep=get_session, settings=tenancy_settings)
get_current_tenant = tenant_deps.get_current_tenant

get_current_tenant returns the Tenant row. It never creates one: if the default tenant was not seeded it raises HTTP 500 ("Default tenant has not been seeded"), because a missing tenant at request time is a deployment error.

Pass it onward:

  • msflib.scope.get_scope_dependencies(get_current_tenant=..., ...) builds the tenant, workspace and account ScopeEnvelope dependencies used by the AI and document modules.
  • msflib.workspaces.router.router(..., get_current_tenant=...) stamps new workspaces with the tenant.

If you omit it, those consumers either carry no tenant dimension or fall back to resolve_default_tenant_id.

Known issue (#288)

Several routers (documents, ingestion, conversation, ai-api, and workspaces when built without settings=) resolve that fallback tenant with the default slug and ignore your DEFAULT_TENANT_SLUG. With a customised slug, pass settings= where the router or dependency factory accepts it, or keep the slug default.

There is no router to mount.

Configuration

Settings live in the TENANCY namespace (TenancySettings).

Key Default Notes
DEFAULT_TENANT_SLUG default Slug of the tenant treated as "the" tenant.
DEFAULT_TENANT_NAME Default Tenant Display name used when it is first created.

With subclassed settings use the flat names (DEFAULT_TENANT_SLUG=main); with composed settings (TENANCY: TenancySettings = TenancySettings()) both the flat names and TENANCY__DEFAULT_TENANT_SLUG work. Pass the same settings (settings.scope("TENANCY")) to seeding and to get_tenant_dependencies; otherwise they may resolve different slugs. A standalone TenancySettings() does not read the environment, so it always has the class defaults.

Behaviour may change (#280)

The nested NS__KEY environment style only works for composed (field) settings, not for subclassed hosts. With subclassed settings, use the flat names.

Key concepts

Tenants, workspaces and accounts as scope dimensions

Three identifiers describe who a piece of data belongs to: tenant_id, workspace_id and account_id. MSFLib carries them together in a ScopeEnvelope (see msflib.scope). tenant_id and workspace_id are handled the same way wherever scopes are built, compared or turned into keys: both come from get_current_* dependencies, both are stringified into the envelope, and both appear as dimensions in the storage-key scheme used by workspace-config. A feature that adds a scoped column or key for one should add the other.

Two rules are worth stating plainly:

  • A missing workspace is a specific bucket, not a wildcard. workspace_id = None means "the anonymous workspace": rows and lookups that belong to no workspace. A query that passes None matches only those rows. It never means "any workspace". The built-in scope profiles encode this as required_nullable: the dimension is always present, and None is matched exactly. Row-scope helpers make you opt in explicitly (RowScope(workspace=None)) so a model without a workspace_id column cannot land in the anonymous workspace by accident.
  • Tenant membership does not imply workspace access. Workspace access is decided by membership rows in msflib-workspaces (see auth). The tenant row records only owner_account_id, an informational soft reference with no foreign key and no permissions attached. Nothing in msflib-tenancy or msflib-auth lets a tenant owner or administrator act inside a workspace they have not joined; they join workspaces to administer them, like any other member.

Behaviour may change (#267)

Today the scope profiles require tenant_id but allow a null workspace_id, so the two dimensions are not fully symmetric, and the drivelink node has a workspace_id but no tenant_id. Whether the profiles should treat the two the same is undecided.

Behaviour may change (#268)

There is currently no tenant-admin role and no tenant-level workspace access, and no test pins this behaviour. Whether tenant administrators should get such access is undecided.

Workspaces belong to a tenant: Workspace.tenant_id is a required foreign key to tenant.id.

The model

TenantBase fields: name, slug (unique), status (TenantStatus.active or suspended) and owner_account_id. TenantAction.create slugifies the name when no slug is given and makes the slug unique.

Default tenant resolution

resolve_default_tenant_id(session, settings=None, create_if_missing=True) returns the integer id of the default tenant. Use it where there is no request, such as a background job or a migration script. Behaviour worth knowing:

  • Results are cached per process and per database, and only after the creating transaction commits. Other sessions never see an id for a row that is not yet visible to them.
  • With create_if_missing=False it returns None for an unseeded tenant instead of inserting one. Use this on read-only paths.
  • It is idempotent, but do not rely on it under concurrent first-time seeding. ensure_default_tenant inserts in a savepoint, and a process that loses the race hits the unique slug and tries to reload the winner's row, but the resolver does not retry, and if the winner's row is not yet visible (for example it has not committed) the original IntegrityError is raised. It never creates a second default tenant. Seed once at startup, or retry the first call.
  • reset_default_tenant_id_cache() clears the cache. Call it in tests that each build their own engine.

resolve_scoped_tenant_id(session, dim_value) is the read-path counterpart: it prefers a tenant id already carried in a scope (set at the endpoint boundary), falls back to the default tenant only when the dimension is absent, and raises ValueError on a malformed value rather than quietly using the default tenant.

Convention for tenant columns

A column that should always belong to exactly one tenant is a non-nullable foreign key to tenant.id, filled with a real id from resolve_default_tenant_id or ensure_default_tenant. Do not use placeholders. A column meaningful only for some rows (for example a profile that is tenant-scoped only sometimes) stays nullable, and NULL there means "not tenant-scoped". A scope dimension has no foreign key behind it; scope construction keeps a missing tenant as None, and host code should resolve a real tenant id. Do not use 0 or any other placeholder for it in your own columns or scopes. The one internal exception is that ai_api (when neither get_current_tenant nor get_session is wired) and the conversation memory-cleanup bridge pass 0 as a "no tenant" value to satisfy profiles that require a non-null tenant_id; real ids start at 1.

Examples

Seed, resolve and inject the tenant

from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from msflib.tenancy import TenancySettings, TenantAction, resolve_default_tenant_id
from msflib.tenancy.deps import get_tenant_dependencies
from msflib.tenancy.models import TenantCreate, TenantUpdate
from msflib.tenancy.models.tenant import Tenant
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine

engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
SQLModel.metadata.create_all(engine)


def get_session():
    with Session(engine) as session:
        yield session


settings = TenancySettings()
tenant_action = TenantAction[Tenant, TenantCreate, TenantUpdate]()
tenant_deps = get_tenant_dependencies(session_dep=get_session, settings=settings)

app = FastAPI()


@app.get("/tenant")
def show_tenant(tenant: Tenant = Depends(tenant_deps.get_current_tenant)):
    return {"id": tenant.id, "slug": tenant.slug}


client = TestClient(app)
client.get("/tenant").status_code  # 500: not seeded yet

with Session(engine) as session:
    tenant = tenant_action.ensure_default_tenant(session, settings=settings)
    resolve_default_tenant_id(session, settings=settings)  # 1

client.get("/tenant").json()  # {"id": 1, "slug": "default"}

Create an additional tenant

with Session(engine) as session:
    other = tenant_action.create(session, data=TenantCreate(name="Globex Corp"))
    other.slug  # "globex-corp"

Creating a second tenant does not change which tenant get_current_tenant returns: it still resolves the default one. Choosing a tenant per request is not implemented in this module.

Use the id in a background job

with Session(engine) as session:
    tenant_id = resolve_default_tenant_id(session, settings=settings)
    # stamp tenant_id on rows you create

Troubleshooting

Symptom Cause and fix
500 "Default tenant has not been seeded" Startup seeding did not run. Call TenantAction.ensure_default_tenant (or resolve_default_tenant_id) once at startup.
no such table: tenant Tenant was not imported before create_all, or the migration is missing. Import msflib.tenancy.models.tenant.
ImportError: cannot import name 'Tenant' from 'msflib.tenancy' By design. Import from msflib.tenancy.models.tenant.
Wrong tenant id in tests The id cache can outlive an engine. Call reset_default_tenant_id_cache() between tests.
Slug of the default tenant differs between seeding and requests Two TenancySettings instances with different DEFAULT_TENANT_SLUG. Share one instance.
ValueError: Malformed tenant_id scope dimension A scope carried a non-numeric tenant_id. Fix the caller; it is not silently replaced by the default.

API reference

See the generated API reference for msflib.tenancy. modules/tenancy/README.md has the convention notes in more detail.

See also