feat: complete TASK-WP1-06 audit immutability
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user