Block GDPR delete through related holds

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 22:03:42 -04:00
1 parent e78e9a8aa0
commit 5b68c629c4
2 files changed
+154 -4

No files matched your search

@@ -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.");
}
@@ -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();
});
});