Skip to content

installAttachmentAccessHooks does not authorize an UNSCOPED multi-delete: no id + no where reads as "nothing to authorize" and deleteMany runs over the whole table #4757

Description

@os-zhuang

Found while implementing #4630 (the symmetric record-level gates for sys_comment). Filed unassigned — the fix belongs to service-storage, not to that PR.

What

packages/services/service-storage/src/attachment-access-hooks.ts, the beforeDelete gate, resolves the rows a delete matches in two ways and then short-circuits when it found none:

const ids = asIdList(ctx?.input?.id);
if (ids) { /* resolve each by id */ }
else if (ctx?.input?.options?.where) { /* resolve the match set, bounded */ }
if (!rows.length) return;   // nothing matched — nothing to authorize

A delete with no id and no where takes neither branch, so rows stays empty and the gate returns allow. That is not "nothing matched" — nothing was ever queried.

The engine then treats the same call as a bulk delete over everything (packages/objectql/src/engine.ts, the delete path):

if (!id) {
  opCtx.ast = { object, ...(options?.where !== undefined ? { where: options.where } : {}) };
}

} else if (options?.multi && driver.deleteMany) {
  const ast = opCtx.ast;               // { object }  — no `where`
  result = await driver.deleteMany(object, ast, );
}

So ql.delete('sys_attachment', { multi: true, context: <a real caller's context> }) reaches deleteMany with an unscoped AST.

Why the other layers do not catch it

  • plugin-sharingbuildWriteFilter returns null for an object with no owner field, and sys_attachment's provenance column is uploaded_by, not owner_id, so no row-scoping predicate is composed onto the AST.
  • plugin-security — the member_default baseline carries no allowDelete (ADR-0090 D5), so a rank-and-file member is refused by RBAC first. But an app that ships a domain grant with the delete bit on sys_attachment — which is exactly what the attachments panel requires, and what attachmentsFixture's att_attachment_manager models — passes RBAC and lands on the ungated path.

Scope

I have not checked whether the REST layer can produce this shape (its delete routes look id-bound); the reachable surfaces are the SDK / ObjectQL / flow delete_record-style callers that pass multi: true without a predicate.

Suggested fix

Fail closed instead of falling through: when there is neither an id nor a where, refuse rather than returning. #4630's sys_comment gate does this — see resolveTargetRows in packages/plugins/plugin-audit/src/comment-access-hooks.ts:

forbid(`Refusing an unscoped multi-${verb} of comments — scope the write to the rows you mean`);

sys_attachment should get the same posture (403 ATTACHMENT_DELETE_DENIED), plus a unit test alongside the existing attachment-access-hooks.test.ts cases. Worth a look at whether any other beforeDelete gate in the repo shares the "no rows resolved ⇒ allow" shape.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions