Documentation/Managing Your App/Building Multi-Tenant Apps
Building Multi-Tenant Apps
How Supero's Domain -> Project -> Tenant hierarchy gives every app you build tenant isolation for free, and how to provision, staff, and customize tenants through the API.
The Hierarchy, In One Diagram
Every account, schema, and row of data in Supero hangs off the same four-level identity hierarchy. Understanding it is the difference between an app that isolates your customers' data correctly and one that leaks it. This page is for developers who are past account setup and are now designing the multi-tenant shape of their own app on top of Supero.
text
Domain (your account -- what most product and marketing copy calls "your tenant")
└── Project (one app; every domain gets a "default-project" automatically)
└── Tenant (one of your app's customers; every project gets a "default-tenant" automatically)
├── UserAccount
└── ApiKey| Level | What it is | Notes |
|---|---|---|
| Domain | Your top-level account. | Created once, at signup. Every hierarchy object underneath it is looked up by a fully-qualified path rooted at the domain. |
| Project | One application. | Your schemas, API endpoints, and generated app all live at this level. A "default-project" is created automatically for every domain. |
| Tenant | One of your app’s customers or workspaces. | The unit of data isolation. A "default-tenant" is created automatically for every project. |
| UserAccount / ApiKey | An individual login, or a service credential. | Both are parented on a tenant. A user or key always resolves to exactly one tenant’s worth of scope. |
Namespace is a separate, orthogonal concept: it's a label on each of your schemas (for example "crm" or "billing") that groups related types together and keeps type names from colliding with another app's schemas in the same domain. It is not a level in the hierarchy above -- a namespace does not contain tenants or projects, it just tags schemas. If you don't set one explicitly, the schema inherits the namespace of the project you upload it into, falling back to the literal string "tenant" when that project has none set -- worth knowing because "tenant" is easy to misread as a reference to the Tenant hierarchy level. Set an explicit namespace on your schemas rather than relying on either fallback.
⚠️ "Tenant" means three different things
Watch for the overload: (1) in marketing and sales conversations, "tenant" usually means Domain -- a customer's whole account; (2) in the data model documented on this page, "tenant" is the level below Project -- one customer's data partition inside a single app; (3) as a literal string, "tenant" is also the default schema namespace value described above. Same word, three different scopes -- know which one is in play.
Design Your App: One Project, Many Tenants
The standard shape for a Supero app is one Project per application, with schemas defined once at the project level and shared by every tenant underneath it. All of your tenants run the same data model -- you are not defining a different schema per customer, you're partitioning the same schema's data by tenant.
One Tenant is one of your customers. If you're building a project-management SaaS product, each customer company (Acme Corp, Globex, ...) is a tenant inside your project. Your project's auto-created default-tenant is a home for your own admin users and test data. Create a dedicated tenant for every real customer: default-tenant grants project-wide cross-tenant access to anyone in it, so customer data placed there is not isolated.
| You need... | Reach for |
|---|---|
| A new customer/workspace inside your existing app | A new Tenant in your existing Project |
| A genuinely different app, or a separate schema/environment (e.g. staging vs. production) | A new Project |
| A completely separate top-level account | A new Domain (only your platform-level admin can do this out-of-band; self-service signup is one domain per email) |
💡 Start from the defaults
Every project already has a default-tenant, so you can start building and testing immediately without provisioning anything. Move to dedicated tenants before you onboard a real customer — anyone left in default-tenant can read every tenant in the project.
Isolation & the Guarantee You Get
Tenant isolation is enforced by the platform on every request, not something you add filters for in your own app code. It rests on a few mechanisms working together:
- •Token-pinned context -- when a user logs in, their access token carries their resolved domain, project, and tenant. An API key is likewise scoped to the tenant (or higher level) it was issued for. Every CRUD call made with that credential inherits its context automatically; you don't pass a tenant identifier on each request.
- •Automatic read clamp -- for tenant-scoped roles (tenant_admin, tenant_user, viewer), list and query results are scoped to the caller's own tenant before they're returned; you don't add a tenant filter yourself.
- •Write placement checks -- creating or updating a record that resolves to a different tenant than the caller's own is rejected with a 403, regardless of what the request body claims. This is checked on the final resolved location, not just the request's stated intent.
- •Fail-closed on ambiguity -- when a request could plausibly resolve to more than one tenant (for example, logging in with an email that exists in two different tenants of the same project without specifying which one), Supero rejects the request and asks you to disambiguate rather than guessing.
ℹ️ What this means for your app
A tenant-scoped user or a tenant-scoped API key cannot see or write another tenant's data through the API, even if your own application code has a bug. You still choose your data model and UI, but the isolation boundary itself is platform-enforced. Read the next callout before you decide which tenant your customers live in — there is one specific tenant where this does not hold.
🚨 Never put customers in default-tenant
Cross-tenant access is granted on two independent conditions, and only one of them is a role. Any user whose tenant is literally named default-tenant is given project-wide access to every tenant in that project — regardless of their role. A tenant_user in default-tenant can read every other tenant's records. This is deliberate platform behaviour for the built-in workspace tenant, not a bug, but it makes default-tenant the one place a customer must never be provisioned. Creating a project automatically creates a default-tenant, and it is where a first-time walkthrough naturally leaves you — so this is easy to do by accident. Create a named tenant per customer and put nobody in default-tenant except your own staff.
Provisioning a Tenant
Tenants are created with POST /api/v1/tenants. This requires the tenant:create permission, which only the domain_admin and project_admin roles hold -- domain_user, project_user, developer, tenant_admin, tenant_user, and viewer cannot create tenants, even though some of those roles can see every tenant in a project.
bash
curl -X POST https://api.supero.dev/api/v1/tenants \
-H "Authorization: Bearer <jwt>" \
-H "Content-Type: application/json" \
-d '{
"name": "acme-corp",
"display_name": "Acme Corporation",
"project_uuid": "<project-uuid>",
"create_admin_user": true,
"admin_user": {
"full_name": "John Smith",
"email": "[email protected]",
"phone_number": "+1234567890",
"send_activation_email": true
}
}'| Field | Notes |
|---|---|
| name | Required. Lowercase letters, digits, and hyphens only. |
| project_uuid | The project to create the tenant in. Defaults to your own project if omitted. |
| display_name | Optional human-readable name shown in your app / dashboard. |
| create_admin_user | If true, provisions an admin user for the new tenant in the same call. |
| admin_user.email / .full_name | Required when create_admin_user is true. |
| admin_user.password | Optional -- if omitted, a temporary password is generated and returned once in the response. |
| admin_user.send_activation_email | Defaults to true -- sends the new admin an activation email. |
The response includes the created tenant, the admin user (if requested, with role tenant_admin and a one-time temporary_password field if the password was auto-generated), and an activation_required flag. If you didn't ask for an admin user, the tenant is created active and ready immediately; if you did, its activation_status starts as pending until the admin completes activation.
| Lifecycle action | Endpoint |
|---|---|
| Create | POST /api/v1/tenants |
| List | GET /api/v1/tenants |
| Get / Update / Delete | GET, PUT (or PATCH), DELETE /api/v1/tenants/{tenant_uuid} |
| Activate | POST /api/v1/tenants/{tenant_uuid}/activate |
| Suspend / Unsuspend | POST /api/v1/tenants/{tenant_uuid}/suspend, /unsuspend |
| Archive | POST /api/v1/tenants/{tenant_uuid}/archive |
| Resend activation email | POST /api/v1/tenants/{tenant_uuid}/resend-activation |
⚠️ The default-tenant is protected
The default-tenant created automatically with every project cannot be deleted or suspended -- it's the project's reserved operator tenant, not an ordinary customer tenant.
Users & Roles Per Tenant
Add a user to a tenant with POST /api/v1/users/register, specifying the target domain/project/tenant and a role:
bash
curl -X POST https://api.supero.dev/api/v1/users/register \
-H "Authorization: Bearer <jwt>" \
-H "Content-Type: application/json" \
-d '{
"domain_name": "your-domain",
"project_name": "your-project",
"tenant_name": "acme-corp",
"email": "[email protected]",
"full_name": "Jane Doe",
"password": "a-strong-password",
"role": "tenant_user"
}'| Role | Scope | Notes |
|---|---|---|
| domain_admin | Whole domain | Domain owner; created automatically on domain registration; lives in default-tenant. |
| domain_user | Whole domain | Full CRUD across every project and tenant in the domain, no admin actions. |
| project_admin | One project | Manages every tenant in the project and defines its schemas. |
| project_user | One project | CRUD across every tenant in the project, no admin actions. |
| developer | One tenant | Technical role: schema, SDK and CRUD work for building and testing. Despite the project-level name, it is clamped to its own tenant like tenant_user — it does not read across tenants. |
| tenant_admin | One tenant | Manages one specific tenant; consumes schemas rather than defining them. |
| tenant_user | One tenant | Regular member of one tenant, full CRUD within it. |
| viewer | One tenant | Read-only. |
Roles form a strict privilege ceiling: you can only assign a role less privileged than your own (domain_admin can hand out domain_user or anything below; a tenant_admin can hand out tenant_user or viewer, but not another tenant_admin, let alone a project- or domain-level role). Update an existing user's role with PUT /api/v1/users/{user_uuid}/role -- the same ceiling applies there too:
bash
curl -X PUT https://api.supero.dev/api/v1/users/<user_uuid>/role \
-H "Authorization: Bearer <jwt>" \
-H "Content-Type: application/json" \
-d '{"role": "tenant_admin"}'Querying Data Across (and Within) Tenants
A CRUD call made with a tenant-scoped credential (a tenant_admin/tenant_user/viewer token, or an API key issued for that tenant) is implicitly scoped to that tenant -- you don't add a tenant filter yourself:
bash
curl https://api.supero.dev/api/v1/crud/your-domain/order \
-H "X-API-Key: ak_..."
# Returns only orders belonging to the tenant this credential is scoped to.To read or write across every tenant in a project -- for a super-admin dashboard, an internal ops tool, or a batch job -- authenticate with a project-level credential instead: a project_admin, project_user, domain_admin, or domain_user role. Note that developer is NOT one of them — it is clamped to its own tenant. Those roles see every tenant in scope by default; a plain tenant_admin/tenant_user/viewer credential cannot, whatever query parameters you send -- with the single exception that a credential belonging to default-tenant does get project-wide cross-tenant access regardless of its role, which is why customers must never be provisioned there.
If you're already logged in and need to move between projects or tenants without a full re-login, re-issue your tokens with POST /api/v1/auth/switch-project:
bash
curl -X POST https://api.supero.dev/api/v1/auth/switch-project \
-H "Authorization: Bearer <jwt>" \
-H "Content-Type: application/json" \
-d '{"project_uuid": "<project-uuid>", "tenant_name": "acme-corp"}'This returns a fresh access/refresh token pair and a context block confirming the domain, project, and tenant the new tokens are scoped to. It only succeeds if your account actually has access to the requested project.
Per-Tenant Customization
The Tenant object itself carries a set of fields you can set and read back to drive tenant-specific behavior in your own app -- branding, contact details, locale, and a free-form metadata field for anything else:
| Field | Purpose |
|---|---|
| display_name, description | Human-readable name and description shown in your UI. |
| icon, logo, primary_color | Branding fields for a tenant-specific look in your own app. |
| contact_name, contact_email, contact_phone, address | Tenant’s primary contact details. |
| timezone, locale, currency, date_format | Regional formatting preferences for that tenant. |
| tags | Freeform labels for your own organization/filtering. |
| metadata | A free-form JSON object for anything not covered by a named field. |
bash
curl -X PUT https://api.supero.dev/api/v1/tenants/<tenant_uuid> \
-H "Authorization: Bearer <jwt>" \
-H "Content-Type: application/json" \
-d '{
"display_name": "Acme Corporation",
"primary_color": "#1E63F0",
"timezone": "America/New_York",
"locale": "en-US",
"metadata": {"plan_notes": "annual contract"}
}'A handful of fields are protected and dropped from update requests rather than applied: uuid, created_at, created_by, and name (the tenant's slug) can't be changed after creation.
Pitfalls & FAQ
Do I need to set a namespace on every schema?
Not strictly, but you should. A schema that leaves it unset inherits the namespace of the project you upload it into, and falls back to the literal string "tenant" when that project has none set. Both outcomes are awkward: the inherited value changes with the target project, so the same schema file can land in different namespaces in different projects, and "tenant" collides in name (though not in meaning) with the Tenant hierarchy level, making logs and error messages harder to read. Give every schema its own explicit namespace.
Can the same email be an admin for two of my tenants?
Yes, when you create both tenants yourself via POST /api/v1/tenants with create_admin_user -- email uniqueness there is scoped to the tenant, not the project. Self-service signup into your domain is different: it enforces email uniqueness across the whole domain, so the same email can't sign itself up twice.
What happens if a login is ambiguous about which tenant it belongs to?
If the same email exists as a distinct user in more than one tenant of the same project, a login attempt that doesn't specify a tenant is rejected rather than guessing -- you'll need to sign in specifying your tenant explicitly. This is a deliberate fail-closed choice, not an error.
Who can create a new tenant?
Only users with the tenant:create permission -- the domain_admin and project_admin roles. A tenant_admin or tenant_user, even one who manages a tenant well, cannot create a sibling tenant.
I changed a user's role or moved them to a new tenant -- why hasn't it taken effect?
A user's role and tenant are captured in their session when they sign in. An existing session keeps the role it was issued with; a role or tenant change takes effect the next time that user signs in from scratch -- so have them sign out and back in after you change it.
Next steps
- •Projects & Tenants -- dashboard-driven project and tenant management, lifecycle states, and best practices.
- •Users & Roles -- the full picture of creating, disabling, and moving users.
- •Access Control (RBAC) -- object-level access policies layered on top of roles.
- •Schema Reference -- defining the schemas your tenants share.
- •What breaks first when your SaaS gets a second tenant -- the places nobody remembers are per-tenant, ordered by how early they bite.
On this page