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:
parent
c48feee190
commit
a6b7b12fd7
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue