Skip to content
AbloAblo Docs
Esc
navigateopen⌘Jpreview
On this page

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.

Was this page helpful?