From 41e1dec6479c4e97ab5a68e1e5fdb4542da03db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 14 May 2026 23:28:29 -0400 Subject: [PATCH] fix(security): enforce company scoped updates --- src/main/api-server/routes-files.ts | 4 +-- src/main/database/automation.ts | 24 ++++++++++---- src/main/database/documents.ts | 21 +++++++----- src/main/database/knowledge.ts | 33 ++++++++++++++----- .../ipc-automation-operations-handlers.ts | 6 ++++ .../document-handlers.ts | 3 ++ .../knowledge-entry-handlers.ts | 3 ++ .../meeting-handlers.ts | 3 ++ .../ipc-company-operations/sprint-handlers.ts | 3 ++ 9 files changed, 74 insertions(+), 26 deletions(-) diff --git a/src/main/api-server/routes-files.ts b/src/main/api-server/routes-files.ts index 6f5b4dcf..61166623 100644 --- a/src/main/api-server/routes-files.ts +++ b/src/main/api-server/routes-files.ts @@ -183,7 +183,7 @@ export function registerFileRoutes(server: AgentApiServer) { // Conflict detection: if baseRevisionId provided, verify it matches current if (data.baseRevisionId !== undefined) { - const currentRevId = db.core.documents.getDocumentLatestRevisionId(documentId); + const currentRevId = db.core.documents.getDocumentLatestRevisionId(documentId, token.companyId); if (currentRevId && data.baseRevisionId !== currentRevId) { server.json(res, 409, { error: "Document was updated by someone else", currentRevisionId: currentRevId }); return; @@ -231,7 +231,7 @@ export function registerFileRoutes(server: AgentApiServer) { }); onDomainChanged(); - server.json(res, 200, { updated: true, latestRevisionId: db.core.documents.getDocumentLatestRevisionId(documentId) }); + server.json(res, 200, { updated: true, latestRevisionId: db.core.documents.getDocumentLatestRevisionId(documentId, token.companyId) }); }); server.addRoute("GET", "/api/companies/:companyId/documents/:documentId/revisions", (_req, res, params, token) => { diff --git a/src/main/database/automation.ts b/src/main/database/automation.ts index 11b7fea9..f3f0139b 100644 --- a/src/main/database/automation.ts +++ b/src/main/database/automation.ts @@ -20,11 +20,16 @@ export function buildAutomationMethods( }) { const id = ensureId(input.id ?? null); const ts = nowIso(); - const existing = getDb().prepare("select id from automation_rules where id = ?").get(id) as { id: string } | undefined; + const existing = getDb().prepare( + "select company_id as companyId from automation_rules where id = ?", + ).get(id) as { companyId: string } | undefined; + if (existing && existing.companyId !== input.companyId) { + throw new Error(`Automation rule ${id} does not belong to company ${input.companyId}`); + } if (existing) { getDb().prepare( - "update automation_rules set name = ?, description = ?, trigger = ?, conditions_json = ?, action = ?, action_config_json = ?, source_department = ?, target_department = ?, priority = ?, status = ?, updated_at = ? where id = ?", - ).run(input.name, input.description ?? "", input.trigger, input.conditionsJson ?? "{}", input.action, input.actionConfigJson ?? "{}", input.sourceDepartment ?? null, input.targetDepartment ?? null, input.priority ?? 50, input.status ?? "active", ts, id); + "update automation_rules set name = ?, description = ?, trigger = ?, conditions_json = ?, action = ?, action_config_json = ?, source_department = ?, target_department = ?, priority = ?, status = ?, updated_at = ? where id = ? and company_id = ?", + ).run(input.name, input.description ?? "", input.trigger, input.conditionsJson ?? "{}", input.action, input.actionConfigJson ?? "{}", input.sourceDepartment ?? null, input.targetDepartment ?? null, input.priority ?? 50, input.status ?? "active", ts, id, input.companyId); } else { getDb().prepare( "insert into automation_rules (id, company_id, name, description, trigger, conditions_json, action, action_config_json, source_department, target_department, priority, status, execution_count, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)", @@ -55,7 +60,7 @@ export function buildAutomationMethods( const id = randomUUID(); const ts = nowIso(); getDb().prepare("insert into automation_log (id, company_id, rule_id, rule_name, trigger, action, detail, executed_at) values (?, ?, ?, ?, ?, ?, ?, ?)").run(id, companyId, ruleId, ruleName, trigger, action, detail, ts); - getDb().prepare("update automation_rules set execution_count = execution_count + 1, last_executed_at = ? where id = ?").run(ts, ruleId); + getDb().prepare("update automation_rules set execution_count = execution_count + 1, last_executed_at = ? where id = ? and company_id = ?").run(ts, ruleId, companyId); }, getAutomationLog(companyId: string) { @@ -82,11 +87,16 @@ export function buildAutomationMethods( }) { const id = ensureId(input.id ?? null); const ts = nowIso(); - const existing = getDb().prepare("select id from workflow_pipelines where id = ?").get(id) as { id: string } | undefined; + const existing = getDb().prepare( + "select company_id as companyId from workflow_pipelines where id = ?", + ).get(id) as { companyId: string } | undefined; + if (existing && existing.companyId !== input.companyId) { + throw new Error(`Workflow ${id} does not belong to company ${input.companyId}`); + } if (existing) { getDb().prepare( - "update workflow_pipelines set name = ?, description = ?, steps_json = ?, trigger_type = ?, trigger_config_json = ?, status = ?, updated_at = ? where id = ?", - ).run(input.name, input.description ?? "", input.stepsJson ?? "[]", input.triggerType, input.triggerConfigJson ?? "{}", input.status ?? "draft", ts, id); + "update workflow_pipelines set name = ?, description = ?, steps_json = ?, trigger_type = ?, trigger_config_json = ?, status = ?, updated_at = ? where id = ? and company_id = ?", + ).run(input.name, input.description ?? "", input.stepsJson ?? "[]", input.triggerType, input.triggerConfigJson ?? "{}", input.status ?? "draft", ts, id, input.companyId); } else { getDb().prepare( "insert into workflow_pipelines (id, company_id, name, description, steps_json, trigger_type, trigger_config_json, status, current_step_index, run_count, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)", diff --git a/src/main/database/documents.ts b/src/main/database/documents.ts index 9d0a8d13..6a692310 100644 --- a/src/main/database/documents.ts +++ b/src/main/database/documents.ts @@ -64,11 +64,16 @@ export function buildDocumentMethods( }) { const id = ensureId(input.id ?? null); const ts = nowIso(); - const existing = getDb().prepare("select id from documents where id = ?").get(id) as { id: string } | undefined; + const existing = getDb().prepare( + "select company_id as companyId from documents where id = ?", + ).get(id) as { companyId: string } | undefined; + if (existing && existing.companyId !== input.companyId) { + throw new Error(`Document ${id} does not belong to company ${input.companyId}`); + } if (existing) { getDb().prepare( - "update documents set type = ?, title = ?, content = ?, author_agent_id = ?, reviewer_agent_id = ?, project_id = ?, goal_id = ?, parent_doc_id = ?, version = ?, status = ?, tags_json = ?, source_run_id = coalesce(?, source_run_id), updated_at = ? where id = ?", - ).run(input.type, input.title, input.content ?? "", input.authorAgentId ?? null, input.reviewerAgentId ?? null, input.projectId ?? null, input.goalId ?? null, input.parentDocId ?? null, input.version ?? 1, input.status ?? "draft", input.tagsJson ?? "[]", input.sourceRunId ?? null, ts, id); + "update documents set type = ?, title = ?, content = ?, author_agent_id = ?, reviewer_agent_id = ?, project_id = ?, goal_id = ?, parent_doc_id = ?, version = ?, status = ?, tags_json = ?, source_run_id = coalesce(?, source_run_id), updated_at = ? where id = ? and company_id = ?", + ).run(input.type, input.title, input.content ?? "", input.authorAgentId ?? null, input.reviewerAgentId ?? null, input.projectId ?? null, input.goalId ?? null, input.parentDocId ?? null, input.version ?? 1, input.status ?? "draft", input.tagsJson ?? "[]", input.sourceRunId ?? null, ts, id, input.companyId); } else { getDb().prepare( "insert into documents (id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, source_run_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", @@ -129,15 +134,15 @@ export function buildDocumentMethods( "INSERT INTO document_revisions (id, company_id, document_id, revision_number, body, change_summary, created_by_agent_id, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ).run(id, input.companyId, input.documentId, revisionNumber, input.body, input.changeSummary ?? null, input.createdByAgentId ?? null, input.createdByUserId ?? null, ts); getDb().prepare( - "UPDATE documents SET version = ?, latest_revision_id = ?, updated_at = ? WHERE id = ?", - ).run(revisionNumber, id, ts, input.documentId); + "UPDATE documents SET version = ?, latest_revision_id = ?, updated_at = ? WHERE id = ? AND company_id = ?", + ).run(revisionNumber, id, ts, input.documentId, input.companyId); return { id, revisionNumber }; }, - getDocumentLatestRevisionId(documentId: string): string | null { + getDocumentLatestRevisionId(documentId: string, companyId: string): string | null { const row = getDb().prepare( - "SELECT latest_revision_id FROM documents WHERE id = ?", - ).get(documentId) as { latest_revision_id: string | null } | undefined; + "SELECT latest_revision_id FROM documents WHERE id = ? AND company_id = ?", + ).get(documentId, companyId) as { latest_revision_id: string | null } | undefined; return row?.latest_revision_id ?? null; }, diff --git a/src/main/database/knowledge.ts b/src/main/database/knowledge.ts index 151ca1ba..9533b314 100644 --- a/src/main/database/knowledge.ts +++ b/src/main/database/knowledge.ts @@ -95,11 +95,16 @@ export function buildKnowledgeMethods( }) { const id = ensureId(input.id ?? null); const ts = nowIso(); - const existing = getDb().prepare("select id from meetings where id = ?").get(id) as { id: string } | undefined; + const existing = getDb().prepare( + "select company_id as companyId from meetings where id = ?", + ).get(id) as { companyId: string } | undefined; + if (existing && existing.companyId !== input.companyId) { + throw new Error(`Meeting ${id} does not belong to company ${input.companyId}`); + } if (existing) { getDb().prepare( - "update meetings set type = ?, title = ?, organizer_agent_id = ?, participant_agent_ids = ?, scheduled_at = ?, duration_minutes = ?, agenda_json = ?, notes_json = ?, decisions_json = ?, action_items_json = ?, status = ?, updated_at = ? where id = ?", - ).run(input.type, input.title, input.organizerAgentId ?? null, input.participantAgentIds ?? "[]", input.scheduledAt, input.durationMinutes ?? 30, input.agendaJson ?? "[]", input.notesJson ?? "[]", input.decisionsJson ?? "[]", input.actionItemsJson ?? "[]", input.status ?? "scheduled", ts, id); + "update meetings set type = ?, title = ?, organizer_agent_id = ?, participant_agent_ids = ?, scheduled_at = ?, duration_minutes = ?, agenda_json = ?, notes_json = ?, decisions_json = ?, action_items_json = ?, status = ?, updated_at = ? where id = ? and company_id = ?", + ).run(input.type, input.title, input.organizerAgentId ?? null, input.participantAgentIds ?? "[]", input.scheduledAt, input.durationMinutes ?? 30, input.agendaJson ?? "[]", input.notesJson ?? "[]", input.decisionsJson ?? "[]", input.actionItemsJson ?? "[]", input.status ?? "scheduled", ts, id, input.companyId); } else { getDb().prepare( "insert into meetings (id, company_id, type, title, organizer_agent_id, participant_agent_ids, scheduled_at, duration_minutes, agenda_json, notes_json, decisions_json, action_items_json, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", @@ -120,11 +125,16 @@ export function buildKnowledgeMethods( }) { const id = ensureId(input.id ?? null); const ts = nowIso(); - const existing = getDb().prepare("select id from knowledge_base where id = ?").get(id) as { id: string } | undefined; + const existing = getDb().prepare( + "select company_id as companyId from knowledge_base where id = ?", + ).get(id) as { companyId: string } | undefined; + if (existing && existing.companyId !== input.companyId) { + throw new Error(`Knowledge entry ${id} does not belong to company ${input.companyId}`); + } if (existing) { getDb().prepare( - "update knowledge_base set category = ?, topic = ?, content = ?, author_agent_id = ?, importance = ?, tags_json = ?, referenced_entity_type = ?, referenced_entity_id = ?, source_run_id = coalesce(?, source_run_id), updated_at = ? where id = ?", - ).run(input.category, input.topic, input.content, input.authorAgentId ?? null, input.importance ?? "medium", input.tagsJson ?? "[]", input.referencedEntityType ?? null, input.referencedEntityId ?? null, input.sourceRunId ?? null, ts, id); + "update knowledge_base set category = ?, topic = ?, content = ?, author_agent_id = ?, importance = ?, tags_json = ?, referenced_entity_type = ?, referenced_entity_id = ?, source_run_id = coalesce(?, source_run_id), updated_at = ? where id = ? and company_id = ?", + ).run(input.category, input.topic, input.content, input.authorAgentId ?? null, input.importance ?? "medium", input.tagsJson ?? "[]", input.referencedEntityType ?? null, input.referencedEntityId ?? null, input.sourceRunId ?? null, ts, id, input.companyId); } else { getDb().prepare( "insert into knowledge_base (id, company_id, category, topic, content, author_agent_id, importance, tags_json, referenced_entity_type, referenced_entity_id, source_run_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", @@ -155,11 +165,16 @@ export function buildKnowledgeMethods( }) { const id = ensureId(input.id ?? null); const ts = nowIso(); - const existing = getDb().prepare("select id from sprints where id = ?").get(id) as { id: string } | undefined; + const existing = getDb().prepare( + "select company_id as companyId from sprints where id = ?", + ).get(id) as { companyId: string } | undefined; + if (existing && existing.companyId !== input.companyId) { + throw new Error(`Sprint ${id} does not belong to company ${input.companyId}`); + } if (existing) { getDb().prepare( - "update sprints set name = ?, goal = ?, start_date = ?, end_date = ?, status = ?, retrospective_notes = ?, velocity_points = ?, completed_points = ?, updated_at = ? where id = ?", - ).run(input.name, input.goal ?? "", input.startDate, input.endDate, input.status ?? "planning", input.retrospectiveNotes ?? "", input.velocityPoints ?? 0, input.completedPoints ?? 0, ts, id); + "update sprints set name = ?, goal = ?, start_date = ?, end_date = ?, status = ?, retrospective_notes = ?, velocity_points = ?, completed_points = ?, updated_at = ? where id = ? and company_id = ?", + ).run(input.name, input.goal ?? "", input.startDate, input.endDate, input.status ?? "planning", input.retrospectiveNotes ?? "", input.velocityPoints ?? 0, input.completedPoints ?? 0, ts, id, input.companyId); } else { getDb().prepare( "insert into sprints (id, company_id, name, goal, start_date, end_date, status, retrospective_notes, velocity_points, completed_points, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", diff --git a/src/main/ipc-automation-operations-handlers.ts b/src/main/ipc-automation-operations-handlers.ts index e8382702..b76277d6 100644 --- a/src/main/ipc-automation-operations-handlers.ts +++ b/src/main/ipc-automation-operations-handlers.ts @@ -29,6 +29,9 @@ export function registerAutomationOperationsHandlers({ const parsed = automationRuleInputSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; + if (parsed.id && !db.core.settings.belongsToCompany("automation_rules", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Automation rule not found in the current company."); + } const id = db.automation.rules.saveAutomationRule(parsed); publishDomainChanged(); return ok(id); @@ -69,6 +72,9 @@ export function registerAutomationOperationsHandlers({ const parsed = workflowInputSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; + if (parsed.id && !db.core.settings.belongsToCompany("workflow_pipelines", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Workflow not found in the current company."); + } const id = db.automation.rules.saveWorkflow(parsed); publishDomainChanged(); return ok(id); diff --git a/src/main/ipc-company-operations/document-handlers.ts b/src/main/ipc-company-operations/document-handlers.ts index 27c79839..1fdad9f0 100644 --- a/src/main/ipc-company-operations/document-handlers.ts +++ b/src/main/ipc-company-operations/document-handlers.ts @@ -18,6 +18,9 @@ export function registerDocumentHandlers({ const parsed = documentInputSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; + if (parsed.id && !db.core.settings.belongsToCompany("documents", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Document not found in the current company."); + } const invalidReference = findInvalidReference(parsed.companyId, [ { id: parsed.authorAgentId, table: "agents", label: "document author" }, diff --git a/src/main/ipc-company-operations/knowledge-entry-handlers.ts b/src/main/ipc-company-operations/knowledge-entry-handlers.ts index 5a28fae7..e7b3a39f 100644 --- a/src/main/ipc-company-operations/knowledge-entry-handlers.ts +++ b/src/main/ipc-company-operations/knowledge-entry-handlers.ts @@ -14,6 +14,9 @@ export function registerKnowledgeEntryHandlers({ const parsed = knowledgeEntryInputSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; + if (parsed.id && !db.core.settings.belongsToCompany("knowledge_base", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Knowledge entry not found in the current company."); + } const knowledgeReferences = [ { id: parsed.authorAgentId, table: "agents", label: "knowledge author" }, diff --git a/src/main/ipc-company-operations/meeting-handlers.ts b/src/main/ipc-company-operations/meeting-handlers.ts index 17018455..457566e5 100644 --- a/src/main/ipc-company-operations/meeting-handlers.ts +++ b/src/main/ipc-company-operations/meeting-handlers.ts @@ -14,6 +14,9 @@ export function registerMeetingHandlers({ const parsed = meetingInputSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; + if (parsed.id && !db.core.settings.belongsToCompany("meetings", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Meeting not found in the current company."); + } let participantAgentIds: string[] = []; try { diff --git a/src/main/ipc-company-operations/sprint-handlers.ts b/src/main/ipc-company-operations/sprint-handlers.ts index 63ab9857..c237a418 100644 --- a/src/main/ipc-company-operations/sprint-handlers.ts +++ b/src/main/ipc-company-operations/sprint-handlers.ts @@ -14,6 +14,9 @@ export function registerSprintHandlers({ const parsed = sprintInputSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; + if (parsed.id && !db.core.settings.belongsToCompany("sprints", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Sprint not found in the current company."); + } let previousSprintStatus: string | null = null; if (parsed.id) {