feat: implement TASK-WP1-04 admin security

This commit is contained in:
suyx
2026-07-28 18:32:50 +08:00
parent 66fe3b763a
commit 03f1509de7
29 changed files with 2344 additions and 40 deletions
+89 -11
View File
@@ -93,6 +93,10 @@ function now() {
return new Date().toISOString();
}
function auditExpiry(occurredAt: string) {
return new Date(Date.parse(occurredAt) + 180 * 24 * 60 * 60 * 1_000).toISOString();
}
function validatePositiveBytes(value: number, name: string) {
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name}_invalid`);
}
@@ -222,12 +226,23 @@ export class ManagedStorage {
);
CREATE TABLE IF NOT EXISTS admin_operation_logs (
log_id TEXT PRIMARY KEY,
operation TEXT NOT NULL,
outcome TEXT NOT NULL,
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,
created_at 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
);
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;
`);
this.migrateLegacyAdminOperationLogs();
const initial = classifyCapacity(0, 0);
this.database.prepare(`
INSERT OR IGNORE INTO local_backend_storage_state
@@ -236,6 +251,54 @@ 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();
}
@@ -561,9 +624,14 @@ export class ManagedStorage {
return row.count > 0;
});
if (conflict) {
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'denied', confirmed_at = ? WHERE request_id = ?").run(now(), requestId);
this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'asset_cleanup', 'denied_reference_conflict', ?, ?)")
.run(randomUUID(), requestId, now());
const occurredAt = now();
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'denied', confirmed_at = ? WHERE request_id = ?").run(occurredAt, 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));
return false;
}
for (const file of files) {
@@ -574,9 +642,14 @@ export class ManagedStorage {
VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?)
`).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, now());
}
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'queued', confirmed_at = ? WHERE request_id = ?").run(now(), requestId);
this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'asset_cleanup', 'queued', ?, ?)")
.run(randomUUID(), requestId, now());
const occurredAt = now();
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'queued', confirmed_at = ? WHERE request_id = ?").run(occurredAt, 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));
return true;
});
if (!transaction()) throw new Error("ASSET_HISTORY_REFERENCE_CONFLICT");
@@ -605,8 +678,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);
this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'physical_file_cleanup', 'completed', ?, ?)")
.run(randomUUID(), row.cleanup_id, now());
const occurredAt = 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));
this.recordPhysicalMeasurement();
});
finish();