Add compliance controls
This commit is contained in:
1 parent
78dbcbe7ce
commit
38c68ca4af
19 files changed
+882
-7
No files matched your search
@@ -13,6 +13,7 @@ import { SocialService } from "../services/social-service";
|
||||
import { FileService } from "../services/file-service";
|
||||
import { MigrationService } from "../services/migration-service";
|
||||
import { OnboardingService } from "../services/onboarding-service";
|
||||
import { ComplianceService } from "../services/compliance-service";
|
||||
|
||||
export interface IpcDeps {
|
||||
connection: DatabaseConnection;
|
||||
@@ -30,6 +31,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
const social = new SocialService(deps.connection.db);
|
||||
const files = new FileService(deps.connection.db, deps.artifactRoot);
|
||||
const migrations = new MigrationService(deps.connection.db);
|
||||
const compliance = new ComplianceService(deps.connection.db);
|
||||
const onboarding = new OnboardingService({
|
||||
db: deps.connection.db,
|
||||
saveProviderCredential: (input) => providers.saveCredential(input),
|
||||
@@ -144,6 +146,11 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.createMigrationReport, (_event, input) => mutate(() => migrations.createReport(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createComplianceExport, (_event, input) => mutate(() => compliance.createExport(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createLegalHold, (_event, input) => mutate(() => compliance.createLegalHold(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.setLegalHoldState, (_event, input) => mutate(() => compliance.setLegalHoldState(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createRetentionFreeze, (_event, input) => mutate(() => compliance.createRetentionFreeze(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.setRetentionFreezeState, (_event, input) => mutate(() => compliance.setRetentionFreezeState(input)));
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.windowMinimize, () => deps.mainWindow.minimize());
|
||||
ipcMain.handle(IPC_CHANNELS.windowMaximize, () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize());
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
export const complianceControlsSchema = `
|
||||
alter table chat_spaces add column retention_policy text not null default 'standard_365d';
|
||||
|
||||
create table if not exists compliance_exports (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
subject_type text not null,
|
||||
subject_id text not null,
|
||||
export_type text not null,
|
||||
state text not null,
|
||||
payload_json text not null,
|
||||
requested_by_employee_id text references employees(id) on delete set null,
|
||||
created_at text not null,
|
||||
completed_at text
|
||||
);
|
||||
|
||||
create table if not exists legal_holds (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
entity_type text not null,
|
||||
entity_id text not null,
|
||||
reason text not null,
|
||||
created_by_employee_id text references employees(id) on delete set null,
|
||||
active integer not null,
|
||||
created_at text not null,
|
||||
released_at text
|
||||
);
|
||||
|
||||
create table if not exists retention_freezes (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
scope_type text not null,
|
||||
scope_id text not null,
|
||||
reason text not null,
|
||||
created_by_employee_id text references employees(id) on delete set null,
|
||||
active integer not null,
|
||||
created_at text not null,
|
||||
released_at text
|
||||
);
|
||||
|
||||
create index if not exists idx_compliance_exports_org_created on compliance_exports(org_id, created_at);
|
||||
create index if not exists idx_legal_holds_org_entity on legal_holds(org_id, entity_type, entity_id, active);
|
||||
create index if not exists idx_retention_freezes_org_scope on retention_freezes(org_id, scope_type, scope_id, active);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (12, 'compliance_controls', datetime('now'));
|
||||
`;
|
||||
@@ -10,6 +10,7 @@ import { providerHealthSchema } from "./migrations/008-provider-health";
|
||||
import { socialGovernanceSchema } from "./migrations/009-social-governance";
|
||||
import { fileHandoffSecuritySchema } from "./migrations/010-file-handoff-security";
|
||||
import { boardInterventionSchema } from "./migrations/011-board-interventions";
|
||||
import { complianceControlsSchema } from "./migrations/012-compliance-controls";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -29,6 +30,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 9, name: "social_governance", sql: socialGovernanceSchema },
|
||||
{ version: 10, name: "file_handoff_security", sql: fileHandoffSecuritySchema },
|
||||
{ version: 11, name: "board_interventions", sql: boardInterventionSchema },
|
||||
{ version: 12, name: "compliance_controls", sql: complianceControlsSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
CreateComplianceExportInput,
|
||||
CreateLegalHoldInput,
|
||||
CreateRetentionFreezeInput,
|
||||
SetLegalHoldStateInput,
|
||||
SetRetentionFreezeStateInput,
|
||||
} from "../../shared/commands";
|
||||
import { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
export class ComplianceService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
createExport(input: CreateComplianceExportInput): string {
|
||||
this.assertOrg(input.orgId);
|
||||
this.assertEmployee(input.orgId, input.requestedByEmployeeId, "Requesting employee was not found.");
|
||||
this.assertSubject(input.orgId, input.subjectType, input.subjectId);
|
||||
if (input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE) this.assertDeleteAllowed(input);
|
||||
const payload = this.subjectPayload(input.orgId, input.subjectType, input.subjectId);
|
||||
if (input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE) this.applyGdprDelete(input.orgId, input.subjectType, input.subjectId);
|
||||
const id = createId("cmp");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into compliance_exports(
|
||||
id, org_id, subject_type, subject_id, export_type, state, payload_json,
|
||||
requested_by_employee_id, created_at, completed_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.subjectType,
|
||||
input.subjectId,
|
||||
input.exportType,
|
||||
COMPLIANCE_EXPORT_STATE.COMPLETED,
|
||||
JSON.stringify(payload),
|
||||
input.requestedByEmployeeId,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "compliance_export",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: input.requestedByEmployeeId,
|
||||
action: input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE ? "gdpr_deleted" : "created",
|
||||
payload: { subjectType: input.subjectType, subjectId: input.subjectId, exportType: input.exportType },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
createLegalHold(input: CreateLegalHoldInput): string {
|
||||
const reason = input.reason.trim();
|
||||
if (!reason) throw new Error("Legal hold reason is required.");
|
||||
this.assertOrg(input.orgId);
|
||||
this.assertEmployee(input.orgId, input.createdByEmployeeId, "Legal hold employee was not found.");
|
||||
this.assertHoldEntity(input.orgId, input.entityType, input.entityId);
|
||||
const id = createId("hold");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into legal_holds(id, org_id, entity_type, entity_id, reason, created_by_employee_id, active, created_at, released_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.entityType, input.entityId, reason, input.createdByEmployeeId, 1, now, null);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "legal_hold",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: input.createdByEmployeeId,
|
||||
action: "created",
|
||||
payload: { entityType: input.entityType, entityId: input.entityId },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
setLegalHoldState(input: SetLegalHoldStateInput): void {
|
||||
this.assertEmployee(input.orgId, input.employeeId, "Legal hold employee was not found.");
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.prepare(`
|
||||
update legal_holds
|
||||
set active = ?, released_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(input.active ? 1 : 0, input.active ? null : now, input.legalHoldId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Legal hold was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "legal_hold",
|
||||
entityId: input.legalHoldId,
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
action: input.active ? "activated" : "released",
|
||||
payload: { active: input.active },
|
||||
});
|
||||
}
|
||||
|
||||
createRetentionFreeze(input: CreateRetentionFreezeInput): string {
|
||||
const reason = input.reason.trim();
|
||||
if (!reason) throw new Error("Retention freeze reason is required.");
|
||||
this.assertOrg(input.orgId);
|
||||
this.assertEmployee(input.orgId, input.createdByEmployeeId, "Retention freeze employee was not found.");
|
||||
this.assertFreezeScope(input.orgId, input.scopeType, input.scopeId);
|
||||
const id = createId("frz");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into retention_freezes(id, org_id, scope_type, scope_id, reason, created_by_employee_id, active, created_at, released_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.scopeType, input.scopeId, reason, input.createdByEmployeeId, 1, now, null);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "retention_freeze",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: input.createdByEmployeeId,
|
||||
action: "created",
|
||||
payload: { scopeType: input.scopeType, scopeId: input.scopeId },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
setRetentionFreezeState(input: SetRetentionFreezeStateInput): void {
|
||||
this.assertEmployee(input.orgId, input.employeeId, "Retention freeze employee was not found.");
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.prepare(`
|
||||
update retention_freezes
|
||||
set active = ?, released_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(input.active ? 1 : 0, input.active ? null : now, input.retentionFreezeId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Retention freeze was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "retention_freeze",
|
||||
entityId: input.retentionFreezeId,
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
action: input.active ? "activated" : "released",
|
||||
payload: { active: input.active },
|
||||
});
|
||||
}
|
||||
|
||||
private subjectPayload(orgId: string, subjectType: string, subjectId: string): Record<string, unknown> {
|
||||
if (subjectType === "employee") {
|
||||
return {
|
||||
profile: this.one("employees", orgId, subjectId),
|
||||
chatMessages: this.db.prepare("select * from chat_messages where org_id = ? and sender_type = 'employee' and sender_id = ? order by created_at").all(orgId, subjectId),
|
||||
approvalsRequested: this.db.prepare("select * from approvals where org_id = ? and requested_by_type = 'employee' and requested_by_id = ? order by created_at").all(orgId, subjectId),
|
||||
approvalsDecided: this.db.prepare("select * from approvals where org_id = ? and decided_by_employee_id = ? order by created_at").all(orgId, subjectId),
|
||||
auditEventsAsActor: this.db.prepare("select * from audit_events where org_id = ? and actor_type = 'employee' and actor_id = ? order by created_at").all(orgId, subjectId),
|
||||
};
|
||||
}
|
||||
return {
|
||||
profile: this.one("agents", orgId, subjectId),
|
||||
tasks: this.db.prepare("select * from tasks where org_id = ? and assignee_agent_id = ? order by created_at").all(orgId, subjectId),
|
||||
taskRuns: this.db.prepare("select * from task_runs where org_id = ? and agent_id = ? order by created_at").all(orgId, subjectId),
|
||||
chatMessages: this.db.prepare("select * from chat_messages where org_id = ? and sender_type = 'agent' and sender_id = ? order by created_at").all(orgId, subjectId),
|
||||
auditEventsAsActor: this.db.prepare("select * from audit_events where org_id = ? and actor_type = 'agent' and actor_id = ? order by created_at").all(orgId, subjectId),
|
||||
};
|
||||
}
|
||||
|
||||
private applyGdprDelete(orgId: string, subjectType: string, subjectId: string): void {
|
||||
if (subjectType === "employee") {
|
||||
this.db.prepare("update employees set display_name = ?, role = ? where id = ? and org_id = ?")
|
||||
.run("Deleted employee", "Deleted", subjectId, orgId);
|
||||
this.db.prepare("update chat_messages set sender_id = null where org_id = ? and sender_type = 'employee' and sender_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update approvals set requested_by_id = null where org_id = ? and requested_by_type = 'employee' and requested_by_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
return;
|
||||
}
|
||||
this.db.prepare("update agents set name = ?, role = ?, lifecycle = ?, model_provider_id = null, model_id = null where id = ? and org_id = ?")
|
||||
.run("Deleted agent", "Deleted", "archived", subjectId, orgId);
|
||||
this.db.prepare("update tasks set assignee_agent_id = null where org_id = ? and assignee_agent_id = ?").run(orgId, subjectId);
|
||||
this.db.prepare("update task_runs set agent_id = null where org_id = ? and agent_id = ?").run(orgId, subjectId);
|
||||
this.db.prepare("update chat_messages set sender_id = null where org_id = ? and sender_type = 'agent' and sender_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
}
|
||||
|
||||
private assertDeleteAllowed(input: CreateComplianceExportInput): void {
|
||||
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 = ?))
|
||||
limit 1
|
||||
`).get(input.orgId, input.subjectType, input.subjectId, input.orgId);
|
||||
const retentionFreeze = this.db.prepare(`
|
||||
select id from retention_freezes
|
||||
where org_id = ? and active = 1 and scope_type = 'organization' and scope_id = ?
|
||||
limit 1
|
||||
`).get(input.orgId, input.orgId);
|
||||
if (legalHold || retentionFreeze) throw new Error("Active compliance hold prevents GDPR delete.");
|
||||
}
|
||||
|
||||
private one(table: "employees" | "agents", orgId: string, id: string): Row {
|
||||
const row = this.db.prepare(`select * from ${table} where id = ? and org_id = ?`).get(id, orgId) as Row | undefined;
|
||||
if (!row) throw new Error("Compliance subject was not found.");
|
||||
return row;
|
||||
}
|
||||
|
||||
private assertSubject(orgId: string, subjectType: string, subjectId: string): void {
|
||||
if (subjectType === "employee") return void this.assertEmployee(orgId, subjectId, "Compliance subject was not found.");
|
||||
if (subjectType === "agent") return void this.assertAgent(orgId, subjectId, "Compliance subject was not found.");
|
||||
throw new Error(`Unsupported compliance subject type: ${subjectType}`);
|
||||
}
|
||||
|
||||
private assertHoldEntity(orgId: string, entityType: string, entityId: string): void {
|
||||
if (entityType === "organization" && entityId === orgId) return;
|
||||
if (entityType === "employee") return void this.assertEmployee(orgId, entityId, "Legal hold entity was not found.");
|
||||
if (entityType === "agent") return void this.assertAgent(orgId, entityId, "Legal hold entity was not found.");
|
||||
if (entityType === "chat_thread") return void this.assertOrgEntity("chat_threads", orgId, entityId, "Legal hold entity was not found.");
|
||||
if (entityType === "file_object") return void this.assertOrgEntity("file_objects", orgId, entityId, "Legal hold entity was not found.");
|
||||
throw new Error(`Unsupported legal hold entity type: ${entityType}`);
|
||||
}
|
||||
|
||||
private assertFreezeScope(orgId: string, scopeType: string, scopeId: string): void {
|
||||
if (scopeType === "organization" && scopeId === orgId) return;
|
||||
if (scopeType === "chat_space") return void this.assertOrgEntity("chat_spaces", orgId, scopeId, "Retention freeze scope was not found.");
|
||||
if (scopeType === "file_object") return void this.assertOrgEntity("file_objects", orgId, scopeId, "Retention freeze scope was not found.");
|
||||
throw new Error(`Unsupported retention freeze scope type: ${scopeType}`);
|
||||
}
|
||||
|
||||
private assertOrg(orgId: string): void {
|
||||
const row = this.db.prepare("select id from organizations where id = ?").get(orgId);
|
||||
if (!row) throw new Error("Organization was not found.");
|
||||
}
|
||||
|
||||
private assertEmployee(orgId: string, employeeId: string, message: string): void {
|
||||
this.assertOrgEntity("employees", orgId, employeeId, message);
|
||||
}
|
||||
|
||||
private assertAgent(orgId: string, agentId: string, message: string): void {
|
||||
this.assertOrgEntity("agents", orgId, agentId, message);
|
||||
}
|
||||
|
||||
private assertOrgEntity(table: "employees" | "agents" | "chat_threads" | "file_objects" | "chat_spaces", orgId: string, id: string, message: string): void {
|
||||
const row = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`).get(id, orgId);
|
||||
if (!row) throw new Error(message);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,9 @@ const LEGACY_TABLES = [
|
||||
"browser_actions",
|
||||
"scheduled_posts",
|
||||
"policy_rules",
|
||||
"compliance_exports",
|
||||
"legal_holds",
|
||||
"retention_freezes",
|
||||
"audit_events",
|
||||
];
|
||||
|
||||
@@ -159,6 +162,12 @@ const RELATION_CHECKS: RelationCheck[] = [
|
||||
{ table: "scheduled_posts", column: "approval_id", referencedTable: "approvals", referencedColumn: "id", reason: "dangling_approval_id" },
|
||||
{ table: "scheduled_posts", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "policy_rules", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "compliance_exports", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "compliance_exports", column: "requested_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_requester_employee_id" },
|
||||
{ table: "legal_holds", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "legal_holds", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "retention_freezes", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "retention_freezes", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "audit_events", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
];
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ export class SnapshotService {
|
||||
.map((binding) => ({ ...binding, enabled: toBool(binding.enabled) }));
|
||||
const files = list<AppSnapshot["files"][number]>(this.db, "file_objects", "created_at desc")
|
||||
.map((file) => ({ ...file, pinned: toBool(file.pinned) }));
|
||||
const legalHolds = list<AppSnapshot["legalHolds"][number]>(this.db, "legal_holds", "created_at desc")
|
||||
.map((hold) => ({ ...hold, active: toBool(hold.active) }));
|
||||
const retentionFreezes = list<AppSnapshot["retentionFreezes"][number]>(this.db, "retention_freezes", "created_at desc")
|
||||
.map((freeze) => ({ ...freeze, active: toBool(freeze.active) }));
|
||||
|
||||
return {
|
||||
activeOrgId,
|
||||
@@ -83,6 +87,9 @@ export class SnapshotService {
|
||||
browserActions: list(this.db, "browser_actions", "created_at desc"),
|
||||
policyRules: list(this.db, "policy_rules", "created_at asc"),
|
||||
migrationReports: list(this.db, "migration_reports", "created_at desc"),
|
||||
complianceExports: list(this.db, "compliance_exports", "created_at desc"),
|
||||
legalHolds,
|
||||
retentionFreezes,
|
||||
auditEvents: list<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc").slice(0, 200),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user