Skip to content

Commit d5e5d40

Browse files
committed
feat(security): make user erasure durable across restores
Document the accepted user-data trust boundary and selective E2EE architecture, including the data inventory, threat model, key and recovery constraints, migration path, and security gates. Record pseudonymous erasure requests before deletion, scrub residual identifiers and browser stores, enforce application-log and verification retention, and replay tombstones before the API restarts after a database restore. Add automatic backup retention, fail-closed restore checks, operator guidance, corrected privacy copy, the named retention-index migration, and coverage across unit, component, and PostgreSQL paths. Implements #312.
1 parent 3891208 commit d5e5d40

56 files changed

Lines changed: 7507 additions & 110 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

SECURITY.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ If you self-host OpenMapX, a few recommendations:
130130
database and follow the operator-controlled cutoff procedure in
131131
[Monitoring & logs](docs/docs/administration/monitoring.md#purging-historical-application-logs);
132132
upgrades never delete these records automatically.
133+
- Treat PostgreSQL snapshots as sensitive user-data copies. Ordinary synchronized
134+
content is not end-to-end encrypted, and deleting a live account does not
135+
rewrite an existing backup. Restrict and encrypt off-host copies as appropriate,
136+
set an expiry policy, and review the
137+
[user-data trust model](docs/docs/developer/user-data-trust-model.md).
133138
- Subscribe to the repository's "Releases only" notifications so security
134139
releases reach you.
135140

apps/api/.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ CORS_ORIGIN=http://localhost:3000,http://127.0.0.1:3000
3737
# `openssl rand -hex 32`.
3838
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/openmapx
3939

40+
# Account-erasure durability. Production Compose creates and mounts these.
41+
# Local development must create a 32-byte base64url key (no trailing newline)
42+
# and initialize the journal through `openmapx compose render` before deletion.
43+
# ERASURE_JOURNAL_PATH=../../infra/docker/data/erasure/journal.jsonl
44+
# ERASURE_JOURNAL_KEY_FILE=../../infra/docker/secrets/erasure-journal-key
45+
# BACKUP_RETENTION_DAYS=30
46+
# LEGAL_SERVER_LOG_RETENTION_DAYS=30
47+
4048
# Redis / Valkey (optional, recommended for transit caching)
4149
# Matches the redis service started by `pnpm openmapx services start --preset dev`.
4250
# REDIS_PASSWORD_FILE is required whenever REDIS_URL is set: point it at the

apps/api/src/auth.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import { db } from "./db";
2525
import { user as userTable } from "./db/schema";
2626
import { managedOAuthProviderOptions } from "./managed-oauth-provider";
27+
import { userErasureHooks } from "./services/user-erasure";
2728
import { auditAdminActionsHook } from "./utils/auth-audit-hook";
2829
import { configuredTrustedWebOrigins } from "./utils/csrf.js";
2930
import { sendMail } from "./utils/email";
@@ -139,6 +140,9 @@ const authOptions = {
139140
encryptOAuthTokens: true,
140141
},
141142
databaseHooks: {
143+
user: {
144+
delete: userErasureHooks,
145+
},
142146
account: {
143147
create: {
144148
after: async (account) => {
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { randomUUID } from "node:crypto";
2+
import { eq, sql } from "drizzle-orm";
3+
import { describe, expect, it } from "vitest";
4+
import { cleanupResidualUserData } from "../../services/user-erasure";
5+
import { db } from "../index";
6+
import { adminAuditLog, appLog, user, verification } from "../schema";
7+
8+
const skipDatabase = process.env.OPENMAPX_RUN_DATABASE_TESTS !== "1";
9+
10+
describe.skipIf(skipDatabase)("user erasure constraints with PostgreSQL", () => {
11+
it("keeps every direct user foreign key on an explicit erase-safe action", async () => {
12+
const result = await db.execute(sql`
13+
SELECT child.relname AS table_name,
14+
child_column.attname AS column_name,
15+
constraint.confdeltype AS delete_action
16+
FROM pg_constraint constraint
17+
JOIN pg_class parent ON parent.oid = constraint.confrelid
18+
JOIN pg_class child ON child.oid = constraint.conrelid
19+
JOIN pg_attribute child_column
20+
ON child_column.attrelid = child.oid
21+
AND child_column.attnum = constraint.conkey[1]
22+
WHERE constraint.contype = 'f'
23+
AND parent.relname = 'user'
24+
ORDER BY child.relname, child_column.attname
25+
`);
26+
27+
const rows = Array.from(result as Iterable<Record<string, unknown>>).map(
28+
(row) => `${row.table_name}.${row.column_name}:${row.delete_action}`,
29+
);
30+
expect(rows).toEqual([
31+
"account.user_id:c",
32+
"admin_audit_log.actor_id:n",
33+
"admin_job.created_by:n",
34+
"installed_extension.installed_by:n",
35+
"installed_integration.installed_by:n",
36+
"integration_secret.updated_by:n",
37+
"labeled_place.user_id:c",
38+
"mangrove_keypair.user_id:c",
39+
"mobile_auth_handoff.user_id:c",
40+
"oauth_access_token.user_id:c",
41+
"oauth_client.user_id:c",
42+
"oauth_consent.user_id:c",
43+
"oauth_refresh_token.user_id:c",
44+
"parked_location.user_id:c",
45+
"passkey.user_id:c",
46+
"personal_timeline_connection.user_id:c",
47+
"personal_vehicle.user_id:c",
48+
"saved_list.user_id:c",
49+
"service_secret.updated_by:n",
50+
"session.user_id:c",
51+
"share_link.user_id:c",
52+
"two_factor.user_id:c",
53+
]);
54+
});
55+
56+
it("scrubs residual identifiers from verification, audit, and persisted logs", async () => {
57+
const suffix = randomUUID();
58+
const userId = `erasure-user-${suffix}`;
59+
const email = `erasure-${suffix}@example.test`;
60+
const auditId = randomUUID();
61+
let userInserted = false;
62+
let auditInserted = false;
63+
let appLogId: number | undefined;
64+
try {
65+
await db.insert(user).values({ id: userId, name: "Erasure test", email });
66+
userInserted = true;
67+
await db.insert(verification).values({
68+
id: randomUUID(),
69+
identifier: `change-email:${userId}:${email}`,
70+
value: userId,
71+
expiresAt: new Date(Date.now() + 60_000),
72+
});
73+
await db.insert(adminAuditLog).values({
74+
id: auditId,
75+
actorId: userId,
76+
targetId: userId,
77+
targetType: "user",
78+
action: "test",
79+
details: { subject: userId, contact: email },
80+
ipAddress: "192.0.2.1",
81+
userAgent: "erasure-test",
82+
});
83+
auditInserted = true;
84+
const [insertedLog] = await db
85+
.insert(appLog)
86+
.values({
87+
level: "warn",
88+
source: "test",
89+
msg: `failure for ${email}`,
90+
metadata: { subject: userId },
91+
})
92+
.returning({ id: appLog.id });
93+
appLogId = insertedLog?.id;
94+
95+
await cleanupResidualUserData({ id: userId, email });
96+
await db.delete(user).where(eq(user.id, userId));
97+
98+
expect(await db.select().from(verification).where(eq(verification.value, userId))).toEqual(
99+
[],
100+
);
101+
expect(
102+
await db
103+
.select()
104+
.from(appLog)
105+
.where(eq(appLog.id, appLogId as number)),
106+
).toEqual([]);
107+
const [audit] = await db.select().from(adminAuditLog).where(eq(adminAuditLog.id, auditId));
108+
expect(audit).toMatchObject({
109+
actorId: null,
110+
targetId: null,
111+
details: null,
112+
ipAddress: null,
113+
userAgent: null,
114+
});
115+
} finally {
116+
if (userInserted) await db.delete(user).where(eq(user.id, userId));
117+
if (auditInserted) await db.delete(adminAuditLog).where(eq(adminAuditLog.id, auditId));
118+
if (appLogId !== undefined) await db.delete(appLog).where(eq(appLog.id, appLogId));
119+
}
120+
});
121+
});

apps/api/src/db/app-log-schema.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,8 @@ export const appLog = pgTable(
1010
metadata: jsonb("metadata"),
1111
createdAt: timestamp("created_at").defaultNow().notNull(),
1212
},
13-
(t) => [index("app_logs_level_source_idx").on(t.level, t.source, t.createdAt)],
13+
(t) => [
14+
index("app_logs_level_source_idx").on(t.level, t.source, t.createdAt),
15+
index("app_logs_created_at_idx").on(t.createdAt),
16+
],
1417
);

apps/api/src/db/auth-schema.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@ export const verification = pgTable(
9090
.$onUpdate(() => /* @__PURE__ */ new Date())
9191
.notNull(),
9292
},
93-
(table) => [index("verification_identifier_idx").on(table.identifier)],
93+
(table) => [
94+
index("verification_identifier_idx").on(table.identifier),
95+
index("verification_expiresAt_idx").on(table.expiresAt),
96+
],
9497
);
9598

9699
export const passkey = pgTable(
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
CREATE INDEX "app_logs_created_at_idx" ON "app_logs" USING btree ("created_at");--> statement-breakpoint
2+
CREATE INDEX "verification_expiresAt_idx" ON "verification" USING btree ("expires_at");

0 commit comments

Comments
 (0)