Q33: document edits and content deletions leave attributed audit records
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cb464c3ee4
commit
66de4ef015
9 files changed
+147
-16
No files matched your search
@@ -59,6 +59,14 @@ export class AuditService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Content changes name a real member of the organization as the actor. */
|
||||
requireActor(orgId: string, actorType: "employee" | "agent", actorId: string): void {
|
||||
const table = { employee: "employees", agent: "agents" }[actorType];
|
||||
if (!table) throw new Error(`Unknown actor type "${actorType}".`);
|
||||
const actor = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`).get(actorId, orgId);
|
||||
if (!actor) throw new Error(`No ${actorType} "${actorId}" in this organization.`);
|
||||
}
|
||||
|
||||
createThreadReplay(input: CreateAuditReplayInput): string {
|
||||
const thread = this.requireThread(input.orgId, input.threadId);
|
||||
this.requireEmployee(input.orgId, input.createdByEmployeeId);
|
||||
|
||||
@@ -48,8 +48,9 @@ export class DocumentService {
|
||||
update(input: UpdateDocumentInput): number {
|
||||
const reason = input.reason.trim();
|
||||
if (!reason) throw new Error("A document update needs a reason.");
|
||||
const current = this.db.prepare("select version from documents where id = ? and org_id = ?")
|
||||
.get(input.documentId, input.orgId) as { version: number } | undefined;
|
||||
this.audit.requireActor(input.orgId, input.changedByType, input.changedById);
|
||||
const current = this.db.prepare("select version, title, status from documents where id = ? and org_id = ?")
|
||||
.get(input.documentId, input.orgId) as { version: number; title: string; status: string } | undefined;
|
||||
if (!current) throw new Error("Document was not found.");
|
||||
const version = current.version + 1;
|
||||
const now = new Date().toISOString();
|
||||
@@ -60,6 +61,15 @@ export class DocumentService {
|
||||
orgId: input.orgId, documentId: input.documentId, version, title: input.title.trim(), content: input.content, status: input.status,
|
||||
changedByType: input.changedByType, changedById: input.changedById, reason, at: now,
|
||||
});
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "document",
|
||||
entityId: input.documentId,
|
||||
actorType: input.changedByType,
|
||||
actorId: input.changedById,
|
||||
action: "revised",
|
||||
payload: { version, reason, title: input.title.trim(), status: input.status, previousTitle: current.title, previousStatus: current.status },
|
||||
});
|
||||
return version;
|
||||
}
|
||||
|
||||
@@ -71,8 +81,20 @@ export class DocumentService {
|
||||
}
|
||||
|
||||
delete(input: DeleteDocumentInput): void {
|
||||
const result = this.db.prepare("delete from documents where id = ? and org_id = ?").run(input.documentId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Document was not found.");
|
||||
this.audit.requireActor(input.orgId, "employee", input.deletedByEmployeeId);
|
||||
const doc = this.db.prepare("select title, doc_type, status, version, content from documents where id = ? and org_id = ?")
|
||||
.get(input.documentId, input.orgId) as { title: string; doc_type: string; status: string; version: number; content: string } | undefined;
|
||||
if (!doc) throw new Error("Document was not found.");
|
||||
this.db.prepare("delete from documents where id = ? and org_id = ?").run(input.documentId, input.orgId);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "document",
|
||||
entityId: input.documentId,
|
||||
actorType: "employee",
|
||||
actorId: input.deletedByEmployeeId,
|
||||
action: "deleted",
|
||||
payload: { title: doc.title, docType: doc.doc_type, status: doc.status, version: doc.version, content: doc.content },
|
||||
});
|
||||
}
|
||||
|
||||
private writeVersion(input: {
|
||||
|
||||
@@ -87,6 +87,7 @@ export class KnowledgeService {
|
||||
update(input: UpdateKnowledgeEntryInput): number {
|
||||
const reason = input.reason.trim();
|
||||
if (!reason) throw new Error("A knowledge correction needs a reason.");
|
||||
this.audit.requireActor(input.orgId, input.changedByType, input.changedById);
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.prepare(`
|
||||
update knowledge_entries set topic = ?, category = ?, content = ?, importance = ?, updated_at = ?
|
||||
@@ -104,7 +105,7 @@ export class KnowledgeService {
|
||||
orgId: input.orgId,
|
||||
entityType: "knowledge_entry",
|
||||
entityId: input.entryId,
|
||||
actorType: input.changedByType === "agent" ? "agent" : "employee",
|
||||
actorType: input.changedByType,
|
||||
actorId: input.changedById,
|
||||
action: "revised",
|
||||
payload: { revision, reason },
|
||||
@@ -120,8 +121,28 @@ export class KnowledgeService {
|
||||
}
|
||||
|
||||
delete(input: DeleteKnowledgeEntryInput): void {
|
||||
const result = this.db.prepare("delete from knowledge_entries where id = ? and org_id = ?").run(input.entryId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Knowledge entry was not found.");
|
||||
this.audit.requireActor(input.orgId, "employee", input.deletedByEmployeeId);
|
||||
const entry = this.db.prepare(`
|
||||
select topic, category, content, importance, scope_type, scope_id,
|
||||
(select max(revision) from knowledge_revisions where entry_id = knowledge_entries.id) as revision
|
||||
from knowledge_entries where id = ? and org_id = ?
|
||||
`).get(input.entryId, input.orgId) as {
|
||||
topic: string; category: string; content: string; importance: string; scope_type: string; scope_id: string | null; revision: number;
|
||||
} | undefined;
|
||||
if (!entry) throw new Error("Knowledge entry was not found.");
|
||||
this.db.prepare("delete from knowledge_entries where id = ? and org_id = ?").run(input.entryId, input.orgId);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "knowledge_entry",
|
||||
entityId: input.entryId,
|
||||
actorType: "employee",
|
||||
actorId: input.deletedByEmployeeId,
|
||||
action: "deleted",
|
||||
payload: {
|
||||
topic: entry.topic, category: entry.category, content: entry.content, importance: entry.importance,
|
||||
scopeType: entry.scope_type, scopeId: entry.scope_id, revision: entry.revision,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private writeRevision(input: {
|
||||
|
||||
@@ -140,7 +140,7 @@ export function DocumentsPanel({
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void perform(() => actions.run(() => window.agentCompany.deleteDocument({ orgId, documentId: selected.id })), () => {
|
||||
void perform(() => actions.run(() => window.agentCompany.deleteDocument({ orgId, documentId: selected.id, deletedByEmployeeId: employeeId })), () => {
|
||||
setConfirmDelete(false);
|
||||
setSelectedId(null);
|
||||
});
|
||||
|
||||
@@ -202,7 +202,7 @@ export function KnowledgePanel({
|
||||
onClose={() => setDialog(null)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
perform(() => actions.run(() => window.agentCompany.deleteKnowledgeEntry({ orgId, entryId: selected.id })), () => {
|
||||
perform(() => actions.run(() => window.agentCompany.deleteKnowledgeEntry({ orgId, entryId: selected.id, deletedByEmployeeId: employeeId })), () => {
|
||||
setDialog(null);
|
||||
setSelectedId(null);
|
||||
});
|
||||
|
||||
@@ -19,12 +19,13 @@ export interface UpdateDocumentInput {
|
||||
/** Why it changed; kept with the new version. */
|
||||
reason: string;
|
||||
changedByType: "employee" | "agent";
|
||||
changedById: string | null;
|
||||
changedById: string;
|
||||
}
|
||||
|
||||
export interface DeleteDocumentInput {
|
||||
orgId: string;
|
||||
documentId: string;
|
||||
deletedByEmployeeId: string;
|
||||
}
|
||||
|
||||
export type KnowledgeScopeType = "organization" | "project" | "goal" | "task" | "agent";
|
||||
@@ -55,8 +56,8 @@ export interface UpdateKnowledgeEntryInput {
|
||||
importance: string;
|
||||
/** Why it changed; kept in the revision history. */
|
||||
reason: string;
|
||||
changedByType: string;
|
||||
changedById: string | null;
|
||||
changedByType: "employee" | "agent";
|
||||
changedById: string;
|
||||
}
|
||||
|
||||
export interface DocumentVersionRecord {
|
||||
@@ -95,6 +96,7 @@ export interface ListKnowledgeRevisionsInput {
|
||||
export interface DeleteKnowledgeEntryInput {
|
||||
orgId: string;
|
||||
entryId: string;
|
||||
deletedByEmployeeId: string;
|
||||
}
|
||||
|
||||
export interface CreateAutomationRuleInput {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { DocumentService } from "../../src/main/services/document-service";
|
||||
import { KnowledgeService } from "../../src/main/services/knowledge-service";
|
||||
|
||||
function setup() {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-audit-${randomUUID()}.db`));
|
||||
let orgId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "A", employeeName: "Eve", mission: "m", goal: "g" });
|
||||
});
|
||||
const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
const events = (entityId: string) => connection.db.prepare(
|
||||
"select actor_type, actor_id, action, payload_json from audit_events where entity_id = ? order by rowid",
|
||||
).all(entityId).map((row) => ({ ...row, payload_json: JSON.parse(String(row.payload_json)) }));
|
||||
return { connection, orgId, employeeId, events };
|
||||
}
|
||||
|
||||
describe("content audit", () => {
|
||||
test("document updates and deletion record who changed what", () => {
|
||||
const { connection, orgId, employeeId, events } = setup();
|
||||
const documents = new DocumentService(connection.db);
|
||||
const id = documents.create({ orgId, title: "Spec", docType: "spec", content: "v1", authorAgentId: null, reviewerAgentId: null, projectId: null, goalId: null, taskId: null });
|
||||
documents.update({ orgId, documentId: id, title: "Spec v2", content: "v2", status: "review", reason: "Added API", changedByType: "employee", changedById: employeeId });
|
||||
documents.delete({ orgId, documentId: id, deletedByEmployeeId: employeeId });
|
||||
|
||||
expect(events(id).slice(1)).toEqual([
|
||||
{ actor_type: "employee", actor_id: employeeId, action: "revised", payload_json: { version: 2, reason: "Added API", title: "Spec v2", status: "review", previousTitle: "Spec", previousStatus: "draft" } },
|
||||
{ actor_type: "employee", actor_id: employeeId, action: "deleted", payload_json: { title: "Spec v2", docType: "spec", status: "review", version: 2, content: "v2" } },
|
||||
]);
|
||||
expect(() => documents.delete({ orgId, documentId: id, deletedByEmployeeId: employeeId })).toThrow("Document was not found.");
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("knowledge deletion records the removed entry and who removed it", () => {
|
||||
const { connection, orgId, employeeId, events } = setup();
|
||||
const knowledge = new KnowledgeService(connection.db);
|
||||
const id = knowledge.create({
|
||||
orgId, topic: "Refunds", category: "policy", content: "14 days", importance: "high",
|
||||
sourceType: "employee", sourceId: employeeId, scopeType: "organization", scopeId: null, validUntil: null, supersedesId: null,
|
||||
});
|
||||
knowledge.delete({ orgId, entryId: id, deletedByEmployeeId: employeeId });
|
||||
|
||||
expect(events(id).at(-1)).toEqual({
|
||||
actor_type: "employee", actor_id: employeeId, action: "deleted",
|
||||
payload_json: { topic: "Refunds", category: "policy", content: "14 days", importance: "high", scopeType: "organization", scopeId: null, revision: 1 },
|
||||
});
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("changes by someone outside the organization are refused", () => {
|
||||
const { connection, orgId, employeeId } = setup();
|
||||
const documents = new DocumentService(connection.db);
|
||||
const id = documents.create({ orgId, title: "Spec", docType: "spec", content: "v1", authorAgentId: null, reviewerAgentId: null, projectId: null, goalId: null, taskId: null });
|
||||
expect(() => documents.update({ orgId, documentId: id, title: "Spec", content: "v2", status: "draft", reason: "r", changedByType: "employee", changedById: "emp_stranger" }))
|
||||
.toThrow('No employee "emp_stranger" in this organization.');
|
||||
const knowledge = new KnowledgeService(connection.db);
|
||||
const entryId = knowledge.create({
|
||||
orgId, topic: "T", category: "fact", content: "c", importance: "normal",
|
||||
sourceType: "employee", sourceId: null, scopeType: "organization", scopeId: null, validUntil: null, supersedesId: null,
|
||||
});
|
||||
expect(() => knowledge.update({ orgId, entryId, topic: "T", category: "fact", content: "c2", importance: "normal", reason: "r", changedByType: "agent", changedById: "agt_stranger" }))
|
||||
.toThrow('No agent "agt_stranger" in this organization.');
|
||||
const badType = { orgId, documentId: id, title: "Spec", content: "v2", status: "draft", reason: "r", changedByType: "system" as "agent", changedById: employeeId };
|
||||
expect(() => documents.update(badType)).toThrow('Unknown actor type "system".');
|
||||
expect(documents.versions(orgId, id)).toHaveLength(1);
|
||||
expect(knowledge.revisions(orgId, entryId)).toHaveLength(1);
|
||||
expect(() => documents.delete({ orgId, documentId: id, deletedByEmployeeId: "emp_stranger" })).toThrow('No employee "emp_stranger" in this organization.');
|
||||
expect(connection.db.prepare("select count(*) as n from documents where id = ?").get(id)).toEqual({ n: 1 });
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -16,16 +16,17 @@ describe("document versions", () => {
|
||||
orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "V", employeeName: "Y", mission: "m", goal: "g" });
|
||||
});
|
||||
const documents = new DocumentService(connection.db);
|
||||
const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
const id = documents.create({ orgId, title: "Spec", docType: "spec", content: "v1 body", authorAgentId: null, reviewerAgentId: null, projectId: null, goalId: null, taskId: null });
|
||||
expect(documents.update({ orgId, documentId: id, title: "Spec", content: "v2 body", status: "review", reason: "Added API", changedByType: "employee", changedById: null })).toBe(2);
|
||||
expect(() => documents.update({ orgId, documentId: id, title: "Spec", content: "v3", status: "review", reason: " ", changedByType: "employee", changedById: null }))
|
||||
expect(documents.update({ orgId, documentId: id, title: "Spec", content: "v2 body", status: "review", reason: "Added API", changedByType: "employee", changedById: employeeId })).toBe(2);
|
||||
expect(() => documents.update({ orgId, documentId: id, title: "Spec", content: "v3", status: "review", reason: " ", changedByType: "employee", changedById: employeeId }))
|
||||
.toThrow("A document update needs a reason.");
|
||||
expect(documents.versions(orgId, id).map((v) => [v.version, v.content, v.status, v.reason])).toEqual([
|
||||
[1, "v1 body", "draft", "created"],
|
||||
[2, "v2 body", "review", "Added API"],
|
||||
]);
|
||||
expect(connection.db.prepare("select version, content from documents where id = ?").get(id)).toEqual({ version: 2, content: "v2 body" });
|
||||
documents.delete({ orgId, documentId: id });
|
||||
documents.delete({ orgId, documentId: id, deletedByEmployeeId: employeeId });
|
||||
expect(documents.versions(orgId, id)).toHaveLength(2);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
@@ -39,7 +39,8 @@ describe("knowledge revisions", () => {
|
||||
expect(run("revise_knowledge", { id, content: "x" })).toBe('ERROR: "reason" is required.');
|
||||
expect(run("revise_knowledge", { id: "kne_missing", reason: "r" })).toBe('ERROR: No knowledge entry with id "kne_missing".');
|
||||
|
||||
new KnowledgeService(connection.db).delete({ orgId, entryId: id });
|
||||
const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
new KnowledgeService(connection.db).delete({ orgId, entryId: id, deletedByEmployeeId: employeeId });
|
||||
expect(run("search_knowledge", { id })).toContain(`Knowledge entry ${id} (deleted), 2 revision(s):`);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user