feat(phase4): rate limit + audit archive + panel backup + DR docs

- src/lib/rate-limit.ts (Redis sliding window, fail-open)
- before-hook on /sign-in/email: 5 attempts/min per IP+email
- worker job audit-archive (daily 03:30, JSONL → MinIO, 90d retention)
- worker job panel-backup (daily 04:00, pg_dump -Fc -Z9 → MinIO)
- Dockerfile adds postgresql16-client
- scripts/restore-drill.sh restores latest dump into panel_drill
- docs/disaster-recovery.md + docs/phase4-deferred.md (mTLS + Infisical rationale)
This commit is contained in:
Semih
2026-05-13 11:14:36 +00:00
parent ed79f4eacd
commit 909cacf7b3
12 changed files with 583 additions and 15 deletions

View File

@@ -1,9 +1,11 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { createAuthMiddleware } from "better-auth/api";
import { APIError, createAuthMiddleware } from "better-auth/api";
import { prisma } from "./db";
import { rateLimit } from "./rate-limit";
const AUDITED_PATHS = new Set(["/sign-in/email", "/sign-out"]);
const SIGN_IN_LIMIT_PER_MIN = 5;
export const auth = betterAuth({
database: prismaAdapter(prisma, { provider: "postgresql" }),
@@ -25,6 +27,21 @@ export const auth = betterAuth({
useSecureCookies: process.env.NODE_ENV === "production",
},
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (ctx.path !== "/sign-in/email") return;
const body = (ctx.body ?? {}) as { email?: string };
const ip =
ctx.request?.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
ctx.request?.headers.get("x-real-ip") ??
"unknown";
const key = `auth:signin:${ip}:${(body.email ?? "").toLowerCase()}`;
const r = await rateLimit(key, SIGN_IN_LIMIT_PER_MIN, 60);
if (!r.ok) {
throw new APIError("TOO_MANY_REQUESTS", {
message: `Too many sign-in attempts. Try again in ${Math.ceil(r.resetMs / 1000)}s.`,
});
}
}),
after: createAuthMiddleware(async (ctx) => {
if (!AUDITED_PATHS.has(ctx.path)) return;
const status = ctx.context.returned ? 200 : 400;

View File

@@ -0,0 +1,45 @@
import IORedis from "ioredis";
const url = process.env.REDIS_URL;
let _redis: IORedis | null = null;
function getRedis(): IORedis | null {
if (!url) return null;
if (_redis) return _redis;
_redis = new IORedis(url, { maxRetriesPerRequest: 1, enableReadyCheck: false, lazyConnect: false });
_redis.on("error", (e) => console.warn("[rate-limit] redis:", e.message));
return _redis;
}
export type RateResult = { ok: boolean; remaining: number; resetMs: number };
/**
* Sliding fixed-window counter: INCR a per-window key, expire it on first hit.
* Fail-open: if Redis is down, allow the request (don't lock the owner out).
*/
export async function rateLimit(
key: string,
limit: number,
windowSec: number,
): Promise<RateResult> {
const r = getRedis();
if (!r) return { ok: true, remaining: limit, resetMs: windowSec * 1000 };
const bucket = Math.floor(Date.now() / 1000 / windowSec);
const k = `rl:${key}:${bucket}`;
try {
const [count] = (await r.multi().incr(k).expire(k, windowSec).exec()) as [
[Error | null, number],
[Error | null, number],
];
if (count[0]) throw count[0];
const n = count[1];
return {
ok: n <= limit,
remaining: Math.max(0, limit - n),
resetMs: (bucket + 1) * windowSec * 1000 - Date.now(),
};
} catch (e) {
console.warn("[rate-limit] failing open:", e instanceof Error ? e.message : e);
return { ok: true, remaining: limit, resetMs: windowSec * 1000 };
}
}