fix(security): scope work product creation

This commit is contained in:
ZacharyZhang-NY committed 2026-05-15 00:00:04 -04:00
1 parent d1a3d23c70
commit 51d468e4c3
2 files changed
+135 -17

No files matched your search

+50 -11
View File
@@ -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<AgentApiServer["json"]>[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<typeof db.core.entity.getTask>;
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) => {
+85 -6
View File
@@ -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],