Skip to main content

Tenant Scoping

Ledgerline is a multi-tenant platform. Every data record — orders, customers, products, inventory, discounts, users — belongs to exactly one tenant. API requests must specify which tenant they operate on.

Prerequisites​

  • An authenticated session (see Authentication).
  • Knowledge of the target tenant's identifier.

How Tenants Work​

A tenant typically maps to a single Discord server (guild). Each tenant has its own isolated dataset: creating an order in tenant alpha does not affect tenant beta.

Tenant records include:

FieldDescription
tenantIdUnique identifier (e.g., default, a guild ID, or a slug).
nameHuman-readable tenant name.
ownerUserIdThe user ID of the tenant owner.

Specifying the Tenant​

Include the tenant identifier in the x-tenant-id request header:

GET /api/data/order
x-tenant-id: default
Cookie: ledgerline_auth=...

This is the preferred method for API integrations.

Option 2: tenantId query parameter or body field​

Some endpoints accept tenantId as a query parameter or in the JSON body. This is mainly used by auth endpoints:

GET /auth/discord/start?tenantId=default

Option 3: Session tenant​

If neither the header nor a parameter is provided, the API falls back to the tenant stored in the current session. You can change the session tenant using the tenant switch endpoint.

Resolution order​

The server resolves the tenant in this order:

  1. x-tenant-id header
  2. tenantId query parameter or body field
  3. Session tenant

The first non-empty value wins.

Server-Side Enforcement​

The API enforces tenant scoping at the repository layer. Even if a client somehow submits another tenant's record ID, the query filters by tenantId and returns 404 or an empty result.

You cannot read, update, or delete records belonging to a different tenant.

Switching Tenants​

If your user has access to multiple tenants, switch the active tenant on the session:

POST /auth/tenant
Content-Type: application/json
Cookie: ledgerline_auth=...

{
"tenantId": "other-tenant"
}

The session is re-issued with permissions evaluated for the new tenant.

Common Patterns​

Hardcode a single tenant​

For simple integrations that only interact with one tenant, set the x-tenant-id header on every request:

const headers = {
'Content-Type': 'application/json',
'x-tenant-id': 'my-tenant'
}

Dynamic tenant selection​

For multi-tenant dashboards or admin tools, switch tenants via the session or vary the x-tenant-id header per request.

Admin Panel Tenant Scoping (LED-67)​

The AdminJS admin panel enforces an independent tenant scoping layer on top of the API layer. This is handled by action hooks in src/utils/admin-tenant-scoping.util.ts and applied to every core resource.

How it works​

Each tenanted AdminJS resource (Order, Payment, Product, Customer, Address, Shipment, InventorySnapshot, InventoryTransaction, Discount, DiscountAssignment, Entitlement) is configured with buildTenantScopedActions(), which adds before/after hooks on five action types:

ActionEnforcement
listInjects tenantId filter — overwrites any attacker-supplied filter value
showVerifies record.params.tenantId matches session tenant in the after hook
edit (before)Pins payload.tenantId to session tenant, preventing cross-tenant writes
edit (after)Verifies returned record belongs to session tenant
deleteRequires session tenant context
newPins payload.tenantId to session tenant

Fail-closed guarantee​

If currentAdmin.tenantId is absent or blank (e.g., a super-admin session without a selected tenant), all hooks throw rather than silently returning empty results. The error surfaces as an AdminJS error card.

This is intentional: cross-tenant leaks are worse than a visible error, so we fail loud.

Extending to new resources​

import { buildTenantScopedActions } from '../../utils/admin-tenant-scoping.util.js'

admin.addResource(MyModel, {
...buildTenantScopedActions(),
navigation: ADMIN_NAV.Commerce,
listProperties: ['tenantId', ...],
})

Known edge cases​

  • Records without tenantId (e.g., system-level entities): the after hooks skip the tenantId check when record.params.tenantId is absent, so non-tenanted resources are unaffected.
  • Bulk actions: AdminJS bulk actions are not yet in scope — add a bulkDelete hook if bulk operations are enabled on a resource.
  • Super-admin access: a future super-admin role would need to bypass these hooks or carry a synthetic tenantId — that path is not implemented yet.