409 lines
11 KiB
TypeScript
409 lines
11 KiB
TypeScript
import type { AuthSession } from './auth';
|
|
import { getAuthSession } from './auth';
|
|
import { getNocoDbConfiguration } from './nocodb';
|
|
|
|
export interface AuditLogEntry {
|
|
id?: number;
|
|
timestamp: string;
|
|
user_id: string;
|
|
user_email: string;
|
|
user_groups: string[];
|
|
action: string;
|
|
resource_type: string;
|
|
resource_id?: string;
|
|
changes?: any;
|
|
metadata: {
|
|
ip_address?: string;
|
|
user_agent?: string;
|
|
session_id?: string;
|
|
request_url?: string;
|
|
request_method?: string;
|
|
response_status?: number;
|
|
request_duration?: number;
|
|
};
|
|
status: 'success' | 'failure';
|
|
error_message?: string;
|
|
}
|
|
|
|
export interface AuditLogFilters {
|
|
startDate?: string;
|
|
endDate?: string;
|
|
userId?: string;
|
|
userEmail?: string;
|
|
action?: string;
|
|
resourceType?: string;
|
|
status?: 'success' | 'failure';
|
|
limit?: number;
|
|
offset?: number;
|
|
}
|
|
|
|
// NocoDB table ID for audit logs (will be updated when table is created)
|
|
const AUDIT_LOGS_TABLE_ID = "audit_logs"; // Placeholder - update with actual table ID
|
|
|
|
/**
|
|
* Log an audit event
|
|
*/
|
|
export const logAuditEvent = async (
|
|
event: any,
|
|
action: string,
|
|
resourceType: string,
|
|
details: {
|
|
resourceId?: string;
|
|
changes?: any;
|
|
status?: 'success' | 'failure';
|
|
errorMessage?: string;
|
|
duration?: number;
|
|
} = {}
|
|
): Promise<void> => {
|
|
try {
|
|
// Get user info from the event
|
|
let userInfo = {
|
|
user_id: 'anonymous',
|
|
user_email: 'anonymous',
|
|
user_groups: [] as string[]
|
|
};
|
|
|
|
try {
|
|
const authSession = await getAuthSession(event);
|
|
if (authSession.authenticated && authSession.user) {
|
|
userInfo = {
|
|
user_id: authSession.user.id,
|
|
user_email: authSession.user.email,
|
|
user_groups: authSession.groups || []
|
|
};
|
|
}
|
|
} catch (authError) {
|
|
// Continue with anonymous user if auth fails
|
|
console.warn('[AUDIT] Could not get user info:', authError);
|
|
}
|
|
|
|
// Extract request metadata
|
|
const headers = event.node.req.headers;
|
|
const metadata = {
|
|
ip_address: getClientIP(event),
|
|
user_agent: headers['user-agent'] || 'unknown',
|
|
session_id: headers['cookie']?.split('nuxt-oidc-auth=')[1]?.split(';')[0] || 'none',
|
|
request_url: event.node.req.url || 'unknown',
|
|
request_method: event.node.req.method || 'unknown',
|
|
response_status: details.status === 'failure' ? 500 : 200,
|
|
request_duration: details.duration || 0
|
|
};
|
|
|
|
// Create audit log entry
|
|
const auditEntry: Omit<AuditLogEntry, 'id'> = {
|
|
timestamp: new Date().toISOString(),
|
|
user_id: userInfo.user_id,
|
|
user_email: userInfo.user_email,
|
|
user_groups: userInfo.user_groups,
|
|
action,
|
|
resource_type: resourceType,
|
|
resource_id: details.resourceId,
|
|
changes: details.changes,
|
|
metadata,
|
|
status: details.status || 'success',
|
|
error_message: details.errorMessage
|
|
};
|
|
|
|
// Store in NocoDB
|
|
await storeAuditLog(auditEntry);
|
|
|
|
console.log('[AUDIT] Logged event:', {
|
|
action,
|
|
resourceType,
|
|
resourceId: details.resourceId,
|
|
user: userInfo.user_email,
|
|
status: details.status || 'success'
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('[AUDIT] Failed to log audit event:', error);
|
|
// Don't throw - audit logging should not break the main request
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Store audit log in NocoDB
|
|
*/
|
|
const storeAuditLog = async (entry: Omit<AuditLogEntry, 'id'>): Promise<void> => {
|
|
const config = getNocoDbConfiguration();
|
|
|
|
// Prepare data for NocoDB
|
|
const data = {
|
|
timestamp: entry.timestamp,
|
|
user_id: entry.user_id,
|
|
user_email: entry.user_email,
|
|
user_groups: JSON.stringify(entry.user_groups),
|
|
action: entry.action,
|
|
resource_type: entry.resource_type,
|
|
resource_id: entry.resource_id || null,
|
|
changes: entry.changes ? JSON.stringify(entry.changes) : null,
|
|
metadata: JSON.stringify(entry.metadata),
|
|
status: entry.status,
|
|
error_message: entry.error_message || null
|
|
};
|
|
|
|
try {
|
|
await $fetch(`${config.url}/api/v2/tables/${AUDIT_LOGS_TABLE_ID}/records`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'xc-token': config.token,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: data
|
|
});
|
|
} catch (error) {
|
|
console.error('[AUDIT] Failed to store audit log in NocoDB:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get audit logs with filtering
|
|
*/
|
|
export const getAuditLogs = async (filters: AuditLogFilters = {}): Promise<{
|
|
list: AuditLogEntry[];
|
|
totalCount: number;
|
|
}> => {
|
|
const config = getNocoDbConfiguration();
|
|
|
|
try {
|
|
const params: any = {
|
|
limit: filters.limit || 100,
|
|
offset: filters.offset || 0,
|
|
sort: '-timestamp' // Newest first
|
|
};
|
|
|
|
// Build where clause
|
|
const whereConditions: string[] = [];
|
|
|
|
if (filters.startDate) {
|
|
whereConditions.push(`(timestamp,gte,${filters.startDate})`);
|
|
}
|
|
|
|
if (filters.endDate) {
|
|
whereConditions.push(`(timestamp,lte,${filters.endDate})`);
|
|
}
|
|
|
|
if (filters.userId) {
|
|
whereConditions.push(`(user_id,eq,${filters.userId})`);
|
|
}
|
|
|
|
if (filters.userEmail) {
|
|
whereConditions.push(`(user_email,like,%${filters.userEmail}%)`);
|
|
}
|
|
|
|
if (filters.action) {
|
|
whereConditions.push(`(action,eq,${filters.action})`);
|
|
}
|
|
|
|
if (filters.resourceType) {
|
|
whereConditions.push(`(resource_type,eq,${filters.resourceType})`);
|
|
}
|
|
|
|
if (filters.status) {
|
|
whereConditions.push(`(status,eq,${filters.status})`);
|
|
}
|
|
|
|
if (whereConditions.length > 0) {
|
|
params.where = whereConditions.join('~and');
|
|
}
|
|
|
|
const response = await $fetch(`${config.url}/api/v2/tables/${AUDIT_LOGS_TABLE_ID}/records`, {
|
|
headers: {
|
|
'xc-token': config.token
|
|
},
|
|
params
|
|
}) as any;
|
|
|
|
// Parse JSON fields
|
|
const parsedList = (response.list || []).map((item: any) => ({
|
|
...item,
|
|
user_groups: JSON.parse(item.user_groups || '[]'),
|
|
changes: item.changes ? JSON.parse(item.changes) : null,
|
|
metadata: JSON.parse(item.metadata || '{}')
|
|
}));
|
|
|
|
return {
|
|
list: parsedList,
|
|
totalCount: response.PageInfo?.totalRows || parsedList.length
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('[AUDIT] Failed to get audit logs:', error);
|
|
return { list: [], totalCount: 0 };
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get audit statistics
|
|
*/
|
|
export const getAuditStats = async (days: number = 30): Promise<{
|
|
totalEvents: number;
|
|
successEvents: number;
|
|
failureEvents: number;
|
|
topActions: Array<{ action: string; count: number }>;
|
|
topUsers: Array<{ user_email: string; count: number }>;
|
|
dailyActivity: Array<{ date: string; count: number }>;
|
|
}> => {
|
|
const endDate = new Date().toISOString();
|
|
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
|
|
try {
|
|
const logs = await getAuditLogs({
|
|
startDate,
|
|
endDate,
|
|
limit: 10000 // Get a large sample for stats
|
|
});
|
|
|
|
const totalEvents = logs.totalCount;
|
|
const successEvents = logs.list.filter(log => log.status === 'success').length;
|
|
const failureEvents = logs.list.filter(log => log.status === 'failure').length;
|
|
|
|
// Calculate top actions
|
|
const actionCounts = logs.list.reduce((acc, log) => {
|
|
acc[log.action] = (acc[log.action] || 0) + 1;
|
|
return acc;
|
|
}, {} as Record<string, number>);
|
|
|
|
const topActions = Object.entries(actionCounts)
|
|
.sort(([,a], [,b]) => b - a)
|
|
.slice(0, 10)
|
|
.map(([action, count]) => ({ action, count }));
|
|
|
|
// Calculate top users
|
|
const userCounts = logs.list.reduce((acc, log) => {
|
|
if (log.user_email !== 'anonymous') {
|
|
acc[log.user_email] = (acc[log.user_email] || 0) + 1;
|
|
}
|
|
return acc;
|
|
}, {} as Record<string, number>);
|
|
|
|
const topUsers = Object.entries(userCounts)
|
|
.sort(([,a], [,b]) => b - a)
|
|
.slice(0, 10)
|
|
.map(([user_email, count]) => ({ user_email, count }));
|
|
|
|
// Calculate daily activity
|
|
const dailyCounts = logs.list.reduce((acc, log) => {
|
|
const date = log.timestamp.split('T')[0];
|
|
acc[date] = (acc[date] || 0) + 1;
|
|
return acc;
|
|
}, {} as Record<string, number>);
|
|
|
|
const dailyActivity = Object.entries(dailyCounts)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([date, count]) => ({ date, count }));
|
|
|
|
return {
|
|
totalEvents,
|
|
successEvents,
|
|
failureEvents,
|
|
topActions,
|
|
topUsers,
|
|
dailyActivity
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('[AUDIT] Failed to get audit stats:', error);
|
|
return {
|
|
totalEvents: 0,
|
|
successEvents: 0,
|
|
failureEvents: 0,
|
|
topActions: [],
|
|
topUsers: [],
|
|
dailyActivity: []
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get client IP address from request
|
|
*/
|
|
const getClientIP = (event: any): string => {
|
|
const headers = event.node.req.headers;
|
|
|
|
// Check various headers for the real IP
|
|
const forwarded = headers['x-forwarded-for'];
|
|
const realIp = headers['x-real-ip'];
|
|
const clientIp = headers['x-client-ip'];
|
|
|
|
if (forwarded) {
|
|
// x-forwarded-for can be a comma-separated list
|
|
return forwarded.toString().split(',')[0].trim();
|
|
}
|
|
|
|
if (realIp) {
|
|
return realIp.toString();
|
|
}
|
|
|
|
if (clientIp) {
|
|
return clientIp.toString();
|
|
}
|
|
|
|
// Fallback to socket remote address
|
|
return event.node.req.socket?.remoteAddress || 'unknown';
|
|
};
|
|
|
|
/**
|
|
* Convenience functions for common audit events
|
|
*/
|
|
export const auditInterestCreated = (event: any, interestId: string, interestData: any) => {
|
|
return logAuditEvent(event, 'CREATE_INTEREST', 'interest', {
|
|
resourceId: interestId,
|
|
changes: { created: interestData },
|
|
status: 'success'
|
|
});
|
|
};
|
|
|
|
export const auditInterestUpdated = (event: any, interestId: string, changes: any) => {
|
|
return logAuditEvent(event, 'UPDATE_INTEREST', 'interest', {
|
|
resourceId: interestId,
|
|
changes,
|
|
status: 'success'
|
|
});
|
|
};
|
|
|
|
export const auditInterestDeleted = (event: any, interestId: string) => {
|
|
return logAuditEvent(event, 'DELETE_INTEREST', 'interest', {
|
|
resourceId: interestId,
|
|
status: 'success'
|
|
});
|
|
};
|
|
|
|
export const auditInterestMerged = (event: any, masterInterestId: string, mergedInterestIds: string[], mergeDetails: any) => {
|
|
return logAuditEvent(event, 'MERGE_INTERESTS', 'interest', {
|
|
resourceId: masterInterestId,
|
|
changes: {
|
|
master_interest_id: masterInterestId,
|
|
merged_interest_ids: mergedInterestIds,
|
|
merge_details: mergeDetails
|
|
},
|
|
status: 'success'
|
|
});
|
|
};
|
|
|
|
export const auditLogin = (event: any, userId: string, userEmail: string) => {
|
|
return logAuditEvent(event, 'USER_LOGIN', 'authentication', {
|
|
resourceId: userId,
|
|
changes: { user_email: userEmail },
|
|
status: 'success'
|
|
});
|
|
};
|
|
|
|
export const auditLogout = (event: any, userId: string, userEmail: string) => {
|
|
return logAuditEvent(event, 'USER_LOGOUT', 'authentication', {
|
|
resourceId: userId,
|
|
changes: { user_email: userEmail },
|
|
status: 'success'
|
|
});
|
|
};
|
|
|
|
export const auditAccessDenied = (event: any, resource: string, requiredRoles: string[]) => {
|
|
return logAuditEvent(event, 'ACCESS_DENIED', 'authorization', {
|
|
resourceId: resource,
|
|
changes: { required_roles: requiredRoles },
|
|
status: 'failure',
|
|
errorMessage: 'Insufficient permissions'
|
|
});
|
|
};
|