|
340 | 340 | "title": "Attaching requires EDIT on the parent record; deleting requires uploader-or-parent-editor; unscoped multi-delete is refused outright", |
341 | 341 | "since": "v15.1", |
342 | 342 | "status": "active", |
343 | | - "revision": 3, |
| 343 | + "revision": 4, |
344 | 344 | "priority": "P1", |
345 | 345 | "surface": "api", |
346 | 346 | "personas": ["member with EDIT on the parent", "member with READ but not EDIT on the parent", "member who uploaded the attachment", "member who is neither uploader nor parent editor"], |
|
356 | 356 | }, |
357 | 357 | "knownGaps": [ |
358 | 358 | "MARKER (QA run #9401 finding 5, carried here so runs stop re-deriving it): the CROSS-TENANT clauses of this area are structurally unprovable in the OSS workspace. `@objectstack/organizations` is cloud-private, so the dogfood matrix's cross-tenant block is gated behind `describe.skipIf(!organizationsAvailable)` and skips BY DESIGN here. That is blocked(dependency), never a pass and never a defect. A run that is supposed to ship the package sets OS_TEST_MULTI_ORG_ENABLED=1, which turns the skip into a hard failure instead of a silent one.", |
359 | | - "clause 3 ('unscoped multi-delete refused outright', #4757) is NOT satisfied by the shipped engine — see #9719 and automated.ref. Do not tick it from the unit test in attachment-access-hooks.test.ts: that suite calls the handler directly with an operation-level context the engine's per-row dispatch no longer produces, so it is green while the wired path does the opposite. The REST lane cannot express the shape at all (DeleteManyDataRequestSchema strips options.where and requires ids, #3897), so the reachable callers are server-side engine holders: flows, actions, scripts, MCP tools.", |
| 359 | + "clause 3 ('unscoped multi-delete refused outright', #4757) IS PINNED as of 2026-08-23. The product gap this entry used to record (#9719) was fixed by PR #9797 and extended to the update verb by #9974: the engine now offers an opt-in whole-operation dispatch (`dispatchUnscopedMultiWrite`) that fires ONCE, before any row is resolved and zero-match included, and attachment-access-hooks.ts declares it on BOTH sys_attachment write registrations — so the refusal answers with ATTACHMENT_DELETE_DENIED / 403 / the 'Refusing an unscoped multi-delete of attachments' message. ⭐ The durable lesson, which outlives the fix: a fixture whose rows SPLIT entitled/not cannot tell 'refused outright' from 'the per-row gate refused one of the rows' — both answer 403 ATTACHMENT_DELETE_DENIED, so the clause reads green either way. Measured on this suite while re-verifying: with the declaration removed from both registrations and service-storage rebuilt, the original split-fixture block stayed 5/5 GREEN. Score clause 3 ONLY from a caller entitled to EVERY matched row (or from the message text) — never from a split fixture. The REST lane still cannot express the shape at all (DeleteManyDataRequestSchema strips options.where and requires ids, #3897), so the reachable callers are server-side engine holders — flows, actions, scripts, MCP tools — which is why this clause's oracle is `test` rather than `api`.", |
360 | 360 | "the FILES_DISABLED clause currently probes showcase_account, which is files-disabled only incidentally — if the showcase ever enables files on it, the clause goes green for the wrong reason. The recipe's qa_nofiles exists to be probed instead; prefer it, and treat showcase_account as the fallback." |
361 | 361 | ] |
362 | 362 | }, |
|
366 | 366 | "as a parent editor, POST the same attach payload with a spoofed uploaded_by of another user and read the row back", |
367 | 367 | "as a member who is neither the uploader nor a parent editor, DELETE the attachment and capture the refusal", |
368 | 368 | "as the uploader, DELETE their own attachment on a parent they cannot edit — the uploader may always detach", |
369 | | - "issue a DELETE against /api/v1/data/sys_attachment with NO id and NO where predicate and capture the refusal" |
| 369 | + "drive a predicate-less multi-delete from an ENGINE HOLDER — ql.delete('sys_attachment', { multi: true, context }) with no id and no where — and capture the refusal. ⛔ Not over REST: DeleteManyDataRequestSchema requires an ids list (#3897), so the HTTP lane cannot express the shape and its schema rejection must never be scored as this refusal" |
370 | 370 | ], |
371 | 371 | "acceptance": [ |
372 | 372 | { |
|
389 | 389 | }, |
390 | 390 | { |
391 | 391 | "clause": "an unscoped multi-delete (no id AND no where) is refused outright (#4757) — 'nothing was ever queried' must not read as 'nothing to authorize'", |
392 | | - "oracle": "api", |
393 | | - "verify": "the predicate-less delete returns 403 ATTACHMENT_DELETE_DENIED with the refusing-unscoped message; the table row count is unchanged", |
394 | | - "evidence": "the refusal + before/after counts" |
| 392 | + "oracle": "test", |
| 393 | + "verify": "driven from an engine holder by a caller who may delete EVERY matched row, the predicate-less delete is refused with 403 ATTACHMENT_DELETE_DENIED and the 'Refusing an unscoped multi-delete of attachments' message (the message is what separates this rule from the per-row gate), the row count is unchanged, and the same refusal fires on an EMPTY table; the same caller's scoped sweep of those rows still succeeds", |
| 394 | + "evidence": "the refusal (code + status + message) + before/after counts, on both a populated and an empty table" |
395 | 395 | }, |
396 | 396 | { |
397 | 397 | "clause": "attaching to an object without enable.files is 403 FILES_DISABLED (the #2727 opt-in gate, enforced by plugin-audit alongside these hooks)", |
|
405 | 405 | "a rejected write that still created the sys_attachment row (verify by re-listing) is a FAIL — the rejection must be authoritative, not cosmetic" |
406 | 406 | ], |
407 | 407 | "traps": ["wrong-persona"], |
408 | | - "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts (clauses 0, 1, 2 and 4: attach without parent EDIT is 403 ATTACHMENT_PARENT_ACCESS while the same member's LIST still succeeds, uploaded_by is server-stamped over a spoofed value, delete splits uploader-vs-outsider, and FILES_DISABLED) + packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts (the delete gate under an UNSCOPED multi-delete AST, both sides: a `{ multi: true }` delete with neither id nor where cannot slip past the per-row gate and removes NOTHING — not even the one row the caller WAS entitled to, so the refusal is authoritative rather than cosmetic and cannot land as a partial delete; `where: {}` behaves identically, since an empty predicate is still every row; a predicate that MATCHES NOTHING is allowed through, which is the live half of the #4757 reasoning that 'nothing matched' is not a refusal; and the same caller's SCOPED delete of their own row succeeds, without which every assertion here would be satisfied by a delete path that is simply broken. The member is granted the sys_attachment delete bit first and that grant is asserted, because otherwise RBAC refuses ahead of the attachment hook and the whole file would be green for a reason it is not about). ⛔ CLAUSE 3 IS NOT PINNED. Measured while writing the above: the #4757 'refused outright' rule does NOT fire through ObjectQL.delete. The engine's predicate path dispatches beforeDelete PER ROW (dispatchPerRowBeforeHooks builds `input: { id: rowId, options }`), so the handler always takes its by-id branch and never reaches the `where === undefined` check; a caller who is the uploader of every matched row deletes the whole table with a predicate-less delete (measured: 2 rows before, resolves, 0 rows after). attachment-access-hooks.test.ts pins the refusal by calling the handler directly with a whole-operation context the engine no longer produces on this path, so that unit green is not evidence about the wired behaviour. Tracked as a PRODUCT gap in #9719; clause 3 is deliberately left unpinned rather than pinned against current behaviour, because pinning today's outcome would turn the eventual fix red." }, |
| 408 | + "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts (clauses 0, 1, 2 and 4: attach without parent EDIT is 403 ATTACHMENT_PARENT_ACCESS while the same member's LIST still succeeds, uploaded_by is server-stamped over a spoofed value, delete splits uploader-vs-outsider, and FILES_DISABLED) + packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts, which now carries TWO blocks and pins CLAUSE 3 end to end. Block 1 (split fixture — the caller owns one row and may not touch the other): the predicate-less `{ multi: true }` delete is refused with ATTACHMENT_DELETE_DENIED / 403 / the 'Refusing an unscoped multi-delete of attachments' message and removes NOTHING, not even the row it WAS entitled to, so the refusal is authoritative and cannot land as a partial delete; a match-all `where: {}` reaches the same verdict by a DIFFERENT rule and asserts the PER-ROW message instead, which is what keeps the #9797 boundary honest (the whole-operation dispatch is deliberately scoped to a delete with no `where` at all, so `where: {}` stays a real query); and a predicate that MATCHES NOTHING is allowed through, the live half of the #4757 reasoning. Block 2 (the discriminating fixture — the caller uploaded EVERY row, so the per-row gate has nothing to refuse): the predicate-less delete is still refused, which is the only shape that proves 'refused OUTRIGHT' rather than 'every row happened to be denied'; the refusal also fires on an EMPTY table (the zero-match limb — the per-row dispatch is gated on matched rows, so a handler-only fix could never reach it); and both sides are held by the same caller sweeping the same rows with a SCOPED predicate successfully, plus a scoped `where: {}` over the empty table resolving. In both blocks the member is granted the sys_attachment delete bit first and the grant is asserted, because otherwise RBAC refuses ahead of the attachment hook and the file would be green for a reason it is not about. ⭐ Block 2 exists because block 1 provably cannot see this clause: ablating `dispatchUnscopedMultiWrite` from both registrations (rebuilt, dist-preflighted) leaves block 1 5/5 GREEN, while block 2 goes red BY RESOLVING — 'promise resolved \"2\"', which is #9719's measured wipe reproduced. Unit-level companion, also through a wired engine rather than a hand-built context: packages/services/service-storage/src/attachment-access-hooks.test.ts, describe 'unscoped multi-delete (no id, no where) — #4757 through the wired engine (#9719)'." }, |
409 | 409 | "source": [ |
410 | 410 | "docs/plans/release-15.1-test-plan.md §A12 (attach 需 parent EDIT)", |
411 | | - "packages/services/service-storage/src/attachment-access-hooks.ts (beforeInsert canEdit gate + uploaded_by stamping; beforeDelete uploader-or-editor + #4757 unscoped refusal, MULTI_DELETE_AUTH_LIMIT fail-closed)", |
| 411 | + "packages/services/service-storage/src/attachment-access-hooks.ts (beforeInsert canEdit gate + uploaded_by stamping; beforeDelete/beforeUpdate uploader-or-editor + #4757 unscoped refusal reached via the `dispatchUnscopedMultiWrite` declaration (#9719/#9974), MULTI_WRITE_AUTH_LIMIT fail-closed)", |
412 | 412 | "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts (items 3, a, f; FILES_DISABLED)" |
413 | 413 | ], |
414 | 414 | "history": [ |
|
419 | 419 | "date": "2026-08-18", |
420 | 420 | "change": "the unscoped-multi-delete surface pinned on the halves that hold, and clause 3 recorded as NOT pinned with the measurement. QA run #9401 listed clause 3 as the gap; implementing it found the #4757 refusal is unreachable through ObjectQL.delete because beforeDelete is dispatched per row with input.id bound, so the guard's own branch never runs and an uploader-of-every-row wipes the table. Filed as #9719. The new pin asserts what does hold — an unscoped AST cannot bypass the per-row gate, deletes nothing (no partial delete), an empty where is treated as every row, a zero-match predicate is allowed through, and the same caller's scoped delete still succeeds — and the ref says plainly that clause 3 stays unpinned rather than being pinned against behaviour a fix will change.", |
421 | 421 | "ref": "#9483" |
| 422 | + }, |
| 423 | + { |
| 424 | + "revision": 4, |
| 425 | + "date": "2026-08-23", |
| 426 | + "change": "clause 3 flipped from NOT pinned to PINNED, and the item's own scoring hazards corrected. Re-verifying this card on the merged ref found the blocker resolved: #9719 was fixed by PR #9797 (opt-in whole-operation `dispatchUnscopedMultiWrite` dispatch, firing once before any row is resolved, zero-match included) and extended to beforeUpdate by #9974, so the #4757 refusal fires again through the wired engine and the clause is pinnable AS WRITTEN — no need to pin behaviour a fix would turn red. Three corrections beyond the flip. (a) The ref and knownGaps had gone stale in the dangerous direction: they told the next runner a shipped, working safety guard was broken. (b) The item's oracle for this clause was `api` and its step 6 prescribed a REST probe, which the item's OWN knownGaps says the HTTP lane cannot express (DeleteManyDataRequestSchema requires an ids list, #3897) — a runner following it would score a schema rejection as the refusal, i.e. green for the wrong reason; oracle is now `test` and the step names an engine holder. (c) The dogfood pin gained a second block, because the first one provably cannot see this clause: its fixture splits entitled/not, so the per-row gate answers with the same code and the block stays 5/5 green with the dispatch ablated. The new block seeds a caller entitled to EVERY matched row — the only shape that distinguishes 'refused outright' from 'every row was denied' — plus the empty-table limb; under ablation it fails by RESOLVING, reproducing #9719's measured wipe. Source list also corrected: MULTI_DELETE_AUTH_LIMIT was renamed MULTI_WRITE_AUTH_LIMIT.", |
| 427 | + "ref": "#9483" |
422 | 428 | } |
423 | 429 | ] |
424 | 430 | }, |
|
0 commit comments