Skip to content
Data

Permissions DSL

JSON permission language shared by REST, GraphQL, realtime filters, and DB persistence.

Backlex has its own JSON permission language. Same DSL is used for:

  • Role permission condition field (DB persistence)
  • REST ?filter=... query string
  • GraphQL filter argument

Compiler emits Drizzle SQL fragments (parameterized) for the SQL path, and an in-process matcher (matchesCondition) for realtime/sandbox.

OpMeansExample
_eqequal{ "status": { "_eq": "published" } }
_neqnot equal
_inin array{ "status": { "_in": ["a", "b"] } }
_ninnot in array
_gt/_gte/_lt/_ltenumeric / lexical{ "views": { "_gt": 100 } }
_nullis/is-not null (boolean){ "deleted_at": { "_null": true } }
_containsLIKE %x%{ "title": { "_contains": "foo" } }
_starts_withLIKE x%
_ends_withLIKE %x
_between, _icontains/_istarts_with/_iends_with, _empty/_nemptyrange, case-insensitive LIKE, null-or-emptysee Querying › Operators

Conditions speak the same DSL as REST filter — the full operator set, relation dot-paths, the _and/_or/_not aliases, and relative dates ({ "$now": { "sub": { "months": 1 } } }) all work in a permission.condition too. See the Querying reference for the complete operator set.

{
"$or": [
{ "owner_id": { "_eq": "$user.id" } },
{ "published": { "_eq": true } }
]
}
  • $and: [...] — all must match. Top-level keys are also implicit AND.
  • $or: [...] — any.
  • $not: {...} — negation.
  • _and / _or / _not are accepted aliases (normalized to the $-forms).

Resolved against the request’s auth subject:

  • $user.id — current user’s id (null when anonymous)
  • $user.email
  • $user.roles — array of role names
  • $tenant.id (aka $user.tenant_id) — active workspace id
  • $now — Date.now()

App-plane only (see Organizations) — these are resolved for workspace end-users and are always null/empty for control-plane identities:

  • $org.id — the organization this request is acting in
  • $org.role — the caller’s membership role in it (owner / admin / member)
  • $user.orgs — array of every organization id they belong to

$user.id resolving to null short-circuits comparison ops to false, so anonymous users never accidentally match { owner_id: { _eq: "$user.id" } }. $org.id behaves the same way: with no organization selected an { org_id: { _eq: "$org.id" } } rule matches nothing rather than falling through to another org’s rows.

The array-valued variables ($user.roles, $user.orgs) can stand in for the whole right-hand side of _in / _nin:

{ "org_id": { "_in": "$user.orgs" } } // every org they belong to

An array variable that resolves to nothing makes _in match no rows and _nin match all of them — the same reading an explicitly empty list has.

Each row in permissions binds a role to a (collection, action) pair:

{
"role_id": "<authenticated-role-uuid>",
"collection": "posts",
"action": "read",
"condition": { "$or": [
{ "published": { "_eq": true } },
{ "owner_id": { "_eq": "$user.id" } }
] },
"fields": ["title", "body", "published", "views"]
}
  • collection: "*" matches every collection.
  • action: one of read | create | update | delete | publish. publish gates the publish/unpublish/schedule endpoint on versioned collections and lets a caller see drafts; it’s separate from update so editors can draft without going live.
  • condition: null → no row-level filter (full access).
  • fields: null → all fields readable/writable. Otherwise allow-list.
  1. loadRolesForUser — fetch the user’s roles. Anonymous = public; any signed-in user gets authenticated implicitly.
  2. If any role has admin: true → bypass all checks.
  3. Else find permission rows matching (role IN roles) AND action AND collection IN (slug, '*').
  4. None found → 403.
  5. OR-combine the conditions across matching rows (most permissive wins). null condition on any matching row = unrestricted.
  6. Field allow-list = union of fields across matching rows.

The compiled whereSql is AND’d with any user-supplied filter from the request, so users can never widen their access via filter.

Resolving is not applying — the condition belongs at the ROW

Section titled “Resolving is not applying — the condition belongs at the ROW”

Steps 1–4 answer a question about the collection. For a grant that carries a condition, that question passes for every row in the table: the condition is what separates the caller’s rows from everyone else’s, and it only does that work when step 5’s whereSql actually reaches a WHERE. Anything that hangs off a row id has to ask the row too, not just the collection it lives in —

SurfaceWhat it applies
?expand= (to-one, to-many, every hop of a chain)the target’s whereSql, soft-delete and draft filters; an unreadable target inlines as null
revision history (both endpoints)the row’s readability (404, like the item), then fields over each snapshot
/api/comments (list and create)the row’s readability — a thread is as visible as the row it is attached to, and no more
/api/vector/* on a collection namespaceread/update on that collection, then hydration back through the table
keyset next_cursorrefuses to sort by a private field or one outside fields
dashboard embeds, KPIs, aggregatesthe embed role’s whereSql + fields

services/items/row-access.ts is the one place that asks it — readableRow for a single id, readableIds for a set — so a new surface has a function to call rather than a filter to remember.

The write side, and why it defaults to warn

Section titled “The write side, and why it defaults to warn”

whereSql answers “which rows that ALREADY EXIST may I touch”. That is the whole question for read and delete, and half of it for create and update: the canonical B2B rule { org_id: { _eq: "$org.id" } } filters reads, updates and deletes, and does not by itself stop an INSERT carrying somebody else’s org_id. Postgres RLS projects the same condition as WITH CHECK on INSERT, so the database was stricter than the API in front of it.

services/items/write.ts::assertWriteConditions closes that, on every path that reaches a row write — REST, GraphQL, batch, bulk and CSV import. It judges the PROPOSED row against the same conditions, in memory.

Terminal window
PERMISSION_WRITE_CHECK=enforce # default — refuse it with a 403
PERMISSION_WRITE_CHECK=warn # count it, allow it (the migration setting)

enforce is the default, and warn is a migration setting rather than a resting state. An empty value falls back to enforce, so PERMISSION_WRITE_CHECK= cannot quietly disable the check.

If you are upgrading an existing deployment, measure before you land on it. A tenant’s integrations may have been writing cross-scope rows for months against a rule that only ever filtered READS, and turning that into a 403 breaks a working application for somebody who changed nothing. The move:

  1. set PERMISSION_WRITE_CHECK=warn and run for a representative window;
  2. read the Advisor’s permission-write-check rule — it reports every recorded write that enforce would refuse, per collection and action;
  3. widen the write conditions it names, or fix the caller;
  4. unset the variable.

Zero findings is the green light. See Advisor.

This defaulted to warn until the advisor rule existed to make the reading a measurement instead of a guess — the same position PLANE_GUARD was in, where a permissive default meant the guarantee was real on the deployments that set it and inert everywhere else.

One case is judged by neither setting: a condition reaching through a relation (author.department) cannot be evaluated against a proposed row in memory, while the SQL compiler lowers it to a correlated EXISTS on reads. Such a condition is skipped rather than denied — refusing there would be an outage, not a fix — and is therefore outside the Advisor’s count too.

When a collection is created with ownerScoped: true, the API auto-seeds four permissions for the authenticated role:

ActionCondition
read{ owner_id: { _eq: "$user.id" } }
create(none — ownership set by route)
update{ owner_id: { _eq: "$user.id" } }
delete{ owner_id: { _eq: "$user.id" } }

These are real permission rows — admin can edit them in /settings afterward.

RoleBypassAuto-assigned
adminyesFirst user to sign up
authenticatednoImplicit on every signed-in request
publicnoAnonymous requests

System roles cannot be deleted from the admin UI.

orgAssignable — may this role leave the workspace?

Section titled “orgAssignable — may this role leave the workspace?”

A role you define is yours by default. Set orgAssignable on it and an org admin — one of your customers’ end-users, not you — may bind it to members of their own organization from the app plane. Leave it off for anything written for your own staff: admin is barred everywhere, but a “Support” role reading every collection is not, and without the flag any org admin could grant it to themselves. Full model in Organizations.

admin is workspace-scoped — the instance operator is separate

Section titled “admin is workspace-scoped — the instance operator is separate”

admin is resolved per workspace: tenantMiddleware recomputes auth.roles from the active workspace on every request, and POST /api/tenants grants admin to whoever creates a workspace. It is therefore a self-serve role, and it deliberately does not authorize anything that spans workspaces or the whole database.

Those surfaces take requireOperatorMw (services/roles/guards.ts::isInstanceOperator) instead. The instance operator is:

  • admin in the default/bootstrap workspace — the first user to sign up is seeded there, so existing single-workspace installs are unaffected; or
  • the address pinned in OWNER_EMAIL, when a provisioner set one.

API keys and app-plane end-users are never operators, whatever roles they hold. Today this gates the raw SQL console and the instance-wide table/migration inventory (routes/db-admin.ts), and acts as the cross-workspace escape hatch on /api/tenants/{id}/*. Backups stay on workspace admin — they run against auth.tenantId and services/backup.ts::TENANT_WHERE narrows every system table to that workspace, including the four with no tenant_id column of their own. A backup row with tenant_id = NULL is different: that one is a full instance dump, and it is reachable only to the operator.

Recovering a lost operator. Nothing in the API renames or deletes a workspace, so the default one is normally permanent. If it was removed by hand, ensureDefaultTenant recreates an empty default that nobody admins and the operator-gated routes start returning 403. Set OWNER_EMAIL to your address to get back in without touching the database.

Two collections, teams and posts with team_id relation field. Visibility rule: a user reads posts of teams they belong to.

{
"role_id": "<authenticated>",
"collection": "posts",
"action": "read",
"condition": {
"team_id": {
"_in": ["$team_ids"]
}
}
}

$team_ids isn’t a built-in variable — for runtime-resolved arrays you’d use a condition that references stable user metadata, or denormalize member_team_ids into the user record. The DSL is intentionally narrow; complex flows belong in functions.

Roles and permissions are admin-scoped on every surface.

GET /api/roles list roles (built-ins + workspace roles)
POST /api/roles create { name, description?, admin? }
PATCH /api/roles/{roleId} rename / edit
DELETE /api/roles/{roleId} delete
GET /api/roles/{roleId}/permissions the rules bound to one role
POST /api/roles/{roleId}/permissions grant { collection, action, condition?, fields? }
DELETE /api/permissions/{permissionId} revoke one rule
POST /api/permissions/simulate dry-run the resolver (below)

A grant returns the created row, and its id is what DELETE /api/permissions/{id} takes — a rule is revoked by its own id, not by re-stating the (role, collection, action) triple.

Terminal window
# Let the portal role read invoices, but only its own customer's.
curl -X POST http://localhost:5173/api/roles/$ROLE/permissions \
-H 'content-type: application/json' -H 'Origin: http://localhost:5173' \
--cookie "$(your admin session cookie)" \
-d '{"collection":"invoices","action":"read",
"condition":{"customer.app_user_id":{"_eq":"$user.id"}}}'

client.permissions.* mirrors the REST surface; GraphQL exposes the same mutations; the MCP tools are permissions-grant / permissions-revoke / permissions-list_for_role / permissions-simulate; the CLI is backlex permissions.

Granular rules are powerful but hard to debug — why can’t user X read posts? The permission simulator dry-runs the resolver for a subject against a (collection, action) and returns the full reasoning trace, without touching any data. It’s read-only and admin-only, scoped to the active workspace.

What it returns:

  • decision — allowed + isAdmin + a human-readable reason.
  • roles — every role the subject holds in the workspace (with the admin flag).
  • matchedRules — each permissions row that granted the action: which role, the normalized condition, and the field allow-list.
  • resolvedVars — the concrete values the DSL variables bound to for this subject ($user.id, $user.email, $user.roles, $tenant.id, $now).
  • whereSql — the OR-combined WHERE clause the REST/GraphQL layers would apply, rendered to parametrized SQL (null = unrestricted).
  • fields — the union of allowed fields (null = all).
  • rowMatch — when you pass a sampleRow, whether that concrete row would pass the combined condition (per-rule and overall).

The subject is either an existing user (userId — roles read live from the DB) or an ad-hoc one (roles by name; with none it’s anonymous — the public role). An ad-hoc subject has no userId, so $user.id resolves to null and an owner_id _eq $user.id clause collapses to 1=0 — pass a real userId to see owner-scoped filters compile to a real predicate.

Admin UI lives under Roles & permissions → Tester. Every programmatic surface mirrors the same call:

Terminal window
# REST
curl -X POST $API/api/permissions/simulate \
-H 'content-type: application/json' \
-d '{ "userId": "<id>", "collection": "posts", "action": "read",
"sampleRow": { "owner_id": "<id>" } }'
# CLI
backlex permissions simulate --collection posts --action read --user <id>
backlex permissions simulate --collection posts --action read --roles authenticated
# SDK
await client.permissions.simulate({ userId: "<id>", collection: "posts", action: "read" });
# MCP (so Ask AI / agents can answer "why can't user X read posts?")
permissions.simulate { "userId": "<id>", "collection": "posts", "action": "read" }
query {
permissionSimulation(collection: "posts", action: "read", userId: "<id>") {
allowed
isAdmin
reason
matchedRules { roleName condition fields }
whereSql { sql params }
fields
}
}