fix(threads): restrict thread update/delete to owner or org admin - #6410
Open
0xcucumbersalad wants to merge 1 commit into
Open
fix(threads): restrict thread update/delete to owner or org admin#64100xcucumbersalad wants to merge 1 commit into
0xcucumbersalad wants to merge 1 commit into
Conversation
COLLECTION_THREADS_UPDATE and COLLECTION_THREADS_DELETE looked the thread up by id and mutated it after only ctx.access.check(). The thread collection tools are basic-usage (granted to every org member regardless of role), so check() passes for any member and the storage layer scopes writes by organization_id only — no owner predicate. Result: any member could rename, re-flag, or permanently delete a teammate's thread (and abort its in-flight run) by id — an intra-org IDOR that violates the documented rule "teammates' threads must be read-only unless owned" (CLAUDE.md / PR #4230). Add assertThreadWritable(thread, caller) in thread/helpers.ts: allow the thread's creator, or an org admin/owner (mirroring the admin bypass in AccessControl); reject everyone else. Wire it into both write tools after the existing fetch. Reads stay org-wide. Tests: - unit (helpers.test.ts): owner allow, non-owner deny, admin/owner bypass, comma-joined multi-role admin, undefined userId/role. - e2e (thread-write-owner-guard.spec.ts): two user-role members in one org — member B is denied update+delete of member A's thread over the wire, can still read it; owner (A) and org owner succeed.
0xcucumbersalad
requested review from
pedrofrxncx and
tlgimenes
and removed request for
tlgimenes
August 21, 2026 06:54
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Found during a Broken Access Control audit.
COLLECTION_THREADS_UPDATEandCOLLECTION_THREADS_DELETEfetched a thread by id and mutated it after onlyctx.access.check().The thread collection tools are basic-usage (
BASIC_USAGE_TOOLS, granted to every org member regardless of role), socheck()passes for any member. The storage layer (OrgScopedThreadStorage) scopes writes byorganization_idonly — there is no owner predicate. Net effect: any member of an org could rename, re-flag, or permanently delete a teammate's thread (and abort its in-flight run) just by knowing/guessing the thread id.This is an intra-org IDOR that violates the documented rule — CLAUDE.md checklist item 2 / PR #4230: "teammates' threads must be read-only unless owned." The read tools already honor it; the write tools did not.
Fix
New shared guard
assertThreadWritable(thread, caller)inthread/helpers.ts:created_by,AccessControl, useshasAdminRoleso comma-joined multi-roles resolve),Wired into both write tool handlers right after the existing fetch. Reads stay org-wide by design.
Testing
thread/helpers.test.ts): owner allow, non-owner deny, admin & owner bypass, comma-joined multi-role admin, undefineduserId/role. ✅ 25 pass locally.packages/e2e/tests/thread-write-owner-guard.spec.ts): twouser-role members in one org — member B is denied UPDATE + DELETE of member A's thread over the wire and can still READ it; owner (A) and org owner succeed. Runs against real Postgres + Better Auth per the e2e black-box contract.bun run fmt,bun run check(apps/api + packages/e2e),bun run lint— clean (0 errors).Affected areas
apps/api/src/tools/thread/{update,delete}.ts,helpers.ts. No schema/storage changes. Behavior change: non-owner, non-admin members now get a Forbidden error on thread update/delete (previously silently allowed).Notes / follow-ups (out of scope, from the same audit)
Not fixed here — flag for separate PRs: legacy unauthenticated
/oauth-proxy/:connectionId/*unscoped lookup;ORGANIZATION_UPDATE/DELETEtrustinginput.idwithout anid === ctx.organization.idguard (siblingsettings-updatehas it); AI provider key preview/delete/update skipping the model-permission gate that LIST enforces; and the systemic storage-layer pattern of mutating by id with noorganization_idpredicate (downstream_tokens, virtual.delete cascade, threads.setRunFence).Summary by cubic
Restricts thread update/delete to the thread’s creator or an org admin/owner to fix an intra‑org IDOR. Previously, any org member could update or permanently delete a teammate’s thread by id after a passed
ctx.access.check(); now non-owner, non-admin calls return Forbidden. Reads remain org‑wide.assertThreadWritable(thread, caller)in helpers useshasAdminRolefrom@decocms/shared/auth/roles; applied inCOLLECTION_THREADS_UPDATEandCOLLECTION_THREADS_DELETEafter the fetch.Tests
Written for commit 1a9823a. Summary will update on new commits.