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;