add features
This commit is contained in:
235
backend/src/middleware/audit.middleware.ts
Normal file
235
backend/src/middleware/audit.middleware.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Audit Middleware
|
||||
*
|
||||
* Factory function that returns an Express middleware for a given
|
||||
* (resourceType, action) pair. Designed to sit after `authenticate`
|
||||
* in the middleware chain.
|
||||
*
|
||||
* Usage examples:
|
||||
*
|
||||
* // Simple: no old_value needed
|
||||
* router.post('/', authenticate, auditMiddleware(AuditResourceType.MEMBER, AuditAction.CREATE), createMember);
|
||||
*
|
||||
* // UPDATE: controller must populate res.locals.auditOldValue before responding
|
||||
* router.put('/:id', authenticate, auditMiddleware(AuditResourceType.MEMBER, AuditAction.UPDATE), updateMember);
|
||||
*
|
||||
* // DELETE: controller must populate res.locals.auditOldValue (the entity before deletion)
|
||||
* router.delete('/:id', authenticate, auditMiddleware(AuditResourceType.MEMBER, AuditAction.DELETE), deleteMember);
|
||||
*
|
||||
* Pattern for capturing old_value in controllers:
|
||||
*
|
||||
* async function updateMember(req: Request, res: Response): Promise<void> {
|
||||
* const existing = await memberService.findById(req.params.id);
|
||||
* res.locals.auditOldValue = existing; // <-- set BEFORE modifying
|
||||
*
|
||||
* const updated = await memberService.update(req.params.id, req.body);
|
||||
* res.locals.auditResourceId = req.params.id;
|
||||
*
|
||||
* res.status(200).json({ success: true, data: updated });
|
||||
* // The middleware intercepts res.json() and logs AFTER the response is sent.
|
||||
* }
|
||||
*
|
||||
* For PERMISSION_DENIED events, use auditPermissionDenied() directly in the
|
||||
* requirePermission middleware — see auth.middleware.ts.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IP extraction helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extracts the real client IP address, respecting X-Forwarded-For when set
|
||||
* by a trusted reverse proxy (Nginx / Traefik in the Docker stack).
|
||||
*
|
||||
* Trust only the leftmost IP in X-Forwarded-For to avoid spoofing.
|
||||
* If you run without a reverse proxy, req.ip is sufficient.
|
||||
*/
|
||||
export function extractIp(req: Request): string | null {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
if (forwarded) {
|
||||
const ips = (typeof forwarded === 'string' ? forwarded : forwarded[0]).split(',');
|
||||
const ip = ips[0].trim();
|
||||
return ip || req.ip || null;
|
||||
}
|
||||
return req.ip || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts User-Agent, truncated to 500 chars to avoid bloating the DB.
|
||||
*/
|
||||
export function extractUserAgent(req: Request): string | null {
|
||||
const ua = req.headers['user-agent'];
|
||||
if (!ua) return null;
|
||||
return ua.substring(0, 500);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// res.locals contract (populated by controllers before res.json())
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extend Express locals so TypeScript knows what the audit middleware reads.
|
||||
*
|
||||
* Controllers set these before calling res.json() / res.status().json():
|
||||
* res.locals.auditOldValue — entity state before the operation (UPDATE, DELETE)
|
||||
* res.locals.auditNewValue — override for new value (optional; defaults to response body)
|
||||
* res.locals.auditResourceId — override for resource ID (optional; defaults to req.params.id)
|
||||
* res.locals.auditMetadata — any extra context object
|
||||
* res.locals.auditSkip — set to true to suppress logging for this request
|
||||
*/
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Locals {
|
||||
auditOldValue?: Record<string, unknown> | null;
|
||||
auditNewValue?: Record<string, unknown> | null;
|
||||
auditResourceId?: string;
|
||||
auditMetadata?: Record<string, unknown>;
|
||||
auditSkip?: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Middleware factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* auditMiddleware — returns Express middleware that fires an audit log entry
|
||||
* after the response has been sent (fire-and-forget; never delays the response).
|
||||
*
|
||||
* @param resourceType The type of entity being operated on.
|
||||
* @param action The action being performed.
|
||||
*/
|
||||
export function auditMiddleware(
|
||||
resourceType: AuditResourceType,
|
||||
action: AuditAction
|
||||
) {
|
||||
return function auditHandler(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
// Intercept res.json() so we can capture the response body after it is
|
||||
// set, without delaying the response to the client.
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
res.json = function auditInterceptedJson(body: unknown): Response {
|
||||
// Call the original json() — this sends the response immediately.
|
||||
const result = originalJson(body);
|
||||
|
||||
// Only log on successful responses (2xx). Failed requests are not
|
||||
// meaningful audit events for data mutations (the data did not change).
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && !res.locals.auditSkip) {
|
||||
const userId = req.user?.id ?? null;
|
||||
const userEmail = req.user?.email ?? null;
|
||||
const ipAddress = extractIp(req);
|
||||
const userAgent = extractUserAgent(req);
|
||||
|
||||
// Resource ID: prefer controller override, then route param, then body id.
|
||||
const resourceId =
|
||||
res.locals.auditResourceId ??
|
||||
req.params.id ??
|
||||
(body !== null && typeof body === 'object'
|
||||
? (body as Record<string, unknown>)?.data?.id as string | undefined
|
||||
: undefined) ??
|
||||
null;
|
||||
|
||||
// old_value: always from res.locals (controller must set it for UPDATE/DELETE).
|
||||
const oldValue = res.locals.auditOldValue ?? null;
|
||||
|
||||
// new_value: prefer controller override, else use the response body data.
|
||||
// We extract the `data` property if it exists (matches our { success, data } envelope).
|
||||
let newValue: Record<string, unknown> | null = res.locals.auditNewValue ?? null;
|
||||
if (newValue === null && action !== AuditAction.DELETE) {
|
||||
const bodyObj = body as Record<string, unknown> | null;
|
||||
newValue =
|
||||
(bodyObj?.data as Record<string, unknown> | undefined) ??
|
||||
(typeof bodyObj === 'object' && bodyObj !== null ? bodyObj : null);
|
||||
}
|
||||
|
||||
// Fire-and-forget — never await in a middleware.
|
||||
auditService.logAudit({
|
||||
user_id: userId,
|
||||
user_email: userEmail,
|
||||
action,
|
||||
resource_type: resourceType,
|
||||
resource_id: resourceId ? String(resourceId) : null,
|
||||
old_value: oldValue,
|
||||
new_value: newValue,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
metadata: res.locals.auditMetadata ?? {},
|
||||
}).catch((err) => {
|
||||
// Belt-and-suspenders: logAudit() never rejects, but just in case.
|
||||
logger.error('auditMiddleware: unexpected logAudit rejection', { err });
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone helpers for use outside of route middleware chains
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* auditPermissionDenied
|
||||
*
|
||||
* Call this inside requirePermission() when access is denied.
|
||||
* Does NOT depend on res.json interception — fires directly.
|
||||
*/
|
||||
export function auditPermissionDenied(
|
||||
req: Request,
|
||||
resourceType: AuditResourceType,
|
||||
resourceId?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): void {
|
||||
auditService.logAudit({
|
||||
user_id: req.user?.id ?? null,
|
||||
user_email: req.user?.email ?? null,
|
||||
action: AuditAction.PERMISSION_DENIED,
|
||||
resource_type: resourceType,
|
||||
resource_id: resourceId ?? null,
|
||||
old_value: null,
|
||||
new_value: null,
|
||||
ip_address: extractIp(req),
|
||||
user_agent: extractUserAgent(req),
|
||||
metadata: {
|
||||
attempted_path: req.path,
|
||||
attempted_method: req.method,
|
||||
...metadata,
|
||||
},
|
||||
}).catch(() => {/* swallowed — audit must not affect the 403 response */});
|
||||
}
|
||||
|
||||
/**
|
||||
* auditExport
|
||||
*
|
||||
* Call this immediately before streaming an export response.
|
||||
*/
|
||||
export function auditExport(
|
||||
req: Request,
|
||||
resourceType: AuditResourceType,
|
||||
metadata: Record<string, unknown> = {}
|
||||
): void {
|
||||
auditService.logAudit({
|
||||
user_id: req.user?.id ?? null,
|
||||
user_email: req.user?.email ?? null,
|
||||
action: AuditAction.EXPORT,
|
||||
resource_type: resourceType,
|
||||
resource_id: null,
|
||||
old_value: null,
|
||||
new_value: null,
|
||||
ip_address: extractIp(req),
|
||||
user_agent: extractUserAgent(req),
|
||||
metadata,
|
||||
}).catch(() => {});
|
||||
}
|
||||
136
backend/src/middleware/rbac.middleware.ts
Normal file
136
backend/src/middleware/rbac.middleware.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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',
|
||||
};
|
||||
|
||||
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 = 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)) {
|
||||
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: ${permission}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export { getUserRole, hasPermission };
|
||||
Reference in New Issue
Block a user