import { Request, Response, NextFunction } from 'express'; import pool from '../config/database'; import logger from '../utils/logger'; import { auditPermissionDenied } from './audit.middleware'; import { AuditResourceType } from '../services/audit.service'; // --------------------------------------------------------------------------- // AppRole — mirrors the roles defined in the project spec. // Tier 1 (RBAC) is assumed complete and adds a `role` column to users. // This middleware reads that column to enforce permissions. // --------------------------------------------------------------------------- export type AppRole = | 'admin' | 'kommandant' | 'gruppenfuehrer' | 'mitglied' | 'bewerber'; /** * Role hierarchy: higher index = more permissions. * Used to implement "at least X role" checks. */ const ROLE_HIERARCHY: AppRole[] = [ 'bewerber', 'mitglied', 'gruppenfuehrer', 'kommandant', 'admin', ]; /** * Permission map: defines which roles hold a given permission string. * All roles at or above the listed minimum also hold the permission. */ const PERMISSION_ROLE_MIN: Record = { 'incidents:read': 'mitglied', 'incidents:write': 'gruppenfuehrer', 'incidents:delete': 'kommandant', 'incidents:read_bericht_text': 'kommandant', 'incidents:manage_personnel': 'gruppenfuehrer', // Training / Calendar 'training:read': 'mitglied', 'training:write': 'gruppenfuehrer', 'training:cancel': 'kommandant', 'training:mark_attendance': 'gruppenfuehrer', 'reports:read': 'kommandant', // Audit log and admin panel — restricted to admin role only 'admin:access': 'admin', 'audit:read': 'admin', 'audit:export': 'admin', 'members:read': 'mitglied', 'members:write': 'kommandant', 'vehicles:write': 'kommandant', 'vehicles:status': 'gruppenfuehrer', 'vehicles:delete': 'admin', 'equipment:write': 'gruppenfuehrer', 'equipment:delete': 'admin', 'events:write': 'gruppenfuehrer', 'events:categories': 'gruppenfuehrer', 'atemschutz:write': 'gruppenfuehrer', 'atemschutz:delete': 'kommandant', 'bookings:write': 'gruppenfuehrer', 'bookings:delete': 'admin', }; function hasPermission(role: AppRole, permission: string): boolean { const minRole = PERMISSION_ROLE_MIN[permission]; if (!minRole) { logger.warn('Unknown permission checked', { permission }); return false; } const userLevel = ROLE_HIERARCHY.indexOf(role); const minLevel = ROLE_HIERARCHY.indexOf(minRole); return userLevel >= minLevel; } /** * Retrieves the role for a given user ID from the database. * Falls back to 'mitglied' if the users table does not yet have a role column * (graceful degradation while Tier 1 migration is pending). */ async function getUserRole(userId: string): Promise { try { const result = await pool.query( `SELECT role FROM users WHERE id = $1`, [userId] ); if (result.rows.length === 0) return 'mitglied'; return (result.rows[0].role as AppRole) ?? 'mitglied'; } catch (error) { // If the column doesn't exist yet (Tier 1 not deployed), degrade gracefully const errMsg = error instanceof Error ? error.message : String(error); if (errMsg.includes('column "role" does not exist')) { logger.warn('users.role column not found — Tier 1 RBAC migration pending. Defaulting to mitglied.'); return 'mitglied'; } logger.error('Error fetching user role', { error, userId }); return 'mitglied'; } } /** * Middleware factory: requires the authenticated user to hold the given * permission (or a role with sufficient hierarchy level). * * Usage: * router.post('/api/incidents', authenticate, requirePermission('incidents:write'), handler) */ export function requirePermission(permission: string) { return async (req: Request, res: Response, next: NextFunction): Promise => { if (!req.user) { res.status(401).json({ success: false, message: 'Authentication required', }); return; } const role = (req.user as any).role ? (req.user as any).role as AppRole : await getUserRole(req.user.id); // Attach role to request for downstream use (e.g., bericht_text redaction) (req as Request & { userRole?: AppRole }).userRole = role; if (!hasPermission(role, permission)) { // Fallback: dashboard_admin group grants admin:access if (permission === 'admin:access') { const userGroups: string[] = req.user?.groups ?? []; if (userGroups.includes('dashboard_admin')) { (req as Request & { userRole?: AppRole }).userRole = 'admin'; next(); return; } } logger.warn('Permission denied', { userId: req.user.id, role, permission, path: req.path, }); // GDPR audit trail — fire-and-forget, never throws auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, { required_permission: permission, user_role: role, }); res.status(403).json({ success: false, message: 'Keine Berechtigung', }); return; } next(); }; } export { getUserRole, hasPermission }; /** * Middleware factory: requires the authenticated user to belong to at least * one of the given Authentik groups (sourced from the JWT `groups` claim). * * Usage: * router.post('/api/vehicles', authenticate, requireGroups(['dashboard_admin']), handler) */ export function requireGroups(requiredGroups: string[]) { return async (req: Request, res: Response, next: NextFunction): Promise => { if (!req.user) { res.status(401).json({ success: false, message: 'Authentication required' }); return; } logger.warn('DEPRECATED: requireGroups() — migrate to requirePermission()', { requiredGroups }); const userGroups: string[] = req.user?.groups ?? []; const hasAccess = requiredGroups.some(g => userGroups.includes(g)); if (!hasAccess) { logger.warn('Group-based access denied', { userId: req.user.id, userGroups, requiredGroups, path: req.path, }); auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, { required_groups: requiredGroups, user_groups: userGroups, }); res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Aktion', }); return; } next(); }; }