apply security audit

This commit is contained in:
Matthias Hochmeister
2026-03-11 13:51:01 +01:00
parent 93a87a7ae9
commit 3c9b7d3446
19 changed files with 247 additions and 341 deletions

View File

@@ -1,10 +1,7 @@
import { Router } from 'express';
import atemschutzController from '../controllers/atemschutz.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware';
const ADMIN_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
@@ -15,13 +12,13 @@ router.get('/stats', authenticate, atemschutzController.getStats.bind(atems
router.get('/my-status', authenticate, atemschutzController.getMyStatus.bind(atemschutzController));
router.get('/:id', authenticate, atemschutzController.getOne.bind(atemschutzController));
// ── Write — admin + kommandant ───────────────────────────────────────────────
// ── Write — gruppenfuehrer+ ─────────────────────────────────────────────────
router.post('/', authenticate, requireGroups(WRITE_GROUPS), atemschutzController.create.bind(atemschutzController));
router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), atemschutzController.update.bind(atemschutzController));
router.post('/', authenticate, requirePermission('atemschutz:write'), atemschutzController.create.bind(atemschutzController));
router.patch('/:id', authenticate, requirePermission('atemschutz:write'), atemschutzController.update.bind(atemschutzController));
// ── Delete — admin only ──────────────────────────────────────────────────────
// ── Delete — kommandant+ ────────────────────────────────────────────────────
router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), atemschutzController.delete.bind(atemschutzController));
router.delete('/:id', authenticate, requirePermission('atemschutz:delete'), atemschutzController.delete.bind(atemschutzController));
export default router;

View File

@@ -1,6 +1,6 @@
import { Router } from 'express';
import authController from '../controllers/auth.controller';
import { optionalAuth } from '../middleware/auth.middleware';
import { authenticate } from '../middleware/auth.middleware';
const router = Router();
@@ -14,9 +14,9 @@ router.post('/callback', authController.handleCallback);
/**
* @route POST /api/auth/logout
* @desc Logout user
* @access Public (optional auth for logging purposes)
* @access Private
*/
router.post('/logout', optionalAuth, authController.handleLogout);
router.post('/logout', authenticate, authController.handleLogout);
/**
* @route POST /api/auth/refresh

View File

@@ -1,10 +1,7 @@
import { Router } from 'express';
import bookingController from '../controllers/booking.controller';
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware';
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator'];
const ADMIN_GROUPS = ['dashboard_admin'];
import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
@@ -22,13 +19,13 @@ router.get('/calendar-token', authenticate, bookingController.getCalendarToken.b
// ── Write operations ──────────────────────────────────────────────────────────
router.post('/', authenticate, bookingController.create.bind(bookingController));
router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), bookingController.update.bind(bookingController));
router.patch('/:id', authenticate, requirePermission('bookings:write'), bookingController.update.bind(bookingController));
// Soft-cancel (sets abgesagt=TRUE)
router.delete('/:id', authenticate, requireGroups(WRITE_GROUPS), bookingController.cancel.bind(bookingController));
router.delete('/:id', authenticate, requirePermission('bookings:write'), bookingController.cancel.bind(bookingController));
// Hard-delete (admin only)
router.delete('/:id/force', authenticate, requireGroups(ADMIN_GROUPS), bookingController.hardDelete.bind(bookingController));
router.delete('/:id/force', authenticate, requirePermission('bookings:delete'), bookingController.hardDelete.bind(bookingController));
// ── Single booking read — after specific routes to avoid path conflicts ───────

View File

@@ -1,10 +1,7 @@
import { Router } from 'express';
import equipmentController from '../controllers/equipment.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware';
const ADMIN_GROUPS = ['dashboard_admin'];
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_zeugmeister'];
import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
@@ -18,15 +15,15 @@ router.get('/vehicle-warnings', authenticate, equipmentController.getVehicleW
router.get('/vehicle/:fahrzeugId', authenticate, equipmentController.getByVehicle.bind(equipmentController));
router.get('/:id', authenticate, equipmentController.getEquipment.bind(equipmentController));
// ── Write — admin + fahrmeister ──────────────────────────────────────────────
// ── Write — gruppenfuehrer+ ────────────────────────────────────────────────
router.post('/', authenticate, requireGroups(WRITE_GROUPS), equipmentController.createEquipment.bind(equipmentController));
router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), equipmentController.updateEquipment.bind(equipmentController));
router.patch('/:id/status', authenticate, requireGroups(WRITE_GROUPS), equipmentController.updateStatus.bind(equipmentController));
router.post('/:id/wartung', authenticate, requireGroups(WRITE_GROUPS), equipmentController.addWartung.bind(equipmentController));
router.post('/', authenticate, requirePermission('equipment:write'), equipmentController.createEquipment.bind(equipmentController));
router.patch('/:id', authenticate, requirePermission('equipment:write'), equipmentController.updateEquipment.bind(equipmentController));
router.patch('/:id/status', authenticate, requirePermission('equipment:write'), equipmentController.updateStatus.bind(equipmentController));
router.post('/:id/wartung', authenticate, requirePermission('equipment:write'), equipmentController.addWartung.bind(equipmentController));
// ── Delete — admin only ──────────────────────────────────────────────────────
router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), equipmentController.deleteEquipment.bind(equipmentController));
router.delete('/:id', authenticate, requirePermission('equipment:delete'), equipmentController.deleteEquipment.bind(equipmentController));
export default router;

View File

@@ -1,13 +1,10 @@
import { Router } from 'express';
import eventsController from '../controllers/events.controller';
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware';
import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
/** Groups that may create, update, or cancel events */
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_moderator'];
// ---------------------------------------------------------------------------
// Categories
// ---------------------------------------------------------------------------
@@ -20,34 +17,34 @@ router.get('/kategorien', authenticate, eventsController.listKategorien.bind(eve
/**
* POST /api/events/kategorien
* Create a new category. Requires admin or moderator.
* Create a new category. Requires gruppenfuehrer+.
*/
router.post(
'/kategorien',
authenticate,
requireGroups(WRITE_GROUPS),
requirePermission('events:categories'),
eventsController.createKategorie.bind(eventsController)
);
/**
* PATCH /api/events/kategorien/:id
* Update an existing category. Requires admin or moderator.
* Update an existing category. Requires gruppenfuehrer+.
*/
router.patch(
'/kategorien/:id',
authenticate,
requireGroups(WRITE_GROUPS),
requirePermission('events:categories'),
eventsController.updateKategorie.bind(eventsController)
);
/**
* DELETE /api/events/kategorien/:id
* Delete a category (only if no events reference it). Requires admin or moderator.
* Delete a category (only if no events reference it). Requires gruppenfuehrer+.
*/
router.delete(
'/kategorien/:id',
authenticate,
requireGroups(WRITE_GROUPS),
requirePermission('events:categories'),
eventsController.deleteKategorie.bind(eventsController)
);
@@ -106,23 +103,23 @@ router.get(
/**
* POST /api/events/import
* Bulk import events from CSV data. Requires admin or moderator.
* Bulk import events from CSV data. Requires gruppenfuehrer+.
*/
router.post(
'/import',
authenticate,
requireGroups(WRITE_GROUPS),
requirePermission('events:write'),
eventsController.importEvents.bind(eventsController)
);
/**
* POST /api/events
* Create a new event. Requires admin or moderator.
* Create a new event. Requires gruppenfuehrer+.
*/
router.post(
'/',
authenticate,
requireGroups(WRITE_GROUPS),
requirePermission('events:write'),
eventsController.createEvent.bind(eventsController)
);
@@ -134,34 +131,34 @@ router.get('/:id', authenticate, eventsController.getById.bind(eventsController)
/**
* PATCH /api/events/:id
* Update an existing event. Requires admin or moderator.
* Update an existing event. Requires gruppenfuehrer+.
*/
router.patch(
'/:id',
authenticate,
requireGroups(WRITE_GROUPS),
requirePermission('events:write'),
eventsController.updateEvent.bind(eventsController)
);
/**
* DELETE /api/events/:id
* Soft-cancel an event (sets abgesagt=TRUE + reason). Requires admin or moderator.
* Soft-cancel an event (sets abgesagt=TRUE + reason). Requires gruppenfuehrer+.
*/
router.delete(
'/:id',
authenticate,
requireGroups(WRITE_GROUPS),
requirePermission('events:write'),
eventsController.cancelEvent.bind(eventsController)
);
/**
* POST /api/events/:id/delete
* Hard-delete an event permanently. Requires admin or moderator.
* Hard-delete an event permanently. Requires gruppenfuehrer+.
*/
router.post(
'/:id/delete',
authenticate,
requireGroups(WRITE_GROUPS),
requirePermission('events:write'),
eventsController.deleteEvent.bind(eventsController)
);

View File

@@ -1,87 +1,16 @@
import { Router, Request, Response, NextFunction } from 'express';
import memberController from '../controllers/member.controller';
import { authenticate } from '../middleware/auth.middleware';
import logger from '../utils/logger';
import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
// ----------------------------------------------------------------
// Role/permission middleware
//
// The JWT currently carries: { userId, email, authentikSub }.
// Roles come from Authentik group membership stored in the
// `groups` array on the UserInfo response. The auth controller
// already upserts the user in the DB on every login; this
// middleware resolves the role from req.user (extended below).
//
// Until a full roles column exists in the users table, roles are
// derived from a well-known Authentik group naming convention:
//
// "feuerwehr-admin" → AppRole 'admin'
// "feuerwehr-kommandant" → AppRole 'kommandant'
// (everything else) → AppRole 'mitglied'
//
// The groups are passed through the JWT as req.user.groups (added
// by the extended type below). Replace this logic with a DB
// lookup once a roles column is added to users.
// Apply authentication to every route in this router.
// requirePermission is applied per-route because PATCH allows the
// owner to update their own limited fields even without 'members:write'.
// ----------------------------------------------------------------
type AppRole = 'admin' | 'kommandant' | 'mitglied';
/**
* Resolves the AppRole from Authentik groups attached to the JWT.
* Mutates req.user.role so downstream controllers can read it directly.
*/
const resolveRole = (req: Request, _res: Response, next: NextFunction): void => {
if (req.user) {
const groups: string[] = (req.user as any).groups ?? [];
if (groups.includes('feuerwehr-admin')) {
req.user.role = 'admin';
} else if (groups.includes('feuerwehr-kommandant')) {
req.user.role = 'kommandant' as any;
} else {
req.user.role = 'mitglied' as any;
}
logger.debug('resolveRole', { userId: req.user.id, role: req.user.role });
}
next();
};
/**
* Factory: creates a middleware that enforces the minimum required role.
* Role hierarchy: admin > kommandant > mitglied
*/
const requirePermission = (permission: 'members:read' | 'members:write') => {
return (req: Request, res: Response, next: NextFunction): void => {
const role = (req.user as any)?.role ?? 'mitglied';
const writeRoles: AppRole[] = ['admin', 'kommandant'];
const readRoles: AppRole[] = ['admin', 'kommandant', 'mitglied'];
const allowed =
permission === 'members:write'
? writeRoles.includes(role)
: readRoles.includes(role);
if (!allowed) {
res.status(403).json({
success: false,
message: 'Keine Berechtigung für diese Aktion.',
});
return;
}
next();
};
};
// ----------------------------------------------------------------
// Apply authentication + role resolution to every route in this
// router. Note: requirePermission is applied per-route because
// PATCH allows the owner to update their own limited fields even
// without 'members:write'.
// ----------------------------------------------------------------
router.use(authenticate, resolveRole);
router.use(authenticate);
// IMPORTANT: The static /stats route must be registered BEFORE
// the dynamic /:userId route, otherwise Express would match

View File

@@ -1,10 +1,7 @@
import { Router } from 'express';
import vehicleController from '../controllers/vehicle.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware';
const ADMIN_GROUPS = ['dashboard_admin'];
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
@@ -16,15 +13,15 @@ router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleCont
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
// ── Write — admin only ────────────────────────────────────────────────────────
// ── Write — kommandant+ ──────────────────────────────────────────────────────
router.post('/', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.createVehicle.bind(vehicleController));
router.patch('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.updateVehicle.bind(vehicleController));
router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.deleteVehicle.bind(vehicleController));
router.post('/', authenticate, requirePermission('vehicles:write'), vehicleController.createVehicle.bind(vehicleController));
router.patch('/:id', authenticate, requirePermission('vehicles:write'), vehicleController.updateVehicle.bind(vehicleController));
router.delete('/:id', authenticate, requirePermission('vehicles:delete'), vehicleController.deleteVehicle.bind(vehicleController));
// ── Status + maintenance log — admin + fahrmeister ────────────────────────────
// ── Status + maintenance log — gruppenfuehrer+ ──────────────────────────────
router.patch('/:id/status', authenticate, requireGroups(WRITE_GROUPS), vehicleController.updateVehicleStatus.bind(vehicleController));
router.post('/:id/wartung', authenticate, requireGroups(WRITE_GROUPS), vehicleController.addWartung.bind(vehicleController));
router.patch('/:id/status', authenticate, requirePermission('vehicles:status'), vehicleController.updateVehicleStatus.bind(vehicleController));
router.post('/:id/wartung', authenticate, requirePermission('vehicles:status'), vehicleController.addWartung.bind(vehicleController));
export default router;