test permissions upgrade boundaries

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-22 01:51:03 +00:00
1 parent ebc4d9a5ac
commit 3463456bee
2 files changed
+515

No files matched your search

@@ -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<typeof createDb>;
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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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,
});
});
});
@@ -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<typeof createDb>;
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<string, unknown>; 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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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<string, unknown>,
});
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");
});
});