rights system
This commit is contained in:
@@ -97,6 +97,7 @@ import configRoutes from './routes/config.routes';
|
||||
import serviceMonitorRoutes from './routes/serviceMonitor.routes';
|
||||
import settingsRoutes from './routes/settings.routes';
|
||||
import bannerRoutes from './routes/banner.routes';
|
||||
import permissionRoutes from './routes/permission.routes';
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/user', userRoutes);
|
||||
@@ -118,6 +119,7 @@ app.use('/api/admin', serviceMonitorRoutes);
|
||||
app.use('/api/admin/settings', settingsRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/banners', bannerRoutes);
|
||||
app.use('/api/permissions', permissionRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use(notFoundHandler);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Request, Response } from 'express';
|
||||
import incidentService from '../services/incident.service';
|
||||
import logger from '../utils/logger';
|
||||
import { AppError } from '../middleware/error.middleware';
|
||||
import { AppRole, hasPermission, resolveRequestRole } from '../middleware/rbac.middleware';
|
||||
import { AppRole } from '../middleware/rbac.middleware';
|
||||
import { permissionService } from '../services/permission.service';
|
||||
import {
|
||||
CreateEinsatzSchema,
|
||||
UpdateEinsatzSchema,
|
||||
@@ -88,9 +89,11 @@ class IncidentController {
|
||||
throw new AppError('Einsatz nicht gefunden', 404);
|
||||
}
|
||||
|
||||
// Role-based redaction: self-contained role resolution (no middleware dependency)
|
||||
const role = resolveRequestRole(req);
|
||||
const canReadBerichtText = hasPermission(role, 'incidents:read_bericht_text');
|
||||
// Role-based redaction: check einsaetze:view_reports permission
|
||||
const groups: string[] = req.user?.groups ?? [];
|
||||
const canReadBerichtText =
|
||||
groups.includes('dashboard_admin') ||
|
||||
permissionService.hasPermission(groups, 'einsaetze:view_reports');
|
||||
|
||||
const responseData = {
|
||||
...incident,
|
||||
|
||||
116
backend/src/controllers/permission.controller.ts
Normal file
116
backend/src/controllers/permission.controller.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { permissionService } from '../services/permission.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
class PermissionController {
|
||||
/**
|
||||
* GET /api/permissions/me
|
||||
* Returns the current user's effective permissions.
|
||||
*/
|
||||
async getMyPermissions(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const groups: string[] = req.user?.groups ?? [];
|
||||
const isAdmin = groups.includes('dashboard_admin');
|
||||
|
||||
let permissions: string[];
|
||||
if (isAdmin) {
|
||||
// Admin gets all permissions
|
||||
const matrix = await permissionService.getMatrix();
|
||||
permissions = matrix.permissions.map(p => p.id);
|
||||
} else {
|
||||
permissions = permissionService.getEffectivePermissions(groups);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
permissions,
|
||||
maintenance: permissionService.getMaintenanceFlags(),
|
||||
isAdmin,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get user permissions', { error });
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Laden der Berechtigungen' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/permissions/matrix
|
||||
* Returns the full permission matrix for the admin UI.
|
||||
*/
|
||||
async getMatrix(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const matrix = await permissionService.getMatrix();
|
||||
res.json({ success: true, data: matrix });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get permission matrix', { error });
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Laden der Berechtigungsmatrix' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/permissions/group/:groupName
|
||||
* Sets all permissions for a given Authentik group.
|
||||
*/
|
||||
async setGroupPermissions(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const groupName = req.params.groupName as string;
|
||||
const { permissions } = req.body;
|
||||
|
||||
if (!Array.isArray(permissions)) {
|
||||
res.status(400).json({ success: false, message: 'permissions must be an array' });
|
||||
return;
|
||||
}
|
||||
|
||||
await permissionService.setGroupPermissions(
|
||||
groupName,
|
||||
permissions,
|
||||
req.user!.id
|
||||
);
|
||||
|
||||
res.json({ success: true, message: 'Berechtigungen aktualisiert' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to set group permissions', { error });
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Speichern der Berechtigungen' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/permissions/groups
|
||||
* Returns all known Authentik groups from the permission table.
|
||||
*/
|
||||
async getGroups(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const groups = await permissionService.getKnownGroups();
|
||||
res.json({ success: true, data: groups });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get groups', { error });
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Laden der Gruppen' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/permissions/maintenance/:featureGroupId
|
||||
* Toggles maintenance mode for a feature group.
|
||||
*/
|
||||
async setMaintenanceFlag(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const featureGroupId = req.params.featureGroupId as string;
|
||||
const { active } = req.body;
|
||||
|
||||
if (typeof active !== 'boolean') {
|
||||
res.status(400).json({ success: false, message: 'active must be a boolean' });
|
||||
return;
|
||||
}
|
||||
|
||||
await permissionService.setMaintenanceFlag(featureGroupId, active);
|
||||
res.json({ success: true, message: 'Wartungsmodus aktualisiert' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to set maintenance flag', { error });
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Setzen des Wartungsmodus' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new PermissionController();
|
||||
563
backend/src/database/migrations/037_create_permission_system.sql
Normal file
563
backend/src/database/migrations/037_create_permission_system.sql
Normal file
@@ -0,0 +1,563 @@
|
||||
-- Migration 037: DB-driven permission system
|
||||
-- Replaces hardcoded RBAC with per-Authentik-group permission assignments
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 1. Feature Groups
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feature_groups (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
label VARCHAR(100) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
maintenance BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
INSERT INTO feature_groups (id, label, sort_order) VALUES
|
||||
('kalender', 'Kalender', 1),
|
||||
('fahrzeuge', 'Fahrzeuge', 2),
|
||||
('einsaetze', 'Einsätze', 3),
|
||||
('ausruestung', 'Ausrüstung', 4),
|
||||
('mitglieder', 'Mitglieder', 5),
|
||||
('atemschutz', 'Atemschutz', 6),
|
||||
('wissen', 'Wissen', 7),
|
||||
('vikunja', 'Vikunja', 8),
|
||||
('nextcloud', 'Nextcloud', 9),
|
||||
('dashboard', 'Dashboard', 10)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 2. Permissions
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id VARCHAR(100) PRIMARY KEY,
|
||||
feature_group_id VARCHAR(50) NOT NULL REFERENCES feature_groups(id) ON DELETE CASCADE,
|
||||
label VARCHAR(150) NOT NULL,
|
||||
description TEXT,
|
||||
sort_order INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Kalender permissions
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('kalender:access', 'kalender', 'Zugriff', 'Kalender-Seite anzeigen', 1),
|
||||
('kalender:view_events', 'kalender', 'Termine ansehen', 'Termine und Übungen einsehen', 2),
|
||||
('kalender:view_bookings', 'kalender', 'Buchungen ansehen', 'Fahrzeugbuchungen einsehen', 3),
|
||||
('kalender:create_events', 'kalender', 'Termine erstellen', 'Neue Termine/Veranstaltungen erstellen', 4),
|
||||
('kalender:create_training', 'kalender', 'Übungen erstellen', 'Neue Übungen anlegen', 5),
|
||||
('kalender:cancel_training', 'kalender', 'Übungen absagen', 'Übungen absagen/löschen', 6),
|
||||
('kalender:mark_attendance', 'kalender', 'Anwesenheit eintragen', 'Teilnahme an Übungen bestätigen', 7),
|
||||
('kalender:create_bookings', 'kalender', 'Buchungen erstellen', 'Neue Fahrzeugbuchungen anlegen', 8),
|
||||
('kalender:edit_bookings', 'kalender', 'Buchungen bearbeiten', 'Bestehende Buchungen ändern', 9),
|
||||
('kalender:delete_bookings', 'kalender', 'Buchungen löschen', 'Buchungen endgültig löschen', 10),
|
||||
('kalender:manage_categories', 'kalender', 'Kategorien verwalten', 'Veranstaltungskategorien verwalten', 11),
|
||||
('kalender:view_reports', 'kalender', 'Berichte ansehen', 'Übungsstatistiken und Berichte einsehen', 12),
|
||||
('kalender:widget_events', 'kalender', 'Widget: Termine', 'Dashboard-Widget für Termine', 13),
|
||||
('kalender:widget_bookings', 'kalender', 'Widget: Buchungen', 'Dashboard-Widget für Buchungen', 14),
|
||||
('kalender:widget_quick_add', 'kalender', 'Widget: Schnell-Termin', 'Dashboard-Widget zum schnellen Erstellen', 15)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Fahrzeuge permissions
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('fahrzeuge:access', 'fahrzeuge', 'Zugriff', 'Fahrzeug-Seite anzeigen', 1),
|
||||
('fahrzeuge:view', 'fahrzeuge', 'Ansehen', 'Fahrzeugdetails einsehen', 2),
|
||||
('fahrzeuge:create', 'fahrzeuge', 'Erstellen', 'Neue Fahrzeuge anlegen', 3),
|
||||
('fahrzeuge:change_status', 'fahrzeuge', 'Status ändern', 'Fahrzeugstatus ändern', 4),
|
||||
('fahrzeuge:manage_maintenance', 'fahrzeuge', 'Wartung verwalten', 'Wartungseinträge erstellen/bearbeiten', 5),
|
||||
('fahrzeuge:delete', 'fahrzeuge', 'Löschen', 'Fahrzeuge löschen', 6),
|
||||
('fahrzeuge:widget', 'fahrzeuge', 'Widget', 'Dashboard-Widget für Fahrzeuge', 7)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Einsätze permissions
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('einsaetze:access', 'einsaetze', 'Zugriff', 'Einsatz-Seite anzeigen', 1),
|
||||
('einsaetze:view', 'einsaetze', 'Ansehen', 'Einsätze einsehen', 2),
|
||||
('einsaetze:view_reports', 'einsaetze', 'Berichte ansehen', 'Einsatzberichte/Berichtstext einsehen', 3),
|
||||
('einsaetze:create', 'einsaetze', 'Erstellen', 'Neue Einsätze anlegen und bearbeiten', 4),
|
||||
('einsaetze:delete', 'einsaetze', 'Löschen', 'Einsätze archivieren/löschen', 5),
|
||||
('einsaetze:manage_personnel', 'einsaetze', 'Personal verwalten', 'Einsatzpersonal und Fahrzeuge zuweisen', 6)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Ausrüstung permissions
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('ausruestung:access', 'ausruestung', 'Zugriff', 'Ausrüstungs-Seite anzeigen', 1),
|
||||
('ausruestung:view', 'ausruestung', 'Ansehen', 'Ausrüstung einsehen', 2),
|
||||
('ausruestung:create', 'ausruestung', 'Erstellen', 'Neue Ausrüstung anlegen und bearbeiten', 3),
|
||||
('ausruestung:manage_maintenance', 'ausruestung', 'Wartung verwalten', 'Wartungseinträge verwalten', 4),
|
||||
('ausruestung:delete', 'ausruestung', 'Löschen', 'Ausrüstung löschen', 5),
|
||||
('ausruestung:widget', 'ausruestung', 'Widget', 'Dashboard-Widget für Ausrüstung', 6)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Mitglieder permissions
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('mitglieder:access', 'mitglieder', 'Zugriff', 'Mitglieder-Seite anzeigen', 1),
|
||||
('mitglieder:view', 'mitglieder', 'Ansehen', 'Mitglieder-Profile einsehen', 2),
|
||||
('mitglieder:edit', 'mitglieder', 'Bearbeiten', 'Mitglieder-Profile bearbeiten', 3),
|
||||
('mitglieder:create_profile', 'mitglieder', 'Profil erstellen', 'Neue Mitglieder-Profile anlegen', 4)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Atemschutz permissions
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('atemschutz:access', 'atemschutz', 'Zugriff', 'Atemschutz-Seite anzeigen', 1),
|
||||
('atemschutz:view', 'atemschutz', 'Ansehen', 'Atemschutz-Daten aller Träger sehen', 2),
|
||||
('atemschutz:create', 'atemschutz', 'Erstellen', 'Atemschutz-Einträge anlegen/ändern', 3),
|
||||
('atemschutz:delete', 'atemschutz', 'Löschen', 'Atemschutz-Einträge löschen', 4),
|
||||
('atemschutz:widget', 'atemschutz', 'Widget', 'Dashboard-Widget für Atemschutz', 5)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Wissen permissions
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('wissen:access', 'wissen', 'Zugriff', 'Wissen-Seite anzeigen', 1),
|
||||
('wissen:widget_recent', 'wissen', 'Widget: Letzte', 'Dashboard-Widget letzte Seiten', 2),
|
||||
('wissen:widget_search', 'wissen', 'Widget: Suche', 'Dashboard-Widget für BookStack-Suche', 3)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Vikunja permissions
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('vikunja:access', 'vikunja', 'Zugriff', 'Vikunja-Integration nutzen', 1),
|
||||
('vikunja:create_tasks', 'vikunja', 'Aufgaben erstellen', 'Neue Vikunja-Aufgaben erstellen', 2),
|
||||
('vikunja:widget_tasks', 'vikunja', 'Widget: Aufgaben', 'Dashboard-Widget für Vikunja-Aufgaben', 3),
|
||||
('vikunja:widget_quick_add', 'vikunja', 'Widget: Schnell-Task', 'Dashboard-Widget zum schnellen Erstellen', 4)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Nextcloud permissions
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('nextcloud:access', 'nextcloud', 'Zugriff', 'Nextcloud-Integration nutzen', 1),
|
||||
('nextcloud:widget', 'nextcloud', 'Widget', 'Dashboard-Widget für Nextcloud', 2)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Dashboard permissions
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('dashboard:widget_links', 'dashboard', 'Widget: Links', 'Dashboard-Widget für externe Links', 1),
|
||||
('dashboard:widget_banner', 'dashboard', 'Widget: Banner', 'Dashboard-Widget für Banner', 2)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 3. Group Permissions
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_permissions (
|
||||
authentik_group VARCHAR(100) NOT NULL,
|
||||
permission_id VARCHAR(100) NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
||||
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
granted_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
PRIMARY KEY (authentik_group, permission_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_permissions_group ON group_permissions(authentik_group);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 4. Seed data — replicate current RBAC behavior
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Helper: grant a list of permissions to a group
|
||||
-- We insert each combination individually with ON CONFLICT DO NOTHING
|
||||
|
||||
-- ── All authenticated users (mitglied-level) ──
|
||||
-- Every non-bewerber group gets access + view + widget permissions
|
||||
|
||||
-- dashboard_kommando — gets everything (except admin)
|
||||
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||
-- Kalender
|
||||
('dashboard_kommando', 'kalender:access'),
|
||||
('dashboard_kommando', 'kalender:view_events'),
|
||||
('dashboard_kommando', 'kalender:view_bookings'),
|
||||
('dashboard_kommando', 'kalender:create_events'),
|
||||
('dashboard_kommando', 'kalender:create_training'),
|
||||
('dashboard_kommando', 'kalender:cancel_training'),
|
||||
('dashboard_kommando', 'kalender:mark_attendance'),
|
||||
('dashboard_kommando', 'kalender:create_bookings'),
|
||||
('dashboard_kommando', 'kalender:edit_bookings'),
|
||||
('dashboard_kommando', 'kalender:delete_bookings'),
|
||||
('dashboard_kommando', 'kalender:manage_categories'),
|
||||
('dashboard_kommando', 'kalender:view_reports'),
|
||||
('dashboard_kommando', 'kalender:widget_events'),
|
||||
('dashboard_kommando', 'kalender:widget_bookings'),
|
||||
('dashboard_kommando', 'kalender:widget_quick_add'),
|
||||
-- Fahrzeuge
|
||||
('dashboard_kommando', 'fahrzeuge:access'),
|
||||
('dashboard_kommando', 'fahrzeuge:view'),
|
||||
('dashboard_kommando', 'fahrzeuge:create'),
|
||||
('dashboard_kommando', 'fahrzeuge:change_status'),
|
||||
('dashboard_kommando', 'fahrzeuge:manage_maintenance'),
|
||||
('dashboard_kommando', 'fahrzeuge:delete'),
|
||||
('dashboard_kommando', 'fahrzeuge:widget'),
|
||||
-- Einsätze
|
||||
('dashboard_kommando', 'einsaetze:access'),
|
||||
('dashboard_kommando', 'einsaetze:view'),
|
||||
('dashboard_kommando', 'einsaetze:view_reports'),
|
||||
('dashboard_kommando', 'einsaetze:create'),
|
||||
('dashboard_kommando', 'einsaetze:delete'),
|
||||
('dashboard_kommando', 'einsaetze:manage_personnel'),
|
||||
-- Ausrüstung
|
||||
('dashboard_kommando', 'ausruestung:access'),
|
||||
('dashboard_kommando', 'ausruestung:view'),
|
||||
('dashboard_kommando', 'ausruestung:create'),
|
||||
('dashboard_kommando', 'ausruestung:manage_maintenance'),
|
||||
('dashboard_kommando', 'ausruestung:delete'),
|
||||
('dashboard_kommando', 'ausruestung:widget'),
|
||||
-- Mitglieder
|
||||
('dashboard_kommando', 'mitglieder:access'),
|
||||
('dashboard_kommando', 'mitglieder:view'),
|
||||
('dashboard_kommando', 'mitglieder:edit'),
|
||||
('dashboard_kommando', 'mitglieder:create_profile'),
|
||||
-- Atemschutz
|
||||
('dashboard_kommando', 'atemschutz:access'),
|
||||
('dashboard_kommando', 'atemschutz:view'),
|
||||
('dashboard_kommando', 'atemschutz:create'),
|
||||
('dashboard_kommando', 'atemschutz:delete'),
|
||||
('dashboard_kommando', 'atemschutz:widget'),
|
||||
-- Wissen
|
||||
('dashboard_kommando', 'wissen:access'),
|
||||
('dashboard_kommando', 'wissen:widget_recent'),
|
||||
('dashboard_kommando', 'wissen:widget_search'),
|
||||
-- Vikunja
|
||||
('dashboard_kommando', 'vikunja:access'),
|
||||
('dashboard_kommando', 'vikunja:create_tasks'),
|
||||
('dashboard_kommando', 'vikunja:widget_tasks'),
|
||||
('dashboard_kommando', 'vikunja:widget_quick_add'),
|
||||
-- Nextcloud
|
||||
('dashboard_kommando', 'nextcloud:access'),
|
||||
('dashboard_kommando', 'nextcloud:widget'),
|
||||
-- Dashboard
|
||||
('dashboard_kommando', 'dashboard:widget_links'),
|
||||
('dashboard_kommando', 'dashboard:widget_banner')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- dashboard_gruppenfuehrer — write-level for most features
|
||||
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||
-- Kalender
|
||||
('dashboard_gruppenfuehrer', 'kalender:access'),
|
||||
('dashboard_gruppenfuehrer', 'kalender:view_events'),
|
||||
('dashboard_gruppenfuehrer', 'kalender:view_bookings'),
|
||||
('dashboard_gruppenfuehrer', 'kalender:create_events'),
|
||||
('dashboard_gruppenfuehrer', 'kalender:create_training'),
|
||||
('dashboard_gruppenfuehrer', 'kalender:mark_attendance'),
|
||||
('dashboard_gruppenfuehrer', 'kalender:create_bookings'),
|
||||
('dashboard_gruppenfuehrer', 'kalender:edit_bookings'),
|
||||
('dashboard_gruppenfuehrer', 'kalender:manage_categories'),
|
||||
('dashboard_gruppenfuehrer', 'kalender:widget_events'),
|
||||
('dashboard_gruppenfuehrer', 'kalender:widget_bookings'),
|
||||
('dashboard_gruppenfuehrer', 'kalender:widget_quick_add'),
|
||||
-- Fahrzeuge
|
||||
('dashboard_gruppenfuehrer', 'fahrzeuge:access'),
|
||||
('dashboard_gruppenfuehrer', 'fahrzeuge:view'),
|
||||
('dashboard_gruppenfuehrer', 'fahrzeuge:change_status'),
|
||||
('dashboard_gruppenfuehrer', 'fahrzeuge:manage_maintenance'),
|
||||
('dashboard_gruppenfuehrer', 'fahrzeuge:widget'),
|
||||
-- Einsätze
|
||||
('dashboard_gruppenfuehrer', 'einsaetze:access'),
|
||||
('dashboard_gruppenfuehrer', 'einsaetze:view'),
|
||||
('dashboard_gruppenfuehrer', 'einsaetze:create'),
|
||||
('dashboard_gruppenfuehrer', 'einsaetze:manage_personnel'),
|
||||
-- Ausrüstung
|
||||
('dashboard_gruppenfuehrer', 'ausruestung:access'),
|
||||
('dashboard_gruppenfuehrer', 'ausruestung:view'),
|
||||
('dashboard_gruppenfuehrer', 'ausruestung:create'),
|
||||
('dashboard_gruppenfuehrer', 'ausruestung:manage_maintenance'),
|
||||
('dashboard_gruppenfuehrer', 'ausruestung:widget'),
|
||||
-- Mitglieder
|
||||
('dashboard_gruppenfuehrer', 'mitglieder:access'),
|
||||
('dashboard_gruppenfuehrer', 'mitglieder:view'),
|
||||
-- Atemschutz
|
||||
('dashboard_gruppenfuehrer', 'atemschutz:access'),
|
||||
('dashboard_gruppenfuehrer', 'atemschutz:view'),
|
||||
('dashboard_gruppenfuehrer', 'atemschutz:create'),
|
||||
('dashboard_gruppenfuehrer', 'atemschutz:widget'),
|
||||
-- Wissen
|
||||
('dashboard_gruppenfuehrer', 'wissen:access'),
|
||||
('dashboard_gruppenfuehrer', 'wissen:widget_recent'),
|
||||
('dashboard_gruppenfuehrer', 'wissen:widget_search'),
|
||||
-- Vikunja
|
||||
('dashboard_gruppenfuehrer', 'vikunja:access'),
|
||||
('dashboard_gruppenfuehrer', 'vikunja:create_tasks'),
|
||||
('dashboard_gruppenfuehrer', 'vikunja:widget_tasks'),
|
||||
('dashboard_gruppenfuehrer', 'vikunja:widget_quick_add'),
|
||||
-- Nextcloud
|
||||
('dashboard_gruppenfuehrer', 'nextcloud:access'),
|
||||
('dashboard_gruppenfuehrer', 'nextcloud:widget'),
|
||||
-- Dashboard
|
||||
('dashboard_gruppenfuehrer', 'dashboard:widget_links'),
|
||||
('dashboard_gruppenfuehrer', 'dashboard:widget_banner')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- dashboard_fahrmeister — vehicle specialist
|
||||
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||
-- Kalender
|
||||
('dashboard_fahrmeister', 'kalender:access'),
|
||||
('dashboard_fahrmeister', 'kalender:view_events'),
|
||||
('dashboard_fahrmeister', 'kalender:view_bookings'),
|
||||
('dashboard_fahrmeister', 'kalender:create_bookings'),
|
||||
('dashboard_fahrmeister', 'kalender:edit_bookings'),
|
||||
('dashboard_fahrmeister', 'kalender:widget_events'),
|
||||
('dashboard_fahrmeister', 'kalender:widget_bookings'),
|
||||
-- Fahrzeuge
|
||||
('dashboard_fahrmeister', 'fahrzeuge:access'),
|
||||
('dashboard_fahrmeister', 'fahrzeuge:view'),
|
||||
('dashboard_fahrmeister', 'fahrzeuge:change_status'),
|
||||
('dashboard_fahrmeister', 'fahrzeuge:manage_maintenance'),
|
||||
('dashboard_fahrmeister', 'fahrzeuge:widget'),
|
||||
-- Einsätze
|
||||
('dashboard_fahrmeister', 'einsaetze:access'),
|
||||
('dashboard_fahrmeister', 'einsaetze:view'),
|
||||
-- Ausrüstung
|
||||
('dashboard_fahrmeister', 'ausruestung:access'),
|
||||
('dashboard_fahrmeister', 'ausruestung:view'),
|
||||
('dashboard_fahrmeister', 'ausruestung:create'),
|
||||
('dashboard_fahrmeister', 'ausruestung:manage_maintenance'),
|
||||
('dashboard_fahrmeister', 'ausruestung:widget'),
|
||||
-- Mitglieder
|
||||
('dashboard_fahrmeister', 'mitglieder:access'),
|
||||
('dashboard_fahrmeister', 'mitglieder:view'),
|
||||
-- Atemschutz
|
||||
('dashboard_fahrmeister', 'atemschutz:access'),
|
||||
('dashboard_fahrmeister', 'atemschutz:widget'),
|
||||
-- Wissen
|
||||
('dashboard_fahrmeister', 'wissen:access'),
|
||||
('dashboard_fahrmeister', 'wissen:widget_recent'),
|
||||
('dashboard_fahrmeister', 'wissen:widget_search'),
|
||||
-- Vikunja
|
||||
('dashboard_fahrmeister', 'vikunja:access'),
|
||||
('dashboard_fahrmeister', 'vikunja:create_tasks'),
|
||||
('dashboard_fahrmeister', 'vikunja:widget_tasks'),
|
||||
('dashboard_fahrmeister', 'vikunja:widget_quick_add'),
|
||||
-- Nextcloud
|
||||
('dashboard_fahrmeister', 'nextcloud:access'),
|
||||
('dashboard_fahrmeister', 'nextcloud:widget'),
|
||||
-- Dashboard
|
||||
('dashboard_fahrmeister', 'dashboard:widget_links'),
|
||||
('dashboard_fahrmeister', 'dashboard:widget_banner')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- dashboard_zeugmeister — equipment specialist
|
||||
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||
-- Kalender
|
||||
('dashboard_zeugmeister', 'kalender:access'),
|
||||
('dashboard_zeugmeister', 'kalender:view_events'),
|
||||
('dashboard_zeugmeister', 'kalender:view_bookings'),
|
||||
('dashboard_zeugmeister', 'kalender:create_bookings'),
|
||||
('dashboard_zeugmeister', 'kalender:widget_events'),
|
||||
('dashboard_zeugmeister', 'kalender:widget_bookings'),
|
||||
-- Fahrzeuge
|
||||
('dashboard_zeugmeister', 'fahrzeuge:access'),
|
||||
('dashboard_zeugmeister', 'fahrzeuge:view'),
|
||||
('dashboard_zeugmeister', 'fahrzeuge:change_status'),
|
||||
('dashboard_zeugmeister', 'fahrzeuge:manage_maintenance'),
|
||||
('dashboard_zeugmeister', 'fahrzeuge:widget'),
|
||||
-- Einsätze
|
||||
('dashboard_zeugmeister', 'einsaetze:access'),
|
||||
('dashboard_zeugmeister', 'einsaetze:view'),
|
||||
-- Ausrüstung
|
||||
('dashboard_zeugmeister', 'ausruestung:access'),
|
||||
('dashboard_zeugmeister', 'ausruestung:view'),
|
||||
('dashboard_zeugmeister', 'ausruestung:create'),
|
||||
('dashboard_zeugmeister', 'ausruestung:manage_maintenance'),
|
||||
('dashboard_zeugmeister', 'ausruestung:widget'),
|
||||
-- Mitglieder
|
||||
('dashboard_zeugmeister', 'mitglieder:access'),
|
||||
('dashboard_zeugmeister', 'mitglieder:view'),
|
||||
-- Atemschutz
|
||||
('dashboard_zeugmeister', 'atemschutz:access'),
|
||||
('dashboard_zeugmeister', 'atemschutz:widget'),
|
||||
-- Wissen
|
||||
('dashboard_zeugmeister', 'wissen:access'),
|
||||
('dashboard_zeugmeister', 'wissen:widget_recent'),
|
||||
('dashboard_zeugmeister', 'wissen:widget_search'),
|
||||
-- Vikunja
|
||||
('dashboard_zeugmeister', 'vikunja:access'),
|
||||
('dashboard_zeugmeister', 'vikunja:create_tasks'),
|
||||
('dashboard_zeugmeister', 'vikunja:widget_tasks'),
|
||||
('dashboard_zeugmeister', 'vikunja:widget_quick_add'),
|
||||
-- Nextcloud
|
||||
('dashboard_zeugmeister', 'nextcloud:access'),
|
||||
('dashboard_zeugmeister', 'nextcloud:widget'),
|
||||
-- Dashboard
|
||||
('dashboard_zeugmeister', 'dashboard:widget_links'),
|
||||
('dashboard_zeugmeister', 'dashboard:widget_banner')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- dashboard_chargen — similar to gruppenfuehrer
|
||||
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||
-- Kalender
|
||||
('dashboard_chargen', 'kalender:access'),
|
||||
('dashboard_chargen', 'kalender:view_events'),
|
||||
('dashboard_chargen', 'kalender:view_bookings'),
|
||||
('dashboard_chargen', 'kalender:create_bookings'),
|
||||
('dashboard_chargen', 'kalender:widget_events'),
|
||||
('dashboard_chargen', 'kalender:widget_bookings'),
|
||||
-- Fahrzeuge
|
||||
('dashboard_chargen', 'fahrzeuge:access'),
|
||||
('dashboard_chargen', 'fahrzeuge:view'),
|
||||
('dashboard_chargen', 'fahrzeuge:change_status'),
|
||||
('dashboard_chargen', 'fahrzeuge:manage_maintenance'),
|
||||
('dashboard_chargen', 'fahrzeuge:widget'),
|
||||
-- Einsätze
|
||||
('dashboard_chargen', 'einsaetze:access'),
|
||||
('dashboard_chargen', 'einsaetze:view'),
|
||||
('dashboard_chargen', 'einsaetze:create'),
|
||||
('dashboard_chargen', 'einsaetze:manage_personnel'),
|
||||
-- Ausrüstung
|
||||
('dashboard_chargen', 'ausruestung:access'),
|
||||
('dashboard_chargen', 'ausruestung:view'),
|
||||
('dashboard_chargen', 'ausruestung:create'),
|
||||
('dashboard_chargen', 'ausruestung:manage_maintenance'),
|
||||
('dashboard_chargen', 'ausruestung:widget'),
|
||||
-- Mitglieder
|
||||
('dashboard_chargen', 'mitglieder:access'),
|
||||
('dashboard_chargen', 'mitglieder:view'),
|
||||
-- Atemschutz
|
||||
('dashboard_chargen', 'atemschutz:access'),
|
||||
('dashboard_chargen', 'atemschutz:view'),
|
||||
('dashboard_chargen', 'atemschutz:create'),
|
||||
('dashboard_chargen', 'atemschutz:widget'),
|
||||
-- Wissen
|
||||
('dashboard_chargen', 'wissen:access'),
|
||||
('dashboard_chargen', 'wissen:widget_recent'),
|
||||
('dashboard_chargen', 'wissen:widget_search'),
|
||||
-- Vikunja
|
||||
('dashboard_chargen', 'vikunja:access'),
|
||||
('dashboard_chargen', 'vikunja:create_tasks'),
|
||||
('dashboard_chargen', 'vikunja:widget_tasks'),
|
||||
('dashboard_chargen', 'vikunja:widget_quick_add'),
|
||||
-- Nextcloud
|
||||
('dashboard_chargen', 'nextcloud:access'),
|
||||
('dashboard_chargen', 'nextcloud:widget'),
|
||||
-- Dashboard
|
||||
('dashboard_chargen', 'dashboard:widget_links'),
|
||||
('dashboard_chargen', 'dashboard:widget_banner')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- dashboard_moderator — event/calendar management + atemschutz view
|
||||
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||
-- Kalender
|
||||
('dashboard_moderator', 'kalender:access'),
|
||||
('dashboard_moderator', 'kalender:view_events'),
|
||||
('dashboard_moderator', 'kalender:view_bookings'),
|
||||
('dashboard_moderator', 'kalender:create_events'),
|
||||
('dashboard_moderator', 'kalender:create_bookings'),
|
||||
('dashboard_moderator', 'kalender:edit_bookings'),
|
||||
('dashboard_moderator', 'kalender:manage_categories'),
|
||||
('dashboard_moderator', 'kalender:widget_events'),
|
||||
('dashboard_moderator', 'kalender:widget_bookings'),
|
||||
('dashboard_moderator', 'kalender:widget_quick_add'),
|
||||
-- Fahrzeuge
|
||||
('dashboard_moderator', 'fahrzeuge:access'),
|
||||
('dashboard_moderator', 'fahrzeuge:view'),
|
||||
('dashboard_moderator', 'fahrzeuge:widget'),
|
||||
-- Einsätze
|
||||
('dashboard_moderator', 'einsaetze:access'),
|
||||
('dashboard_moderator', 'einsaetze:view'),
|
||||
-- Ausrüstung
|
||||
('dashboard_moderator', 'ausruestung:access'),
|
||||
('dashboard_moderator', 'ausruestung:view'),
|
||||
('dashboard_moderator', 'ausruestung:widget'),
|
||||
-- Mitglieder
|
||||
('dashboard_moderator', 'mitglieder:access'),
|
||||
('dashboard_moderator', 'mitglieder:view'),
|
||||
-- Atemschutz
|
||||
('dashboard_moderator', 'atemschutz:access'),
|
||||
('dashboard_moderator', 'atemschutz:view'),
|
||||
('dashboard_moderator', 'atemschutz:widget'),
|
||||
-- Wissen
|
||||
('dashboard_moderator', 'wissen:access'),
|
||||
('dashboard_moderator', 'wissen:widget_recent'),
|
||||
('dashboard_moderator', 'wissen:widget_search'),
|
||||
-- Vikunja
|
||||
('dashboard_moderator', 'vikunja:access'),
|
||||
('dashboard_moderator', 'vikunja:create_tasks'),
|
||||
('dashboard_moderator', 'vikunja:widget_tasks'),
|
||||
('dashboard_moderator', 'vikunja:widget_quick_add'),
|
||||
-- Nextcloud
|
||||
('dashboard_moderator', 'nextcloud:access'),
|
||||
('dashboard_moderator', 'nextcloud:widget'),
|
||||
-- Dashboard
|
||||
('dashboard_moderator', 'dashboard:widget_links'),
|
||||
('dashboard_moderator', 'dashboard:widget_banner')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- dashboard_atemschutz — atemschutz specialist
|
||||
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||
-- Kalender (basic access)
|
||||
('dashboard_atemschutz', 'kalender:access'),
|
||||
('dashboard_atemschutz', 'kalender:view_events'),
|
||||
('dashboard_atemschutz', 'kalender:view_bookings'),
|
||||
('dashboard_atemschutz', 'kalender:create_bookings'),
|
||||
('dashboard_atemschutz', 'kalender:widget_events'),
|
||||
('dashboard_atemschutz', 'kalender:widget_bookings'),
|
||||
-- Fahrzeuge (read)
|
||||
('dashboard_atemschutz', 'fahrzeuge:access'),
|
||||
('dashboard_atemschutz', 'fahrzeuge:view'),
|
||||
('dashboard_atemschutz', 'fahrzeuge:widget'),
|
||||
-- Einsätze (read)
|
||||
('dashboard_atemschutz', 'einsaetze:access'),
|
||||
('dashboard_atemschutz', 'einsaetze:view'),
|
||||
-- Ausrüstung (read)
|
||||
('dashboard_atemschutz', 'ausruestung:access'),
|
||||
('dashboard_atemschutz', 'ausruestung:view'),
|
||||
('dashboard_atemschutz', 'ausruestung:widget'),
|
||||
-- Mitglieder (read)
|
||||
('dashboard_atemschutz', 'mitglieder:access'),
|
||||
('dashboard_atemschutz', 'mitglieder:view'),
|
||||
-- Atemschutz (full)
|
||||
('dashboard_atemschutz', 'atemschutz:access'),
|
||||
('dashboard_atemschutz', 'atemschutz:view'),
|
||||
('dashboard_atemschutz', 'atemschutz:create'),
|
||||
('dashboard_atemschutz', 'atemschutz:widget'),
|
||||
-- Wissen
|
||||
('dashboard_atemschutz', 'wissen:access'),
|
||||
('dashboard_atemschutz', 'wissen:widget_recent'),
|
||||
('dashboard_atemschutz', 'wissen:widget_search'),
|
||||
-- Vikunja
|
||||
('dashboard_atemschutz', 'vikunja:access'),
|
||||
('dashboard_atemschutz', 'vikunja:create_tasks'),
|
||||
('dashboard_atemschutz', 'vikunja:widget_tasks'),
|
||||
('dashboard_atemschutz', 'vikunja:widget_quick_add'),
|
||||
-- Nextcloud
|
||||
('dashboard_atemschutz', 'nextcloud:access'),
|
||||
('dashboard_atemschutz', 'nextcloud:widget'),
|
||||
-- Dashboard
|
||||
('dashboard_atemschutz', 'dashboard:widget_links'),
|
||||
('dashboard_atemschutz', 'dashboard:widget_banner')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- dashboard_mitglied — basic member access (read + basic booking creation)
|
||||
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||
-- Kalender
|
||||
('dashboard_mitglied', 'kalender:access'),
|
||||
('dashboard_mitglied', 'kalender:view_events'),
|
||||
('dashboard_mitglied', 'kalender:view_bookings'),
|
||||
('dashboard_mitglied', 'kalender:create_bookings'),
|
||||
('dashboard_mitglied', 'kalender:widget_events'),
|
||||
('dashboard_mitglied', 'kalender:widget_bookings'),
|
||||
-- Fahrzeuge
|
||||
('dashboard_mitglied', 'fahrzeuge:access'),
|
||||
('dashboard_mitglied', 'fahrzeuge:view'),
|
||||
('dashboard_mitglied', 'fahrzeuge:widget'),
|
||||
-- Einsätze
|
||||
('dashboard_mitglied', 'einsaetze:access'),
|
||||
('dashboard_mitglied', 'einsaetze:view'),
|
||||
-- Ausrüstung
|
||||
('dashboard_mitglied', 'ausruestung:access'),
|
||||
('dashboard_mitglied', 'ausruestung:view'),
|
||||
('dashboard_mitglied', 'ausruestung:widget'),
|
||||
-- Mitglieder
|
||||
('dashboard_mitglied', 'mitglieder:access'),
|
||||
('dashboard_mitglied', 'mitglieder:view'),
|
||||
-- Atemschutz
|
||||
('dashboard_mitglied', 'atemschutz:access'),
|
||||
('dashboard_mitglied', 'atemschutz:widget'),
|
||||
-- Wissen
|
||||
('dashboard_mitglied', 'wissen:access'),
|
||||
('dashboard_mitglied', 'wissen:widget_recent'),
|
||||
('dashboard_mitglied', 'wissen:widget_search'),
|
||||
-- Vikunja
|
||||
('dashboard_mitglied', 'vikunja:access'),
|
||||
('dashboard_mitglied', 'vikunja:create_tasks'),
|
||||
('dashboard_mitglied', 'vikunja:widget_tasks'),
|
||||
('dashboard_mitglied', 'vikunja:widget_quick_add'),
|
||||
-- Nextcloud
|
||||
('dashboard_mitglied', 'nextcloud:access'),
|
||||
('dashboard_mitglied', 'nextcloud:widget'),
|
||||
-- Dashboard
|
||||
('dashboard_mitglied', 'dashboard:widget_links'),
|
||||
('dashboard_mitglied', 'dashboard:widget_banner')
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -1,13 +1,11 @@
|
||||
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';
|
||||
import { permissionService } from '../services/permission.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.
|
||||
// AppRole — kept for backward compatibility (resolveRequestRole, bericht_text)
|
||||
// ---------------------------------------------------------------------------
|
||||
export type AppRole =
|
||||
| 'admin'
|
||||
@@ -16,105 +14,14 @@ export type AppRole =
|
||||
| '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',
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive an AppRole from Authentik JWT groups (highest matching role wins).
|
||||
*/
|
||||
function roleFromGroups(groups: string[]): AppRole {
|
||||
if (groups.includes('dashboard_admin')) return 'admin';
|
||||
if (groups.includes('dashboard_kommando')) return 'kommandant';
|
||||
if (groups.includes('dashboard_gruppenfuehrer') || groups.includes('dashboard_fahrmeister') || groups.includes('dashboard_zeugmeister') || groups.includes('dashboard_chargen')) return 'gruppenfuehrer';
|
||||
return 'mitglied';
|
||||
}
|
||||
|
||||
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).
|
||||
* permission. Permission is checked against the DB-driven permission system.
|
||||
*
|
||||
* Usage:
|
||||
* router.post('/api/incidents', authenticate, requirePermission('incidents:write'), handler)
|
||||
* Hardwired rules:
|
||||
* - `dashboard_admin` group always passes (full access).
|
||||
* - `admin:access` is checked via group membership (not DB).
|
||||
* - Maintenance mode blocks non-admin access per feature group.
|
||||
*/
|
||||
export function requirePermission(permission: string) {
|
||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
@@ -126,27 +33,65 @@ export function requirePermission(permission: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dbRole = (req.user as any).role
|
||||
? (req.user as any).role as AppRole
|
||||
: await getUserRole(req.user.id);
|
||||
const groupRole = roleFromGroups(req.user?.groups ?? []);
|
||||
const role = ROLE_HIERARCHY.indexOf(groupRole) > ROLE_HIERARCHY.indexOf(dbRole) ? groupRole : dbRole;
|
||||
const groups: string[] = req.user?.groups ?? [];
|
||||
|
||||
// Attach role to request for downstream use (e.g., bericht_text redaction)
|
||||
(req as Request & { userRole?: AppRole }).userRole = role;
|
||||
// Attach resolved role for downstream use (bericht_text redaction, etc.)
|
||||
(req as any).userRole = resolveRequestRole(req);
|
||||
|
||||
if (!hasPermission(role, permission)) {
|
||||
logger.warn('Permission denied', {
|
||||
// Hardwired: dashboard_admin always has full access
|
||||
if (groups.includes('dashboard_admin')) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Hardwired: admin:access only for dashboard_admin (already returned above)
|
||||
if (permission === 'admin:access') {
|
||||
logger.warn('Permission denied — admin:access', {
|
||||
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;
|
||||
}
|
||||
|
||||
// Check maintenance mode for the feature group
|
||||
const featureGroup = permission.split(':')[0];
|
||||
if (permissionService.isFeatureInMaintenance(featureGroup)) {
|
||||
logger.info('Feature in maintenance mode', {
|
||||
userId: req.user.id,
|
||||
featureGroup,
|
||||
permission,
|
||||
path: req.path,
|
||||
});
|
||||
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: 'Diese Funktion befindet sich im Wartungsmodus',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check DB-driven permission
|
||||
if (!permissionService.hasPermission(groups, permission)) {
|
||||
logger.warn('Permission denied', {
|
||||
userId: req.user.id,
|
||||
groups,
|
||||
permission,
|
||||
path: req.path,
|
||||
});
|
||||
|
||||
auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, {
|
||||
required_permission: permission,
|
||||
user_groups: groups,
|
||||
});
|
||||
|
||||
res.status(403).json({
|
||||
@@ -161,25 +106,42 @@ export function requirePermission(permission: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective AppRole for a request, combining DB role and group role.
|
||||
* Self-contained — does not depend on requirePermission() middleware having run.
|
||||
* Resolve the effective AppRole for a request.
|
||||
* Simplified: returns 'admin' for dashboard_admin, 'kommandant' for dashboard_kommando,
|
||||
* 'gruppenfuehrer' for specialist groups, else 'mitglied'.
|
||||
* Used for backward-compatible features like bericht_text redaction.
|
||||
*/
|
||||
export function resolveRequestRole(req: Request): AppRole {
|
||||
const dbRole = (req.user as any)?.role
|
||||
? ((req.user as any).role as AppRole)
|
||||
: 'mitglied';
|
||||
const groupRole = roleFromGroups(req.user?.groups ?? []);
|
||||
return ROLE_HIERARCHY.indexOf(groupRole) > ROLE_HIERARCHY.indexOf(dbRole) ? groupRole : dbRole;
|
||||
const groups: string[] = req.user?.groups ?? [];
|
||||
if (groups.includes('dashboard_admin')) return 'admin';
|
||||
if (groups.includes('dashboard_kommando')) return 'kommandant';
|
||||
if (
|
||||
groups.includes('dashboard_gruppenfuehrer') ||
|
||||
groups.includes('dashboard_fahrmeister') ||
|
||||
groups.includes('dashboard_zeugmeister') ||
|
||||
groups.includes('dashboard_chargen')
|
||||
) return 'gruppenfuehrer';
|
||||
return 'mitglied';
|
||||
}
|
||||
|
||||
export { getUserRole, hasPermission, roleFromGroups };
|
||||
// Legacy exports for backward compatibility
|
||||
export function getUserRole(_userId: string): Promise<AppRole> {
|
||||
return Promise.resolve('mitglied');
|
||||
}
|
||||
|
||||
export function roleFromGroups(groups: string[]): AppRole {
|
||||
if (groups.includes('dashboard_admin')) return 'admin';
|
||||
if (groups.includes('dashboard_kommando')) return 'kommandant';
|
||||
if (groups.includes('dashboard_gruppenfuehrer') || groups.includes('dashboard_fahrmeister') || groups.includes('dashboard_zeugmeister') || groups.includes('dashboard_chargen')) return 'gruppenfuehrer';
|
||||
return 'mitglied';
|
||||
}
|
||||
|
||||
export function hasPermission(role: AppRole, _permission: string): boolean {
|
||||
return role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* @deprecated Use requirePermission() instead.
|
||||
*/
|
||||
export function requireGroups(requiredGroups: string[]) {
|
||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
@@ -195,15 +157,15 @@ export function requireGroups(requiredGroups: string[]) {
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn('Group-based access denied', {
|
||||
userId: req.user.id,
|
||||
userId: req.user.id,
|
||||
userGroups,
|
||||
requiredGroups,
|
||||
path: req.path,
|
||||
path: req.path,
|
||||
});
|
||||
|
||||
auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, {
|
||||
required_groups: requiredGroups,
|
||||
user_groups: userGroups,
|
||||
user_groups: userGroups,
|
||||
});
|
||||
|
||||
res.status(403).json({
|
||||
|
||||
@@ -16,8 +16,8 @@ router.get('/:id', authenticate, atemschutzController.getOne.bind(atem
|
||||
|
||||
// ── Write — gruppenfuehrer+ ─────────────────────────────────────────────────
|
||||
|
||||
router.post('/', authenticate, requirePermission('atemschutz:write'), atemschutzController.create.bind(atemschutzController));
|
||||
router.patch('/:id', authenticate, requirePermission('atemschutz:write'), atemschutzController.update.bind(atemschutzController));
|
||||
router.post('/', authenticate, requirePermission('atemschutz:create'), atemschutzController.create.bind(atemschutzController));
|
||||
router.patch('/:id', authenticate, requirePermission('atemschutz:create'), atemschutzController.update.bind(atemschutzController));
|
||||
|
||||
// ── Delete — kommandant+ ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -19,14 +19,14 @@ router.get('/calendar-token', authenticate, bookingController.getCalendarToken.b
|
||||
// ── Write operations ──────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/', authenticate, bookingController.create.bind(bookingController));
|
||||
router.patch('/:id', authenticate, requirePermission('bookings:write'), bookingController.update.bind(bookingController));
|
||||
router.patch('/:id', authenticate, requirePermission('kalender:edit_bookings'), bookingController.update.bind(bookingController));
|
||||
|
||||
// Soft-cancel (sets abgesagt=TRUE) — creator or bookings:write
|
||||
router.delete('/:id', authenticate, bookingController.cancel.bind(bookingController));
|
||||
router.patch('/:id/cancel', authenticate, bookingController.cancel.bind(bookingController));
|
||||
|
||||
// Hard-delete (admin only)
|
||||
router.delete('/:id/force', authenticate, requirePermission('bookings:delete'), bookingController.hardDelete.bind(bookingController));
|
||||
router.delete('/:id/force', authenticate, requirePermission('kalender:delete_bookings'), bookingController.hardDelete.bind(bookingController));
|
||||
|
||||
// ── Single booking read — after specific routes to avoid path conflicts ───────
|
||||
|
||||
|
||||
@@ -17,13 +17,13 @@ router.get('/:id', authenticate, equipmentController.getEquipmen
|
||||
|
||||
// ── Write — gruppenfuehrer+ ────────────────────────────────────────────────
|
||||
|
||||
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));
|
||||
router.post('/', authenticate, requirePermission('ausruestung:create'), equipmentController.createEquipment.bind(equipmentController));
|
||||
router.patch('/:id', authenticate, requirePermission('ausruestung:create'), equipmentController.updateEquipment.bind(equipmentController));
|
||||
router.patch('/:id/status', authenticate, requirePermission('ausruestung:create'), equipmentController.updateStatus.bind(equipmentController));
|
||||
router.post('/:id/wartung', authenticate, requirePermission('ausruestung:create'), equipmentController.addWartung.bind(equipmentController));
|
||||
|
||||
// ── Delete — admin only ──────────────────────────────────────────────────────
|
||||
|
||||
router.delete('/:id', authenticate, requirePermission('equipment:delete'), equipmentController.deleteEquipment.bind(equipmentController));
|
||||
router.delete('/:id', authenticate, requirePermission('ausruestung:delete'), equipmentController.deleteEquipment.bind(equipmentController));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -22,7 +22,7 @@ router.get('/kategorien', authenticate, eventsController.listKategorien.bind(eve
|
||||
router.post(
|
||||
'/kategorien',
|
||||
authenticate,
|
||||
requirePermission('events:categories'),
|
||||
requirePermission('kalender:manage_categories'),
|
||||
eventsController.createKategorie.bind(eventsController)
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ router.post(
|
||||
router.patch(
|
||||
'/kategorien/:id',
|
||||
authenticate,
|
||||
requirePermission('events:categories'),
|
||||
requirePermission('kalender:manage_categories'),
|
||||
eventsController.updateKategorie.bind(eventsController)
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ router.patch(
|
||||
router.delete(
|
||||
'/kategorien/:id',
|
||||
authenticate,
|
||||
requirePermission('events:categories'),
|
||||
requirePermission('kalender:manage_categories'),
|
||||
eventsController.deleteKategorie.bind(eventsController)
|
||||
);
|
||||
|
||||
@@ -118,7 +118,7 @@ router.get(
|
||||
router.post(
|
||||
'/import',
|
||||
authenticate,
|
||||
requirePermission('events:write'),
|
||||
requirePermission('kalender:create_events'),
|
||||
eventsController.importEvents.bind(eventsController)
|
||||
);
|
||||
|
||||
@@ -129,7 +129,7 @@ router.post(
|
||||
router.post(
|
||||
'/',
|
||||
authenticate,
|
||||
requirePermission('events:write'),
|
||||
requirePermission('kalender:create_events'),
|
||||
eventsController.createEvent.bind(eventsController)
|
||||
);
|
||||
|
||||
@@ -146,7 +146,7 @@ router.get('/:id', authenticate, eventsController.getById.bind(eventsController)
|
||||
router.patch(
|
||||
'/:id',
|
||||
authenticate,
|
||||
requirePermission('events:write'),
|
||||
requirePermission('kalender:create_events'),
|
||||
eventsController.updateEvent.bind(eventsController)
|
||||
);
|
||||
|
||||
@@ -157,7 +157,7 @@ router.patch(
|
||||
router.delete(
|
||||
'/:id',
|
||||
authenticate,
|
||||
requirePermission('events:write'),
|
||||
requirePermission('kalender:create_events'),
|
||||
eventsController.cancelEvent.bind(eventsController)
|
||||
);
|
||||
|
||||
@@ -168,7 +168,7 @@ router.delete(
|
||||
router.post(
|
||||
'/:id/delete',
|
||||
authenticate,
|
||||
requirePermission('events:write'),
|
||||
requirePermission('kalender:create_events'),
|
||||
eventsController.deleteEvent.bind(eventsController)
|
||||
);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ router.use(authenticate);
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
requirePermission('incidents:read'),
|
||||
requirePermission('einsaetze:view'),
|
||||
incidentController.listIncidents.bind(incidentController)
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/stats',
|
||||
requirePermission('incidents:read'),
|
||||
requirePermission('einsaetze:view'),
|
||||
incidentController.getStats.bind(incidentController)
|
||||
);
|
||||
|
||||
@@ -42,7 +42,7 @@ router.get(
|
||||
*/
|
||||
router.post(
|
||||
'/refresh-stats',
|
||||
requirePermission('incidents:delete'), // kommandant+ (repurposing delete permission for admin ops)
|
||||
requirePermission('einsaetze:delete'),
|
||||
incidentController.refreshStats.bind(incidentController)
|
||||
);
|
||||
|
||||
@@ -53,7 +53,7 @@ router.post(
|
||||
*/
|
||||
router.get(
|
||||
'/:id',
|
||||
requirePermission('incidents:read'),
|
||||
requirePermission('einsaetze:view'),
|
||||
incidentController.getIncident.bind(incidentController)
|
||||
);
|
||||
|
||||
@@ -64,7 +64,7 @@ router.get(
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
requirePermission('incidents:write'),
|
||||
requirePermission('einsaetze:create'),
|
||||
incidentController.createIncident.bind(incidentController)
|
||||
);
|
||||
|
||||
@@ -75,7 +75,7 @@ router.post(
|
||||
*/
|
||||
router.patch(
|
||||
'/:id',
|
||||
requirePermission('incidents:write'),
|
||||
requirePermission('einsaetze:create'),
|
||||
incidentController.updateIncident.bind(incidentController)
|
||||
);
|
||||
|
||||
@@ -86,7 +86,7 @@ router.patch(
|
||||
*/
|
||||
router.delete(
|
||||
'/:id',
|
||||
requirePermission('incidents:delete'),
|
||||
requirePermission('einsaetze:delete'),
|
||||
incidentController.deleteIncident.bind(incidentController)
|
||||
);
|
||||
|
||||
@@ -98,7 +98,7 @@ router.delete(
|
||||
*/
|
||||
router.post(
|
||||
'/:id/personnel',
|
||||
requirePermission('incidents:manage_personnel'),
|
||||
requirePermission('einsaetze:manage_personnel'),
|
||||
incidentController.assignPersonnel.bind(incidentController)
|
||||
);
|
||||
|
||||
@@ -109,7 +109,7 @@ router.post(
|
||||
*/
|
||||
router.delete(
|
||||
'/:id/personnel/:userId',
|
||||
requirePermission('incidents:manage_personnel'),
|
||||
requirePermission('einsaetze:manage_personnel'),
|
||||
incidentController.removePersonnel.bind(incidentController)
|
||||
);
|
||||
|
||||
@@ -121,7 +121,7 @@ router.delete(
|
||||
*/
|
||||
router.post(
|
||||
'/:id/vehicles',
|
||||
requirePermission('incidents:manage_personnel'),
|
||||
requirePermission('einsaetze:manage_personnel'),
|
||||
incidentController.assignVehicle.bind(incidentController)
|
||||
);
|
||||
|
||||
@@ -132,7 +132,7 @@ router.post(
|
||||
*/
|
||||
router.delete(
|
||||
'/:id/vehicles/:fahrzeugId',
|
||||
requirePermission('incidents:manage_personnel'),
|
||||
requirePermission('einsaetze:manage_personnel'),
|
||||
incidentController.removeVehicle.bind(incidentController)
|
||||
);
|
||||
|
||||
|
||||
@@ -17,49 +17,49 @@ router.use(authenticate);
|
||||
// "stats" as a userId parameter.
|
||||
router.get(
|
||||
'/stats',
|
||||
requirePermission('members:read'),
|
||||
requirePermission('mitglieder:view'),
|
||||
memberController.getMemberStats.bind(memberController)
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
requirePermission('members:read'),
|
||||
requirePermission('mitglieder:view'),
|
||||
memberController.getMembers.bind(memberController)
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:userId',
|
||||
requirePermission('members:read'),
|
||||
requirePermission('mitglieder:view'),
|
||||
memberController.getMemberById.bind(memberController)
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:userId/profile',
|
||||
requirePermission('members:write'),
|
||||
requirePermission('mitglieder:edit'),
|
||||
memberController.createMemberProfile.bind(memberController)
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:userId/befoerderungen',
|
||||
requirePermission('members:read'),
|
||||
requirePermission('mitglieder:view'),
|
||||
memberController.getBefoerderungen.bind(memberController)
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:userId/untersuchungen',
|
||||
requirePermission('members:read'),
|
||||
requirePermission('mitglieder:view'),
|
||||
memberController.getUntersuchungen.bind(memberController)
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:userId/fahrgenehmigungen',
|
||||
requirePermission('members:read'),
|
||||
requirePermission('mitglieder:view'),
|
||||
memberController.getFahrgenehmigungen.bind(memberController)
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:userId/ausbildungen',
|
||||
requirePermission('members:read'),
|
||||
requirePermission('mitglieder:view'),
|
||||
memberController.getAusbildungen.bind(memberController)
|
||||
);
|
||||
|
||||
@@ -76,7 +76,7 @@ const requireOwnerOrWrite = (req: Request, res: Response, next: NextFunction): v
|
||||
return;
|
||||
}
|
||||
// Not the owner — must have members:write permission
|
||||
requirePermission('members:write')(req, res, next);
|
||||
requirePermission('mitglieder:edit')(req, res, next);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
17
backend/src/routes/permission.routes.ts
Normal file
17
backend/src/routes/permission.routes.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Router } from 'express';
|
||||
import permissionController from '../controllers/permission.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import { requirePermission } from '../middleware/rbac.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── User-facing (any authenticated user) ──────────────────────────────────
|
||||
router.get('/me', authenticate, permissionController.getMyPermissions.bind(permissionController));
|
||||
|
||||
// ── Admin-only routes ─────────────────────────────────────────────────────
|
||||
router.get('/admin/matrix', authenticate, requirePermission('admin:access'), permissionController.getMatrix.bind(permissionController));
|
||||
router.get('/admin/groups', authenticate, requirePermission('admin:access'), permissionController.getGroups.bind(permissionController));
|
||||
router.put('/admin/group/:groupName', authenticate, requirePermission('admin:access'), permissionController.setGroupPermissions.bind(permissionController));
|
||||
router.put('/admin/maintenance/:featureGroupId', authenticate, requirePermission('admin:access'), permissionController.setMaintenanceFlag.bind(permissionController));
|
||||
|
||||
export default router;
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import trainingController from '../controllers/training.controller';
|
||||
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
|
||||
import { requirePermission, getUserRole } from '../middleware/rbac.middleware';
|
||||
import { requirePermission } from '../middleware/rbac.middleware';
|
||||
import { permissionService } from '../services/permission.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// injectTeilnahmenFlag
|
||||
//
|
||||
// Sets req.canSeeTeilnahmen = true for Gruppenführer and above.
|
||||
// Sets req.canSeeTeilnahmen = true for users with kalender:mark_attendance.
|
||||
// Regular Mitglieder see only attendance counts; officers see the full list.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -19,12 +20,10 @@ async function injectTeilnahmenFlag(
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (req.user) {
|
||||
const role = await getUserRole(req.user.id);
|
||||
const ROLE_ORDER: Record<string, number> = {
|
||||
bewerber: -1, mitglied: 0, gruppenfuehrer: 1, kommandant: 2, admin: 3,
|
||||
};
|
||||
const groups: string[] = req.user?.groups ?? [];
|
||||
(req as any).canSeeTeilnahmen =
|
||||
(ROLE_ORDER[role] ?? 0) >= ROLE_ORDER.gruppenfuehrer;
|
||||
groups.includes('dashboard_admin') ||
|
||||
permissionService.hasPermission(groups, 'kalender:mark_attendance');
|
||||
}
|
||||
} catch (_err) {
|
||||
// Non-fatal — default to restricted view
|
||||
@@ -68,12 +67,12 @@ router.get('/calendar-token', authenticate, trainingController.getCalendarToken)
|
||||
/**
|
||||
* GET /api/training/stats?year=<YYYY>
|
||||
* Annual participation stats.
|
||||
* Requires Kommandant or above (requirePermission('reports:read')).
|
||||
* Requires Kommandant or above (requirePermission('kalender:view_reports')).
|
||||
*/
|
||||
router.get(
|
||||
'/stats',
|
||||
authenticate,
|
||||
requirePermission('reports:read'),
|
||||
requirePermission('kalender:view_reports'),
|
||||
trainingController.getStats
|
||||
);
|
||||
|
||||
@@ -92,12 +91,12 @@ router.get(
|
||||
/**
|
||||
* POST /api/training
|
||||
* Create a new training event.
|
||||
* Requires Gruppenführer or above (requirePermission('training:write')).
|
||||
* Requires Gruppenführer or above (requirePermission('kalender:create_training')).
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
authenticate,
|
||||
requirePermission('training:write'),
|
||||
requirePermission('kalender:create_training'),
|
||||
trainingController.createEvent
|
||||
);
|
||||
|
||||
@@ -109,7 +108,7 @@ router.post(
|
||||
router.patch(
|
||||
'/:id',
|
||||
authenticate,
|
||||
requirePermission('training:write'),
|
||||
requirePermission('kalender:create_training'),
|
||||
trainingController.updateEvent
|
||||
);
|
||||
|
||||
@@ -121,7 +120,7 @@ router.patch(
|
||||
router.delete(
|
||||
'/:id',
|
||||
authenticate,
|
||||
requirePermission('training:cancel'),
|
||||
requirePermission('kalender:cancel_training'),
|
||||
trainingController.cancelEvent
|
||||
);
|
||||
|
||||
@@ -142,7 +141,7 @@ router.patch(
|
||||
router.post(
|
||||
'/:id/attendance/mark',
|
||||
authenticate,
|
||||
requirePermission('training:mark_attendance'),
|
||||
requirePermission('kalender:mark_attendance'),
|
||||
trainingController.markAttendance
|
||||
);
|
||||
|
||||
|
||||
@@ -10,19 +10,19 @@ const router = Router();
|
||||
router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController));
|
||||
router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController));
|
||||
router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleController));
|
||||
router.get('/alerts/export', authenticate, requirePermission('vehicles:read'), vehicleController.exportAlerts.bind(vehicleController));
|
||||
router.get('/alerts/export', authenticate, requirePermission('fahrzeuge:view'), vehicleController.exportAlerts.bind(vehicleController));
|
||||
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
|
||||
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
|
||||
|
||||
// ── Write — kommandant+ ──────────────────────────────────────────────────────
|
||||
|
||||
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));
|
||||
router.post('/', authenticate, requirePermission('fahrzeuge:create'), vehicleController.createVehicle.bind(vehicleController));
|
||||
router.patch('/:id', authenticate, requirePermission('fahrzeuge:create'), vehicleController.updateVehicle.bind(vehicleController));
|
||||
router.delete('/:id', authenticate, requirePermission('fahrzeuge:delete'), vehicleController.deleteVehicle.bind(vehicleController));
|
||||
|
||||
// ── Status + maintenance log — gruppenfuehrer+ ──────────────────────────────
|
||||
|
||||
router.patch('/:id/status', authenticate, requirePermission('vehicles:status'), vehicleController.updateVehicleStatus.bind(vehicleController));
|
||||
router.post('/:id/wartung', authenticate, requirePermission('vehicles:status'), vehicleController.addWartung.bind(vehicleController));
|
||||
router.patch('/:id/status', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.updateVehicleStatus.bind(vehicleController));
|
||||
router.post('/:id/wartung', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.addWartung.bind(vehicleController));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -4,6 +4,7 @@ import logger from './utils/logger';
|
||||
import { testConnection, closePool, runMigrations } from './config/database';
|
||||
import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job';
|
||||
import { startNotificationJob, stopNotificationJob } from './jobs/notification-generation.job';
|
||||
import { permissionService } from './services/permission.service';
|
||||
|
||||
const startServer = async (): Promise<void> => {
|
||||
try {
|
||||
@@ -16,6 +17,9 @@ const startServer = async (): Promise<void> => {
|
||||
} else {
|
||||
// Run pending migrations automatically on startup
|
||||
await runMigrations();
|
||||
|
||||
// Load permission cache after migrations
|
||||
await permissionService.loadCache();
|
||||
}
|
||||
|
||||
// Start the GDPR IP anonymisation job
|
||||
|
||||
172
backend/src/services/permission.service.ts
Normal file
172
backend/src/services/permission.service.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import pool from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
export interface FeatureGroupRow {
|
||||
id: string;
|
||||
label: string;
|
||||
sort_order: number;
|
||||
maintenance: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionRow {
|
||||
id: string;
|
||||
feature_group_id: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface MatrixData {
|
||||
featureGroups: FeatureGroupRow[];
|
||||
permissions: PermissionRow[];
|
||||
groups: string[];
|
||||
grants: Record<string, string[]>;
|
||||
maintenance: Record<string, boolean>;
|
||||
}
|
||||
|
||||
class PermissionService {
|
||||
private groupPermissions: Map<string, Set<string>> = new Map();
|
||||
private maintenanceFlags: Map<string, boolean> = new Map();
|
||||
|
||||
async loadCache(): Promise<void> {
|
||||
try {
|
||||
// Load group permissions
|
||||
const gpResult = await pool.query('SELECT authentik_group, permission_id FROM group_permissions');
|
||||
const newMap = new Map<string, Set<string>>();
|
||||
for (const row of gpResult.rows) {
|
||||
if (!newMap.has(row.authentik_group)) {
|
||||
newMap.set(row.authentik_group, new Set());
|
||||
}
|
||||
newMap.get(row.authentik_group)!.add(row.permission_id);
|
||||
}
|
||||
this.groupPermissions = newMap;
|
||||
|
||||
// Load maintenance flags
|
||||
const mResult = await pool.query('SELECT id, maintenance FROM feature_groups');
|
||||
const newFlags = new Map<string, boolean>();
|
||||
for (const row of mResult.rows) {
|
||||
newFlags.set(row.id, row.maintenance);
|
||||
}
|
||||
this.maintenanceFlags = newFlags;
|
||||
|
||||
logger.info('Permission cache loaded', {
|
||||
groups: this.groupPermissions.size,
|
||||
featureGroups: this.maintenanceFlags.size,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to load permission cache', { error });
|
||||
// Don't throw — service can still function with empty cache
|
||||
// dashboard_admin bypass ensures admins always have access
|
||||
}
|
||||
}
|
||||
|
||||
getEffectivePermissions(groups: string[]): string[] {
|
||||
const permSet = new Set<string>();
|
||||
for (const group of groups) {
|
||||
const perms = this.groupPermissions.get(group);
|
||||
if (perms) {
|
||||
for (const p of perms) {
|
||||
permSet.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(permSet);
|
||||
}
|
||||
|
||||
hasPermission(groups: string[], permission: string): boolean {
|
||||
for (const group of groups) {
|
||||
const perms = this.groupPermissions.get(group);
|
||||
if (perms?.has(permission)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isFeatureInMaintenance(featureGroup: string): boolean {
|
||||
return this.maintenanceFlags.get(featureGroup) ?? false;
|
||||
}
|
||||
|
||||
getMaintenanceFlags(): Record<string, boolean> {
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const [k, v] of this.maintenanceFlags) {
|
||||
result[k] = v;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Admin methods ──
|
||||
|
||||
async getMatrix(): Promise<MatrixData> {
|
||||
const [fgResult, pResult, gpResult] = await Promise.all([
|
||||
pool.query('SELECT id, label, sort_order, maintenance FROM feature_groups ORDER BY sort_order'),
|
||||
pool.query('SELECT id, feature_group_id, label, description, sort_order FROM permissions ORDER BY feature_group_id, sort_order'),
|
||||
pool.query('SELECT authentik_group, permission_id FROM group_permissions'),
|
||||
]);
|
||||
|
||||
const grants: Record<string, string[]> = {};
|
||||
const groupSet = new Set<string>();
|
||||
for (const row of gpResult.rows) {
|
||||
groupSet.add(row.authentik_group);
|
||||
if (!grants[row.authentik_group]) grants[row.authentik_group] = [];
|
||||
grants[row.authentik_group].push(row.permission_id);
|
||||
}
|
||||
|
||||
const maintenance: Record<string, boolean> = {};
|
||||
for (const row of fgResult.rows) {
|
||||
maintenance[row.id] = row.maintenance;
|
||||
}
|
||||
|
||||
return {
|
||||
featureGroups: fgResult.rows,
|
||||
permissions: pResult.rows,
|
||||
groups: Array.from(groupSet).sort(),
|
||||
grants,
|
||||
maintenance,
|
||||
};
|
||||
}
|
||||
|
||||
async getKnownGroups(): Promise<string[]> {
|
||||
const result = await pool.query(
|
||||
'SELECT DISTINCT authentik_group FROM group_permissions ORDER BY authentik_group'
|
||||
);
|
||||
return result.rows.map((r: any) => r.authentik_group);
|
||||
}
|
||||
|
||||
async setGroupPermissions(group: string, permIds: string[], grantedBy: string): Promise<void> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
// Remove all existing permissions for this group
|
||||
await client.query('DELETE FROM group_permissions WHERE authentik_group = $1', [group]);
|
||||
// Insert new permissions
|
||||
for (const permId of permIds) {
|
||||
await client.query(
|
||||
'INSERT INTO group_permissions (authentik_group, permission_id, granted_by) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING',
|
||||
[group, permId, grantedBy]
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Reload cache
|
||||
await this.loadCache();
|
||||
|
||||
logger.info('Group permissions updated', { group, permissionCount: permIds.length, grantedBy });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async setMaintenanceFlag(featureGroup: string, active: boolean): Promise<void> {
|
||||
await pool.query(
|
||||
'UPDATE feature_groups SET maintenance = $1 WHERE id = $2',
|
||||
[active, featureGroup]
|
||||
);
|
||||
// Reload cache
|
||||
await this.loadCache();
|
||||
logger.info('Maintenance flag updated', { featureGroup, active });
|
||||
}
|
||||
}
|
||||
|
||||
export const permissionService = new PermissionService();
|
||||
Reference in New Issue
Block a user