Skip to content

Commit 4efe0d1

Browse files
committed
test(webapp): pin cross-tenant isolation for the chat store
A chat/session belongs to one (org, user) pair. Pin that every store read — getChatMessages, getSession, chatExists, listChats, countUserMessages — and appendChatMessageOnce refuse a foreign tenant, so a chatId from another org reads as not-found and never leaks a transcript or the session's public access token. TRI-11166.
1 parent 0edbb22 commit 4efe0d1

1 file changed

Lines changed: 240 additions & 0 deletions

File tree

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import {
2+
appendChatMessageOnce,
3+
chatExists,
4+
countUserMessages,
5+
createChat,
6+
createDashboardAgentDb,
7+
getChatMessages,
8+
getSession,
9+
listChats,
10+
persistTurn,
11+
type DashboardAgentDb,
12+
type DashboardAgentDbClient,
13+
} from "@internal/dashboard-agent-db";
14+
import { postgresTest } from "@internal/testcontainers";
15+
import type { PrismaClient } from "@trigger.dev/database";
16+
import { readdirSync, readFileSync } from "node:fs";
17+
import path from "node:path";
18+
import { afterEach, describe, expect } from "vitest";
19+
20+
/**
21+
* Cross-tenant isolation for the chat store, against a real table (TRI-11166).
22+
*
23+
* The 2026-06-10 chat.agent audit flagged a cross-tenant read: a chat/session belongs to
24+
* one (org, user) pair, and every read that hands back its transcript or its session token
25+
* has to be scoped by that pair. A chatId from another tenant must read as not-found — never
26+
* as another tenant's transcript, and never as another tenant's public access token, which
27+
* is the credential a resumed session boots from.
28+
*
29+
* The store's own queries are the floor: the resource route scopes on project.organizationId
30+
* above this, but a bug there would still be caught here because these queries refuse a
31+
* foreign (org, user) outright rather than trusting the caller.
32+
*/
33+
34+
let agentDb: DashboardAgentDb;
35+
let agentDbClient: DashboardAgentDbClient | undefined;
36+
37+
const MIGRATIONS = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
38+
39+
/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */
40+
async function applyAgentSchema(prisma: PrismaClient) {
41+
for (const name of readdirSync(MIGRATIONS)
42+
.filter((file) => file.endsWith(".sql"))
43+
.sort()) {
44+
const sql = readFileSync(path.join(MIGRATIONS, name), "utf8");
45+
for (const statement of sql.split("--> statement-breakpoint")) {
46+
const trimmed = statement.trim();
47+
if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
48+
}
49+
}
50+
}
51+
52+
// Org A owns the chat. Org B and a same-org other user are the foreign tenants.
53+
const ORG_A = "org_a";
54+
const USER_A = "user_a";
55+
const ORG_B = "org_b";
56+
const USER_B = "user_b";
57+
const CHAT = "chat_owned_by_a";
58+
59+
async function boot(prisma: PrismaClient, connectionUri: string) {
60+
await applyAgentSchema(prisma);
61+
agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 });
62+
agentDb = agentDbClient.db;
63+
}
64+
65+
afterEach(async () => {
66+
await agentDbClient?.close();
67+
agentDbClient = undefined;
68+
});
69+
70+
function textMessage(id: string, role: "user" | "assistant" = "assistant") {
71+
return { id, role, parts: [{ type: "text", text: id }] };
72+
}
73+
74+
/** Seed a chat under org A with a transcript and a live session (its PAT is the credential). */
75+
async function seedOwnedChat() {
76+
await createChat(agentDb, { id: CHAT, organizationId: ORG_A, userId: USER_A });
77+
await persistTurn(agentDb, {
78+
chatId: CHAT,
79+
messages: [textMessage("u1", "user"), textMessage("a1")],
80+
session: { publicAccessToken: "pat_secret_of_a", lastEventId: "42", runId: "run_a" },
81+
});
82+
}
83+
84+
const foreignScopes = [
85+
{ name: "another org", organizationId: ORG_B, userId: USER_B },
86+
// Same org, different user: a member of A's org still isn't the chat's owner.
87+
{ name: "another user in the same org", organizationId: ORG_A, userId: USER_B },
88+
// Right user id, wrong org: the id alone must not carry across a tenant boundary.
89+
{ name: "the owner's user id under another org", organizationId: ORG_B, userId: USER_A },
90+
];
91+
92+
describe("getChatMessages is scoped to the owning (org, user)", () => {
93+
postgresTest(
94+
"the owner reads the transcript; every foreign tenant reads not-found",
95+
async ({ prisma, postgresContainer }) => {
96+
await boot(prisma, postgresContainer.getConnectionUri());
97+
await seedOwnedChat();
98+
99+
const owned = await getChatMessages(agentDb, {
100+
chatId: CHAT,
101+
organizationId: ORG_A,
102+
userId: USER_A,
103+
});
104+
expect((owned as { id: string }[]).map((m) => m.id)).toEqual(["u1", "a1"]);
105+
106+
for (const scope of foreignScopes) {
107+
// null is not-found. It must never be [] (a visible-but-empty chat) and never A's rows.
108+
const seen = await getChatMessages(agentDb, {
109+
chatId: CHAT,
110+
organizationId: scope.organizationId,
111+
userId: scope.userId,
112+
});
113+
expect(seen, scope.name).toBeNull();
114+
}
115+
},
116+
30_000
117+
);
118+
});
119+
120+
describe("getSession never hands a foreign tenant the owner's access token", () => {
121+
postgresTest(
122+
"the owner gets the session; every foreign tenant gets null",
123+
async ({ prisma, postgresContainer }) => {
124+
await boot(prisma, postgresContainer.getConnectionUri());
125+
await seedOwnedChat();
126+
127+
const owned = await getSession(agentDb, {
128+
chatId: CHAT,
129+
organizationId: ORG_A,
130+
userId: USER_A,
131+
});
132+
expect(owned?.publicAccessToken).toBe("pat_secret_of_a");
133+
134+
for (const scope of foreignScopes) {
135+
const seen = await getSession(agentDb, {
136+
chatId: CHAT,
137+
organizationId: scope.organizationId,
138+
userId: scope.userId,
139+
});
140+
// A leaked session row would carry A's PAT — the resume credential. Refuse outright.
141+
expect(seen, scope.name).toBeNull();
142+
}
143+
},
144+
30_000
145+
);
146+
});
147+
148+
describe("chatExists is the owner check the action routes gate on", () => {
149+
postgresTest(
150+
"true for the owner, false for every foreign tenant",
151+
async ({ prisma, postgresContainer }) => {
152+
await boot(prisma, postgresContainer.getConnectionUri());
153+
await seedOwnedChat();
154+
155+
expect(
156+
await chatExists(agentDb, { chatId: CHAT, organizationId: ORG_A, userId: USER_A })
157+
).toBe(true);
158+
for (const scope of foreignScopes) {
159+
expect(
160+
await chatExists(agentDb, {
161+
chatId: CHAT,
162+
organizationId: scope.organizationId,
163+
userId: scope.userId,
164+
}),
165+
scope.name
166+
).toBe(false);
167+
}
168+
},
169+
30_000
170+
);
171+
});
172+
173+
describe("listChats and countUserMessages never surface another tenant's chat", () => {
174+
postgresTest(
175+
"a foreign tenant lists nothing and counts nothing of the owner's",
176+
async ({ prisma, postgresContainer }) => {
177+
await boot(prisma, postgresContainer.getConnectionUri());
178+
await seedOwnedChat();
179+
180+
const ownedList = await listChats(agentDb, { organizationId: ORG_A, userId: USER_A });
181+
expect(ownedList.map((c) => c.id)).toEqual([CHAT]);
182+
expect(await countUserMessages(agentDb, { organizationId: ORG_A, userId: USER_A })).toBe(1);
183+
184+
for (const scope of foreignScopes) {
185+
const list = await listChats(agentDb, {
186+
organizationId: scope.organizationId,
187+
userId: scope.userId,
188+
});
189+
expect(list, scope.name).toEqual([]);
190+
expect(
191+
await countUserMessages(agentDb, {
192+
organizationId: scope.organizationId,
193+
userId: scope.userId,
194+
}),
195+
scope.name
196+
).toBe(0);
197+
}
198+
},
199+
30_000
200+
);
201+
});
202+
203+
describe("a foreign org cannot append to another tenant's chat", () => {
204+
postgresTest(
205+
"appendChatMessageOnce with a foreign org writes nothing and leaves the transcript intact",
206+
async ({ prisma, postgresContainer }) => {
207+
await boot(prisma, postgresContainer.getConnectionUri());
208+
await seedOwnedChat();
209+
210+
const before = await getChatMessages(agentDb, {
211+
chatId: CHAT,
212+
organizationId: ORG_A,
213+
userId: USER_A,
214+
});
215+
216+
// A chat id from another org appends nothing when the org is verified.
217+
const wroteForeignOrg = await appendChatMessageOnce(agentDb, {
218+
chatId: CHAT,
219+
userId: USER_A,
220+
organizationId: ORG_B,
221+
message: { id: "intruder", role: "assistant" },
222+
});
223+
expect(wroteForeignOrg).toBe(false);
224+
225+
// And a foreign user, same org, is refused too.
226+
const wroteForeignUser = await appendChatMessageOnce(agentDb, {
227+
chatId: CHAT,
228+
userId: USER_B,
229+
organizationId: ORG_A,
230+
message: { id: "intruder2", role: "assistant" },
231+
});
232+
expect(wroteForeignUser).toBe(false);
233+
234+
expect(
235+
await getChatMessages(agentDb, { chatId: CHAT, organizationId: ORG_A, userId: USER_A })
236+
).toEqual(before);
237+
},
238+
30_000
239+
);
240+
});

0 commit comments

Comments
 (0)