From b972c5318108cc19f262cd1e066f46becd49cebf Mon Sep 17 00:00:00 2001 From: Paranoa-dev <287413997+Paranoa-dev@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:35:25 +0100 Subject: [PATCH] fix: add server-side authorization to admin audit-log route The GET /api/admin/audit-log endpoint now verifies the caller's wallet address matches the pool's creator_address before returning data. Previously, the creator-only restriction was only enforced in the React component, leaving the API route accessible to anyone with a valid poolId. Changes: - Accept callerAddress query param in the route - Compare it (case-insensitive) against pool.creator_address from DB - Return 403 Forbidden on mismatch - Update AdminAuditLog component to pass callerAddress - Add unit tests covering auth and consistency logic --- .../app/api/admin/audit-log/route.test.ts | 112 ++++++++++++++++++ frontend/app/api/admin/audit-log/route.ts | 18 ++- frontend/components/group/admin-audit-log.tsx | 9 +- frontend/package.json | 2 +- 4 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 frontend/app/api/admin/audit-log/route.test.ts diff --git a/frontend/app/api/admin/audit-log/route.test.ts b/frontend/app/api/admin/audit-log/route.test.ts new file mode 100644 index 0000000..0f4363e --- /dev/null +++ b/frontend/app/api/admin/audit-log/route.test.ts @@ -0,0 +1,112 @@ +import { test } from "node:test" +import assert from "node:assert" + +// ── Authorization logic (mirrors the server-side check in the route) ───────── + +function isAuthorized(callerAddress: string, creatorAddress: string): boolean { + return callerAddress.toLowerCase() === creatorAddress.toLowerCase() +} + +function getAuthErrorCode( + poolId: string | null, + callerAddress: string | null, + pool: { creator_address: string } | null, +): number | null { + if (!poolId) return 400 + if (!callerAddress) return 400 + if (!pool) return 404 + if (!isAuthorized(callerAddress, pool.creator_address)) return 403 + return null // authorized +} + +// ── Consistency-check logic (mirrors the API route calculation) ────────────── + +function computeConsistency( + activities: { activity_type: string; amount: number | null }[], + recorded: number, +): { inconsistent: boolean; activityNet: number } { + const activityNet = activities.reduce((sum, r) => { + const amt = r.amount ?? 0 + const t = r.activity_type.toLowerCase() + if (t === "deposit") return sum + amt + if (t === "withdraw" || t === "payout") return sum - amt + return sum + }, 0) + return { inconsistent: Math.abs(activityNet - recorded) > 0.01, activityNet } +} + +// ── Authorization tests ───────────────────────────────────────────────────── + +test("authorization — returns 400 when poolId is missing", () => { + const code = getAuthErrorCode(null, "GABC", { creator_address: "GCREATOR" }) + assert.strictEqual(code, 400) +}) + +test("authorization — returns 400 when callerAddress is missing", () => { + const code = getAuthErrorCode("p1", null, { creator_address: "GCREATOR" }) + assert.strictEqual(code, 400) +}) + +test("authorization — returns 404 when pool is not found", () => { + const code = getAuthErrorCode("p1", "GABC", null) + assert.strictEqual(code, 404) +}) + +test("authorization — returns 403 when caller is not the pool creator", () => { + const code = getAuthErrorCode("p1", "GINTRUDER", { creator_address: "GCREATOR" }) + assert.strictEqual(code, 403) +}) + +test("authorization — returns null (authorized) when caller matches creator", () => { + const code = getAuthErrorCode("p1", "GCREATOR", { creator_address: "GCREATOR" }) + assert.strictEqual(code, null) +}) + +test("authorization — case-insensitive address comparison", () => { + assert.strictEqual(isAuthorized("gcreator", "GCREATOR"), true) + assert.strictEqual(isAuthorized("GCREATOR", "gcreator"), true) + assert.strictEqual(isAuthorized("gcreator123", "GCREATOR456"), false) +}) + +// ── Consistency-check tests ────────────────────────────────────────────────── + +test("consistency check — consistent when net matches recorded", () => { + const acts = [ + { activity_type: "deposit", amount: 100 }, + { activity_type: "payout", amount: 40 }, + ] + const { inconsistent, activityNet } = computeConsistency(acts, 60) + assert.strictEqual(inconsistent, false) + assert.strictEqual(activityNet, 60) +}) + +test("consistency check — inconsistent when net diverges from recorded", () => { + const acts = [{ activity_type: "deposit", amount: 100 }] + const { inconsistent } = computeConsistency(acts, 90) + assert.strictEqual(inconsistent, true) +}) + +test("consistency check — tolerates floating-point differences <= 0.01", () => { + const acts = [{ activity_type: "deposit", amount: 100.001 }] + const { inconsistent } = computeConsistency(acts, 100) + assert.strictEqual(inconsistent, false) +}) + +test("consistency check — non-financial activity types are ignored", () => { + const acts = [ + { activity_type: "deposit", amount: 50 }, + { activity_type: "member_joined", amount: null }, + ] + const { inconsistent, activityNet } = computeConsistency(acts, 50) + assert.strictEqual(inconsistent, false) + assert.strictEqual(activityNet, 50) +}) + +test("consistency check — withdraw reduces the net", () => { + const acts = [ + { activity_type: "deposit", amount: 200 }, + { activity_type: "withdraw", amount: 80 }, + ] + const { activityNet } = computeConsistency(acts, 120) + assert.strictEqual(activityNet, 120) +}) diff --git a/frontend/app/api/admin/audit-log/route.ts b/frontend/app/api/admin/audit-log/route.ts index 67901cb..d6b5560 100644 --- a/frontend/app/api/admin/audit-log/route.ts +++ b/frontend/app/api/admin/audit-log/route.ts @@ -17,15 +17,15 @@ export interface AuditRow { } /** - * GET /api/admin/audit-log?poolId= + * GET /api/admin/audit-log?poolId=&callerAddress=
* * Returns all pool_activity rows for the given pool together with a * per-pool consistency flag: `inconsistent` is true when the pool's * recorded `total_saved` diverges from the sum of deposit/withdrawal * activity rows by more than 0.01 (floating-point tolerance). * - * Only the pool creator should call this route; the component enforces - * that guard on the client side. A missing poolId returns 400. + * The caller's wallet address is verified server-side against the + * pool's `creator_address` before any data is returned. */ export async function GET(req: NextRequest) { const limited = readLimiter(req) @@ -36,9 +36,14 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: "poolId is required" }, { status: 400 }) } + const callerAddress = req.nextUrl.searchParams.get("callerAddress") + if (!callerAddress) { + return NextResponse.json({ error: "callerAddress is required" }, { status: 400 }) + } + const { data: pool, error: poolErr } = await supabase .from("pools") - .select("id, name, total_saved") + .select("id, name, total_saved, creator_address") .eq("id", poolId) .single() @@ -46,6 +51,11 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: "Pool not found" }, { status: 404 }) } + // Server-side authorization: reject if caller is not the pool creator + if (callerAddress.toLowerCase() !== pool.creator_address.toLowerCase()) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + const { data: activity, error: actErr } = await supabase .from("pool_activity") .select("id, pool_id, activity_type, user_address, amount, tx_hash, description, created_at") diff --git a/frontend/components/group/admin-audit-log.tsx b/frontend/components/group/admin-audit-log.tsx index 876df9b..f3cd30b 100644 --- a/frontend/components/group/admin-audit-log.tsx +++ b/frontend/components/group/admin-audit-log.tsx @@ -39,8 +39,11 @@ export function AdminAuditLog({ groupId, creatorAddress }: AdminAuditLogProps) { useEffect(() => { if (!isCreator) return setLoading(true) - fetch(`/api/admin/audit-log?poolId=${groupId}`) - .then((r) => r.json()) + fetch(`/api/admin/audit-log?poolId=${groupId}&callerAddress=${address}`) + .then((r) => { + if (r.status === 403) throw new Error("You are not authorized to view this audit log") + return r.json() + }) .then((data) => { if (data.error) throw new Error(data.error) setRows(data.rows) @@ -50,7 +53,7 @@ export function AdminAuditLog({ groupId, creatorAddress }: AdminAuditLogProps) { }) .catch((e) => setError(e.message)) .finally(() => setLoading(false)) - }, [isCreator, groupId]) + }, [isCreator, groupId, address]) if (!isCreator) return null diff --git a/frontend/package.json b/frontend/package.json index 7f5907c..c208d74 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "dev": "next dev --webpack", "lint": "next lint", "start": "next start", - "test:unit": "tsx --test lib/csv-export.test.ts lib/analytics.test.ts lib/pool-health.test.ts lib/form-validation.test.ts hooks/use-keyboard-shortcuts.test.ts", + "test:unit": "tsx --test lib/csv-export.test.ts lib/analytics.test.ts lib/pool-health.test.ts lib/form-validation.test.ts hooks/use-keyboard-shortcuts.test.ts app/api/admin/audit-log/route.test.ts", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright show-report"