Skip to content

fix: scope chat queries by project and split chat read/write authorization - #5058

Open
disintegrator wants to merge 2 commits into
mainfrom
horizontal-priv-esc
Open

fix: scope chat queries by project and split chat read/write authorization#5058
disintegrator wants to merge 2 commits into
mainfrom
horizontal-priv-esc

Conversation

@disintegrator

@disintegrator disintegrator commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes AIS-424 and AIS-485.

Why

GetChat looked up chats by id alone — no project_id predicate — so every per-chat endpoint had to re-derive tenancy in Go. Only chat.load wrote 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.load disclosure 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_id rule the rest of the codebase already follows. GetChat and ~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 no project_id of their own (ai_integration_config_chats, chat_resolution_messages) are scoped through the chat or resolution they belong to. UpsertChat's ON 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. loadAuthorizedChat pairs 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:read and chat:write are 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 how chat:read was already treated), chat:write satisfies chat:read by expansion, and owners always reach their own sessions without either grant.

chat.summarize also now writes the chat_session:access audit 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.load already required chat:read, and assistants.sendMessage already 404s for everyone since CallerOwnsDashboardChat can't match NULL). What this PR newly regresses is pin/rename/delete/feedback, which previously fell back to plain project membership.

assistantChatOwnerID attributes 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

  1. Cross-project reads return not_found, not unauthorized. The project-scoped GetChat yields 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.
  2. UpsertChat can now return ErrNoRows for a foreign chat id; StartOrResumeChat maps that to not_found rather 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.
  3. New forbidden responses on chat.delete, chat.setPinned, chat.summarize, chat.generateTitle, chat.submitFeedback. No design edit needed; errors are declared service-wide.
  4. chat:read no longer conveys delete. An org that granted chat:read for session review keeps read access; anyone who relied on it to delete other members' sessions needs chat:write.

Tests

Every pre-existing chat test authenticated as a chat:read holder, which is exactly why this survived. New coverage:

  • chat_write_authorization_test.go — endpoint × caller matrix (member / chat:read holder / chat:write holder / 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 -race passes across chat, authz, access, risk, assistants, aiintegrations, hooks, background, skills.

Note: TestSkillEfficacySweepWorkflowContinuesAsNewPastAFullPage races intermittently under parallel load. It is pre-existing — clean main fails it 2 runs in 3 — and untouched by this branch.

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.Require still 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

@disintegrator
disintegrator requested review from a team as code owners August 7, 2026 14:51
@disintegrator disintegrator added bug Something isn't working go Pull requests that update go code labels Aug 7, 2026
@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

AIS-424

AIS-485

@changeset-bot

changeset-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 3055a35

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 56 files

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread server/internal/aiintegrations/compliance_import.go
Comment thread server/internal/authz/checks.go
Comment thread server/internal/chat/queries.sql
Comment thread server/internal/chat/queries.sql Outdated
Comment thread server/internal/chat/queries.sql Outdated
Comment thread server/internal/chat/queries.sql
Comment thread server/internal/background/activities/queries.sql
Comment thread server/internal/chat/queries_scoping_test.go
Comment thread server/internal/chat/impl.go
Comment thread server/internal/chat/impl.go
Comment thread server/internal/chat/queries.sql
Comment thread server/internal/chat/turnstream_http.go
@disintegrator
disintegrator force-pushed the horizontal-priv-esc branch 2 times, most recently from 841d195 to d302e9b Compare August 7, 2026 15:48

@tgmendes tgmendes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - one comment which shouldn't be blocking.

RBAC wise it's what I'd expect

Comment thread server/internal/chat/impl.go
Comment thread server/internal/chat/message_capture_strategy.go
…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>
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>
@blacksmith-sh

This comment has been minimized.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working go Pull requests that update go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants