Files
HealthLog/src/lib/logging/event-builder.ts
T
Marc-André Bombeck e9029ed3c2 feat(sharing): resolve the acting account inside the auth layer
One resolver, both transports, on every request. The cookie path reads
the carrier off the session row it has already loaded; the Bearer path
reads the `X-HealthLog-Account` selector header, because stamping switch
state onto a long-lived token would make the credential itself ambient
authority. Two carriers converge on one function for the same reason
`resolveBearerToken` lives in one file for two wires.

Three declared modes. `requireRecordAuth("read" | "write")` honours a
valid switch and substitutes the owner as the data scope while keeping
the delegate as the actor. `requireActorAuth()` always serves the
caller's own rows and keeps working under a switch, which is what the
switcher and the banner need.

Bare `requireAuth()` refuses to run under a switch at all, with a stable
`sharing.not_permitted` code. It does not fall back to the caller's own
account, and that arm is the point of the whole design: a delegate who
believes they are reading the owner's record and is handed their own is
the same data mixing pointed the other way. A route nobody has reviewed
is inaccessible while switched, never wrongly accessible.

The grant is loaded from Postgres on the request that uses it. Nothing
memoises the verdict, so a revocation lands on the delegate's next
request rather than their next login.

Refusals do not enumerate. The denial takes no constructor arguments and
carries a fixed message, and neither branch looks the named account up:
an account that does not exist and one that granted nothing produce the
same empty row from the same query, so the two answers are identical in
bytes and in timing rather than by careful wording. The reason reaches
the audit row and the wide event and nothing else.

`getSession()` still answers who is calling. That is what leaves
`requireAdmin`, `requireCookieAuth` and `requireFreshMfa` structurally
out of a switch's reach; the row's carrier rides out beside the session
id as a value to be checked, never as a decision already made.

The selector header is a Bearer carrier only. Sent over the cookie
transport, or to an actor surface, it is refused rather than ignored. A
client that names an account on a route which never reads one has a bug,
and a correct response to a wrong expectation hides that bug until it
arrives as a support case about data that looks wrong.

Five test files gain the session shape their fixtures always claimed to
have; they were building an auth context without one.
2026-08-02 11:32:00 +02:00

234 lines
7.5 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type { WideEvent, LogLevel, EventKind } from "./types";
import { LOG_LEVEL_PRIORITY } from "./types";
import { getDeployContext } from "./config";
import { redactOptional, redactSecrets } from "./redact";
/**
* Apply `redactSecrets` to every string a value contains, at any depth.
*
* The builder's contract is that nothing leaves through it unscrubbed. Strings
* nested inside an object or array are just as visible in the emitted JSON as a
* top-level one, so the walk has to reach them. Non-strings pass through
* untouched; the structure is rebuilt rather than mutated so a caller's object
* is never modified behind its back.
*
* The depth bound stops a cyclic or pathologically nested value from turning a
* log write into a stack overflow. Anything past it is dropped rather than
* emitted unscrubbed — an unreadable log line is recoverable, a leaked
* credential is not.
*/
function redactDeep<T>(value: T, depth = 0): T {
if (depth > 8) return "[redacted: too deep]" as unknown as T;
if (typeof value === "string") return redactSecrets(value) as unknown as T;
if (Array.isArray(value)) {
return value.map((v) => redactDeep(v, depth + 1)) as unknown as T;
}
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = redactDeep(v, depth + 1);
}
return out as unknown as T;
}
return value;
}
/**
* Baut ein Wide Event Schritt fuer Schritt auf.
* Wird am Anfang eines Requests/Tasks erstellt und am Ende emittiert.
*/
export class WideEventBuilder {
private event: Partial<WideEvent>;
private startTime: number;
private highestLevel: LogLevel = "info";
constructor(kind: EventKind = "http") {
this.startTime = performance.now();
this.event = {
timestamp: new Date().toISOString(),
request_id: randomUUID(),
trace_id: randomUUID(),
kind,
service: "healthlog",
environment: process.env.NODE_ENV || "development",
deploy: getDeployContext(),
level: "info",
};
}
setRequestId(id: string): this {
this.event.request_id = id;
return this;
}
setTraceId(id: string): this {
this.event.trace_id = id;
return this;
}
setHttp(http: WideEvent["http"]): this {
if (http) {
// Fix-J (v1.4.25 W21): the Withings webhook ships its shared
// secret as a path segment, so `path` and `route` carry the
// secret unless we scrub on the way in. `redactSecrets` is the
// single egress-redaction surface (see redact.ts for the rules
// and the PATH_SECRET_PATHS registry).
this.event.http = {
...http,
path: redactSecrets(http.path),
route: redactSecrets(http.route),
};
} else {
this.event.http = http;
}
return this;
}
setAuth(auth: WideEvent["auth"]): this {
this.event.auth = auth;
return this;
}
/**
* v1.36.0 — record that this request acted on another account's record.
*
* Merges rather than replaces, because the actor identity is already on the
* event by the time the acting account is resolved and overwriting it is the
* exact confusion the two fields exist to prevent.
*/
setActingAs(userId: string): this {
this.event.auth = { ...(this.event.auth ?? {}), acting_as: userId };
return this;
}
setAction(action: WideEvent["action"]): this {
this.event.action = action;
return this;
}
setBackground(bg: WideEvent["background"]): this {
this.event.background = bg;
return this;
}
setError(err: unknown): this {
this.elevateLevel("error");
if (err instanceof Error) {
this.event.error = {
type: err.constructor.name,
message: redactSecrets(err.message),
stack: redactOptional(err.stack),
code: (err as { statusCode?: number }).statusCode,
};
} else {
this.event.error = {
type: "Unknown",
message: redactSecrets(String(err)),
};
}
return this;
}
addWarning(msg: string): this {
this.elevateLevel("warn");
if (!this.event.warnings) this.event.warnings = [];
this.event.warnings.push(msg);
return this;
}
addExternalCall(call: NonNullable<WideEvent["external_calls"]>[0]): this {
if (!this.event.external_calls) this.event.external_calls = [];
// An outbound failure carries whatever the remote said, and for several
// integrations that is the request URL — which for Telegram embeds the bot
// token. `setError` and `setHttp` scrub on the way in; this entry point did
// not, so a credential that is encrypted at rest reached stdout and the log
// store in plaintext. Scrub here too: the redaction contract is the
// builder's, not each caller's.
this.event.external_calls.push(redactDeep(call));
return this;
}
addDbQuery(durationMs: number): this {
if (!this.event.db)
this.event.db = { query_count: 0, query_duration_ms: 0 };
this.event.db.query_count++;
this.event.db.query_duration_ms += durationMs;
return this;
}
setRateLimit(info: WideEvent["rate_limit"]): this {
this.event.rate_limit = info;
return this;
}
addMeta(key: string, value: unknown): this {
if (!this.event.meta) this.event.meta = {};
// Meta is emitted verbatim: the finished event is JSON-stringified whole to
// stdout and the log store. It was the one builder entry point that did not
// scrub, which made it a trusted sink by accident rather than by design —
// and the AI provider chain feeds upstream error bodies through it, so a
// gateway that echoes the offending request put prompt content and
// non-standard credentials into the logs. Scrub every string, at any depth.
this.event.meta[key] = redactDeep(value);
return this;
}
/** Level nur hochstufen, nie runter */
elevateLevel(level: LogLevel): this {
if (LOG_LEVEL_PRIORITY[level] > LOG_LEVEL_PRIORITY[this.highestLevel]) {
this.highestLevel = level;
}
return this;
}
/** Request/Operation beenden, Duration berechnen */
finish(httpStatus?: number): this {
this.event.duration_ms = Math.round(performance.now() - this.startTime);
// Event-Loop-Lag des letzten Sampling-Fensters anheften, falls der
// Monitor laeuft. Bewusst ueber den Global-Slot statt Import gelesen:
// dieser Builder wird in die Edge-Runtime gebuendelt, wo perf_hooks
// nicht existiert — der Slot ist dort einfach leer.
const lag = (
globalThis as unknown as {
[key: symbol]: { loop_max_ms: number; loop_last_ms: number };
}
)[Symbol.for("healthlog.eventLoopLag")];
if (lag) this.event.runtime = lag;
if (httpStatus !== undefined && this.event.http) {
this.event.http.status = httpStatus;
}
if (httpStatus !== undefined) {
if (httpStatus >= 500) this.elevateLevel("error");
else if (httpStatus >= 400) this.elevateLevel("warn");
}
this.event.level = this.highestLevel;
return this;
}
toJSON(): WideEvent {
return structuredClone(this.event) as WideEvent;
}
getDurationMs(): number {
return Math.round(performance.now() - this.startTime);
}
getLevel(): LogLevel {
return this.highestLevel;
}
/** The HTTP method of the current request, if one was attached. */
getHttpMethod(): string | undefined {
return this.event.http?.method;
}
getRequestId(): string {
return this.event.request_id!;
}
getTraceId(): string {
return this.event.trace_id!;
}
}