Skip to main content

2026-09-30-ownership-through-access-rules


title: "Whose row it is moves from handler bodies into DwAccessRule.resource" affects: dartway_core_server: "0.21.0-dev.11" dartway_cli: "0.17.0"​

Who is affected​

A project whose handlers check ownership after the rule: signedIn or a role rule, then if (row.ownerProfileId != me) refuse(notFound) in the body or in a private _requireOwn… helper. Nothing fails — dart run dartway_cli:dartway check now warns on each such site (inlineOwnershipCheck) — but every copy of the check is a place for the answers to drift apart. The class of bug this ends: one membership ("is the caller in this conversation") defined in several places, one of which forgot the block check. The recipe is below; the reference is Access and roles.

What to change​

1. An inline owner check becomes the rule. The row read and the comparison move into DwAccessRule.resource; the handler takes the row from ctx.accessed:

- access: DwAccessRule.signedIn,
- handle: (ctx, command) async {
- final task = await ctx.db.tasks.findById(command.taskId, lock: DwRowLock.forUpdate);
- if (task == null || task.userProfileId != (await ctx.profile).id) {
- ctx.refuse(DwCoreRefusal.notFound);
- }
+ access: DwAccessRule.resource<CompleteTask, TaskRow>(
+ load: (ctx, command) =>
+ ctx.db.tasks.findById(command.taskId, lock: DwRowLock.forUpdate),
+ allows: (ctx, command, task) async =>
+ task.userProfileId == (await ctx.profile).id,
+ ),
+ handle: (ctx, command) async {
+ final task = ctx.accessed<TaskRow>();

A handler under a role rule moves the role into allows as well: allows carries the whole permission, and load answers null for a caller without the role before it locks anything (await ctx.isStaff ? … : null).

2. A shared _requireOwn… helper becomes a rule function. Where several handlers called one helper, one function in AppAccess returns the rule, and each handler names it:

- Future<TaskRow> _requireOwnTask(int taskId) async { … refuse(DwCoreRefusal.notFound); … }
+ static DwAccessRule ownTask<C extends DwServerCall<Object?>>(int Function(C call) taskId) =>
+ DwAccessRule.resource<C, TaskRow>(
+ load: (ctx, call) => ctx.db.tasks.findById(taskId(call), lock: DwRowLock.forUpdate),
+ allows: (ctx, call, task) async => task.userProfileId == (await ctx.profile).id,
+ );

3. Locks move into load, in the order the handler took them. A helper that locked the parent before the child (a day's plan before its entry, so two writers never wait on each other) keeps that order inside load, which now returns both as a record:

load: (ctx, command) async {
final entry = await ctx.db.entries.findById(command.entryId);
if (entry == null || entry.userProfileId != (await ctx.profile).id) return null;
final plan = await ctx.db.plans.findById(entry.planId, lock: DwRowLock.forUpdate);
final locked = await ctx.db.entries.findById(entry.id!, lock: DwRowLock.forUpdate);
return plan == null || locked == null ? null : (locked, plan);
},
allows: (ctx, command, found) => true, // decided above, before any lock

4. One membership function. Whatever defines "a member" (active, accepted, not blocked) goes into one function in the context extension that returns the caller's membership or null — ctx.membershipOf(conversationId). Every resource rule's load, the conversation's DwChannelRule.keyed canSubscribe and DwFileStorage.canRead for its attachments call it; delete every other query that answers the same question.

5. A message in a conversation is (message, membership), with visible. Editing or deleting someone else's message in a conversation the caller reads is dw.forbidden, not dw.notFound:

access: DwAccessRule.resource<EditMessage, (MessageRow, ConversationMemberRow)>(
load: (ctx, command) async {
final seen = await ctx.db.messages.findById(command.messageId);
if (seen == null) return null;
final membership = await ctx.membershipOf(seen.conversationId);
if (membership == null) return null;
final message = await ctx.db.messages.findById(
command.messageId,
lock: DwRowLock.forUpdate,
);
return message == null ? null : (message, membership);
},
allows: (ctx, command, found) => found.$1.senderProfileId == found.$2.profileId,
visible: (ctx, command, found) => true,
),

visible is not a gate: it only turns a refusal allows already made into dw.forbidden.

6. Lists stay as they are. A list of the caller's rows is signedIn with the caller in the query's where; a list inside a conversation takes the membership rule of step 4.

How to check​

dart run dartway_cli:dartway check reports no inlineOwnershipCheck, and the server suite has one refused call per rule: someone else's id and a missing id both dw.notFound, a visible row that is not the caller's dw.forbidden, a caller who lost the role refused.