Isolate Bursar tenants
Prerequisites
- Install the Bursar CLI and have a PostgreSQL
DATABASE_URL— see CLI and deployment. - 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 the migrations with
DATABASE_URLset (see CLI and deployment). - Provision the tenant with an operator connection:
export DATABASE_URL=postgresql://...
bursar tenant create acme \
--id 018f7f5f-7b4a-7000-8000-000000000001 \
--display-name "Acme"
- Deployments that also need to publish initial pricing can use the idempotent bootstrap boundary, which validates the config before provisioning:
export BURSAR_TENANT_ID=018f7f5f-7b4a-7000-8000-000000000001
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 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):
- Python
- 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,
)
)
import { PostgresStore } from "@zonastery/bursar";
import { createBursarRuntime } from "@zonastery/bursar/node";
const store = new PostgresStore({ postgres: databaseUrl, tenantId });
const runtime = await createBursarRuntime({
postgres: databaseUrl,
tenantId,
});
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:
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.