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
- Run migrations with the dedicated migration owner in
BURSAR_MIGRATION_DATABASE_URL(see the CLI reference). - Provision the separate operator and application logins described in the CLI guide, then create the tenant with the operator connection:
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"
- Applications that also need to publish initial pricing can use the idempotent bootstrap boundary, which validates the config before provisioning:
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"
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):
- Python
- 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",
)
)
import { PostgresStore } from "@zonastery/bursar";
import { createBursarRuntime } from "@zonastery/bursar/node";
const store = new PostgresStore({
postgres: databaseUrl,
tenantId,
providerEnvironment: "test",
});
const runtime = await createBursarRuntime({
postgres: databaseUrl,
operatorPostgres: operatorDatabaseUrl,
tenantId,
providerEnvironment: "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:
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.