Tenant Apps
Serve several organizations from one App, each member seeing only their own organization’s rows, with the business that runs them above.
tablewalk check --app ./leads --policy ./leads/server/tenant-policy.ts --commands ./leads/commands.tsNo shipped example is a tenant App, so this declaration is illustrative. Each dealer is a tenant; the lender above them is the platform:
auth: { providers: ['email'], // A sign-in grants nothing until a membership does: the dealer's owner // invites its people, the platform's operators grant its own. rows: 'tenant', tenancy: { label: 'Dealer', profile: 'dealer', platform: { label: 'Lender' } }, roles: { owner: { label: 'Owner', realm: 'tenant', read: dealerReads, commands: financing }, // … automation: { label: 'Automation', realm: 'platform', read: ['loan_application', 'decision_alert'], commands: ['flagLateDecision'] }, },},What you’ll see
Section titled “What you’ll see”- After sign-in, a chooser of the tenants the person is a member of; their roles come from that membership, not the account. A person with one membership goes straight in, unless the address names another tenant.
- Every list, record and form inside the chosen tenant: a new row is given the tenant’s ownership column by the server, and no reply ever carries it. The bar’s bell holds that tenant’s notices. A generated column is read like any other in a tenant’s tables and written by nobody, as under a row policy.
- Platform staff in a realm of their own, and a short, audited, read-only support visit into one tenant when the policy allows it.
When to use it, and when not
Section titled “When to use it, and when not”- Use a tenant App when each customer is an organization with its own members, roles, invitations and administration, sharing one database and one App.
- Use a row policy instead when one organization’s people see different rows, without separate memberships.
- Use separate Apps, or a suite, when the organizations share nothing at all.
Options
Section titled “Options”| Part | What it does | Status |
|---|---|---|
auth.rows: 'tenant' |
Scopes every row by tenant membership | Stable |
auth.tenancy |
label (what a tenant is called), profile (its one-row table), platform (the realm above) |
Stable |
A role’s realm |
'tenant' or 'platform'; every role says one once a platform is declared |
Stable |
A resource’s owner |
'tenant' (each tenant’s own), 'global' (read by all, never written), 'platform' (no tenant reads it) |
Stable |
defineAppTenantPolicy |
The server-only policy: ownership columns, rows, administration, invitations, support, intake, public reads | Stable |
Tenant Apps run on SQLite and PostgreSQL. The server loads the policy with
--policy, and a database holds one tenant App:
tablewalk check --app ./leads --policy ./leads/server/tenant-policy.ts --commands ./leads/commands.tstablewalk --config tenant.json --app ./leads --policy ./leads/server/tenant-policy.ts --commands ./leads/commands.tsThe policy and its store
Section titled “The policy and its store”defineAppTenantPolicy from tablewalk/policy
says which column owns each table, which rows a member and the platform read
(resolveRows, resolvePlatformRows), who may administer memberships and
issue invitations, and who may enter a tenant for support. Its ownership
must agree with each resource’s owner, or the App does not start. Seed
tenants and memberships with putTenant and grantTenantMembership from
tablewalk/maintenance.
A whole policy, with every part it can declare. Each part past the first
six is optional, and tablewalk check --policy names a part that is
wrong:
import { defineAppTenantPolicy } from 'tablewalk/policy';
const DAY = 24 * 60 * 60 * 1000;const dealerRows = { lead: true, listing: true, rate_card: true } as const;// The operator who creates and suspends dealers: an existing account, by id.const operator = process.env.LENDER_OPERATOR_ID ?? 'operator-account-id';
export default defineAppTenantPolicy({ kind: 'tenant', appId: 'leads', // the App's id storeId: 'leads-tenants', ownership: { dealer: { kind: 'platform' }, // the profile table: one row per tenant decision_alert: { kind: 'platform' }, rate_card: { kind: 'global', access: 'read-only' }, lead: { kind: 'tenant', column: 'dealer_id' }, listing: { kind: 'tenant', column: 'dealer_id' }, }, // A member reads a row only where both answer for its table; the server adds the tenant to each. resolveMembershipRows: () => dealerRows, resolveRows: (member) => (member.roles.includes('owner') ? dealerRows : { ...dealerRows, rate_card: false }), resolvePlatformRows: () => ({ ...dealerRows, dealer: true, decision_alert: true }), // Without it, a member's Delete of a tenant row is refused. authorizeDeletedRecord: (member, image) => image.table === 'lead', // With --commands: one per registered command, naming the rows its result points at. resultReferences: { submitLead: (result) => [{ table: 'lead', key: { id: (result as { id: string }).id } }] }, administration: { id: 'leads-administration', version: '1', maxMembershipLifetimeMs: 366 * DAY, operators: [ { accountId: operator, tenants: ['north', 'south'], operations: ['createTenant', 'setTenantStatus'], roles: [] }, { accountId: operator, tenants: ['north', 'south'], operations: ['grantMembership', 'revokeMembership'], roles: ['owner'] }, ], // A tenant's owner manages its own staff and sandboxes, never an owner. delegates: [{ role: 'owner', operations: ['grantMembership', 'revokeMembership', 'cloneTenant', 'importTenant'], roles: ['staff'] }], }, invitations: { id: 'leads-invitations', version: '1', maxMembershipLifetimeMs: 366 * DAY, maxRedemptionLifetimeMs: 7 * DAY, maxPendingPerTenant: 50, // The operator invites each dealer's first owner; the owner invites its staff. operators: [{ accountId: operator, tenants: ['north', 'south'], operations: ['issueInvitation'], roles: ['owner'] }], delegates: [{ role: 'owner', operations: ['issueInvitation', 'revokeInvitation', 'reissueInvitation'], roles: ['staff'] }], }, support: { id: 'leads-support', version: '1', grants: [{ platformRole: 'automation', tenants: 'all', dealerRoles: ['staff'] }] }, intake: { address: 'intake_address' }, public: { address: 'public_address' }, resolvePublicRows: () => ({ listing: { groups: [[{ column: 'status', op: '=', value: 'published' }]] } }), assistant: { tokensPerTenantPerDay: 200_000, retentionDays: 30 },});Policy reference
Section titled “Policy reference”| Key | Type | Default | Limit |
|---|---|---|---|
kind |
'tenant' |
required | |
appId |
the App’s id |
required | 256 characters |
storeId |
text naming the tenant store | required | 256 characters |
ownership |
per table: { kind: 'tenant', column }, { kind: 'global', access: 'read-only' } or { kind: 'platform' } |
required | 1 to 256 tables, at least one 'tenant' |
resolveMembershipRows |
(member) => RowScope |
required | |
resolveRows |
(member) => RowScope, for the roles a request acts under |
required | |
resolvePlatformRows |
(member) => RowScope |
none | required once a table is 'platform' |
authorizeDeletedRecord |
(member, image) => boolean |
none: a member deletes no tenant row | |
resultReferences |
per command id, (result) => [{ table, key }] |
none | one per command under --commands; 4096 |
administration.id, .version |
text | required | 256 characters |
administration.maxMembershipLifetimeMs |
milliseconds | required | 1 to 366 days |
administration.operators |
[{ accountId, tenants, operations, roles }] |
required | 256 rules, each 1 to 256 tenants; empty only beside delegates |
administration.delegates |
[{ role, operations, roles }] |
none | each operation in one rule |
invitations.id, .version |
text | required | 256 bytes |
invitations.maxMembershipLifetimeMs |
milliseconds | required | 1 to 366 days |
invitations.maxRedemptionLifetimeMs |
milliseconds | required | 1 to 7 days |
invitations.maxPendingPerTenant |
count | required | 1 to 10000 |
invitations.operators |
[{ accountId, tenants, operations, roles }] |
required | 256 rules, each 1 to 256 tenants; empty only beside delegates |
invitations.delegates |
[{ role, operations, roles }] |
none | each operation in one rule |
support.id, .version |
text | required | 256 bytes |
support.grants |
[{ platformRole, tenants: 'all' or [ids], dealerRoles }] |
required | 1 to 256, one per platform role; 1024 tenants; 256 roles |
intake.address |
a text column of the profile table | none: no public form | needs a 'platform' table |
public.address |
a text column of the profile table | none: no public reads | needs a 'platform' table; comes with resolvePublicRows |
resolvePublicRows |
(visitor) => RowScope |
none | comes with public |
assistant.tokensPerTenantPerDay |
tokens | the App’s own caps | 1 to 1000000000 |
assistant.retentionDays |
days | 90 | 1 to 3650 |
Administration operations: createTenant, setTenantStatus,
grantMembership, replaceMembership, revokeMembership,
setTenantAssistant, cloneTenant and importTenant; a delegate takes any
but the first two, and importTenant is a delegate’s alone. Invitation
operations: issueInvitation, revokeInvitation and reissueInvitation.
A rule’s roles is the ceiling of what it grants, and no two operator rules
grant one account the same operation on the same tenant.
A role’s write on a tenant table gives it Save and New there; Delete needs
authorizeDeletedRecord as well; tablewalk check warns
(tenant-delete-unauthorized) where a role writes a tenant table and the policy
has none. A role’s commands runs a record’s command only where an action or
form offers it (action('Record payment', { command: 'recordPayment' }) on the
resource), as in any App; tablewalk check warns (command-unoffered) of a
command that names a record and that nothing offers.
Provision the stores
Section titled “Provision the stores”Startup never creates a tenant App’s stores. Make them once, with the server stopped, using the flags you start it with:
tablewalk migrate stores up --app ./leads --policy ./leads/server/tenant-policy.ts --config tenant.jsonThis makes the tenant store and the administration, invitation and support
audit journals the policy declares. It also makes the command journal, the
event store, the sandbox store and the record history, where the App uses
them. Each is left alone if it is already there. With support, set
TABLEWALK_AUDIT_KEY first: the audit store is set up under that key, and
tablewalk check needs it too, because it verifies the store against it.
When a store is absent, check and a start name it and print this command.
In code, provisionStores from tablewalk/maintenance does the same, one
store at a time (only: ['tenant-store'] with app: { tenant: binding }).
On your machine, tablewalk dev ./leads --policy ./leads/server/tenant-policy.ts --commands ./leads/commands.ts serves it and prints a sign-in per role: each
account is a member of the first two tenants of the profile table, or of the
platform realm for a platform role. In tests, host.tenant(id, { roles })
makes a member the same way (Test an App).
Keys in a tenant App
Section titled “Keys in a tenant App”Two rules hold for every tenant-owned table, and check refuses a table that breaks one, naming the table and the fix:
- Its primary key includes the ownership column (
primary key (tenant_id, id)), or it is a single opaque text id declaredid text not null primary key(auuidalso serves on PostgreSQL). Write thenot null: SQLite reports a baretext primary keyas nullable, and a nullable key is refused. Anintegerorserialid counts up across every tenant, so it needs the first form. - Every unique index includes the ownership column (
unique (tenant_id, code)), so that uniqueness holds within one tenant.
Upgrading the stores
Section titled “Upgrading the stores”Startup never migrates a tenant App’s stores: it refuses one behind its layout and prints the command that moves it forward. Stop the server, back up the business database, then:
tablewalk migrate stores status --app ./app --config server.jsontablewalk migrate stores up --app ./app --config server.json--app upgrades the stores the database holds, all tenants at once, each
step in one transaction. It creates none unless --policy is given. There are no down steps: to go
back, restore the backup and run the previous tablewalk. A tenant store made
before 0.8.11 has no step; reseed it.
The platform realm
Section titled “The platform realm”A platform role reads what resolvePlatformRows grants. It edits the
platform’s own access: 'write' resources with Save, New and Delete by naming
them in write (write: ['dealer', 'rate_card']), and changes a tenant’s rows
only through commands registered with realm: 'platform'. The profile’s key
and address columns are read-only, its rows are never made by New, and Delete
is offered only where no key refers to the table. Every root nav group and
platform view says its realm. The tenant chooser, the bar and the consoles
call each tenant by its profile row’s record name.
A platform command writes the platform’s rows and updates a tenant’s. To insert or remove a tenant’s rows it names the one tenant it acts in, by an input field:
seedDesk: defineCommand({ realm: 'platform', tenant: 'customer_id', source: 'business', target: 'desk_setting', targets: 'none', // desk_setting.tenant_id references customer: a write proves the rows it points at are readable. read: ['customer'], input: { customer_id: { label: 'Customer', type: 'text' } }, async handle({ db }) { await db.desk_setting.insert({ id: 'default', timezone: 'UTC' }); },}),The resource it writes says access: 'write', a platform role grants it
(commands: ['seedDesk']), and a form runs it from the platform realm
(commandForm('Seed a desk', 'seedDesk', { realm: 'platform' })). Its footprint
reads every table the inserted row points at, the ownership column’s parent
included; tablewalk check names one it is missing. That tenant must be active. Its rows are written as a member’s command writes
them, the owner placed for it, and no other tenant’s row is in reach. The
receipt is the platform member’s; every audit row names the tenant.
Who me is
Section titled “Who me is”auth.me: { from: 'person', column: 'account_id' } names the people table
and the column holding each person’s account id. In a tenant, me is
the row in the tenant the person is working in whose column is their
account: a person has one row per membership, and entering another tenant
makes me that tenant’s row. It is resolved on every request from the
admitted session, never from anything the browser sends. A membership
without a row matches nothing (assignee = me is an empty list, not an
error), and a support visit is no one. Beside a text column, me is still
the session’s email; {me.name} in a heading is the account’s display name.
team_id in (me.team_member.team_id) reads that row’s memberships in the
same tenant, so a row another tenant keeps about the same account is never
theirs.
The platform’s own staff are not a tenant’s people. Name their table in
auth.tenancy.platform.me, in the same shape: platform: { label: 'Provider', me: { from: 'staff', column: 'account_id' } }. On a platform lease me is
the staff row whose column is the account, so a provider’s queue can offer
mine: 'owner_id = me' over a column that points at staff. The table must
be owner: 'platform', and auth.me then names a tenant’s people. Each realm
resolves its own: on a tenant lease me is never a staff row, on a platform
lease never a tenant’s person, and staff without a row match nothing.
Without the mapping, a platform lease’s me is the session’s email, and a
staff reference beside it is refused. A platform command’s when still
reads me as auth.me says.
A notice rule’s to that reaches the people table, and an @mention, name
people by that same account id. Each is told at their account’s email in
the record’s tenant inbox, and only while they hold an active membership of
that tenant: an account that is not a member is told nothing, so a notice
never crosses tenants. tablewalk check warns when the me column looks
like an email, since then every such recipient would be dropped.
Public intake
Section titled “Public intake”A tenant App’s public form lands in
exactly one tenant. The policy names the profile column holding each tenant’s
intake address (intake: { address: 'intake_address' }), and the tenant
publishes /form/<key>?via=<address>. An unknown address or a suspended
tenant lands with the platform for triage. Placed with a tenant, the form’s
command inserts and may update that tenant’s rows of the tables it declares
under write (a routing rule’s tally); it never removes one, and triage only
inserts. A form saved on every step keeps
its draft in the tenant where its first step landed, and its token continues
nothing under another tenant’s link.
Public reads
Section titled “Public reads”A visitor may browse one tenant’s public rows (auth.public) by that tenant’s
public address, ?via=<address> on every page, or on the tenant’s mapped host.
The policy names the profile column that holds it (public: { address: 'public_address' }) and chooses the rows (resolvePublicRows(visitor), given
the tenant); the server adds the tenant itself, so a visitor reads that
tenant’s rows and global ones, never another tenant’s or the platform’s.
auth.public.read may not name a platform-owned resource. An unknown address,
or a suspended tenant, is the ordinary not-found. Visitors run no command.
A tenant reads its own addresses from /api/app (addresses: { intake, public }), and a custom component turns them into a link with
useNavigate().publicLink(view).
What a tenant keeps
Section titled “What a tenant keeps”Each feature works inside the tenant whose rows it touches: record history
(read only through a record the reader can read), activity() feeds,
notifications in that tenant’s inbox, CSV import (the owner injected; a file
cannot name a tenant), custom components, signed-in forms, jobs at times
(realm: 'tenant' or 'platform'), a machine’s timers (each run in its
row’s tenant; startup refuses one on a global table), workflows keyed per tenant, and flows
from rows: the record and its flow tables are tenant-owned, a change starts
only its own tenant’s flows, and a manual start is a member’s in their own
tenant. The
App API binds each token to one membership. A tenant link
adds a hint after the App’s own parameters (/leads?tab=open&tenant=north);
the hint is not authority, and tablewalk check --routes prints each realm’s
addresses.
A natural key (address) may be unique within the tenant,
UNIQUE (tenant_id, number). Another tenant’s record address opens the
ordinary not-found, and the session stays.
Refused in a tenant App: email and
mentions, webhook effects, inbound endpoints, the API’s event feed and
generic writes, MySQL, and the database browser’s --mcp.
Sandboxes
Section titled “Sandboxes”Mark a tenant-owned resource clone: 'config' (copied, and imported back) or
clone: 'sample' (copied when asked), and a tenant’s owner can make a
sandbox of their tenant in Manage tenants, try changes there, and approve
importing them back. Any conflict refuses the whole import. A sandbox shows a
Sandbox chip beside its name, and
tablewalk tenant clock <sandbox> --ahead 3d runs its timers ahead of real
time; a live tenant has no such clock. Provision the store once with
provisionStores(<the database>, { only: ['tenant-sandboxes'] }) from tablewalk/maintenance.
Common mistakes
Section titled “Common mistakes”- The App does not start: the policy’s
ownershipand a resource’sownerdisagree → make them say the same column and kind. [claim-in-tenant-app]: a role hasclaims→ grant the membership in the tenant store instead.TenantStoreOfAnotherApp→ a database holds one tenant App; give the second App a database of its own.- A role’s
actionsgrant is refused → grant the command the action runs, orwriteon the resource.
Related
Section titled “Related”- Row access: rows scoped within one organization.
- Roles and field access: the grants each membership’s role carries.
docs/tenant-apps.md: the full startup contract.- Sign in with your identity provider: a tenant’s own provider.