Skip to content

Sign-in, roles & data

This page covers who may use an App and which data it reaches: sign-in, roles, field and row scoping, tenant Apps and named sources.

resources: { lead: { access: 'write' }, account: { access: 'read' } },
auth: {
providers: ['email'],
signUp: 'invite',
defaultRole: 'viewer',
roles: {
viewer: { read: true },
sales: { read: true, write: ['lead'] },
},
},

Three layers, each enforced on the server: authentication identifies the person, grants limit resources, fields and rows, and the source binding picks the database. Navigation and view filters only decide what is shown. Every write still needs the connection’s "writable": true, enforced at the adapter.

auth signs people in through Better Auth, with email and password, GitHub or Google. Present, every request needs a session.

Key Says
providers email (default), github, google. Social providers read their credentials from the environment.
signUp 'invite' (default: an administrator adds accounts) or 'open'.
defaultRole The role a new account gets. Default user.
store Where sessions live: a SQLite file or a postgres:// URL, separate from the business database. Default: auth/{id}.db in tablewalk’s config directory.

The server needs BETTER_AUTH_SECRET and its public URL; changing auth needs a restart. Mail goes through the config’s email service:

  • Password reset. With an email service, “Forgot password?” emails a one-time link, valid for an hour, and a reset ends the account’s other sessions. With TABLEWALK_AUTH_RESET=log the link goes to the server log instead, for an operator to pass on; set it only where the log is as private as the secret. With neither, sign-in says an administrator resets passwords.
  • Verified email. With an email service, sign-up sends a link that verifies the account’s address, which a form’s link.email trusts.
  • First account. An administrator adds it, or sign-up is 'open'; the samples’ READMEs show the seed step for each.

auth without roles is sign-in only. With roles, a missing grant or an unknown role denies.

The bar’s account menu shows who is signed in, Your profile (the display name; email and sign-in methods are read-only), Preferences, dark mode and Sign out. Sign-out locks and clears the App at once; if the server cannot confirm it, the App stays locked and says so.

An account holding the admin role finds Manage accounts in the account menu. It can:

  • add an account: name, email, roles and, with email sign-in, a first password;
  • set its roles, from the App’s auth.roles, its defaultRole and admin;
  • deactivate or reactivate it (deactivating ends its sessions);
  • set a new password, which ends the account’s other sessions.

Each change is authorized from the verified session and checked again by Better Auth. An administrator cannot remove their own admin role or deactivate themself. A tenant App manages members through its own administration.

Every change is recorded in the sign-in store, in the same transaction as the change, so a change that cannot be recorded is not made: who made it, when, the account, and the fields as they were and became — never a password or a session token. So is every account made on someone’s behalf: by the operator’s createAppAccounts step (recorded as maintenance) or by claiming an invitation (recorded as by invitation). History on the accounts screen reads the whole record, and View history on an account reads that account’s. The record is append-only and kept as long as the store (no retention limit; SECURITY.md). A PostgreSQL store made before it existed is brought up by running migratePostgresAuthStore again; a SQLite store gains it on start.

A one-row resource, such as a company profile, opens from Settings in the account menu and from ⌘K instead of from a list:

resources: { company: { access: 'write', settings: 'Company profile' } },

It opens the first row by key, on the resource’s own page, under its own grants. Payday keeps its company profile this way.

Each resource declares the App’s ceiling, access: 'read' or 'write', and auth.roles grants within it:

Grant Says
read true for every resource with an access, or a list.
write true for every 'write' resource, or a list, within the role’s reads.
commands Server command ids the role may run. Grants no Save, New or Delete.
actions Named fixed actions on a resource the role reads but does not write: { pay_run: ['Submit for review'] }. Under a row policy, only on the principal’s rows; not with rows: 'tenant'.
fields Column narrowing per resource (below).
operations 'readiness', 'bundle': operator reads.
realm 'tenant' or 'platform', once the platform realm is declared.

A role cannot widen a ceiling. To let a role run an operation without generic Save, grant the command or the fixed action and leave write off; see granting a fixed action and granting an operation. Grants are listed in authority.lock (see features).

A resource’s fields is its column ceiling for signed-in roles; a role’s fields narrows it further:

resources: {
lead: { access: 'write', fields: { read: ['id', 'name', 'status'], write: ['name', 'status'] } },
},
auth: {
roles: {
sales: { read: true, write: true, fields: { lead: { read: ['id', 'name', 'status'], write: ['name'] } } },
},
},

write defaults to no columns; primary keys stay readable and writable columns must be readable. A hidden field is left out of metadata and projections, and a query that names it is refused. Field rules are not row isolation.

hidden keeps columns from every reader, signed in or not, with no auth needed:

resources: {
account: { hidden: ['password_hash', 'reset_token'] },
},

The server cuts them from the catalog it answers with. Lists, records, exports, history, search and the App API never carry them, and a query, filter or sort that names one is refused as naming a column that is not there. No role re-grants a hidden column, and no form or action writes it. The primary key, the address and a search field cannot be hidden. The cut is that resource’s columns alone: every other resource reads and writes as before, and a rollup whose query names only visible columns counts its rows.

To cover the database browser, MCP and every App on a connection, put "hidden" on the connection in tablewalk.json:

{ "name": "accounts", "url": "postgres://reader@db/app",
"hidden": { "account": ["password_hash"], "*": ["*_token"] } }

A key is a table (or "*" for every table), and * matches within a column name. Raw SQL is refused on that connection, because a statement can name any column. Neither is a substitute for database privileges: anyone with the connection URL still reads every column. The strongest form is a database role without SELECT on those columns.

auth.public lets a visitor who has not signed in read named resources, read-only:

auth: {
providers: ['email'],
roles: { staff: { read: true } },
public: { read: ['vehicle', 'vehicle_photo', 'dealer'], fields: { dealer: { read: ['id', 'name', 'city'] } } },
},

Each resource is named (read: true is refused) and stays within its read ceiling. A visitor writes nothing except through a public form. It needs auth.roles, works beside rows: 'all' or 'policy', and is refused under rows: 'tenant'. Preview; Forecourt is the sample. Public pages shows the front page and public lists built on it.

auth.rows: 'policy' scopes every row a signed-in account reads by a server-only resolver:

resources: { ticket: { access: 'read' } },
auth: { rows: 'policy', roles: { reviewer: { read: true } } },
// policy.ts — server only, never imported by the App
import { defineAppRowPolicy } from 'tablewalk/policy';
export default defineAppRowPolicy({
appId: 'service-review',
resolveRows: principal => ({
ticket: { groups: [[{ column: 'assigned_user_id', op: '=', value: principal.id }]] },
}),
});
Terminal window
tablewalk --config server.json --app ./app --policy ./policy.ts --check-app
tablewalk --config server.json --app ./app --policy ./policy.ts

The resolver receives the verified account id, App id and roles. An omitted resource or false denies rows; a filter is ANDed with every query. Lists, records, related lists, searches, counts and exports are scoped. Raw SQL, MCP and CLI export modes are refused. Exactly one --app; policy changes need a restart.

With access: 'write' and role write grants, a row-policy App also updates and creates rows (Save, inline edit, fixed actions, New) with Undo, on a writable SQLite or PostgreSQL source; MySQL is not supported. Deleting and restoring need an explicit authorizeDeletedRecord(principal, { table, key, row }) hook in the policy, returning a boolean.

--commands works beside --policy. The policy supplies resultReferences, one extractor per signed-in command naming the rows its result draws on, and may enroll a fixed public form with publicForms. See load and run and the authoring guide.

auth.rows: 'tenant' makes one App serve several organizations, each member seeing only their tenant’s rows (preview, SQLite and PostgreSQL):

Terminal window
tablewalk --config tenant.json --app ./ignition --policy ./tenant-policy.ts \
--commands ./commands.ts --check-app
  • The policy. defineAppTenantPolicy from tablewalk/policy declares which resources each tenant owns and by which column. Creation injects the ownership column on the server; replies never carry it.
  • The store. Provision once from tablewalk/commands (provisionSqliteTenantStore, migratePostgresTenantStore); startup never creates it. A PostgreSQL tenant source needs a database of its own. Seed and maintain tenants and memberships with the tenant steps of tablewalk/maintenance (putTenant, grantTenantMembership, …).
  • Members. A signed-in person picks a tenant in the chooser; roles come from their membership. Account-bound invitations (issued, reviewed and accepted from the account menu) and delegated tenant administration are preview.
  • Support sessions. A platform member may enter one tenant for a short, audited, read-only visit; the tenant sees a display name, never an email.
  • Also. --jobs runs beside --policy; the App API binds each token to one membership. No tenant Apps on MySQL; the database browser’s --mcp refuses them.

The full startup contract is docs/tenant-apps.md.

auth.tenancy.platform adds the business that runs the tenants, a lender above its dealers, as a realm of its own:

auth: {
rows: 'tenant',
tenancy: { label: 'Dealer', profile: 'dealer', platform: { label: 'Lender' } },
roles: {
staff: { realm: 'tenant', read: true, write: ['lead'] },
underwriter: { realm: 'platform', read: true, commands: ['approve-lead'] },
},
},
resources: {
dealer: { access: 'read', owner: 'platform' },
lead: { access: 'write', owner: 'tenant' },
vehicle_model: { access: 'read', owner: 'global' },
},
owner Rows
'tenant' Each tenant’s own.
'global' Reference rows every tenant reads; access: 'read'.
'platform' The platform’s own; no tenant reads them.

Every role, root nav group and platform view says its realm. A platform role reads what the policy’s resolvePlatformRows grants and writes only through commands registered with realm: 'platform'. A platform-only database joins as a source with realm: 'platform', read-only.

The profile has one row per tenant, and the tenant chooser, the bar, the support banner and the administration and invitation consoles call each tenant by that row’s name (its recordLabel, from the profile’s own columns); a tenant without one is called by its id. A role is said as words (branch_viewer is “Branch viewer”).

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' }); the tenant publishes /{app}/form/{key}?via=<address>. An unknown address or a suspended tenant lands with the platform realm for triage. A form saved on every step keeps its draft where its first step landed: a root table in each place (the tenant’s and the platform’s), every step resolved again, and the applicant’s token bound to that tenant, so another tenant’s link continues nothing.

A tenant App keeps the same App features, each inside the tenant whose rows they are:

  • Record history (history()): kept under the record’s key and its owner; read only through a record the reader can read. activity() feeds are refused.
  • Notifications (notify): each notice goes to the inbox of the tenant whose record was written, read and marked only on that tenant’s lease. email and mentions are refused.
  • CSV import: every row is written in the member’s own context, the owner injected; a file cannot name a tenant.
  • Custom components: served to signed-in members; every read they declare is an ordinary read in the member’s tenant.

Provision the history and notification stores in the App’s own database (provisionSqliteRecordHistory, provisionPostgresInbox, …); startup refuses without them.

A tenant link is the App’s address with a hint after its own parameters: /atlas/items?tab=open&tenant=north, or ?realm=platform for the platform realm. The hint is not authority: sign-in and the chooser still come first. tablewalk check --routes prints each realm’s addresses.

An App reads one connection, or several named sources:

sources: {
lending: { connection: 'loans', default: true },
applicants: { connection: 'applicants' },
},

The default source’s resources are bare (loan_application); others are qualified (applicants.applicant). At most one source is the default, and none has to be; a source standing alone is, unless it says default: false. The App’s own source (its accounts and command journal) is the default, or the first source it names. physical binds a name the catalog cannot find alone (physical: 'archive.opportunity'); a name two schemas share is inferred as <schema>_<name> for each copy. Connection URLs and credentials stay in the server’s configuration. There are no joins or transactions across connections, and named-source Apps are refused over MCP.

Several sources can share one PostgreSQL connection, each scoped to a schema (schema: 'catalog'); each reads and writes its own schema’s tables alone, while a real key between two of them joins and their writes are one transaction. Grants and row policies are per resource as ever, and the connection’s writable covers every source on it. A platform-only source (realm: 'platform') keeps a connection of its own. resources: 'all' on a source declares the tables a view names there (a list, the ones it lists) without a block each, and grants nothing (sources).

A column that names another resource’s record without a foreign key in the database (a text key, a code) can declare it:

resources: {
stock_move: { references: { product_sku: { to: 'product', column: 'sku' } } },
},

It is then followed like a foreign key: the list shows the product’s recordLabel, the value opens the product, the product’s page lists its moves, and rollups, filters, sorts and label paths walk it. column defaults to the target’s primary key, and it must be proven unique by the primary key or a unique constraint or index on that column alone. The two columns must be of one kind. An App refuses to load with a reference it cannot prove.

The edge exists only in the App’s catalog, so the reader’s grants, fields and hidden on the target apply as for any key, and a reader who cannot read the target does not see the reference. It is never authority for a write: nothing cascades from it, and it adds no delete impact.

A target in another source on the same connection joins in SQL. One on another connection (to: 'catalog.product') is stitched: the list’s rows first, then one read of the distinct keys the page names (at most 500), through the target source’s own reader, so its grants, hidden columns and row policy decide what a label shows; a hidden target reads as empty. It labels the list, opens the target by its primary key, and gives the target a related list. A filter, sort or grouping on the far side, a rollup, tags or a notify path through it is refused, at load where the App says it and per request otherwise. Under auth.rows: 'policy' a path through a reference is scoped like one through a key; a tenant App’s references stay within its one source, and the target key must be unique on its own (its opaque primary key).

An App serves only its declared resources, and its readers never get the Workbench: no connection manager, raw SQL, generated SQL, schema browser or physical bindings. The standalone database browser keeps them, so treat a deployment that serves it as database access.

  • The shell sends a Content-Security-Policy: scripts from the server only, connect-src 'self', no framing by other sites. Custom components reach other origins only through ui.connect and ui.images.
  • A signed-in reader’s pictures are private, no-cache with Vary: Cookie, so a withdrawn grant or sign-out takes effect at once.
  • Compiled custom components and their source maps are private in an App that signs people in.
  • History, the activity feed and notices name people, never their email.
  • Previously fetched data is not erased from a browser; there is no policy polling.

Authenticated Apps over MCP (use the App API instead), shared saved layouts under role grants, email in tenant Apps, uploads on row-policy or tenant Apps, and scoped MySQL writes.