An update changes a row that a grant covered. The row it leaves must still be one a grant covers, and it must be the same grant. Access checks an update twice to hold that.
The two checks
- The scope.
scopeOf({ action: "update" })picks the rows a grant'swherecovers and no update deny matches. The update runs on those rows only. - The row after. Each row the update leaves must still be covered by an update grant, by that grant's
whereand itsaftertogether, and no update deny may match it.
A grant's where is checked on the new row by default. So a grant where: { authorId: me.id } does not let the author
hand the row to someone else. after adds what the new row must also meet:
grant("signedIn", "update", "Character", {
where: { ownerId: me.id },
after: { campaign: { $role: "player" } },
})This lets a player move a character only to a campaign they play in.
One grant must cover both rows
can can take both rows: row, the row after, and before, the row as it was. One update grant must then cover both:
its where the row before, its where and after the row after. No update moves a row from what one grant reaches to
what another reaches. Update denies are asked about the row after only.
The engine lists in afterChecks every Resource whose update grants read the row (a where, an after, a role held on
a row) or whose update denies have a where. Aventara asks can about each row such an update changed, with the row
before it, inside the update's transaction, and rolls the update back when one fails. Where it has no interactive
transaction, it refuses those updates.
An update that changes a reference
An update that changes a reference is held to the same rule as a create
(Writing a reference needs read on its target): the row it
points at must be one the caller may read, where the app serves that Resource. That check comes on top of the two
above. In the TTRPG example a player who hands a character to another player (ownerId: "olive") may read olive's
Profile, so the reference passes. The update is refused by the grant itself: its where (ownerId: me.profileId),
checked on the new row, no longer holds.
A reference to a foreign Resource is not held to what the caller may read (no caller reads one), so the update grant's
own conditions on the new row are all that decide which row it may point at: its where and its after. Constrain it
through the relation, after: { campaign: { members: { some: { userId: me.id } } } }
(A reference to a foreign Resource).
grantsOf and beforeGrants
A grant whose where reads a relation (tags: { some: { name: "shared" } }, or a role held through a membership)
must be judged on what the database held before the update. Otherwise a nested write in the same request could make
it hold: tagging a row "shared" in the same update that hands it to someone else.
So the decision about the row before is made before the write:
- Before the write, ask
grantsOf({ action: "update", row })about the row as it is now. It answers the keys of the update grants that cover it. - After the write, in the same transaction, ask
can({ action: "update", row, before, beforeGrants }). The grant that covers the row after must be one of those keys.
Aventara does this itself for every Resource in afterChecks (it needs @aventara/core 1.0.0-rc.10 or later). It asks
grantsOf after it reads the row and before any of your stages or the write runs. An older core hands can the row
after only, and can then lets an update in when any update grant covers that row.
Outside Aventara, the same two calls look like this:
// scripts/update.check.ts
import type { AccessExists } from "@aventara/access";
import { type Principal, policies } from "../src/access.policies.js";
const engine = policies.engine();
/** Whether a row of `resource` meets `where`, asked of your database in the update's transaction. */
declare const exists: AccessExists;
type NoteRow = {
readonly id: string;
readonly campaignId: string;
readonly authorId: string;
readonly body: string;
readonly visibility: "PARTY" | "PRIVATE" | "DM_ONLY";
};
/** Checks one Note update: `before` as read in the transaction, `after` as the update leaves it. */
export async function mayUpdateNote(
principal: Principal,
before: NoteRow,
after: NoteRow,
): Promise<boolean> {
const question = {
principal,
origin: "application",
resource: "Note",
action: "update",
} as const;
// Before the write: the update grants that cover the row as it is now.
const beforeGrants = await engine.grantsOf({ ...question, row: before, exists });
// After the write, in the same transaction: the row it leaves, with the row before.
return engine.can({ ...question, row: after, before, beforeGrants, exists });
}What holds:
- The keys
grantsOfanswers are opaque: a short hash of the policies file's grants, then a grant's place in it. They never carry a grant's conditions. A key list from another policies file covers nothing. - With
beforetoo,canchecks each listed grant'swhereon the row before in memory. A key list replayed from another row or another principal fails on a scalar condition. Only a relation condition is left tograntsOf's answer. grantsOfanswers no key on a row an update deny already refuses in memory.- Without
beforeGrants,canasks a relation condition on the row before throughexistsat the after check, when the update has already written. grantsOfanswers what the database held when it was asked. A relation fact that changes between that question and the write, through one of your own stages or through another transaction that commits in between (under PostgreSQL's default READ COMMITTED), is not in its answer. Only someone who could already make that change can use the gap.- Aventara may ask
grantsOfabout several rows of one update at once, so yourexistscan be called concurrently inside one transaction. Prisma's interactive transaction queues the calls on its one connection; anexistsof your own must allow it too. grantsOfdecides nothing: it is not reported to the audit log's decision observer and has noexplain. Thecanthat follows it is the decision, and is reported and explained as any other.
Rows handed to can
A row is a Resource's own scalar fields. A relation on it (author: { … }, tags: [ … ]) is never read: a relation
condition is always asked through exists, on what the database holds. Never put relation data a client sent on a row.
A to-one step whose reference fields the row carries (campaign through campaignId) is asked on the row those values
name, so a question asked before the write sees where the row is moving. Anything else is asked about the row itself, by
its identifier.
The caller must also limit the update itself to the rows scopeOf({ action: "update" }) returns. Otherwise an update
could start from a row no grant covers. A generated client works the same way
(The generated client).