Files
opencrm/backend/src/controllers/contractTask.controller.ts
T
duffyduck ef238b0145 Security-Hardening Runde 13: Live-Vollmacht-Konsistenz + embedded DTOs
Pentest Runde 10:

MEDIUM – Stale Token nach Vollmacht-Widerruf:
Selbst ein frischer Portal-Login lieferte JWT mit representedCustomer-
Ids/representedCustomers, obwohl die Vollmacht widerrufen war. Live-
Check beim Datenzugriff fing das ab (403), aber die UI zeigte weiter
„kann vertreten". customerLogin und getCustomerPortalUser (= /me +
Refresh) filtern representingFor jetzt zusätzlich über
getAuthorizedCustomerIds() – nur Beziehungen mit isGranted=true
landen im Token.

MEDIUM – DTO-Leak in embedded Objekten:
GET /customers/:id lieferte contracts[] mit commission/notes/
portalPasswordEncrypted/nextReviewDate; embedded customer in
/contracts/:id zeigte notes. sanitizeCustomer(Strict) ruft jetzt
sanitizeContract(Strict) auf jedes Element von contracts[] auf;
`notes` ist als PORTAL_HIDDEN_CUSTOMER_FIELDS aufgenommen.

LOW – /tasks?customerId=X gibt 200 mit leerem Array statt 403:
Konsistenz-Fix: wenn Portal-User explizit nach customerId filtert,
die er nicht vertreten darf → 403.

Live-verifiziert:
- Customer 1 vertritt 2+3 (Vollmachten widerrufen) → JWT
  representedCustomerIds=[], /me dito
- Portal /customers/1.contracts[0]: keine Leaks; Admin sieht weiter
  commission/notes; portalPasswordEncrypted generell weg
- Portal /tasks?customerId=2 → 403; /tasks?customerId=1 → 200

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:47:20 +02:00

489 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Response } from 'express';
import * as contractTaskService from '../services/contractTask.service.js';
import * as contractService from '../services/contract.service.js';
import * as customerService from '../services/customer.service.js';
import * as appSettingService from '../services/appSetting.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessContract, getPortalAllowedCustomerIds } from '../utils/accessControl.js';
// ==================== ALL TASKS (Dashboard & Task List) ====================
export async function getAllTasks(req: AuthRequest, res: Response): Promise<void> {
try {
const { status, customerId } = req.query;
const customerIdNum = customerId ? parseInt(customerId as string) : undefined;
// Für Kundenportal: Filter auf erlaubte Kunden (mit Live-Vollmacht-Check)
const allowedIds = await getPortalAllowedCustomerIds(req);
let customerPortalCustomerIds: number[] | undefined;
let customerPortalEmails: string[] | undefined;
if (allowedIds) {
// Wenn der Portal-User explizit nach einer customerId filtert, die er
// nicht (mehr) vertreten darf → 403 statt 200 mit leerem Array
// (Pentest Runde 10 LOW: konsistentes Response-Verhalten nach
// Vollmacht-Widerruf).
if (customerIdNum !== undefined && !allowedIds.includes(customerIdNum)) {
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' } as ApiResponse);
return;
}
customerPortalCustomerIds = allowedIds;
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
customerPortalEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
.filter((email: string | null): email is string => !!email);
}
const tasks = await contractTaskService.getAllTasks({
status: status as 'OPEN' | 'COMPLETED' | undefined,
customerId: customerIdNum,
customerPortalCustomerIds,
customerPortalEmails,
});
res.json({ success: true, data: tasks } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Aufgaben',
} as ApiResponse);
}
}
export async function getTaskStats(req: AuthRequest, res: Response): Promise<void> {
try {
// Für Kundenportal: Filter auf erlaubte Kunden (mit Live-Vollmacht-Check)
const allowedIds = await getPortalAllowedCustomerIds(req);
let customerPortalCustomerIds: number[] | undefined;
let customerPortalEmails: string[] | undefined;
if (allowedIds) {
customerPortalCustomerIds = allowedIds;
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
customerPortalEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
.filter((email: string | null): email is string => !!email);
}
const stats = await contractTaskService.getTaskStats({
customerPortalCustomerIds,
customerPortalEmails,
});
res.json({ success: true, data: stats } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Statistik',
} as ApiResponse);
}
}
// ==================== TASKS BY CONTRACT ====================
export async function getTasks(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
const { status } = req.query;
// Zentraler canAccessContract-Check inkl. Live-Vollmacht-Prüfung über
// hasAuthorization (Pentest Runde 6 HOCH-04: widerrufene Vollmachten
// hatten vorher weiter Zugriff, weil nur representedCustomerIds-Array
// konsultiert wurde, ohne Status-Check).
if (!(await canAccessContract(req, res, contractId))) return;
// Für Kundenportal-Benutzer: Lade E-Mails der erlaubten Kunden (mit Live-Vollmacht-Check)
let customerPortalEmails: string[] | undefined;
const allowedIds = await getPortalAllowedCustomerIds(req);
if (allowedIds) {
const customers = await customerService.getCustomersByIds(allowedIds);
customerPortalEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
.filter((email: string | null): email is string => !!email);
}
const tasks = await contractTaskService.getTasksByContract({
contractId,
status: status as 'OPEN' | 'COMPLETED' | undefined,
customerPortalEmails,
});
res.json({ success: true, data: tasks } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Aufgaben',
} as ApiResponse);
}
}
export async function createTask(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
const { title, description, visibleInPortal } = req.body;
if (!title) {
res.status(400).json({
success: false,
error: 'Titel ist erforderlich',
} as ApiResponse);
return;
}
const createdBy = req.user?.email;
// Für Kundenportal-Benutzer: visibleInPortal wird automatisch auf true gesetzt
const finalVisibleInPortal = req.user?.isCustomerPortal ? true : visibleInPortal;
const task = await contractTaskService.createTask({
contractId,
title,
description,
visibleInPortal: finalVisibleInPortal,
createdBy,
});
await logChange({
req, action: 'CREATE', resourceType: 'ContractTask',
resourceId: task.id.toString(),
label: `Aufgabe "${title}" erstellt`,
});
res.status(201).json({ success: true, data: task } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Aufgabe',
} as ApiResponse);
}
}
// Für Kundenportal-Benutzer: Support-Anfrage erstellen (ohne contracts:update Permission)
export async function createSupportTicket(req: AuthRequest, res: Response): Promise<void> {
try {
// Prüfe ob Support-Tickets aktiviert sind
const supportEnabled = await appSettingService.getSettingBool('customerSupportTicketsEnabled');
if (!supportEnabled) {
res.status(403).json({
success: false,
error: 'Support-Anfragen sind nicht aktiviert',
} as ApiResponse);
return;
}
const contractId = parseInt(req.params.contractId);
const { title, description } = req.body;
if (!title) {
res.status(400).json({
success: false,
error: 'Titel ist erforderlich',
} as ApiResponse);
return;
}
// canAccessContract inkl. Live-Vollmacht-Prüfung (siehe getTasks).
if (!(await canAccessContract(req, res, contractId))) return;
const createdBy = req.user?.email;
const task = await contractTaskService.createTask({
contractId,
title,
description,
visibleInPortal: true, // Immer sichtbar im Portal
createdBy,
});
await logChange({
req, action: 'CREATE', resourceType: 'ContractTask',
resourceId: task.id.toString(),
label: `Support-Anfrage "${title}" erstellt`,
});
res.status(201).json({ success: true, data: task } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Support-Anfrage',
} as ApiResponse);
}
}
export async function updateTask(req: AuthRequest, res: Response): Promise<void> {
try {
const taskId = parseInt(req.params.taskId);
const { title, description, visibleInPortal } = req.body;
const task = await contractTaskService.updateTask(taskId, {
title,
description,
visibleInPortal,
});
await logChange({
req, action: 'UPDATE', resourceType: 'ContractTask',
resourceId: taskId.toString(),
label: `Aufgabe aktualisiert`,
});
res.json({ success: true, data: task } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Aufgabe',
} as ApiResponse);
}
}
export async function completeTask(req: AuthRequest, res: Response): Promise<void> {
try {
const taskId = parseInt(req.params.taskId);
const task = await contractTaskService.completeTask(taskId);
await logChange({
req, action: 'UPDATE', resourceType: 'ContractTask',
resourceId: taskId.toString(),
label: `Aufgabe abgeschlossen`,
});
res.json({ success: true, data: task } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Abschließen der Aufgabe',
} as ApiResponse);
}
}
export async function reopenTask(req: AuthRequest, res: Response): Promise<void> {
try {
const taskId = parseInt(req.params.taskId);
const task = await contractTaskService.reopenTask(taskId);
await logChange({
req, action: 'UPDATE', resourceType: 'ContractTask',
resourceId: taskId.toString(),
label: `Aufgabe wiedereröffnet`,
});
res.json({ success: true, data: task } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Wiedereröffnen der Aufgabe',
} as ApiResponse);
}
}
export async function deleteTask(req: AuthRequest, res: Response): Promise<void> {
try {
const taskId = parseInt(req.params.taskId);
await contractTaskService.deleteTask(taskId);
await logChange({
req, action: 'DELETE', resourceType: 'ContractTask',
resourceId: taskId.toString(),
label: `Aufgabe gelöscht`,
});
res.json({ success: true, message: 'Aufgabe gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Aufgabe',
} as ApiResponse);
}
}
// ==================== SUBTASKS ====================
export async function createSubtask(req: AuthRequest, res: Response): Promise<void> {
try {
const taskId = parseInt(req.params.taskId);
const { title } = req.body;
if (!title) {
res.status(400).json({
success: false,
error: 'Titel ist erforderlich',
} as ApiResponse);
return;
}
const createdBy = req.user?.email;
const subtask = await contractTaskService.createSubtask({
taskId,
title,
createdBy,
});
await logChange({
req, action: 'CREATE', resourceType: 'ContractSubtask',
resourceId: subtask.id.toString(),
label: `Unteraufgabe "${title}" erstellt`,
});
res.status(201).json({ success: true, data: subtask } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Unteraufgabe',
} as ApiResponse);
}
}
// Kundenportal: Antwort auf eigenes Ticket erstellen
export async function createCustomerReply(req: AuthRequest, res: Response): Promise<void> {
try {
const taskId = parseInt(req.params.taskId);
const { title } = req.body;
if (!title) {
res.status(400).json({
success: false,
error: 'Antwort ist erforderlich',
} as ApiResponse);
return;
}
// Hole den Task
const task = await contractTaskService.getTaskById(taskId);
if (!task) {
res.status(404).json({
success: false,
error: 'Anfrage nicht gefunden',
} as ApiResponse);
return;
}
if (!req.user?.isCustomerPortal || !req.user.customerId) {
res.status(403).json({
success: false,
error: 'Nur für Kundenportal-Benutzer',
} as ApiResponse);
return;
}
// Strikter Owner-Check über den Vertrag (mit Live-Vollmacht-Prüfung
// via hasAuthorization, Pentest Runde 6 HOCH-04). Damit kann ein
// Portal-User keine fremde Task-ID mit visibleInPortal=true abgreifen.
if (!(await canAccessContract(req, res, task.contractId))) return;
// Zusätzlich: portal-User darf nur antworten, wenn die Task von ihm
// initiiert wurde ODER explizit für ihn sichtbar markiert ist.
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
const allowedEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
.filter((email: string | null): email is string => !!email);
const isOwnTask = task.createdBy && allowedEmails.includes(task.createdBy);
if (!task.visibleInPortal && !isOwnTask) {
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diese Anfrage',
} as ApiResponse);
return;
}
const createdBy = req.user?.email;
const subtask = await contractTaskService.createSubtask({
taskId,
title,
createdBy,
});
await logChange({
req, action: 'CREATE', resourceType: 'ContractSubtask',
resourceId: subtask.id.toString(),
label: `Kundenantwort erstellt`,
});
res.status(201).json({ success: true, data: subtask } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Antwort',
} as ApiResponse);
}
}
export async function updateSubtask(req: AuthRequest, res: Response): Promise<void> {
try {
const subtaskId = parseInt(req.params.subtaskId);
const { title } = req.body;
if (!title) {
res.status(400).json({
success: false,
error: 'Titel ist erforderlich',
} as ApiResponse);
return;
}
const subtask = await contractTaskService.updateSubtask(subtaskId, { title });
await logChange({
req, action: 'UPDATE', resourceType: 'ContractSubtask',
resourceId: subtaskId.toString(),
label: `Unteraufgabe aktualisiert`,
});
res.json({ success: true, data: subtask } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Unteraufgabe',
} as ApiResponse);
}
}
export async function completeSubtask(req: AuthRequest, res: Response): Promise<void> {
try {
const subtaskId = parseInt(req.params.subtaskId);
const subtask = await contractTaskService.completeSubtask(subtaskId);
await logChange({
req, action: 'UPDATE', resourceType: 'ContractSubtask',
resourceId: subtaskId.toString(),
label: `Unteraufgabe abgeschlossen`,
});
res.json({ success: true, data: subtask } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Abschließen der Unteraufgabe',
} as ApiResponse);
}
}
export async function reopenSubtask(req: AuthRequest, res: Response): Promise<void> {
try {
const subtaskId = parseInt(req.params.subtaskId);
const subtask = await contractTaskService.reopenSubtask(subtaskId);
await logChange({
req, action: 'UPDATE', resourceType: 'ContractSubtask',
resourceId: subtaskId.toString(),
label: `Unteraufgabe wiedereröffnet`,
});
res.json({ success: true, data: subtask } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Wiedereröffnen der Unteraufgabe',
} as ApiResponse);
}
}
export async function deleteSubtask(req: AuthRequest, res: Response): Promise<void> {
try {
const subtaskId = parseInt(req.params.subtaskId);
await contractTaskService.deleteSubtask(subtaskId);
await logChange({
req, action: 'DELETE', resourceType: 'ContractSubtask',
resourceId: subtaskId.toString(),
label: `Unteraufgabe gelöscht`,
});
res.json({ success: true, message: 'Unteraufgabe gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Unteraufgabe',
} as ApiResponse);
}
}