Row-level policies
Object-level RBAC answers “may this role read players?”. Row-level
policies answer “which players rows?” — the difference between a role that
can read every player and one that can only read its own row.
A row policy rides on a normal permission grant, so you configure it exactly where you configure everything else about a role (the roles API or the admin Roles editor):
{ "resource": "players", "actions": ["create", "read", "update"], "rowPolicy": "own"}Row policies apply when RBAC enforcement is on (ION_REQUIRE_AUTH=true),
across every surface — REST, GraphQL (including relation fields), MCP,
aggregates, bulk operations, and the realtime event stream — because they are
enforced once, in the shared data service.
The policy language
Section titled “The policy language”Deliberately tiny and non-Turing. A rowPolicy is one of:
| Policy | Meaning |
|---|---|
(absent) or "all" | No row restriction — exactly the pre-policy behavior. |
"own" | Only rows whose created_by equals the acting principal’s id. |
"none" | The grant allows the action object-level, but matches no rows. |
{ "field": "<f>", "equals": "actor.id" } | Rows where <f> equals the actor’s id — generalizes own to any user-id column. |
{ "field": "<f>", "contains": "actor.id" } | Rows where <f> (a json array or multi_enum column) contains the actor’s id. |
actor.id is the only supported binding: the user id for sessions (including
anonymous-plugin guests), the API key id for user-less keys. There are no
comparisons, functions, or nesting — a policy is a lookup, not a program.
How policies combine
Section titled “How policies combine”Grants union, and so do their row policies — the most permissive allowing grant wins:
- If any grant that allows the action carries no
rowPolicy(or"all"), the action is unrestricted. This is also the bypass: the built-inadminrole’s{ "resource": "*", "actions": ["manage"] }grant carries no policy, so admins — and API keys bound to the admin role (your service key) — always see and touch everything. - Several restricted policies OR together: a user holding
ownfrom one role and a field match from another sees rows matching either. "none"contributes nothing (useful for “may hit the endpoint, sees no rows yet” states).
Public-role grants may carry row policies too; they union into every authenticated principal’s reads like always (read-only rails still apply).
What enforcement looks like
Section titled “What enforcement looks like”- Reads — lists, counts, pagination totals, free-text search, and
aggregates all agree: policy conditions join the same WHERE pipeline as
filters.
GET /:idon a row outside your policy is a 404, exactly like a missing row — reads never reveal that a hidden row exists. - Relations —
expand=and GraphQL relation fields apply the target object’s read policy to each hydration: a policy-hidden row hydrates asnull/ is absent from lists, like a deleted FK target. Traversing into an object your roles have no grant on fails closed the same way. - Writes — update/delete (single and bulk) and link writes only touch rows
your policy matches; anything else 404s. Creates must produce a row you will
own:
ownworks automatically (created_byis server-stamped and never client-writable), anequalsfield is stamped with your id when you omit it and rejected (403 ROW_POLICY_DENIED) when you supply someone else’s, and acontainsfield must already include you. - Upserts — the insert half follows your create policy; the
conflict-update half counts as an update, applied as the
DO UPDATE’s WHERE. An upsert that conflicts with a foreign row is a403 ROW_POLICY_DENIED— it can never hijack the row. - Realtime — the SSE stream and GraphQL subscriptions additionally check each data event’s row image against your read policy, so an own-scoped reader’s feed only shows their own rows’ changes.
System code — scheduled tasks, event dispatch, boot — runs outside any request and is unaffected.
Keep
containscolumns server-written. Acontainspolicy grants visibility by array membership — it does not restrict who may write the array. If the column is client-writable, any writer can include your id and plant rows into your view (spam/noise — not exfiltration: planting grants the writer no read access to anything of yours). This is also why the write guard that stops anequalscolumn from being reassigned to a foreign id deliberately leavescontainscolumns alone: writing other principals’ ids into a membership array is exactly the column’s legitimate use (the server stamping a match’s participants, below). Keep such columns writable only by trusted principals — likeparticipant_idsin the worked example, which only the service key writes.
Worked example: a game backend
Section titled “Worked example: a game backend”The three policies from issue #7 (a multiplayer game with an anti-cheat posture: no client-writable score surface may exist):
// Role "player" — assign to signed-in users; mirror the players grant onto// the built-in "anonymous" role if you use guest sign-in.{ "name": "player", "permissions": [ // (a) each user reads/updates their own player row only { "resource": "players", "actions": ["create", "read", "update"], "rowPolicy": "own" }, // (c) matches are readable by their participants: the match row carries a // json column of participant user ids, written by the server { "resource": "matches", "actions": ["read"], "rowPolicy": { "field": "participant_ids", "contains": "actor.id" } } ]}// (b) the public leaderboard: world-readable player_stats via the built-in// "public" role. No user role gets any write action on player_stats —// so nothing but the service key can write a score.{ "resource": "player_stats", "actions": ["read"] }The game server holds an API key bound to the admin role: it creates
matches (stamping participant_ids), writes server-computed stats, and reads
everything — the bypass in action.
Relation-scoped policies (“readable by participants via an m2m relation”) are not in the language yet; the
containsfield match on a server-written id column is the supported shape. See ADR-025.
Guarantees and limits
Section titled “Guarantees and limits”- Zero change by default. No
rowPolicyon a grant means"all"; existing deployments behave identically. - Fail closed. A policy naming a field the object doesn’t have, or an actor-bound policy with no actor, matches nothing rather than everything.
- Whole rows. Policies scope which rows you see, not which columns — field masking is a planned, separate Phase 17 item. Don’t put secrets in world-readable rows.
- Validated at write time. Malformed policies are rejected (400) on every role mutation path, so stored grants are always well-formed.
- Broad platform grants behave as before: a role granted
readon thedataplatform resource (the GraphQL/MCP transport gate) reads objects it has no per-object grant for, unrestricted — give scoped roles per-object grants, not platform grants.