From 51d468e4c32cf7199b25ed45b37c262da69ba95d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 15 May 2026 00:00:04 -0400 Subject: [PATCH] fix(security): scope work product creation --- src/main/api-server/routes-files.ts | 61 ++++++++++--- src/main/database/documents-work-products.ts | 91 ++++++++++++++++++-- 2 files changed, 135 insertions(+), 17 deletions(-) diff --git a/src/main/api-server/routes-files.ts b/src/main/api-server/routes-files.ts index 61166623..1326028b 100644 --- a/src/main/api-server/routes-files.ts +++ b/src/main/api-server/routes-files.ts @@ -8,6 +8,7 @@ import { } from "@shared/contracts"; import { requireParam, type AgentApiServer } from "./server-core"; import { normalizeExternalHttpUrl } from "../external-url-policy"; +import { WorkProductScopeError } from "../database/documents-work-products"; const workProductUrlSchema = z.string().trim().superRefine((value, ctx) => { try { @@ -73,6 +74,30 @@ const linkDocumentSchema = z.object({ documentId: z.string(), }); +function writeWorkProductCreateError( + server: AgentApiServer, + res: Parameters[0], + error: unknown, +): boolean { + if (error instanceof WorkProductScopeError) { + server.json(res, 422, { error: error.message }); + return true; + } + const message = error instanceof Error ? error.message : String(error); + if (/UNIQUE constraint failed/i.test(message)) { + const conflict = message.includes("work_products.company_id, work_products.task_id, work_products.type") + ? "Work product primary selection conflicts with an existing active primary" + : "Work product identity conflicts with an existing artifact"; + server.json(res, 409, { error: conflict }); + return true; + } + if (/constraint failed/i.test(message)) { + server.json(res, 422, { error: "Work product violates a database constraint" }); + return true; + } + return false; +} + export function registerFileRoutes(server: AgentApiServer) { const db = server.db; const callbacks = server.callbacks; @@ -257,16 +282,23 @@ export function registerFileRoutes(server: AgentApiServer) { }); server.addRoute("POST", "/api/issues/:issueId/work-products", (_req, res, params, token, body) => { + let task: ReturnType; + try { + task = db.core.entity.getTask(requireParam(params, "issueId")); + } catch (error) { + log.warn(`[api/work-products] task lookup failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`); + server.json(res, 404, { error: "Task not found" }); + return; + } + if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; } + const parsed = createWorkProductSchema.safeParse(body); + if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; } + const data = parsed.data; try { - const task = db.core.entity.getTask(requireParam(params, "issueId")); - if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; } - const parsed = createWorkProductSchema.safeParse(body); - if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; } - const data = parsed.data; const id = db.core.documents.createWorkProduct({ companyId: task.companyId, taskId: task.id, - projectId: data.projectId ?? task.projectId, + projectId: data.projectId, workspaceId: task.workspaceId ?? null, type: data.type, provider: data.provider, @@ -283,8 +315,9 @@ export function registerFileRoutes(server: AgentApiServer) { onDomainChanged(); server.json(res, 201, { id }); } catch (error) { + if (writeWorkProductCreateError(server, res, error)) return; log.warn(`[api/work-products] create failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`); - server.json(res, 404, { error: "Task not found" }); + server.json(res, 500, { error: "Work product could not be created" }); } }); @@ -293,10 +326,16 @@ export function registerFileRoutes(server: AgentApiServer) { if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; } const parsed = updateWorkProductSchema.safeParse(body); if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; } - const changes = db.core.documents.updateWorkProduct(wpId, token.companyId, parsed.data); - if (changes === 0) { server.json(res, 404, { error: "Work product not found" }); return; } - onDomainChanged(); - server.json(res, 200, { updated: true }); + try { + const changes = db.core.documents.updateWorkProduct(wpId, token.companyId, parsed.data); + if (changes === 0) { server.json(res, 404, { error: "Work product not found" }); return; } + onDomainChanged(); + server.json(res, 200, { updated: true }); + } catch (error) { + if (writeWorkProductCreateError(server, res, error)) return; + log.warn(`[api/work-products] update failed for work product ${wpId}: ${error instanceof Error ? error.message : String(error)}`); + server.json(res, 500, { error: "Work product could not be updated" }); + } }); server.addRoute("DELETE", "/api/work-products/:workProductId", (_req, res, params, token) => { diff --git a/src/main/database/documents-work-products.ts b/src/main/database/documents-work-products.ts index 88881989..669da673 100644 --- a/src/main/database/documents-work-products.ts +++ b/src/main/database/documents-work-products.ts @@ -13,7 +13,7 @@ import { type WorkProductType, } from "@shared/contracts"; import type { ReadAll, Transaction } from "./helpers"; -import { nowIso } from "./helpers"; +import { normalizeOptionalId, nowIso } from "./helpers"; function resolveWorkspaceRoot( getDb: () => DatabaseSync, @@ -99,6 +99,77 @@ function parseWorkProductReviewState(value: string): WorkProductReviewState { return workProductReviewStateEnum.parse(value); } +export class WorkProductScopeError extends Error { + constructor( + public readonly reference: "task" | "project" | "workspace", + message: string, + ) { + super(message); + this.name = "WorkProductScopeError"; + } +} + +function resolveWorkProductScope( + getDb: () => DatabaseSync, + input: { + companyId: string; + taskId?: string | null; + projectId?: string | null; + workspaceId?: string | null; + }, +): { taskId: string | null; projectId: string | null; workspaceId: string | null } { + const taskId = normalizeOptionalId(input.taskId); + let taskProjectId: string | null = null; + let taskWorkspaceId: string | null = null; + if (taskId) { + const task = getDb() + .prepare("SELECT project_id AS projectId, workspace_id AS workspaceId FROM tasks WHERE id = ? AND company_id = ? LIMIT 1") + .get(taskId, input.companyId) as { projectId: string | null; workspaceId: string | null } | undefined; + if (!task) { + throw new WorkProductScopeError("task", "Work product task does not belong to the current company"); + } + taskProjectId = task.projectId ?? null; + taskWorkspaceId = task.workspaceId ?? null; + } + + let projectId = input.projectId === undefined + ? taskProjectId + : normalizeOptionalId(input.projectId); + if (taskId && projectId !== taskProjectId) { + throw new WorkProductScopeError("project", "Work product project must match the task project"); + } + if (taskId) { + projectId = taskProjectId; + } + if (projectId) { + const project = getDb() + .prepare("SELECT id FROM projects WHERE id = ? AND company_id = ? LIMIT 1") + .get(projectId, input.companyId) as { id: string } | undefined; + if (!project) { + throw new WorkProductScopeError("project", "Work product project does not belong to the current company"); + } + } + + const requestedWorkspaceId = normalizeOptionalId(input.workspaceId); + if (taskWorkspaceId && requestedWorkspaceId && requestedWorkspaceId !== taskWorkspaceId) { + throw new WorkProductScopeError("workspace", "Work product workspace must match the task workspace"); + } + const workspaceId = taskWorkspaceId ?? requestedWorkspaceId; + if (workspaceId) { + const workspace = getDb() + .prepare("SELECT id, project_id AS projectId FROM workspaces WHERE id = ? AND company_id = ? LIMIT 1") + .get(workspaceId, input.companyId) as { id: string; projectId: string | null } | undefined; + if (!workspace) { + throw new WorkProductScopeError("workspace", "Work product workspace does not belong to the current company"); + } + if (projectId && workspace.projectId !== projectId) { + throw new WorkProductScopeError("workspace", "Work product workspace project must match the work product project"); + } + } + + return { taskId, projectId, workspaceId }; +} + export function buildWorkProductMethods( getDb: () => DatabaseSync, readAll: ReadAll, @@ -116,21 +187,22 @@ export function buildWorkProductMethods( return transaction(() => { const id = randomUUID(); const ts = nowIso(); + const scope = resolveWorkProductScope(getDb, input); const type = parseWorkProductType(input.type); const provider = parseWorkProductProvider(input.provider); const status = input.status !== undefined ? parseWorkProductStatus(input.status) : "active"; const reviewState = input.reviewState !== undefined ? parseWorkProductReviewState(input.reviewState) : "none"; - if (input.isPrimary && input.taskId && status !== "archived") { + if (input.isPrimary && scope.taskId && status !== "archived") { getDb().prepare( "UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ? AND status <> 'archived'", - ).run(ts, input.companyId, input.taskId, type); + ).run(ts, input.companyId, scope.taskId, type); } getDb().prepare( `INSERT INTO work_products (id, company_id, project_id, task_id, workspace_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, last_touched_by_run_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( - id, input.companyId, input.projectId ?? null, input.taskId ?? null, - input.workspaceId ?? null, + id, input.companyId, scope.projectId, scope.taskId, + scope.workspaceId, type, provider, input.externalId ?? null, input.title, input.url ?? null, status, reviewState, input.isPrimary ? 1 : 0, @@ -199,6 +271,13 @@ export function buildWorkProductMethods( const nextStatus = patch.status !== undefined ? parseWorkProductStatus(patch.status) : parseWorkProductStatus(current.status); const parsedProvider = patch.provider !== undefined ? parseWorkProductProvider(patch.provider) : undefined; const parsedReviewState = patch.reviewState !== undefined ? parseWorkProductReviewState(patch.reviewState) : undefined; + const parsedWorkspaceId = patch.workspaceId !== undefined + ? resolveWorkProductScope(getDb, { + companyId, + taskId: current.taskId, + workspaceId: patch.workspaceId, + }).workspaceId + : undefined; const nextIsPrimary = patch.isPrimary ?? Boolean(current.isPrimary); if (nextIsPrimary && current.taskId && nextStatus !== "archived") { getDb().prepare( @@ -212,7 +291,7 @@ export function buildWorkProductMethods( ["type", "type", () => nextType], ["provider", "provider", () => parsedProvider ?? null], ["externalId", "external_id", (v) => (v as string | null) ?? null], - ["workspaceId", "workspace_id", (v) => (v as string | null) ?? null], + ["workspaceId", "workspace_id", () => parsedWorkspaceId ?? null], ["title", "title", (v) => (v as string) ?? null], ["url", "url", (v) => (v as string | null) ?? null], ["status", "status", () => nextStatus],