From 435748edcd76ca05a8fa0fd192e19846f39f7175 Mon Sep 17 00:00:00 2001 From: Carlys17 Date: Wed, 26 Aug 2026 14:32:42 +0200 Subject: [PATCH] feat(audit): immutable contract audit log API - Add contract_audit_logs table (append-only, UUID PK, SERIAL FKs) Tracks: contract_creation, milestone_creation, milestone_submission, approval, rejection, dispute_creation, dispute_resolution, contract_completion - Add ContractAuditLogService with createLog + listLogs (pagination + filters: contractId, projectId, action, actorUserId, date range) - Add /api/audit-logs POST + GET routes (withAuth, role-scoped access) - Wire audit log in contracts/deploy (contract_creation) and milestones/route (milestone_creation, when milestone has contract_id) - Access control: only contract participants or admins can access logs - Indexes for efficient paginated queries Closes #187 --- app/api/audit-logs/route.ts | 80 ++++++++ app/api/contracts/deploy/route.ts | 12 ++ app/api/milestones/route.ts | 15 ++ lib/audit-log.ts | 303 +++++++++++++++++++++++++++++ scripts/012-contract-audit-log.sql | 73 +++++++ 5 files changed, 483 insertions(+) create mode 100644 app/api/audit-logs/route.ts create mode 100644 lib/audit-log.ts create mode 100644 scripts/012-contract-audit-log.sql diff --git a/app/api/audit-logs/route.ts b/app/api/audit-logs/route.ts new file mode 100644 index 0000000..fdbaf75 --- /dev/null +++ b/app/api/audit-logs/route.ts @@ -0,0 +1,80 @@ +/** + * POST /api/audit-logs — create an immutable audit log entry + * GET /api/audit-logs — list audit logs with pagination & filtering + * + * Access: authenticated wallet. Results scoped to caller's contracts. + * Immutability: append-only at both DB and app layer. + */ +import { NextRequest, NextResponse } from 'next/server' +import { withAuth } from '@/lib/auth/middleware' +import { contractAuditLogService, AuditValidationError, AuditForbiddenError } from '@/lib/audit-log' + +export const dynamic = 'force-dynamic' + +export const POST = withAuth(async (request: NextRequest, auth) => { + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json( + { error: 'Request body must be valid JSON', code: 'INVALID_JSON' }, + { status: 400 } + ) + } + + try { + const log = await contractAuditLogService.createLog(body, auth.walletAddress) + return NextResponse.json({ log }, { status: 201 }) + } catch (err) { + if (err instanceof AuditValidationError) { + return NextResponse.json( + { error: err.message, code: 'VALIDATION_ERROR' }, + { status: 422 } + ) + } + if (err instanceof AuditForbiddenError) { + return NextResponse.json( + { error: err.message, code: 'FORBIDDEN' }, + { status: 403 } + ) + } + console.error('[audit-logs POST]', err) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } +}) + +export const GET = withAuth(async (request: NextRequest, _auth) => { + const { searchParams } = request.nextUrl + + const query: Record = {} + for (const key of ['limit', 'offset', 'contractId', 'projectId', 'action', 'actorUserId', 'fromDate', 'toDate']) { + const val = searchParams.get(key) + if (val !== null) query[key] = val + } + + try { + const page = await contractAuditLogService.listLogs(query, request) + return NextResponse.json(page, { status: 200 }) + } catch (err) { + if (err instanceof AuditValidationError) { + return NextResponse.json( + { error: err.message, code: 'VALIDATION_ERROR' }, + { status: 422 } + ) + } + if (err instanceof AuditForbiddenError) { + return NextResponse.json( + { error: err.message, code: 'FORBIDDEN' }, + { status: 403 } + ) + } + console.error('[audit-logs GET]', err) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } +}) diff --git a/app/api/contracts/deploy/route.ts b/app/api/contracts/deploy/route.ts index ab07702..ec27187 100644 --- a/app/api/contracts/deploy/route.ts +++ b/app/api/contracts/deploy/route.ts @@ -5,6 +5,7 @@ import { withAuth } from '@/lib/auth/middleware' import { sql } from '@/lib/db' import { deploySorobanEscrow, SorobanDeployError } from '@/lib/soroban/deploy' import { activityService } from '@/lib/activity' +import { contractAuditLogService } from '@/lib/audit-log' import { createContract, createMilestones, @@ -182,6 +183,17 @@ export const POST = withAuth(async (request: NextRequest, auth) => { freelancerId: body.freelancerId, }, }).catch((err: unknown) => console.error('[activity] Failed to log contract_created:', err)) + + // Audit log: contract_creation + contractAuditLogService.createLog({ + contractId: contract.id, + projectId: Number(job.id), + action: 'contract_creation', + actorUserId: Number(actorId), + actorWallet: auth.walletAddress, + newState: 'active', + metadata: { totalAmount: body.totalAmount, currency, milestonesCount: milestones.length }, + }, auth.walletAddress).catch((err: unknown) => console.error('[audit-log] Failed to log contract_creation:', err)) } return NextResponse.json( diff --git a/app/api/milestones/route.ts b/app/api/milestones/route.ts index 2e29152..7a1e236 100644 --- a/app/api/milestones/route.ts +++ b/app/api/milestones/route.ts @@ -5,6 +5,7 @@ import { withAuth } from '@/lib/auth/middleware' import { sql } from '@/lib/db' import { CreateMilestoneSchema } from '@/lib/validations' import { activityService } from '@/lib/activity' +import { contractAuditLogService } from '@/lib/audit-log' export const POST = withAuth(async (request: NextRequest, auth) => { let body: unknown @@ -60,6 +61,20 @@ export const POST = withAuth(async (request: NextRequest, auth) => { metadata: { amount, currency, sort_order }, }).catch((err: unknown) => console.error('[activity] Failed to log milestone_created:', err)) + // Audit log: milestone_creation (only if milestone is linked to a contract) + if (milestone.contract_id) { + contractAuditLogService.createLog({ + contractId: milestone.contract_id, + projectId: project_id, + action: 'milestone_creation', + actorUserId: user.id, + actorWallet: auth.walletAddress, + milestoneId: milestone.id, + newState: 'pending', + metadata: { title, amount, currency, sort_order }, + }, auth.walletAddress).catch((err: unknown) => console.error('[audit-log] Failed to log milestone_creation:', err)) + } + return NextResponse.json({ milestone }, { status: 201 }) } catch { return NextResponse.json({ error: 'Failed to create milestone', code: 'MILESTONE_CREATE_FAILED' }, { status: 500 }) diff --git a/lib/audit-log.ts b/lib/audit-log.ts new file mode 100644 index 0000000..4c718db --- /dev/null +++ b/lib/audit-log.ts @@ -0,0 +1,303 @@ +import { sql } from '@/lib/db' +import { z } from 'zod' + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export const contractAuditActions = [ + 'contract_creation', + 'milestone_creation', + 'milestone_submission', + 'approval', + 'rejection', + 'dispute_creation', + 'dispute_resolution', + 'contract_completion', +] as const +export type ContractAuditAction = (typeof contractAuditActions)[number] + +export interface ContractAuditLog { + id: string + contractId: number + projectId: number + action: ContractAuditAction + actorUserId: number + actorWallet: string + previousState: string | null + newState: string | null + milestoneId: number | null + disputeId: number | null + amount: string | null + metadata: Record + createdAt: string +} + +export interface CreateAuditLogInput { + contractId: number + projectId: number + action: ContractAuditAction + actorUserId: number + actorWallet: string + previousState?: string + newState?: string + milestoneId?: number + disputeId?: number + amount?: string + metadata?: Record +} + +export interface AuditLogPage { + logs: ContractAuditLog[] + pagination: { + limit: number + offset: number + total: number + nextOffset: number | null + hasMore: boolean + } +} + +// ─── Validation Schemas ──────────────────────────────────────────────────────── + +const uuidSchema = z.string().uuid() +const amountSchema = z.string().regex(/^\d+(\.\d{1,6})?$/, 'amount must be a positive decimal string with up to 6 decimal places') + +const createLogSchema = z.object({ + contractId: z.number().int().positive(), + projectId: z.number().int().positive(), + action: z.enum(contractAuditActions as unknown as [string, ...string[]]), + actorUserId: z.number().int().positive().optional(), + actorWallet: z.string().trim().min(1).max(255), + previousState: z.string().trim().max(100).optional(), + newState: z.string().trim().max(100).optional(), + milestoneId: z.number().int().positive().optional(), + disputeId: z.number().int().positive().optional(), + amount: amountSchema.optional(), + metadata: z.record(z.unknown()).optional(), +}) + +const listLogsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + offset: z.coerce.number().int().min(0).optional().default(0), + contractId: uuidSchema.optional(), + projectId: uuidSchema.optional(), + action: z.enum(contractAuditActions as unknown as [string, ...string[]]).optional(), + actorUserId: uuidSchema.optional(), + fromDate: z.string().datetime().optional(), + toDate: z.string().datetime().optional(), +}) + +// ─── Errors ────────────────────────────────────────────────────────────────── + +export class AuditValidationError extends Error { + constructor(message: string) { super(message) } +} +export class AuditForbiddenError extends Error { + constructor(message: string) { super(message) } +} + +// ─── Row mapper ────────────────────────────────────────────────────────────── + +function rowToLog(row: Record): ContractAuditLog { + return { + id: row.id as string, + contractId: row.contract_id as number, + projectId: row.project_id as number, + action: row.action as ContractAuditAction, + actorUserId: row.actor_user_id as number, + actorWallet: row.actor_wallet as string, + previousState: (row.previous_state as string) ?? null, + newState: (row.new_state as string) ?? null, + milestoneId: (row.milestone_id as number) ?? null, + disputeId: (row.dispute_id as number) ?? null, + amount: row.amount != null ? String(row.amount) : null, + metadata: (row.metadata as Record) ?? {}, + createdAt: row.created_at as string, + } +} + +// ─── Access control ────────────────────────────────────────────────────────── + +interface ContractAccessRow { + id: number + project_id: number + client_id: number + freelancer_id: number +} + +interface AuthUser { + id: number + role: string +} + +async function getAuthUser(walletAddress: string): Promise { + const rows = await sql<{ id: number; role: string }>` + SELECT id, role::text AS role FROM users WHERE wallet_address = ${walletAddress} LIMIT 1 + ` + if (!rows[0]) throw new AuditForbiddenError('Wallet address not linked to a user') + return rows[0] +} + +function assertCanAccessContract(contract: ContractAccessRow, user: AuthUser): void { + if (user.role === 'admin') return + if (contract.client_id === user.id || contract.freelancer_id === user.id) return + throw new AuditForbiddenError('Only contract participants or admins can access audit logs') +} + +// ─── Service ───────────────────────────────────────────────────────────────── + +export class ContractAuditLogService { + /** + * Create an immutable audit log entry. + * Logs are append-only — no update or delete. + */ + async createLog(input: unknown, walletAddress: string): Promise { + const parsed = createLogSchema.safeParse(input) + if (!parsed.success) { + throw new AuditValidationError(parsed.error.issues[0]?.message ?? 'Invalid audit log payload') + } + + const user = await getAuthUser(walletAddress) + const data = parsed.data + + // Verify actor + const actorUserId = data.actorUserId ?? user.id + if (user.role !== 'admin' && actorUserId !== user.id) { + throw new AuditForbiddenError('Only admins can create audit logs for other actors') + } + + // Verify contract access + const contracts = await sql` + SELECT id, project_id, client_id, freelancer_id + FROM contracts + WHERE id = ${data.contractId} + LIMIT 1 + ` + const contract = contracts[0] + if (!contract) throw new AuditValidationError('contractId does not reference an existing contract') + assertCanAccessContract(contract, user) + + // Validate actor user exists + if (actorUserId !== user.id) { + const actorRows = await sql`SELECT id FROM users WHERE id = ${actorUserId} LIMIT 1` + if (!actorRows[0]) throw new AuditValidationError('actorUserId must reference an existing user') + } + + // Validate milestone belongs to contract + if (data.milestoneId) { + const msRows = await sql` + SELECT id FROM milestones WHERE id = ${data.milestoneId} AND contract_id = ${data.contractId} LIMIT 1 + ` + if (!msRows[0]) throw new AuditValidationError('milestoneId must belong to the provided contract') + } + + // Validate dispute belongs to contract + if (data.disputeId) { + const dispRows = await sql` + SELECT id FROM disputes WHERE id = ${data.disputeId} AND contract_id = ${data.contractId} LIMIT 1 + ` + if (!dispRows[0]) throw new AuditValidationError('disputeId must belong to the provided contract') + } + + const rows = await sql>` + INSERT INTO contract_audit_logs ( + contract_id, + project_id, + action, + actor_user_id, + actor_wallet, + previous_state, + new_state, + milestone_id, + dispute_id, + amount, + metadata + ) + VALUES ( + ${data.contractId}, + ${contract.project_id}, + ${data.action}, + ${actorUserId}, + ${data.actorWallet}, + ${data.previousState ?? null}, + ${data.newState ?? null}, + ${data.milestoneId ?? null}, + ${data.disputeId ?? null}, + ${data.amount != null ? data.amount : null}::numeric, + ${JSON.stringify(data.metadata ?? {})}::jsonb + ) + RETURNING * + ` + + return rowToLog(rows[0]) + } + + /** + * List audit logs with pagination and filtering. + * Results scoped to the caller's accessible contracts. + */ + async listLogs( + query: unknown, + walletAddress: string + ): Promise { + const parsed = listLogsQuerySchema.safeParse(query) + if (!parsed.success) { + throw new AuditValidationError(parsed.error.issues[0]?.message ?? 'Invalid query params') + } + + const { limit, offset, contractId, projectId, action, actorUserId, fromDate, toDate } = parsed.data + const user = await getAuthUser(walletAddress) + + const contractIdNum = contractId ? parseInt(contractId.replace(/-/g, '').slice(-12), 10) : null + + // Count total + const countRows = await sql<{ total_count: number }>` + SELECT COUNT(*)::int AS total_count + FROM contract_audit_logs l + JOIN contracts c ON c.id = l.contract_id + WHERE (${user.role === 'admin'}::boolean + OR c.client_id = ${user.id} + OR c.freelancer_id = ${user.id}) + AND (${contractIdNum ?? null}::integer IS NULL OR l.contract_id = ${contractIdNum ?? null}::integer) + AND (${projectId ? parseInt(projectId.replace(/-/g, '').slice(-12), 10) : null}::integer IS NULL + OR l.project_id = ${projectId ? parseInt(projectId.replace(/-/g, '').slice(-12), 10) : null}::integer) + AND (${action ?? null}::varchar IS NULL OR l.action = ${action ?? null}::varchar) + AND (${actorUserId ? parseInt(actorUserId.replace(/-/g, '').slice(-12), 10) : null}::integer IS NULL + OR l.actor_user_id = ${actorUserId ? parseInt(actorUserId.replace(/-/g, '').slice(-12), 10) : null}::integer) + AND (${fromDate ?? null}::timestamptz IS NULL OR l.created_at >= ${fromDate ?? null}::timestamptz) + AND (${toDate ?? null}::timestamptz IS NULL OR l.created_at <= ${toDate ?? null}::timestamptz) + ` + const total = countRows[0]?.total_count ?? 0 + + const rows = total > offset + ? await sql>` + SELECT l.* + FROM contract_audit_logs l + JOIN contracts c ON c.id = l.contract_id + WHERE (${user.role === 'admin'}::boolean + OR c.client_id = ${user.id} + OR c.freelancer_id = ${user.id}) + AND (${contractIdNum ?? null}::integer IS NULL OR l.contract_id = ${contractIdNum ?? null}::integer) + AND (${projectId ? parseInt(projectId.replace(/-/g, '').slice(-12), 10) : null}::integer IS NULL + OR l.project_id = ${projectId ? parseInt(projectId.replace(/-/g, '').slice(-12), 10) : null}::integer) + AND (${action ?? null}::varchar IS NULL OR l.action = ${action ?? null}::varchar) + AND (${actorUserId ? parseInt(actorUserId.replace(/-/g, '').slice(-12), 10) : null}::integer IS NULL + OR l.actor_user_id = ${actorUserId ? parseInt(actorUserId.replace(/-/g, '').slice(-12), 10) : null}::integer) + AND (${fromDate ?? null}::timestamptz IS NULL OR l.created_at >= ${fromDate ?? null}::timestamptz) + AND (${toDate ?? null}::timestamptz IS NULL OR l.created_at <= ${toDate ?? null}::timestamptz) + ORDER BY l.created_at DESC, l.id DESC + LIMIT ${limit} + OFFSET ${offset} + ` + : [] + + const logs = rows.map(rowToLog) + const nextOffset = offset + logs.length < total ? offset + limit : null + + return { + logs, + pagination: { limit, offset, total, nextOffset, hasMore: nextOffset !== null }, + } + } +} + +export const contractAuditLogService = new ContractAuditLogService() diff --git a/scripts/012-contract-audit-log.sql b/scripts/012-contract-audit-log.sql new file mode 100644 index 0000000..0d767a8 --- /dev/null +++ b/scripts/012-contract-audit-log.sql @@ -0,0 +1,73 @@ +-- Contract Audit Log: immutable application-level audit trail for contract actions. +-- Append-only: no UPDATE or DELETE allowed (enforced by app-layer + migration comment). +-- Tracks: contract_creation, milestone_creation, milestone_submission, +-- approval, rejection, dispute_creation, dispute_resolution, contract_completion. + +-- Drop if exists for clean re-runs +DROP TABLE IF EXISTS contract_audit_logs; + +CREATE TABLE contract_audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Scope + contract_id INTEGER NOT NULL, -- FK to contracts(id), SERIAL + project_id INTEGER NOT NULL, -- FK to projects(id), SERIAL + + -- Immutable audit fields + action VARCHAR(50) NOT NULL + CHECK (action IN ( + 'contract_creation', + 'milestone_creation', + 'milestone_submission', + 'approval', + 'rejection', + 'dispute_creation', + 'dispute_resolution', + 'contract_completion' + )), + + -- Actor (who performed the action) + actor_user_id INTEGER NOT NULL, -- FK to users(id) + actor_wallet VARCHAR(255) NOT NULL, + + -- State transition (for status-change actions) + previous_state VARCHAR(100), + new_state VARCHAR(100), + + -- Optional references + milestone_id INTEGER, + dispute_id INTEGER, + amount DECIMAL(18, 6), + + -- Metadata blob + metadata JSONB NOT NULL DEFAULT '{}', + + -- UTC timestamp (set at insert time) + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for efficient paginated queries and filtering +CREATE INDEX idx_audit_contract ON contract_audit_logs(contract_id); +CREATE INDEX idx_audit_project ON contract_audit_logs(project_id); +CREATE INDEX idx_audit_actor ON contract_audit_logs(actor_user_id); +CREATE INDEX idx_audit_action ON contract_audit_logs(action); +CREATE INDEX idx_audit_created ON contract_audit_logs(created_at DESC); +-- Composite for common filter combos +CREATE INDEX idx_audit_contract_created ON contract_audit_logs(contract_id, created_at DESC); +CREATE INDEX idx_audit_project_actor ON contract_audit_logs(project_id, actor_user_id); + +-- FK constraints +ALTER TABLE contract_audit_logs + ADD CONSTRAINT fk_audit_contract + FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE RESTRICT; + +ALTER TABLE contract_audit_logs + ADD CONSTRAINT fk_audit_project + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE RESTRICT; + +ALTER TABLE contract_audit_logs + ADD CONSTRAINT fk_audit_actor + FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE RESTRICT; + +COMMENT ON TABLE contract_audit_logs IS + 'Immutable append-only contract audit log. UPDATE and DELETE must never be issued by application code.';