mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user