Audit log: keep entries for 2 years, then delete them once a day (#169)
The audit log grew without a limit. The privacy policy draft now says it is kept 2 years, so the daily housekeeping (03:00 UTC, with the email log's pruning) deletes entries older than 730 days, 5,000 at a time. The audit log page and the limits reference say so. Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent ce12993commit 7041870c9cefe4879bb3f37986a93a9d670ccf48Browse files
5 files changed+49−1
Files (5)
modifiedapps/diffinery/docs/reference/limits.md+1−0View file
| @@ -353,6 +353,7 @@ | ||
| 353 | 353 | | Limit | Value | Applies to | |
| 354 | 354 | | --- | --- | --- | |
| 355 | 355 | | Email log retention | 90 days | **Settings → Email** and `GET /api/v3/diffinery/emails` show emails from the last 90 days. Older entries are deleted once a day. | |
| 356 | +| Audit log retention | 2 years | Entries older than 730 days are deleted once a day (see [Audit log](/docs/security/audit-log#how-long-entries-are-kept)). | | |
| 356 | 357 | | Sending retries | 6 attempts | A temporary sending failure (rate limit, internal error or delivery failure reported by Cloudflare) is retried with backoff (starting at 30 seconds and doubling, at most 10 minutes). Other failures are logged as failed at once. After 6 attempts the email is logged as failed. | |
| 357 | 358 | | Message body | 20,000 characters | Longer issue, pull request or comment text in an email is cut and ends with `…`. | |
| 358 | 359 | | Subject | 900 characters | | |
modifiedapps/diffinery/docs/security/audit-log.md+4−0View file
| @@ -25,6 +25,10 @@ | ||
| 25 | 25 | |
| 26 | 26 | There is no filtering, search or paging on the page, and organization owners and repository admins cannot open it. There is no REST API endpoint, MCP tool or CLI command for reading the audit log on this version. |
| 27 | 27 | |
| 28 | +## How long entries are kept | |
| 29 | + | |
| 30 | +Entries are kept for 2 years (730 days). Older ones are deleted once a day, at about 03:00 UTC. An account deletion doesn't remove the deleted account's entries; they go when they reach 2 years like the rest. | |
| 31 | + | |
| 28 | 32 | ## Entry fields |
| 29 | 33 | |
| 30 | 34 | Each entry is stored with these fields: |
modifiedapps/diffinery/src/app.ts+3−1View file
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | import type { DiffineryEvent } from "@diffinery/shared"; |
| 4 | 4 | import type { Env } from "./env.js"; |
| 5 | 5 | import { credentialFrom, resolvePrincipal, type AppVars } from "./auth/principal.js"; |
| 6 | -import { Directory } from "./lib/directory.js"; | |
| 6 | +import { Directory, pruneAuditLog } from "./lib/directory.js"; | |
| 7 | 7 | import { getFirst } from "./lib/storage-keys.js"; |
| 8 | 8 | import { BOOKMARK_COOKIE, BOOKMARK_HEADER, d1Session, sessionConstraint } from "./lib/d1.js"; |
| 9 | 9 | import { SESSION_COOKIE } from "./auth/sessions.js"; |
| @@ -248,6 +248,8 @@ | ||
| 248 | 248 | schedulerStub(env).ensure().catch((e) => console.error("scheduler alarm check failed", e)), |
| 249 | 249 | // Token expiry reminders and email log pruning. |
| 250 | 250 | mailCron(env, new Directory(env), at).catch((e) => console.error("mail cron failed", e)), |
| 251 | + // Audit log entries past their 2 years, once a day. | |
| 252 | + (at.getUTCHours() === 3 && at.getUTCMinutes() < 5 ? pruneAuditLog(env.DB, at) : Promise.resolve(0)).catch((e) => console.error("audit log pruning failed", e)), | |
| 251 | 253 | // Account exports past their 7 days (accounts/export.ts). |
| 252 | 254 | expireExports(env).catch((e) => console.error("export expiry failed", e)), |
| 253 | 255 | // Expire abandoned draft bulk imports after 7 days, and null their stored GitHub token (Low 5). |
modifiedapps/diffinery/src/lib/directory.ts+10−0View file
| @@ -1067,3 +1067,13 @@ | ||
| 1067 | 1067 | return (await this.db.prepare("SELECT * FROM audit_log ORDER BY at DESC LIMIT ?").bind(limit).all<Record<string, string | null>>()).results; |
| 1068 | 1068 | } |
| 1069 | 1069 | } |
| 1070 | + | |
| 1071 | +/** How long audit log entries are kept (docs/security/audit-log.md, and the privacy policy). */ | |
| 1072 | +export const AUDIT_RETENTION_DAYS = 730; | |
| 1073 | + | |
| 1074 | +/** Delete audit log entries older than AUDIT_RETENTION_DAYS, a bounded batch at a time; returns how many went. */ | |
| 1075 | +export async function pruneAuditLog(db: D1Database, at = new Date(), batch = 5000): Promise<number> { | |
| 1076 | + const before = new Date(at.getTime() - AUDIT_RETENTION_DAYS * 86400_000).toISOString(); | |
| 1077 | + const r = await db.prepare("DELETE FROM audit_log WHERE id IN (SELECT id FROM audit_log WHERE at < ? LIMIT ?)").bind(before, batch).run(); | |
| 1078 | + return r.meta.changes ?? 0; | |
| 1079 | +} |
addedapps/diffinery/test/unit/audit-retention.test.ts+31−0View file
| @@ -1,0 +1,31 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { AUDIT_RETENTION_DAYS, pruneAuditLog } from "../../src/lib/directory.js"; | |
| 3 | +import { testD1 } from "./stubs/d1.js"; | |
| 4 | + | |
| 5 | +// The privacy policy says the audit log is kept 2 years: entries older than that are deleted once a day. | |
| 6 | +describe("audit log retention", () => { | |
| 7 | + it("deletes entries older than two years and keeps the rest", async () => { | |
| 8 | + const DB = testD1(); | |
| 9 | + const at = new Date("2026-10-14T03:00:00Z"); | |
| 10 | + const daysAgo = (d: number) => new Date(at.getTime() - d * 86400_000).toISOString(); | |
| 11 | + const add = DB.sqlite.prepare("INSERT INTO audit_log (id, at, action) VALUES (?, ?, 'session.create')"); | |
| 12 | + add.run("old1", daysAgo(AUDIT_RETENTION_DAYS + 1)); | |
| 13 | + add.run("old2", daysAgo(AUDIT_RETENTION_DAYS + 400)); | |
| 14 | + add.run("kept1", daysAgo(AUDIT_RETENTION_DAYS - 1)); | |
| 15 | + add.run("kept2", daysAgo(0)); | |
| 16 | + expect(AUDIT_RETENTION_DAYS).toBe(730); | |
| 17 | + expect(await pruneAuditLog(DB, at)).toBe(2); | |
| 18 | + const ids = (DB.sqlite.prepare("SELECT id FROM audit_log ORDER BY id").all() as Array<{ id: string }>).map((r) => r.id); | |
| 19 | + expect(ids).toEqual(["kept1", "kept2"]); | |
| 20 | + }); | |
| 21 | + | |
| 22 | + it("deletes a bounded batch at a time", async () => { | |
| 23 | + const DB = testD1(); | |
| 24 | + const at = new Date("2026-10-14T03:00:00Z"); | |
| 25 | + const add = DB.sqlite.prepare("INSERT INTO audit_log (id, at, action) VALUES (?, ?, 'git.push')"); | |
| 26 | + for (let i = 0; i < 5; i++) add.run(`old${i}`, "2020-01-01T00:00:00.000Z"); | |
| 27 | + expect(await pruneAuditLog(DB, at, 3)).toBe(3); | |
| 28 | + expect(await pruneAuditLog(DB, at, 3)).toBe(2); | |
| 29 | + expect(await pruneAuditLog(DB, at, 3)).toBe(0); | |
| 30 | + }); | |
| 31 | +}); |