Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
}),
);

Expand Down
16 changes: 14 additions & 2 deletions packages/chat/src/platform-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,16 @@ export function createHubChatPlatform(
});
},

async fetchBlob(_channelId, blobId): Promise<string | Uint8Array> {
async fetchBlob(channelId, blobId): Promise<string | Uint8Array> {
// 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}"`);
Expand All @@ -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}"`);
Expand Down
50 changes: 48 additions & 2 deletions packages/chat/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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
Expand Down Expand Up @@ -467,6 +486,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono<TenantEnv> {
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,
Expand Down Expand Up @@ -514,6 +537,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono<TenantEnv> {
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
Expand Down Expand Up @@ -560,14 +587,18 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono<TenantEnv> {
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 });
},
);

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) {
Expand Down Expand Up @@ -897,6 +928,9 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono<TenantEnv> {
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,
Expand Down Expand Up @@ -931,6 +965,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono<TenantEnv> {
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,
Expand All @@ -949,9 +987,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono<TenantEnv> {
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 },
Expand All @@ -964,7 +1006,11 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono<TenantEnv> {
"/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({
Expand Down
33 changes: 32 additions & 1 deletion packages/chat/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -102,6 +107,13 @@ export interface ChatStore {
principalId: string,
): Promise<ReadStateRow | undefined>;
putReadState(input: PutReadStateInput): Promise<ReadStateRow>;
/**
* 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<boolean>;
}

/**
Expand Down Expand Up @@ -246,6 +258,20 @@ export function createDrizzleChatStore<TSchema extends Record<string, unknown>>(
}
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;
},
};
}

Expand All @@ -259,6 +285,7 @@ export function createInMemoryChatStore(): ChatStore {
const settingsByKey = new Map<string, ChannelSettingsRow>();
const readStateByKey = new Map<string, ReadStateRow>();
const benchSettingsByTenant = new Map<string, ChatBenchSettingsRow>();
const launchedByKey = new Set<string>();

const settingsKey = (tenantId: string, channelId: string) =>
`${tenantId}:${channelId}`;
Expand Down Expand Up @@ -338,5 +365,9 @@ export function createInMemoryChatStore(): ChatStore {
);
return row;
},

async hasLaunchedInstance(tenantId, instanceId) {
return launchedByKey.has(`${tenantId}:${instanceId}`);
},
};
}
126 changes: 121 additions & 5 deletions packages/chat/test/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`, {
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<typeof createChatRoutes>,
tenant: typeof TENANT,
principalId: string,
) {
const app = new Hono<TenantEnv>();
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<typeof fakePlatform>).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<string>();
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);
});
});
Loading
Loading