Add role-based authorization system with admin functionality

- Implement authorization middleware and composables for role checking
- Add groups/roles support to authentication and session management
- Create admin dashboard pages and API endpoints
- Add audit logging utility for tracking user actions
- Enhance expense page with role-based access control
- Improve session caching with authorization state management
This commit is contained in:
2025-07-09 10:40:27 -04:00
parent 2774b4050f
commit f8d5e4d7e2
11 changed files with 1244 additions and 42 deletions

View File

@@ -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'
};
}
});

View File

@@ -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'
};
}
});

View File

@@ -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: [] }
}
})

View File

@@ -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;

View File

@@ -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<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'
});
};

View File

@@ -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<boolean> => {
}
}
export const requireAuth = async (event: any) => {
/**
* Get the full authenticated session with user and groups
*/
export const getAuthSession = async (event: any): Promise<AuthSession> => {
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<string[]> => {
const session = await getAuthSession(event);
return session.groups || [];
}
/**
* Check if user has specific role/group
*/
export const hasRole = async (event: any, role: string): Promise<boolean> => {
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<boolean> => {
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<AuthSession> => {
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<any | null> => {
export const getAuthenticatedUser = async (event: any): Promise<AuthenticatedUser | null> => {
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<AuthSession> => {
return requireAuth(event, ['admin']);
}
export const requireSalesOrAdmin = async (event: any): Promise<AuthSession> => {
return requireAuth(event, ['sales', 'admin']);
}
export const requireUserOrAbove = async (event: any): Promise<AuthSession> => {
return requireAuth(event, ['user', 'sales', 'admin']);
}
function parseCookies(cookieString: string): Record<string, string> {
return cookieString.split(';').reduce((cookies: Record<string, string>, cookie) => {
const [name, value] = cookie.trim().split('=');