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
[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):
| Expression | Meaning |
|---|---|
true / false | Constant |
auth.uid() != null | Authenticated |
auth.uid() == null | Anonymous |
auth.role() == 'admin' | Role check |
field = auth.uid() / row.field = auth.uid() | Row field equals caller id |
field = 'literal' / row.field = 123 | Equality |
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.field | Compare 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 B | Boolean 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:
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).
[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
| Operation | Rule field | Notes |
|---|---|---|
| GET list / get | read | Unauthorized rows look like missing (no existence leak) |
| POST create | create | Evaluated on the proposed body, then again on the persisted row after DEFAULTS/triggers (WITH CHECK); denied creates roll back |
| PATCH update | update | Re-checked on current + projected row in one transaction |
| DELETE | delete | Re-checked on current row |
| History / state / permissions | Applicable row rules | Relationship expressions use the same database-backed evaluator as CRUD |
| Offline bootstrap / pull | read | Relationship changes force a safe reset before cursor advancement |
| Typed search operation | read | Search candidates are filtered inside the database transaction |
| WebSocket subscribe | subscribe + read | Table-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 push | notify | Evaluated 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.