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"; } from "@shared/contracts";
import { requireParam, type AgentApiServer } from "./server-core"; import { requireParam, type AgentApiServer } from "./server-core";
import { normalizeExternalHttpUrl } from "../external-url-policy"; import { normalizeExternalHttpUrl } from "../external-url-policy";
import { WorkProductScopeError } from "../database/documents-work-products";
const workProductUrlSchema = z.string().trim().superRefine((value, ctx) => { const workProductUrlSchema = z.string().trim().superRefine((value, ctx) => {
try { try {
@@ -73,6 +74,30 @@ const linkDocumentSchema = z.object({
documentId: z.string(), 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) { export function registerFileRoutes(server: AgentApiServer) {
const db = server.db; const db = server.db;
const callbacks = server.callbacks; 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) => { 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 { 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({ const id = db.core.documents.createWorkProduct({
companyId: task.companyId, companyId: task.companyId,
taskId: task.id, taskId: task.id,
projectId: data.projectId ?? task.projectId, projectId: data.projectId,
workspaceId: task.workspaceId ?? null, workspaceId: task.workspaceId ?? null,
type: data.type, type: data.type,
provider: data.provider, provider: data.provider,
@@ -283,8 +315,9 @@ export function registerFileRoutes(server: AgentApiServer) {
onDomainChanged(); onDomainChanged();
server.json(res, 201, { id }); server.json(res, 201, { id });
} catch (error) { } 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)}`); 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; } if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; }
const parsed = updateWorkProductSchema.safeParse(body); const parsed = updateWorkProductSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; } 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); try {
if (changes === 0) { server.json(res, 404, { error: "Work product not found" }); return; } const changes = db.core.documents.updateWorkProduct(wpId, token.companyId, parsed.data);
onDomainChanged(); if (changes === 0) { server.json(res, 404, { error: "Work product not found" }); return; }
server.json(res, 200, { updated: true }); 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) => { server.addRoute("DELETE", "/api/work-products/:workProductId", (_req, res, params, token) => {
+85 -6
View File
@@ -13,7 +13,7 @@ import {
type WorkProductType, type WorkProductType,
} from "@shared/contracts"; } from "@shared/contracts";
import type { ReadAll, Transaction } from "./helpers"; import type { ReadAll, Transaction } from "./helpers";
import { nowIso } from "./helpers"; import { normalizeOptionalId, nowIso } from "./helpers";
function resolveWorkspaceRoot( function resolveWorkspaceRoot(
getDb: () => DatabaseSync, getDb: () => DatabaseSync,
@@ -99,6 +99,77 @@ function parseWorkProductReviewState(value: string): WorkProductReviewState {
return workProductReviewStateEnum.parse(value); 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( export function buildWorkProductMethods(
getDb: () => DatabaseSync, getDb: () => DatabaseSync,
readAll: ReadAll, readAll: ReadAll,
@@ -116,21 +187,22 @@ export function buildWorkProductMethods(
return transaction(() => { return transaction(() => {
const id = randomUUID(); const id = randomUUID();
const ts = nowIso(); const ts = nowIso();
const scope = resolveWorkProductScope(getDb, input);
const type = parseWorkProductType(input.type); const type = parseWorkProductType(input.type);
const provider = parseWorkProductProvider(input.provider); const provider = parseWorkProductProvider(input.provider);
const status = input.status !== undefined ? parseWorkProductStatus(input.status) : "active"; const status = input.status !== undefined ? parseWorkProductStatus(input.status) : "active";
const reviewState = input.reviewState !== undefined ? parseWorkProductReviewState(input.reviewState) : "none"; 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( getDb().prepare(
"UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ? AND status <> 'archived'", "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( 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) `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run( ).run(
id, input.companyId, input.projectId ?? null, input.taskId ?? null, id, input.companyId, scope.projectId, scope.taskId,
input.workspaceId ?? null, scope.workspaceId,
type, provider, input.externalId ?? null, type, provider, input.externalId ?? null,
input.title, input.url ?? null, status, input.title, input.url ?? null, status,
reviewState, input.isPrimary ? 1 : 0, reviewState, input.isPrimary ? 1 : 0,
@@ -199,6 +271,13 @@ export function buildWorkProductMethods(
const nextStatus = patch.status !== undefined ? parseWorkProductStatus(patch.status) : parseWorkProductStatus(current.status); const nextStatus = patch.status !== undefined ? parseWorkProductStatus(patch.status) : parseWorkProductStatus(current.status);
const parsedProvider = patch.provider !== undefined ? parseWorkProductProvider(patch.provider) : undefined; const parsedProvider = patch.provider !== undefined ? parseWorkProductProvider(patch.provider) : undefined;
const parsedReviewState = patch.reviewState !== undefined ? parseWorkProductReviewState(patch.reviewState) : 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); const nextIsPrimary = patch.isPrimary ?? Boolean(current.isPrimary);
if (nextIsPrimary && current.taskId && nextStatus !== "archived") { if (nextIsPrimary && current.taskId && nextStatus !== "archived") {
getDb().prepare( getDb().prepare(
@@ -212,7 +291,7 @@ export function buildWorkProductMethods(
["type", "type", () => nextType], ["type", "type", () => nextType],
["provider", "provider", () => parsedProvider ?? null], ["provider", "provider", () => parsedProvider ?? null],
["externalId", "external_id", (v) => (v as string | null) ?? 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], ["title", "title", (v) => (v as string) ?? null],
["url", "url", (v) => (v as string | null) ?? null], ["url", "url", (v) => (v as string | null) ?? null],
["status", "status", () => nextStatus], ["status", "status", () => nextStatus],