mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(api): meter every authenticated write where the caller is resolved
169 of 364 mutating handlers met no rate limit, every PATCH and DELETE on an [id] among them. The shared per-account write bucket (300 a minute, keyed on the acting account) was called by hand in a dozen creates; it is now charged by the identity resolvers for any POST, PUT, PATCH or DELETE, once per request, so a route is metered by resolving its caller. The bulk ingest routes the native client syncs through in bursts (measurements, workouts, intake, mood and cycle bulk, nutrients, the ECG sweep) declare writeBucket: "own" and keep their dedicated ceilings, so a large HealthKit backfill cannot lock the person out of logging a dose. A read-over-POST stays free. write-rate-limit-guard fails on any mutating route that neither resolves its caller through a metering resolver nor calls a limiter, unless it is on a short reasoned exception list; an opt-out must be on its own reviewed list and call its own limiter.
This commit is contained in:
1 parent
3e9594b3b8
commit
39d23dbb7d
33 files changed
+730
-216
No files matched your search
@@ -55,6 +55,7 @@ import {
|
||||
attachRecordContextEcho,
|
||||
} from "./sharing/record-session-fence";
|
||||
import { refuseOnKeyMismatch } from "./boot/key-mismatch-refusal";
|
||||
import { meterAccountWrite, type WriteMeterOptions } from "./auth/write-meter";
|
||||
import { acceptIncomingRequestId } from "./http/request-id";
|
||||
import { withErrorEnvelopeDefaults } from "./api-response";
|
||||
import {
|
||||
@@ -545,12 +546,16 @@ export function isScopedCredential(auth: AuthContext): boolean {
|
||||
*/
|
||||
export async function requireAuth(
|
||||
requiredPermission?: string,
|
||||
options?: Pick<WriteMeterOptions, "writeBucket">,
|
||||
): Promise<AuthContext> {
|
||||
const auth = await authenticateCaller(requiredPermission);
|
||||
const carrier = await readActingCarrier(auth);
|
||||
if (carrier.kind !== "none") {
|
||||
throw refuseUndeclaredMode(auth, carrier);
|
||||
}
|
||||
// The shared write ceiling, for every write this resolver admits — see
|
||||
// `meterAccountWrite`.
|
||||
await meterAccountWrite(auth.user.id, options);
|
||||
return auth;
|
||||
}
|
||||
|
||||
@@ -866,6 +871,7 @@ export async function requireActorAuth(): Promise<AuthContext> {
|
||||
auditRefusal(auth, { kind: "header", accountId: header }, "actor_surface");
|
||||
throw new SharingNotPermittedError();
|
||||
}
|
||||
await meterAccountWrite(auth.user.id);
|
||||
return auth;
|
||||
}
|
||||
|
||||
@@ -913,6 +919,13 @@ export interface RecordAuthOptions {
|
||||
* above `"read"`.
|
||||
*/
|
||||
readonly readOverPost?: true;
|
||||
/**
|
||||
* This route meters its writes with a dedicated bucket instead of the shared
|
||||
* per-account one (`meterAccountWrite`). Frozen per route, with the reason,
|
||||
* by `write-rate-limit-guard.test.ts`, which also requires the route to call
|
||||
* its own limiter.
|
||||
*/
|
||||
readonly writeBucket?: "own";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -966,6 +979,13 @@ export async function requireRecordAuth(
|
||||
// fence reads, so the invariant is "before the carrier", not "before the
|
||||
// first database await".
|
||||
await assertRecordSessionFence(auth);
|
||||
// The shared write ceiling, charged to the ACTING account before any grant
|
||||
// is read, so a refused write counts like an admitted one — see
|
||||
// `meterAccountWrite`.
|
||||
await meterAccountWrite(auth.user.id, {
|
||||
readOverPost: options?.readOverPost,
|
||||
writeBucket: options?.writeBucket,
|
||||
});
|
||||
const carrier = await readActingCarrier(auth);
|
||||
|
||||
// A narrow, single-purpose Bearer never acts on another record.
|
||||
@@ -1063,6 +1083,7 @@ export async function requireGuardianAuth(): Promise<RecordAuthContext> {
|
||||
// a context that has since moved must be refused before the marker is read,
|
||||
// not after.
|
||||
await assertRecordSessionFence(auth);
|
||||
await meterAccountWrite(auth.user.id);
|
||||
const carrier = await readActingCarrier(auth);
|
||||
|
||||
if (carrier.kind === "none") {
|
||||
@@ -1207,6 +1228,7 @@ export async function requireAdmin(): Promise<AuthContext> {
|
||||
if (sessionData.user.role !== "ADMIN") {
|
||||
throw new HttpError(403, "Admin access required", AUTH_ERROR_CODES.admin);
|
||||
}
|
||||
await meterAccountWrite(sessionData.user.id);
|
||||
return { ...sessionData, authMethod: "cookie" };
|
||||
}
|
||||
|
||||
@@ -1233,6 +1255,7 @@ export async function requireCookieAuth(): Promise<AuthContext> {
|
||||
auth_method: "session",
|
||||
});
|
||||
}
|
||||
await meterAccountWrite(sessionData.user.id);
|
||||
return { ...sessionData, authMethod: "cookie" };
|
||||
}
|
||||
|
||||
@@ -1343,6 +1366,8 @@ export async function requireFreshMfa(
|
||||
});
|
||||
}
|
||||
|
||||
await meterAccountWrite(sessionData.user.id);
|
||||
|
||||
// The account must hold a credential that can stamp `mfaVerifiedAt` in the
|
||||
// first place. Without one a step-up-gated action is unreachable by design
|
||||
// rather than merely refused, so the refusal is named differently
|
||||
@@ -1661,6 +1686,7 @@ export async function requireBearerAuth(): Promise<BearerAuthContext> {
|
||||
}
|
||||
const raw = authHeader.slice(7);
|
||||
const auth = await authenticateBearer(raw, undefined);
|
||||
await meterAccountWrite(auth.user.id);
|
||||
return {
|
||||
...auth,
|
||||
apiTokenId: auth.session.id,
|
||||
|
||||
Reference in new issue
Block a user