From a2f64d895c2d2ca29c0e2ffd968071b279ab96a3 Mon Sep 17 00:00:00 2001 From: Sri Aradhyula Date: Wed, 24 Jun 2026 21:53:03 -0500 Subject: [PATCH 01/12] fix(chat): prevent private conversation visibility leak Scope conversation list/search/trash candidates before ReBAC checks so unshared private chats are not considered visible, and keep conversation discovery grants out of everyone public grants. Signed-off-by: Sri Aradhyula --- ui/e2e/rbac/chat-share-exposed.spec.ts | 189 +++++++++++++++++- .../chat-conversations-agent-auth.test.ts | 23 ++- .../chat-conversations-remaining-rbac.test.ts | 71 ++++++- .../chat-conversations-sa-grant.test.ts | 8 + .../api/__tests__/chat-sharing-public.test.ts | 29 ++- .../api/__tests__/chat-sharing-teams.test.ts | 43 ++-- ui/src/app/api/chat/conversations/route.ts | 10 +- .../app/api/chat/conversations/trash/route.ts | 12 +- ui/src/app/api/chat/search/route.ts | 9 +- ui/src/lib/authz/__tests__/http.test.ts | 10 + ui/src/lib/authz/http.ts | 1 - .../conversation-implicit-authz.test.ts | 12 ++ .../lib/rbac/conversation-implicit-authz.ts | 14 ++ 13 files changed, 382 insertions(+), 49 deletions(-) diff --git a/ui/e2e/rbac/chat-share-exposed.spec.ts b/ui/e2e/rbac/chat-share-exposed.spec.ts index 53dec6f2bf..d49554b371 100644 --- a/ui/e2e/rbac/chat-share-exposed.spec.ts +++ b/ui/e2e/rbac/chat-share-exposed.spec.ts @@ -14,13 +14,15 @@ * - Private conversations that should never be shared are not displayed * - API requests are made with correct scoping parameters * - Empty states render per tab when there are no matching conversations + * - Related list/search/trash endpoints do not expose unshared private chats + * - The grant API rejects conversation discovery grants to everyone * * All API calls are mocked so no live backend is required. * Enable with: RUN_RBAC_REGRESSION=1 npx playwright test --config=playwright.rbac.config.ts */ -import { expect, test } from "@playwright/test"; -import { fulfillJson, installMockedRbacApp, mockedRbacEnabled } from "./_mocked-rbac"; +import { expect, test, type Page } from "@playwright/test"; +import { fulfillJson, installMockedRbacApp, mockedRbacEnabled, postJson } from "./_mocked-rbac"; // ─── Fixtures ──────────────────────────────────────────────────────────────── @@ -58,7 +60,27 @@ function makeConversation( }; } -function paginatedResponse(items: ReturnType[]) { +type ConversationFixture = ReturnType; + +type ApiResult = { + status: number; + body: T; +}; + +type ConversationListBody = { + success?: boolean; + data?: { + items?: ConversationFixture[]; + }; +}; + +type GrantRequestBody = { + resource?: { type?: string; id?: string }; + grantee?: { type?: string; id?: string }; + capability?: string; +}; + +function paginatedResponse(items: ConversationFixture[]) { return { success: true, data: { items, total: items.length, page: 1, page_size: 20 }, @@ -68,15 +90,52 @@ function paginatedResponse(items: ReturnType[]) { // ─── Helpers ───────────────────────────────────────────────────────────────── type SharedApiOptions = { - items?: ReturnType[]; + items?: ConversationFixture[]; + conversationItems?: ConversationFixture[]; + searchItems?: ConversationFixture[]; + trashItems?: ConversationFixture[]; onRequest?: (url: URL) => void; + onConversationRequest?: (url: URL) => void; + onSearchRequest?: (url: URL) => void; + onTrashRequest?: (url: URL) => void; }; +async function fetchJson(page: Page, path: string, init?: RequestInit): Promise> { + return page.evaluate( + async ({ path: requestPath, init: requestInit }) => { + const response = await fetch(requestPath, requestInit); + let body: unknown = null; + try { + body = await response.json(); + } catch { + body = await response.text(); + } + return { status: response.status, body }; + }, + { path, init }, + ) as Promise>; +} + +async function postJsonFromPage( + page: Page, + path: string, + body: unknown, +): Promise> { + return fetchJson(page, path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + async function installHomePageMocks( page: Parameters[0], options: SharedApiOptions = {}, ) { const sharedItems = options.items ?? []; + const conversationItems = options.conversationItems ?? []; + const searchItems = options.searchItems ?? []; + const trashItems = options.trashItems ?? []; // Force MongoDB storage mode so the SharedConversations section renders. // The server injects __APP_CONFIG__ via an inline