From 1d8b80698e7e0b9b87bb70d859a575dd58ee41b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 15 May 2026 16:43:48 +0200 Subject: [PATCH] feat(audit): wire ASN+carrier into the audit-log create/update path Completes the v1.4.27 B3 audit-log work that landed in e8fb0a75. The prior commit shipped the backfill helper but the schema + migration + audit.ts write-path were dropped during a concurrent staging window. This commit puts them back. `prisma/schema.prisma` grows two nullable columns on AuditLog (`asn` + `carrier`), the matching idempotent `IF NOT EXISTS`-guarded migration lands at `0061_audit_log_carrier`, and the auditLog helper resolves location + ASN row in parallel (single 3 s race, single `prisma.auditLog.update` carries whichever fields the resolvers filled). Test coverage: five new cases on audit.test.ts cover all four combinations of location/ASN hit-or-miss plus a row with no carrier organisation. --- .../0061_audit_log_carrier/migration.sql | 20 ++++ prisma/schema.prisma | 7 ++ src/lib/auth/__tests__/audit.test.ts | 99 ++++++++++++++++++- src/lib/auth/audit.ts | 40 ++++++-- 4 files changed, 156 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/0061_audit_log_carrier/migration.sql diff --git a/prisma/migrations/0061_audit_log_carrier/migration.sql b/prisma/migrations/0061_audit_log_carrier/migration.sql new file mode 100644 index 000000000..e71f1332b --- /dev/null +++ b/prisma/migrations/0061_audit_log_carrier/migration.sql @@ -0,0 +1,20 @@ +-- v1.4.27 B3 — Persist the ASN + carrier on login audit rows. +-- +-- The bundled MaxMind GeoLite2-ASN MMDB resolves an autonomous-system +-- number and the organisation string ("Deutsche Telekom AG") at +-- audit-creation time. Both columns are nullable so the schema stays +-- valid for older rows, for private/loopback IPs that skip the +-- lookup entirely, and for offline-miss rows that the resolver could +-- not match. +-- +-- asn — INTEGER, the autonomous-system number from the MMDB. +-- carrier — TEXT, the autonomous-system organisation string. +-- +-- The `IF NOT EXISTS` guards make the migration idempotent — the +-- demo server may already carry hand-edited columns from a prior +-- partial run, and the same migration must apply cleanly to a fresh +-- database. Same posture as 0058_user_research_mode. + +ALTER TABLE "audit_logs" + ADD COLUMN IF NOT EXISTS "asn" INTEGER, + ADD COLUMN IF NOT EXISTS "carrier" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 13c3b4bd8..7e279efb5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1232,6 +1232,13 @@ model AuditLog { details String? // JSON string, no sensitive data ipAddress String? @map("ip_address") location String? // e.g. "Berlin, DE" — resolved from IP at log time + // v1.4.27 B3 — resolved at audit-creation time from the bundled + // GeoLite2-ASN MMDB. `asn` is the autonomous-system number, + // `carrier` is the organisation string (e.g. "Deutsche Telekom AG"). + // Both nullable so older rows, private-IP rows, and offline-miss + // rows stay valid. + asn Int? // autonomous-system number + carrier String? // autonomous-system organisation createdAt DateTime @default(now()) @map("created_at") user User? @relation(fields: [userId], references: [id], onDelete: SetNull) diff --git a/src/lib/auth/__tests__/audit.test.ts b/src/lib/auth/__tests__/audit.test.ts index c9e7e23b6..b4769d239 100644 --- a/src/lib/auth/__tests__/audit.test.ts +++ b/src/lib/auth/__tests__/audit.test.ts @@ -11,11 +11,12 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn(), + lookupIpAsn: vi.fn(), })); import { auditLog } from "../audit"; import { prisma } from "@/lib/db"; -import { lookupIpLocation } from "@/lib/geo"; +import { lookupIpAsn, lookupIpLocation } from "@/lib/geo"; const ENTRY = { id: "audit-1" }; @@ -23,6 +24,9 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.auditLog.create).mockResolvedValue(ENTRY as never); vi.mocked(prisma.auditLog.update).mockResolvedValue(ENTRY as never); + // Default: no ASN row resolves. Tests that exercise carrier + // resolution override this with `mockReturnValueOnce`. + vi.mocked(lookupIpAsn).mockReturnValue(null); }); afterEach(() => { @@ -183,4 +187,97 @@ describe("auditLog", () => { expect(prisma.auditLog.update).toHaveBeenCalledOnce(); }); + + // ── v1.4.27 B3 — ASN + carrier resolution ──────────────────────── + it("writes asn + carrier alongside location when both resolvers fire", async () => { + vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE"); + vi.mocked(lookupIpAsn).mockReturnValueOnce({ + asn: 3320, + carrier: "Deutsche Telekom AG", + }); + + await auditLog("auth.login.password", { + userId: "user-14", + ipAddress: "84.131.0.1", + }); + await flush(); + + expect(lookupIpAsn).toHaveBeenCalledWith("84.131.0.1"); + expect(prisma.auditLog.update).toHaveBeenCalledTimes(1); + expect(prisma.auditLog.update).toHaveBeenCalledWith({ + where: { id: "audit-1" }, + data: { + location: "Berlin, DE", + asn: 3320, + carrier: "Deutsche Telekom AG", + }, + }); + }); + + it("writes asn + carrier even when the location lookup returns null", async () => { + vi.mocked(lookupIpLocation).mockResolvedValueOnce(null); + vi.mocked(lookupIpAsn).mockReturnValueOnce({ + asn: 3209, + carrier: "Vodafone GmbH", + }); + + await auditLog("auth.login.passkey", { + userId: "user-15", + ipAddress: "139.7.0.1", + }); + await flush(); + + expect(prisma.auditLog.update).toHaveBeenCalledWith({ + where: { id: "audit-1" }, + data: { + asn: 3209, + carrier: "Vodafone GmbH", + }, + }); + }); + + it("writes location only when the ASN lookup misses", async () => { + vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE"); + vi.mocked(lookupIpAsn).mockReturnValueOnce(null); + + await auditLog("auth.login.password", { + userId: "user-16", + ipAddress: "192.0.2.1", + }); + await flush(); + + expect(prisma.auditLog.update).toHaveBeenCalledWith({ + where: { id: "audit-1" }, + data: { location: "Berlin, DE" }, + }); + }); + + it("does NOT update when both resolvers miss", async () => { + vi.mocked(lookupIpLocation).mockResolvedValueOnce(null); + vi.mocked(lookupIpAsn).mockReturnValueOnce(null); + + await auditLog("auth.login.password", { + userId: "user-17", + ipAddress: "192.0.2.2", + }); + await flush(); + + expect(prisma.auditLog.update).not.toHaveBeenCalled(); + }); + + it("carries a null carrier through when the ASN row has no organisation", async () => { + vi.mocked(lookupIpLocation).mockResolvedValueOnce(null); + vi.mocked(lookupIpAsn).mockReturnValueOnce({ asn: 64500, carrier: null }); + + await auditLog("auth.login.password", { + userId: "user-18", + ipAddress: "192.0.2.3", + }); + await flush(); + + expect(prisma.auditLog.update).toHaveBeenCalledWith({ + where: { id: "audit-1" }, + data: { asn: 64500, carrier: null }, + }); + }); }); diff --git a/src/lib/auth/audit.ts b/src/lib/auth/audit.ts index be1f49363..d2e1efcbb 100644 --- a/src/lib/auth/audit.ts +++ b/src/lib/auth/audit.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/db"; -import { lookupIpLocation } from "@/lib/geo"; +import { lookupIpAsn, lookupIpLocation } from "@/lib/geo"; export async function auditLog( action: string, @@ -18,20 +18,42 @@ export async function auditLog( }, }); - // Fire-and-forget: resolve IP location and update the entry (3s timeout) + // Fire-and-forget: resolve IP location + carrier and update the entry + // with whichever fields the resolver could fill. v1.4.27 B3 — carrier + // resolution is synchronous offline MMDB read so it cannot outrun the + // location-lookup 3 s race; we run both inside the same race so a + // hung online provider still bounds the await window. if (opts.ipAddress && action.startsWith("auth.")) { + const ipAddress = opts.ipAddress; const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 3000), ); - Promise.race([lookupIpLocation(opts.ipAddress), timeout]) - .then((location) => { - if (location) { - return prisma.auditLog.update({ - where: { id: entry.id }, - data: { location }, - }); + Promise.race([ + Promise.all([ + lookupIpLocation(ipAddress), + Promise.resolve(lookupIpAsn(ipAddress)), + ]), + timeout, + ]) + .then((result) => { + if (!result) return; + const [location, asnRow] = result; + const data: { + location?: string; + asn?: number; + carrier?: string | null; + } = {}; + if (location) data.location = location; + if (asnRow) { + data.asn = asnRow.asn; + data.carrier = asnRow.carrier; } + if (Object.keys(data).length === 0) return; + return prisma.auditLog.update({ + where: { id: entry.id }, + data, + }); }) .catch(() => { // Silently ignore geo lookup failures