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:
| Field | Description |
|---|---|
tenantId | Unique identifier (e.g., default, a guild ID, or a slug). |
name | Human-readable tenant name. |
ownerUserId | The user ID of the tenant owner. |
Specifying the Tenant
Option 1: x-tenant-id header (recommended)
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:
x-tenant-idheadertenantIdquery parameter or body field- 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:
| Action | Enforcement |
|---|---|
list | Injects tenantId filter — overwrites any attacker-supplied filter value |
show | Verifies 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 |
delete | Requires session tenant context |
new | Pins 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): theafterhooks skip the tenantId check whenrecord.params.tenantIdis absent, so non-tenanted resources are unaffected. - Bulk actions: AdminJS bulk actions are not yet in scope — add a
bulkDeletehook 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.