fix(security): scope work product creation
This commit is contained in:
1 parent
d1a3d23c70
commit
51d468e4c3
2 files changed
+135
-17
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in new issue
Block a user