import { randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { activityLog, agents, assets, companies, companyMemberships, createDb, documents, heartbeatRuns, issueAttachments, issueComments, issueDocuments, issues, issueWorkProducts, principalPermissionGrants, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; vi.hoisted(() => { process.env.PAPERCLIP_HOME = "/tmp/paperclip-test-home"; process.env.PAPERCLIP_INSTANCE_ID = "vitest"; process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs"; process.env.PAPERCLIP_IN_WORKTREE = "false"; }); vi.mock("../services/issue-assignment-wakeup.js", () => ({ queueIssueAssignmentWakeup: vi.fn(), })); const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; type Db = ReturnType; function agentActor(companyId: string, agentId: string): Express.Request["actor"] { return { type: "agent", agentId, companyId, runId: null, source: "agent_jwt", }; } async function createApp(db: Db, actor: Express.Request["actor"]) { process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs"; process.env.PAPERCLIP_IN_WORKTREE = "false"; const [{ activityRoutes }, { issueRoutes }] = await Promise.all([ import("../routes/activity.js"), import("../routes/issues.js"), ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = actor; next(); }); app.use("/api", issueRoutes(db, {} as any)); app.use("/api", activityRoutes(db)); app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" }); }); return app; } async function seedCompany(db: Db, label: string) { return db .insert(companies) .values({ name: `Permissions Boundary ${label}`, issuePrefix: `PB${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`, }) .returning() .then((rows) => rows[0]!); } async function seedAgent( db: Db, companyId: string, input: { role?: string; permissions?: Record; status?: "active" | "idle" } = {}, ) { return db .insert(agents) .values({ companyId, name: `Agent ${randomUUID()}`, role: input.role ?? "engineer", status: input.status ?? "active", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, permissions: input.permissions ?? {}, }) .returning() .then((rows) => rows[0]!); } describeEmbeddedPostgres("permissions upgrade visibility and route boundaries", () => { let db!: Db; let tempDb: Awaited> | null = null; beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-permissions-boundary-routes-"); db = createDb(tempDb.connectionString); }, 20_000); afterEach(async () => { await db.delete(issueAttachments); await db.delete(assets); await db.delete(issueDocuments); await db.delete(documents); await db.delete(issueWorkProducts); await db.delete(issueComments); await db.delete(activityLog); await db.delete(principalPermissionGrants); await db.delete(companyMemberships); await db.delete(heartbeatRuns); await db.delete(issues); await db.delete(agents); await db.delete(companies); }); afterAll(async () => { await tempDb?.cleanup(); }); it("keeps V1 private agent visibility from becoming issue, comment, document, attachment, activity, or work product privacy", async () => { const company = await seedCompany(db, "Visibility"); const readerAgent = await seedAgent(db, company.id); const privateTargetAgent = await seedAgent(db, company.id, { permissions: { authorizationPolicy: { agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true, }, assignmentPolicy: { mode: "protected" }, protectedAgent: { requiresApproval: false }, managedBy: "permissions-extension", }, }, }); const issue = await db .insert(issues) .values({ companyId: company.id, identifier: `${company.issuePrefix}-1`, title: "Visible work for a private target agent", status: "todo", priority: "medium", assigneeAgentId: privateTargetAgent.id, }) .returning() .then((rows) => rows[0]!); const comment = await db .insert(issueComments) .values({ companyId: company.id, issueId: issue.id, authorAgentId: privateTargetAgent.id, body: "Private target agent status is still company-visible.", }) .returning() .then((rows) => rows[0]!); const doc = await db .insert(documents) .values({ companyId: company.id, title: "Plan", latestBody: "Shared plan body", createdByAgentId: privateTargetAgent.id, updatedByAgentId: privateTargetAgent.id, }) .returning() .then((rows) => rows[0]!); await db.insert(issueDocuments).values({ companyId: company.id, issueId: issue.id, documentId: doc.id, key: "plan", }); const asset = await db .insert(assets) .values({ companyId: company.id, provider: "local_disk", objectKey: `attachments/${randomUUID()}.txt`, contentType: "text/plain", byteSize: 12, sha256: "abc123", originalFilename: "note.txt", createdByAgentId: privateTargetAgent.id, }) .returning() .then((rows) => rows[0]!); await db.insert(issueAttachments).values({ companyId: company.id, issueId: issue.id, issueCommentId: comment.id, assetId: asset.id, }); await db.insert(issueWorkProducts).values({ companyId: company.id, issueId: issue.id, type: "url", provider: "test", title: "Preview", url: "https://example.test/preview", status: "ready", }); await db.insert(activityLog).values({ companyId: company.id, actorType: "agent", actorId: privateTargetAgent.id, agentId: privateTargetAgent.id, action: "issue.updated", entityType: "issue", entityId: issue.id, details: { source: "test" }, }); const app = await createApp(db, agentActor(company.id, readerAgent.id)); const [issueList, comments, docs, docDetail, attachments, activity, workProducts] = await Promise.all([ request(app).get(`/api/companies/${company.id}/issues`), request(app).get(`/api/issues/${issue.id}/comments`), request(app).get(`/api/issues/${issue.id}/documents`), request(app).get(`/api/issues/${issue.id}/documents/plan`), request(app).get(`/api/issues/${issue.id}/attachments`), request(app).get(`/api/issues/${issue.id}/activity`), request(app).get(`/api/issues/${issue.id}/work-products`), ]); expect(issueList.status, JSON.stringify(issueList.body)).toBe(200); expect(issueList.body.items ?? issueList.body).toEqual( expect.arrayContaining([expect.objectContaining({ id: issue.id })]), ); expect(comments.status, JSON.stringify(comments.body)).toBe(200); expect(comments.body).toEqual(expect.arrayContaining([expect.objectContaining({ id: comment.id })])); expect(docs.status, JSON.stringify(docs.body)).toBe(200); expect(docs.body).toEqual(expect.arrayContaining([expect.objectContaining({ key: "plan" })])); expect(docDetail.status, JSON.stringify(docDetail.body)).toBe(200); expect(docDetail.body.body ?? docDetail.body.latestBody).toContain("Shared plan body"); expect(attachments.status, JSON.stringify(attachments.body)).toBe(200); expect(attachments.body).toEqual(expect.arrayContaining([expect.objectContaining({ id: expect.any(String) })])); expect(activity.status, JSON.stringify(activity.body)).toBe(200); expect(activity.body).toEqual(expect.arrayContaining([expect.objectContaining({ action: "issue.updated" })])); expect(workProducts.status, JSON.stringify(workProducts.body)).toBe(200); expect(workProducts.body).toEqual(expect.arrayContaining([expect.objectContaining({ title: "Preview" })])); }); it("denies cross-company issue reads before private-agent grant evaluation can matter", async () => { const sourceCompany = await seedCompany(db, "Source"); const targetCompany = await seedCompany(db, "Target"); const sourceAgent = await seedAgent(db, sourceCompany.id); const privateTargetAgent = await seedAgent(db, targetCompany.id, { permissions: { authorizationPolicy: { agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true }, assignmentPolicy: { mode: "company_default" }, protectedAgent: { requiresApproval: false }, }, }, }); const issue = await db .insert(issues) .values({ companyId: targetCompany.id, title: "Other company work", status: "todo", priority: "medium", assigneeAgentId: privateTargetAgent.id, }) .returning() .then((rows) => rows[0]!); const res = await request(await createApp(db, agentActor(sourceCompany.id, sourceAgent.id))) .get(`/api/issues/${issue.id}`); expect(res.status).toBe(403); expect(res.body.error).toContain("Agent key cannot access another company"); }); it("allows same-company route assignment after upgrade but keeps private target assignment grant constrained", async () => { const company = await seedCompany(db, "Assignment"); const actorAgent = await seedAgent(db, company.id); const openTargetAgent = await seedAgent(db, company.id); const privateTargetAgent = await seedAgent(db, company.id, { permissions: { authorizationPolicy: { agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true }, assignmentPolicy: { mode: "company_default" }, protectedAgent: { requiresApproval: false }, managedBy: "permissions-extension", }, }, }); const app = await createApp(db, agentActor(company.id, actorAgent.id)); const openAssignment = await request(app) .post(`/api/companies/${company.id}/issues`) .send({ title: "Assignable after upgrade", assigneeAgentId: openTargetAgent.id }); expect(openAssignment.status, JSON.stringify(openAssignment.body)).toBe(201); const deniedPrivateAssignment = await request(app) .post(`/api/companies/${company.id}/issues`) .send({ title: "Private target needs scope", assigneeAgentId: privateTargetAgent.id }); expect(deniedPrivateAssignment.status).toBe(403); expect(deniedPrivateAssignment.body.error).toContain("private"); await db.insert(companyMemberships).values({ companyId: company.id, principalType: "agent", principalId: actorAgent.id, status: "active", membershipRole: "member", }); await db.insert(principalPermissionGrants).values({ companyId: company.id, principalType: "agent", principalId: actorAgent.id, permissionKey: "tasks:assign_scope", scope: { assigneeAgentIds: [privateTargetAgent.id] }, grantedByUserId: null, }); const allowedPrivateAssignment = await request(app) .post(`/api/companies/${company.id}/issues`) .send({ title: "Private target has explicit scope", assigneeAgentId: privateTargetAgent.id }); expect(allowedPrivateAssignment.status, JSON.stringify(allowedPrivateAssignment.body)).toBe(201); const otherPrivateTargetAgent = await seedAgent(db, company.id, { permissions: privateTargetAgent.permissions as Record, }); const deniedOutsideScope = await request(app) .post(`/api/companies/${company.id}/issues`) .send({ title: "Different private target stays denied", assigneeAgentId: otherPrivateTargetAgent.id }); expect(deniedOutsideScope.status).toBe(403); expect(deniedOutsideScope.body.error).toContain("private"); }); });