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