Der Retest deckte auf, dass abgewehrte Cross-Customer-Zugriffe (canAccess*-403) nur im SecurityEvent-Monitoring-Stream landeten (ACCESS_DENIED, /api/monitoring/events) – nicht im AuditLog, wo der Pentester suchte. Der Monitoring-Stream ist zudem löschbar (DELETE /api/monitoring/events) und nicht hash-verkettet. emitAccessDenied schreibt jetzt zusätzlich einen tamper-evidenten AuditLog-Eintrag (action READ, resourceType 'AccessDenied', Sensitivity HIGH), der ein Clearen des Monitoring-Streams überlebt und über die Hash-Kette manipulationssicher ist. Gilt für alle canAccess*-403 (Contract + Customer, inkl. Vollmacht- fehlt-Fall). Kein Enum-/Schema-Change: READ + distinktiver resourceType, filterbar via searchAuditLogs. Korrigiert damit auch meine frühere ungenaue Aussage „landet im Audit" – vorher war das die falsche Tabelle. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
/**
|
||
* Access-Control-Helper für Portal-Kunden-Isolation.
|
||
*
|
||
* Portal-Kunden haben die Permission `contracts:read` / `customers:read`, damit
|
||
* sie ihre eigenen Daten sehen können. Damit sie aber NICHT fremde Daten über
|
||
* geratene IDs abrufen (IDOR), muss bei jedem Endpoint der eine sensible
|
||
* Ressource (Vertrag, Rechnung, Passwort, ...) zurückliefert, der Kunde auf
|
||
* Besitz/Vollmacht geprüft werden.
|
||
*/
|
||
import { Response } from 'express';
|
||
import prisma from '../lib/prisma.js';
|
||
import * as authorizationService from '../services/authorization.service.js';
|
||
import { AuthRequest } from '../types/index.js';
|
||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||
import { logChange } from '../services/audit.service.js';
|
||
|
||
/**
|
||
* Wird intern aufgerufen, wenn ein canAccess*-Check 403 zurückgibt.
|
||
*
|
||
* Schreibt in ZWEI Streams:
|
||
* 1. SecurityEvent (Monitoring): Alerting + Threshold-Detection, aber
|
||
* über /api/monitoring/events löschbar und nicht hash-verkettet.
|
||
* 2. AuditLog (tamper-evident): hash-verketteter Eintrag, überlebt ein
|
||
* Clearen des Monitoring-Streams. Pentest R120 hat aufgedeckt, dass
|
||
* der reine SecurityEvent-Stream für Forensik „lautlos genug" wirkt –
|
||
* ein Cross-Customer-Zugriffsversuch (IDOR) soll manipulationssicher
|
||
* nachweisbar sein. `action: READ`, weil es ein unautorisierter
|
||
* Lese-VERSUCH ist (kein eigener Enum-Wert nötig); der distinktive
|
||
* resourceType `AccessDenied` macht es filterbar
|
||
* (`searchAuditLogs({ resourceType: 'AccessDenied' })`).
|
||
*/
|
||
function emitAccessDenied(req: AuthRequest, label: string, targetId: number | string): void {
|
||
const ctx = contextFromRequest(req);
|
||
emitSecurityEvent({
|
||
type: 'ACCESS_DENIED',
|
||
severity: 'MEDIUM',
|
||
message: `Zugriff verweigert: ${label} #${targetId}`,
|
||
ipAddress: ctx.ipAddress,
|
||
userId: ctx.userId,
|
||
customerId: ctx.customerId,
|
||
userEmail: ctx.userEmail,
|
||
endpoint: ctx.endpoint,
|
||
details: { resource: label, targetId },
|
||
});
|
||
// Fire-and-forget; logChange fängt eigene Fehler intern ab und darf die
|
||
// 403-Response nie blockieren.
|
||
void logChange({
|
||
req,
|
||
action: 'READ',
|
||
resourceType: 'AccessDenied',
|
||
resourceId: String(targetId),
|
||
label: `IDOR-Zugriffsversuch abgewehrt: ${label} #${targetId}`,
|
||
details: { resource: label, targetId, endpoint: ctx.endpoint },
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Prüft ob der authentifizierte User auf einen bestimmten Vertrag zugreifen darf.
|
||
* - Mitarbeiter/Admin mit customers:read / contracts:read: ja, immer
|
||
* - Portal-Kunde: nur wenn contract.customerId = eigener customerId ODER
|
||
* wenn er einen Vertreter für diesen Kunden ist MIT gültiger Vollmacht
|
||
*
|
||
* @returns true = erlaubt, false = Zugriff verweigert (Response wurde bereits gesendet)
|
||
*/
|
||
export async function canAccessContract(
|
||
req: AuthRequest,
|
||
res: Response,
|
||
contractId: number,
|
||
): Promise<boolean> {
|
||
// Nicht-Portal-User (Mitarbeiter/Admin) kommen hier immer durch, wenn sie die Permission haben
|
||
if (!req.user?.isCustomerPortal) {
|
||
return true;
|
||
}
|
||
|
||
if (!req.user.customerId) {
|
||
res.status(403).json({ success: false, error: 'Kein Zugriff' });
|
||
return false;
|
||
}
|
||
|
||
// Vertrag laden, Besitzer-ID prüfen
|
||
const contract = await prisma.contract.findUnique({
|
||
where: { id: contractId },
|
||
select: { customerId: true },
|
||
});
|
||
|
||
if (!contract) {
|
||
res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' });
|
||
return false;
|
||
}
|
||
|
||
// Eigene Verträge = immer erlaubt
|
||
if (contract.customerId === req.user.customerId) {
|
||
return true;
|
||
}
|
||
|
||
// Fremde Verträge nur mit aktiver Vollmacht
|
||
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
||
if (!representedIds.includes(contract.customerId)) {
|
||
emitAccessDenied(req, 'Contract', contractId);
|
||
res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Vertrag' });
|
||
return false;
|
||
}
|
||
|
||
const hasAuth = await authorizationService.hasAuthorization(
|
||
contract.customerId,
|
||
req.user.customerId,
|
||
);
|
||
if (!hasAuth) {
|
||
emitAccessDenied(req, 'Contract (Vollmacht fehlt)', contractId);
|
||
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Prüft Zugriff auf einen Kunden (analog zu canAccessContract).
|
||
*/
|
||
export async function canAccessCustomer(
|
||
req: AuthRequest,
|
||
res: Response,
|
||
customerId: number,
|
||
): Promise<boolean> {
|
||
if (!req.user?.isCustomerPortal) {
|
||
return true;
|
||
}
|
||
|
||
if (!req.user.customerId) {
|
||
res.status(403).json({ success: false, error: 'Kein Zugriff' });
|
||
return false;
|
||
}
|
||
|
||
if (customerId === req.user.customerId) {
|
||
return true;
|
||
}
|
||
|
||
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
||
if (!representedIds.includes(customerId)) {
|
||
emitAccessDenied(req, 'Customer', customerId);
|
||
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' });
|
||
return false;
|
||
}
|
||
|
||
const hasAuth = await authorizationService.hasAuthorization(customerId, req.user.customerId);
|
||
if (!hasAuth) {
|
||
emitAccessDenied(req, 'Customer (Vollmacht fehlt)', customerId);
|
||
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Liefert die Liste aller Customer-IDs, auf die ein Portal-User aktuell
|
||
* Zugriff hat: eigene + vertretene MIT aktiver Vollmacht (Live-Check via
|
||
* `authorizationService.hasAuthorization`). Für Nicht-Portal-User wird
|
||
* `null` zurückgegeben (= kein Filter, alle Kunden erlaubt).
|
||
*
|
||
* Diese Funktion fängt einen wiederkehrenden Pentest-Befund ab: ohne den
|
||
* Live-Vollmacht-Check hätte ein Portal-User mit widerrufener Vollmacht
|
||
* weiterhin Zugriff auf die Daten des vertretenen Kunden, nur weil seine
|
||
* `representedCustomerIds` im JWT noch drin sind (Token kann bis zu
|
||
* 15min alt sein).
|
||
*/
|
||
export async function getPortalAllowedCustomerIds(
|
||
req: AuthRequest,
|
||
): Promise<number[] | null> {
|
||
if (!req.user?.isCustomerPortal || !req.user.customerId) return null;
|
||
const allowed: number[] = [req.user.customerId];
|
||
const represented: number[] = (req.user as any).representedCustomerIds || [];
|
||
for (const repCustId of represented) {
|
||
const hasAuth = await authorizationService.hasAuthorization(
|
||
repCustId,
|
||
req.user.customerId,
|
||
);
|
||
if (hasAuth) allowed.push(repCustId);
|
||
}
|
||
return allowed;
|
||
}
|
||
|
||
/**
|
||
* Generische Zugriffsprüfung: Ressource → customerId → canAccessCustomer.
|
||
*/
|
||
async function canAccessResourceByCustomerId(
|
||
req: AuthRequest,
|
||
res: Response,
|
||
customerId: number | null | undefined,
|
||
resourceLabel: string,
|
||
): Promise<boolean> {
|
||
if (!req.user?.isCustomerPortal) return true;
|
||
|
||
if (!customerId) {
|
||
res.status(404).json({ success: false, error: `${resourceLabel} nicht gefunden` });
|
||
return false;
|
||
}
|
||
return canAccessCustomer(req, res, customerId);
|
||
}
|
||
|
||
/**
|
||
* Zugriff auf eine Adresse prüfen (lädt sie aus der DB, prüft customerId).
|
||
*/
|
||
export async function canAccessAddress(
|
||
req: AuthRequest,
|
||
res: Response,
|
||
addressId: number,
|
||
): Promise<boolean> {
|
||
if (!req.user?.isCustomerPortal) return true;
|
||
const addr = await prisma.address.findUnique({
|
||
where: { id: addressId },
|
||
select: { customerId: true },
|
||
});
|
||
return canAccessResourceByCustomerId(req, res, addr?.customerId, 'Adresse');
|
||
}
|
||
|
||
/**
|
||
* Zugriff auf eine BankCard prüfen.
|
||
*/
|
||
export async function canAccessBankCard(
|
||
req: AuthRequest,
|
||
res: Response,
|
||
bankCardId: number,
|
||
): Promise<boolean> {
|
||
if (!req.user?.isCustomerPortal) return true;
|
||
const card = await prisma.bankCard.findUnique({
|
||
where: { id: bankCardId },
|
||
select: { customerId: true },
|
||
});
|
||
return canAccessResourceByCustomerId(req, res, card?.customerId, 'Bankkarte');
|
||
}
|
||
|
||
/**
|
||
* Zugriff auf ein IdentityDocument prüfen.
|
||
*/
|
||
export async function canAccessIdentityDocument(
|
||
req: AuthRequest,
|
||
res: Response,
|
||
documentId: number,
|
||
): Promise<boolean> {
|
||
if (!req.user?.isCustomerPortal) return true;
|
||
const doc = await prisma.identityDocument.findUnique({
|
||
where: { id: documentId },
|
||
select: { customerId: true },
|
||
});
|
||
return canAccessResourceByCustomerId(req, res, doc?.customerId, 'Ausweis');
|
||
}
|
||
|
||
/**
|
||
* Zugriff auf einen Meter prüfen.
|
||
*/
|
||
export async function canAccessMeter(
|
||
req: AuthRequest,
|
||
res: Response,
|
||
meterId: number,
|
||
): Promise<boolean> {
|
||
if (!req.user?.isCustomerPortal) return true;
|
||
const meter = await prisma.meter.findUnique({
|
||
where: { id: meterId },
|
||
select: { customerId: true },
|
||
});
|
||
return canAccessResourceByCustomerId(req, res, meter?.customerId, 'Zähler');
|
||
}
|
||
|
||
/**
|
||
* Zugriff auf eine StressfreiEmail prüfen.
|
||
*/
|
||
export async function canAccessStressfreiEmail(
|
||
req: AuthRequest,
|
||
res: Response,
|
||
stressfreiEmailId: number,
|
||
): Promise<boolean> {
|
||
if (!req.user?.isCustomerPortal) return true;
|
||
const sfe = await prisma.stressfreiEmail.findUnique({
|
||
where: { id: stressfreiEmailId },
|
||
select: { customerId: true },
|
||
});
|
||
return canAccessResourceByCustomerId(req, res, sfe?.customerId, 'E-Mail-Konto');
|
||
}
|
||
|
||
/**
|
||
* Zugriff auf eine CachedEmail prüfen (StressfreiEmail → customerId).
|
||
*/
|
||
export async function canAccessCachedEmail(
|
||
req: AuthRequest,
|
||
res: Response,
|
||
emailId: number,
|
||
): Promise<boolean> {
|
||
if (!req.user?.isCustomerPortal) return true;
|
||
const email = await prisma.cachedEmail.findUnique({
|
||
where: { id: emailId },
|
||
select: { stressfreiEmail: { select: { customerId: true } } },
|
||
});
|
||
return canAccessResourceByCustomerId(
|
||
req,
|
||
res,
|
||
email?.stressfreiEmail?.customerId,
|
||
'E-Mail',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Zugriff auf ein EnergyContractDetails prüfen (ECD → Contract → customerId).
|
||
*/
|
||
export async function canAccessEnergyContractDetails(
|
||
req: AuthRequest,
|
||
res: Response,
|
||
ecdId: number,
|
||
): Promise<boolean> {
|
||
if (!req.user?.isCustomerPortal) return true;
|
||
const ecd = await prisma.energyContractDetails.findUnique({
|
||
where: { id: ecdId },
|
||
select: { contract: { select: { customerId: true } } },
|
||
});
|
||
return canAccessResourceByCustomerId(
|
||
req,
|
||
res,
|
||
ecd?.contract?.customerId,
|
||
'Energievertrag',
|
||
);
|
||
}
|