rights system
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user