diff --git a/server/src/__tests__/access-routes-permissions-upgrade.test.ts b/server/src/__tests__/access-routes-permissions-upgrade.test.ts new file mode 100644 index 00000000000..9db328d4519 --- /dev/null +++ b/server/src/__tests__/access-routes-permissions-upgrade.test.ts @@ -0,0 +1,167 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { and, eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + companies, + companyMemberships, + createDb, + 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"; +}); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +type Db = ReturnType; + +async function createApp(db: Db, companyId: string, userId: string) { + process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs"; + process.env.PAPERCLIP_IN_WORKTREE = "false"; + const { accessRoutes } = await import("../routes/access.js"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { + type: "board", + userId, + source: "local_implicit", + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "owner", status: "active" }], + isInstanceAdmin: true, + }; + next(); + }); + app.use("/api", accessRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + })); + 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 createCompanyWithOwner(db: Db) { + const company = await db + .insert(companies) + .values({ + name: `Access Routes ${randomUUID()}`, + issuePrefix: `AR${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); + const owner = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `owner-${randomUUID()}`, + status: "active", + membershipRole: "owner", + }) + .returning() + .then((rows) => rows[0]!); + return { company, owner }; +} + +describeEmbeddedPostgres("access routes permissions upgrade compatibility", () => { + let db!: Db; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-access-routes-permissions-upgrade-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("rejects owner self-lockout through the member route after the permissions upgrade", async () => { + const { company, owner } = await createCompanyWithOwner(db); + + const res = await request(await createApp(db, company.id, owner.principalId)) + .patch(`/api/companies/${company.id}/members/${owner.id}`) + .send({ membershipRole: "admin" }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toContain("You cannot remove yourself"); + + const unchanged = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.id, owner.id)) + .then((rows) => rows[0]!); + expect(unchanged.membershipRole).toBe("owner"); + }); + + it("keeps custom grants when the role-only member route changes a member role", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const member = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `admin-${randomUUID()}`, + status: "active", + membershipRole: "admin", + }) + .returning() + .then((rows) => rows[0]!); + const customScope = { projectIds: ["project-1"] }; + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: member.principalId, + permissionKey: "tasks:assign_scope", + scope: customScope, + grantedByUserId: owner.principalId, + }); + + const res = await request(await createApp(db, company.id, owner.principalId)) + .patch(`/api/companies/${company.id}/members/${member.id}`) + .send({ membershipRole: "operator" }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.membershipRole).toBe("operator"); + + const grants = await db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, company.id), + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, member.principalId), + ), + ); + expect(grants).toHaveLength(1); + expect(grants[0]).toMatchObject({ + permissionKey: "tasks:assign_scope", + scope: customScope, + grantedByUserId: owner.principalId, + }); + }); +}); diff --git a/server/src/__tests__/permissions-upgrade-boundary-routes.test.ts b/server/src/__tests__/permissions-upgrade-boundary-routes.test.ts new file mode 100644 index 00000000000..2d53659115a --- /dev/null +++ b/server/src/__tests__/permissions-upgrade-boundary-routes.test.ts @@ -0,0 +1,348 @@ +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"); + }); +});