mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
fix: redact private project from heartbeat context
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
c761282d76
commit
a87df0c351
2 files changed
+82
-8
No files matched your search
@@ -14,6 +14,8 @@ import {
|
||||
issueReferenceMentions,
|
||||
issueRelations,
|
||||
issues,
|
||||
projectAccessMembers,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
@@ -44,6 +46,7 @@ function agentActor(companyId: string, agentId: string): Express.Request["actor"
|
||||
|
||||
async function createApp(db: Db, actor: Express.Request["actor"]) {
|
||||
const { issueRoutes } = await import("../routes/issues.js");
|
||||
const { projectRoutes } = await import("../routes/projects.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -51,6 +54,7 @@ async function createApp(db: Db, actor: Express.Request["actor"]) {
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes(db, {} as any));
|
||||
app.use("/api", projectRoutes(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" });
|
||||
});
|
||||
@@ -75,6 +79,8 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes",
|
||||
await db.delete(issueAccessGrants);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(projectAccessMembers);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(authUsers);
|
||||
@@ -144,6 +150,12 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes",
|
||||
},
|
||||
},
|
||||
]).returning();
|
||||
const privateProject = await db.insert(projects).values({
|
||||
companyId,
|
||||
name: "Secret launch project",
|
||||
visibility: "private",
|
||||
status: "planned",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const privateIssueId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: privateIssueId,
|
||||
@@ -152,6 +164,7 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes",
|
||||
title: "Confidential root",
|
||||
status: "backlog",
|
||||
priority: "medium",
|
||||
projectId: privateProject.id,
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: ownerId,
|
||||
@@ -164,6 +177,7 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes",
|
||||
title: "Confidential child",
|
||||
status: "backlog",
|
||||
priority: "medium",
|
||||
projectId: privateProject.id,
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: ownerId,
|
||||
@@ -184,6 +198,7 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes",
|
||||
sharedUserId,
|
||||
assignedAgent: assignedAgent!,
|
||||
sharedAgent: sharedAgent!,
|
||||
privateProject,
|
||||
privateIssueId,
|
||||
privateChild,
|
||||
openIssue,
|
||||
@@ -296,6 +311,58 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes",
|
||||
expect(createdActivities.filter((row) => row.action === "issue_access_grant.created")).toHaveLength(2);
|
||||
}, 20_000);
|
||||
|
||||
it("keeps private project metadata hidden from user and agent issue grantees", async () => {
|
||||
const fixture = await seedFixture();
|
||||
await db.insert(issueAccessGrants).values([
|
||||
{
|
||||
issueId: fixture.privateIssueId,
|
||||
subjectType: "user",
|
||||
subjectId: fixture.sharedUserId,
|
||||
source: "explicit",
|
||||
grantedByUserId: fixture.ownerId,
|
||||
},
|
||||
{
|
||||
issueId: fixture.privateIssueId,
|
||||
subjectType: "agent",
|
||||
subjectId: fixture.sharedAgent.id,
|
||||
source: "explicit",
|
||||
grantedByUserId: fixture.ownerId,
|
||||
},
|
||||
]);
|
||||
|
||||
const granteeApps = [
|
||||
await createApp(db, boardActor(fixture.companyId, fixture.sharedUserId)),
|
||||
await createApp(db, agentActor(fixture.companyId, fixture.sharedAgent.id)),
|
||||
];
|
||||
|
||||
for (const app of granteeApps) {
|
||||
const detail = await request(app).get(`/api/issues/${fixture.privateIssueId}`);
|
||||
expect(detail.status, JSON.stringify(detail.body)).toBe(200);
|
||||
expect(detail.body.projectId).toBe(fixture.privateProject.id);
|
||||
expect(detail.body.project).toBeNull();
|
||||
|
||||
const heartbeatContext = await request(app)
|
||||
.get(`/api/issues/${fixture.privateIssueId}/heartbeat-context`);
|
||||
expect(heartbeatContext.status, JSON.stringify(heartbeatContext.body)).toBe(200);
|
||||
expect(heartbeatContext.body.issue.projectId).toBe(fixture.privateProject.id);
|
||||
expect(heartbeatContext.body.project).toBeNull();
|
||||
|
||||
await request(app).get(`/api/projects/${fixture.privateProject.id}`).expect(404);
|
||||
|
||||
const projectList = await request(app)
|
||||
.get(`/api/companies/${fixture.companyId}/projects`);
|
||||
expect(projectList.status, JSON.stringify(projectList.body)).toBe(200);
|
||||
expect(projectList.body.map((project: { id: string }) => project.id))
|
||||
.not.toContain(fixture.privateProject.id);
|
||||
|
||||
const projectSearch = await request(app)
|
||||
.get(`/api/companies/${fixture.companyId}/search?q=Secret%20launch&scope=projects`);
|
||||
expect(projectSearch.status, JSON.stringify(projectSearch.body)).toBe(200);
|
||||
expect(projectSearch.body.results.map((project: { id: string }) => project.id))
|
||||
.not.toContain(fixture.privateProject.id);
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("returns exact locked stubs only through visible blocker and mention edges", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const visibleIssue = await db.insert(issues).values({
|
||||
|
||||
@@ -3500,6 +3500,14 @@ export function issueRoutes(
|
||||
}).then((decision) => decision.allowed);
|
||||
}
|
||||
|
||||
async function visibleIssueProject<T extends { id: string; companyId: string }>(
|
||||
req: Request,
|
||||
project: T | null,
|
||||
): Promise<T | null> {
|
||||
if (!project) return null;
|
||||
return (await canActorReadProject(req, project)) ? project : null;
|
||||
}
|
||||
|
||||
async function assertCanManageIssuePrivacy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -5592,6 +5600,7 @@ export function issueRoutes(
|
||||
currentExecutionWorkspacePromise,
|
||||
recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id),
|
||||
]);
|
||||
const visibleProject = await visibleIssueProject(req, project);
|
||||
const recoveryActionsByRelationIssue = await relationRecoveryActionMap(
|
||||
recoveryActionsSvc,
|
||||
issue.companyId,
|
||||
@@ -5658,12 +5667,12 @@ export function issueRoutes(
|
||||
status: ancestor.status,
|
||||
priority: ancestor.priority,
|
||||
})),
|
||||
project: project
|
||||
project: visibleProject
|
||||
? {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
status: project.status,
|
||||
targetDate: project.targetDate,
|
||||
id: visibleProject.id,
|
||||
name: visibleProject.name,
|
||||
status: visibleProject.status,
|
||||
targetDate: visibleProject.targetDate,
|
||||
}
|
||||
: null,
|
||||
goal: goal
|
||||
@@ -5887,9 +5896,7 @@ export function issueRoutes(
|
||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||
: [];
|
||||
const [visibleProject, mentionedProjectVisibility] = await Promise.all([
|
||||
project
|
||||
? canActorReadProject(req, project).then((allowed) => allowed ? project : null)
|
||||
: Promise.resolve(null),
|
||||
visibleIssueProject(req, project),
|
||||
Promise.all(mentionedProjects.map((mentionedProject) => canActorReadProject(req, mentionedProject))),
|
||||
]);
|
||||
const visibleMentionedProjects = mentionedProjects.filter((_, index) => mentionedProjectVisibility[index]);
|
||||
|
||||
Reference in new issue
Block a user