Multi-tenant B2B SaaS: isolation you can prove
A role model that survives contact with real customers — and a schema you can still change in month six.
The hard parts of B2B SaaS are not features. They are isolation you can prove, a role model you can extend without re-auditing, and the ability to change a schema while customers are using it. All three are platform concerns on Supero.
What we are building
Your fourth customer asks for their own role names. Your ninth wants email sent from their own SendGrid account. Your fortieth is the one who finds the query that forgot its tenant filter. None of that is on your roadmap, and all of it is in your next two quarters.
This guide builds a contract-management SaaS — companies sign up themselves, invite colleagues, work on their own documents, pay monthly — and spends its time on the part that actually kills timelines: a tenancy model that is enforced rather than remembered, a role hierarchy that does not need re-auditing every time a customer invents a job title, subscription lifecycle, credential handling, usage metering, and the one that bites in month six, changing the schema while people are using the product.
Map your product onto the hierarchy — carefully
Supero has a fixed ownership hierarchy. Getting your product onto it correctly is the single most consequential decision in this build; everything downstream either follows naturally or fights you forever.
Domain ← you, the vendor (globally unique)
└─ Project ← your product / environment (its own schemas + API surface)
└─ Tenant ← one customer company (the isolation boundary)
└─ UserAccount / ApiKey ← their people and machines| Level | In a B2B SaaS | Why |
|---|---|---|
| Domain | Your company | One per vendor. Domain names are globally unique. |
| Project | Your product — and a second for staging | Its own schemas, data and generated API surface. Schemas are defined at project level. |
| Tenant | One per customer company | The data-isolation boundary. This is the decision that matters. |
| UserAccount / ApiKey | Their staff, and their integrations | Both resolve to a domain/project/tenant scope. |
Because scope is carried by the credential rather than the request body, isolation is not something your query code has to remember. A caller cannot widen their own scope by editing a payload, because the scope was never in the payload.
Signup, and the request that follows it
tenant_user: the privilege ladder comes from the base role, the per-entity rules come from the policy written against the label.A role model that extends without re-auditing
Roles are strictly hierarchical — a lower level number is more privileged — and a caller can only assign a role strictly below their own. That ceiling is enforced on every role-carrying write, not just in the dashboard. Eight are assignable in your app; two more sit above them and are Supero-staff-only.
| Role | Level | Scope | Typical B2B use |
|---|---|---|---|
domain_admin | 1 | domain | Your platform team |
domain_user | 2 | domain | Internal analyst — cross-tenant read + CRUD, no admin |
project_admin | 3 | project | Your engineers — the role that defines schemas |
project_user | 4 | project | Cross-tenant CRUD within one product, no admin |
developer | 5 | project | Schema, SDK and feature-flag work across tenants |
tenant_admin | 6 | tenant | The customer's own administrator |
tenant_user | 7 | tenant | The customer's regular users |
viewer | 8 | tenant | Read-only inside a customer |
Custom roles are relabels, and that is a feature
When a customer wants "Counsel" and "Reviewer" instead of "admin" and "user", register custom role names. A custom role adds zero new control-plane privilege — the hierarchy, the admin-surface routing and the permission list all resolve through its base_role. What it can have is its own data access: object-level policy is looked up by the role name you assigned, so pair every custom role with a PolicyDef of the same name. Skip that and it inherits nothing but the base role's defaults.
POST /api/v1/crud/acme-corp/role
{
"parent_type": "project",
"parent_uuid": "<project-uuid>",
"name": "counsel",
"display_name": "Counsel",
"description": "Customer-side administrator",
"base_role": "tenant_admin"
}base_rolemust betenant_admin,tenant_userorviewer— a hard ceiling. A custom role can never resolve to a project-, domain- or platform-level privilege whatever you call it.- You cannot shadow a built-in role name, and you cannot create a custom role whose
base_roleoutranks your own. - At login and on refresh, the custom name resolves to its base role for privilege purposes — so "Counsel" gets
tenant_admin's permission list and the admin surface — while per-entity access still comes from the policy written against "counsel".
Subscriptions, and one honest caveat
The recurring_plan service models the plan lifecycle. (It is named recurring_plan because subscription is a reserved schema name.) membership is the separate, related primitive for "is a member of this org/team" — role-bearing membership, not billing.
For the money itself, stripe_checkout handles hosted checkout and payment links, billing covers charges, customers, subscriptions and invoices, and billing_paypal / billing_razorpay / billing_phonepe cover the regional gateways. Mirror the gateway result onto a payment record with authorize_payment → capture_payment so your own system has a first-class record of it.
Per-customer integrations, and the scope you actually get
Sooner or later a customer asks to send email from their own SendGrid account, or post to their own Slack. In a hand-built SaaS this is a schema change, a secrets-management project and a support burden. Supero gives you most of the way there: credentials live in a per-project credential store, encrypted at rest on Supero Cloud and masked on read, so no secret ever enters your codebase. Know the scope, though — service credentials belong to the project, so every tenant in a project shares them. When one customer genuinely needs their own provider account, register their endpoint in the plugin registry rather than splitting them into a project of their own.
- Your application code never holds a secret. It names a
service_idand anoperation. - Wire test keys while you build; switch to live keys in the admin panel — no redeploy, no code change.
- API keys carry a domain/project/tenant scope and are revocable at any time.
- For anything not in the catalog, register the customer's own HTTP endpoint in the plugin registry with its own auth, retry and circuit-breaker settings.
Changing the schema while customers are using it
This is the part of B2B SaaS nobody demos. You need to add a field, rename another, and drop one that was a mistake — and there are now forty tenants with live data on the old shape.
Supero classifies a schema change before applying it. An additive change goes through. A change classified as breaking — removing a previously required attribute, for instance — is rejected synchronously with HTTP 422 rather than silently migrating, and destructive regeneration is refused outright against a live-mode project. The failure mode is a refusal you see immediately in your deploy, not a corruption you find in a support ticket. There is a force_breaking override for when you genuinely mean it; the point is that you have to mean it.
Inheritance does the boring changes
A field that belongs on every entity goes on the base schema. extends resolution is server-side, so clients never walk the chain — they just see the merged shape.
Breaking is a decision, not an accident
The classifier names what is breaking and why, so widening a field or adding an enum value stops being a gamble.
Deploys are verifiable
Pass the expected bundle hash to the smoke test and it tells you whether the running app is the one you published — a stuck roll or a stale cache stops being invisible.
Dev vs live is a mode
A project carries a mode. Destructive data operations — project reset, whole-app replace, schema regenerate — are refused against a live-mode project, with no client override.
How to build it on Supero
Decide what a tenant is, and write it down
One customer company = one tenant. Write the rule down: every ambiguous case later — "what about a customer with two subsidiaries?" — is either answered by that one sentence or re-litigated from scratch.
⏱ ~15 minutes
Generate the app, then read the schema before you accept it
Describe the product; review the inferred data model. Put shared fields on a base schema and extends from it — a contract and a template that both need owner_org, retention_state and audit fields should inherit, not duplicate.
⏱ ~30 minutes
Write the policies as a deny-by-default matrix
One PolicyDef per role. Start every customer-facing role at default_access="none" and add back what it needs. Use owner scoping wherever "mine" is a concept — the platform stamps the caller, so it cannot be forged. filter_match takes exactly $user.email, $user.uuid or $user.name, and filter_field must be a scalar field on the record holding that value, never a reference name; anything else silently applies no filter at all.
POLICIES = [
PolicyDef(role="tenant_admin", default_access="full", rules=[]),
PolicyDef(role="tenant_user", default_access="none", rules=[
PolicyRule(entity="contract", can_read=True, can_create=True, can_update=True,
filter_field="owner_username", filter_match="$user.name"),
PolicyRule(entity="template", can_read=True),
PolicyRule(entity="org_member", can_read=True),
]),
PolicyDef(role="viewer", default_access="none", rules=[
PolicyRule(entity="contract", can_read=True),
]),
]⏱ ~2 hours
Wire signup to onboarding with one binding
Register a workflow and bind it to @signup. Note the payload shape: for @signup the account is nested under user, so paths are user.email and user.full_name — unlike @create/@update/@delete, where the payload is the record itself and a bare uuid is the record's own.
[{"event": "@signup",
"workflow_id": "onboard_new_company",
"input_map": {"email": "user.email", "name": "user.full_name"}}]Validate the graph with validate_workflow before you activate it — it walks the step graph without executing anything, catching unknown step types, duplicate step ids, bad on_error values and missing per-type fields, and reporting which services the workflow references. It does not confirm those services are imported or that their operations exist. Remember a workflow only runs when status is exactly "Active" and enabled is not false.
⏱ ~30 minutes
Add plans, and schedule the billing tick yourself
Extend recurring_plan:base_plan, import a gateway, and build the renewal tick as a scheduled job that calls payment.authorize_payment → payment.capture_payment, then recurring_plan.renew_period. Guard it: renew_period is cumulative, so read the plan's current period before re-running after a timeout.
⏱ ~2 hours
Prove the isolation, then ship
The test that matters is adversarial, not functional. Create two tenants with real data. Then, as a tenant_admin of the first, try to read the second's records by uuid, list across tenants, and assign a role above your own. All three should be refused. Only then run the quality gates and deploy — a managed URL, your own AWS or GCP (only the app's UI container runs in your account; auth, data and logic stay on the Supero backend), or the self-hosted platform when a customer needs real sovereignty.
⏱ ~2 hours for the adversarial pass — then roughly a day of policy iteration as you discover which rules were too tight, plus onboarding, plan and invoice copy. Budget the second day for that.
What this build does not give you
- No recurring-charge scheduler. The plan state machine ships; the clock does not. The renewal tick — authorize, capture,
renew_period— is a scheduled job you write and operate, andrenew_periodis cumulative so a blind retry advances the period twice. - Credentials are per project, not per tenant. "Each customer brings their own SendGrid" is a plugin-registry pattern or a separate project, not a config field.
- Custom roles are labels, not privilege levels. A customer who wants a genuinely new tier of access is asking for something the ladder does not have, and the honest answer is no.
- A breaking schema change is refused, not migrated. That is the right default, but it means the data migration is still yours to write when you genuinely need one.
- Metering tells you usage; it does not price it. Plan limits, overage behaviour and the upgrade path are product decisions you build on top.
Time to deliver
| Phase | On Supero | A framework plus an auth vendor |
|---|---|---|
| Tenant model + enforced isolation | 0 to get, ~2 h to prove | 3–6 weeks |
| Role hierarchy + assignment ceiling | 0 — built in | 2–4 weeks |
| Custom per-customer role labels | ~15 min per role | 1–2 weeks, plus an ongoing audit burden |
| Self-serve signup + onboarding automation | ~30 min (one binding) | 1–2 weeks |
| Subscription lifecycle | ~2 h, plus a day for the renewal tick you compose yourself | 2–3 weeks |
| Credential store, out of the codebase | 0 — platform default | 2–3 weeks |
| Usage metering | 0 — Billing & Usage API | 1–2 weeks |
| Safe schema evolution | 0 — classified + gated | A migration process you own and keep paying for |
| Total | ~2 days | ~4 months |
The foundation is the part nobody sees and everybody rebuilds
Every B2B SaaS converges on the same substrate: tenancy, roles, policy, subscriptions, credentials, metering, and a safe way to change the schema. Teams spend their first year building it and their second year fixing it.
The argument for putting this in a platform is not that it is hard. It is that it is identical at every company, and every team that rebuilds it re-earns the same bugs in the same order. If you think you already have it, the test is cheap — run the three adversarial checks from step 6 against what you have today, and see whether all three come back refused.
Build this on Supero
Describe it and get schemas, APIs, SDKs and a governed multi-tenant backend — or point your own AI editor at Supero over MCP and build it there.