fix(audit): readable names, single writer, denial rows, redacted args

The activity log recorded Start's hashed function id (a sha256 URL
segment) as the name; the middleware now reads compile-time
serverFnMeta.name with the path segment as fallback. toServerResult
resolves { success:false } instead of throwing, so the middleware logged
failures as successes while toServerResult wrote a duplicate row with its
own dead name parser — the middleware is now the single writer and reads
the envelope's success flag. Admin denials, which threw before the logging
middleware ran, get their own audit row. Arguments are redacted
(phone/otp/token keys) and truncated at 2KB. The admin activities search
binds its filter parameters (likePattern escaping) instead of
interpolating raw input. Adds vitest with node-env tests for the
middleware, redaction, and filter utils, and extends logging coverage to
mutating fns that lacked it.
This commit is contained in:
2026-08-23 18:27:53 -07:00
parent 60e91d1371
commit 9fc79dfc07
18 changed files with 691 additions and 67 deletions
+79 -13
View File
@@ -1,6 +1,7 @@
import { createMiddleware } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
import { Logger } from "@/lib/logger";
import { redactValue } from "@/lib/redact";
import type { ActivityInput } from "@/lib/pocketbase/services/activities";
const logger = new Logger("Activities");
@@ -16,16 +17,67 @@ const recordActivity = (activity: ActivityInput) => {
});
};
const MAX_ARGUMENTS_CHARS = 2048;
const redactArguments = (data: unknown): unknown => {
if (data === undefined) return undefined;
const redacted = redactValue(data);
const serialized = JSON.stringify(redacted) ?? "";
if (serialized.length > MAX_ARGUMENTS_CHARS) {
return { truncated: serialized.slice(0, MAX_ARGUMENTS_CHARS) };
}
return redacted;
};
const auditErrorMessage = (
error?: { code?: string; userMessage?: string }
): string => {
if (!error) return "error";
return [error.code, error.userMessage].filter(Boolean).join(": ") || "error";
};
type Actor = { player_id?: string };
type ServerFnMeta = { name?: string };
// Start's default generateFunctionId hashes the entry id, so the URL segment is
// a sha256 and the compile-time meta is the only readable name. The path stays
// as the fallback for requests that carry no meta.
const serverFnName = (request: Request, meta?: ServerFnMeta): string => {
if (meta?.name) return meta.name;
const pathParts = new URL(request.url).pathname.split("/").filter(Boolean);
return pathParts[pathParts.length - 1] || "unknown";
};
// The admin middleware refuses before serverFnLoggingMiddleware ever runs, so
// a denial has to write its own row — otherwise an operator reviewing the log
// during a probing attempt sees a clean history. The rejected payload is left
// out: it never reached a validator, so it is unbounded attacker input.
export const recordDeniedServerFn = (
request: Request,
actor?: Actor,
meta?: ServerFnMeta
) => {
recordActivity({
name: serverFnName(request, meta),
player: actor?.player_id,
duration: 0,
success: false,
error: "FORBIDDEN: Access denied",
user_agent: request.headers.get("user-agent") || undefined,
});
};
export const serverFnLoggingMiddleware = createMiddleware({
type: "function",
}).server(async ({ next, data, context }) => {
}).server(async ({ next, data, context, serverFnMeta }) => {
const request = getRequest();
const url = new URL(request.url);
const pathParts = url.pathname.split('/').filter(Boolean);
const serverFnName = pathParts[pathParts.length - 1] || 'unknown';
const userId = (context as any)?.metadata?.player_id || 'unknown';
const userAgent = request.headers.get('user-agent') || undefined;
const name = serverFnName(request, serverFnMeta);
const playerId = (context as any)?.metadata?.player_id as string | undefined;
const userAgent = request.headers.get("user-agent") || undefined;
const safeArgs = redactArguments(data);
const startTime = Date.now();
@@ -33,12 +85,26 @@ export const serverFnLoggingMiddleware = createMiddleware({
const result = await next();
const duration = Date.now() - startTime;
// Single audit writer; toServerResult resolves { success:false } instead of throwing, so read the flag.
const envelope = (result as { result?: unknown })?.result;
const failed =
!!envelope &&
typeof envelope === "object" &&
"success" in envelope &&
(envelope as { success: boolean }).success === false;
recordActivity({
name: serverFnName,
player: userId !== 'unknown' ? userId : undefined,
name,
player: playerId,
duration,
success: true,
arguments: data,
success: !failed,
error: failed
? auditErrorMessage(
(envelope as { error?: { code?: string; userMessage?: string } })
.error
)
: undefined,
arguments: safeArgs,
user_agent: userAgent,
});
@@ -48,12 +114,12 @@ export const serverFnLoggingMiddleware = createMiddleware({
const errorMessage = error instanceof Error ? error.message : String(error);
recordActivity({
name: serverFnName,
player: userId !== 'unknown' ? userId : undefined,
name,
player: playerId,
duration,
success: false,
error: errorMessage,
arguments: data,
arguments: safeArgs,
user_agent: userAgent,
});