diff --git a/AgentCompany-New/src/main/services/compliance-service.ts b/AgentCompany-New/src/main/services/compliance-service.ts index 23a62f7c..b857bbf6 100644 --- a/AgentCompany-New/src/main/services/compliance-service.ts +++ b/AgentCompany-New/src/main/services/compliance-service.ts @@ -186,14 +186,80 @@ export class ComplianceService { const legalHold = this.db.prepare(` select id from legal_holds where org_id = ? and active = 1 - and ((entity_type = ? and entity_id = ?) or (entity_type = 'organization' and entity_id = ?)) + and ( + (entity_type = ? and entity_id = ?) + or (entity_type = 'organization' and entity_id = ?) + or ( + entity_type = 'chat_thread' + and exists ( + select 1 from chat_messages + where org_id = ? + and thread_id = legal_holds.entity_id + and sender_type = ? + and sender_id = ? + ) + ) + or ( + entity_type = 'file_object' + and exists ( + select 1 from file_objects + where org_id = ? + and id = legal_holds.entity_id + and owner_type = ? + and owner_id = ? + ) + ) + ) limit 1 - `).get(input.orgId, input.subjectType, input.subjectId, input.orgId); + `).get( + input.orgId, + input.subjectType, + input.subjectId, + input.orgId, + input.orgId, + input.subjectType, + input.subjectId, + input.orgId, + input.subjectType, + input.subjectId, + ); const retentionFreeze = this.db.prepare(` select id from retention_freezes - where org_id = ? and active = 1 and scope_type = 'organization' and scope_id = ? + where org_id = ? and active = 1 + and ( + (scope_type = 'organization' and scope_id = ?) + or ( + scope_type = 'chat_space' + and exists ( + select 1 from chat_messages + where org_id = ? + and space_id = retention_freezes.scope_id + and sender_type = ? + and sender_id = ? + ) + ) + or ( + scope_type = 'file_object' + and exists ( + select 1 from file_objects + where org_id = ? + and id = retention_freezes.scope_id + and owner_type = ? + and owner_id = ? + ) + ) + ) limit 1 - `).get(input.orgId, input.orgId); + `).get( + input.orgId, + input.orgId, + input.orgId, + input.subjectType, + input.subjectId, + input.orgId, + input.subjectType, + input.subjectId, + ); if (legalHold || retentionFreeze) throw new Error("Active compliance hold prevents GDPR delete."); } diff --git a/AgentCompany-New/tests/unit/compliance-service.test.ts b/AgentCompany-New/tests/unit/compliance-service.test.ts index 4aa9d774..6fd871ef 100644 --- a/AgentCompany-New/tests/unit/compliance-service.test.ts +++ b/AgentCompany-New/tests/unit/compliance-service.test.ts @@ -162,4 +162,88 @@ describe("compliance service", () => { }))).toThrow("Active compliance hold prevents GDPR delete."); connection.close(); }); + + test("blocks GDPR delete under related chat and file holds", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const chat = new ChatService(connection.db); + const compliance = new ComplianceService(connection.db); + let orgId = ""; + let employeeId = ""; + let spaceId = ""; + let threadId = ""; + let fileId = ""; + let holdId = ""; + let freezeId = ""; + + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); + employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" }); + chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "employee", senderId: employeeId, bodyMd: "Held chat", attachmentFileIds: [] }); + threadId = (connection.db.prepare("select thread_id from chat_messages where org_id = ?").get(orgId) as { thread_id: string }).thread_id; + fileId = "file-held"; + connection.db.prepare(` + insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(fileId, orgId, "held.txt", "text/plain", 4, "a".repeat(64), "artifacts/held.txt", "employee", employeeId, new Date().toISOString()); + holdId = compliance.createLegalHold({ + orgId, + entityType: "chat_thread", + entityId: threadId, + reason: "Thread discovery", + createdByEmployeeId: employeeId, + }); + }); + + expect(() => connection.transaction(() => compliance.createExport({ + orgId, + subjectType: "employee", + subjectId: employeeId, + exportType: "gdpr_delete", + requestedByEmployeeId: employeeId, + }))).toThrow("Active compliance hold prevents GDPR delete."); + + connection.transaction(() => { + compliance.setLegalHoldState({ orgId, legalHoldId: holdId, active: false, employeeId }); + freezeId = compliance.createRetentionFreeze({ + orgId, + scopeType: "chat_space", + scopeId: spaceId, + reason: "Space retention", + createdByEmployeeId: employeeId, + }); + }); + expect(() => connection.transaction(() => compliance.createExport({ + orgId, + subjectType: "employee", + subjectId: employeeId, + exportType: "gdpr_delete", + requestedByEmployeeId: employeeId, + }))).toThrow("Active compliance hold prevents GDPR delete."); + + connection.transaction(() => { + compliance.setRetentionFreezeState({ orgId, retentionFreezeId: freezeId, active: false, employeeId }); + compliance.createLegalHold({ + orgId, + entityType: "file_object", + entityId: fileId, + reason: "File discovery", + createdByEmployeeId: employeeId, + }); + }); + expect(() => connection.transaction(() => compliance.createExport({ + orgId, + subjectType: "employee", + subjectId: employeeId, + exportType: "gdpr_delete", + requestedByEmployeeId: employeeId, + }))).toThrow("Active compliance hold prevents GDPR delete."); + connection.close(); + }); });