Skip to content

Commit 17c72cb

Browse files
authored
Merge #15: preserve audit logs of deleted connections via SET NULL + denormalized fields
Preserve audit logs when a connection is deleted
2 parents d3bc12b + 05a857d commit 17c72cb

11 files changed

Lines changed: 376 additions & 55 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# CI: install, lint, format check, build, unit tests, e2e (Playwright)
1+
# CI: install, lint, format check, build, unit tests, migrations + DB integration tests, e2e (Playwright)
22
# Uses latest action versions; Node 24 + pnpm 10 per package.json engines.
33
name: CI
44

@@ -31,8 +31,8 @@ jobs:
3131
- name: Set up Node.js
3232
uses: actions/setup-node@v6
3333
with:
34-
node-version: "24"
35-
cache: "pnpm"
34+
node-version: '24'
35+
cache: 'pnpm'
3636

3737
- name: Install dependencies
3838
run: pnpm install --frozen-lockfile
@@ -73,8 +73,8 @@ jobs:
7373
- name: Set up Node.js
7474
uses: actions/setup-node@v6
7575
with:
76-
node-version: "24"
77-
cache: "pnpm"
76+
node-version: '24'
77+
cache: 'pnpm'
7878

7979
- name: Install dependencies
8080
run: pnpm install --frozen-lockfile
@@ -85,6 +85,63 @@ jobs:
8585
- name: Run unit tests
8686
run: pnpm run test
8787

88+
integration:
89+
name: Migrations & DB integration tests
90+
runs-on: ubuntu-latest
91+
# postgres:17-alpine matches production (docker-compose.yml), so the migration chain is
92+
# exercised against the same major version it runs on in prod.
93+
services:
94+
postgres:
95+
image: postgres:17-alpine
96+
env:
97+
POSTGRES_USER: postgres
98+
POSTGRES_PASSWORD: postgres
99+
POSTGRES_DB: bunker46
100+
options: >-
101+
--health-cmd "pg_isready -U postgres"
102+
--health-interval 10s
103+
--health-timeout 5s
104+
--health-retries 5
105+
ports:
106+
- 5432:5432
107+
env:
108+
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/bunker46
109+
steps:
110+
- name: Checkout
111+
uses: actions/checkout@v6
112+
113+
- name: Install pnpm
114+
uses: pnpm/action-setup@v4
115+
with:
116+
run_install: false
117+
118+
- name: Set up Node.js
119+
uses: actions/setup-node@v6
120+
with:
121+
node-version: '24'
122+
cache: 'pnpm'
123+
124+
- name: Install dependencies
125+
run: pnpm install --frozen-lockfile
126+
127+
- name: Build workspace packages
128+
run: pnpm --filter @bunker46/shared-types --filter @bunker46/config run build
129+
130+
# Applies every committed migration in order against an empty DB: proves the full chain
131+
# (initial schema -> current) applies cleanly. Uses `migrate deploy`, NOT `db:push`, so it
132+
# is the migration SQL that actually runs — db:push would bypass the migration files.
133+
- name: Apply migrations from scratch (full chain)
134+
run: pnpm --filter @bunker46/server run db:deploy
135+
136+
# Fails (exit code 2) if the migrated DB and schema.prisma disagree — catches a schema.prisma
137+
# edit that was never captured in a migration.
138+
- name: Check migrations match schema (no drift)
139+
run: pnpm --filter @bunker46/server run db:check-drift
140+
141+
# Runs the RUN_DB_TESTS-gated integration specs against the migration-built schema.
142+
- name: Run DB integration tests
143+
run: pnpm --filter @bunker46/server run test:integration
144+
88145
e2e:
89146
name: E2E (Playwright)
90147
runs-on: ubuntu-latest
@@ -118,8 +175,8 @@ jobs:
118175
- name: Set up Node.js
119176
uses: actions/setup-node@v6
120177
with:
121-
node-version: "24"
122-
cache: "pnpm"
178+
node-version: '24'
179+
cache: 'pnpm'
123180

124181
- name: Install dependencies
125182
run: pnpm install --frozen-lockfile
@@ -156,4 +213,4 @@ jobs:
156213
pnpm run e2e
157214
env:
158215
CI: true
159-
E2E_USE_PREVIEW: "1"
216+
E2E_USE_PREVIEW: '1'

‎apps/server/package.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
"test": "vitest run",
1313
"test:watch": "vitest",
1414
"test:coverage": "vitest run --coverage",
15+
"test:integration": "RUN_DB_TESTS=1 vitest run int.spec",
1516
"db:generate": "prisma generate",
1617
"db:migrate": "prisma migrate dev",
18+
"db:deploy": "prisma migrate deploy",
19+
"db:check-drift": "prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code",
1720
"db:baseline-legacy": "prisma db execute --file prisma/baseline-from-legacy.sql && prisma migrate resolve --applied 20260330100000_initial_schema",
1821
"db:push": "prisma db push",
1922
"db:seed": "tsx prisma/seed.ts",
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
-- Preserve audit logs after a connection is deleted.
2+
--
3+
-- Previously signing_logs.connection_id was NOT NULL with ON DELETE CASCADE, so deleting a
4+
-- connection wiped its entire signing history. We now decouple the log from the connection
5+
-- lifecycle: the FK becomes nullable + ON DELETE SET NULL, and the owning user, connection name
6+
-- and client pubkey are denormalized onto each log so an orphaned record stays scoped to its
7+
-- user and remains readable. Deleting a *user* still purges their logs (new FK below cascades).
8+
9+
-- AlterTable: add denormalized columns (nullable first so existing rows can be backfilled)
10+
ALTER TABLE "signing_logs"
11+
ADD COLUMN "user_id" TEXT,
12+
ADD COLUMN "connection_name" TEXT,
13+
ADD COLUMN "client_pubkey" TEXT;
14+
15+
-- Backfill from the connections that still exist (every current log has a valid connection,
16+
-- since the old FK was NOT NULL + CASCADE, so there are no orphans to leave behind).
17+
UPDATE "signing_logs" sl
18+
SET "user_id" = bc."user_id",
19+
"connection_name" = bc."name",
20+
"client_pubkey" = bc."client_pubkey"
21+
FROM "bunker_connections" bc
22+
WHERE sl."connection_id" = bc."id";
23+
24+
-- Enforce NOT NULL now that the columns are populated
25+
ALTER TABLE "signing_logs"
26+
ALTER COLUMN "user_id" SET NOT NULL,
27+
ALTER COLUMN "connection_name" SET NOT NULL,
28+
ALTER COLUMN "client_pubkey" SET NOT NULL;
29+
30+
-- Make connection_id nullable so ON DELETE SET NULL can apply
31+
ALTER TABLE "signing_logs" ALTER COLUMN "connection_id" DROP NOT NULL;
32+
33+
-- Swap the connection FK from CASCADE to SET NULL
34+
ALTER TABLE "signing_logs" DROP CONSTRAINT "signing_logs_connection_id_fkey";
35+
ALTER TABLE "signing_logs" ADD CONSTRAINT "signing_logs_connection_id_fkey"
36+
FOREIGN KEY ("connection_id") REFERENCES "bunker_connections"("id") ON DELETE SET NULL ON UPDATE CASCADE;
37+
38+
-- Add the user FK (CASCADE so deleting a user still purges their audit logs)
39+
ALTER TABLE "signing_logs" ADD CONSTRAINT "signing_logs_user_id_fkey"
40+
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
41+
42+
-- Index for user-scoped log queries
43+
CREATE INDEX "signing_logs_user_id_idx" ON "signing_logs"("user_id");

‎apps/server/prisma/schema.prisma‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ model User {
2323
passkeys Passkey[]
2424
nsecKeys NsecKey[]
2525
connections BunkerConnection[]
26+
signingLogs SigningLog[]
2627
relayConfigs RelayConfig[]
2728
2829
@@map("users")
@@ -131,19 +132,27 @@ model ConnectionPermission {
131132
}
132133

133134
model SigningLog {
134-
id String @id @default(cuid())
135-
connectionId String @map("connection_id")
136-
method String
137-
eventKind Int? @map("event_kind")
138-
result LogResult
139-
durationMs Int @map("duration_ms")
140-
errorMessage String? @map("error_message")
141-
metadata Json?
142-
createdAt DateTime @default(now()) @map("created_at")
143-
144-
connection BunkerConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
135+
id String @id @default(cuid())
136+
// Nullable: when a connection is deleted the FK is set to null (ON DELETE SET NULL) so the
137+
// audit record survives. The owning user, connection name and client pubkey below are
138+
// denormalized at write time so an orphaned log stays scoped to its user and readable.
139+
connectionId String? @map("connection_id")
140+
userId String @map("user_id")
141+
connectionName String @map("connection_name")
142+
clientPubkey String @map("client_pubkey")
143+
method String
144+
eventKind Int? @map("event_kind")
145+
result LogResult
146+
durationMs Int @map("duration_ms")
147+
errorMessage String? @map("error_message")
148+
metadata Json?
149+
createdAt DateTime @default(now()) @map("created_at")
150+
151+
connection BunkerConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull)
152+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
145153
146154
@@index([connectionId])
155+
@@index([userId])
147156
@@index([createdAt])
148157
@@index([method])
149158
@@map("signing_logs")

‎apps/server/src/bunker/bunker-rpc.handler.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,9 @@ export class BunkerRpcHandler {
234234

235235
await this.loggingService.logSigningAction({
236236
connectionId: connection.id,
237+
userId: connection.userId,
238+
connectionName: connection.name,
239+
clientPubkey: connection.clientPubkey,
237240
method: request.method,
238241
eventKind,
239242
result: error ? 'ERROR' : 'APPROVED',

‎apps/server/src/logging/logging.service.spec.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,19 @@ describe('LoggingService', () => {
2424
it('should create signing log entry', async () => {
2525
await service.logSigningAction({
2626
connectionId: 'conn-1',
27+
userId: 'user-1',
28+
connectionName: 'My App',
29+
clientPubkey: 'a'.repeat(64),
2730
method: 'sign_event',
2831
result: 'APPROVED',
2932
durationMs: 100,
3033
});
3134
expect(prisma.signingLog?.create).toHaveBeenCalledWith({
3235
data: expect.objectContaining({
3336
connectionId: 'conn-1',
37+
userId: 'user-1',
38+
connectionName: 'My App',
39+
clientPubkey: 'a'.repeat(64),
3440
method: 'sign_event',
3541
result: 'APPROVED',
3642
durationMs: 100,
@@ -43,6 +49,9 @@ describe('LoggingService', () => {
4349
it('should include eventKind and metadata when provided', async () => {
4450
await service.logSigningAction({
4551
connectionId: 'conn-1',
52+
userId: 'user-1',
53+
connectionName: 'My App',
54+
clientPubkey: 'a'.repeat(64),
4655
method: 'sign_event',
4756
eventKind: 1,
4857
result: 'DENIED',
@@ -58,6 +67,54 @@ describe('LoggingService', () => {
5867
});
5968
});
6069

70+
describe('getDashboardActivity', () => {
71+
it('scopes by denormalized userId (not the connection relation) so deleted-connection logs survive', async () => {
72+
vi.mocked(prisma.signingLog!.findMany!).mockResolvedValue([
73+
{
74+
id: 'log-1',
75+
connectionId: null, // connection was deleted; FK is SET NULL
76+
connectionName: 'Deleted App',
77+
method: 'sign_event',
78+
eventKind: 1,
79+
result: 'APPROVED',
80+
createdAt: new Date('2026-01-01T00:00:00Z'),
81+
} as never,
82+
]);
83+
vi.mocked(prisma.signingLog!.count!).mockResolvedValue(1);
84+
85+
const result = await service.getDashboardActivity('user-1');
86+
87+
// Must filter on the denormalized userId, never via `connection: { userId }`,
88+
// otherwise orphaned (connectionId=null) logs drop out of the feed.
89+
const findManyArgs = vi.mocked(prisma.signingLog!.findMany!).mock.calls[0]![0]!;
90+
expect(findManyArgs.where).toEqual({ userId: 'user-1' });
91+
expect(findManyArgs.where).not.toHaveProperty('connection');
92+
// No relation include anymore — the name comes from the denormalized column.
93+
expect(findManyArgs).not.toHaveProperty('include');
94+
95+
expect(result.data[0]).toMatchObject({
96+
id: 'log-1',
97+
connectionName: 'Deleted App',
98+
method: 'sign_event',
99+
});
100+
expect(result.total).toBe(1);
101+
});
102+
103+
it('applies connectionName and method filters against denormalized columns', async () => {
104+
vi.mocked(prisma.signingLog!.findMany!).mockResolvedValue([]);
105+
vi.mocked(prisma.signingLog!.count!).mockResolvedValue(0);
106+
107+
await service.getDashboardActivity('user-1', 1, 15, 'My App', 'sign_event');
108+
109+
const findManyArgs = vi.mocked(prisma.signingLog!.findMany!).mock.calls[0]![0]!;
110+
expect(findManyArgs.where).toEqual({
111+
userId: 'user-1',
112+
connectionName: 'My App',
113+
method: 'sign_event',
114+
});
115+
});
116+
});
117+
61118
describe('getLogsForConnection', () => {
62119
it('should return paginated logs with total', async () => {
63120
vi.mocked(prisma.signingLog!.findMany!).mockResolvedValue([

‎apps/server/src/logging/logging.service.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import type { Prisma } from '@/generated/prisma/client.js';
55

66
interface LogEntry {
77
connectionId: string;
8+
// Denormalized so the log survives connection deletion (FK is SET NULL) and stays scoped/readable.
9+
userId: string;
10+
connectionName: string;
11+
clientPubkey: string;
812
method: string;
913
eventKind?: number;
1014
result: 'APPROVED' | 'DENIED' | 'ERROR';
@@ -24,6 +28,9 @@ export class LoggingService {
2428
await this.prisma.signingLog.create({
2529
data: {
2630
connectionId: entry.connectionId,
31+
userId: entry.userId,
32+
connectionName: entry.connectionName,
33+
clientPubkey: entry.clientPubkey,
2734
method: entry.method,
2835
eventKind: entry.eventKind,
2936
result: entry.result as LogResult,
@@ -44,18 +51,17 @@ export class LoggingService {
4451
connectionName?: string,
4552
method?: string,
4653
) {
54+
// Scope by the denormalized userId/connectionName so logs from deleted connections
55+
// (connectionId is null) still appear in the owner's activity feed.
4756
const where: Prisma.SigningLogWhereInput = {
48-
connection: {
49-
userId,
50-
...(connectionName ? { name: connectionName } : {}),
51-
},
57+
userId,
58+
...(connectionName ? { connectionName } : {}),
5259
...(method ? { method } : {}),
5360
};
5461

5562
const [data, total] = await Promise.all([
5663
this.prisma.signingLog.findMany({
5764
where,
58-
include: { connection: { select: { name: true } } },
5965
orderBy: { createdAt: 'desc' },
6066
skip: (page - 1) * limit,
6167
take: limit,
@@ -68,7 +74,7 @@ export class LoggingService {
6874
id: log.id,
6975
method: log.method,
7076
eventKind: log.eventKind,
71-
connectionName: log.connection.name,
77+
connectionName: log.connectionName,
7278
result: log.result,
7379
timestamp: log.createdAt.toISOString(),
7480
})),

0 commit comments

Comments
 (0)