fix: scope chat queries by project and split chat read/write authorization - #5058
fix: scope chat queries by project and split chat read/write authorization#5058disintegrator wants to merge 2 commits into
Conversation
|
There was a problem hiding this comment.
All reported issues were addressed across 56 files
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
841d195 to
d302e9b
Compare
tgmendes
left a comment
There was a problem hiding this comment.
LGTM - one comment which shouldn't be blocking.
RBAC wise it's what I'd expect
…ation Chat rows were reachable by id alone: GetChat carried no project_id predicate, so every per-chat endpoint had to re-derive tenancy in Go. Five of the six stopped at project equality and never established ownership, which let any project member read, summarize, rename, pin, delete, or annotate another member's session knowing only its UUID (AIS-424). The same missing predicate let a client-supplied chat id on the completions proxy append messages to another organization's chat and bump its generation, blanking the owner's transcript (AIS-485). Scoping the SQL is the durable half of the fix: the invariant then holds even when a future call site forgets it, and it brings these queries in line with the project_id rule the rest of the codebase already follows. The Go gate is still required for the part SQL cannot express — whether a non-owner holds the grant needed to reach someone else's session — so the lookup and that gate are now inseparable behind one helper. chat:read and chat:write are separate scopes because reading a transcript and destroying one are different privileges: a session reviewer should be able to open every session in the project without being able to delete any. Neither is a default for any system role, and owners always reach their own sessions without either grant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
d302e9b to
8417cca
Compare
Externally-triggered assistant turns — cron, Slack, warmup — carry no user, so their chat rows were written with a NULL user_id. That is not a neutral state: chat access is decided by owner-matching first and by an explicit chat:read or chat:write grant otherwise, and neither scope is a default on any system role. A session nobody owns is therefore a session nobody can open, continue, rename or delete without someone first minting a custom role. Splitting chat:read from chat:write sharpened that edge, since the mutations on those sessions previously fell back to plain project membership. Attributing an externally-triggered session to whoever created the assistant restores the capability without weakening the scope model, and makes the session behave like one that person started. The fallback only applies when the turn carries no user of its own, so a dashboard turn still belongs to its sender rather than the assistant's creator. An assistant with no recorded creator leaves the chat owner-less exactly as before; existing rows are unaffected until they are backfilled separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes AIS-424 and AIS-485.
Why
GetChatlooked up chats by id alone — noproject_idpredicate — so every per-chat endpoint had to re-derive tenancy in Go. Onlychat.loadwrote the complete rule. The other five stopped at project equality and never established ownership, so any project member could read, summarize, rename, pin, delete, or attach feedback to another member's session knowing only its UUID (AIS-424).The same missing predicate made the completions proxy's client-supplied chat id unchecked: a caller could append messages to a chat in another organization and bump its generation, which blanked the owner's transcript in their own dashboard (AIS-485). That one crosses a tenancy boundary AIS-424 does not.
The reported
chat.loaddisclosure itself was already closed by the RBAC-by-default rollout (#4681, #4780) — see "Before merging" below.Approach
Scope the SQL. This is the durable half: the invariant holds even when a future call site forgets it, and it brings these queries in line with the
project_idrule the rest of the codebase already follows.GetChatand ~12 other queries gain the predicate; 15 weak(project_id IS NULL OR project_id = @project_id)clauses collapse to hard equality; writes on tables that carry noproject_idof their own (ai_integration_config_chats,chat_resolution_messages) are scoped through the chat or resolution they belong to.UpsertChat'sON CONFLICT (id)is fenced so a foreign chat id yields no row instead of landing on the victim's. No schema change, no migration.One gate, applied everywhere.
loadAuthorizedChatpairs the lookup with the access check so they cannot be separated — the failure mode here was handlers fetching the row themselves and re-deriving a partial rule.chat:readandchat:writeare separate scopes. Reading a transcript and destroying one are different privileges: a session reviewer should be able to open every session in the project without being able to delete any. Neither is a default for any system role (matching howchat:readwas already treated),chat:writesatisfieschat:readby expansion, and owners always reach their own sessions without either grant.chat.summarizealso now writes thechat_session:accessaudit entry, including on the cached path — it returns transcript-derived prose, so the audit log should not claim the session was never read.Assistant sessions (second commit)
Externally-triggered assistant turns — cron, Slack, warmup — carry no user, so their chat rows were written with a NULL
user_id. Owner-less is not neutral here: access is decided by owner-matching first and by an explicit grant otherwise, so a session nobody owns is one nobody can open, continue, rename or delete without a custom role.Two of those consequences predate this PR (
chat.loadalready requiredchat:read, andassistants.sendMessagealready 404s for everyone sinceCallerOwnsDashboardChatcan't match NULL). What this PR newly regresses is pin/rename/delete/feedback, which previously fell back to plain project membership.assistantChatOwnerIDattributes such a session to whoever created the assistant. A dashboard turn still belongs to its sender; the fallback only fires when the turn carries no user. An assistant with no recorded creator leaves the chat owner-less exactly as before.Existing rows stay owner-less until backfilled — tracked in DNO-807, which is worth running before this merges to avoid a window where admins cannot manage existing cron/Slack-started sessions.
Behaviour changes worth a look
not_found, notunauthorized. The project-scopedGetChatyields no row rather than a row that then fails a Go comparison. This removes an existence oracle and matches what the turn-stream route already did. Three existing assertions updated.UpsertChatcan now returnErrNoRowsfor a foreign chat id;StartOrResumeChatmaps that tonot_foundrather than an unexpected error. Not-found rather than unauthorized deliberately: that branch is only reachable when the id names a real chat in another project, so a distinct code or message would confirm the id belongs to another tenant. This is the only change on a success path — worth a second pair of eyes since it sits on the completions hot path.forbiddenresponses onchat.delete,chat.setPinned,chat.summarize,chat.generateTitle,chat.submitFeedback. No design edit needed; errors are declared service-wide.chat:readno longer conveys delete. An org that grantedchat:readfor session review keeps read access; anyone who relied on it to delete other members' sessions needschat:write.Tests
Every pre-existing chat test authenticated as a
chat:readholder, which is exactly why this survived. New coverage:chat_write_authorization_test.go— endpoint × caller matrix (member /chat:readholder /chat:writeholder / owner / cross-project / missing). A new endpoint that forgets the gate fails here.submit_feedback_test.go— this endpoint had no tests at all.queries_scoping_test.go— asserts the SQL predicates directly, not only through handlers.TestStartOrResumeChat_RejectsChatFromAnotherProject— the AIS-485 regression guard: the call is rejected, no rows land against the victim chat, and its generation is unchanged.go test -racepasses acrosschat,authz,access,risk,assistants,aiintegrations,hooks,background,skills.Before merging
chat.load's fix depends on RBAC enforcement being live for existing orgs. #4780 states its one-time org backfill was deliberately left out of that PR. If it has not run in production,authz.Requirestill short-circuits and the originally reported transcript disclosure remains open independently of this change. Worth confirming before AIS-424 is closed.🤖 Generated with Claude Code