fix(voice): retain company authority for Cloud owners

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-13 21:28:36 -05:00
1 parent c2531c98d9
commit f84fee2605
4 files changed
+44 -4

No files matched your search

@@ -21,7 +21,9 @@ PSTN/interruption qualification.
Private voice routes accept both local authenticated sessions and trusted Cloud
user sessions. Cloud callers keep their authenticated user ID and current
company scope. Board API keys, MCP OAuth callers, Cloud control credentials,
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
@@ -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();
@@ -43,9 +43,15 @@ describe("voice caller authentication", () => {
expect(service.start).toHaveBeenCalledWith(expect.objectContaining({ companyId, endpointId, caller: { id: actor.userId, authority: "member" } }));
});
it("preserves authenticated Cloud instance-admin authority", async () => {
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" });
});
+1 -1
View File
@@ -14,7 +14,7 @@ function caller(req: Request): VoiceCaller {
assertBoard(req);
if (req.actor.source === "local_implicit") return { id: "local-board", authority: "local_board" };
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.isInstanceAdmin ? "instance_admin" : "member" };
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;