From 34120177754267f5540f612f8f5f084bd0d8827c Mon Sep 17 00:00:00 2001 From: eleven_qy <64311644+alph-cmky@users.noreply.github.com> Date: Fri, 29 May 2026 17:43:11 +0800 Subject: [PATCH] feat: add audit log service --- apps/server/src/audit/audit-schema.ts | 41 ++++++++ apps/server/src/audit/audit-service.test.ts | 95 ++++++++++++++++++ apps/server/src/audit/audit-service.ts | 101 ++++++++++++++++++++ apps/server/src/index.ts | 12 +++ docs/development/task-backlog.md | 8 +- 5 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/audit/audit-schema.ts create mode 100644 apps/server/src/audit/audit-service.test.ts create mode 100644 apps/server/src/audit/audit-service.ts diff --git a/apps/server/src/audit/audit-schema.ts b/apps/server/src/audit/audit-schema.ts new file mode 100644 index 0000000..1a260fb --- /dev/null +++ b/apps/server/src/audit/audit-schema.ts @@ -0,0 +1,41 @@ +export type AuditAction = + | "task.execution" + | "patch.proposal" + | "approval.decision" + | "sensitive.action"; + +export type AuditActorType = "user" | "agent" | "system"; +export type AuditApprovalDecision = "approved" | "rejected" | "notRequired" | "pending"; + +export interface AuditActor { + readonly type: AuditActorType; + readonly id: string; + readonly displayName?: string; +} + +export type AuditMetadataValue = + | string + | number + | boolean + | null + | readonly AuditMetadataValue[] + | { readonly [key: string]: AuditMetadataValue }; + +export type AuditMetadata = Readonly>; + +export interface AuditEntry { + readonly id: string; + readonly action: AuditAction; + readonly actor: AuditActor; + readonly taskId?: string; + readonly agentId?: string; + readonly runtimeId?: string; + readonly patchProposalId?: string; + readonly commandSummary?: string; + readonly diffSummary?: string; + readonly approvalDecision?: AuditApprovalDecision; + readonly metadata?: AuditMetadata; + readonly createdAt: string; +} + +export type CreateAuditEntryInput = Omit; diff --git a/apps/server/src/audit/audit-service.test.ts b/apps/server/src/audit/audit-service.test.ts new file mode 100644 index 0000000..24e38a3 --- /dev/null +++ b/apps/server/src/audit/audit-service.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { createAuditLogService } from "./audit-service.js"; + +describe("createAuditLogService", () => { + it("records who changed what, when, through which task, agent, and runtime", async () => { + const service = createAuditLogService({ + now: () => "2026-05-29T02:00:00.000Z", + nextId: () => "audit-1", + }); + + const entry = await service.record({ + action: "task.execution", + actor: { type: "agent", id: "reviewer", displayName: "Reviewer" }, + taskId: "task-1", + agentId: "reviewer", + runtimeId: "codex", + commandSummary: "git diff --cached", + diffSummary: "2 files changed", + approvalDecision: "notRequired", + metadata: { branch: "feat/example" }, + }); + + expect(entry).toEqual({ + id: "audit-1", + action: "task.execution", + actor: { type: "agent", id: "reviewer", displayName: "Reviewer" }, + taskId: "task-1", + agentId: "reviewer", + runtimeId: "codex", + commandSummary: "git diff --cached", + diffSummary: "2 files changed", + approvalDecision: "notRequired", + metadata: { branch: "feat/example" }, + createdAt: "2026-05-29T02:00:00.000Z", + }); + expect(await service.list()).toEqual([entry]); + expect(await service.findByTask("task-1")).toEqual([entry]); + expect(await service.findByRuntime("codex")).toEqual([entry]); + expect(await service.findByActor("reviewer")).toEqual([entry]); + }); + + it("records patch proposal and approval events", async () => { + const service = createAuditLogService({ + now: () => "2026-05-29T03:00:00.000Z", + nextId: () => "audit-approval", + }); + + const entry = await service.record({ + action: "approval.decision", + actor: { type: "user", id: "maintainer" }, + patchProposalId: "patch-1", + diffSummary: "replace /status", + approvalDecision: "approved", + }); + + expect(entry).toMatchObject({ + id: "audit-approval", + action: "approval.decision", + patchProposalId: "patch-1", + diffSummary: "replace /status", + approvalDecision: "approved", + }); + }); + + it("redacts secrets before persistence", async () => { + const service = createAuditLogService({ nextId: () => "audit-secret" }); + + const entry = await service.record({ + action: "sensitive.action", + actor: { type: "user", id: "maintainer" }, + commandSummary: "curl -H token=abc123 https://example.test", + diffSummary: "added api_key: sk-live-123", + approvalDecision: "approved", + metadata: { + nested: { + password: "hunter2", + safe: "kept", + }, + }, + }); + + expect(entry.commandSummary).toBe("curl -H token=[REDACTED] https://example.test"); + expect(entry.diffSummary).toBe("added api_key: [REDACTED]"); + expect(entry.metadata).toEqual({ + nested: { + password: "[REDACTED]", + safe: "kept", + }, + }); + expect(JSON.stringify(await service.list())).not.toContain("abc123"); + expect(JSON.stringify(await service.list())).not.toContain("hunter2"); + expect(JSON.stringify(await service.list())).not.toContain("sk-live-123"); + }); +}); diff --git a/apps/server/src/audit/audit-service.ts b/apps/server/src/audit/audit-service.ts new file mode 100644 index 0000000..df9e308 --- /dev/null +++ b/apps/server/src/audit/audit-service.ts @@ -0,0 +1,101 @@ +import type { + AuditEntry, + AuditMetadata, + AuditMetadataValue, + CreateAuditEntryInput, +} from "./audit-schema.js"; + +export interface AuditLogService { + readonly record: (input: CreateAuditEntryInput) => Promise; + readonly list: () => Promise; + readonly findByTask: (taskId: string) => Promise; + readonly findByRuntime: (runtimeId: string) => Promise; + readonly findByActor: (actorId: string) => Promise; +} + +export interface CreateAuditLogServiceOptions { + readonly now?: () => string; + readonly nextId?: () => string; +} + +const SECRET_KEY_PATTERN = /^(api[_-]?key|token|password|secret)$/i; + +export function createAuditLogService(options: CreateAuditLogServiceOptions = {}): AuditLogService { + const entries: AuditEntry[] = []; + const now = options.now ?? (() => new Date().toISOString()); + const nextId = options.nextId ?? (() => crypto.randomUUID()); + + return { + async record(input) { + const entry: AuditEntry = { + ...redactAuditInput(input), + id: nextId(), + createdAt: now(), + }; + entries.push(entry); + return cloneJson(entry); + }, + async list() { + return cloneJson(entries); + }, + async findByTask(taskId) { + return cloneJson(entries.filter((entry) => entry.taskId === taskId)); + }, + async findByRuntime(runtimeId) { + return cloneJson(entries.filter((entry) => entry.runtimeId === runtimeId)); + }, + async findByActor(actorId) { + return cloneJson(entries.filter((entry) => entry.actor.id === actorId)); + }, + }; +} + +function redactAuditInput(input: CreateAuditEntryInput): CreateAuditEntryInput { + return { + ...input, + ...(input.commandSummary ? { commandSummary: redactText(input.commandSummary) } : {}), + ...(input.diffSummary ? { diffSummary: redactText(input.diffSummary) } : {}), + ...(input.metadata ? { metadata: redactMetadata(input.metadata) } : {}), + }; +} + +function redactMetadata(metadata: AuditMetadata): AuditMetadata { + return redactMetadataValue(metadata) as AuditMetadata; +} + +function redactMetadataValue(value: AuditMetadataValue, key = ""): AuditMetadataValue { + if (SECRET_KEY_PATTERN.test(key)) { + return "[REDACTED]"; + } + + if (typeof value === "string") { + return redactText(value); + } + + if (Array.isArray(value)) { + return value.map((item) => redactMetadataValue(item)); + } + + if (typeof value === "object" && value !== null) { + return Object.fromEntries( + Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + redactMetadataValue(entryValue, entryKey), + ]), + ); + } + + return value; +} + +function redactText(value: string): string { + return value + .replace(/\b(api[_-]?key\s*[:=]\s*)([^\s,]+)/gi, "$1[REDACTED]") + .replace(/\b(token\s*[:=]\s*)([^\s,]+)/gi, "$1[REDACTED]") + .replace(/\b(password\s*[:=]\s*)([^\s,]+)/gi, "$1[REDACTED]") + .replace(/\b(secret\s*[:=]\s*)([^\s,]+)/gi, "$1[REDACTED]"); +} + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index bafcde7..43021d7 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -83,3 +83,15 @@ export type { PermissionName, PermissionPolicy, } from "./security/permission-policy.js"; +export { createAuditLogService } from "./audit/audit-service.js"; +export type { AuditLogService, CreateAuditLogServiceOptions } from "./audit/audit-service.js"; +export type { + AuditAction, + AuditActor, + AuditActorType, + AuditApprovalDecision, + AuditEntry, + AuditMetadata, + AuditMetadataValue, + CreateAuditEntryInput, +} from "./audit/audit-schema.js"; diff --git a/docs/development/task-backlog.md b/docs/development/task-backlog.md index ba28946..5f5cfce 100644 --- a/docs/development/task-backlog.md +++ b/docs/development/task-backlog.md @@ -531,10 +531,10 @@ Status markers: **Tasks:** -- [ ] Record who/what initiated each action. -- [ ] Record task ID, runtime ID, command summary, diff summary, approval decision, and timestamps. -- [ ] Redact secrets before persistence. -- [ ] Commit with message `feat: add audit log service`. +- [x] Record who/what initiated each action. +- [x] Record task ID, runtime ID, command summary, diff summary, approval decision, and timestamps. +- [x] Redact secrets before persistence. +- [x] Commit with message `feat: add audit log service`. **Acceptance Criteria:**