Skip to main content
Version: 2.x

Isolate Bursar tenants

Prerequisites

Outcome

  • A provisioned tenant that every store and runtime is bound to by tenant_id.
  • Database-level isolation: tenant-prefixed unique constraints, forced row-level security, and composite foreign keys.

Bursar uses one PostgreSQL schema with shared tables. Every catalog, credit, usage, quota, team, and billing row carries a mandatory tenant_id. Tenant-prefixed unique constraints let different tenants reuse subject, provider, and idempotency identifiers. Composite foreign keys prevent a row from referencing another tenant's rows even if privileged code has a bug.

Provision and bind a tenant

  1. Run the migrations with DATABASE_URL set (see CLI and deployment).
  2. Provision the tenant with an operator connection:
Terminal
export DATABASE_URL=postgresql://...
bursar tenant create acme \
--id 018f7f5f-7b4a-7000-8000-000000000001 \
--display-name "Acme"
  1. Deployments that also need to publish initial pricing can use the idempotent bootstrap boundary, which validates the config before provisioning:
Terminal
export BURSAR_TENANT_ID=018f7f5f-7b4a-7000-8000-000000000001
bursar tenant bootstrap acme ./pricing.yaml \
--display-name "Acme"
warning

Do not insert into bursar.tenants from host migrations or seed SQL. Tenant storage and lifecycle are Bursar implementation details behind the operator CLI.

Pass the tenant UUID when constructing a store. The store comes from the package top level; the optional runtime composition root lives in bursar.storage (Python) and the @zonastery/bursar/node subpath (TypeScript):

from bursar import PostgresStore
from bursar.storage import BursarRuntimeOptions, create_bursar_runtime

store = PostgresStore(database_url, tenant_id=tenant_id)

runtime = create_bursar_runtime(
BursarRuntimeOptions(
postgres=database_url,
tenant_id=tenant_id,
)
)

Each SDK checks out one pooled connection, starts a transaction, sets bursar.tenant_id with transaction-local scope, performs the RPC, then commits or rolls back before releasing the connection. Never set a session-scoped tenant value on a pooled connection — it would leak into the next caller.

Database enforcement

Tenant RPCs are SECURITY DEFINER functions owned by the bursar_runtime role. That role is NOLOGIN and NOBYPASSRLS. Business tables use forced row level security, so isolation remains active even when the application connects as PostgreSQL service_role, which normally bypasses RLS.

The tenant can also come from request.jwt.claims.app_metadata.tenant_id for a trusted PostgREST integration. Bursar never reads user_metadata, because an end user can change it. Server SDKs should prefer explicit transaction-local binding.

Missing tenant context fails closed on writes. Suspended and closed tenants cannot read or mutate business rows. Operators change lifecycle state:

Terminal
bursar tenant status 018f7f5f-7b4a-7000-8000-000000000001 suspended
bursar tenant status 018f7f5f-7b4a-7000-8000-000000000001 active

Host triggers and external storage

A host application attaches Bursar's tenant-aware trigger API to its principal table and passes its provisioned tenant slug:

CREATE TRIGGER bursar_account_created
AFTER INSERT ON app.users
FOR EACH ROW
EXECUTE FUNCTION bursar.provision_subject_account_on_insert('acme');

Bursar resolves the active tenant, binds transaction-local context, assigns the active default plan, and runs eligible account_created grants. Host SQL must not read Bursar tables or implement those steps itself.

The operator API can claim the global outbox, while SDK runtimes use the tenant-filtered claim overload so one runtime cannot take another tenant's work. Claimed events and exported payloads include tenant_id. S3 keys use <prefix>/tenants/<tenant-id>/billing-events/... (the prefix defaults to bursar), ClickHouse rows and analytics queries include a tenant filter, and archive exports embed the tenant id. Keep this field in any custom outbox handler or projection.