diff --git a/doc/plans/2026-09-11-speko-voice-e2e-runbook.md b/doc/plans/2026-09-11-speko-voice-e2e-runbook.md index 2e59578412b..2867fd32d55 100644 --- a/doc/plans/2026-09-11-speko-voice-e2e-runbook.md +++ b/doc/plans/2026-09-11-speko-voice-e2e-runbook.md @@ -17,6 +17,20 @@ unchanged. See the [live-call push qualification](2026-10-09-speko-pr-qualificat for the real-provider text-input proof, timings, reproduction and remaining PSTN/interruption qualification. +## Cloud caller authentication (2026-10-13) + +Private voice routes accept both local authenticated sessions and trusted Cloud +user sessions. Cloud callers keep their authenticated user ID and current +company scope. Cloud owners use current company membership even when temporary +Cloud administrator access is enabled; they do not need a persisted local +instance-admin role. Board API keys, MCP OAuth callers, Cloud control credentials, +and raw Cloud user headers do not create private caller authority. + +On a Cloud workspace, verify that the signed-in operator can load call history +and start a browser session. Repeat with a user outside the company and confirm +access is denied. Provider configuration alone does not qualify conversation +or spoken result delivery. + ## October task-parity qualification Updated 2026-10-07: September's browser proof is historical. The current task-parity diff --git a/server/src/__tests__/voice-session-store.integration.test.ts b/server/src/__tests__/voice-session-store.integration.test.ts index 07cbfa2e92c..276bdb353f4 100644 --- a/server/src/__tests__/voice-session-store.integration.test.ts +++ b/server/src/__tests__/voice-session-store.integration.test.ts @@ -1,3 +1,7 @@ +import express from "express"; +import request from "supertest"; +import { errorHandler } from "../middleware/error-handler.js"; +import { voiceSessionRoutes } from "../routes/voice-sessions.js"; import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; @@ -7,7 +11,7 @@ import { VOICE_RESULT_NOTIFICATION } from "@paperclipai/shared"; import { createHash, createHmac, randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { chatActions, principalPermissionGrants, environments, chatVoiceInboundCalls, chatVoicePhoneLines, chatVoiceReports, heartbeatRuns, toolProfileBindings, chatVoiceCallbacks, agentWakeupRequests, issueThreadInteractions, issueQuestionResponseDeliveries, issueComments, agents, chatConversations, chatDeliveries, chatEndpoints, chatPublications, chatVoiceReplies, chatVoiceSessions, chatVoiceToolCalls, companies, companyMemberships, companySecrets, connectionGrants, connectionGrantMembers, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, issues, toolApplications, toolConnections, toolConnectionInstalls } from "@paperclipai/db"; +import { chatActions, instanceUserRoles, principalPermissionGrants, environments, chatVoiceInboundCalls, chatVoicePhoneLines, chatVoiceReports, heartbeatRuns, toolProfileBindings, chatVoiceCallbacks, agentWakeupRequests, issueThreadInteractions, issueQuestionResponseDeliveries, issueComments, agents, chatConversations, chatDeliveries, chatEndpoints, chatPublications, chatVoiceReplies, chatVoiceSessions, chatVoiceToolCalls, companies, companyMemberships, companySecrets, connectionGrants, connectionGrantMembers, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, issues, toolApplications, toolConnections, toolConnectionInstalls } from "@paperclipai/db"; import { configureSpekoSessionTools } from "../services/voice/speko-tool-setup.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { chatChannelService } from "../services/chat-channels.js"; @@ -522,6 +526,34 @@ const support = await getEmbeddedPostgresTestSupport(); return { ...f, service, transport, credentials, input }; } + it("loads history and starts calls for Cloud owners without local admin roles, retaining membership revocation", async () => { + await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true }); + const f = await nativeServiceFixture(); + await db.update(companyMemberships).set({ membershipRole: "owner" }).where(eq(companyMemberships.principalId, f.callerId)); + expect(await db.select().from(instanceUserRoles).where(eq(instanceUserRoles.userId, f.callerId))).toEqual([]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { type: "board", source: "cloud_tenant", userId: f.callerId, isInstanceAdmin: true, companyIds: [f.companyId] }; + next(); + }); + app.use("/api", voiceSessionRoutes(db, f.service)); + app.use(errorHandler); + const historyPath = `/api/companies/${f.companyId}/voice-history/${f.endpointId}`; + const history = await request(app).get(historyPath).expect(200); + expect(history.body).toHaveLength(1); + const started = await request(app).post(`/api/companies/${f.companyId}/voice-sessions`) + .send({ endpointId: f.endpointId, issueId: f.issueId, idempotencyKey: randomUUID() }).expect(201); + const [persisted] = await db.select().from(chatVoiceSessions).where(eq(chatVoiceSessions.id, started.body.session.id)); + expect(persisted).toMatchObject({ callerId: f.callerId, callerAuthority: "member", companyId: f.companyId }); + expect(f.transport.createBrowserSession).toHaveBeenCalledTimes(1); + await db.update(companyMemberships).set({ status: "removed" }).where(eq(companyMemberships.principalId, f.callerId)); + await request(app).get(historyPath).expect(403); + await request(app).post(`/api/companies/${f.companyId}/voice-sessions`) + .send({ endpointId: f.endpointId, issueId: f.issueId, idempotencyKey: randomUUID() }).expect(403); + expect(f.transport.createBrowserSession).toHaveBeenCalledTimes(1); + }); + async function pushFixture() { await instanceSettingsService(db).updateExperimental({enableChatConnectors: true}); const f = await nativeServiceFixture(); diff --git a/server/src/routes/voice-sessions.auth.test.ts b/server/src/routes/voice-sessions.auth.test.ts new file mode 100644 index 00000000000..99a9ed7a196 --- /dev/null +++ b/server/src/routes/voice-sessions.auth.test.ts @@ -0,0 +1,91 @@ +import express from "express"; +import request from "supertest"; +import type { Db } from "@paperclipai/db"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/error-handler.js"; +import type { VoiceSessionService } from "../services/voice/voice-session-service.js"; +import { voiceSessionRoutes } from "./voice-sessions.js"; + +vi.mock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => ({ getExperimental: async () => ({ enableChatConnectors: true }) }), +})); + +const companyId = "11111111-1111-4111-8111-111111111111"; +const endpointId = "22222222-2222-4222-8222-222222222222"; +const idempotencyKey = "33333333-3333-4333-8333-333333333333"; +const actor: Express.Request["actor"] = { + type: "board", source: "session", userId: "signed-in-user", companyIds: [companyId], +}; +const service = { history: vi.fn(), start: vi.fn() }; + +function app(currentActor = actor) { + const server = express(); + server.use(express.json()); + server.use((req, _res, next) => { req.actor = currentActor; next(); }); + server.use("/api", voiceSessionRoutes({} as Db, service as unknown as VoiceSessionService)); + server.use(errorHandler); + return server; +} + +beforeEach(() => { + vi.resetAllMocks(); + service.history.mockResolvedValue([]); + service.start.mockResolvedValue({ session: { id: "test-session" } }); +}); + +describe("voice caller authentication", () => { + it.each(["session", "cloud_tenant"] as const)("allows signed-in %s users to read calls and start a session", async (source) => { + const server = app({ ...actor, source }); + await request(server).get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(200, []); + expect(service.history).toHaveBeenCalledWith(companyId, endpointId, { id: actor.userId, authority: "member" }); + await request(server).post(`/api/companies/${companyId}/voice-sessions`) + .send({ endpointId, idempotencyKey }).expect(200); + expect(service.start).toHaveBeenCalledWith(expect.objectContaining({ companyId, endpointId, caller: { id: actor.userId, authority: "member" } })); + }); + + it("keeps Cloud owners company-scoped without a persisted local admin role", async () => { + await request(app({ ...actor, source: "cloud_tenant", isInstanceAdmin: true })) + .get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(200); + expect(service.history).toHaveBeenCalledWith(companyId, endpointId, { id: actor.userId, authority: "member" }); + }); + + it("preserves local authenticated administrator authority", async () => { + await request(app({ ...actor, isInstanceAdmin: true })) + .get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(200); + expect(service.history).toHaveBeenCalledWith(companyId, endpointId, { id: actor.userId, authority: "instance_admin" }); + }); + + it("preserves local trusted callers", async () => { + await request(app({ type: "board", source: "local_implicit" })) + .get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(200); + expect(service.history).toHaveBeenCalledWith(companyId, endpointId, { id: "local-board", authority: "local_board" }); + }); + + it.each(["board_key", "mcp_oauth", "cloud_control"] as const)("rejects %s credentials as private human callers", async (source) => { + await request(app({ ...actor, source })).get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(403); + expect(service.history).not.toHaveBeenCalled(); + }); + + it.each(["session", "cloud_tenant"] as const)("requires a user identity for %s callers", async (source) => { + await request(app({ ...actor, source, userId: undefined })).get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(403); + expect(service.history).not.toHaveBeenCalled(); + }); + + it("retains Cloud company isolation", async () => { + const server = app({ ...actor, source: "cloud_tenant", companyIds: [] }); + await request(server).get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(403); + await request(server).post(`/api/companies/${companyId}/voice-sessions`).send({ endpointId, idempotencyKey }).expect(403); + expect(service.history).not.toHaveBeenCalled(); + expect(service.start).not.toHaveBeenCalled(); + }); + + it("does not authenticate raw Cloud headers or agent callers", async () => { + await request(app({ type: "none", source: "none" })) + .get(`/api/companies/${companyId}/voice-history/${endpointId}`) + .set("x-paperclip-cloud-user-id", actor.userId!).expect(403); + await request(app({ type: "agent", source: "agent_key", companyId })) + .post(`/api/companies/${companyId}/voice-sessions`).send({ endpointId, idempotencyKey }).expect(403); + expect(service.history).not.toHaveBeenCalled(); + expect(service.start).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/routes/voice-sessions.ts b/server/src/routes/voice-sessions.ts index ed16b415294..add9b278395 100644 --- a/server/src/routes/voice-sessions.ts +++ b/server/src/routes/voice-sessions.ts @@ -13,8 +13,8 @@ import { assertBoard, assertCompanyAccess } from "./authz.js"; function caller(req: Request): VoiceCaller { assertBoard(req); if (req.actor.source === "local_implicit") return { id: "local-board", authority: "local_board" }; - if (!req.actor.userId || req.actor.source !== "session") throw forbidden("Sign in to start a private voice conversation"); - return { id: req.actor.userId, authority: req.actor.isInstanceAdmin ? "instance_admin" : "member" }; + if (!req.actor.userId || !["session", "cloud_tenant"].includes(req.actor.source ?? "")) throw forbidden("Sign in to start a private voice conversation"); + return { id: req.actor.userId, authority: req.actor.source === "session" && req.actor.isInstanceAdmin ? "instance_admin" : "member" }; } function company(req: Request, requireAccess = true) { const id = req.params.companyId as string;