feat: complete TASK-WP1-06 audit immutability
This commit is contained in:
@@ -152,12 +152,14 @@ describe("TDD-WP1-DEL-002-anonymous-retention", () => {
|
||||
const harness = await createRegisteredHarness("retention@example.invalid");
|
||||
const userId = harness.completed.user.userId;
|
||||
const csrfToken = harness.service.issueUserCsrfToken(harness.completed.sessionToken);
|
||||
const accessExpiresAt = fixedNow + 30_000;
|
||||
const accessActorRef = randomUUID();
|
||||
const accessTargetRef = randomUUID();
|
||||
const accessExpiresAt = fixedNow + 180 * 86_400_000;
|
||||
harness.service.database.prepare(`
|
||||
INSERT INTO private_content_access_logs (
|
||||
log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, 'image', ?, ?)
|
||||
`).run(randomUUID(), "admin-fixture", userId, "private-target-fixture", fixedNow - 1_000, accessExpiresAt);
|
||||
`).run(randomUUID(), accessActorRef, userId, accessTargetRef, fixedNow, accessExpiresAt);
|
||||
|
||||
const sent = await harness.service.sendAccountDeletionCode({ csrfToken, sessionToken: harness.completed.sessionToken });
|
||||
harness.service.completeAccountDeletion({
|
||||
@@ -182,12 +184,12 @@ describe("TDD-WP1-DEL-002-anonymous-retention", () => {
|
||||
});
|
||||
expect(JSON.stringify(anonymous)).not.toContain(harness.email);
|
||||
expect(JSON.stringify(anonymous)).not.toContain(userId);
|
||||
expect(JSON.stringify(anonymous)).not.toContain("private-target-fixture");
|
||||
expect(JSON.stringify(anonymous)).not.toContain(accessTargetRef);
|
||||
|
||||
const access = harness.service.database.prepare("SELECT * FROM private_content_access_logs").get();
|
||||
expect(access).toMatchObject({ actor_ref: "admin-fixture", expires_at: accessExpiresAt });
|
||||
expect(access).toMatchObject({ actor_ref: accessActorRef, expires_at: accessExpiresAt });
|
||||
expect(access.subject_ref).not.toBe(userId);
|
||||
expect(access.target_ref).not.toBe("private-target-fixture");
|
||||
expect(access.target_ref).not.toBe(accessTargetRef);
|
||||
expect(access.subject_ref).not.toBe(anonymous[0].anonymous_subject_id);
|
||||
|
||||
writeEvidence("DADA_EVIDENCE_DIR_RETENTION", "db-diff.json", {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
import { RetentionCleanup } from "../../apps/worker/src/retention-cleanup.js";
|
||||
import { registrationNotice } from "../../packages/shared-contracts/src/registration-notice.js";
|
||||
|
||||
const requireFromApi = createRequire(new URL("../../apps/api/package.json", import.meta.url));
|
||||
const Database = requireFromApi("better-sqlite3");
|
||||
const retentionMilliseconds = 180 * 24 * 60 * 60 * 1_000;
|
||||
const baseNow = Date.now();
|
||||
const roots: string[] = [];
|
||||
const services: RegistrationService[] = [];
|
||||
|
||||
function createHarness() {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp1-06-audit-"));
|
||||
roots.push(root);
|
||||
const databasePath = join(root, "dada.sqlite3");
|
||||
const service = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x41), clock: () => baseNow,
|
||||
currentPrivacyNoticeVersion: registrationNotice.version, databasePath,
|
||||
invitePepper: Buffer.alloc(32, 0x42), resend: new MockResendAdapter(),
|
||||
sessionPepper: Buffer.alloc(32, 0x43),
|
||||
});
|
||||
services.push(service);
|
||||
return { databasePath, service };
|
||||
}
|
||||
|
||||
function insertAuditPair(service: RegistrationService, occurredAt: number) {
|
||||
const adminLogId = randomUUID();
|
||||
const privateLogId = randomUUID();
|
||||
const expiresAt = occurredAt + retentionMilliseconds;
|
||||
service.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', 'backend_secure_config', 'secure_config_apply',
|
||||
'secure_config_revision', 'revision:fixture', 'succeeded', ?, ?, ?, ?)
|
||||
`).run(adminLogId, JSON.stringify({ revision: 1 }), JSON.stringify({ revision: 2 }), occurredAt, expiresAt);
|
||||
service.database.prepare(`
|
||||
INSERT INTO private_content_access_logs (
|
||||
log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, 'image', ?, ?)
|
||||
`).run(privateLogId, randomUUID(), randomUUID(), randomUUID(), occurredAt, expiresAt);
|
||||
return { adminLogId, expiresAt, privateLogId };
|
||||
}
|
||||
|
||||
function writeEvidence(file: string, value: unknown) {
|
||||
const directory = process.env.DADA_EVIDENCE_DIR_AUDIT;
|
||||
if (!directory) return;
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const service of services.splice(0)) service.close();
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe("TDD-WP1-AUD-001-immutable-triggers", () => {
|
||||
it("blocks ordinary and forged-scope mutations but lets the Worker delete both audit classes at expiry", () => {
|
||||
const { databasePath, service } = createHarness();
|
||||
const pair = insertAuditPair(service, baseNow);
|
||||
const ordinary = new Database(databasePath);
|
||||
ordinary.pragma("busy_timeout = 5000");
|
||||
try {
|
||||
expect(() => ordinary.prepare("UPDATE admin_operation_logs SET result = 'failed' WHERE log_id = ?").run(pair.adminLogId)).toThrow();
|
||||
expect(() => ordinary.prepare("DELETE FROM admin_operation_logs WHERE log_id = ?").run(pair.adminLogId)).toThrow();
|
||||
expect(() => ordinary.prepare("UPDATE private_content_access_logs SET actor_ref = ? WHERE log_id = ?").run(randomUUID(), pair.privateLogId)).toThrow();
|
||||
expect(() => ordinary.prepare("DELETE FROM private_content_access_logs WHERE log_id = ?").run(pair.privateLogId)).toThrow();
|
||||
|
||||
ordinary.function("dada_allow_retention_purge", () => 1);
|
||||
expect(() => ordinary.prepare("DELETE FROM private_content_access_logs WHERE log_id = ?").run(pair.privateLogId)).toThrow();
|
||||
ordinary.function("dada_allow_privacy_purge", () => 1);
|
||||
expect(() => ordinary.prepare(`
|
||||
UPDATE private_content_access_logs SET subject_ref = ?, target_ref = ? WHERE log_id = ?
|
||||
`).run(randomUUID(), randomUUID(), pair.privateLogId)).toThrow();
|
||||
} finally {
|
||||
ordinary.close();
|
||||
}
|
||||
|
||||
const early = new RetentionCleanup({ clock: () => pair.expiresAt - 1, databasePath });
|
||||
expect(early.purgeExpired()).toEqual({ admin_operation_logs: 0, anonymous_events: 0, private_access_logs: 0 });
|
||||
early.close();
|
||||
const expired = new RetentionCleanup({ clock: () => pair.expiresAt, databasePath });
|
||||
expect(expired.purgeExpired()).toEqual({ admin_operation_logs: 1, anonymous_events: 0, private_access_logs: 1 });
|
||||
expired.close();
|
||||
|
||||
expect(service.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs").get().count).toBe(0);
|
||||
expect(service.database.prepare("SELECT COUNT(*) AS count FROM private_content_access_logs").get().count).toBe(0);
|
||||
writeEvidence("sql-results.json", {
|
||||
forged_scope_delete: "blocked",
|
||||
forged_privacy_scope_update: "blocked",
|
||||
ordinary_delete: "blocked",
|
||||
ordinary_update: "blocked",
|
||||
retention_delete_at_expiry: "allowed",
|
||||
});
|
||||
writeEvidence("db-diff.json", { admin_operation_logs: 0, private_content_access_logs: 0 });
|
||||
});
|
||||
|
||||
it("enforces exact retention and rejects sensitive operation summaries", () => {
|
||||
const { service } = createHarness();
|
||||
const badExpiry = baseNow + retentionMilliseconds + 1;
|
||||
expect(() => service.database.prepare(`
|
||||
INSERT INTO private_content_access_logs (
|
||||
log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, 'prompt', ?, ?)
|
||||
`).run(randomUUID(), randomUUID(), randomUUID(), randomUUID(), baseNow, badExpiry)).toThrow();
|
||||
expect(() => service.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', 'backend_secure_config', 'secure_config_apply',
|
||||
'secure_config_revision', 'revision:fixture', 'failed', ?, NULL, ?, ?)
|
||||
`).run(
|
||||
randomUUID(),
|
||||
JSON.stringify({ relative_path: "private/fixture", session_token: "sensitive-fixture-token" }),
|
||||
baseNow,
|
||||
baseNow + retentionMilliseconds,
|
||||
)).toThrow();
|
||||
expect(service.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs").get().count).toBe(0);
|
||||
expect(service.database.prepare("SELECT COUNT(*) AS count FROM private_content_access_logs").get().count).toBe(0);
|
||||
|
||||
const triggers = service.database.prepare(`
|
||||
SELECT name, sql FROM sqlite_master
|
||||
WHERE type = 'trigger' AND name LIKE '%logs_%' ORDER BY name
|
||||
`).all();
|
||||
writeEvidence("trigger-definition.json", { triggers });
|
||||
});
|
||||
});
|
||||
@@ -41,8 +41,8 @@ describe("TDD-WP1-DEL-002 retention worker", () => {
|
||||
const anonymousExpiry = baseNow + 2_000;
|
||||
service.database.prepare(`
|
||||
INSERT INTO private_content_access_logs (log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at)
|
||||
VALUES (?, 'admin-fixture', ?, ?, 'prompt', ?, ?)
|
||||
`).run(randomUUID(), randomUUID(), randomUUID(), baseNow - 10_000, accessExpiry);
|
||||
VALUES (?, ?, ?, ?, 'prompt', ?, ?)
|
||||
`).run(randomUUID(), randomUUID(), randomUUID(), randomUUID(), accessExpiry - 180 * 86_400_000, accessExpiry);
|
||||
service.database.prepare(`
|
||||
INSERT INTO anonymous_retained_events (
|
||||
event_id, anonymous_subject_id, event_type, model_id, outcome, error_category,
|
||||
@@ -54,13 +54,13 @@ describe("TDD-WP1-DEL-002 retention worker", () => {
|
||||
expect(() => service.database.prepare("DELETE FROM private_content_access_logs").run()).toThrow();
|
||||
|
||||
const early = new RetentionCleanup({ clock: () => baseNow, databasePath });
|
||||
expect(early.purgeExpired()).toEqual({ anonymous_events: 0, private_access_logs: 0 });
|
||||
expect(early.purgeExpired()).toEqual({ admin_operation_logs: 0, anonymous_events: 0, private_access_logs: 0 });
|
||||
early.close();
|
||||
const firstExpiry = new RetentionCleanup({ clock: () => accessExpiry, databasePath });
|
||||
expect(firstExpiry.purgeExpired()).toEqual({ anonymous_events: 0, private_access_logs: 1 });
|
||||
expect(firstExpiry.purgeExpired()).toEqual({ admin_operation_logs: 0, anonymous_events: 0, private_access_logs: 1 });
|
||||
firstExpiry.close();
|
||||
const secondExpiry = new RetentionCleanup({ clock: () => anonymousExpiry, databasePath });
|
||||
expect(secondExpiry.purgeExpired()).toEqual({ anonymous_events: 1, private_access_logs: 0 });
|
||||
expect(secondExpiry.purgeExpired()).toEqual({ admin_operation_logs: 0, anonymous_events: 1, private_access_logs: 0 });
|
||||
secondExpiry.close();
|
||||
|
||||
writeEvidence({
|
||||
|
||||
Reference in New Issue
Block a user