Serving Many Customers
One account, one schema, and a session scoped to the customer whose data it may read.
Serving many customers from one backend has two shapes, and the first question is whether isolating them is a security boundary or a routing convenience.
One Ablo organization per customer is the hard boundary. Every row carries the organization, and the engine compares it on every read and every write, below your code. Choose it when one customer reading another’s rows would be an incident.
One organization, customers as rows told apart by sync groups is delivery and read routing. It is declarative, it depends on every model being covered, and it is not enforced on every path. Choose it when cross-customer reads are tolerable or intentional, not when they are a breach.
The rest of this page is the second shape. Read Where the boundary is enforced before you rely on it.
// 1. src/ablo/schema.ts — your customer table is a scope root.
import { defineSchema, identityRole, relation, model, z } from '@abloatai/ablo/schema';
export const schema = defineSchema(
{
// Its rows form the group `customer:<id>`; the kind comes from `groups.root`.
customers: model(
{ name: z.string() },
{ groups: { root: 'customer' } },
),
// A child inherits its customer's group through the `parent` edge.
decks: model(
{ customerId: z.string(), title: z.string() },
{ relations: { customer: relation.belongsTo('customers', 'customerId', { parent: true }) } },
),
},
{
identityRoles: [
identityRole({ kind: 'org', source: 'organizationId' }),
identityRole({ kind: 'user', source: 'userId' }),
],
},
);
// 2. app/api/ablo-session/route.ts — mint for one customer, on your backend.
import { syncGroup } from '@abloatai/ablo/schema';
import { credentialEndpointSuccessSchema } from '@abloatai/ablo/auth';
import { ablo } from '@/ablo/server';
export async function POST() {
const member = await requireSignedInMember();
const session = await ablo.sessions.create({
user: { id: member.userId },
can: { customers: ['read'], decks: ['read', 'create', 'update'] },
syncGroups: [syncGroup('customer', member.customerId)],
});
return Response.json(
credentialEndpointSuccessSchema.parse({
token: session.token,
expiresAt: session.expiresAt,
credentialKind: 'ephemeral',
}),
{ headers: { 'Cache-Control': 'no-store' } },
);
}
That is the whole integration. The rest of this page is why each line is where it is.
What each layer is
Four things carry a name in this arrangement, and mixing two of them up is the one mistake worth spending a page to prevent.
| Layer | What it is | Where it lives |
|---|---|---|
| Your account | The organization you signed up with. Colleagues join it with their own logins and share one bill. | Ablo |
| Your application | A project. One per app you run, bound to one schema in your database. | Ablo |
| Your customer | A row in your own table, with your own id on it. | Your database |
| One person’s session | An ek_ your backend mints, cut to one customer’s group. |
Minted per sign-in |
Your customers sit in the third row. They are not accounts, because an account is something you invite colleagues into. They are not projects, because a project binds to a Postgres schema and you run one application, not one per customer.
Your sk_ already carries your account, so a session never names it. What the
session adds is which customer the person in front of it may read.
Where the boundary is enforced
Two mechanisms do different jobs, and the difference is the whole of this page.
Your account is the tenant boundary. Every row Ablo stores carries your organization, project, and branch, and all three are compared on every read and every write, from the credential rather than the request. A client cannot reach past them by asking. This is the boundary that holds unconditionally.
Sync groups are a cut inside your account, and they are not applied everywhere. They decide which changes are delivered and which rows a log-served read returns. That is routing. It is not a universal authorization boundary, and the gaps are specific:
| Path | Group cut applied |
|---|---|
| Live delivery and fan-out | Yes |
| HTTP read on a log-served plane (a connected database) | Yes |
| HTTP read on a hosted or direct-query plane | No. Scoped by organization |
| Writes | No. The groups are recorded on the change, never checked against the row |
| Claim listings and presence | Yes |
So a session cut to one customer, on a hosted plane, can read another customer’s rows over HTTP; and on any plane it can write to them. What stops it today is the organization, which both customers share under this shape.
If isolating your customers is a security requirement, give each one its own Ablo organization. The stronger row-and-subject authorization that would make this shape safe on every path is not in the engine yet.
Naming a group
Build a group with the syncGroup(kind, id) helper rather than a string. The
kind is the one you declared in groups.root, and the id is your own
identifier for the customer.
syncGroups: [syncGroup('customer', member.customerId)]
Resolve member.customerId from the membership you just authenticated on the
server. A signed-in person can put any value in a request body, and the session
you mint is what decides what they can read.
When a customer should be its own organization
Whenever their isolation has to hold. Give each customer its own Ablo organization when one of them reading or writing another’s rows would be an incident rather than a bug, when you cannot audit group coverage across every model, or when a customer is a separate paying business that signs in to Ablo itself and invites its own developers.
Your backend then names the customer’s organization on the mint, which takes a
secret key carrying organization:act-as. The customer never sees Ablo; the
scope exists because the session leaves the organization the key belongs to.
Onboarding a customer
Insert the row. There is nothing to register with Ablo, because the group is derived from the row’s id, and the first session minted against it is delivered its data.
Add a project only when you add an application. npx ablo projects create takes
a management credential from ablo login, and one project holds one schema.
Troubleshooting
A session reads nothing
Check the groups the session was minted with against the kind in groups.root.
A group whose kind is not declared matches nothing, which reads as an empty
database rather than an error.
A session reads another customer’s rows
Check that the model declares a parent edge up to the scope root. A model with
no group of its own and no parent belongs to no group, so a group cut does not
narrow it.
The mint is refused
Naming organizationId reaches into a different account and takes
organization:act-as. A platform serving its own customers names groups
instead, and its key needs no scope at all.
See it yourself
npx ablo whoami --json
The syncGroups it reports are the cut the engine will apply. If a customer’s
group is missing there, no read will show its rows.
Related guides
- Identity & Sync Groups — how groups are declared and resolved.
- Sessions — session lifetime, refresh, and revocation.
- API Keys — credential classes and scopes.
- Projects — one project per application.