feat: complete TASK-WP1-06 audit immutability

This commit is contained in:
suyx
2026-07-28 19:29:24 +08:00
parent 2358f97e5c
commit 59e566885b
9 changed files with 487 additions and 125 deletions
+224
View File
@@ -0,0 +1,224 @@
import type BetterSqlite3 from "better-sqlite3";
export const auditRetentionMilliseconds = 180 * 24 * 60 * 60 * 1_000;
const auditRefPattern = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/;
const forbiddenSummaryKeys = new Set([
"absolute_path", "api_key", "body", "code_hmac", "content", "credential",
"email", "image", "image_content", "password", "path", "prompt", "secret",
"session_token", "verification_code", "whitelist",
]);
const forbiddenSummaryKeyFragments = [
"content", "credential", "email", "image", "password", "path", "prompt", "secret", "token",
];
const safeStringPattern = /^[A-Za-z0-9_.:@-]{1,160}$/;
function isSafeSummaryValue(value: unknown, depth: number): boolean {
if (depth > 3) return false;
if (value === null || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isSafeInteger(value);
if (typeof value === "string") return safeStringPattern.test(value) && !value.includes("@");
if (Array.isArray(value)) return value.length <= 20 && value.every((entry) => isSafeSummaryValue(entry, depth + 1));
if (!value || typeof value !== "object") return false;
const entries = Object.entries(value);
return entries.length <= 32 && entries.every(([key, entry]) => (
auditRefPattern.test(key)
&& !forbiddenSummaryKeys.has(key.toLowerCase())
&& !forbiddenSummaryKeyFragments.some((fragment) => key.toLowerCase().includes(fragment))
&& isSafeSummaryValue(entry, depth + 1)
));
}
export function isSafeAuditRef(value: unknown) {
return typeof value === "string" && auditRefPattern.test(value) ? 1 : 0;
}
export function isSafeAuditSummaryJson(value: unknown) {
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > 2_048) return 0;
try {
return isSafeSummaryValue(JSON.parse(value), 0) ? 1 : 0;
} catch {
return 0;
}
}
export function serializeAuditSummary(value: Record<string, unknown> | null) {
if (value === null) return null;
const serialized = JSON.stringify(value);
if (isSafeAuditSummaryJson(serialized) !== 1) throw new Error("audit_summary_sensitive_or_invalid");
return serialized;
}
function toMilliseconds(value: string | number | null | undefined, fallback: number) {
if (typeof value === "number" && Number.isSafeInteger(value)) return value;
const parsed = Date.parse(String(value));
return Number.isFinite(parsed) ? parsed : fallback;
}
function createAdminOperationTable(database: BetterSqlite3.Database) {
database.exec(`
CREATE TABLE admin_operation_logs (
log_id TEXT PRIMARY KEY,
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
actor_ref TEXT NOT NULL CHECK (dada_audit_ref_is_safe(actor_ref) = 1),
operation_type TEXT NOT NULL CHECK (dada_audit_ref_is_safe(operation_type) = 1),
target_type TEXT NOT NULL CHECK (dada_audit_ref_is_safe(target_type) = 1),
target_ref TEXT NOT NULL CHECK (dada_audit_ref_is_safe(target_ref) = 1),
result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')),
before_summary TEXT CHECK (before_summary IS NULL OR dada_audit_summary_is_safe(before_summary) = 1),
after_summary TEXT CHECK (after_summary IS NULL OR dada_audit_summary_is_safe(after_summary) = 1),
occurred_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL CHECK (expires_at = occurred_at + ${auditRetentionMilliseconds})
);
`);
}
function installAdminOperationTriggers(database: BetterSqlite3.Database) {
database.exec(`
DROP TRIGGER IF EXISTS admin_operation_logs_no_update;
DROP TRIGGER IF EXISTS admin_operation_logs_no_delete;
CREATE TRIGGER admin_operation_logs_no_update
BEFORE UPDATE ON admin_operation_logs
BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
CREATE TRIGGER admin_operation_logs_no_delete
BEFORE DELETE ON admin_operation_logs
WHEN dada_allow_retention_purge() <> 1 OR OLD.expires_at > dada_retention_purge_now()
BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
`);
}
export function ensureAdminOperationAuditSchema(database: BetterSqlite3.Database, fallbackNow: number) {
const table = database.prepare(`
SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'admin_operation_logs'
`).get() as { sql: string } | undefined;
if (!table) {
createAdminOperationTable(database);
installAdminOperationTriggers(database);
return;
}
if (table.sql.includes("dada_audit_summary_is_safe") && table.sql.includes(String(auditRetentionMilliseconds))) {
installAdminOperationTriggers(database);
return;
}
const columns = database.prepare("PRAGMA table_info(admin_operation_logs)").all() as Array<{ name: string }>;
const current = columns.some((column) => column.name === "actor_type");
const rows = current
? database.prepare("SELECT * FROM admin_operation_logs").all() as Array<Record<string, string | number | null>>
: database.prepare("SELECT log_id, operation, outcome, target_ref, created_at FROM admin_operation_logs").all() as Array<Record<string, string | number | null>>;
const migrate = database.transaction(() => {
database.exec(`
DROP TRIGGER IF EXISTS admin_operation_logs_no_update;
DROP TRIGGER IF EXISTS admin_operation_logs_no_delete;
ALTER TABLE admin_operation_logs RENAME TO admin_operation_logs_before_wp1_06;
`);
createAdminOperationTable(database);
const insert = database.prepare(`
INSERT INTO admin_operation_logs (
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
result, before_summary, after_summary, occurred_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const row of rows) {
const occurredAt = toMilliseconds(current ? row.occurred_at! : row.created_at!, fallbackNow);
const expiresAt = current ? toMilliseconds(row.expires_at!, fallbackNow) : occurredAt + auditRetentionMilliseconds;
if (expiresAt !== occurredAt + auditRetentionMilliseconds) throw new Error("audit_retention_invalid");
const beforeSummary = current && typeof row.before_summary === "string"
? serializeAuditSummary(JSON.parse(row.before_summary) as Record<string, unknown>)
: null;
const afterSummary = current
? typeof row.after_summary === "string" ? serializeAuditSummary(JSON.parse(row.after_summary) as Record<string, unknown>) : null
: serializeAuditSummary({ legacy_outcome: String(row.outcome) });
insert.run(
row.log_id,
current ? row.actor_type : "system",
current ? row.actor_ref : "managed_storage_migration",
current ? row.operation_type : row.operation,
current ? row.target_type : "legacy_operation",
row.target_ref,
current ? row.result : String(row.outcome).startsWith("denied") ? "failed" : "succeeded",
beforeSummary,
afterSummary,
occurredAt,
expiresAt,
);
}
database.exec("DROP TABLE admin_operation_logs_before_wp1_06");
installAdminOperationTriggers(database);
});
migrate();
}
function createPrivateAccessTable(database: BetterSqlite3.Database) {
database.exec(`
CREATE TABLE private_content_access_logs (
log_id TEXT PRIMARY KEY,
actor_ref TEXT NOT NULL CHECK (length(actor_ref) = 36 AND dada_audit_ref_is_safe(actor_ref) = 1),
subject_ref TEXT NOT NULL CHECK (length(subject_ref) = 36 AND dada_audit_ref_is_safe(subject_ref) = 1),
target_ref TEXT NOT NULL CHECK (length(target_ref) = 36 AND dada_audit_ref_is_safe(target_ref) = 1),
content_type TEXT NOT NULL CHECK (content_type IN ('image', 'prompt')),
occurred_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL CHECK (expires_at = occurred_at + ${auditRetentionMilliseconds})
);
`);
}
function installPrivateAccessTriggers(database: BetterSqlite3.Database) {
database.exec(`
DROP TRIGGER IF EXISTS private_content_access_logs_no_update;
DROP TRIGGER IF EXISTS private_content_access_logs_no_delete;
CREATE TRIGGER private_content_access_logs_no_update
BEFORE UPDATE ON private_content_access_logs
WHEN dada_allow_privacy_purge() <> 1
OR OLD.subject_ref <> dada_privacy_purge_subject()
OR NEW.actor_ref <> OLD.actor_ref
OR NEW.content_type <> OLD.content_type
OR NEW.occurred_at <> OLD.occurred_at
OR NEW.expires_at <> OLD.expires_at
OR NEW.subject_ref = OLD.subject_ref
OR NEW.target_ref = OLD.target_ref
BEGIN SELECT RAISE(ABORT, 'private_content_access_logs_immutable'); END;
CREATE TRIGGER private_content_access_logs_no_delete
BEFORE DELETE ON private_content_access_logs
WHEN dada_allow_retention_purge() <> 1 OR OLD.expires_at > dada_retention_purge_now()
BEGIN SELECT RAISE(ABORT, 'private_content_access_logs_immutable'); END;
`);
}
export function ensurePrivateAccessAuditSchema(database: BetterSqlite3.Database) {
const table = database.prepare(`
SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'private_content_access_logs'
`).get() as { sql: string } | undefined;
if (!table) {
createPrivateAccessTable(database);
installPrivateAccessTriggers(database);
return;
}
if (table.sql.includes(String(auditRetentionMilliseconds))) {
installPrivateAccessTriggers(database);
return;
}
const rows = database.prepare("SELECT * FROM private_content_access_logs").all() as Array<Record<string, string | number>>;
const migrate = database.transaction(() => {
database.exec(`
DROP TRIGGER IF EXISTS private_content_access_logs_no_update;
DROP TRIGGER IF EXISTS private_content_access_logs_no_delete;
ALTER TABLE private_content_access_logs RENAME TO private_content_access_logs_before_wp1_06;
`);
createPrivateAccessTable(database);
const insert = database.prepare(`
INSERT INTO private_content_access_logs (
log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
`);
for (const row of rows) {
const occurredAt = toMilliseconds(row.occurred_at, 0);
const expiresAt = toMilliseconds(row.expires_at, 0);
if (expiresAt !== occurredAt + auditRetentionMilliseconds) throw new Error("audit_retention_invalid");
insert.run(row.log_id, row.actor_ref, row.subject_ref, row.target_ref, row.content_type, occurredAt, expiresAt);
}
database.exec("DROP TABLE private_content_access_logs_before_wp1_06");
installPrivateAccessTriggers(database);
});
migrate();
}
+26 -59
View File
@@ -15,6 +15,13 @@ import { pipeline } from "node:stream/promises";
import type BetterSqlite3 from "better-sqlite3";
import {
auditRetentionMilliseconds,
ensureAdminOperationAuditSchema,
isSafeAuditRef,
isSafeAuditSummaryJson,
serializeAuditSummary,
} from "./audit-policy.js";
import { resolvePathWithinRoot } from "./local-data-root.js";
import {
HARD_LIMIT_BYTES,
@@ -93,8 +100,8 @@ function now() {
return new Date().toISOString();
}
function auditExpiry(occurredAt: string) {
return new Date(Date.parse(occurredAt) + 180 * 24 * 60 * 60 * 1_000).toISOString();
function auditExpiry(occurredAt: number) {
return occurredAt + auditRetentionMilliseconds;
}
function validatePositiveBytes(value: number, name: string) {
@@ -155,6 +162,12 @@ export class ManagedStorage {
this.database.pragma("journal_mode = WAL");
this.database.pragma("foreign_keys = ON");
this.database.pragma("busy_timeout = 5000");
this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef);
this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson);
this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
this.database.function("dada_privacy_purge_subject", { deterministic: false }, () => "");
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
this.migrate();
const state = this.database.prepare("SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1").get() as { managed_content_bytes: number };
this.measurementBaselineBytes = Math.max(0, state.managed_content_bytes - this.physicalManagedBytes());
@@ -247,7 +260,7 @@ export class ManagedStorage {
if (!managedFileColumns.some((column) => column.name === "owner_ref")) {
this.database.exec("ALTER TABLE managed_files ADD COLUMN owner_ref TEXT");
}
this.migrateLegacyAdminOperationLogs();
ensureAdminOperationAuditSchema(this.database, Date.now());
const initial = classifyCapacity(0, 0);
this.database.prepare(`
INSERT OR IGNORE INTO local_backend_storage_state
@@ -256,54 +269,6 @@ export class ManagedStorage {
`).run(HARD_LIMIT_BYTES, initial.capacity_notice_level, initial.storage_status, now());
}
private migrateLegacyAdminOperationLogs() {
const columns = this.database.prepare("PRAGMA table_info(admin_operation_logs)").all() as Array<{ name: string }>;
if (columns.some((column) => column.name === "actor_type")) return;
const entries = this.database.prepare(`
SELECT log_id, operation, outcome, target_ref, created_at FROM admin_operation_logs
`).all() as Array<{ created_at: string; log_id: string; operation: string; outcome: string; target_ref: string }>;
this.database.exec(`
ALTER TABLE admin_operation_logs RENAME TO admin_operation_logs_legacy;
CREATE TABLE admin_operation_logs (
log_id TEXT PRIMARY KEY,
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
actor_ref TEXT NOT NULL,
operation_type TEXT NOT NULL,
target_type TEXT NOT NULL,
target_ref TEXT NOT NULL,
result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')),
before_summary TEXT,
after_summary TEXT,
occurred_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
`);
const insert = this.database.prepare(`
INSERT INTO admin_operation_logs (
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
result, before_summary, after_summary, occurred_at, expires_at
) VALUES (?, 'system', 'managed_storage_migration', ?, 'legacy_operation', ?, ?, NULL, ?, ?, ?)
`);
for (const entry of entries) {
insert.run(
entry.log_id,
entry.operation,
entry.target_ref,
entry.outcome.startsWith("denied") ? "failed" : "succeeded",
JSON.stringify({ legacy_outcome: entry.outcome }),
entry.created_at,
auditExpiry(entry.created_at),
);
}
this.database.exec(`
DROP TABLE admin_operation_logs_legacy;
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update
BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete
BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
`);
}
close() {
this.database.close();
}
@@ -629,14 +594,15 @@ export class ManagedStorage {
return row.count > 0;
});
if (conflict) {
const occurredAt = now();
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'denied', confirmed_at = ? WHERE request_id = ?").run(occurredAt, requestId);
const confirmedAt = now();
const occurredAt = Date.now();
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'denied', confirmed_at = ? WHERE request_id = ?").run(confirmedAt, requestId);
this.database.prepare(`
INSERT INTO admin_operation_logs (
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
result, before_summary, after_summary, occurred_at, expires_at
) VALUES (?, 'system', 'managed_storage', 'asset_cleanup', 'cleanup_request', ?, 'failed', NULL, ?, ?, ?)
`).run(randomUUID(), requestId, JSON.stringify({ reason: "reference_conflict" }), occurredAt, auditExpiry(occurredAt));
`).run(randomUUID(), requestId, serializeAuditSummary({ reason: "reference_conflict" }), occurredAt, auditExpiry(occurredAt));
return false;
}
for (const file of files) {
@@ -647,14 +613,15 @@ export class ManagedStorage {
VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?)
`).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, now());
}
const occurredAt = now();
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'queued', confirmed_at = ? WHERE request_id = ?").run(occurredAt, requestId);
const confirmedAt = now();
const occurredAt = Date.now();
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'queued', confirmed_at = ? WHERE request_id = ?").run(confirmedAt, requestId);
this.database.prepare(`
INSERT INTO admin_operation_logs (
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
result, before_summary, after_summary, occurred_at, expires_at
) VALUES (?, 'system', 'managed_storage', 'asset_cleanup', 'cleanup_request', ?, 'succeeded', NULL, ?, ?, ?)
`).run(randomUUID(), requestId, JSON.stringify({ status: "queued" }), occurredAt, auditExpiry(occurredAt));
`).run(randomUUID(), requestId, serializeAuditSummary({ status: "queued" }), occurredAt, auditExpiry(occurredAt));
return true;
});
if (!transaction()) throw new Error("ASSET_HISTORY_REFERENCE_CONFLICT");
@@ -683,13 +650,13 @@ export class ManagedStorage {
}
}
this.database.prepare("UPDATE file_cleanup_queue SET status = 'completed', completed_at = ?, last_error = NULL WHERE cleanup_id = ?").run(now(), row.cleanup_id);
const occurredAt = now();
const occurredAt = Date.now();
this.database.prepare(`
INSERT INTO admin_operation_logs (
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
result, before_summary, after_summary, occurred_at, expires_at
) VALUES (?, 'system', 'managed_storage', 'physical_file_cleanup', 'cleanup_queue_item', ?, 'succeeded', NULL, ?, ?, ?)
`).run(randomUUID(), row.cleanup_id, JSON.stringify({ status: "completed" }), occurredAt, auditExpiry(occurredAt));
`).run(randomUUID(), row.cleanup_id, serializeAuditSummary({ status: "completed" }), occurredAt, auditExpiry(occurredAt));
this.recordPhysicalMeasurement();
});
finish();
+18 -55
View File
@@ -3,6 +3,13 @@ import { createRequire } from "node:module";
import type BetterSqlite3 from "better-sqlite3";
import {
ensureAdminOperationAuditSchema,
ensurePrivateAccessAuditSchema,
isSafeAuditRef,
isSafeAuditSummaryJson,
serializeAuditSummary,
} from "./audit-policy.js";
import type { ResendAdapter } from "./resend-adapter.js";
import {
RegistrationError,
@@ -222,6 +229,7 @@ export class RegistrationService {
readonly options: Required<Pick<RegistrationServiceOptions, "clock" | "codeGenerator" | "inviteCodeGenerator">> & RegistrationServiceOptions;
private adminAllowlistHashes = new Set<string>();
private privacyPurgeActive = false;
private privacyPurgeSubject = "";
constructor(options: RegistrationServiceOptions) {
assertSecret("invitePepper", options.invitePepper);
@@ -239,8 +247,12 @@ export class RegistrationService {
this.database.pragma("foreign_keys = ON");
this.database.pragma("synchronous = FULL");
this.database.pragma("busy_timeout = 5000");
this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef);
this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson);
this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => this.privacyPurgeActive ? 1 : 0);
this.database.function("dada_privacy_purge_subject", { deterministic: false }, () => this.privacyPurgeSubject);
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
this.migrate();
}
@@ -1167,6 +1179,7 @@ export class RegistrationService {
}
this.privacyPurgeActive = true;
this.privacyPurgeSubject = session.user_id;
try {
this.database.prepare("DELETE FROM credit_ledger WHERE user_id = ?").run(session.user_id);
this.database.prepare(`
@@ -1174,6 +1187,7 @@ export class RegistrationService {
`).run(randomUUID(), randomUUID(), session.user_id);
} finally {
this.privacyPurgeActive = false;
this.privacyPurgeSubject = "";
}
this.queueOwnedManagedFiles(session.user_id, now);
@@ -1468,59 +1482,8 @@ export class RegistrationService {
singleton, applied_revision, allowlist_count, applied_at
) VALUES (1, 0, 0, 0);
`);
this.migrateLegacyAdminOperationLogs();
}
private migrateLegacyAdminOperationLogs() {
const columns = this.database.prepare("PRAGMA table_info(admin_operation_logs)").all() as Array<{ name: string }>;
if (columns.some((column) => column.name === "actor_type")) return;
const legacy = this.database.prepare(`
SELECT log_id, operation, outcome, target_ref, created_at FROM admin_operation_logs
`).all() as Array<{ created_at: string | number; log_id: string; operation: string; outcome: string; target_ref: string }>;
this.database.exec(`
DROP TRIGGER IF EXISTS admin_operation_logs_no_update;
DROP TRIGGER IF EXISTS admin_operation_logs_no_delete;
ALTER TABLE admin_operation_logs RENAME TO admin_operation_logs_legacy;
CREATE TABLE admin_operation_logs (
log_id TEXT PRIMARY KEY,
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
actor_ref TEXT NOT NULL,
operation_type TEXT NOT NULL,
target_type TEXT NOT NULL,
target_ref TEXT NOT NULL,
result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')),
before_summary TEXT,
after_summary TEXT,
occurred_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
`);
const insert = this.database.prepare(`
INSERT INTO admin_operation_logs (
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
result, before_summary, after_summary, occurred_at, expires_at
) VALUES (?, 'system', 'managed_storage_migration', ?, 'legacy_operation', ?, ?, NULL, ?, ?, ?)
`);
for (const entry of legacy) {
const parsed = typeof entry.created_at === "number" ? entry.created_at : Date.parse(entry.created_at);
const occurredAt = Number.isFinite(parsed) ? parsed : this.options.clock();
insert.run(
entry.log_id,
entry.operation,
entry.target_ref,
entry.outcome.startsWith("denied") ? "failed" : "succeeded",
JSON.stringify({ legacy_outcome: entry.outcome }),
occurredAt,
occurredAt + 180 * 24 * 60 * 60 * 1_000,
);
}
this.database.exec(`
DROP TABLE admin_operation_logs_legacy;
CREATE TRIGGER admin_operation_logs_no_update
BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
CREATE TRIGGER admin_operation_logs_no_delete
BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
`);
ensureAdminOperationAuditSchema(this.database, this.options.clock());
ensurePrivateAccessAuditSchema(this.database);
}
private runImmediate<T>(
@@ -1757,8 +1720,8 @@ export class RegistrationService {
input.targetType,
input.targetRef,
input.result,
input.beforeSummary === null ? null : JSON.stringify(input.beforeSummary),
input.afterSummary === null ? null : JSON.stringify(input.afterSummary),
serializeAuditSummary(input.beforeSummary),
serializeAuditSummary(input.afterSummary),
now,
now + 180 * 24 * 60 * 60 * 1_000,
);
+7
View File
@@ -9,6 +9,7 @@ export class RetentionCleanup {
private readonly clock: () => number;
private readonly database: BetterSqlite3.Database;
private retentionPurgeActive = false;
private retentionPurgeNow = 0;
constructor(input: { clock?: () => number; databasePath: string }) {
this.clock = input.clock ?? Date.now;
@@ -18,6 +19,7 @@ export class RetentionCleanup {
this.database.pragma("busy_timeout = 5000");
this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => this.retentionPurgeActive ? 1 : 0);
this.database.function("dada_retention_purge_now", { deterministic: false }, () => this.retentionPurgeNow);
}
purgeExpired() {
@@ -25,16 +27,21 @@ export class RetentionCleanup {
this.database.exec("BEGIN IMMEDIATE");
try {
this.retentionPurgeActive = true;
this.retentionPurgeNow = now;
const adminOperations = this.database.prepare("DELETE FROM admin_operation_logs WHERE expires_at <= ?").run(now);
const privateAccess = this.database.prepare("DELETE FROM private_content_access_logs WHERE expires_at <= ?").run(now);
const anonymous = this.database.prepare("DELETE FROM anonymous_retained_events WHERE expires_at <= ?").run(now);
this.retentionPurgeActive = false;
this.retentionPurgeNow = 0;
this.database.exec("COMMIT");
return {
admin_operation_logs: adminOperations.changes,
anonymous_events: anonymous.changes,
private_access_logs: privateAccess.changes,
};
} catch (error) {
this.retentionPurgeActive = false;
this.retentionPurgeNow = 0;
if (this.database.inTransaction) this.database.exec("ROLLBACK");
throw error;
}