mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
fix(voice): retain company authority for Cloud owners
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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
|
Private voice routes accept both local authenticated sessions and trusted Cloud
|
||||||
user sessions. Cloud callers keep their authenticated user ID and current
|
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.
|
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
|
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 { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
@@ -7,7 +11,7 @@ import { VOICE_RESULT_NOTIFICATION } from "@paperclipai/shared";
|
|||||||
import { createHash, createHmac, randomUUID } from "node:crypto";
|
import { createHash, createHmac, randomUUID } from "node:crypto";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
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 { configureSpekoSessionTools } from "../services/voice/speko-tool-setup.js";
|
||||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||||
import { chatChannelService } from "../services/chat-channels.js";
|
import { chatChannelService } from "../services/chat-channels.js";
|
||||||
@@ -522,6 +526,34 @@ const support = await getEmbeddedPostgresTestSupport();
|
|||||||
return { ...f, service, transport, credentials, input };
|
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() {
|
async function pushFixture() {
|
||||||
await instanceSettingsService(db).updateExperimental({enableChatConnectors: true});
|
await instanceSettingsService(db).updateExperimental({enableChatConnectors: true});
|
||||||
const f = await nativeServiceFixture();
|
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" } }));
|
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 }))
|
await request(app({ ...actor, source: "cloud_tenant", isInstanceAdmin: true }))
|
||||||
.get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(200);
|
.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" });
|
expect(service.history).toHaveBeenCalledWith(companyId, endpointId, { id: actor.userId, authority: "instance_admin" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function caller(req: Request): VoiceCaller {
|
|||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
if (req.actor.source === "local_implicit") return { id: "local-board", authority: "local_board" };
|
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");
|
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) {
|
function company(req: Request, requireAccess = true) {
|
||||||
const id = req.params.companyId as string;
|
const id = req.params.companyId as string;
|
||||||
|
|||||||
Reference in new issue
Block a user