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.