Loomup Docs
Guide /docs/access-rules

Access rules guide

Loomup enforces default-deny row-level rules consistently across REST, offline sync, realtime, history/state/permissions, typed search operations, and push delivery. Rules live in project config (loomup.toml) under each table — this is the intentional source of truth (not a SQL _access_rules table).

Config shape

toml
[tables.todos]
expose = true
realtime = true
primary_key = "id"

[tables.todos.rules]
read = "true"
create = "auth.uid() != null"
update = "user_id = auth.uid()"
delete = "user_id = auth.uid()"
subscribe = "auth.uid() != null"
notify = "false"   # mobile push; see docs/push.md

Unconfigured tables are not exposed and have deny-all rules.

Expression grammar

Supported atoms (safe subset):

ExpressionMeaning
true / falseConstant
auth.uid() != nullAuthenticated
auth.uid() == nullAnonymous
auth.role() == 'admin'Role check
field = auth.uid() / row.field = auth.uid()Row field equals caller id
field = 'literal' / row.field = 123Equality
exists(table, field = value, ...)True when a related row matches all filters
lookup(table, result_field, field = value, ...)Read one field from a matching related row; may be nested
lookup(...) = lookup(...) / lookup(...) = row.fieldCompare a related value on the left with another related value, a row field, caller identity, or a literal; supports =, ==, !=, and <>
A AND B / A OR BBoolean combine (left-associative; parentheses supported)

field and row.field are equivalent when referring to the current row. The qualified form is useful in compiler-generated rules and is required when a current-row value is passed into exists(...) or lookup(...).

Malformed trailing AND/OR branches are errors, not silently skipped.

Relationship rules

Relationship expressions may traverse generic tables without resource-specific server code. For example, a comment can inherit workspace membership through its issue:

toml
read = "exists(workspace_memberships, workspace_id = lookup(issues, workspace_id, id = row.issue_id), user_id = auth.uid())"

Every database-backed authorization surface evaluates exists(...) and lookup(...) against the same SQLite snapshot. A relationship expression is rejected if evaluated without a database connection; it never degrades to a false or null placeholder that could silently deny or allow access.

Storage rules use the same comparisons. Compiled object rules can compare an attachment's workspace with its parent project's workspace using lookups on both sides; an authorized non-owner can therefore read the object without changing its owner or making the bucket public. A missing lookup follows the existing null comparison semantics; include the required membership/existence checks in access rules rather than treating equality of two missing values as a grant.

When a synchronized resource's read rule references another table, Loomup also captures changes to that dependency table. A dependency change emits a payload-free realtime INVALIDATE event on the dependent resource channel. For clients subject to read rules, pull returns reset_required for permission-relevant or uncertain changes before its sync cursor advances. Rebootstrap removes newly private rows and adds newly visible rows. Service keys authorized to read the requested resources and admins with rule bypass enabled skip this dependency reset because row rules do not restrict their visibility. Resource scopes and other sync reset conditions still apply; dependency capture and realtime invalidation remain active. The dependency row and table name are not included in the realtime payload. Each dependency table must have exactly one primary-key column so its changes can be journaled.

Offline read rules must be event-driven. Loomup rejects now() in a synchronized resource's read rule because the result could change without any journal event to invalidate a cached row.

Admin bypass

By default, users with role = admin bypass all table rules (convenient for the admin UI).

toml
[admin]
rule_bypass = true   # default
# rule_bypass = false  # admins must satisfy the same rules

Sensitive admin actions (password reset, session revoke, disconnect, GC) are written to _admin_audit regardless.

Operations

OperationRule fieldNotes
GET list / getreadUnauthorized rows look like missing (no existence leak)
POST createcreateEvaluated on the proposed body, then again on the persisted row after DEFAULTS/triggers (WITH CHECK); denied creates roll back
PATCH updateupdateRe-checked on current + projected row in one transaction
DELETEdeleteRe-checked on current row
History / state / permissionsApplicable row rulesRelationship expressions use the same database-backed evaluator as CRUD
Offline bootstrap / pullreadRelationship changes force a safe reset before cursor advancement
Typed search operationreadSearch candidates are filtered inside the database transaction
WebSocket subscribesubscribe + readTable-wide: row fields deferred to delivery. Row-scoped (id set): rules evaluated at subscribe time against the loaded row — unauthorized row ids are denied immediately
Mobile pushnotifyEvaluated per candidate user id from notify_user_fields (no admin rule bypass). Requires [push].enabled and tables.*.push = true. See Push

Realtime delivery

Every broadcast revalidates the access token, user disabled status, table realtime flags, and read/subscribe rules against the event row. Expired tokens or tightened rules mute delivery without killing the socket.

Simulator

Admin UI → Rules → “Test rule” posts to /admin/api/rules/test with sample auth + row JSON. Relationship expressions are resolved against the project's database, matching production evaluation.

Intentional TOML source of truth

Access rules are not stored in _access_rules. Editing loomup.toml (or the admin Rules UI, which rewrites the TOML) is the supported model. This keeps rules reviewable in git and avoids dual sources of truth.