205 lines
6.4 KiB
TypeScript
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();
|
|
};
|
|
}
|