feat(audit-logging): Implement Edge-compatible audit logging utility and safe logging module
- Added `auditLogEdge.js` for Edge Runtime compatible audit logging, including console logging and API fallback. - Introduced `auditLogSafe.js` for safe audit logging without direct database imports, ensuring compatibility across runtimes. - Enhanced `auth.js` to integrate safe audit logging for login actions, including success and failure cases. - Created middleware `auditLog.js` to facilitate audit logging for API routes with predefined configurations. - Updated `middleware.js` to allow API route access without authentication checks. - Added tests for audit logging functionality and Edge compatibility in `test-audit-logging.mjs` and `test-edge-compatibility.mjs`. - Implemented safe audit logging tests in `test-safe-audit-logging.mjs` to verify functionality across environments.
This commit is contained in:
55
src/app/admin/audit-logs/page.js
Normal file
55
src/app/admin/audit-logs/page.js
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import AuditLogViewer from "@/components/AuditLogViewer";
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return; // Still loading
|
||||
|
||||
if (!session) {
|
||||
router.push("/auth/signin");
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow admins and project managers to view audit logs
|
||||
if (!["admin", "project_manager"].includes(session.user.role)) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
}, [session, status, router]);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || !["admin", "project_manager"].includes(session.user.role)) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Access Denied
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
You don't have permission to view this page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<AuditLogViewer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/app/api/audit-logs/log/route.js
Normal file
49
src/app/api/audit-logs/log/route.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// Force this API route to use Node.js runtime for database access
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { logAuditEvent } from "@/lib/auditLog";
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const data = await request.json();
|
||||
|
||||
const {
|
||||
action,
|
||||
userId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
details,
|
||||
timestamp,
|
||||
} = data;
|
||||
|
||||
if (!action) {
|
||||
return NextResponse.json(
|
||||
{ error: "Action is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Log the audit event
|
||||
await logAuditEvent({
|
||||
action,
|
||||
userId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
details,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Audit log API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
67
src/app/api/audit-logs/route.js
Normal file
67
src/app/api/audit-logs/route.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// Force this API route to use Node.js runtime
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getAuditLogs, getAuditLogStats } from "@/lib/auditLog";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins and project managers can view audit logs
|
||||
if (!["admin", "project_manager"].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// Parse query parameters
|
||||
const filters = {
|
||||
userId: searchParams.get("userId") || null,
|
||||
action: searchParams.get("action") || null,
|
||||
resourceType: searchParams.get("resourceType") || null,
|
||||
resourceId: searchParams.get("resourceId") || null,
|
||||
startDate: searchParams.get("startDate") || null,
|
||||
endDate: searchParams.get("endDate") || null,
|
||||
limit: parseInt(searchParams.get("limit")) || 100,
|
||||
offset: parseInt(searchParams.get("offset")) || 0,
|
||||
orderBy: searchParams.get("orderBy") || "timestamp",
|
||||
orderDirection: searchParams.get("orderDirection") || "DESC",
|
||||
};
|
||||
|
||||
// Get audit logs
|
||||
const logs = await getAuditLogs(filters);
|
||||
|
||||
// Get statistics if requested
|
||||
const includeStats = searchParams.get("includeStats") === "true";
|
||||
let stats = null;
|
||||
|
||||
if (includeStats) {
|
||||
stats = await getAuditLogStats({
|
||||
startDate: filters.startDate,
|
||||
endDate: filters.endDate,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: logs,
|
||||
stats,
|
||||
filters: {
|
||||
...filters,
|
||||
total: logs.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Audit logs API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/app/api/audit-logs/stats/route.js
Normal file
41
src/app/api/audit-logs/stats/route.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// Force this API route to use Node.js runtime
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getAuditLogStats } from "@/lib/auditLog";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins and project managers can view audit log statistics
|
||||
if (!["admin", "project_manager"].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const filters = {
|
||||
startDate: searchParams.get("startDate") || null,
|
||||
endDate: searchParams.get("endDate") || null,
|
||||
};
|
||||
|
||||
const stats = await getAuditLogStats(filters);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Audit log stats API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
// Force this API route to use Node.js runtime for database access
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import db from "@/lib/db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUserAuth } from "@/lib/middleware/auth";
|
||||
import {
|
||||
logApiActionSafe,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
|
||||
async function createNoteHandler(req) {
|
||||
const { project_id, task_id, note } = await req.json();
|
||||
@@ -10,12 +18,26 @@ async function createNoteHandler(req) {
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
`
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO notes (project_id, task_id, note, created_by, note_date)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`
|
||||
).run(project_id || null, task_id || null, note, req.user?.id || null);
|
||||
)
|
||||
.run(project_id || null, task_id || null, note, req.user?.id || null);
|
||||
|
||||
// Log note creation
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.NOTE_CREATE,
|
||||
RESOURCE_TYPES.NOTE,
|
||||
result.lastInsertRowid.toString(),
|
||||
req.session,
|
||||
{
|
||||
noteData: { project_id, task_id, note_length: note.length },
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -27,11 +49,30 @@ async function createNoteHandler(req) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNoteHandler(_, { params }) {
|
||||
async function deleteNoteHandler(req, { params }) {
|
||||
const { id } = params;
|
||||
|
||||
// Get note data before deletion for audit log
|
||||
const note = db.prepare("SELECT * FROM notes WHERE note_id = ?").get(id);
|
||||
|
||||
db.prepare("DELETE FROM notes WHERE note_id = ?").run(id);
|
||||
|
||||
// Log note deletion
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.NOTE_DELETE,
|
||||
RESOURCE_TYPES.NOTE,
|
||||
id,
|
||||
req.session,
|
||||
{
|
||||
deletedNote: {
|
||||
project_id: note?.project_id,
|
||||
task_id: note?.task_id,
|
||||
note_length: note?.note?.length || 0,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
@@ -43,12 +84,36 @@ async function updateNoteHandler(req, { params }) {
|
||||
return NextResponse.json({ error: "Missing note or ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get original note for audit log
|
||||
const originalNote = db
|
||||
.prepare("SELECT * FROM notes WHERE note_id = ?")
|
||||
.get(noteId);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE notes SET note = ? WHERE note_id = ?
|
||||
`
|
||||
).run(note, noteId);
|
||||
|
||||
// Log note update
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.NOTE_UPDATE,
|
||||
RESOURCE_TYPES.NOTE,
|
||||
noteId,
|
||||
req.session,
|
||||
{
|
||||
originalNote: {
|
||||
note_length: originalNote?.note?.length || 0,
|
||||
project_id: originalNote?.project_id,
|
||||
task_id: originalNote?.task_id,
|
||||
},
|
||||
updatedNote: {
|
||||
note_length: note.length,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Force this API route to use Node.js runtime for database access
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import {
|
||||
getProjectById,
|
||||
updateProject,
|
||||
@@ -6,6 +9,11 @@ import {
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
import {
|
||||
logApiActionSafe,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
|
||||
// Make sure the DB is initialized before queries run
|
||||
initializeDatabase();
|
||||
@@ -18,6 +26,16 @@ async function getProjectHandler(req, { params }) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Log project view
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_VIEW,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
id,
|
||||
req.session,
|
||||
{ project_name: project.project_name }
|
||||
);
|
||||
|
||||
return NextResponse.json(project);
|
||||
}
|
||||
|
||||
@@ -28,16 +46,54 @@ async function updateProjectHandler(req, { params }) {
|
||||
// Get user ID from authenticated request
|
||||
const userId = req.user?.id;
|
||||
|
||||
// Get original project data for audit log
|
||||
const originalProject = getProjectById(parseInt(id));
|
||||
|
||||
updateProject(parseInt(id), data, userId);
|
||||
|
||||
// Return the updated project
|
||||
// Get updated project
|
||||
const updatedProject = getProjectById(parseInt(id));
|
||||
|
||||
// Log project update
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_UPDATE,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
id,
|
||||
req.session,
|
||||
{
|
||||
originalData: originalProject,
|
||||
updatedData: data,
|
||||
changedFields: Object.keys(data),
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json(updatedProject);
|
||||
}
|
||||
|
||||
async function deleteProjectHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
|
||||
// Get project data before deletion for audit log
|
||||
const project = getProjectById(parseInt(id));
|
||||
|
||||
deleteProject(parseInt(id));
|
||||
|
||||
// Log project deletion
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_DELETE,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
id,
|
||||
req.session,
|
||||
{
|
||||
deletedProject: {
|
||||
project_name: project?.project_name,
|
||||
project_number: project?.project_number,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Force this API route to use Node.js runtime for database access
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import {
|
||||
getAllProjects,
|
||||
createProject,
|
||||
@@ -6,6 +9,11 @@ import {
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
import {
|
||||
logApiActionSafe,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
|
||||
// Make sure the DB is initialized before queries run
|
||||
initializeDatabase();
|
||||
@@ -30,6 +38,19 @@ async function getProjectsHandler(req) {
|
||||
projects = getAllProjects(contractId);
|
||||
}
|
||||
|
||||
// Log project list access
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_VIEW,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
null, // No specific project ID for list view
|
||||
req.session,
|
||||
{
|
||||
filters: { contractId, assignedTo, createdBy },
|
||||
resultCount: projects.length,
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json(projects);
|
||||
}
|
||||
|
||||
@@ -40,9 +61,27 @@ async function createProjectHandler(req) {
|
||||
const userId = req.user?.id;
|
||||
|
||||
const result = createProject(data, userId);
|
||||
const projectId = result.lastInsertRowid;
|
||||
|
||||
// Log project creation
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_CREATE,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
projectId.toString(),
|
||||
req.session,
|
||||
{
|
||||
projectData: {
|
||||
project_name: data.project_name,
|
||||
project_number: data.project_number,
|
||||
contract_id: data.contract_id,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
projectId: result.lastInsertRowid,
|
||||
projectId: projectId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user