Skip to content

Multi-tenant scope

Scope is the reason this library exists as more than “CRUD from schema metadata.”

scope returns an equality filter (usually { tenantId }). That fragment is applied to list, read, update, delete, relation picks, and custom actions — not only the table.

ts
admin.register("Post", {
  scope: async (adminUser) =>
    adminUser.isSuperAdmin
      ? {}
      : { tenantId: adminUser.tenantId ?? "__no_tenant__" },
});

{} means no extra filter. Linus uses that path. Ada and Grace get { tenantId }.

Ada, Grace, Linus

The examples seed this:

PersonEmailRoleTenant
Adaada@example.testADMINNorthwind
Gracegrace@example.testADMINContoso
Linuslinus@example.testSUPER_ADMINNorthwind

Ada opens /admin/posts and sees “Welcome to PanelJS.” She does not see Grace’s Contoso draft. Changing the id in the URL does not help: detail, update, and delete use AND: [scope, { id }]. A foreign id is 404 RECORD_NOT_FOUND, not a leaked row.

Grace has the same ADMIN permissions. Different tenantId on AdminUser. Different rows.

Linus is a super-admin. The example scope returns {} for him, so he sees both companies. If you forgot that branch, Linus would only see Northwind — super-admin does not skip scope by itself.

Create

Simple equality scopes are written into new records:

ts
// Ada posts { title, authorId, published }
// The API adds tenantId: northwind

If the client sends a conflicting tenantId, the request is 400. Complex predicates cannot be turned into create data automatically, so models that administrators can create must use simple equality scopes.

Update

Fields used anywhere in a scope predicate cannot be changed through the admin. This includes a related FK such as orderId when the scope contains order: { tenantId: ... }.

Relations and actions

Author dropdowns query User under User’s scope. Ada cannot assign Grace as author.

Publish-selected reloads the ids under Post’s scope. If any id is missing, the action does not run.

How to write scope

ts
scope: async (adminUser) => {
  if (adminUser.isSuperAdmin) return {};
  if (!adminUser.tenantId) return { tenantId: "__no_tenant__" };
  return { tenantId: adminUser.tenantId };
};

The "__no_tenant__" fallback is deliberate: a missing tenant id must match nothing, not everything.

Put whatever you need on AdminUser (tenantId, institutionId, metadata) inside getCurrentUser. The library does not infer tenancy from the schema.

Mental model

permissions  →  may they use this verb?
scope        →  on which rows?

Both run on every record operation. Skipping either one is how tenant A sees tenant B.

Released under the MIT License.