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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 16:43:48 +02:00
1 parent c0ec1fecfb
commit 1d8b80698e
4 files changed
+156 -10

No files matched your search

@@ -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;
+7
View File
@@ -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)
+98 -1
View File
@@ -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 },
});
});
});
+31 -9
View File
@@ -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<null>((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