NEWSupero Studio — generate, control, and deploy production apps.Start free →HIRINGSee the roles →

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.

SaaSBuild estimate 2 days12 min readAugust 25, 2026

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.

The brief

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.

1
tenant per customer, enforced structurally
8
assignable roles (10 total, 2 staff-only)
~2 days
typical build for one developer
The foundation

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.

text
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
LevelIn a B2B SaaSWhy
DomainYour companyOne per vendor. Domain names are globally unique.
ProjectYour product — and a second for stagingIts own schemas, data and generated API surface. Schemas are defined at project level.
TenantOne per customer companyThe data-isolation boundary. This is the decision that matters.
UserAccount / ApiKeyTheir staff, and their integrationsBoth 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.

The mechanism

Signup, and the request that follows it

A company signs itself up
Visitormarketing siteSupero APIsignup + authTenantnew boundaryWorkflows@signup bindingPlan + billingrecurring_plan1sign up {company, email, password}2provision tenant + first user (tenant_admin)3session: access_token (8 h) + refresh_token (7 d)the response also carries ui_view — which surface to renderEVERYTHING ELSE IS BOUND, NOT CALLED4@signup fires; input_map reads user.email, user.full_name5parallel: welcome email · #signups Slack ping6recurring_plan.start_plan → active (trial)7stamp workflow_status = onboardeda path that does not resolve is omitted, not fatal — the run still fires
No signup controller, no onboarding job, no queue. The account write is the trigger, which means the same onboarding runs whether the signup came from the site, the API, or a sales rep creating the account by hand.
Every request afterwards — including the one that should fail
Customer A usertenant_userSupero APIrequest pathRole resolverbase_roleObject policyPolicyDef rulesDatarecords1GET /crud/{domain}/contract?limit=252resolve role → base_role ("Reviewer" → tenant_user)3permission list from base_role · object policy by role name4read, scoped to the caller's tenant (and owner, if the rule says so)5only Customer A's contractsTHE INTERESTING CASE6GET /crud/{domain}/contract/<Customer B uuid>7refused — the uuid is outside the credential's scopeclient-side has()/can()/isAdmin() gate UI only — every request is re-checked server-side
"Reviewer" is a label over tenant_user: the privilege ladder comes from the base role, the per-entity rules come from the policy written against the label.
The part customers negotiate with you about

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.

RoleLevelScopeTypical B2B use
domain_admin1domainYour platform team
domain_user2domainInternal analyst — cross-tenant read + CRUD, no admin
project_admin3projectYour engineers — the role that defines schemas
project_user4projectCross-tenant CRUD within one product, no admin
developer5projectSchema, SDK and feature-flag work across tenants
tenant_admin6tenantThe customer's own administrator
tenant_user7tenantThe customer's regular users
viewer8tenantRead-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.

json
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_role must be tenant_admin, tenant_user or viewer — 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_role outranks 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".
Getting paid

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.

The plan, as guarded transitions
RECURRING_PLAN LIFECYCLEinactiveinitialactivestart_planpausedpause_plan ⇄ resume_plancancelledterminalexpiredreserved · you set it

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.

The B2B-specific bit

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_id and an operation.
  • 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.
Month six

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.

Step by step

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.

python
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.

json
[{"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.

Know the edges

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, and renew_period is 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.
The economics

Time to deliver

PhaseOn SuperoA framework plus an auth vendor
Tenant model + enforced isolation0 to get, ~2 h to prove3–6 weeks
Role hierarchy + assignment ceiling0 — built in2–4 weeks
Custom per-customer role labels~15 min per role1–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 yourself2–3 weeks
Credential store, out of the codebase0 — platform default2–3 weeks
Usage metering0 — Billing & Usage API1–2 weeks
Safe schema evolution0 — classified + gatedA migration process you own and keep paying for
Total~2 days~4 months
The takeaway

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.

Delivery estimates in this guide are for the scope described, built by one experienced developer with any vendor sandbox credentials already in hand. Treat them as a realistic starting point, not a commitment — and check the documentation for the authoritative behaviour of any API named here.

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.