diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 5d980ff..bcabf22 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -3,6 +3,7 @@ import { Keypair, StrKey } from '@stellar/stellar-sdk'; import { getAndClearNonce } from '@/lib/auth-cache'; import { createSession, getSessionCookieHeader } from '@/lib/session'; import { prisma } from '@/lib/prisma'; +import { auditLog, createAuditEvent, extractIp, AuditAction } from '@/lib/audit'; // Force dynamic rendering for this route export const dynamic = 'force-dynamic'; @@ -35,6 +36,15 @@ export async function POST(request: NextRequest) { // Verify nonce exists and matches (atomic read + delete) const storedNonce = getAndClearNonce(address); if (!storedNonce || storedNonce !== message) { + // Log failed login attempt + await auditLog( + createAuditEvent(AuditAction.LOGIN_FAIL, 'failure', { + address, + ip: extractIp(request), + error: 'Invalid or expired nonce', + }) + ); + return NextResponse.json( { error: 'Invalid or expired nonce' }, { status: 401 } @@ -50,6 +60,15 @@ export async function POST(request: NextRequest) { const isValid = keypair.verify(messageBuffer, signatureBuffer); if (!isValid) { + // Log failed login attempt + await auditLog( + createAuditEvent(AuditAction.LOGIN_FAIL, 'failure', { + address, + ip: extractIp(request), + error: 'Invalid signature', + }) + ); + return NextResponse.json( { error: 'Invalid signature' }, { status: 401 } @@ -85,10 +104,29 @@ export async function POST(request: NextRequest) { getSessionCookieHeader(sealed) ); + // Log successful login + await auditLog( + createAuditEvent(AuditAction.LOGIN_SUCCESS, 'success', { + address, + ip: extractIp(request), + }) + ); + return response; } catch (error) { console.error('Login error:', error); + + // Log failed login due to internal error + const body = await request.clone().json().catch(() => ({})); + await auditLog( + createAuditEvent(AuditAction.LOGIN_FAIL, 'failure', { + address: body.address, + ip: extractIp(request), + error: 'Internal server error', + }) + ); + return NextResponse.json( { error: 'Internal Server Error' }, { status: 500 } diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 104b52c..fe342d7 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,8 +1,28 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; +import { getSession } from '@/lib/session'; +import { auditLog, createAuditEvent, extractIp, AuditAction } from '@/lib/audit'; export async function POST(request: NextRequest) { + // Get user address before clearing session + let address: string | undefined; + try { + const session = await getSession(); + address = session?.address; + } catch { + // Session not found or expired + } + const cookieStore = await cookies(); cookieStore.delete('session'); + + // Log logout event + await auditLog( + createAuditEvent(AuditAction.LOGOUT, 'success', { + address, + ip: extractIp(request), + }) + ); + return NextResponse.json({ success: true }); } diff --git a/app/api/auth/nonce/route.ts b/app/api/auth/nonce/route.ts index 3f4c463..4ad4acc 100644 --- a/app/api/auth/nonce/route.ts +++ b/app/api/auth/nonce/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { setNonce } from "@/lib/auth-cache"; import { randomBytes } from "crypto"; +import { auditLog, createAuditEvent, extractIp, AuditAction } from '@/lib/audit'; export async function POST(request: NextRequest) { const { publicKey } = await request.json(); @@ -19,5 +20,14 @@ export async function POST(request: NextRequest) { // Store nonce in cache for later verification setNonce(publicKey, nonce); + // Log nonce request (optional - can be high volume) + // Uncomment if you want to track authentication attempts + await auditLog( + createAuditEvent(AuditAction.NONCE_REQUESTED, 'success', { + address: publicKey, + ip: extractIp(request), + }) + ); + return NextResponse.json({ nonce }); } diff --git a/app/api/goals/[id]/add/route.ts b/app/api/goals/[id]/add/route.ts index 277ca2a..b365137 100644 --- a/app/api/goals/[id]/add/route.ts +++ b/app/api/goals/[id]/add/route.ts @@ -13,6 +13,7 @@ import { validateGoalId } from '@/lib/validation/savings-goals'; import { ApiSuccessResponse } from '@/lib/types/savings-goals'; +import { auditLog, createAuditEvent, extractIp, AuditAction } from '@/lib/audit'; export async function POST( request: NextRequest, @@ -61,6 +62,16 @@ export async function POST( // Build transaction const result = await buildAddToGoalTx(publicKey, goalId, amount); + // Log goal funding + await auditLog( + createAuditEvent(AuditAction.GOAL_ADD_FUNDS, 'success', { + address: publicKey, + ip: extractIp(request), + resource: goalId, + metadata: { amount }, + }) + ); + // Return success response const response: ApiSuccessResponse = { xdr: result.xdr diff --git a/app/api/goals/[id]/lock/route.ts b/app/api/goals/[id]/lock/route.ts index 9f1fb62..1ed3c71 100644 --- a/app/api/goals/[id]/lock/route.ts +++ b/app/api/goals/[id]/lock/route.ts @@ -10,6 +10,7 @@ import { } from '@/lib/errors/api-errors'; import { validateGoalId } from '@/lib/validation/savings-goals'; import { ApiSuccessResponse } from '@/lib/types/savings-goals'; +import { auditLog, createAuditEvent, extractIp, AuditAction } from '@/lib/audit'; export async function POST( request: NextRequest, @@ -39,6 +40,15 @@ export async function POST( // Build transaction const result = await buildLockGoalTx(publicKey, goalId); + // Log goal lock + await auditLog( + createAuditEvent(AuditAction.GOAL_LOCK, 'success', { + address: publicKey, + ip: extractIp(request), + resource: goalId, + }) + ); + // Return success response const response: ApiSuccessResponse = { xdr: result.xdr diff --git a/app/api/goals/[id]/unlock/route.ts b/app/api/goals/[id]/unlock/route.ts index c2007f5..61a79a5 100644 --- a/app/api/goals/[id]/unlock/route.ts +++ b/app/api/goals/[id]/unlock/route.ts @@ -10,6 +10,7 @@ import { } from '@/lib/errors/api-errors'; import { validateGoalId } from '@/lib/validation/savings-goals'; import { ApiSuccessResponse } from '@/lib/types/savings-goals'; +import { auditLog, createAuditEvent, extractIp, AuditAction } from '@/lib/audit'; export async function POST( request: NextRequest, @@ -39,6 +40,15 @@ export async function POST( // Build transaction const result = await buildUnlockGoalTx(publicKey, goalId); + // Log goal unlock + await auditLog( + createAuditEvent(AuditAction.GOAL_UNLOCK, 'success', { + address: publicKey, + ip: extractIp(request), + resource: goalId, + }) + ); + // Return success response const response: ApiSuccessResponse = { xdr: result.xdr diff --git a/app/api/goals/[id]/withdraw/route.ts b/app/api/goals/[id]/withdraw/route.ts index a7bf377..55e7431 100644 --- a/app/api/goals/[id]/withdraw/route.ts +++ b/app/api/goals/[id]/withdraw/route.ts @@ -13,6 +13,7 @@ import { validateGoalId } from '@/lib/validation/savings-goals'; import { ApiSuccessResponse } from '@/lib/types/savings-goals'; +import { auditLog, createAuditEvent, extractIp, AuditAction } from '@/lib/audit'; export async function POST( request: NextRequest, @@ -61,6 +62,16 @@ export async function POST( // Build transaction const result = await buildWithdrawFromGoalTx(publicKey, goalId, amount); + // Log goal withdrawal + await auditLog( + createAuditEvent(AuditAction.GOAL_WITHDRAW, 'success', { + address: publicKey, + ip: extractIp(request), + resource: goalId, + metadata: { amount }, + }) + ); + // Return success response const response: ApiSuccessResponse = { xdr: result.xdr diff --git a/app/api/goals/route.ts b/app/api/goals/route.ts index d0d21ac..f9c87ed 100644 --- a/app/api/goals/route.ts +++ b/app/api/goals/route.ts @@ -15,34 +15,7 @@ import { validateGoalName } from '@/lib/validation/savings-goals'; import { ApiSuccessResponse } from '@/lib/types/savings-goals'; - - -export async function GET(req: Request) { - try { - const publicKey = req.headers.get("x-public-key"); - - if (!publicKey) { - return NextResponse.json( - { error: "Unauthorized" }, - { status: 401 } - ); - } - - const goals = await getAllGoals(publicKey); - - return NextResponse.json(goals, { status: 200 }); - - } catch (error) { - console.error("GET /api/goals error:", error); - - return NextResponse.json( - { error: "Failed to fetch goals" }, - { status: 500 } - ); - } -} - - +import { auditLog, createAuditEvent, extractIp, AuditAction } from '@/lib/audit'; // ===== Goal Interface & Mock Data ===== @@ -83,6 +56,14 @@ async function getHandler(request: NextRequest, session: string) { cursor ); + // Log goals list retrieval + await auditLog( + createAuditEvent(AuditAction.GOAL_LIST, 'success', { + address: session, + ip: extractIp(request), + }) + ); + return NextResponse.json(paginatedResult); } catch (error) { console.error('Error fetching goals:', error); @@ -131,6 +112,19 @@ async function postHandler(request: NextRequest, session: string) { const response: ApiSuccessResponse = { xdr: result.xdr }; + // Log goal creation + await auditLog( + createAuditEvent(AuditAction.GOAL_CREATE, 'success', { + address: session, + ip: extractIp(request), + metadata: { + name, + targetAmount, + targetDate, + }, + }) + ); + return NextResponse.json(response, { status: 200 }); } catch (error) { @@ -138,6 +132,6 @@ async function postHandler(request: NextRequest, session: string) { } } +// Remove the old unprotected GET export and use only auth-wrapped versions export const GET = withAuth(getHandler); - export const POST = withAuth(postHandler); diff --git a/app/api/split/route.ts b/app/api/split/route.ts index 03aa4b3..7a6c75f 100644 --- a/app/api/split/route.ts +++ b/app/api/split/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { withAuth, ApiError } from '@/lib/auth'; import { getSplit } from '@/lib/contracts/remittance-split-cached'; +import { auditLog, createAuditEvent, extractIp, AuditAction } from '@/lib/audit'; async function getHandler(request: NextRequest, session: string) { try { @@ -11,6 +12,14 @@ async function getHandler(request: NextRequest, session: string) { throw new ApiError(404, 'Split configuration not found'); } + // Log split configuration read + await auditLog( + createAuditEvent(AuditAction.SPLIT_GET, 'success', { + address: session, + ip: extractIp(request), + }) + ); + return NextResponse.json({ percentages: { savings: config.savings_percent, @@ -27,6 +36,21 @@ async function getHandler(request: NextRequest, session: string) { async function postHandler(request: NextRequest, session: string) { const body = await request.json(); + + // Log split configuration update + await auditLog( + createAuditEvent(AuditAction.SPLIT_UPDATE, 'success', { + address: session, + ip: extractIp(request), + metadata: { + savings: body.savings, + bills: body.bills, + insurance: body.insurance, + family: body.family, + }, + }) + ); + // TODO: Call Soroban remittance_split contract to update config return NextResponse.json({ success: true }); } diff --git a/app/api/v1/bills/route.ts b/app/api/v1/bills/route.ts index b815fe5..7f56fb6 100644 --- a/app/api/v1/bills/route.ts +++ b/app/api/v1/bills/route.ts @@ -3,6 +3,7 @@ import { getTranslator } from '../../../../lib/i18n' import { buildCreateBillTx } from '../../../../lib/contracts/bill-payments' import { StrKey } from '@stellar/stellar-sdk' import { ApiRouteError, withApiErrorHandler } from '@/lib/api/error-handler' +import { auditLog, createAuditEvent, extractIp, AuditAction } from '@/lib/audit' export const POST = withApiErrorHandler(async function POST(req: Request) { const t = getTranslator(req.headers.get('accept-language')); @@ -29,6 +30,23 @@ export const POST = withApiErrorHandler(async function POST(req: Request) { throw new ApiRouteError(400, 'VALIDATION_ERROR', t('errors.invalid_due_date') || 'Invalid dueDate') } - const xdr = await buildCreateBillTx(caller, name, numAmount, dueDate, Boolean(recurring), frequencyDays ? Number(frequencyDays) : undefined) + // For non-recurring bills, pass 0 as frequencyDays (it won't be used) + const numFrequencyDays = frequencyDays ? Number(frequencyDays) : 0; + const xdr = await buildCreateBillTx(caller, name, numAmount, dueDate, Boolean(recurring), numFrequencyDays) + + // Log bill creation + await auditLog( + createAuditEvent(AuditAction.BILL_CREATE, 'success', { + address: caller, + ip: extractIp(req), + metadata: { + name, + amount: numAmount, + dueDate, + recurring, + }, + }) + ); + return NextResponse.json({ xdr }) }) diff --git a/lib/audit/index.ts b/lib/audit/index.ts new file mode 100644 index 0000000..60799f1 --- /dev/null +++ b/lib/audit/index.ts @@ -0,0 +1,9 @@ +/** + * Audit Logging Module + * + * Centralized exports for audit logging functionality. + */ + +export { auditLog, extractIp, createAuditEvent } from './logger'; +export { AuditAction, type AuditEvent, type AuditResult, type AuditConfig } from './types'; +export { sanitize, sanitizeError, maskAddress, extractSafeMetadata } from './sanitize'; diff --git a/lib/audit/logger.ts b/lib/audit/logger.ts new file mode 100644 index 0000000..2fe2e55 --- /dev/null +++ b/lib/audit/logger.ts @@ -0,0 +1,171 @@ +/** + * Core Audit Logger + * + * Centralized logging for all sensitive operations in RemitWise. + * Logs to stdout (structured JSON) or database, with no secrets included. + */ + +import type { AuditEvent, AuditConfig } from './types'; +import { sanitize, sanitizeError } from './sanitize'; +import prisma from '@/lib/db'; + +/** + * Get audit configuration from environment variables + */ +function getConfig(): AuditConfig { + return { + enabled: process.env.AUDIT_LOG_ENABLED !== 'false', // Enabled by default + destination: (process.env.AUDIT_LOG_DESTINATION as any) || 'stdout', + retentionDays: parseInt(process.env.AUDIT_RETENTION_DAYS || '90', 10), + includeMetadata: process.env.AUDIT_INCLUDE_METADATA !== 'false', + }; +} + +/** + * Format audit event as JSON string + */ +function formatEvent(event: AuditEvent): string { + return JSON.stringify(event); +} + +/** + * Write audit event to stdout + */ +async function writeToStdout(event: AuditEvent): Promise { + console.log('[AUDIT]', formatEvent(event)); +} + +/** + * Write audit event to database + * Note: Requires AuditLog table in Prisma schema + */ +async function writeToDatabase(event: AuditEvent): Promise { + try { + // Using raw SQL for flexibility - table may not exist yet + await prisma.$executeRawUnsafe( + `INSERT INTO AuditLog (id, timestamp, action, address, ip, resource, result, error, metadata, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`, + generateId(), + event.timestamp, + event.action, + event.address || null, + event.ip || null, + event.resource || null, + event.result, + event.error || null, + event.metadata ? JSON.stringify(event.metadata) : null + ); + } catch (error) { + // If table doesn't exist or other DB error, fallback to stdout + console.error('[AUDIT] Failed to write to database, falling back to stdout:', error); + await writeToStdout(event); + } +} + +/** + * Generate a unique ID for audit log entries + */ +function generateId(): string { + return `audit_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Main audit logging function + * + * @param event Audit event to log + * + * @example + * auditLog({ + * timestamp: new Date().toISOString(), + * action: AuditAction.LOGIN_SUCCESS, + * address: 'GDEMOX...XXXX', + * ip: '192.168.1.100', + * result: 'success', + * }); + */ +export async function auditLog(event: AuditEvent): Promise { + const config = getConfig(); + + // Skip if disabled + if (!config.enabled) { + return; + } + + // Sanitize the event + const sanitizedEvent: AuditEvent = { + ...event, + metadata: event.metadata ? sanitize(event.metadata) : undefined, + error: event.error ? sanitizeError(event.error) : undefined, + }; + + // Remove metadata if not configured + if (!config.includeMetadata) { + delete sanitizedEvent.metadata; + } + + // Write to appropriate destination + try { + switch (config.destination) { + case 'database': + await writeToDatabase(sanitizedEvent); + break; + case 'stdout': + default: + await writeToStdout(sanitizedEvent); + break; + } + } catch (error) { + console.error('[AUDIT] Failed to log event:', error); + } +} + +/** + * Helper to extract IP address from Next.js request + * Handles x-forwarded-for, x-real-ip, and direct connection + */ +export function extractIp(request: Request | { headers: Headers }): string | undefined { + const headers = request.headers; + + // Try x-forwarded-for first (most common with proxies) + const forwarded = headers.get('x-forwarded-for'); + if (forwarded) { + // Take the first IP in the list + return forwarded.split(',')[0].trim(); + } + + // Try x-real-ip + const realIp = headers.get('x-real-ip'); + if (realIp) { + return realIp; + } + + // No IP found + return undefined; +} + +/** + * Create an audit event with common fields pre-filled + * + * @param action The action being audited + * @param result Success or failure + * @param options Additional event properties + * @returns Complete audit event + */ +export function createAuditEvent( + action: string, + result: 'success' | 'failure', + options: { + address?: string; + ip?: string; + resource?: string; + error?: string; + metadata?: Record; + } = {} +): AuditEvent { + return { + timestamp: new Date().toISOString(), + action: action as any, + result, + ...options, + }; +} diff --git a/lib/audit/middleware.ts b/lib/audit/middleware.ts new file mode 100644 index 0000000..160700f --- /dev/null +++ b/lib/audit/middleware.ts @@ -0,0 +1,141 @@ +/** + * Audit Middleware + * + * Wrapper functions to automatically audit API route handlers. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { auditLog, extractIp, createAuditEvent, AuditAction } from './index'; +import { getSession } from '@/lib/session'; + +/** + * Wrap a route handler with automatic audit logging + * Logs both success and failure outcomes + * + * @param action The audit action to log + * @param handler The route handler function + * @param extractResource Optional function to extract resource ID from request/response + * @param extractMetadata Optional function to extract metadata from request/response + * + * @example + * export const POST = withAudit( + * AuditAction.GOAL_CREATE, + * async (request) => { + * // Your handler logic + * return NextResponse.json({ id: 'goal_123' }); + * }, + * (req, res) => res?.id // Extract resource ID + * ); + */ +export function withAudit( + action: AuditAction, + handler: (request: NextRequest) => Promise>, + extractResource?: (request: NextRequest, response?: any) => string | undefined, + extractMetadata?: (request: NextRequest, response?: any) => Record | undefined +) { + return async (request: NextRequest): Promise> => { + const startTime = Date.now(); + let address: string | undefined; + let responseData: any; + let result: 'success' | 'failure' = 'success'; + let error: string | undefined; + + try { + // Try to get user address from session + try { + const session = await getSession(); + address = session?.address; + } catch { + // Session not available, continue without it + } + + // Try to get address from headers as fallback + if (!address) { + address = request.headers.get('x-user') || + request.headers.get('x-stellar-public-key') || + undefined; + } + + // Execute the handler + const response = await handler(request); + + // Extract response data if successful + try { + const clonedResponse = response.clone(); + responseData = await clonedResponse.json(); + } catch { + // Response is not JSON or already consumed + } + + // Log successful audit event + await auditLog( + createAuditEvent(action, 'success', { + address, + ip: extractIp(request), + resource: extractResource ? extractResource(request, responseData) : undefined, + metadata: extractMetadata ? extractMetadata(request, responseData) : undefined, + }) + ); + + return response; + + } catch (err: any) { + result = 'failure'; + error = err?.message || String(err); + + // Log failed audit event + await auditLog( + createAuditEvent(action, 'failure', { + address, + ip: extractIp(request), + resource: extractResource ? extractResource(request, responseData) : undefined, + error, + }) + ); + + // Re-throw the error + throw err; + } + }; +} + +/** + * Simpler audit wrapper that just logs the action without automatic error handling + * Use this when you want more control over the audit logging + * + * @param action The audit action + * @param request The Next.js request + * @param result Success or failure + * @param options Additional audit options + */ +export async function logAudit( + action: AuditAction, + request: NextRequest, + result: 'success' | 'failure', + options: { + resource?: string; + error?: string; + metadata?: Record; + } = {} +): Promise { + let address: string | undefined; + + // Try to get user address from session + try { + const session = await getSession(); + address = session?.address; + } catch { + // Session not available, try headers + address = request.headers.get('x-user') || + request.headers.get('x-stellar-public-key') || + undefined; + } + + await auditLog( + createAuditEvent(action, result, { + address, + ip: extractIp(request), + ...options, + }) + ); +} diff --git a/lib/audit/sanitize.ts b/lib/audit/sanitize.ts new file mode 100644 index 0000000..999fca3 --- /dev/null +++ b/lib/audit/sanitize.ts @@ -0,0 +1,139 @@ +/** + * Data Sanitization for Audit Logs + * + * Ensures no sensitive information (passwords, keys, tokens) is logged. + */ + +/** + * List of field names that should never be logged + */ +const SENSITIVE_FIELDS = [ + 'password', + 'privateKey', + 'private_key', + 'secret', + 'signature', + 'token', + 'accessToken', + 'access_token', + 'refreshToken', + 'refresh_token', + 'authorization', + 'cookie', + 'sessionId', + 'session_id', + 'apiKey', + 'api_key', + 'AUTH_SECRET', + 'SESSION_PASSWORD', +]; + +/** + * Mask a Stellar address (show first 6 and last 4 characters) + * @param address Full Stellar address + * @returns Masked address (e.g., GDEMOX...XXXX) + */ +export function maskAddress(address: string): string { + if (!address || address.length < 10) { + return '***'; + } + return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; +} + +/** + * Sanitize an object by removing sensitive fields + * @param data Object to sanitize + * @param deep Whether to recursively sanitize nested objects + * @returns Sanitized copy of the object + */ +export function sanitize(data: any, deep: boolean = true): any { + if (data === null || data === undefined) { + return data; + } + + // Primitive types - return as-is + if (typeof data !== 'object') { + return data; + } + + // Arrays + if (Array.isArray(data)) { + return deep ? data.map(item => sanitize(item, deep)) : data; + } + + // Objects + const sanitized: Record = {}; + + for (const [key, value] of Object.entries(data)) { + const lowerKey = key.toLowerCase(); + + // Skip sensitive fields + if (SENSITIVE_FIELDS.some(field => lowerKey.includes(field.toLowerCase()))) { + sanitized[key] = '[REDACTED]'; + continue; + } + + // Recursively sanitize nested objects + if (deep && value && typeof value === 'object') { + sanitized[key] = sanitize(value, deep); + } else { + // Truncate very long strings + if (typeof value === 'string' && value.length > 500) { + sanitized[key] = value.substring(0, 500) + '... [TRUNCATED]'; + } else { + sanitized[key] = value; + } + } + } + + return sanitized; +} + +/** + * Sanitize an error for logging (remove stack traces with sensitive info) + * @param error Error object + * @returns Sanitized error message + */ +export function sanitizeError(error: any): string { + if (!error) { + return 'Unknown error'; + } + + // If it's a string, return it + if (typeof error === 'string') { + return error; + } + + // If it has a message property, use it + if (error.message) { + return error.message; + } + + // Otherwise stringify + return String(error); +} + +/** + * Extract safe metadata from request body + * Removes sensitive fields and limits size + * @param body Request body + * @returns Sanitized metadata + */ +export function extractSafeMetadata(body: any): Record | undefined { + if (!body || typeof body !== 'object') { + return undefined; + } + + const sanitized = sanitize(body, true); + + // Limit metadata size + const jsonString = JSON.stringify(sanitized); + if (jsonString.length > 1000) { + return { + _note: 'Metadata truncated due to size', + _size: jsonString.length, + }; + } + + return sanitized; +} diff --git a/lib/audit/types.ts b/lib/audit/types.ts new file mode 100644 index 0000000..580ef2c --- /dev/null +++ b/lib/audit/types.ts @@ -0,0 +1,97 @@ +/** + * Audit Logging Types + * + * Defines the structure and types for audit events in RemitWise. + * All sensitive operations should be logged using these types. + */ + +/** + * Enum of all auditable actions in the system + */ +export enum AuditAction { + // Authentication + LOGIN_SUCCESS = 'LOGIN_SUCCESS', + LOGIN_FAIL = 'LOGIN_FAIL', + LOGOUT = 'LOGOUT', + NONCE_REQUESTED = 'NONCE_REQUESTED', + + // Remittance + REMITTANCE_BUILD = 'REMITTANCE_BUILD', + REMITTANCE_EMERGENCY = 'REMITTANCE_EMERGENCY', + REMITTANCE_STATUS_CHECK = 'REMITTANCE_STATUS_CHECK', + + // Split Configuration + SPLIT_INITIALIZE = 'SPLIT_INITIALIZE', + SPLIT_UPDATE = 'SPLIT_UPDATE', + SPLIT_GET = 'SPLIT_GET', + + // Savings Goals + GOAL_CREATE = 'GOAL_CREATE', + GOAL_ADD_FUNDS = 'GOAL_ADD_FUNDS', + GOAL_WITHDRAW = 'GOAL_WITHDRAW', + GOAL_LOCK = 'GOAL_LOCK', + GOAL_UNLOCK = 'GOAL_UNLOCK', + GOAL_LIST = 'GOAL_LIST', + + // Bills + BILL_CREATE = 'BILL_CREATE', + BILL_PAY = 'BILL_PAY', + BILL_UPDATE = 'BILL_UPDATE', + BILL_DELETE = 'BILL_DELETE', + + // Insurance + POLICY_CREATE = 'POLICY_CREATE', + PREMIUM_PAY = 'PREMIUM_PAY', + POLICY_UPDATE = 'POLICY_UPDATE', + POLICY_CANCEL = 'POLICY_CANCEL', + + // Family Wallet + FAMILY_MEMBER_ADD = 'FAMILY_MEMBER_ADD', + FAMILY_MEMBER_UPDATE = 'FAMILY_MEMBER_UPDATE', + FAMILY_MEMBER_REMOVE = 'FAMILY_MEMBER_REMOVE', + FAMILY_LIMIT_CHANGE = 'FAMILY_LIMIT_CHANGE', +} + +/** + * Result of an audit event + */ +export type AuditResult = 'success' | 'failure'; + +/** + * Core audit event structure + */ +export interface AuditEvent { + /** ISO 8601 timestamp */ + timestamp: string; + + /** The action being audited */ + action: AuditAction; + + /** Stellar public key of the user (optional for anonymous actions) */ + address?: string; + + /** Client IP address */ + ip?: string; + + /** Resource ID being acted upon (e.g., goal ID, bill ID) */ + resource?: string; + + /** Success or failure */ + result: AuditResult; + + /** Error message (sanitized, no secrets) */ + error?: string; + + /** Additional context (sanitized) */ + metadata?: Record; +} + +/** + * Configuration for audit logging + */ +export interface AuditConfig { + enabled: boolean; + destination: 'stdout' | 'database'; + retentionDays?: number; + includeMetadata?: boolean; +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 26b5cf0..bea5bb9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,3 +23,24 @@ model UserPreference { language String @default("en") notifications_enabled Boolean @default(true) } + +// Audit Log for security and compliance +// Optional: Used when AUDIT_LOG_DESTINATION=database +model AuditLog { + id String @id @default(cuid()) + timestamp String // ISO 8601 timestamp + action String // Action type (LOGIN_SUCCESS, GOAL_CREATE, etc.) + address String? // Stellar public key + ip String? // Client IP address + resource String? // Resource ID (goal ID, bill ID, etc.) + result String // 'success' or 'failure' + error String? // Error message (sanitized) + metadata String? // JSON string with additional context (sanitized) + createdAt DateTime @default(now()) + + @@index([address]) + @@index([action]) + @@index([timestamp]) + @@index([createdAt]) + @@index([result]) +}