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.
Authentication
Section titled “Authentication”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=logthe 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.emailtrusts. - 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.
Account menu
Section titled “Account menu”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.
Managing accounts
Section titled “Managing accounts”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, itsdefaultRoleandadmin; - 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.
Settings records
Section titled “Settings records”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.
Resource permissions
Section titled “Resource permissions”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).
Field permissions
Section titled “Field permissions”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 columns
Section titled “Hidden columns”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.
Signed-out reads
Section titled “Signed-out reads”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.
Read-only row policies
Section titled “Read-only row policies”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 Appimport { defineAppRowPolicy } from 'tablewalk/policy';
export default defineAppRowPolicy({ appId: 'service-review', resolveRows: principal => ({ ticket: { groups: [[{ column: 'assigned_user_id', op: '=', value: principal.id }]] }, }),});tablewalk --config server.json --app ./app --policy ./policy.ts --check-apptablewalk --config server.json --app ./app --policy ./policy.tsThe 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.
Scoped writes
Section titled “Scoped writes”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.
Scoped commands
Section titled “Scoped commands”--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.
Tenant App startup
Section titled “Tenant App startup”auth.rows: 'tenant' makes one App serve several organizations, each member
seeing only their tenant’s rows (preview, SQLite and PostgreSQL):
tablewalk --config tenant.json --app ./ignition --policy ./tenant-policy.ts \ --commands ./commands.ts --check-app- The policy.
defineAppTenantPolicyfromtablewalk/policydeclares 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 oftablewalk/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.
--jobsruns beside--policy; the App API binds each token to one membership. No tenant Apps on MySQL; the database browser’s--mcprefuses them.
The full startup contract is docs/tenant-apps.md.
The platform realm
Section titled “The platform realm”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”).
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' }); 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.
What a tenant keeps
Section titled “What a tenant keeps”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.emailandmentionsare 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.
Tenant-qualified links
Section titled “Tenant-qualified links”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.
Named sources
Section titled “Named sources”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).
Declared references
Section titled “Declared references”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).
App and browser boundaries
Section titled “App and browser boundaries”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.
Security posture
Section titled “Security posture”- 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 throughui.connectandui.images. - A signed-in reader’s pictures are
private, no-cachewithVary: 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.
Not yet
Section titled “Not yet”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.