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";
|
} 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) => {
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
Reference in new issue
Block a user