Multi-tenant SaaS
Tenant isolation you can demonstrate
Organizations, members, and invitations as real tables, with row access scoped by membership in the database and the boundary tested by signing in as someone else.
- Who this is for
- Teams building B2B software with shared workspaces
- What you already have
- A product where customers have teammates
- What you need
- Per-tenant data isolation that holds
The problem
Multi-tenancy is where authorization bugs become incidents. The rule is simple to state — a member sees their organization's rows and nothing else — and easy to get subtly wrong: an endpoint that forgets the tenant filter, a policy that grants one side of a join, a query written before the rule existed. Nothing fails loudly. One customer sees another customer's data, and you find out from them.
What you would normally build
An organizations and members model, an invitation flow with expiring tokens, a role system, and a tenant filter applied at every single query site — plus the discipline to never miss one, forever, across everyone who joins the team.
What Backenly does
Provision
The organization model as real tables
enable_teams ensures end-user auth is on, then creates organizations (name, unique slug, owner), organization_members (organization, user, role defaulting to member, joined_at), and organization_invitations (email, role, unique token, inviter, expiry, accepted_at) — with CRUD endpoints and the lookup indexes membership checks need. It is idempotent, so re-running it is safe.
Scope
Membership decides row access, in the database
The org_members policy grants access to rows whose organization_id matches an organization the calling user belongs to. It requires the table to have an organization_id column and teams to be enabled — a precondition it checks rather than assumes.
Refine
Exact SQL where a template is not enough
set_rls takes your predicate verbatim, one rule per command, and reads pg_policies back before reporting success. Naming only update and delete leaves the select and insert rules byte-identical, so a scoped edit stays scoped.
Prove
Sign in as the other tenant
The isolation check creates a second end-user and asserts they receive zero rows. This is the difference between a policy that reads correctly and a boundary that holds — and it is the evidence to show a customer who asks how you separate their data.
What you end up with
Tenant separation enforced by PostgreSQL rather than by remembering a WHERE clause, a membership model with the indexes it needs, and a behavioural check you can point at when someone asks how isolation works.
Who owns what
Backenly does
- Provisions the organization, member, and invitation tables with their endpoints and indexes.
- Scopes rows by membership through a policy enforced in the database.
- Installs custom predicates verbatim and verifies them against pg_policies.
- Asserts cross-user isolation behaviourally and returns the evidence.
You own
- The invitation UX — sending the email, and the accept screen.
- What each role is allowed to do in your product; the schema seeds owner, admin, and member as values, not as behaviour.
- Billing and seat logic.
- Adding organization_id to the tables that should be tenant-scoped.
What this is built on
| Capability | What it does here |
|---|---|
| enable_teams | Creates organizations, organization_members, and organization_invitations with CRUD APIs and membership indexes. Idempotent. Dispatchable but not advertised — reach it through backend_chat. |
| org_members policy | Row access where organization_id matches an org the caller belongs to. Requires the column and enabled teams. |
| set_rls | Exact SQL per command, installed verbatim, read back from pg_policies. Scoped edits leave other commands untouched. |
| Behavioural verification | A second end-user is created and signed in; the check passes only on zero rows. |
| Schema isolation | Each project has its own PostgreSQL schema; cross-project isolation is a grant, not a filter. |
Known limitations
- This is your application's team model. It is unrelated to Backenly account seats, which are how many people can log into your Backenly dashboard — a separate, plan-limited thing.
- enable_teams provisions the data model and endpoints. Sending invitation emails and building the accept flow are yours.
- Roles are seeded as values (owner, admin, member). What each one may do is your policy work, expressed with set_rls.
- The org_members template requires an organization_id column on each scoped table and teams already enabled. It refuses rather than guessing.
- Cross-organization reporting needs deliberate design — the policies that make isolation hold also make aggregate queries across tenants intentionally hard.
Common questions
Is this the same as inviting teammates to Backenly?
No, and the distinction matters. Backenly account seats control who can open your dashboard and are limited by plan. This use case is about organizations inside the product you are building — your customers' teams, living in your project's own schema, with their own users and policies.
How do I prove tenant isolation to a customer?
Point at the isolation check. It creates a second end-user, signs in, and asserts zero rows are returned — a behavioural result rather than a claim about policy text. Combined with per-project schema isolation enforced by Postgres grants, that is a concrete answer to a question most teams answer with an architecture diagram.
Can a tenant have nested teams or custom roles?
The provisioned model is organizations, members with a role string, and invitations. Anything beyond that — nested groups, per-resource permissions — is schema you add and policies you write with set_rls, which takes arbitrary predicates including EXISTS lookups against a parent row.
Try it on one free project
No credit card. Connect your agent over MCP and judge it by the verification evidence.