Skip to content

fix(threads): restrict thread update/delete to owner or org admin - #6410

Open
0xcucumbersalad wants to merge 1 commit into
mainfrom
fix/thread-write-owner-guard
Open

fix(threads): restrict thread update/delete to owner or org admin#6410
0xcucumbersalad wants to merge 1 commit into
mainfrom
fix/thread-write-owner-guard

Conversation

@0xcucumbersalad

@0xcucumbersalad 0xcucumbersalad commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Found during a Broken Access Control audit. COLLECTION_THREADS_UPDATE and COLLECTION_THREADS_DELETE fetched a thread by id and mutated it after only ctx.access.check().

The thread collection tools are basic-usage (BASIC_USAGE_TOOLS, granted to every org member regardless of role), so check() passes for any member. The storage layer (OrgScopedThreadStorage) scopes writes by organization_id only — 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) in thread/helpers.ts:

  • allow the thread's created_by,
  • allow an org admin/owner (mirrors the admin bypass in AccessControl, uses hasAdminRole so comma-joined multi-roles resolve),
  • reject everyone else.

Wired into both write tool handlers right after the existing fetch. Reads stay org-wide by design.

Testing

  • Unit (thread/helpers.test.ts): owner allow, non-owner deny, admin & owner bypass, comma-joined multi-role admin, undefined userId/role. ✅ 25 pass locally.
  • E2E (packages/e2e/tests/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 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/DELETE trusting input.id without an id === ctx.organization.id guard (sibling settings-update has 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 no organization_id predicate (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.

  • New assertThreadWritable(thread, caller) in helpers uses hasAdminRole from @decocms/shared/auth/roles; applied in COLLECTION_THREADS_UPDATE and COLLECTION_THREADS_DELETE after the fetch.
  • No schema/storage changes. Client impact: members can no longer mutate teammates’ threads; use the owner account or an admin/owner role.

Tests

  • Unit: owner allow; non-owner deny; admin/owner bypass; multi-role string; undefined userId/role.
  • E2E: member B denied UPDATE/DELETE of member A’s thread; A and org owner allowed; READ unchanged.

Written for commit 1a9823a. Summary will update on new commits.

Review in cubic

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
0xcucumbersalad requested review from pedrofrxncx and tlgimenes and removed request for tlgimenes August 21, 2026 06:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant