Permissions
Rules, per entity, deciding which objects each user can see and change.
withPermissions is planned; today we set an environment's rules for you from the same document.Actions decide what an operation does. Permissions decide which data a user can touch at all. You need both: a bug in one action, or a new action written in a hurry, cannot leak or overwrite another user's data if the rules forbid it.
export default domain("orders")
.withSchema({
entities: {
orders_order: i.entity({ status: i.string().indexed(), total: i.number() }),
},
links: {
orders_orderOwner: {
forward: { on: "orders_order", has: "one", label: "owner" },
reverse: { on: "$users", has: "many", label: "orders" },
},
},
rooms: {},
})
.withActions({ /* … */ })
.withPermissions({
orders_order: {
bind: { isOwner: "auth.id in data.ref('owner.id')" },
allow: {
view: "isOwner",
create: "isOwner",
update: "isOwner",
delete: "false",
},
},
$default: { allow: { $default: "false" } },
});Users see only their own orders. An action can create or change an order only for the user who called it. Nobody deletes one. Everything else is denied.
Who is checked
Rules run against the caller of the action, not the action:
- A signed-in user who calls
orders.placecreates the order as that user, so thecreaterule must allow it. - The browser cannot write at all: the environment accepts writes only from your action code. You do not need
create: "false"to keep browsers out; write rules for the user an action acts for. - A service key is an administrator. Rules do not apply to it.
Development environments apply the same rules, so you can test permissions while you develop.
Operations
| Checked when | data is | |
|---|---|---|
view | the object would appear in a query result | the object |
create | a tx creates it | the object after the write, links included |
update | a tx changes it | the object before; newData is after |
delete | a tx deletes it | the object |
link, unlink | a tx links or unlinks, per label: allow: { link: { owner: "…" } } | the object |
A hidden object is simply missing from query results — no error, no count, so the caller cannot tell it exists. A denied write fails the whole tx with permission-denied.
What a rule can use
auth.id | the signed-in user's id; null when signed out |
auth.email | the email the token was minted with, if any |
auth.claims | claims your backend put in the user token (up to 1 KB), e.g. auth.claims.role |
data | the object (see the table above) |
newData | in update, the object after the change |
data.ref('path.attr') | values reached through links, always a list |
auth.ref('$user.path.attr') | the same, starting from the signed-in user |
ruleParams | parameters the query sent with itself |
Rules are CEL expressions that must evaluate to true.
Following links: data.ref
data.ref walks links and returns a list, even for a has: "one" link. Compare with in, never ==, and end the path with an attribute:
"auth.id in data.ref('owner.id')" // ✅
"auth.id == data.ref('owner.id')" // ❌ compares a string with a list
"auth.id in data.ref('owner')" // ❌ no attribute at the end
"auth.id in newData.ref('owner.id')" // ❌ newData has no ref; on create, use dataReusing parts of a rule: bind
orders_order: {
bind: {
isOwner: "auth.id in data.ref('owner.id')",
isManager: "auth.claims.role == 'manager'",
},
allow: { view: "isOwner || isManager", update: "isManager" },
},Hiding fields
To let everyone see an entity but only some users see one attribute, add a fields rule:
orders_customer: {
allow: { view: "true" },
fields: { email: "auth.id in data.ref('account.id')" },
},Defaults
For your entities, a missing rule allows. $default changes that for every entity and operation you did not write a rule for. Start from deny and open what you need:
$default: { allow: { $default: "false" } },System entities have their own defaults: $files and $streams are denied until you add rules; each user sees only their own $users row and their own $executions.
When a rule says no
- Reproduce it with a user token against a production release (development runs actions as an administrator today).
- Split the rule. If
isOwner && isManagerfails, try each half alone. - Check the link.
data.ref('owner.id')is empty if the action forgot.link({ owner: … })in the sametx.