Files
HealthLog/src/app/api/admin/audit-log/route.ts
T
Marc-André Bombeck 84683f4ce9 perf: bound heavy-tenant memory on feature, consolidation, backup and auth paths
Cap and page the unbounded reads that let a multi-year tenant pull its
whole measurement history into memory on hot paths.

- extractFeatures: read newest-first with a 6000-row cap (mirrors the
  Coach snapshot) and reverse to ascending, so a 365-400 day window no
  longer materialises hundreds of thousands of PULSE / glucose rows per
  Coach turn and nightly briefing. Full-history extremes still come from
  readAllTimeExtremes, so the cap never relabels an extreme.
- Add a (userId, measuredAt) index on measurements (migration 0210) for
  the type-less range scans that extractFeatures and the consolidation
  passes drive; the existing (userId, type, measuredAt) cannot serve them
  as a prefix.
- runConsolidation: keyset-paginate the per-(user, type) source scan on
  (measuredAt, id) instead of one unbounded findMany, removing the giant
  single result-set allocation that drove the boot storm. Output is
  identical; covered by a paging unit test.
- Stagger the boot-time backfill enqueues with increasing startAfter
  offsets so the heavy full-history loads no longer all drain in parallel
  onto one tenant at startup. Non-boot callers keep their immediate send.
- Backups: replace load-all-then-stringify with paged narrow-select reads
  for the weekly and off-host writers, bounding transient heap. Off-host
  still includes soft-deleted rows. Covered by a serialize/restore
  round-trip test.
- Drop the per-request auth.bearer.success audit insert; the wide event
  already records auth_method and user_id, so the polling client no longer
  drives a write + pool checkout on every request. Failure-path audit
  stays.
- Coach conversation detail: cap the message include to the newest 200 so
  a long thread no longer decrypts every message on each open; response
  shape unchanged.
- Admin audit-log: floor the scan at the retention horizon so the listing
  and its count stop sweeping open-ended over the substring filters.
2026-06-28 03:07:15 +02:00

192 lines
7.1 KiB
TypeScript

import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { redactSecrets } from "@/lib/logging/redact";
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
/**
* v1.4.16 phase B4: parse the union of legacy + new query params. Legacy
* `limit`/`offset`/`filter` keys still work so the existing
* `<RecentAuditPreview>` and `<LoginOverviewSection>` callers don't break;
* new `page`/`perPage`/`actor`/`action`/`target`/`since`/`until` drive
* the deepened admin viewer.
*/
const ALLOWED_PER_PAGE = [25, 50, 100] as const;
const querySchema = z.object({
// Legacy
limit: z.coerce.number().int().min(1).max(500).optional(),
offset: z.coerce.number().int().min(0).default(0),
filter: z.enum(["auth", "all"]).optional(),
// New
page: z.coerce.number().int().min(1).default(1),
perPage: z.coerce.number().int().optional(),
actor: z.string().trim().min(1).max(200).optional(),
action: z.string().trim().min(1).max(200).optional(),
target: z.string().trim().min(1).max(200).optional(),
since: z.string().datetime().optional(),
until: z.string().datetime().optional(),
});
export const dynamic = "force-dynamic";
/**
* Admin endpoint: list audit log entries.
*
* Filters (all optional, AND-combined):
* - actor — substring match against `userId` OR `user.username`
* - action — exact match (e.g. `auth.login.failed`)
* - target — substring match against the JSON-encoded `details` field
* - since — ISO-8601 lower bound on `createdAt`. Absent, the scan
* floors at the audit-log retention horizon so an
* open-ended sweep can't seqscan the table unbounded.
* - until — ISO-8601 upper bound on `createdAt`
* - filter — legacy shortcut: `auth` restricts to `auth.*` actions
*
* Pagination:
* - `page` + `perPage` (25/50/100; default 50). Out-of-range `perPage`
* falls back to the default rather than 400-ing so a stale UI never
* gets stuck on a request the server refuses.
* - Legacy `limit` + `offset` honored when present.
*
* Response: `{ entries, meta: { total, page, perPage, limit, offset } }`.
*/
export const GET = apiHandler(async (request: NextRequest) => {
await requireAdmin();
annotate({ action: { name: "admin.audit-log.list" } });
const { searchParams } = new URL(request.url);
const result = querySchema.safeParse({
limit: searchParams.get("limit") ?? undefined,
offset: searchParams.get("offset") ?? undefined,
filter: searchParams.get("filter") ?? undefined,
page: searchParams.get("page") ?? undefined,
perPage: searchParams.get("perPage") ?? undefined,
actor: searchParams.get("actor") ?? undefined,
action: searchParams.get("action") ?? undefined,
target: searchParams.get("target") ?? undefined,
since: searchParams.get("since") ?? undefined,
until: searchParams.get("until") ?? undefined,
});
if (!result.success) return returnAllZodIssues(result.error);
const parsed = result.data;
// Resolve perPage: clamp to allowed set, fall back to 50.
const perPageResolved =
parsed.perPage !== undefined &&
(ALLOWED_PER_PAGE as readonly number[]).includes(parsed.perPage)
? parsed.perPage
: 50;
// Pagination: legacy `limit`/`offset` win when present so the existing
// callers (preview component, login-overview filter buttons) keep their
// exact behaviour. Otherwise derive from `page`/`perPage`.
const take = parsed.limit ?? perPageResolved;
const skip =
parsed.limit !== undefined
? parsed.offset
: (parsed.page - 1) * perPageResolved;
// Build the WHERE filter compositionally so each filter is independent.
const ands: Record<string, unknown>[] = [];
if (parsed.filter === "auth") {
ands.push({ action: { startsWith: "auth." } });
}
if (parsed.action) {
ands.push({ action: parsed.action });
}
if (parsed.actor) {
ands.push({
OR: [
{ userId: parsed.actor },
{
user: {
username: { contains: parsed.actor, mode: "insensitive" },
},
},
],
});
}
if (parsed.target) {
// `details` is a JSON-encoded string column, so a substring match on
// the raw text is the cheapest cross-field "target" filter.
ands.push({ details: { contains: parsed.target } });
}
// Bound the lower edge of the scan. `actor`/`target` are substring
// matches (seqscans) and the exact `count` below runs a second pass over
// the same predicate, so an open-ended history sweep on this admin-only,
// low-frequency surface would scan the table twice with no floor. The
// cleanup job already prunes rows past the retention horizon, so aligning
// the default floor to that same horizon bounds both passes without ever
// hiding a row the deployment still keeps. An explicit `since` overrides.
const lowerBound = parsed.since
? new Date(parsed.since)
: new Date(Date.now() - getAuditLogRetentionDays() * 86_400_000);
const createdAt: { gte: Date; lte?: Date } = { gte: lowerBound };
if (parsed.until) createdAt.lte = new Date(parsed.until);
// Keep `createdAt` a top-level sibling of the optional filter clause so a
// single substring/exact filter stays flat (no `AND` wrapper); the
// collapse below only folds the independent filter `ands` entries.
const filterWhere: Record<string, unknown> =
ands.length === 0 ? {} : ands.length === 1 ? ands[0] : { AND: ands };
const where: Record<string, unknown> = { ...filterWhere, createdAt };
const [entries, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: "desc" },
take,
skip,
select: {
id: true,
action: true,
ipAddress: true,
location: true,
// v1.4.27 B3 — surface the carrier chip data alongside the
// existing location string. Both nullable so older rows render
// with the same dash that `location` falls back to.
asn: true,
carrier: true,
details: true,
createdAt: true,
user: {
select: { id: true, username: true },
},
},
}),
prisma.auditLog.count({ where }),
]);
// v1.4.16 phase D reconcile (security H2) — `details` is a JSON string
// column written by `auditLog()` calls across the app. A user-controlled
// value can land in here (e.g. `auth.login.failed → details.identifier`)
// and a typo'd "Bearer hlk_..." in a username field would otherwise be
// shown verbatim in the admin viewer + CSV export. Run the redaction
// pass at egress so already-persisted rows are scrubbed too.
const redactedEntries = entries.map((entry) => ({
...entry,
details: entry.details ? redactSecrets(entry.details) : entry.details,
}));
return apiSuccess({
entries: redactedEntries,
meta: {
total,
// Both shapes returned so old callers keep working.
limit: take,
offset: skip,
page: parsed.page,
perPage: perPageResolved,
},
});
});