fix(audit): keep refusals and job telemetry out of audit_logs

A *.validation-failed row was written on every malformed request, the
caller's own included, and the PR detection enqueue wrote one per batch.
Neither is a security event and nothing reads them. auditLog now files a
refused attempt only when a delegate made it, for the owner's activity
feed; otherwise it rides the wide event as meta.rejected_attempt. The PR
detection enqueue moves to annotate().
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 06:49:03 +02:00
1 parent ca937ba87a
commit 4979805d11
6 files changed
+96 -41

No files matched your search

@@ -493,13 +493,14 @@ describe("a batch of duplicates only (v1.42)", () => {
await post([weight("uuid-new", new Date(NOW - DAY))]);
expect(enqueuePrDetection).toHaveBeenCalledTimes(1);
expect(enqueueReminderSatisfy).toHaveBeenCalledTimes(1);
// The enqueue is telemetry on the wide event, never an audit row.
expect(
vi
.mocked(auditLog)
.mock.calls.some(
([action]) => action === "personal_records.detection_enqueued",
),
).toBe(true);
).toBe(false);
});
});
+3 -7
View File
@@ -1302,13 +1302,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
});
try {
await enqueuePrDetection(user.id, { silent });
await auditLog("personal_records.detection_enqueued", {
userId: user.id,
details: {
source: "measurement.batch",
batchSize: entries.length,
silent,
},
// Telemetry, not an audit event: it rides the wide event.
annotate({
meta: { pr_detection_enqueued: true, pr_detection_silent: silent },
});
} catch (err) {
// Enqueue failure is non-fatal — the 30-minute fallback cron
+3 -7
View File
@@ -893,13 +893,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
const silent = workouts.length > PR_DETECTION_SILENT_THRESHOLD;
try {
await enqueuePrDetection(user.id, { silent });
await auditLog("personal_records.detection_enqueued", {
userId: user.id,
details: {
source: "workout.batch",
batchSize: workouts.length,
silent,
},
// Telemetry, not an audit event: it rides the wide event.
annotate({
meta: { pr_detection_enqueued: true, pr_detection_silent: silent },
});
} catch (err) {
annotate({
+48
View File
@@ -320,3 +320,51 @@ describe("auditLog", () => {
});
});
});
describe("auditLog — refused attempts", () => {
it("keeps a refusal on the caller's own record out of audit_logs and on the wide event", async () => {
const { WideEventBuilder } = await import("@/lib/logging/event-builder");
const { eventStorage } = await import("@/lib/logging/context");
const evt = new WideEventBuilder("http");
evt.setAuth({ user_id: "owner-1", auth_method: "session" });
await eventStorage.run(evt, () =>
auditLog("measurements.list.validation-failed", {
userId: "owner-1",
details: { issues: [] },
}),
);
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(
(evt.toJSON() as { meta?: Record<string, unknown> }).meta
?.rejected_attempt,
).toBe("measurements.list.validation-failed");
});
it("writes a delegate's refusal under the record it targeted, for the owner's feed", async () => {
const { WideEventBuilder } = await import("@/lib/logging/event-builder");
const { eventStorage } = await import("@/lib/logging/context");
const evt = new WideEventBuilder("http");
evt.setAuth({ user_id: "delegate-1", auth_method: "session" });
evt.setActingAs("owner-1");
await eventStorage.run(evt, () =>
auditLog("measurements.create.validation-failed", { userId: "owner-1" }),
);
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
expect(
vi.mocked(prisma.auditLog.create).mock.calls[0][0].data,
).toMatchObject({
action: "measurements.create.validation-failed",
userId: "owner-1",
actorUserId: "delegate-1",
});
});
it("still writes every other action on the caller's own record", async () => {
await auditLog("measurement.create", { userId: "owner-1" });
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
});
});
+28 -5
View File
@@ -1,7 +1,7 @@
import { settleWithin } from "@/lib/async/settle-within";
import { prisma } from "@/lib/db";
import { lookupIpGeo } from "@/lib/geo";
import { getEvent } from "@/lib/logging/context";
import { annotate, getEvent } from "@/lib/logging/context";
/**
* v1.36.0 — the action a delegated request records against the record it read.
@@ -46,6 +46,26 @@ function actingActorFor(filedUnder: string | null): string | null {
return filedUnder === owner ? actor : null;
}
/**
* A request that was refused before it did anything: `<surface>.validation-failed`.
*
* Every route that parses a body or a query files one of these when the parse
* fails, through `auditLog`, and until v1.43.7 every one of them became an
* `audit_logs` row, on the owner's own requests too. A person's app sending a
* malformed query is not a security event, and nothing reads those rows: the
* activity feed shows refusals only where somebody else acted, and the wide
* event already carries the refusal. They were telemetry in the audit table,
* a few per sync on a busy client, growing the one table a self-hoster keeps.
*
* So a refused attempt is written only where it means something to someone
* other than the caller: a delegated request, filed under the record it
* targeted, which is what the owner's activity feed reads. Everywhere else it
* stays on the wide event (`meta.rejected_attempt`).
*/
export function isRejectedAttemptAction(action: string): boolean {
return action.endsWith(".validation-failed");
}
export async function auditLog(
action: string,
opts: {
@@ -60,14 +80,17 @@ export async function auditLog(
) {
const userId = opts.userId ?? null;
const client = opts.client ?? prisma;
const actorUserId =
opts.actorUserId === undefined ? actingActorFor(userId) : opts.actorUserId;
if (isRejectedAttemptAction(action) && actorUserId === null) {
annotate({ meta: { rejected_attempt: action } });
return;
}
const entry = await client.auditLog.create({
data: {
action,
userId,
actorUserId:
opts.actorUserId === undefined
? actingActorFor(userId)
: opts.actorUserId,
actorUserId,
details: opts.details ? JSON.stringify(opts.details) : null,
ipAddress: opts.ipAddress ?? null,
},
@@ -377,8 +377,9 @@ describe("PUT /api/dashboard/widgets — Save persists full layouts", () => {
// v1.4.42 W2 — the route used to return only `issues[0].message`,
// which forced one round-trip per wrong field during iOS contract
// debugging. The route now returns every issue under `details.issues`
// AND writes a `dashboard.widgets.validation-failed` row to the
// append-only audit ledger so the operator can grep `/api/admin/audit`.
// and keeps the refusal on the wide event (`meta.rejected_attempt`);
// since v1.43.7 a refusal on the caller's own record no longer writes an
// audit row.
describe("PUT 422 — multi-issue envelope (v1.4.42 W2)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
const { userId } = await seedUser();
@@ -406,25 +407,15 @@ describe("PUT /api/dashboard/widgets — Save persists full layouts", () => {
const paths = body.details.issues.map((i) => i.path).sort();
expect(paths).toEqual(["version", "widgets"]);
// Audit-ledger breadcrumb. The route emits the row fire-and-forget,
// so poll until it materialises instead of asserting after one fixed
// tick — under container load the detached insert can land well past
// 25 ms, which made this assertion flaky. Bounded at ~2 s.
const deadline = Date.now() + 2_000;
let audit: { details: string | null } | null = null;
while (audit === null && Date.now() < deadline) {
audit = await getPrismaClient().auditLog.findFirst({
where: { userId, action: "dashboard.widgets.validation-failed" },
});
if (audit === null) {
await new Promise((r) => setTimeout(r, 50));
}
}
expect(audit).not.toBeNull();
const details = JSON.parse(audit?.details ?? "{}") as {
issues: Array<{ path: string }>;
};
expect(details.issues.length).toBe(2);
// A refusal on the caller's own record is telemetry, not an audit
// event: it stays on the wide event and never reaches the ledger
// (only a delegate's refusal is filed, for the owner's feed). Give a
// detached insert time to land before asserting it did not.
await new Promise((r) => setTimeout(r, 300));
const audit = await getPrismaClient().auditLog.findFirst({
where: { userId, action: "dashboard.widgets.validation-failed" },
});
expect(audit).toBeNull();
});
it("surfaces THREE simultaneous validation errors", async () => {