Skip to main content
Version: 2.x

Isolate Bursar tenants

Prerequisites​

  • Install the Bursar CLI and prepare separate migration, operator, and application runtime PostgreSQL credentials — see the CLI reference.
  • Read credit accounting for the tenant-scoped accounting entities.
  • Have a validated configuration if you want to bootstrap pricing with the tenant.

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 migrations with the dedicated migration owner in BURSAR_MIGRATION_DATABASE_URL (see the CLI reference).
  2. Provision the separate operator and application logins described in the CLI guide, then create the tenant with the operator connection:
Terminal
export BURSAR_OPERATOR_DATABASE_URL=postgresql://bursar_ops@db.example.com/bursar
bursar tenant create acme \
--id 018f7f5f-7b4a-7000-8000-000000000001 \
--display-name "Acme"
  1. Applications that also need to publish initial pricing can use the idempotent bootstrap boundary, which validates the config before provisioning:
Terminal
export DATABASE_URL=postgresql://bursar_app@db.example.com/bursar
export BURSAR_TENANT_ID=018f7f5f-7b4a-7000-8000-000000000001
export BURSAR_PROVIDER_ENVIRONMENT=test
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 and the application runtime connection when constructing a store. The runtime principal must not be a superuser, have BYPASSRLS, or use Supabase's service_role. 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,
provider_environment="test",
)

runtime = create_bursar_runtime(
BursarRuntimeOptions(
postgres=database_url,
operator_postgres=operator_database_url,
tenant_id=tenant_id,
provider_environment="test",
)
)

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​

Business tables use forced row-level security. Server SDKs supply the tenant only through transaction-local bursar.tenant_id on the same checked-out connection as the RPC. Bursar does not derive the tenant from PostgREST JWT metadata, and a privileged connection is not an isolation boundary.

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.