From a6b7b12fd779de59d3a2fd8663fb85866fb2e432 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 3 Jul 2026 05:19:14 -0500 Subject: [PATCH] Harden work timeline security filters (#8923) Squash merge PR #8923. Verified before merge: - PR head: 1d9a8f22916c65b55490571356b64fb87285de6f - GitHub status/check rollup: all completed successfully - Greptile Review: success, 5/5, no review threads - Scope: server/src/services/work-timeline.ts and server/src/__tests__/work-timeline-service.test.ts --- .../__tests__/work-timeline-service.test.ts | 100 ++++++++++++++++++ server/src/services/work-timeline.ts | 45 +++++--- 2 files changed, 131 insertions(+), 14 deletions(-) diff --git a/server/src/__tests__/work-timeline-service.test.ts b/server/src/__tests__/work-timeline-service.test.ts index 160d063f5d..8825b805e1 100644 --- a/server/src/__tests__/work-timeline-service.test.ts +++ b/server/src/__tests__/work-timeline-service.test.ts @@ -253,6 +253,72 @@ describeEmbeddedPostgres("work timeline aggregation", () => { ])); }); + it("does not join activity rows to runs from another company", async () => { + const { companyId, agentAId } = await seedBase(); + const otherCompanyId = randomUUID(); + const otherAgentId = randomUUID(); + const issueId = randomUUID(); + const foreignRunId = randomUUID(); + + await db.insert(companies).values({ + id: otherCompanyId, + name: "Other Timeline Co", + issuePrefix: `O${randomUUID().replace(/-/g, "").slice(0, 4).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: otherAgentId, + companyId: otherCompanyId, + name: "Foreign Coder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Local issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentAId, + createdAt: new Date("2026-03-02T10:00:00Z"), + updatedAt: new Date("2026-03-02T10:00:00Z"), + }); + await db.insert(heartbeatRuns).values({ + id: foreignRunId, + companyId: otherCompanyId, + agentId: otherAgentId, + status: "completed", + invocationSource: "manual", + startedAt: new Date("2026-03-02T11:00:00Z"), + finishedAt: new Date("2026-03-02T11:15:00Z"), + contextSnapshot: {}, + }); + await db.insert(activityLog).values({ + companyId, + actorType: "agent", + actorId: agentAId, + action: "issue.updated", + entityType: "issue", + entityId: issueId, + agentId: agentAId, + runId: foreignRunId, + createdAt: new Date("2026-03-02T11:05:00Z"), + }); + + const result = await workTimelineService(db).getTimeline({ + companyId, + from: new Date("2026-03-02T00:00:00Z"), + to: new Date("2026-03-03T00:00:00Z"), + }); + + expect(result.events.map((event) => event.issueId)).toContain(issueId); + expect(result.spans.map((span) => span.runId)).not.toContain(foreignRunId); + }); + it("applies the user lens as a transitive issue subtree", async () => { const { companyId, userId, agentAId, agentBId } = await seedBase(); const rootIssueId = randomUUID(); @@ -345,6 +411,40 @@ describeEmbeddedPostgres("work timeline aggregation", () => { expect(result.pagination.totalIssues).toBe(1); }); + it("bounds pre-pagination ACL checks", async () => { + const { companyId, agentAId } = await seedBase(); + const issueCount = 40; + await db.insert(issues).values(Array.from({ length: issueCount }, (_, index) => ({ + id: randomUUID(), + companyId, + title: `Visible ${index}`, + status: "todo", + priority: "medium", + assigneeAgentId: agentAId, + createdAt: new Date(Date.parse("2026-03-04T10:00:00Z") + index * 1000), + updatedAt: new Date(Date.parse("2026-03-04T10:00:00Z") + index * 1000), + }))); + + let activeChecks = 0; + let maxActiveChecks = 0; + const result = await workTimelineService(db).getTimeline({ + companyId, + from: new Date("2026-03-04T00:00:00Z"), + to: new Date("2026-03-05T00:00:00Z"), + limit: issueCount, + canReadIssue: async () => { + activeChecks += 1; + maxActiveChecks = Math.max(maxActiveChecks, activeChecks); + await new Promise((resolve) => setTimeout(resolve, 1)); + activeChecks -= 1; + return true; + }, + }); + + expect(result.pagination.totalIssues).toBe(issueCount); + expect(maxActiveChecks).toBeLessThanOrEqual(16); + }); + it("serves GET /api/companies/:companyId/timeline", async () => { const { companyId, agentAId } = await seedBase(); const issueId = randomUUID(); diff --git a/server/src/services/work-timeline.ts b/server/src/services/work-timeline.ts index 1c32baa70e..56756b6a57 100644 --- a/server/src/services/work-timeline.ts +++ b/server/src/services/work-timeline.ts @@ -78,6 +78,7 @@ const MAX_LIMIT = 500; const MAX_WINDOW_MS = 31 * 24 * 60 * 60 * 1000; const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; const MAX_SOURCE_ROWS = 5_000; +const ACL_FILTER_CONCURRENCY = 16; function actorId(type: TimelineActorType, id: string) { return `${type}:${id}`; @@ -132,6 +133,34 @@ function runOverlapsWindow(from: Date, to: Date) { } export function workTimelineService(db: Db) { + async function filterReadableIssues( + rows: IssueRow[], + canReadIssue: NonNullable | undefined, + ) { + if (!canReadIssue) return rows; + + const allowedRows: IssueRow[] = []; + for (let index = 0; index < rows.length; index += ACL_FILTER_CONCURRENCY) { + const batch = rows.slice(index, index + ACL_FILTER_CONCURRENCY); + const decisions = await Promise.all(batch.map(async (issue) => ({ + issue, + allowed: await canReadIssue({ + id: issue.id, + companyId: issue.companyId, + projectId: issue.projectId, + parentId: issue.parentId, + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, + status: issue.status, + }), + }))); + for (const decision of decisions) { + if (decision.allowed) allowedRows.push(decision.issue); + } + } + return allowedRows; + } + async function collectIssueIds(input: WorkTimelineQuery, from: Date, to: Date) { const ids = new Set(); @@ -403,20 +432,7 @@ export function workTimelineService(db: Db) { const candidateIssueIds = await collectIssueIds(input, from, to); const loadedIssues = await loadIssues(input, candidateIssueIds); const userScopedIssues = await applyUserLens(input, loadedIssues, from, to); - const accessibleIssues = input.canReadIssue - ? (await Promise.all(userScopedIssues.map(async (issue) => ({ - issue, - allowed: await input.canReadIssue?.({ - id: issue.id, - companyId: issue.companyId, - projectId: issue.projectId, - parentId: issue.parentId, - assigneeAgentId: issue.assigneeAgentId, - assigneeUserId: issue.assigneeUserId, - status: issue.status, - }), - })))).filter((entry) => entry.allowed).map((entry) => entry.issue) - : userScopedIssues; + const accessibleIssues = await filterReadableIssues(userScopedIssues, input.canReadIssue); const sortedIssues = accessibleIssues.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()); const pagedIssues = sortedIssues.slice(offset, offset + limit); const issueById = new Map(pagedIssues.map((issue) => [issue.id, issue])); @@ -519,6 +535,7 @@ export function workTimelineService(db: Db) { .where( and( eq(activityLog.companyId, input.companyId), + eq(heartbeatRuns.companyId, input.companyId), eq(activityLog.entityType, "issue"), inArray(activityLog.entityId, readableIssueIds), runOverlapsWindow(from, to),