diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 313ce9201..55fee6f0c 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -351,6 +351,10 @@ export async function createHub(config: HubConfig) { grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, }), + channelBelongsToTenant: async (tenantId, channelId) => + (await chatStore.getChannelSettings(tenantId, channelId)) !== + undefined || + (await chatStore.hasLaunchedInstance(tenantId, channelId)), }), ); diff --git a/packages/chat/src/platform-adapter.ts b/packages/chat/src/platform-adapter.ts index 9a87eac8e..dcbf9b324 100644 --- a/packages/chat/src/platform-adapter.ts +++ b/packages/chat/src/platform-adapter.ts @@ -476,7 +476,16 @@ export function createHubChatPlatform( }); }, - async fetchBlob(_channelId, blobId): Promise { + async fetchBlob(channelId, blobId): Promise { + // Blobs are only readable when the mail row lives on this channel's + // session. Looking up by mail id alone let any authenticated caller + // read another tenant's attachment by guessing a blob id. + const run = await findFoldedRunById(deps.db, channelId); + if (run === undefined) { + throw new Error(`No channel run for "${channelId}"`); + } + const sessionId = await resolveFoldedRunSessionId(deps.db, run); + const match = /^blob_(.+?)_(\d[\d.]*)$/.exec(blobId); if (match === null) { throw new Error(`Invalid blob id "${blobId}"`); @@ -486,7 +495,10 @@ export function createHubChatPlatform( throw new Error(`Invalid blob id "${blobId}"`); } const mailRow = await deps.db.query.sessionMail.findFirst({ - where: eq(sessionMail.id, mailId), + where: and( + eq(sessionMail.id, mailId), + eq(sessionMail.sessionId, sessionId), + ), }); if (mailRow === undefined) { throw new Error(`No mail "${mailId}" for blob "${blobId}"`); diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index aa1cf1ddb..7d17de0c3 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -181,6 +181,25 @@ const PutReadStateBody = type({ lastSeenId: "string", }); +/** + * Every `/channels/:id/*` handler must resolve the channel inside the + * request tenant before acting. A channel is in-tenant when it has a + * `channel_settings` row **or** a `channel_launch` row (agent host / + * invite instance ids are mailboxes with no settings). A miss is a 404 + * — never a silent pass that lets a wildcard grant operate on another + * tenant's channel. + */ +async function channelInTenant( + store: ChatStore, + tenantId: string, + channelId: string, +): Promise { + if ((await store.getChannelSettings(tenantId, channelId)) !== undefined) { + return true; + } + return store.hasLaunchedInstance(tenantId, channelId); +} + /** * Decides whether an incoming channel message opens the command path * at all, and if so, dispatches it. `undefined` — the caller's cue to @@ -467,6 +486,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const channelId = c.req.param("id"); const cursor = c.req.query("cursor"); + if (!(await channelInTenant(deps.store, tenant.id, channelId))) { + return c.json(ErrorEnvelope("not_found", "channel not found"), 404); + } + const listed = await deps.platform.listMail({ tenantId: tenant.id, channelId, @@ -514,6 +537,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const channelId = c.req.param("id"); const messageParts = parsed as PartType[]; + if (!(await channelInTenant(deps.store, tenant.id, channelId))) { + return c.json(ErrorEnvelope("not_found", "channel not found"), 404); + } + // Slash messages, and `@name` messages whose name resolves to a // command rather than an already-invited agent participant, are // intercepted here and never posted as mail themselves — only @@ -560,6 +587,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { deps.requireGrant(idResource("workflow-run", "id"), "read"), async (c) => { const tenant = c.get("tenant"); + const channelId = c.req.param("id"); + if (!(await channelInTenant(deps.store, tenant.id, channelId))) { + return c.json(ErrorEnvelope("not_found", "channel not found"), 404); + } const items = await deps.platform.listInvitableDefinitions(tenant.id); return c.json({ items }); }, @@ -567,7 +598,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { app.post( "/channels/:id/invite", - deps.requireGrant("workflow-run:*", "create"), + deps.requireGrant(idResource("workflow-run", "id"), "create"), async (c) => { const body = InviteAgentBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { @@ -897,6 +928,9 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const tenant = c.get("tenant"); const principal = c.get("principal"); const channelId = c.req.param("id"); + if (!(await channelInTenant(deps.store, tenant.id, channelId))) { + return c.json(ErrorEnvelope("not_found", "channel not found"), 404); + } const row = await deps.store.getReadState( tenant.id, channelId, @@ -931,6 +965,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const principal = c.get("principal"); const channelId = c.req.param("id"); + if (!(await channelInTenant(deps.store, tenant.id, channelId))) { + return c.json(ErrorEnvelope("not_found", "channel not found"), 404); + } + const row = await deps.store.putReadState({ tenantId: tenant.id, channelId, @@ -949,9 +987,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { app.post( "/channels/:id/typing", deps.requireGrant(idResource("workflow-run", "id"), "write"), - (c) => { + async (c) => { + const tenant = c.get("tenant"); const principal = c.get("principal"); const channelId = c.req.param("id"); + if (!(await channelInTenant(deps.store, tenant.id, channelId))) { + return c.json(ErrorEnvelope("not_found", "channel not found"), 404); + } publish(channelId, { type: "chat.typing", data: { principalId: principal.id }, @@ -964,7 +1006,11 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { "/channels/:id/stream", deps.requireGrant(idResource("workflow-run", "id"), "read"), async (c) => { + const tenant = c.get("tenant"); const channelId = c.req.param("id"); + if (!(await channelInTenant(deps.store, tenant.id, channelId))) { + return c.json(ErrorEnvelope("not_found", "channel not found"), 404); + } return streamSSE(c, async (stream) => { const unbridge = bridgeChannelStream({ diff --git a/packages/chat/src/store.ts b/packages/chat/src/store.ts index b5c6ce121..5371e50a3 100644 --- a/packages/chat/src/store.ts +++ b/packages/chat/src/store.ts @@ -13,7 +13,12 @@ import { and, eq } from "drizzle-orm"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import { channelReadState, channelSettings, chatBenchSettings } from "./schema"; +import { + channelLaunch, + channelReadState, + channelSettings, + chatBenchSettings, +} from "./schema"; /** * The drizzle handle `createDrizzleChatStore` operates against. Generic over @@ -102,6 +107,13 @@ export interface ChatStore { principalId: string, ): Promise; putReadState(input: PutReadStateInput): Promise; + /** + * True when `instanceId` is a workflow instance this tenant launched + * (channel host or invited agent). Agent mailboxes are addressed by + * instance id, not by a `channel_settings` row, so tenancy gates on + * message routes must consult this as well as `getChannelSettings`. + */ + hasLaunchedInstance(tenantId: string, instanceId: string): Promise; } /** @@ -246,6 +258,20 @@ export function createDrizzleChatStore>( } return row as ReadStateRow; }, + + async hasLaunchedInstance(tenantId, instanceId) { + const [row] = await db + .select({ instanceId: channelLaunch.instanceId }) + .from(channelLaunch) + .where( + and( + eq(channelLaunch.tenantId, tenantId), + eq(channelLaunch.instanceId, instanceId), + ), + ) + .limit(1); + return row !== undefined; + }, }; } @@ -259,6 +285,7 @@ export function createInMemoryChatStore(): ChatStore { const settingsByKey = new Map(); const readStateByKey = new Map(); const benchSettingsByTenant = new Map(); + const launchedByKey = new Set(); const settingsKey = (tenantId: string, channelId: string) => `${tenantId}:${channelId}`; @@ -338,5 +365,9 @@ export function createInMemoryChatStore(): ChatStore { ); return row; }, + + async hasLaunchedInstance(tenantId, instanceId) { + return launchedByKey.has(`${tenantId}:${instanceId}`); + }, }; } diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index 1205eda86..1da1a1686 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -171,7 +171,7 @@ describe("messages", () => { test("POST encodes Part[] via the codec and sends as the calling principal", async () => { const deps = buildDeps(); const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: channel } = await createChannel(app, { kind: "chat" }); + const { body: channel } = await createChannel(app, { kind: "channel" }); const parts: Part[] = [{ kind: "text", text: "hello" }]; const response = await app.request(`/channels/${channel.id}/messages`, { @@ -190,7 +190,7 @@ describe("messages", () => { test("POST rejects a malformed message body with the 400 envelope", async () => { const deps = buildDeps(); const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: channel } = await createChannel(app, { kind: "chat" }); + const { body: channel } = await createChannel(app, { kind: "channel" }); const response = await app.request(`/channels/${channel.id}/messages`, { method: "POST", @@ -206,7 +206,7 @@ describe("messages", () => { test("GET decodes run mail back to Part[]", async () => { const deps = buildDeps(); const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: channel } = await createChannel(app, { kind: "chat" }); + const { body: channel } = await createChannel(app, { kind: "channel" }); await app.request(`/channels/${channel.id}/messages`, { method: "POST", @@ -252,7 +252,9 @@ describe("read-state", () => { const app = createChatRoutes(deps); const appAlice = mountAs(app, "prn_alice"); const appBob = mountAs(app, "prn_bob"); - const { body: channel } = await createChannel(appAlice, { kind: "chat" }); + const { body: channel } = await createChannel(appAlice, { + kind: "channel", + }); await appAlice.request(`/channels/${channel.id}/read-state`, { method: "PUT", @@ -317,7 +319,7 @@ describe("typing", () => { test("is never persisted", async () => { const deps = buildDeps(); const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: channel } = await createChannel(app, { kind: "chat" }); + const { body: channel } = await createChannel(app, { kind: "channel" }); const response = await app.request(`/channels/${channel.id}/typing`, { method: "POST", @@ -641,3 +643,117 @@ describe("channel tenancy", () => { expect(tenancyA[0]?.tenantId).not.toBe(tenancyB[0]?.tenantId); }); }); + +describe("cross-tenant channel isolation", () => { + function mountTenant( + routes: ReturnType, + tenant: typeof TENANT, + principalId: string, + ) { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("tenant", tenant); + c.set("principal", principal(principalId)); + await next(); + }); + app.route("/", routes); + return app; + } + + test("POST/GET messages reject a channel owned by another tenant", async () => { + const OTHER_TENANT = { ...TENANT, id: "tnt_2", domain: "other.example" }; + const deps = buildDeps(); + const routes = createChatRoutes(deps); + const appA = mountTenant(routes, TENANT, "prn_alice"); + const appB = mountTenant(routes, OTHER_TENANT, "prn_bob"); + + const { body: channel } = await createChannel(appA, { kind: "channel" }); + + const postB = await appB.request(`/channels/${channel.id}/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify([{ kind: "text", text: "cross-tenant write" }]), + }); + expect(postB.status).toBe(404); + expect( + (deps.platform as ReturnType).sentMail, + ).toHaveLength(0); + + const getB = await appB.request(`/channels/${channel.id}/messages`); + expect(getB.status).toBe(404); + }); + + test("typing and stream reject a channel owned by another tenant", async () => { + const OTHER_TENANT = { ...TENANT, id: "tnt_2", domain: "other.example" }; + const deps = buildDeps(); + const routes = createChatRoutes(deps); + const appA = mountTenant(routes, TENANT, "prn_alice"); + const appB = mountTenant(routes, OTHER_TENANT, "prn_bob"); + + const { body: channel } = await createChannel(appA, { kind: "channel" }); + + const typing = await appB.request(`/channels/${channel.id}/typing`, { + method: "POST", + }); + expect(typing.status).toBe(404); + + const stream = await appB.request(`/channels/${channel.id}/stream`); + expect(stream.status).toBe(404); + }); + + test("read-state and invitable reject a channel owned by another tenant", async () => { + const OTHER_TENANT = { ...TENANT, id: "tnt_2", domain: "other.example" }; + const deps = buildDeps(); + const routes = createChatRoutes(deps); + const appA = mountTenant(routes, TENANT, "prn_alice"); + const appB = mountTenant(routes, OTHER_TENANT, "prn_bob"); + + const { body: channel } = await createChannel(appA, { kind: "channel" }); + + const readGet = await appB.request(`/channels/${channel.id}/read-state`); + expect(readGet.status).toBe(404); + + const readPut = await appB.request(`/channels/${channel.id}/read-state`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + lastSeenCreatedAt: "2026-01-01T00:00:00.000Z", + lastSeenId: "mail_x", + }), + }); + expect(readPut.status).toBe(404); + + const invitable = await appB.request(`/channels/${channel.id}/invitable`); + expect(invitable.status).toBe(404); + }); + + test("GET messages allows a launched agent instance in the same tenant", async () => { + // Agent mailboxes are instance ids with a channel_launch row, not a + // channel_settings row. The tenancy gate must accept those so the + // e2e "invite agent → list its messages" path keeps working. + const baseStore = createInMemoryChatStore(); + const launchedKeys = new Set(); + const gatedStore = { + ...baseStore, + hasLaunchedInstance: async (tenantId: string, instanceId: string) => + launchedKeys.has(`${tenantId}:${instanceId}`) || + baseStore.hasLaunchedInstance(tenantId, instanceId), + }; + const deps = buildDeps({ store: gatedStore }); + const routes = createChatRoutes(deps); + const app = mountTenant(routes, TENANT, "prn_alice"); + + launchedKeys.add(`${TENANT.id}:ins_agent_mailbox`); + const res = await app.request(`/channels/ins_agent_mailbox/messages`); + expect(res.status).toBe(200); + + // Foreign tenant still 404s even with the same instance id shape. + const other = mountTenant( + routes, + { ...TENANT, id: "tnt_2", domain: "other.example" }, + "prn_bob", + ); + const denied = await other.request(`/channels/ins_agent_mailbox/messages`); + expect(denied.status).toBe(404); + }); +}); diff --git a/packages/commands/src/routes.ts b/packages/commands/src/routes.ts index d9fd07628..6e667e8eb 100644 --- a/packages/commands/src/routes.ts +++ b/packages/commands/src/routes.ts @@ -18,6 +18,16 @@ import type { CommandListing, CommandRegistry } from "./registry"; export type CreateCommandRoutesDeps = { registry: CommandRegistry; requireGrant: RequireGrant; + /** + * Resolves whether `channelId` is a channel this tenant can see. The + * execute body carries a free-form channel id — without this check a + * principal with a tenant-wide grant could run commands against + * another tenant's channel. + */ + channelBelongsToTenant: ( + tenantId: string, + channelId: string, + ) => Promise; }; const ErrorEnvelope = (code: string, message: string) => ({ @@ -68,6 +78,14 @@ export function createCommandRoutes( const tenant = c.get("tenant"); const principal = c.get("principal"); + const belongs = await deps.channelBelongsToTenant( + tenant.id, + body.channelId, + ); + if (!belongs) { + return c.json(ErrorEnvelope("not_found", "channel not found"), 404); + } + const result = await dispatchSlashCommand( deps.registry, `/${body.name} ${body.args ?? ""}`.trimEnd(), diff --git a/packages/commands/test/routes.test.ts b/packages/commands/test/routes.test.ts new file mode 100644 index 000000000..5eb983879 --- /dev/null +++ b/packages/commands/test/routes.test.ts @@ -0,0 +1,107 @@ +// Command HTTP surface: listing and execute, including the tenant-scoped +// channel membership gate on execute (CL-5768). +import { describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import type { TenantEnv } from "@intx/hub-api"; +import type { RequireGrant } from "@intx/hub-api"; +import { createCommandRegistry } from "../src/registry"; +import { createCommandRoutes } from "../src/routes"; + +const TENANT = { + id: "tnt_1", + name: "Acme", + slug: "acme", + domain: "acme.example", + parentId: null, + config: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +function principal(id: string) { + return { + id, + tenantId: TENANT.id, + kind: "user" as const, + refId: id, + status: "active" as const, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +function mount(routes: ReturnType) { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("tenant", TENANT); + c.set("principal", principal("prn_alice")); + await next(); + }); + app.route("/", routes); + return app; +} + +const passGrant: RequireGrant = () => async (_c, next) => next(); + +describe("POST /commands/execute", () => { + test("rejects a channel that does not belong to the tenant", async () => { + const registry = createCommandRegistry(); + registry.registerCommand({ + name: "ping", + description: "pong", + handler: async () => ({ type: "message", text: "pong" }), + }); + + const app = mount( + createCommandRoutes({ + registry, + requireGrant: passGrant, + channelBelongsToTenant: async () => false, + }), + ); + + const response = await app.request("/commands/execute", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "ping", + channelId: "ins_foreign", + }), + }); + + expect(response.status).toBe(404); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("not_found"); + }); + + test("dispatches when the channel belongs to the tenant", async () => { + const registry = createCommandRegistry(); + registry.registerCommand({ + name: "ping", + description: "pong", + handler: async () => ({ type: "message", text: "pong" }), + }); + + const app = mount( + createCommandRoutes({ + registry, + requireGrant: passGrant, + channelBelongsToTenant: async (tenantId, channelId) => + tenantId === TENANT.id && channelId === "ins_mine", + }), + ); + + const response = await app.request("/commands/execute", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "ping", + channelId: "ins_mine", + }), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { type: string; text: string }; + expect(body).toEqual({ type: "message", text: "pong" }); + }); +});