Harden work timeline security filters (#8923)

Squash merge PR #8923.

Verified before merge:
- PR head: 1d9a8f2291
- 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
This commit is contained in:
Dotta 2026-07-03 05:19:14 -05:00 committed by GitHub
parent c48feee190
commit a6b7b12fd7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 131 additions and 14 deletions

View File

@ -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();

View File

@ -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<WorkTimelineQuery["canReadIssue"]> | 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<string>();
@ -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),