Files
dashboard/backend/src/middleware/rbac.middleware.ts
2026-03-12 11:37:25 +01:00

205 lines
6.4 KiB
TypeScript

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<string, AppRole> = {
'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<AppRole> {
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<void> => {
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<void> => {
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();
};
}