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