diff --git a/composables/useAuthorization.ts b/composables/useAuthorization.ts new file mode 100644 index 0000000..1d54f07 --- /dev/null +++ b/composables/useAuthorization.ts @@ -0,0 +1,198 @@ +export interface UserWithGroups { + id: string; + email: string; + username: string; + name: string; + authMethod: string; + groups: string[]; +} + +export interface AuthState { + user: UserWithGroups | null; + authenticated: boolean; + groups: string[]; +} + +/** + * Authorization composable for role-based access control + */ +export const useAuthorization = () => { + // Get the current user state from Nuxt + const nuxtApp = useNuxtApp(); + + /** + * Get current user groups from session + */ + const getUserGroups = (): string[] => { + const authState = nuxtApp.payload.data?.authState as AuthState; + return authState?.groups || []; + }; + + /** + * Get current authenticated user + */ + const getCurrentUser = (): UserWithGroups | null => { + const authState = nuxtApp.payload.data?.authState as AuthState; + return authState?.user || null; + }; + + /** + * Check if user has specific role/group + */ + const hasRole = (role: string): boolean => { + const groups = getUserGroups(); + return groups.includes(role); + }; + + /** + * Check if user has any of the specified roles + */ + const hasAnyRole = (roles: string[]): boolean => { + const groups = getUserGroups(); + return roles.some(role => groups.includes(role)); + }; + + /** + * Check if user has all of the specified roles + */ + const hasAllRoles = (roles: string[]): boolean => { + const groups = getUserGroups(); + return roles.every(role => groups.includes(role)); + }; + + /** + * Check if user can access a specific resource/feature + */ + const canAccess = (resource: string): boolean => { + const groups = getUserGroups(); + + // Define access rules for different resources + const accessRules: Record = { + 'expenses': ['sales', 'admin'], + 'admin-console': ['admin'], + 'audit-logs': ['admin'], + 'system-logs': ['admin'], + 'duplicate-management': ['admin'], + 'user-management': ['admin'], + 'interests': ['user', 'sales', 'admin'], + 'berths': ['user', 'sales', 'admin'], + 'dashboard': ['user', 'sales', 'admin'], + }; + + const requiredRoles = accessRules[resource]; + if (!requiredRoles) { + // If no specific rules defined, allow for authenticated users + return groups.length > 0; + } + + return hasAnyRole(requiredRoles); + }; + + /** + * Convenience methods for common role checks + */ + const isAdmin = (): boolean => hasRole('admin'); + const isSales = (): boolean => hasRole('sales'); + const isUser = (): boolean => hasRole('user'); + const isSalesOrAdmin = (): boolean => hasAnyRole(['sales', 'admin']); + const isUserOrAbove = (): boolean => hasAnyRole(['user', 'sales', 'admin']); + + /** + * Get user's highest role (for display purposes) + */ + const getHighestRole = (): string => { + const groups = getUserGroups(); + if (groups.includes('admin')) return 'admin'; + if (groups.includes('sales')) return 'sales'; + if (groups.includes('user')) return 'user'; + return 'none'; + }; + + /** + * Get role display name + */ + const getRoleDisplayName = (role: string): string => { + const roleNames: Record = { + 'admin': 'Administrator', + 'sales': 'Sales Team', + 'user': 'User', + 'none': 'No Access' + }; + return roleNames[role] || role; + }; + + /** + * Get role color for UI display + */ + const getRoleColor = (role: string): string => { + const roleColors: Record = { + 'admin': 'red', + 'sales': 'purple', + 'user': 'blue', + 'none': 'grey' + }; + return roleColors[role] || 'grey'; + }; + + /** + * Check if current route requires specific roles + */ + const checkRouteAccess = (route: any): boolean => { + if (!route.meta?.roles) { + // No role requirements, allow access + return true; + } + + const requiredRoles = Array.isArray(route.meta.roles) ? route.meta.roles : [route.meta.roles]; + return hasAnyRole(requiredRoles); + }; + + /** + * Get navigation items filtered by user permissions + */ + const getFilteredNavigation = (navigationItems: any[]): any[] => { + return navigationItems.filter(item => { + if (!item.roles) return true; // No role restrictions + return hasAnyRole(item.roles); + }); + }; + + /** + * Update auth state (called by middleware) + */ + const updateAuthState = (authState: AuthState) => { + if (!nuxtApp.payload.data) { + nuxtApp.payload.data = {}; + } + nuxtApp.payload.data.authState = authState; + }; + + return { + // State getters + getUserGroups, + getCurrentUser, + + // Role checking + hasRole, + hasAnyRole, + hasAllRoles, + canAccess, + + // Convenience methods + isAdmin, + isSales, + isUser, + isSalesOrAdmin, + isUserOrAbove, + + // Utility methods + getHighestRole, + getRoleDisplayName, + getRoleColor, + checkRouteAccess, + getFilteredNavigation, + + // State management + updateAuthState + }; +}; diff --git a/middleware/authentication.ts b/middleware/authentication.ts index a7323b0..af54805 100644 --- a/middleware/authentication.ts +++ b/middleware/authentication.ts @@ -23,12 +23,21 @@ export default defineNuxtRouteMiddleware(async (to) => { const cacheExpiry = 30000; // 30 seconds cache // Check if we have a cached session - const cachedSession = nuxtApp.payload.data[cacheKey]; + const cachedSession = nuxtApp.payload.data?.[cacheKey]; const now = Date.now(); if (cachedSession && cachedSession.timestamp && (now - cachedSession.timestamp) < cacheExpiry) { console.log('[MIDDLEWARE] Using cached session'); if (cachedSession.authenticated && cachedSession.user) { + // Store auth state for components + if (!nuxtApp.payload.data) { + nuxtApp.payload.data = {}; + } + nuxtApp.payload.data.authState = { + user: cachedSession.user, + authenticated: cachedSession.authenticated, + groups: cachedSession.groups || [] + }; return; } return navigateTo('/login'); @@ -48,19 +57,39 @@ export default defineNuxtRouteMiddleware(async (to) => { clearTimeout(timeout); // Cache the session data + if (!nuxtApp.payload.data) { + nuxtApp.payload.data = {}; + } + nuxtApp.payload.data[cacheKey] = { ...sessionData, timestamp: now }; + // Store auth state for components + nuxtApp.payload.data.authState = { + user: sessionData.user, + authenticated: sessionData.authenticated, + groups: sessionData.groups || [] + }; + console.log('[MIDDLEWARE] Session check result:', { authenticated: sessionData.authenticated, hasUser: !!sessionData.user, - userId: sessionData.user?.id + userId: sessionData.user?.id, + groups: sessionData.groups || [] }); if (sessionData.authenticated && sessionData.user) { console.log('[MIDDLEWARE] User authenticated, allowing access'); + + // Check for any auth errors from authorization middleware + if (nuxtApp.payload.authError) { + const toast = useToast(); + toast.error(String(nuxtApp.payload.authError)); + delete nuxtApp.payload.authError; + } + return; } @@ -73,10 +102,19 @@ export default defineNuxtRouteMiddleware(async (to) => { // If it's a network error or timeout, check if we have a recent cached session if (error.name === 'AbortError' || error.code === 'ECONNREFUSED') { console.log('[MIDDLEWARE] Network error, checking for recent cache'); - const recentCache = nuxtApp.payload.data[cacheKey]; + const recentCache = nuxtApp.payload.data?.[cacheKey]; if (recentCache && recentCache.timestamp && (now - recentCache.timestamp) < 300000) { // 5 minutes console.log('[MIDDLEWARE] Using recent cache despite network error'); if (recentCache.authenticated && recentCache.user) { + // Store auth state for components + if (!nuxtApp.payload.data) { + nuxtApp.payload.data = {}; + } + nuxtApp.payload.data.authState = { + user: recentCache.user, + authenticated: recentCache.authenticated, + groups: recentCache.groups || [] + }; return; } } diff --git a/middleware/authorization.ts b/middleware/authorization.ts new file mode 100644 index 0000000..16d3453 --- /dev/null +++ b/middleware/authorization.ts @@ -0,0 +1,57 @@ +export default defineNuxtRouteMiddleware(async (to) => { + // Skip on server-side rendering + if (import.meta.server) return; + + // Skip if no auth requirements or roles specified + if (!to.meta.roles) { + return; + } + + console.log('[AUTHORIZATION] Checking route access for:', to.path, 'Required roles:', to.meta.roles); + + try { + // Get current session data with groups + const sessionData = await $fetch('/api/auth/session') as any; + + if (!sessionData.authenticated || !sessionData.user) { + console.log('[AUTHORIZATION] User not authenticated, redirecting to login'); + return navigateTo('/login'); + } + + // Get required roles for this route + const requiredRoles = Array.isArray(to.meta.roles) ? to.meta.roles : [to.meta.roles]; + const userGroups = sessionData.groups || []; + + // Check if user has any of the required roles + const hasRequiredRole = requiredRoles.some(role => userGroups.includes(role)); + + if (!hasRequiredRole) { + console.log('[AUTHORIZATION] Access denied. User groups:', userGroups, 'Required roles:', requiredRoles); + + // Store the error in nuxtApp to show toast on redirect + const nuxtApp = useNuxtApp(); + nuxtApp.payload.authError = `Access denied. This page requires one of the following roles: ${requiredRoles.join(', ')}`; + + // Redirect to dashboard instead of login since user is authenticated + return navigateTo('/dashboard'); + } + + // Store auth state in nuxtApp for use by components + const nuxtApp = useNuxtApp(); + if (!nuxtApp.payload.data) { + nuxtApp.payload.data = {}; + } + nuxtApp.payload.data.authState = { + user: sessionData.user, + authenticated: sessionData.authenticated, + groups: sessionData.groups || [] + }; + + console.log('[AUTHORIZATION] Access granted for route:', to.path); + } catch (error) { + console.error('[AUTHORIZATION] Error checking route access:', error); + + // If session check fails, redirect to login + return navigateTo('/login'); + } +}); diff --git a/pages/dashboard/admin/index.vue b/pages/dashboard/admin/index.vue new file mode 100644 index 0000000..6902b15 --- /dev/null +++ b/pages/dashboard/admin/index.vue @@ -0,0 +1,265 @@ + + + diff --git a/pages/dashboard/expenses.vue b/pages/dashboard/expenses.vue index c56a37a..f570b95 100644 --- a/pages/dashboard/expenses.vue +++ b/pages/dashboard/expenses.vue @@ -268,7 +268,8 @@ const ExpenseDetailsModal = defineAsyncComponent(() => import('@/components/Expe // Page meta definePageMeta({ middleware: 'authentication', - layout: 'dashboard' + layout: 'dashboard', + roles: ['sales', 'admin'] }); // Reactive state diff --git a/server/api/admin/audit-logs/list.ts b/server/api/admin/audit-logs/list.ts new file mode 100644 index 0000000..1c402f2 --- /dev/null +++ b/server/api/admin/audit-logs/list.ts @@ -0,0 +1,62 @@ +import { requireAdmin } from '~/server/utils/auth'; +import { getAuditLogs } from '~/server/utils/audit-logger'; +import type { AuditLogFilters } from '~/server/utils/audit-logger'; + +export default defineEventHandler(async (event) => { + console.log('[ADMIN] Audit logs list request'); + + try { + // Require admin authentication + await requireAdmin(event); + + // Get query parameters + const query = getQuery(event); + + const filters: AuditLogFilters = { + startDate: query.startDate as string, + endDate: query.endDate as string, + userId: query.userId as string, + userEmail: query.userEmail as string, + action: query.action as string, + resourceType: query.resourceType as string, + status: query.status as 'success' | 'failure', + limit: query.limit ? parseInt(query.limit as string) : 50, + offset: query.offset ? parseInt(query.offset as string) : 0 + }; + + // Get audit logs + const result = await getAuditLogs(filters); + + console.log('[ADMIN] Returning audit logs:', { + count: result.list.length, + total: result.totalCount, + filters + }); + + return { + success: true, + data: result.list, + pagination: { + total: result.totalCount, + limit: filters.limit || 50, + offset: filters.offset || 0, + hasMore: (filters.offset || 0) + result.list.length < result.totalCount + } + }; + + } catch (error: any) { + console.error('[ADMIN] Failed to get audit logs:', error); + + if (error.statusCode === 403) { + return { + success: false, + error: 'Insufficient permissions. Admin access required.' + }; + } + + return { + success: false, + error: 'Failed to retrieve audit logs' + }; + } +}); diff --git a/server/api/admin/audit-logs/stats.ts b/server/api/admin/audit-logs/stats.ts new file mode 100644 index 0000000..acb1816 --- /dev/null +++ b/server/api/admin/audit-logs/stats.ts @@ -0,0 +1,43 @@ +import { requireAdmin } from '~/server/utils/auth'; +import { getAuditStats } from '~/server/utils/audit-logger'; + +export default defineEventHandler(async (event) => { + console.log('[ADMIN] Audit stats request'); + + try { + // Require admin authentication + await requireAdmin(event); + + // Get query parameters + const query = getQuery(event); + const days = query.days ? parseInt(query.days as string) : 30; + + // Get audit statistics + const stats = await getAuditStats(days); + + console.log('[ADMIN] Returning audit stats:', { + totalEvents: stats.totalEvents, + days + }); + + return { + success: true, + data: stats + }; + + } catch (error: any) { + console.error('[ADMIN] Failed to get audit stats:', error); + + if (error.statusCode === 403) { + return { + success: false, + error: 'Insufficient permissions. Admin access required.' + }; + } + + return { + success: false, + error: 'Failed to retrieve audit statistics' + }; + } +}); diff --git a/server/api/auth/session.ts b/server/api/auth/session.ts index 6cc844a..431c5f8 100644 --- a/server/api/auth/session.ts +++ b/server/api/auth/session.ts @@ -7,7 +7,7 @@ export default defineEventHandler(async (event) => { if (!oidcSessionCookie) { console.log('[SESSION] No OIDC session cookie found') - return { user: null, authenticated: false } + return { user: null, authenticated: false, groups: [] } } console.log('[SESSION] OIDC session cookie found, parsing...') @@ -19,6 +19,7 @@ export default defineEventHandler(async (event) => { console.log('[SESSION] Session data parsed successfully:', { hasUser: !!sessionData.user, hasAccessToken: !!sessionData.accessToken, + hasIdToken: !!sessionData.idToken, expiresAt: sessionData.expiresAt, createdAt: sessionData.createdAt, timeUntilExpiry: sessionData.expiresAt ? sessionData.expiresAt - Date.now() : 'unknown' @@ -31,7 +32,7 @@ export default defineEventHandler(async (event) => { domain: cookieDomain, path: '/' }) - return { user: null, authenticated: false } + return { user: null, authenticated: false, groups: [] } } // Validate session structure @@ -45,7 +46,7 @@ export default defineEventHandler(async (event) => { domain: cookieDomain, path: '/' }) - return { user: null, authenticated: false } + return { user: null, authenticated: false, groups: [] } } // Check if session is still valid @@ -61,13 +62,51 @@ export default defineEventHandler(async (event) => { domain: cookieDomain, path: '/' }) - return { user: null, authenticated: false } + return { user: null, authenticated: false, groups: [] } + } + + // Extract groups from ID token + let userGroups: string[] = []; + if (sessionData.idToken) { + try { + // Parse JWT payload (base64 decode the middle section) + const tokenParts = sessionData.idToken.split('.'); + if (tokenParts.length >= 2) { + const payload = JSON.parse(atob(tokenParts[1])); + userGroups = payload.groups || []; + console.log('[SESSION] Groups extracted from token:', userGroups); + } + } catch (tokenError) { + console.error('[SESSION] Failed to parse ID token:', tokenError); + // Continue without groups - not a fatal error + } + } + + // Also check access token for groups as fallback + if (userGroups.length === 0 && sessionData.accessToken) { + try { + const tokenParts = sessionData.accessToken.split('.'); + if (tokenParts.length >= 2) { + const payload = JSON.parse(atob(tokenParts[1])); + userGroups = payload.groups || []; + console.log('[SESSION] Groups extracted from access token:', userGroups); + } + } catch (tokenError) { + console.error('[SESSION] Failed to parse access token:', tokenError); + } + } + + // Default group assignment if no groups found + if (userGroups.length === 0) { + console.log('[SESSION] No groups found in token, assigning default "user" group'); + userGroups = ['user']; } console.log('[SESSION] Valid session found for user:', { id: sessionData.user.id, email: sessionData.user.email, - username: sessionData.user.username + username: sessionData.user.username, + groups: userGroups }) return { @@ -76,9 +115,11 @@ export default defineEventHandler(async (event) => { email: sessionData.user.email, username: sessionData.user.username, name: sessionData.user.name, - authMethod: sessionData.user.authMethod || 'keycloak' + authMethod: sessionData.user.authMethod || 'keycloak', + groups: userGroups }, - authenticated: true + authenticated: true, + groups: userGroups } } catch (error) { console.error('[SESSION] OIDC session check error:', error) @@ -88,6 +129,6 @@ export default defineEventHandler(async (event) => { domain: cookieDomain, path: '/' }) - return { user: null, authenticated: false } + return { user: null, authenticated: false, groups: [] } } }) diff --git a/server/api/expenses/export-csv.ts b/server/api/expenses/export-csv.ts index 386e1aa..1814402 100644 --- a/server/api/expenses/export-csv.ts +++ b/server/api/expenses/export-csv.ts @@ -1,11 +1,12 @@ -import { requireAuth } from '@/server/utils/auth'; +import { requireSalesOrAdmin } from '@/server/utils/auth'; +import { logAuditEvent } from '@/server/utils/audit-logger'; import { getExpenseById } from '@/server/utils/nocodb'; import { processExpenseWithCurrency } from '@/server/utils/currency'; import { uploadBuffer } from '@/server/utils/minio'; import type { Expense } from '@/utils/types'; export default defineEventHandler(async (event) => { - await requireAuth(event); + await requireSalesOrAdmin(event); const body = await readBody(event); const { expenseIds } = body; @@ -141,7 +142,7 @@ export default defineEventHandler(async (event) => { // Return CSV for direct download setHeader(event, 'Content-Type', 'text/csv'); setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`); - setHeader(event, 'Content-Length', csvBuffer.length.toString()); + setHeader(event, 'Content-Length', csvBuffer.length); return csvContent; diff --git a/server/utils/audit-logger.ts b/server/utils/audit-logger.ts new file mode 100644 index 0000000..c75c0f1 --- /dev/null +++ b/server/utils/audit-logger.ts @@ -0,0 +1,408 @@ +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 => { + 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 = { + 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): Promise => { + 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); + + 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); + + 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); + + 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' + }); +}; diff --git a/server/utils/auth.ts b/server/utils/auth.ts index 31ead07..cc99dbe 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -1,3 +1,18 @@ +export interface AuthenticatedUser { + id: string; + email: string; + username: string; + name: string; + authMethod: string; + groups: string[]; +} + +export interface AuthSession { + user: AuthenticatedUser | null; + authenticated: boolean; + groups: string[]; +} + /** * Check if the request is authenticated via Keycloak OIDC session */ @@ -54,24 +69,99 @@ export const isAuthenticated = async (event: any): Promise => { } } -export const requireAuth = async (event: any) => { +/** + * Get the full authenticated session with user and groups + */ +export const getAuthSession = async (event: any): Promise => { + try { + const sessionData = await $fetch('/api/auth/session', { + headers: { + cookie: getHeader(event, 'cookie') || '' + } + }) as AuthSession; + + return sessionData; + } catch (error) { + console.error('[auth] Failed to get auth session:', error); + return { user: null, authenticated: false, groups: [] }; + } +} + +/** + * Get user groups from the session + */ +export const getUserGroups = async (event: any): Promise => { + const session = await getAuthSession(event); + return session.groups || []; +} + +/** + * Check if user has specific role/group + */ +export const hasRole = async (event: any, role: string): Promise => { + const groups = await getUserGroups(event); + return groups.includes(role); +} + +/** + * Check if user has any of the specified roles + */ +export const hasAnyRole = async (event: any, roles: string[]): Promise => { + const groups = await getUserGroups(event); + return roles.some(role => groups.includes(role)); +} + +/** + * Require authentication and optionally specific roles + */ +export const requireAuth = async (event: any, requiredRoles?: string[]): Promise => { + console.log('[requireAuth] Checking authentication for:', event.node.req.url, 'Required roles:', requiredRoles); + // First check for internal API authentication const internalAuth = checkInternalAuth(event); if (internalAuth) { console.log('[requireAuth] Internal API authentication successful'); - return; + return { + user: { + id: 'system', + email: 'system@internal', + username: 'system', + name: 'System', + authMethod: 'internal', + groups: ['admin'] // Internal calls have admin privileges + }, + authenticated: true, + groups: ['admin'] + }; } - // Then check user authentication - const authenticated = await isAuthenticated(event); - if (!authenticated) { + // Get full session with groups + const session = await getAuthSession(event); + + if (!session.authenticated || !session.user) { console.log('[requireAuth] Authentication failed for:', event.node.req.url); - console.log('[requireAuth] Available cookies:', Object.keys(event.node.req.headers.cookie ? parseCookies(event.node.req.headers.cookie) : {})); throw createError({ statusCode: 401, - statusMessage: "Authentication required. Please login with Keycloak." + statusMessage: "Authentication required. Please login." }); } + + // Check role requirements if specified + if (requiredRoles && requiredRoles.length > 0) { + const userGroups = session.groups || []; + const hasRequiredRole = requiredRoles.some(role => userGroups.includes(role)); + + if (!hasRequiredRole) { + console.log('[requireAuth] Access denied. User groups:', userGroups, 'Required roles:', requiredRoles); + throw createError({ + statusCode: 403, + statusMessage: 'Insufficient permissions. This action requires one of the following roles: ' + requiredRoles.join(', ') + }); + } + } + + console.log('[requireAuth] Authentication successful for user:', session.user.email, 'Groups:', session.groups); + return session; } /** @@ -116,33 +206,31 @@ const checkInternalAuth = (event: any): boolean => { /** * Get the authenticated user from the session */ -export const getAuthenticatedUser = async (event: any): Promise => { +export const getAuthenticatedUser = async (event: any): Promise => { try { - const oidcSession = getCookie(event, 'nuxt-oidc-auth'); - - if (!oidcSession) { - return null; - } - - const sessionData = JSON.parse(oidcSession); - - // Validate session - if (!sessionData.user || !sessionData.accessToken) { - return null; - } - - // Check if session is still valid - if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) { - return null; - } - - return sessionData.user; + const session = await getAuthSession(event); + return session.user; } catch (error) { console.error('[getAuthenticatedUser] Error:', error); return null; } } +/** + * Authorization helper functions for common roles + */ +export const requireAdmin = async (event: any): Promise => { + return requireAuth(event, ['admin']); +} + +export const requireSalesOrAdmin = async (event: any): Promise => { + return requireAuth(event, ['sales', 'admin']); +} + +export const requireUserOrAbove = async (event: any): Promise => { + return requireAuth(event, ['user', 'sales', 'admin']); +} + function parseCookies(cookieString: string): Record { return cookieString.split(';').reduce((cookies: Record, cookie) => { const [name, value] = cookie.trim().split('=');