Compare commits
17
Commits
23505afc05
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ef28c7a87 | ||
|
|
f964610af7 | ||
|
|
75d009138d | ||
|
|
702e630ee5 | ||
|
|
b2963be482 | ||
|
|
9f879759d8 | ||
|
|
77aeb69aeb | ||
|
|
cca242119b | ||
|
|
3a50a40ad5 | ||
|
|
e76b4ace88 | ||
|
|
ed676ca4eb | ||
|
|
eb0580ac54 | ||
|
|
2ca6ed2f70 | ||
|
|
df442bb1a0 | ||
|
|
31c4c209e4 | ||
|
|
fc6f39eba0 | ||
|
|
909e523634 |
@@ -398,6 +398,27 @@ lässt das Siegel aus.
|
|||||||
| Muss ich ihn irgendwo eintragen außer in der `.env`? | Nein. Einmal setzen, Backup anlegen, fertig. |
|
| Muss ich ihn irgendwo eintragen außer in der `.env`? | Nein. Einmal setzen, Backup anlegen, fertig. |
|
||||||
| Verlangsamt das etwas? | Nein, spürbar nicht. |
|
| Verlangsamt das etwas? | Nein, spürbar nicht. |
|
||||||
|
|
||||||
|
**Wichtig bei einer bestehenden Installation: den Altbestand versiegeln**
|
||||||
|
|
||||||
|
Das Siegel gilt nur für Einträge, die **ab** dem Setzen des Schlüssels
|
||||||
|
geschrieben werden. Alles, was vorher im Protokoll steht, bleibt ungeschützt –
|
||||||
|
Änderungen daran wären nicht erkennbar. Die Integritätsprüfung sagt das auch:
|
||||||
|
|
||||||
|
Hinweis: Der Altbestand ist nicht versiegelt – Änderungen daran wären
|
||||||
|
nicht erkennbar. Behebbar mit POST /api/audit-logs/seal-backlog
|
||||||
|
{"confirm":"SEAL"}.
|
||||||
|
|
||||||
|
Das ist ein **einmaliger** Schritt: Er zieht ein Siegel über den vorhandenen
|
||||||
|
Bestand, sodass spätere Änderungen daran auffallen. Der Ablauf – wer es darf
|
||||||
|
(Haken **„Audit-Betrieb"** in der Benutzerverwaltung), was vorher zu prüfen ist
|
||||||
|
und wie man das Ergebnis gegenprüft – steht Schritt für Schritt unter
|
||||||
|
**[Den Altbestand versiegeln](tools/audit-notary/README.md#den-altbestand-versiegeln-einmalig-im-crm)**.
|
||||||
|
Die Anleitung liegt beim Gegenbuch, gilt aber **auch ohne** – es ist ein
|
||||||
|
Vorgang im CRM.
|
||||||
|
|
||||||
|
Den Zustand siehst du jederzeit unter **Einstellungen → Audit-Protokoll**, ganz
|
||||||
|
oben.
|
||||||
|
|
||||||
**Schlüssel wechseln (`AUDIT_HMAC_KEY_OLD`)**
|
**Schlüssel wechseln (`AUDIT_HMAC_KEY_OLD`)**
|
||||||
|
|
||||||
Möchtest du den Schlüssel austauschen – etwa weil du vermutest, dass er in
|
Möchtest du den Schlüssel austauschen – etwa weil du vermutest, dass er in
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Systemrollen kennzeichnen (Rechtemodell, Etappe 1).
|
||||||
|
--
|
||||||
|
-- Bis hierher war jede Rolle ueber die Rollen-CRUD frei aenderbar - auch
|
||||||
|
-- "Admin", "DSGVO" und "Developer". Man konnte sie umbenennen oder ihre
|
||||||
|
-- Rechte leeren. Zugleich haengen die versteckten Rollen an ihrem NAMEN:
|
||||||
|
-- `user.service.ts` sucht sie per `findFirst({ where: { name: 'DSGVO' } })`.
|
||||||
|
-- Ein umbenannter Datensatz haette den Notfallpfad ins Leere laufen lassen,
|
||||||
|
-- ohne dass irgendwo etwas gemeldet worden waere.
|
||||||
|
--
|
||||||
|
-- `isSystem` macht diese Rollen zu dem, was sie immer sein sollten: von der
|
||||||
|
-- Anwendung gepflegt, ueber die API sichtbar, aber nicht veraenderbar.
|
||||||
|
-- `isHidden` ersetzt die im Frontend hartkodierte Namensliste, in der
|
||||||
|
-- "Gegenbuch" bisher fehlte - die Rolle tauchte deshalb als anhakbare
|
||||||
|
-- Rolle im Benutzerformular auf.
|
||||||
|
ALTER TABLE `Role` ADD COLUMN IF NOT EXISTS `isSystem` BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE `Role` ADD COLUMN IF NOT EXISTS `isHidden` BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Backfill nach Namen. Wer eine dieser Rollen lokal umbenannt hat, wird hier
|
||||||
|
-- nicht getroffen; `synchronisiereRechteUndRollen` legt beim naechsten Start
|
||||||
|
-- eine neue Rolle unter dem erwarteten Namen an und markiert sie. Das ist
|
||||||
|
-- sichtbar (zwei Rollen in der Liste) und damit behandelbar - im Gegensatz
|
||||||
|
-- zu einer stillen Fehlzuordnung.
|
||||||
|
UPDATE `Role` SET `isSystem` = true
|
||||||
|
WHERE `name` IN ('Admin','Developer','DSGVO','Audit-Betrieb','Gegenbuch',
|
||||||
|
'Mitarbeiter','Mitarbeiter (Nur-Lesen)','Kunde');
|
||||||
|
|
||||||
|
UPDATE `Role` SET `isHidden` = true
|
||||||
|
WHERE `name` IN ('Developer','DSGVO','Audit-Betrieb','Gegenbuch','Kunde');
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
-- Rechtekatalog begradigen (Rechtemodell, Etappe 1).
|
||||||
|
--
|
||||||
|
-- 18 der 50 Rechte bewachten nichts: `tariffs:*`, `cancellation-periods:*`,
|
||||||
|
-- `contract-durations:*` und `email-providers:*` standen im Katalog und waren
|
||||||
|
-- in der Rollenverwaltung anhakbar - die Routen prueften in Wahrheit
|
||||||
|
-- `providers:*`, `platforms:*` und `settings:*`. Ein Haken, der nichts tut,
|
||||||
|
-- ist schlimmer als ein fehlender: Er behauptet eine Trennung, die es nicht
|
||||||
|
-- gibt, und wer sich darauf verlaesst, vergibt zu viel oder zu wenig, ohne
|
||||||
|
-- es zu merken.
|
||||||
|
--
|
||||||
|
-- Diese Migration ist REIN ADDITIV. Sie entzieht keiner Rolle irgendetwas.
|
||||||
|
-- Sie muss vor dem Code laufen, der die Routen umstellt - sonst verlieren
|
||||||
|
-- bestehende Rollen den Zugriff auf Tarife, Kuendigungsfristen, Laufzeiten
|
||||||
|
-- und E-Mail-Provider.
|
||||||
|
--
|
||||||
|
-- Das Aufraeumen der jetzt ueberfluessigen Sammelrechte ist ausdruecklich
|
||||||
|
-- NICHT Aufgabe dieser Migration, sondern eine bewusste Entscheidung des
|
||||||
|
-- Betreibers in der Rollenoberflaeche.
|
||||||
|
|
||||||
|
-- 1) Fehlende Rechte in den Katalog.
|
||||||
|
-- UNIQUE(resource, action) traegt die Idempotenz.
|
||||||
|
INSERT IGNORE INTO `Permission` (`resource`,`action`) VALUES
|
||||||
|
('roles','manage'),
|
||||||
|
('tariffs','create'),('tariffs','read'),('tariffs','update'),('tariffs','delete'),
|
||||||
|
('cancellation-periods','create'),('cancellation-periods','read'),
|
||||||
|
('cancellation-periods','update'),('cancellation-periods','delete'),
|
||||||
|
('contract-durations','create'),('contract-durations','read'),
|
||||||
|
('contract-durations','update'),('contract-durations','delete'),
|
||||||
|
('contract-categories','create'),('contract-categories','read'),
|
||||||
|
('contract-categories','update'),('contract-categories','delete'),
|
||||||
|
('email-providers','create'),('email-providers','read'),
|
||||||
|
('email-providers','update'),('email-providers','delete'),
|
||||||
|
('platforms','read');
|
||||||
|
|
||||||
|
-- 2) Vererbung alt -> neu: Jede Rolle, die bisher das Sammelrecht hielt,
|
||||||
|
-- bekommt das neue Recht dazu. INSERT IGNORE gegen den Primaerschluessel
|
||||||
|
-- (roleId, permissionId) macht den Block beliebig wiederholbar.
|
||||||
|
--
|
||||||
|
-- `users:delete` -> `roles:manage` bewusst nur ueber `users:delete`, nicht
|
||||||
|
-- ueber `users:create`/`users:update`: `users:delete` ist im Code bereits
|
||||||
|
-- die Admin-Heuristik (user.service.ts). Wuerde man `roles:manage` an
|
||||||
|
-- jeden mit `users:update` vererben, waere die Trennung, die dieses Recht
|
||||||
|
-- herstellen soll, im selben Zug wieder eingerissen.
|
||||||
|
INSERT IGNORE INTO `RolePermission` (`roleId`,`permissionId`)
|
||||||
|
SELECT rp.`roleId`, neu.`id`
|
||||||
|
FROM `RolePermission` rp
|
||||||
|
JOIN `Permission` alt ON alt.`id` = rp.`permissionId`
|
||||||
|
JOIN (
|
||||||
|
SELECT 'providers' AS aRes,'read' AS aAct,'tariffs' AS nRes,'read' AS nAct
|
||||||
|
UNION ALL SELECT 'providers','create','tariffs','create'
|
||||||
|
UNION ALL SELECT 'providers','update','tariffs','update'
|
||||||
|
UNION ALL SELECT 'providers','delete','tariffs','delete'
|
||||||
|
UNION ALL SELECT 'platforms','create','cancellation-periods','create'
|
||||||
|
UNION ALL SELECT 'platforms','update','cancellation-periods','update'
|
||||||
|
UNION ALL SELECT 'platforms','delete','cancellation-periods','delete'
|
||||||
|
UNION ALL SELECT 'platforms','create','contract-durations','create'
|
||||||
|
UNION ALL SELECT 'platforms','update','contract-durations','update'
|
||||||
|
UNION ALL SELECT 'platforms','delete','contract-durations','delete'
|
||||||
|
UNION ALL SELECT 'settings','read', 'email-providers','read'
|
||||||
|
UNION ALL SELECT 'settings','update','email-providers','create'
|
||||||
|
UNION ALL SELECT 'settings','update','email-providers','update'
|
||||||
|
UNION ALL SELECT 'settings','update','email-providers','delete'
|
||||||
|
UNION ALL SELECT 'users','delete','roles','manage'
|
||||||
|
) m ON m.aRes = alt.`resource` AND m.aAct = alt.`action`
|
||||||
|
JOIN `Permission` neu ON neu.`resource` = m.nRes AND neu.`action` = m.nAct;
|
||||||
|
|
||||||
|
-- 3) Die bisher ungegateten Lese-Endpunkte (Plattformen, Vertragstypen,
|
||||||
|
-- Kuendigungsfristen, Laufzeiten) bekommen ab jetzt ein Recht. Damit
|
||||||
|
-- niemand seine Listen verliert, erhalten es alle Rollen, die die
|
||||||
|
-- Anwendung ueberhaupt benutzen.
|
||||||
|
--
|
||||||
|
-- Bewusst NICHT pauschal an jede Rolle: "Gegenbuch" haelt nur `audit:read`
|
||||||
|
-- und soll so wenig wert bleiben wie moeglich - sein Kennwort liegt auf
|
||||||
|
-- der Notar-Maschine im Klartext (R185-01). Dasselbe gilt fuer
|
||||||
|
-- "Audit-Betrieb".
|
||||||
|
INSERT IGNORE INTO `RolePermission` (`roleId`,`permissionId`)
|
||||||
|
SELECT r.`id`, p.`id`
|
||||||
|
FROM `Role` r
|
||||||
|
JOIN `Permission` p
|
||||||
|
ON (p.`resource`,p.`action`) IN
|
||||||
|
(('cancellation-periods','read'),('contract-durations','read'),
|
||||||
|
('contract-categories','read'),('platforms','read'),('tariffs','read'))
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM `RolePermission` rp2
|
||||||
|
JOIN `Permission` p2 ON p2.`id` = rp2.`permissionId`
|
||||||
|
WHERE rp2.`roleId` = r.`id`
|
||||||
|
AND (p2.`resource`,p2.`action`) IN
|
||||||
|
(('customers','read'),('contracts','read'),('providers','read'),
|
||||||
|
('platforms','create'),('settings','read'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4) Alle Sitzungen einmalig beenden.
|
||||||
|
--
|
||||||
|
-- Die Rechte stehen im Zugangstoken. Nach der Routenumstellung verlangt
|
||||||
|
-- z.B. PUT /tariffs/:id das Recht `tariffs:update`; jedes bereits
|
||||||
|
-- ausgestellte Token traegt aber noch den alten Anspruch und liefe bis zu
|
||||||
|
-- 15 Minuten lang in ein 403. Die Datenbank waere dann laengst richtig -
|
||||||
|
-- nur das Token alt.
|
||||||
|
--
|
||||||
|
-- Einmal neu anmelden ist ehrlicher als ein Uebergangs-Doppelgate, das in
|
||||||
|
-- sechs Monaten jemand fuer Absicht haelt. Die Meldung dafuer gibt es
|
||||||
|
-- bereits im Klartext ("Ihre Berechtigungen wurden geändert."), und das
|
||||||
|
-- Gegenbuch-Dienstkonto meldet sich stuendlich ohnehin neu an.
|
||||||
|
UPDATE `User` SET `tokenInvalidatedAt` = NOW();
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Bestandsaufnahme des Rechtesystems. Liest nur, aendert nichts.
|
||||||
|
*
|
||||||
|
* npx tsx prisma/rechte-report.ts → Bericht auf stdout
|
||||||
|
* npx tsx prisma/rechte-report.ts > vorher.txt
|
||||||
|
*
|
||||||
|
* Zweck: Vor und nach dem Rechte-Umbau denselben Bericht erzeugen und
|
||||||
|
* vergleichen. Die Zusage lautet "niemand verliert Zugriff" - und eine
|
||||||
|
* Zusage, die niemand nachrechnen kann, ist keine.
|
||||||
|
*
|
||||||
|
* Der Bericht ist bewusst zeilenweise und sortiert, damit `diff` darauf
|
||||||
|
* etwas Lesbares ausgibt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Ueber src/lib/prisma.js, NICHT ueber einen eigenen PrismaClient: Dort wird
|
||||||
|
// DATABASE_URL aus den DB_*-Teilen zusammengesetzt, falls sie fehlt. Bei
|
||||||
|
// `docker compose exec <dienst> npx tsx prisma/<skript>.ts` ist genau das der
|
||||||
|
// Fall - der Entrypoint exportiert sie nur in den Serverprozess, eine neue
|
||||||
|
// Shell erbt sie nicht. Ein eigener Client scheitert dort mit
|
||||||
|
// "Environment variable not found: DATABASE_URL".
|
||||||
|
import prisma from '../src/lib/prisma.js';
|
||||||
|
import { ALLE_RECHTE, SYSTEMROLLEN_NAMEN } from '../src/config/rechte-katalog.js';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest isSystem/isHidden, sofern die Spalten existieren.
|
||||||
|
*
|
||||||
|
* Der Bericht muss VOR und NACH der Migration laufen - das ist sein Zweck.
|
||||||
|
* Vorher gibt es die Spalten noch nicht, und ein Absturz an dieser Stelle
|
||||||
|
* haette genau die Baseline verhindert, gegen die spaeter verglichen wird.
|
||||||
|
*/
|
||||||
|
async function leseKennzeichen(): Promise<Map<number, { isSystem: boolean; isHidden: boolean }>> {
|
||||||
|
const karte = new Map<number, { isSystem: boolean; isHidden: boolean }>();
|
||||||
|
try {
|
||||||
|
const zeilen = await prisma.$queryRawUnsafe<
|
||||||
|
Array<{ id: number; isSystem: number | boolean; isHidden: number | boolean }>
|
||||||
|
>('SELECT id, isSystem, isHidden FROM `Role`');
|
||||||
|
for (const z of zeilen) {
|
||||||
|
karte.set(Number(z.id), { isSystem: Boolean(z.isSystem), isHidden: Boolean(z.isHidden) });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Spalten noch nicht vorhanden - das ist der Zustand vor der Migration.
|
||||||
|
}
|
||||||
|
return karte;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const kennzeichen = await leseKennzeichen();
|
||||||
|
const rollen = await prisma.role.findMany({
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
permissions: { include: { permission: true } },
|
||||||
|
_count: { select: { users: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('=== ROLLEN ===');
|
||||||
|
for (const r of rollen) {
|
||||||
|
const rechte = r.permissions
|
||||||
|
.map((rp) => `${rp.permission.resource}:${rp.permission.action}`)
|
||||||
|
.sort();
|
||||||
|
const k = kennzeichen.get(r.id);
|
||||||
|
const etikett = [k?.isSystem ? 'System' : null, k?.isHidden ? 'versteckt' : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
console.log(
|
||||||
|
`\n[${r.name}] #${r.id}` +
|
||||||
|
(etikett ? ` (${etikett})` : '') +
|
||||||
|
` – ${r._count.users} Konten, ${rechte.length} Rechte`,
|
||||||
|
);
|
||||||
|
for (const recht of rechte) console.log(` ${recht}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== KONTEN (aktiv) ===');
|
||||||
|
const konten = await prisma.user.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { email: 'asc' },
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
roles: {
|
||||||
|
select: {
|
||||||
|
role: {
|
||||||
|
// Ausdrueckliche Spaltenauswahl, damit der Bericht auch auf einer
|
||||||
|
// noch nicht migrierten Datenbank laeuft.
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
permissions: { include: { permission: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const k of konten) {
|
||||||
|
const rechte = new Set<string>();
|
||||||
|
for (const ur of k.roles) {
|
||||||
|
for (const rp of ur.role.permissions) {
|
||||||
|
rechte.add(`${rp.permission.resource}:${rp.permission.action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rollenNamen = k.roles.map((ur) => ur.role.name).sort().join(', ') || '(keine)';
|
||||||
|
console.log(`\n[${k.email}] Rollen: ${rollenNamen} – ${rechte.size} Rechte`);
|
||||||
|
for (const recht of [...rechte].sort()) console.log(` ${recht}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auffaelligkeiten -----------------------------------------------------
|
||||||
|
// Nicht als Alarm gemeint, sondern als das, was man vor einem Deploy
|
||||||
|
// wissen will. Eine umbenannte Systemrolle etwa wird vom Backfill der
|
||||||
|
// Migration nicht getroffen.
|
||||||
|
console.log('\n=== HINWEISE ===');
|
||||||
|
const hinweise: string[] = [];
|
||||||
|
|
||||||
|
const vorhandeneNamen = new Set(rollen.map((r) => r.name));
|
||||||
|
for (const erwartet of SYSTEMROLLEN_NAMEN) {
|
||||||
|
if (!vorhandeneNamen.has(erwartet)) {
|
||||||
|
hinweise.push(`Systemrolle "${erwartet}" fehlt in der Datenbank.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of rollen) {
|
||||||
|
const k = kennzeichen.get(r.id);
|
||||||
|
// Vor der Migration gibt es die Spalte nicht - dann ist "nicht
|
||||||
|
// gekennzeichnet" kein Befund, sondern der erwartete Zustand.
|
||||||
|
if (kennzeichen.size === 0) continue;
|
||||||
|
if (SYSTEMROLLEN_NAMEN.includes(r.name) && !k?.isSystem) {
|
||||||
|
hinweise.push(`Rolle "${r.name}" ist eine Systemrolle, aber isSystem ist nicht gesetzt.`);
|
||||||
|
}
|
||||||
|
if (!SYSTEMROLLEN_NAMEN.includes(r.name) && k?.isSystem) {
|
||||||
|
hinweise.push(`Rolle "${r.name}" traegt isSystem, gehoert aber nicht zum Katalog.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alleRechteDb = await prisma.permission.findMany();
|
||||||
|
const imKatalog = new Set(ALLE_RECHTE);
|
||||||
|
for (const p of alleRechteDb) {
|
||||||
|
const s = `${p.resource}:${p.action}`;
|
||||||
|
if (!imKatalog.has(s)) hinweise.push(`Recht "${s}" steht in der Datenbank, aber nicht im Katalog.`);
|
||||||
|
}
|
||||||
|
const inDb = new Set(alleRechteDb.map((p) => `${p.resource}:${p.action}`));
|
||||||
|
for (const s of ALLE_RECHTE) {
|
||||||
|
if (!inDb.has(s)) hinweise.push(`Recht "${s}" steht im Katalog, aber nicht in der Datenbank.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Konten ohne jede Rolle koennen sich anmelden und sehen nichts - eine
|
||||||
|
// haeufige Ursache fuer "bei mir ist alles leer".
|
||||||
|
for (const k of konten) {
|
||||||
|
if (k.roles.length === 0) hinweise.push(`Konto "${k.email}" hat keine Rolle.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hinweise.length === 0) console.log(' keine');
|
||||||
|
else for (const h of hinweise) console.log(` ! ${h}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('[rechte-report] Fehler:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Weist einem Konto eine Rolle zu oder nimmt sie ihm weg - von der
|
||||||
|
* Kommandozeile aus.
|
||||||
|
*
|
||||||
|
* npx tsx prisma/rolle-zuweisen.ts <email> <Rollenname>
|
||||||
|
* npx tsx prisma/rolle-zuweisen.ts <email> <Rollenname> --entfernen
|
||||||
|
* npx tsx prisma/rolle-zuweisen.ts --liste
|
||||||
|
*
|
||||||
|
* Im Container:
|
||||||
|
* docker compose exec backend npx tsx prisma/rolle-zuweisen.ts \
|
||||||
|
* name@firma.de Developer
|
||||||
|
*
|
||||||
|
* WOZU: Die Teilmengenregel verhindert, dass jemand ueber die Weboberflaeche
|
||||||
|
* Rechte vergibt, die er selbst nicht haelt. Das ist gewollt - es schliesst
|
||||||
|
* die Selbst-Erhoehung. Es hat aber eine Kehrseite: Nach einer
|
||||||
|
* Neuinstallation haelt niemand `developer:access` oder `audit:admin`, und
|
||||||
|
* dann kann diese Rechte auch niemand erstmalig vergeben.
|
||||||
|
*
|
||||||
|
* Dieses Skript ist der dokumentierte Ausweg. Es umgeht die Regel bewusst,
|
||||||
|
* denn die Vertrauensgrenze stimmt: Wer eine Shell auf dieser Maschine hat,
|
||||||
|
* hat ohnehin Zugriff auf die Datenbank. Ein gestohlener Web-Zugang hat das
|
||||||
|
* nicht - und genau das ist der Unterschied, den die Regel schuetzen soll.
|
||||||
|
*
|
||||||
|
* Der Vorgang wird protokolliert und meldet die Traeger ab, damit die
|
||||||
|
* Aenderung sofort wirkt und nicht spurlos bleibt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Ueber src/lib/prisma.js, NICHT ueber einen eigenen PrismaClient: Dort wird
|
||||||
|
// DATABASE_URL aus den DB_*-Teilen zusammengesetzt, falls sie fehlt. Bei
|
||||||
|
// `docker compose exec <dienst> npx tsx prisma/<skript>.ts` ist genau das der
|
||||||
|
// Fall - der Entrypoint exportiert sie nur in den Serverprozess, eine neue
|
||||||
|
// Shell erbt sie nicht. Ein eigener Client scheitert dort mit
|
||||||
|
// "Environment variable not found: DATABASE_URL".
|
||||||
|
import prisma from '../src/lib/prisma.js';
|
||||||
|
import { createAuditLog } from '../src/services/audit.service.js';
|
||||||
|
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (args.includes('--liste') || args.length === 0) {
|
||||||
|
const rollen = await prisma.role.findMany({
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
include: { _count: { select: { users: true } } },
|
||||||
|
});
|
||||||
|
console.log('Vorhandene Rollen:');
|
||||||
|
for (const r of rollen) {
|
||||||
|
const kennz = [r.isSystem ? 'System' : null, r.isHidden ? 'versteckt' : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
console.log(` ${r.name}${kennz ? ` (${kennz})` : ''} – ${r._count.users} Konten`);
|
||||||
|
}
|
||||||
|
if (args.length === 0) {
|
||||||
|
console.log('\nAufruf: npx tsx prisma/rolle-zuweisen.ts <email> <Rollenname> [--entfernen]');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entfernen = args.includes('--entfernen');
|
||||||
|
const [email, rollenName] = args.filter((a) => !a.startsWith('--'));
|
||||||
|
|
||||||
|
if (!email || !rollenName) {
|
||||||
|
console.error('Aufruf: npx tsx prisma/rolle-zuweisen.ts <email> <Rollenname> [--entfernen]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const konto = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (!konto) {
|
||||||
|
console.error(`Kein Konto mit der Adresse "${email}".`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rolle = await prisma.role.findUnique({ where: { name: rollenName } });
|
||||||
|
if (!rolle) {
|
||||||
|
console.error(`Keine Rolle mit dem Namen "${rollenName}". Vorhandene mit --liste ansehen.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorhanden = await prisma.userRole.findUnique({
|
||||||
|
where: { userId_roleId: { userId: konto.id, roleId: rolle.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entfernen) {
|
||||||
|
if (!vorhanden) {
|
||||||
|
console.log(`"${email}" hat die Rolle "${rollenName}" gar nicht. Nichts zu tun.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await prisma.userRole.delete({
|
||||||
|
where: { userId_roleId: { userId: konto.id, roleId: rolle.id } },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (vorhanden) {
|
||||||
|
console.log(`"${email}" hat die Rolle "${rollenName}" bereits. Nichts zu tun.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await prisma.userRole.create({ data: { userId: konto.id, roleId: rolle.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sofort wirksam machen: Die Rechte stehen im Zugangstoken, sonst behielte
|
||||||
|
// das Konto bis zu 15 Minuten den alten Stand.
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: konto.id },
|
||||||
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nachvollziehbar machen. Ein Eingriff von der Kommandozeile ist berechtigt,
|
||||||
|
// aber er darf nicht unsichtbar sein - sonst waere das Skript selbst die
|
||||||
|
// Luecke, die es schliessen soll.
|
||||||
|
//
|
||||||
|
// Ueber createAuditLog, NICHT ueber prisma.auditLog.create: Das Protokoll
|
||||||
|
// ist eine Hash-Kette. Ein roh eingefuegter Datensatz haette kein `hash`
|
||||||
|
// und keinen `previousHash` und wuerde bei der naechsten Pruefung als
|
||||||
|
// Luecke erscheinen - das Skript wuerde also ausgerechnet dort Zweifel
|
||||||
|
// saeen, wo es Klarheit schaffen soll.
|
||||||
|
await createAuditLog({
|
||||||
|
userEmail: 'system (CLI)',
|
||||||
|
userRole: 'Kommandozeile',
|
||||||
|
action: entfernen ? 'DELETE' : 'CREATE',
|
||||||
|
sensitivity: 'CRITICAL',
|
||||||
|
resourceType: 'UserRole',
|
||||||
|
resourceId: `${konto.id}:${rolle.id}`,
|
||||||
|
resourceLabel: entfernen
|
||||||
|
? `Rolle "${rolle.name}" von ${email} entfernt (Kommandozeile)`
|
||||||
|
: `Rolle "${rolle.name}" an ${email} vergeben (Kommandozeile)`,
|
||||||
|
endpoint: 'prisma/rolle-zuweisen.ts',
|
||||||
|
httpMethod: 'CLI',
|
||||||
|
ipAddress: 'lokal',
|
||||||
|
changesAfter: { konto: email, rolle: rolle.name, entfernt: entfernen },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
entfernen
|
||||||
|
? `Rolle "${rolle.name}" von "${email}" entfernt.`
|
||||||
|
: `Rolle "${rolle.name}" an "${email}" vergeben.`,
|
||||||
|
);
|
||||||
|
console.log('Das Konto muss sich neu anmelden, damit die Änderung greift.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('[rolle-zuweisen] Fehler:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -106,6 +106,16 @@ model Role {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
description String?
|
description String?
|
||||||
|
/// Von der Anwendung gepflegte Rolle (config/rechte-katalog.ts). Über die
|
||||||
|
/// API sichtbar, aber nicht umbenennbar, nicht löschbar, Rechte nicht
|
||||||
|
/// änderbar. Ohne diese Sperre liess sich die Admin-Rolle über die
|
||||||
|
/// Rollen-CRUD leeren oder umbenennen, und die Namens-Schlüssel, an denen
|
||||||
|
/// die versteckten Rollen hängen, waren nur scheinbar stabil.
|
||||||
|
isSystem Boolean @default(false)
|
||||||
|
/// Nicht in der normalen Rollenauswahl anbieten. Diese Rollen werden über
|
||||||
|
/// die Haken im Benutzerformular vergeben (DSGVO, Entwicklerzugriff,
|
||||||
|
/// Audit-Betrieb) oder gehören zu einem technischen Konto.
|
||||||
|
isHidden Boolean @default(false)
|
||||||
permissions RolePermission[]
|
permissions RolePermission[]
|
||||||
users UserRole[]
|
users UserRole[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
+27
-213
@@ -1,225 +1,28 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { synchronisiereRechteUndRollen } from '../src/services/rollen-sync.service.js';
|
||||||
|
import { ROLLE_ADMIN, ROLLE_DSGVO } from '../src/config/rechte-katalog.js';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('Seeding database...');
|
console.log('Seeding database...');
|
||||||
|
|
||||||
// ==================== PERMISSIONS ====================
|
// ==================== RECHTE UND ROLLEN ====================
|
||||||
// Ressourcen mit ihren erlaubten Aktionen
|
// Katalog und Systemrollen kommen aus einer einzigen Definition
|
||||||
const resourcePermissions: Record<string, string[]> = {
|
// (src/config/rechte-katalog.ts), aufgeloest von rollen-sync.service.ts.
|
||||||
// Haupt-Ressourcen (CRUD)
|
//
|
||||||
customers: ['create', 'read', 'update', 'delete'],
|
// Bis 09/2026 stand hier eine zweite, eigene Kopie - und sie wich ab: Die
|
||||||
contracts: ['create', 'read', 'update', 'delete'],
|
// DSGVO-Rolle bekam `audit:*` komplett, also auch `audit:admin`. Gerettet
|
||||||
users: ['create', 'read', 'update', 'delete'],
|
// hat das nur die Reihenfolge im Container-Start (sync-roles lief danach
|
||||||
platforms: ['create', 'read', 'update', 'delete'],
|
// und raeumte Ueberzaehliges weg); ein einzelnes `npm run db:seed` brachte
|
||||||
providers: ['create', 'read', 'update', 'delete'],
|
// die Buendelung zurueck. Drei Beschreibungen desselben Sachverhalts sind
|
||||||
tariffs: ['create', 'read', 'update', 'delete'],
|
// zwei zu viel.
|
||||||
// Konfiguration (CRUD)
|
await synchronisiereRechteUndRollen(prisma);
|
||||||
'cancellation-periods': ['create', 'read', 'update', 'delete'],
|
|
||||||
'contract-durations': ['create', 'read', 'update', 'delete'],
|
|
||||||
'contract-categories': ['create', 'read', 'update', 'delete'],
|
|
||||||
'email-providers': ['create', 'read', 'update', 'delete'],
|
|
||||||
// Einstellungen (nur lesen/ändern)
|
|
||||||
settings: ['read', 'update'],
|
|
||||||
// Spezial-Permissions
|
|
||||||
developer: ['access'],
|
|
||||||
emails: ['delete'],
|
|
||||||
// DSGVO & Audit
|
|
||||||
audit: ['read', 'export', 'admin'],
|
|
||||||
gdpr: ['export', 'delete', 'admin'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const permissions: { resource: string; action: string }[] = [];
|
const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_ADMIN } });
|
||||||
for (const [resource, actions] of Object.entries(resourcePermissions)) {
|
const gdprRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_DSGVO } });
|
||||||
for (const action of actions) {
|
|
||||||
permissions.push({ resource, action });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const perm of permissions) {
|
|
||||||
await prisma.permission.upsert({
|
|
||||||
where: { resource_action: perm },
|
|
||||||
update: {},
|
|
||||||
create: perm,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Permissions created (${permissions.length} total)`);
|
|
||||||
|
|
||||||
// Get all permissions
|
|
||||||
const allPermissions = await prisma.permission.findMany();
|
|
||||||
const customerReadPerm = allPermissions.find(
|
|
||||||
(p) => p.resource === 'customers' && p.action === 'read'
|
|
||||||
);
|
|
||||||
const contractReadPerm = allPermissions.find(
|
|
||||||
(p) => p.resource === 'contracts' && p.action === 'read'
|
|
||||||
);
|
|
||||||
const platformReadPerm = allPermissions.find(
|
|
||||||
(p) => p.resource === 'platforms' && p.action === 'read'
|
|
||||||
);
|
|
||||||
const providerReadPerm = allPermissions.find(
|
|
||||||
(p) => p.resource === 'providers' && p.action === 'read'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Helper: Sync permissions for a role (adds missing, removes excess)
|
|
||||||
async function syncRolePermissions(roleId: number, permissionIds: number[]) {
|
|
||||||
const existing = await prisma.rolePermission.findMany({
|
|
||||||
where: { roleId },
|
|
||||||
select: { permissionId: true },
|
|
||||||
});
|
|
||||||
const existingIds = new Set(existing.map((e) => e.permissionId));
|
|
||||||
const targetIds = new Set(permissionIds);
|
|
||||||
|
|
||||||
// Add missing permissions
|
|
||||||
const missing = permissionIds.filter((id) => !existingIds.has(id));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
await prisma.rolePermission.createMany({
|
|
||||||
data: missing.map((permissionId) => ({ roleId, permissionId })),
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
console.log(` → ${missing.length} Permissions hinzugefügt für Rolle #${roleId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove excess permissions
|
|
||||||
const excess = existing.filter((e) => !targetIds.has(e.permissionId)).map((e) => e.permissionId);
|
|
||||||
if (excess.length > 0) {
|
|
||||||
await prisma.rolePermission.deleteMany({
|
|
||||||
where: { roleId, permissionId: { in: excess } },
|
|
||||||
});
|
|
||||||
console.log(` → ${excess.length} Permissions entfernt für Rolle #${roleId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create roles
|
|
||||||
// Admin - all permissions EXCEPT developer:access and audit/gdpr (controlled separately via checkboxes)
|
|
||||||
const adminPermissions = allPermissions.filter(
|
|
||||||
(p) =>
|
|
||||||
!(p.resource === 'developer' && p.action === 'access') &&
|
|
||||||
p.resource !== 'audit' &&
|
|
||||||
p.resource !== 'gdpr'
|
|
||||||
);
|
|
||||||
const adminRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'Admin' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'Admin',
|
|
||||||
description: 'Voller Zugriff auf alle Funktionen',
|
|
||||||
permissions: {
|
|
||||||
create: adminPermissions.map((p) => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(adminRole.id, adminPermissions.map((p) => p.id));
|
|
||||||
|
|
||||||
// Developer - ALL permissions (developer:access + alles andere)
|
|
||||||
const developerPermissions = allPermissions;
|
|
||||||
const developerRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'Developer' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'Developer',
|
|
||||||
description: 'Voller Zugriff inkl. Entwickler-Tools',
|
|
||||||
permissions: {
|
|
||||||
create: developerPermissions.map((p) => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(developerRole.id, developerPermissions.map((p) => p.id));
|
|
||||||
|
|
||||||
// DSGVO - audit and gdpr permissions (hidden role, controlled via hasGdprAccess)
|
|
||||||
const gdprPermissions = allPermissions.filter(
|
|
||||||
(p) => p.resource === 'audit' || p.resource === 'gdpr'
|
|
||||||
);
|
|
||||||
const gdprRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'DSGVO' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'DSGVO',
|
|
||||||
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
|
|
||||||
permissions: {
|
|
||||||
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(gdprRole.id, gdprPermissions.map((p) => p.id));
|
|
||||||
|
|
||||||
// Employee - full access to customers, contracts, read access to lookup tables
|
|
||||||
const employeePermIds = allPermissions
|
|
||||||
.filter(
|
|
||||||
(p) =>
|
|
||||||
p.resource === 'customers' ||
|
|
||||||
p.resource === 'contracts' ||
|
|
||||||
// Read-only Zugriff auf Stammdaten und Konfiguration
|
|
||||||
(p.action === 'read' && [
|
|
||||||
'platforms',
|
|
||||||
'providers',
|
|
||||||
'tariffs',
|
|
||||||
'cancellation-periods',
|
|
||||||
'contract-durations',
|
|
||||||
'contract-categories',
|
|
||||||
].includes(p.resource))
|
|
||||||
)
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
const employeeRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'Mitarbeiter' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'Mitarbeiter',
|
|
||||||
description: 'Kann Kunden und Verträge verwalten',
|
|
||||||
permissions: {
|
|
||||||
create: employeePermIds.map((id) => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(employeeRole.id, employeePermIds);
|
|
||||||
|
|
||||||
// Read-only employee - read access to main entities and lookup tables
|
|
||||||
const readOnlyResources = [
|
|
||||||
'customers',
|
|
||||||
'contracts',
|
|
||||||
'platforms',
|
|
||||||
'providers',
|
|
||||||
'tariffs',
|
|
||||||
'cancellation-periods',
|
|
||||||
'contract-durations',
|
|
||||||
'contract-categories',
|
|
||||||
];
|
|
||||||
const readOnlyPermIds = allPermissions
|
|
||||||
.filter((p) => p.action === 'read' && readOnlyResources.includes(p.resource))
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
const readOnlyRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'Mitarbeiter (Nur-Lesen)' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'Mitarbeiter (Nur-Lesen)',
|
|
||||||
description: 'Kann nur lesen, keine Änderungen',
|
|
||||||
permissions: {
|
|
||||||
create: readOnlyPermIds.map((id) => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(readOnlyRole.id, readOnlyPermIds);
|
|
||||||
|
|
||||||
// Customer role - read own data only (handled in middleware)
|
|
||||||
const customerRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'Kunde' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'Kunde',
|
|
||||||
description: 'Kann nur eigene Daten lesen',
|
|
||||||
permissions: {
|
|
||||||
create: readOnlyPermIds.map((id) => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(customerRole.id, readOnlyPermIds);
|
|
||||||
|
|
||||||
console.log('Roles created');
|
|
||||||
|
|
||||||
// Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
|
// Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
|
||||||
// landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen-
|
// landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen-
|
||||||
@@ -265,8 +68,19 @@ async function main() {
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
firstName: 'Admin',
|
firstName: 'Admin',
|
||||||
lastName: 'User',
|
lastName: 'User',
|
||||||
|
// Zusaetzlich die DSGVO-Rolle (Pentest R189-01).
|
||||||
|
//
|
||||||
|
// Ohne sie kann nach einem frischen Seed NIEMAND eine Auskunft nach
|
||||||
|
// Art. 15 oder eine Loeschung nach Art. 17 ausfuehren - die Rechte
|
||||||
|
// haengen an DSGVO und Developer, und beide waren keinem Konto
|
||||||
|
// zugewiesen. Ein Ausfall mit Fristwirkung, ausgeloest durch nichts
|
||||||
|
// weiter als eine Neuinstallation.
|
||||||
|
//
|
||||||
|
// Die Trennung bleibt: Die Admin-ROLLE bekommt diese Rechte weiterhin
|
||||||
|
// nicht. Nur dieses eine Bootstrap-Konto traegt beide, damit ueberhaupt
|
||||||
|
// jemand handlungsfaehig ist.
|
||||||
roles: {
|
roles: {
|
||||||
create: [{ roleId: adminRole.id }],
|
create: [{ roleId: adminRole.id }, { roleId: gdprRole.id }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-167
@@ -1,179 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* Idempotenter Permissions+Rollen-Sync für den Container-Start.
|
* Bringt Rechtekatalog und Systemrollen beim Container-Start auf Stand.
|
||||||
*
|
*
|
||||||
* Hintergrund: seed.ts läuft nur auf leeren DBs (USER_COUNT=0). Wer das
|
* Hintergrund: seed.ts laeuft nur auf leeren Datenbanken (USER_COUNT=0). Wer
|
||||||
* System schon installiert hat, bekommt nachträglich hinzugefügte
|
* das System schon installiert hat, bekommt nachtraeglich hinzugefuegte
|
||||||
* Permissions oder neue Rollenzuordnungen NICHT — die DSGVO-Rolle kann
|
* Rechte oder geaenderte Rollenzuordnungen sonst NICHT.
|
||||||
* dann z.B. ohne audit:read landen, obwohl Settings.tsx das voraussetzt.
|
|
||||||
*
|
*
|
||||||
* Dieses Skript synchronisiert ausschließlich:
|
* Die Definition selbst steht in `src/config/rechte-katalog.ts`, die Logik in
|
||||||
* - Permission-Katalog (resource/action-Paare aus dem Code)
|
* `src/services/rollen-sync.service.ts`. Dieses Skript ist nur noch der
|
||||||
* - Roll-Zuordnungen (Admin, Developer, DSGVO, Mitarbeiter,
|
* Einstiegspunkt fuer die Kommandozeile - bis 09/2026 trug es eine eigene
|
||||||
* Mitarbeiter (Nur-Lesen), Kunde)
|
* Kopie des Katalogs, und es war nicht die einzige.
|
||||||
*
|
*
|
||||||
* KEINE Stammdaten, KEINE User, KEINE Verträge — das Skript ist auf
|
* KEINE Stammdaten, KEINE Benutzer, KEINE Vertraege - auf laufenden
|
||||||
* laufenden Prod-DBs sicher.
|
* Produktionsdatenbanken sicher.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
// Ueber src/lib/prisma.js, NICHT ueber einen eigenen PrismaClient: Dort wird
|
||||||
|
// DATABASE_URL aus den DB_*-Teilen zusammengesetzt, falls sie fehlt. Bei
|
||||||
|
// `docker compose exec <dienst> npx tsx prisma/<skript>.ts` ist genau das der
|
||||||
|
// Fall - der Entrypoint exportiert sie nur in den Serverprozess, eine neue
|
||||||
|
// Shell erbt sie nicht. Ein eigener Client scheitert dort mit
|
||||||
|
// "Environment variable not found: DATABASE_URL".
|
||||||
|
import prisma from '../src/lib/prisma.js';
|
||||||
|
import { synchronisiereRechteUndRollen } from '../src/services/rollen-sync.service.js';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
const RESOURCE_PERMISSIONS: Record<string, string[]> = {
|
synchronisiereRechteUndRollen(prisma)
|
||||||
customers: ['create', 'read', 'update', 'delete'],
|
|
||||||
contracts: ['create', 'read', 'update', 'delete'],
|
|
||||||
users: ['create', 'read', 'update', 'delete'],
|
|
||||||
platforms: ['create', 'read', 'update', 'delete'],
|
|
||||||
providers: ['create', 'read', 'update', 'delete'],
|
|
||||||
tariffs: ['create', 'read', 'update', 'delete'],
|
|
||||||
'cancellation-periods': ['create', 'read', 'update', 'delete'],
|
|
||||||
'contract-durations': ['create', 'read', 'update', 'delete'],
|
|
||||||
'contract-categories': ['create', 'read', 'update', 'delete'],
|
|
||||||
'email-providers': ['create', 'read', 'update', 'delete'],
|
|
||||||
settings: ['read', 'update'],
|
|
||||||
developer: ['access'],
|
|
||||||
emails: ['delete'],
|
|
||||||
audit: ['read', 'export', 'admin'],
|
|
||||||
gdpr: ['export', 'delete', 'admin'],
|
|
||||||
};
|
|
||||||
|
|
||||||
async function syncRolePermissions(roleId: number, permissionIds: number[]) {
|
|
||||||
const existing = await prisma.rolePermission.findMany({
|
|
||||||
where: { roleId },
|
|
||||||
select: { permissionId: true },
|
|
||||||
});
|
|
||||||
const existingIds = new Set(existing.map((e) => e.permissionId));
|
|
||||||
const targetIds = new Set(permissionIds);
|
|
||||||
|
|
||||||
const missing = permissionIds.filter((id) => !existingIds.has(id));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
await prisma.rolePermission.createMany({
|
|
||||||
data: missing.map((permissionId) => ({ roleId, permissionId })),
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
console.log(` → +${missing.length} Permissions an Rolle #${roleId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const excess = existing
|
|
||||||
.filter((e) => !targetIds.has(e.permissionId))
|
|
||||||
.map((e) => e.permissionId);
|
|
||||||
if (excess.length > 0) {
|
|
||||||
await prisma.rolePermission.deleteMany({
|
|
||||||
where: { roleId, permissionId: { in: excess } },
|
|
||||||
});
|
|
||||||
console.log(` → -${excess.length} Permissions von Rolle #${roleId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('[sync-roles] Permissions-Katalog upserten…');
|
|
||||||
for (const [resource, actions] of Object.entries(RESOURCE_PERMISSIONS)) {
|
|
||||||
for (const action of actions) {
|
|
||||||
await prisma.permission.upsert({
|
|
||||||
where: { resource_action: { resource, action } },
|
|
||||||
update: {},
|
|
||||||
create: { resource, action },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const allPermissions = await prisma.permission.findMany();
|
|
||||||
console.log(`[sync-roles] ${allPermissions.length} Permissions vorhanden`);
|
|
||||||
|
|
||||||
// Admin: alles AUSSER developer:access und audit/gdpr (DSGVO + Developer
|
|
||||||
// sind separate hidden roles, über Checkboxen zugewiesen)
|
|
||||||
const adminPermIds = allPermissions
|
|
||||||
.filter(
|
|
||||||
(p) =>
|
|
||||||
!(p.resource === 'developer' && p.action === 'access') &&
|
|
||||||
p.resource !== 'audit' &&
|
|
||||||
p.resource !== 'gdpr'
|
|
||||||
)
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
// Developer: alles
|
|
||||||
const developerPermIds = allPermissions.map((p) => p.id);
|
|
||||||
|
|
||||||
// DSGVO: audit + gdpr komplett
|
|
||||||
const gdprPermIds = allPermissions
|
|
||||||
.filter((p) => p.resource === 'audit' || p.resource === 'gdpr')
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
// Gegenbuch: NUR audit:read.
|
|
||||||
//
|
|
||||||
// Der externe Notar ruft genau zwei Endpunkte auf, /audit-logs/checkpoint
|
|
||||||
// und /audit-logs/verify, und beide verlangen audit:read. Bis hierher gab es
|
|
||||||
// dafuer keine passende Rolle: Wer dem Dienstkonto Leserechte aufs Protokoll
|
|
||||||
// geben wollte, musste den DSGVO-Haken setzen - und der vergibt `audit:*`
|
|
||||||
// KOMPLETT, also auch `audit:admin` mit seal-backlog, rehash und cleanup.
|
|
||||||
//
|
|
||||||
// Damit haette ein Einbruch auf der Gegenbuch-Maschine nicht nur den
|
|
||||||
// Waechter gehabt, sondern gleich die Mittel, das Bewachte umzuschreiben -
|
|
||||||
// genau die Waesche aus R185-01, und genau die Trennung, wegen der das
|
|
||||||
// Gegenbuch ueberhaupt auf einer eigenen Maschine laeuft. Das Kennwort des
|
|
||||||
// Dienstkontos liegt dort im Klartext in der .env; es muss deshalb so wenig
|
|
||||||
// wert sein wie moeglich.
|
|
||||||
const gegenbuchPermIds = allPermissions
|
|
||||||
.filter((p) => p.resource === 'audit' && p.action === 'read')
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
// Mitarbeiter: customers + contracts + read auf Stammdaten
|
|
||||||
const employeePermIds = allPermissions
|
|
||||||
.filter(
|
|
||||||
(p) =>
|
|
||||||
p.resource === 'customers' ||
|
|
||||||
p.resource === 'contracts' ||
|
|
||||||
(p.action === 'read' &&
|
|
||||||
[
|
|
||||||
'platforms',
|
|
||||||
'providers',
|
|
||||||
'tariffs',
|
|
||||||
'cancellation-periods',
|
|
||||||
'contract-durations',
|
|
||||||
'contract-categories',
|
|
||||||
].includes(p.resource))
|
|
||||||
)
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
// Read-only Mitarbeiter + Kunde: nur read auf Haupt-Entities + Stammdaten
|
|
||||||
const readOnlyResources = [
|
|
||||||
'customers',
|
|
||||||
'contracts',
|
|
||||||
'platforms',
|
|
||||||
'providers',
|
|
||||||
'tariffs',
|
|
||||||
'cancellation-periods',
|
|
||||||
'contract-durations',
|
|
||||||
'contract-categories',
|
|
||||||
];
|
|
||||||
const readOnlyPermIds = allPermissions
|
|
||||||
.filter((p) => p.action === 'read' && readOnlyResources.includes(p.resource))
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
|
|
||||||
{ name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds },
|
|
||||||
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
|
|
||||||
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung', permIds: gdprPermIds },
|
|
||||||
{ name: 'Mitarbeiter', description: 'Kann Kunden und Verträge verwalten', permIds: employeePermIds },
|
|
||||||
{ name: 'Mitarbeiter (Nur-Lesen)', description: 'Kann nur lesen, keine Änderungen', permIds: readOnlyPermIds },
|
|
||||||
{ name: 'Gegenbuch', description: 'Darf das Audit-Protokoll nur lesen und prüfen – sonst nichts', permIds: gegenbuchPermIds },
|
|
||||||
{ name: 'Kunde', description: 'Kann nur eigene Daten lesen', permIds: readOnlyPermIds },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const r of rolesSpec) {
|
|
||||||
const role = await prisma.role.upsert({
|
|
||||||
where: { name: r.name },
|
|
||||||
update: { description: r.description },
|
|
||||||
create: { name: r.name, description: r.description },
|
|
||||||
});
|
|
||||||
await syncRolePermissions(role.id, r.permIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[sync-roles] fertig.');
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error('[sync-roles] Fehler:', e);
|
console.error('[rollen-sync] Fehler:', e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
})
|
})
|
||||||
.finally(async () => {
|
.finally(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
/**
|
||||||
|
* Der Rechte- und Rollenkatalog. Eine Quelle, drei Verbraucher.
|
||||||
|
*
|
||||||
|
* Bis hierher stand derselbe Katalog dreimal im Code: in `prisma/seed.ts`,
|
||||||
|
* in `prisma/sync-roles.ts` und - abweichend - in `factoryReset`
|
||||||
|
* (`services/backup.service.ts`). Die dritte Kopie kannte weder `audit:*`
|
||||||
|
* noch `gdpr:*` und legte die Rollen DSGVO, Audit-Betrieb und Gegenbuch gar
|
||||||
|
* nicht erst an: Nach einem Werksreset konnte niemand mehr eine Auskunft
|
||||||
|
* nach Art. 15 DSGVO ausfuehren, und gemerkt haette man es erst, wenn eine
|
||||||
|
* Frist laeuft.
|
||||||
|
*
|
||||||
|
* Drei Beschreibungen desselben Sachverhalts sind zwei zu viel. Welche davon
|
||||||
|
* gilt, entschied bisher die Reihenfolge im Container-Start.
|
||||||
|
*
|
||||||
|
* Dieses Modul liegt bewusst unter `src/` und nicht unter `prisma/`: Es wird
|
||||||
|
* sowohl vom kompilierten Backend (`dist/`) als auch von den CLI-Skripten
|
||||||
|
* unter `prisma/` gebraucht, die per `tsx` laufen. Das Dockerfile kopiert
|
||||||
|
* `src/` zusaetzlich ins Runtime-Image, genau dafuer.
|
||||||
|
*
|
||||||
|
* KEIN Prisma-Import hier - reine Daten. Die Aufloesung gegen die Datenbank
|
||||||
|
* macht `services/rollen-sync.service.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RechtDefinition {
|
||||||
|
resource: string;
|
||||||
|
action: string;
|
||||||
|
/** Klartext fuer die Oberflaeche. "cancellation-periods:update" sagt einem
|
||||||
|
* Sachbearbeiter nichts. */
|
||||||
|
bezeichnung: string;
|
||||||
|
/** Ueberschrift, unter der das Recht in der Rechtematrix steht. */
|
||||||
|
gruppe: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GRUPPE = {
|
||||||
|
KUNDEN: 'Kunden',
|
||||||
|
VERTRAEGE: 'Verträge',
|
||||||
|
EMAILS: 'E-Mails',
|
||||||
|
STAMMDATEN: 'Stammdaten',
|
||||||
|
EINSTELLUNGEN: 'Einstellungen',
|
||||||
|
BENUTZER: 'Benutzer und Rechte',
|
||||||
|
AUFSICHT: 'Aufsicht und Datenschutz',
|
||||||
|
SYSTEM: 'System',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der vollstaendige Katalog. Was hier nicht steht, existiert nicht.
|
||||||
|
*
|
||||||
|
* Bis 09/2026 standen hier 18 Rechte, die nichts bewachten: `tariffs:*`,
|
||||||
|
* `cancellation-periods:*`, `contract-durations:*` und `email-providers:*`
|
||||||
|
* waren anhakbar, aber die Routen prueften in Wahrheit `providers:*`,
|
||||||
|
* `platforms:*` und `settings:*`. Ein Haken, der nichts tut, ist schlimmer
|
||||||
|
* als ein fehlender: Er behauptet eine Trennung, die es nicht gibt.
|
||||||
|
* Seit dem granularen Gaten stimmt jede Zeile hier mit mindestens einer
|
||||||
|
* Route ueberein.
|
||||||
|
*/
|
||||||
|
export const RECHTE_KATALOG: RechtDefinition[] = [
|
||||||
|
// ---- Kunden -------------------------------------------------------------
|
||||||
|
{ resource: 'customers', action: 'create', gruppe: GRUPPE.KUNDEN, bezeichnung: 'Kunden anlegen' },
|
||||||
|
{ resource: 'customers', action: 'read', gruppe: GRUPPE.KUNDEN, bezeichnung: 'Kunden sehen' },
|
||||||
|
{ resource: 'customers', action: 'update', gruppe: GRUPPE.KUNDEN, bezeichnung: 'Kunden bearbeiten' },
|
||||||
|
{ resource: 'customers', action: 'delete', gruppe: GRUPPE.KUNDEN, bezeichnung: 'Kunden löschen' },
|
||||||
|
|
||||||
|
// ---- Verträge -----------------------------------------------------------
|
||||||
|
{ resource: 'contracts', action: 'create', gruppe: GRUPPE.VERTRAEGE, bezeichnung: 'Verträge anlegen' },
|
||||||
|
{ resource: 'contracts', action: 'read', gruppe: GRUPPE.VERTRAEGE, bezeichnung: 'Verträge sehen' },
|
||||||
|
{ resource: 'contracts', action: 'update', gruppe: GRUPPE.VERTRAEGE, bezeichnung: 'Verträge bearbeiten' },
|
||||||
|
{ resource: 'contracts', action: 'delete', gruppe: GRUPPE.VERTRAEGE, bezeichnung: 'Verträge löschen' },
|
||||||
|
|
||||||
|
// ---- E-Mails ------------------------------------------------------------
|
||||||
|
{ resource: 'emails', action: 'delete', gruppe: GRUPPE.EMAILS, bezeichnung: 'E-Mails löschen' },
|
||||||
|
|
||||||
|
// ---- Stammdaten ---------------------------------------------------------
|
||||||
|
{ resource: 'platforms', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertriebsplattformen anlegen' },
|
||||||
|
{ resource: 'platforms', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertriebsplattformen sehen' },
|
||||||
|
{ resource: 'platforms', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertriebsplattformen bearbeiten' },
|
||||||
|
{ resource: 'platforms', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertriebsplattformen löschen' },
|
||||||
|
|
||||||
|
{ resource: 'providers', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Anbieter anlegen' },
|
||||||
|
{ resource: 'providers', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Anbieter sehen' },
|
||||||
|
{ resource: 'providers', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Anbieter bearbeiten' },
|
||||||
|
{ resource: 'providers', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Anbieter löschen' },
|
||||||
|
|
||||||
|
{ resource: 'tariffs', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Tarife anlegen' },
|
||||||
|
{ resource: 'tariffs', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Tarife sehen' },
|
||||||
|
{ resource: 'tariffs', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Tarife bearbeiten' },
|
||||||
|
{ resource: 'tariffs', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Tarife löschen' },
|
||||||
|
|
||||||
|
{ resource: 'cancellation-periods', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Kündigungsfristen anlegen' },
|
||||||
|
{ resource: 'cancellation-periods', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Kündigungsfristen sehen' },
|
||||||
|
{ resource: 'cancellation-periods', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Kündigungsfristen bearbeiten' },
|
||||||
|
{ resource: 'cancellation-periods', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Kündigungsfristen löschen' },
|
||||||
|
|
||||||
|
{ resource: 'contract-durations', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragslaufzeiten anlegen' },
|
||||||
|
{ resource: 'contract-durations', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragslaufzeiten sehen' },
|
||||||
|
{ resource: 'contract-durations', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragslaufzeiten bearbeiten' },
|
||||||
|
{ resource: 'contract-durations', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragslaufzeiten löschen' },
|
||||||
|
|
||||||
|
{ resource: 'contract-categories', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragstypen anlegen' },
|
||||||
|
{ resource: 'contract-categories', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragstypen sehen' },
|
||||||
|
{ resource: 'contract-categories', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragstypen bearbeiten' },
|
||||||
|
{ resource: 'contract-categories', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragstypen löschen' },
|
||||||
|
|
||||||
|
// ---- Einstellungen ------------------------------------------------------
|
||||||
|
{ resource: 'settings', action: 'read', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'Einstellungen sehen' },
|
||||||
|
{ resource: 'settings', action: 'update', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'Einstellungen ändern' },
|
||||||
|
|
||||||
|
{ resource: 'email-providers', action: 'create', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'E-Mail-Provider anlegen' },
|
||||||
|
{ resource: 'email-providers', action: 'read', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'E-Mail-Provider sehen' },
|
||||||
|
{ resource: 'email-providers', action: 'update', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'E-Mail-Provider bearbeiten' },
|
||||||
|
{ resource: 'email-providers', action: 'delete', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'E-Mail-Provider löschen' },
|
||||||
|
|
||||||
|
// ---- Benutzer und Rechte ------------------------------------------------
|
||||||
|
{ resource: 'users', action: 'create', gruppe: GRUPPE.BENUTZER, bezeichnung: 'Benutzer anlegen' },
|
||||||
|
{ resource: 'users', action: 'read', gruppe: GRUPPE.BENUTZER, bezeichnung: 'Benutzer sehen' },
|
||||||
|
{ resource: 'users', action: 'update', gruppe: GRUPPE.BENUTZER, bezeichnung: 'Benutzer bearbeiten' },
|
||||||
|
{ resource: 'users', action: 'delete', gruppe: GRUPPE.BENUTZER, bezeichnung: 'Benutzer löschen' },
|
||||||
|
{
|
||||||
|
resource: 'roles',
|
||||||
|
action: 'manage',
|
||||||
|
gruppe: GRUPPE.BENUTZER,
|
||||||
|
// Getrennt von `users:*`, weil "Konten anlegen" und "festlegen, was ein
|
||||||
|
// Konto darf" zwei verschiedene Befugnisse sind (Pentest, Rollenmodell).
|
||||||
|
bezeichnung: 'Rollen und Rechte verwalten',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Aufsicht und Datenschutz -------------------------------------------
|
||||||
|
{ resource: 'audit', action: 'read', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Audit-Protokoll lesen' },
|
||||||
|
{ resource: 'audit', action: 'export', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Audit-Protokoll exportieren' },
|
||||||
|
{ resource: 'audit', action: 'admin', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Audit-Protokoll versiegeln, aufräumen, Aufbewahrung ändern' },
|
||||||
|
|
||||||
|
{ resource: 'gdpr', action: 'export', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Auskunft nach Art. 15 DSGVO erteilen' },
|
||||||
|
{ resource: 'gdpr', action: 'delete', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Löschung nach Art. 17 DSGVO ausführen' },
|
||||||
|
{ resource: 'gdpr', action: 'admin', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Datenschutz-Verwaltung' },
|
||||||
|
|
||||||
|
// ---- System -------------------------------------------------------------
|
||||||
|
{ resource: 'developer', action: 'access', gruppe: GRUPPE.SYSTEM, bezeichnung: 'Entwicklerwerkzeuge und Datenbankzugriff' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** `resource:action` in der Schreibweise, die `requirePermission` erwartet. */
|
||||||
|
export function alsRechtString(r: { resource: string; action: string }): string {
|
||||||
|
return `${r.resource}:${r.action}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALLE_RECHTE: string[] = RECHTE_KATALOG.map(alsRechtString);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rollen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Namen der von der Anwendung gepflegten Rollen. Sie werden an mehreren
|
||||||
|
* Stellen als Schluessel benutzt (Checkbox-Zuweisung, Notfallpfade in
|
||||||
|
* `user.service.ts`, Anzeigefilter im Frontend). Ab der Systemrollen-Sperre
|
||||||
|
* sind sie stabil: Eine Rolle mit `isSystem` laesst sich nicht mehr
|
||||||
|
* umbenennen, deshalb traegt der Name jetzt, was er vorher nur zu tragen
|
||||||
|
* schien.
|
||||||
|
*/
|
||||||
|
export const ROLLE_ADMIN = 'Admin';
|
||||||
|
export const ROLLE_DEVELOPER = 'Developer';
|
||||||
|
export const ROLLE_DSGVO = 'DSGVO';
|
||||||
|
export const ROLLE_AUDIT_BETRIEB = 'Audit-Betrieb';
|
||||||
|
export const ROLLE_GEGENBUCH = 'Gegenbuch';
|
||||||
|
export const ROLLE_MITARBEITER = 'Mitarbeiter';
|
||||||
|
export const ROLLE_MITARBEITER_LESEND = 'Mitarbeiter (Nur-Lesen)';
|
||||||
|
export const ROLLE_KUNDE = 'Kunde';
|
||||||
|
|
||||||
|
export interface SystemrollenSpec {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
/**
|
||||||
|
* Nicht in der normalen Rollenauswahl anbieten. Diese Rollen werden ueber
|
||||||
|
* die Haken im Benutzerformular vergeben (DSGVO, Entwicklerzugriff,
|
||||||
|
* Audit-Betrieb) oder gehoeren zu einem technischen Konto.
|
||||||
|
*/
|
||||||
|
isHidden: boolean;
|
||||||
|
/** Praedikat ueber den Katalog. */
|
||||||
|
rechte: (r: RechtDefinition) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stammdaten, die ein Sachbearbeiter zum Arbeiten lesen koennen muss. */
|
||||||
|
const LESE_STAMMDATEN = [
|
||||||
|
'platforms',
|
||||||
|
'providers',
|
||||||
|
'tariffs',
|
||||||
|
'cancellation-periods',
|
||||||
|
'contract-durations',
|
||||||
|
'contract-categories',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SYSTEMROLLEN: SystemrollenSpec[] = [
|
||||||
|
{
|
||||||
|
name: ROLLE_ADMIN,
|
||||||
|
description:
|
||||||
|
'Voller Zugriff auf alle Fachfunktionen (ohne Audit & Datenschutz – dafür die separaten Rollen DSGVO und Audit-Betrieb)',
|
||||||
|
isHidden: false,
|
||||||
|
// Alles ausser den Entwicklerwerkzeugen und der Aufsicht. Die Trennung
|
||||||
|
// ist Absicht: Wer verwaltet, beaufsichtigt sich nicht selbst.
|
||||||
|
rechte: (r) =>
|
||||||
|
r.resource !== 'developer' && r.resource !== 'audit' && r.resource !== 'gdpr',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: ROLLE_DEVELOPER,
|
||||||
|
description: 'Voller Zugriff inkl. Entwickler-Tools',
|
||||||
|
isHidden: true,
|
||||||
|
rechte: () => true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: ROLLE_DSGVO,
|
||||||
|
description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung',
|
||||||
|
isHidden: true,
|
||||||
|
// Bewusst OHNE `audit:admin`. Diese Rolle beaufsichtigt das Protokoll -
|
||||||
|
// sie darf es nicht umschreiben koennen. Mit `audit:admin` haette ein
|
||||||
|
// DSGVO-Beauftragter seal-backlog, rehash und cleanup, also die Mittel,
|
||||||
|
// seine eigene Beweisgrundlage zu ersetzen (Pentest R186).
|
||||||
|
rechte: (r) =>
|
||||||
|
r.resource === 'gdpr' ||
|
||||||
|
(r.resource === 'audit' && (r.action === 'read' || r.action === 'export')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: ROLLE_AUDIT_BETRIEB,
|
||||||
|
description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern',
|
||||||
|
isHidden: true,
|
||||||
|
// Die eingreifenden Rechte am Protokoll, plus `audit:read` - siegeln zu
|
||||||
|
// duerfen, ohne das Ergebnis sehen zu koennen, waere unbrauchbar.
|
||||||
|
rechte: (r) => r.resource === 'audit' && (r.action === 'read' || r.action === 'admin'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: ROLLE_GEGENBUCH,
|
||||||
|
description: 'Darf das Audit-Protokoll nur lesen und prüfen – sonst nichts',
|
||||||
|
isHidden: true,
|
||||||
|
// Der externe Notar ruft genau zwei Endpunkte auf, /audit-logs/checkpoint
|
||||||
|
// und /verify, und beide verlangen `audit:read`. Das Kennwort des
|
||||||
|
// Dienstkontos liegt auf der Gegenbuch-Maschine im Klartext in der .env;
|
||||||
|
// es muss deshalb so wenig wert sein wie moeglich (Pentest R185-01).
|
||||||
|
rechte: (r) => r.resource === 'audit' && r.action === 'read',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: ROLLE_MITARBEITER,
|
||||||
|
description: 'Kann Kunden und Verträge verwalten',
|
||||||
|
isHidden: false,
|
||||||
|
rechte: (r) =>
|
||||||
|
r.resource === 'customers' ||
|
||||||
|
r.resource === 'contracts' ||
|
||||||
|
(r.action === 'read' && LESE_STAMMDATEN.includes(r.resource)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: ROLLE_MITARBEITER_LESEND,
|
||||||
|
description: 'Kann nur lesen, keine Änderungen',
|
||||||
|
isHidden: false,
|
||||||
|
rechte: (r) =>
|
||||||
|
r.action === 'read' &&
|
||||||
|
(r.resource === 'customers' ||
|
||||||
|
r.resource === 'contracts' ||
|
||||||
|
LESE_STAMMDATEN.includes(r.resource)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: ROLLE_KUNDE,
|
||||||
|
description: 'Kann nur eigene Daten lesen',
|
||||||
|
isHidden: true,
|
||||||
|
// Hinweis: Portal-Kunden bekommen ihre Rechte NICHT aus dieser Rolle,
|
||||||
|
// sondern aus einem festen Array in `auth.service.ts`. Die Rolle existiert
|
||||||
|
// fuer CRM-seitig angelegte Kundenkonten.
|
||||||
|
rechte: (r) =>
|
||||||
|
r.action === 'read' &&
|
||||||
|
(r.resource === 'customers' ||
|
||||||
|
r.resource === 'contracts' ||
|
||||||
|
LESE_STAMMDATEN.includes(r.resource)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SYSTEMROLLEN_NAMEN: string[] = SYSTEMROLLEN.map((r) => r.name);
|
||||||
|
|
||||||
|
/** Die ueber Haken im Benutzerformular vergebenen Rollen. */
|
||||||
|
export const VERSTECKTE_ROLLEN_NAMEN: string[] = SYSTEMROLLEN.filter((r) => r.isHidden).map(
|
||||||
|
(r) => r.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Rechte einer Systemrolle als `resource:action`-Liste. */
|
||||||
|
export function rechteDerSystemrolle(name: string): string[] {
|
||||||
|
const spec = SYSTEMROLLEN.find((r) => r.name === name);
|
||||||
|
if (!spec) return [];
|
||||||
|
return RECHTE_KATALOG.filter(spec.rechte).map(alsRechtString);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vergleicht Rollennamen so, wie die Sperre sie vergleichen muss: getrimmt
|
||||||
|
* und ohne Ruecksicht auf Gross-/Kleinschreibung. Sonst liesse sich eine
|
||||||
|
* zweite Rolle " admin " anlegen, die in Listen wie die echte aussieht.
|
||||||
|
*/
|
||||||
|
export function istSystemrollenName(name: string): boolean {
|
||||||
|
const normalisiert = name.trim().toLocaleLowerCase('de-DE');
|
||||||
|
return SYSTEMROLLEN_NAMEN.some((n) => n.toLocaleLowerCase('de-DE') === normalisiert);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Kundenportal - bewusst NICHT Teil des Rollensystems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Rechte eines angemeldeten Portal-Kunden. Fest, kurz, abschliessend.
|
||||||
|
*
|
||||||
|
* DIESE TRENNUNG IST ABSICHT UND SOLL BLEIBEN.
|
||||||
|
*
|
||||||
|
* Kunden im Portal ziehen ihre Rechte NICHT aus Rollen, sondern aus dieser
|
||||||
|
* Liste. Das ist kein Altbestand, den man noch migrieren muesste - es ist
|
||||||
|
* die Sicherung dagegen, dass eine Aenderung am Rollenmodell versehentlich
|
||||||
|
* auf die Kundenseite durchschlaegt. Ein Kunde, der ueber eine Rolle
|
||||||
|
* ploetzlich `contracts:create` haelt, koennte in fremdem Namen Vertraege
|
||||||
|
* anlegen; die Portalansicht ist fuer so etwas nicht gebaut und pruefte es
|
||||||
|
* nicht.
|
||||||
|
*
|
||||||
|
* Wer hier etwas hinzufuegen will, aendert nicht eine Liste, sondern eine
|
||||||
|
* Vertrauensgrenze. `pruefePortalRechte()` weist beim Start alles ab, was
|
||||||
|
* nicht Lesen ist - absichtlich als Alarm und nicht als stiller Default.
|
||||||
|
*
|
||||||
|
* Die Durchsetzung liegt zusaetzlich im Gate (`requirePermission`): Ein
|
||||||
|
* Portal-Zugang bekommt dort nur, was auch hier steht, unabhaengig davon,
|
||||||
|
* was in seinem Token behauptet wird.
|
||||||
|
*/
|
||||||
|
export const PORTAL_RECHTE: readonly string[] = [
|
||||||
|
'contracts:read', // eigene Vertraege lesen
|
||||||
|
'customers:read', // eigene Kundendaten lesen
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const PORTAL_ERLAUBTE_AKTIONEN = ['read'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wacht darueber, dass die Portal-Liste eine Leseliste bleibt.
|
||||||
|
*
|
||||||
|
* Wird beim Start aufgerufen. Ein schreibendes Recht hier waere kein
|
||||||
|
* Schoenheitsfehler, sondern eine stille Ausweitung der Kundenrechte - und
|
||||||
|
* genau die soll nicht durch eine unbedachte Zeile passieren koennen.
|
||||||
|
*/
|
||||||
|
export function pruefePortalRechte(): string[] {
|
||||||
|
const beanstandet: string[] = [];
|
||||||
|
for (const recht of PORTAL_RECHTE) {
|
||||||
|
const [, aktion] = recht.split(':');
|
||||||
|
if (!PORTAL_ERLAUBTE_AKTIONEN.includes(aktion)) {
|
||||||
|
beanstandet.push(recht);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return beanstandet;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as appSettingService from '../services/appSetting.service.js';
|
import * as appSettingService from '../services/appSetting.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
@@ -81,10 +82,7 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise<vo
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Einstellung gespeichert' } as ApiResponse);
|
res.json({ success: true, message: 'Einstellung gespeichert' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Speichern der Einstellung');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Einstellung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,9 +144,6 @@ export async function updateSettings(req: AuthRequest, res: Response): Promise<v
|
|||||||
|
|
||||||
res.json({ success: true, message: 'Einstellungen gespeichert' } as ApiResponse);
|
res.json({ success: true, message: 'Einstellungen gespeichert' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Speichern der Einstellungen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Einstellungen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
|
import { FachlicherFehler } from '../utils/apiError.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
import * as auditService from '../services/audit.service.js';
|
import * as auditService from '../services/audit.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
@@ -18,7 +20,11 @@ import { emit as emitSecurityEvent, contextFromRequest } from '../services/secur
|
|||||||
* sprengt die Query erst in der Datenbank) und fuer Zahlen (`parseInt('x')`
|
* sprengt die Query erst in der Datenbank) und fuer Zahlen (`parseInt('x')`
|
||||||
* ergibt NaN).
|
* ergibt NaN).
|
||||||
*/
|
*/
|
||||||
class FilterFehler extends Error {}
|
class FilterFehler extends FachlicherFehler {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const AUDIT_ACTIONS = Object.values(AuditAction) as string[];
|
const AUDIT_ACTIONS = Object.values(AuditAction) as string[];
|
||||||
const AUDIT_SENSITIVITIES = Object.values(AuditSensitivity) as string[];
|
const AUDIT_SENSITIVITIES = Object.values(AuditSensitivity) as string[];
|
||||||
@@ -262,6 +268,10 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
|||||||
? ' ⚠ Bestandssiegel ENTFERNT: Es liegen versiegelte Blattwerte vor, aber kein gültiger ' +
|
? ' ⚠ Bestandssiegel ENTFERNT: Es liegen versiegelte Blattwerte vor, aber kein gültiger ' +
|
||||||
'Siegel-Marker mehr. Der Marker wurde gelöscht oder unbrauchbar gemacht – Änderungen am ' +
|
'Siegel-Marker mehr. Der Marker wurde gelöscht oder unbrauchbar gemacht – Änderungen am ' +
|
||||||
'Altbestand wären dadurch wieder unsichtbar. Das ist KEIN Normalzustand.'
|
'Altbestand wären dadurch wieder unsichtbar. Das ist KEIN Normalzustand.'
|
||||||
|
: result.backlogSealStatus === 'leer'
|
||||||
|
? ' Hinweis: Es besteht ein Bestandssiegel, das aber NICHTS umschließt – ' +
|
||||||
|
'zum Zeitpunkt des Siegelns gab es keine unsignierten Alteinträge. Es sichert ' +
|
||||||
|
'also nichts ab. Das ist kein Fehler, aber auch keine Zusage.'
|
||||||
: result.backlogSealStatus === 'nicht_noetig'
|
: result.backlogSealStatus === 'nicht_noetig'
|
||||||
? ' Ein Bestandssiegel wird hier nicht gebraucht: Es gibt keine unsignierten Alteinträge.'
|
? ' Ein Bestandssiegel wird hier nicht gebraucht: Es gibt keine unsignierten Alteinträge.'
|
||||||
: result.backlogSealStatus === 'kein_siegel'
|
: result.backlogSealStatus === 'kein_siegel'
|
||||||
@@ -269,6 +279,49 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
|||||||
'Behebbar mit POST /api/audit-logs/seal-backlog {"confirm":"SEAL"}.'
|
'Behebbar mit POST /api/audit-logs/seal-backlog {"confirm":"SEAL"}.'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// Eine Neuberechnung der Kette gehoert IMMER erwaehnt – auch und gerade,
|
||||||
|
// wenn sonst alles grün ist. Nach einem Rehash ist die Kette
|
||||||
|
// zwangslaeufig stimmig, auch ueber Loeschungen hinweg. „Lueckenlos
|
||||||
|
// verkettet“ heisst dann nur noch „seit dem Rehash“, und wer das nicht
|
||||||
|
// mitliest, nimmt eine Entwarnung mit, die es so nicht gibt.
|
||||||
|
const rehashText = (() => {
|
||||||
|
if (result.rehashes.length === 0) return '';
|
||||||
|
const letzter = result.rehashes[result.rehashes.length - 1];
|
||||||
|
const datum = new Date(letzter.zeitpunkt).toLocaleString('de-DE', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
const wieOft =
|
||||||
|
result.rehashes.length === 1
|
||||||
|
? 'Die Kette wurde einmal neu berechnet'
|
||||||
|
: `Die Kette wurde ${result.rehashes.length}× neu berechnet, zuletzt`;
|
||||||
|
// Nur benennen, was es zu benennen gibt. Bei sauberem Vorzustand ist die
|
||||||
|
// Auskunft „nichts uebertuencht“ selbst eine nuetzliche Information –
|
||||||
|
// ein Rehash ueber einer unbeanstandeten Kette wiegt anders als einer
|
||||||
|
// ueber 700 Luecken.
|
||||||
|
const vb = letzter.vorbefund;
|
||||||
|
const uebertuencht = !vb
|
||||||
|
? ' Der Zustand vor dieser Neuberechnung ist nicht mehr feststellbar.'
|
||||||
|
: vb.manipuliert === 0 && vb.luecken === 0
|
||||||
|
? ' Die Kette war unmittelbar davor unbeanstandet – es wurde nichts überdeckt.'
|
||||||
|
: ' Unmittelbar davor: ' +
|
||||||
|
`${vb.manipuliert} beanstandete${vb.manipuliert === 1 ? 'r Eintrag' : ' Einträge'}` +
|
||||||
|
` und ${vb.luecken} Lücke${vb.luecken === 1 ? '' : 'n'}. ` +
|
||||||
|
'Diese Spuren sind seitdem nicht mehr in der Kette sichtbar, sondern nur noch im ' +
|
||||||
|
`Vorbefund des Rehash-Eintrags (id ${letzter.id}).`;
|
||||||
|
const ohneSignatur = result.rehashes.some((r) => !r.signiert)
|
||||||
|
? ' Achtung: Mindestens ein Rehash-Eintrag trägt keine gültige Signatur.'
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
` ⚠ ${wieOft} am ${datum}` +
|
||||||
|
(letzter.neuBerechnet !== null ? ` (${letzter.neuBerechnet} Einträge)` : '') +
|
||||||
|
'. Ein Rehash verknüpft alle Einträge neu – die Aussage dieser Prüfung ' +
|
||||||
|
'reicht deshalb nur bis dorthin zurück, nicht weiter.' +
|
||||||
|
uebertuencht +
|
||||||
|
ohneSignatur
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
// Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es
|
// Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es
|
||||||
// ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03).
|
// ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03).
|
||||||
const mehrfach =
|
const mehrfach =
|
||||||
@@ -299,7 +352,10 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
|||||||
: beglaubigt > 0
|
: beglaubigt > 0
|
||||||
? 'Alle Einträge sind unverändert. Seit dem Bestandssiegel ist keine neue Lücke ' +
|
? 'Alle Einträge sind unverändert. Seit dem Bestandssiegel ist keine neue Lücke ' +
|
||||||
'entstanden.'
|
'entstanden.'
|
||||||
: 'Alle Einträge sind unverändert und lückenlos verkettet.';
|
: result.rehashes.length > 0
|
||||||
|
? 'Alle Einträge sind unverändert und lückenlos verkettet – allerdings erst ' +
|
||||||
|
'seit der letzten Neuberechnung.'
|
||||||
|
: 'Alle Einträge sind unverändert und lückenlos verkettet.';
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -315,8 +371,10 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
|||||||
unexplainedGaps: result.unexplainedGaps,
|
unexplainedGaps: result.unexplainedGaps,
|
||||||
// Alt-Lücken, die das Bestandssiegel als bereits vorhanden beglaubigt.
|
// Alt-Lücken, die das Bestandssiegel als bereits vorhanden beglaubigt.
|
||||||
attestedGaps: result.attestedGaps,
|
attestedGaps: result.attestedGaps,
|
||||||
|
// Protokollierte Neuberechnungen – begrenzen die Reichweite der Aussage.
|
||||||
|
rehashes: result.rehashes,
|
||||||
tampered: tampered > 0,
|
tampered: tampered > 0,
|
||||||
message: message + beglaubigtText + keinSchluessel + siegel + mehrfach,
|
message: message + beglaubigtText + rehashText + keinSchluessel + siegel + mehrfach,
|
||||||
unverifiableEntries: result.unverifiableEntries,
|
unverifiableEntries: result.unverifiableEntries,
|
||||||
// Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
|
// Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
|
||||||
backlogSealStatus: result.backlogSealStatus,
|
backlogSealStatus: result.backlogSealStatus,
|
||||||
@@ -485,10 +543,7 @@ export async function sealBacklog(req: AuthRequest, res: Response) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Versiegeln des Altbestands:', error);
|
console.error('Fehler beim Versiegeln des Altbestands:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Versiegeln');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Versiegeln',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response, CookieOptions } from 'express';
|
import { Request, Response, CookieOptions } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as authService from '../services/auth.service.js';
|
import * as authService from '../services/auth.service.js';
|
||||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
@@ -281,10 +282,7 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
|
|||||||
ipAddress: ctx.ipAddress,
|
ipAddress: ctx.ipAddress,
|
||||||
endpoint: ctx.endpoint,
|
endpoint: ctx.endpoint,
|
||||||
});
|
});
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Passwort-Reset fehlgeschlagen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,10 +398,12 @@ export async function refresh(req: Request, res: Response): Promise<void> {
|
|||||||
endpoint: ctx.endpoint,
|
endpoint: ctx.endpoint,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.status(401).json({
|
// `msg` geht weiterhin in den Alarmkanal (oben), aber nicht mehr an den
|
||||||
success: false,
|
// Aufrufer: Bei einem abgelehnten Refresh stammt der Wortlaut aus der
|
||||||
error: msg,
|
// JWT-Bibliothek ("jwt malformed", "invalid signature") und sagt einem
|
||||||
} as ApiResponse);
|
// Angreifer, WORAN sein Token gescheitert ist. Fuer den berechtigten
|
||||||
|
// Nutzer aendert das nichts - er muss sich so oder so neu anmelden.
|
||||||
|
antworteAufFehler(res, error, 'Refresh fehlgeschlagen', 401);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,9 +525,6 @@ export async function changeInitialPortalPassword(req: AuthRequest, res: Respons
|
|||||||
clearRefreshCookie(res);
|
clearRefreshCookie(res);
|
||||||
res.json({ success: true, message: 'Passwort geändert' } as ApiResponse);
|
res.json({ success: true, message: 'Passwort geändert' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Passwort konnte nicht geändert werden', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Passwort konnte nicht geändert werden',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,7 +374,12 @@ export async function factoryReset(req: Request, res: Response) {
|
|||||||
label: `Werkseinstellungen wiederhergestellt`,
|
label: `Werkseinstellungen wiederhergestellt`,
|
||||||
});
|
});
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Werkseinstellungen wiederhergestellt. Bitte melden Sie sich mit admin@admin.com / admin an.',
|
// Das Kennwort ist nicht mehr "admin", sondern zufaellig und steht
|
||||||
|
// genau einmal im Server-Log. Die alte Meldung hier nannte es noch
|
||||||
|
// im Klartext - sie haette nach der Haertung ins Leere gefuehrt.
|
||||||
|
message:
|
||||||
|
'Werkseinstellungen wiederhergestellt. Das neue Kennwort für admin@admin.com ' +
|
||||||
|
'steht einmalig im Server-Log (docker compose logs backend).',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
|
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import { isValidIBAN } from 'ibantools';
|
import { isValidIBAN } from 'ibantools';
|
||||||
import * as blzData from '../services/blzData.service.js';
|
import * as blzData from '../services/blzData.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
@@ -47,9 +48,6 @@ export async function lookupIban(req: AuthRequest, res: Response): Promise<void>
|
|||||||
},
|
},
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim IBAN-Nachschlagen', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim IBAN-Nachschlagen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
import * as birthdayService from '../services/birthday.service.js';
|
import * as birthdayService from '../services/birthday.service.js';
|
||||||
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
|
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
|
||||||
@@ -89,10 +90,7 @@ export async function resetBirthdayGreeting(req: AuthRequest, res: Response) {
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Zurücksetzen:', error);
|
console.error('Fehler beim Zurücksetzen:', error);
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Zurücksetzen', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,9 +178,6 @@ export async function sendBirthdayGreeting(req: AuthRequest, res: Response) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Senden des Geburtstagsgrußes:', error);
|
console.error('Fehler beim Senden des Geburtstagsgrußes:', error);
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Senden', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Senden',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import * as blzData from '../services/blzData.service.js';
|
import * as blzData from '../services/blzData.service.js';
|
||||||
@@ -9,10 +10,7 @@ export async function getStatus(req: AuthRequest, res: Response): Promise<void>
|
|||||||
const status = await blzData.getStatus();
|
const status = await blzData.getStatus();
|
||||||
res.json({ success: true, data: status } as ApiResponse);
|
res.json({ success: true, data: status } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Laden des BLZ-Status', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Laden des BLZ-Status',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,9 +30,6 @@ export async function updateNow(req: AuthRequest, res: Response): Promise<void>
|
|||||||
const status = await blzData.getStatus();
|
const status = await blzData.getStatus();
|
||||||
res.json({ success: true, data: { result, status } } as ApiResponse);
|
res.json({ success: true, data: { result, status } } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(502).json({
|
antworteAufFehler(res, error, 'BLZ-Aktualisierung fehlgeschlagen', 502);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'BLZ-Aktualisierung fehlgeschlagen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// ==================== CACHED EMAIL CONTROLLER ====================
|
// ==================== CACHED EMAIL CONTROLLER ====================
|
||||||
|
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as cachedEmailService from '../services/cachedEmail.service.js';
|
import * as cachedEmailService from '../services/cachedEmail.service.js';
|
||||||
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
||||||
import * as invoiceService from '../services/invoice.service.js';
|
import * as invoiceService from '../services/invoice.service.js';
|
||||||
@@ -272,10 +273,7 @@ export async function assignToContract(req: AuthRequest, res: Response): Promise
|
|||||||
res.json({ success: true, data: email } as ApiResponse);
|
res.json({ success: true, data: email } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('assignToContract error:', error);
|
console.error('assignToContract error:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Zuordnen der E-Mail');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Zuordnen der E-Mail',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,10 +288,7 @@ export async function unassignFromContract(req: AuthRequest, res: Response): Pro
|
|||||||
res.json({ success: true, data: email } as ApiResponse);
|
res.json({ success: true, data: email } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('unassignFromContract error:', error);
|
console.error('unassignFromContract error:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aufheben der Zuordnung');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aufheben der Zuordnung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1660,12 +1655,7 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveAttachmentTo error:', error);
|
console.error('saveAttachmentTo error:', error);
|
||||||
// Detailliertere Fehlermeldung für Debugging
|
// Detailliertere Fehlermeldung für Debugging
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
antworteAufFehler(res, error, 'Fehler beim Speichern des Anhangs', 500);
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: `Fehler beim Speichern des Anhangs: ${errorMessage}`,
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1909,11 +1899,7 @@ export async function saveEmailAsPdf(req: AuthRequest, res: Response): Promise<v
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveEmailAsPdf error:', error);
|
console.error('saveEmailAsPdf error:', error);
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der PDF', 500);
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: `Fehler beim Erstellen der PDF: ${errorMessage}`,
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2036,11 +2022,7 @@ export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promi
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveEmailAsInvoice error:', error);
|
console.error('saveEmailAsInvoice error:', error);
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Rechnung', 500);
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2145,9 +2127,7 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
|
|||||||
console.error('saveEmailAsContractDocument error:', error);
|
console.error('saveEmailAsContractDocument error:', error);
|
||||||
// Pentest 64.1: ApiError mit eigenem statusCode (z.B. 400 vom Race-
|
// Pentest 64.1: ApiError mit eigenem statusCode (z.B. 400 vom Race-
|
||||||
// Lock) statt pauschal 500.
|
// Lock) statt pauschal 500.
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
antworteAufFehler(res, error, 'Fehler beim Speichern', 500);
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
|
||||||
res.status(status).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2332,12 +2312,7 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveAttachmentAsInvoice error:', error);
|
console.error('saveAttachmentAsInvoice error:', error);
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Rechnung', 500);
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2512,11 +2487,6 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveAttachmentAsContractDocument error:', error);
|
console.error('saveAttachmentAsContractDocument error:', error);
|
||||||
// Pentest 64.1: ApiError mit eigenem statusCode statt pauschal 500.
|
// Pentest 64.1: ApiError mit eigenem statusCode statt pauschal 500.
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
antworteAufFehler(res, error, 'Fehler beim Speichern', 500);
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: `Fehler beim Speichern: ${errorMessage}`,
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as cancellationPeriodService from '../services/cancellation-period.service.js';
|
import * as cancellationPeriodService from '../services/cancellation-period.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
@@ -46,10 +47,7 @@ export async function createCancellationPeriod(req: Request, res: Response): Pro
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: period } as ApiResponse);
|
res.status(201).json({ success: true, data: period } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Kündigungsfrist');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Kündigungsfrist',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +62,7 @@ export async function updateCancellationPeriod(req: Request, res: Response): Pro
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: period } as ApiResponse);
|
res.json({ success: true, data: period } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Kündigungsfrist');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Kündigungsfrist',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +78,6 @@ export async function deleteCancellationPeriod(req: Request, res: Response): Pro
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Kündigungsfrist gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Kündigungsfrist gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Kündigungsfrist');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Kündigungsfrist',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as contractDurationService from '../services/contract-duration.service.js';
|
import * as contractDurationService from '../services/contract-duration.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
@@ -46,10 +47,7 @@ export async function createContractDuration(req: Request, res: Response): Promi
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: duration } as ApiResponse);
|
res.status(201).json({ success: true, data: duration } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Laufzeit');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Laufzeit',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +62,7 @@ export async function updateContractDuration(req: Request, res: Response): Promi
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: duration } as ApiResponse);
|
res.json({ success: true, data: duration } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Laufzeit');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Laufzeit',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +78,6 @@ export async function deleteContractDuration(req: Request, res: Response): Promi
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Laufzeit gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Laufzeit gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Laufzeit');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Laufzeit',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as contractService from '../services/contract.service.js';
|
import * as contractService from '../services/contract.service.js';
|
||||||
@@ -211,10 +212,7 @@ export async function createContract(req: AuthRequest, res: Response): Promise<v
|
|||||||
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
||||||
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Vertrags');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Vertrags',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,10 +372,7 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
|||||||
: sanitizeContract(contract as any);
|
: sanitizeContract(contract as any);
|
||||||
res.json({ success: true, data: sanitized } as ApiResponse);
|
res.json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Vertrags');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Vertrags',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,10 +391,7 @@ export async function deleteContract(req: Request, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Vertrag gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Vertrag gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Vertrags');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Vertrags',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,10 +439,7 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
|
|||||||
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
||||||
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Folgevertrags');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Folgevertrags',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,10 +491,7 @@ export async function createRenewal(req: AuthRequest, res: Response): Promise<vo
|
|||||||
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
||||||
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der VVL');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der VVL',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,10 +779,7 @@ export async function addSuccessorMeter(req: AuthRequest, res: Response): Promis
|
|||||||
|
|
||||||
res.json({ success: true, data: contractMeter } as ApiResponse);
|
res.json({ success: true, data: contractMeter } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Hinzufügen des Folgezählers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Folgezählers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,10 +796,7 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: null } as ApiResponse);
|
res.json({ success: true, data: null } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Entfernen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Entfernen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -905,13 +885,9 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
|||||||
// Pentest 64.1: ApiError mit eigenem statusCode honorieren (z.B. 400
|
// Pentest 64.1: ApiError mit eigenem statusCode honorieren (z.B. 400
|
||||||
// vom Race-Lock); fallback bleibt 400 für sonstige ContractDocument-
|
// vom Race-Lock); fallback bleibt 400 für sonstige ContractDocument-
|
||||||
// Schreibfehler.
|
// Schreibfehler.
|
||||||
const status = error instanceof ApiError ? error.statusCode : 400;
|
|
||||||
// Multer hat die Datei schon geschrieben – bei Reject räumen.
|
// Multer hat die Datei schon geschrieben – bei Reject räumen.
|
||||||
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||||
res.status(status).json({
|
antworteAufFehler(res, error, 'Fehler beim Hochladen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Hochladen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -948,10 +924,7 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
|
|||||||
|
|
||||||
res.json({ success: true, message: 'Dokument gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Dokument gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as contractCategoryService from '../services/contractCategory.service.js';
|
import * as contractCategoryService from '../services/contractCategory.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
@@ -46,10 +47,7 @@ export async function createContractCategory(req: Request, res: Response): Promi
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: category } as ApiResponse);
|
res.status(201).json({ success: true, data: category } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Vertragskategorie');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Vertragskategorie',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +62,7 @@ export async function updateContractCategory(req: Request, res: Response): Promi
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: category } as ApiResponse);
|
res.json({ success: true, data: category } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Vertragskategorie');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Vertragskategorie',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +78,6 @@ export async function deleteContractCategory(req: Request, res: Response): Promi
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Vertragskategorie gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Vertragskategorie gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Vertragskategorie');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Vertragskategorie',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as contractHistoryService from '../services/contractHistory.service.js';
|
import * as contractHistoryService from '../services/contractHistory.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
@@ -47,10 +48,7 @@ export async function createHistoryEntry(req: AuthRequest, res: Response): Promi
|
|||||||
|
|
||||||
res.status(201).json({ success: true, data: entry } as ApiResponse);
|
res.status(201).json({ success: true, data: entry } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Eintrags');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Eintrags',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +72,7 @@ export async function updateHistoryEntry(req: AuthRequest, res: Response): Promi
|
|||||||
|
|
||||||
res.json({ success: true, data: entry } as ApiResponse);
|
res.json({ success: true, data: entry } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Eintrags');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Eintrags',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,9 +92,6 @@ export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promi
|
|||||||
|
|
||||||
res.json({ success: true, message: 'Eintrag gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Eintrag gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Eintrags');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Eintrags',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as contractTaskService from '../services/contractTask.service.js';
|
import * as contractTaskService from '../services/contractTask.service.js';
|
||||||
import * as contractService from '../services/contract.service.js';
|
import * as contractService from '../services/contract.service.js';
|
||||||
import * as customerService from '../services/customer.service.js';
|
import * as customerService from '../services/customer.service.js';
|
||||||
@@ -146,10 +147,7 @@ export async function createGeneralTask(req: AuthRequest, res: Response): Promis
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: task } as ApiResponse);
|
res.status(201).json({ success: true, data: task } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Aufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Aufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,10 +185,7 @@ export async function createTask(req: AuthRequest, res: Response): Promise<void>
|
|||||||
|
|
||||||
res.status(201).json({ success: true, data: task } as ApiResponse);
|
res.status(201).json({ success: true, data: task } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Aufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Aufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,10 +234,7 @@ export async function createSupportTicket(req: AuthRequest, res: Response): Prom
|
|||||||
|
|
||||||
res.status(201).json({ success: true, data: task } as ApiResponse);
|
res.status(201).json({ success: true, data: task } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Support-Anfrage');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Support-Anfrage',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,10 +257,7 @@ export async function updateTask(req: AuthRequest, res: Response): Promise<void>
|
|||||||
|
|
||||||
res.json({ success: true, data: task } as ApiResponse);
|
res.json({ success: true, data: task } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Aufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Aufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,10 +272,7 @@ export async function completeTask(req: AuthRequest, res: Response): Promise<voi
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: task } as ApiResponse);
|
res.json({ success: true, data: task } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Abschließen der Aufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Abschließen der Aufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,10 +287,7 @@ export async function reopenTask(req: AuthRequest, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: task } as ApiResponse);
|
res.json({ success: true, data: task } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Wiedereröffnen der Aufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Wiedereröffnen der Aufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,10 +302,7 @@ export async function deleteTask(req: AuthRequest, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Aufgabe gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Aufgabe gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Aufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Aufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,10 +337,7 @@ export async function createSubtask(req: AuthRequest, res: Response): Promise<vo
|
|||||||
|
|
||||||
res.status(201).json({ success: true, data: subtask } as ApiResponse);
|
res.status(201).json({ success: true, data: subtask } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Unteraufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Unteraufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,10 +416,7 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
|
|||||||
|
|
||||||
res.status(201).json({ success: true, data: subtask } as ApiResponse);
|
res.status(201).json({ success: true, data: subtask } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Antwort');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Antwort',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,10 +441,7 @@ export async function updateSubtask(req: AuthRequest, res: Response): Promise<vo
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: subtask } as ApiResponse);
|
res.json({ success: true, data: subtask } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Unteraufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Unteraufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,10 +456,7 @@ export async function completeSubtask(req: AuthRequest, res: Response): Promise<
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: subtask } as ApiResponse);
|
res.json({ success: true, data: subtask } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Abschließen der Unteraufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Abschließen der Unteraufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,10 +471,7 @@ export async function reopenSubtask(req: AuthRequest, res: Response): Promise<vo
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: subtask } as ApiResponse);
|
res.json({ success: true, data: subtask } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Wiedereröffnen der Unteraufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Wiedereröffnen der Unteraufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,9 +486,6 @@ export async function deleteSubtask(req: AuthRequest, res: Response): Promise<vo
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Unteraufgabe gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Unteraufgabe gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Unteraufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Unteraufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiError } from '../utils/apiError.js';
|
import { ApiError } from '../utils/apiError.js';
|
||||||
@@ -36,11 +37,9 @@ function idParam(req: AuthRequest, res: Response, name: string): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleError(res: Response, error: unknown, fallback: string) {
|
function handleError(res: Response, error: unknown, fallback: string) {
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
// Eigene Huelle beibehalten, damit die Aufrufer unveraendert bleiben - die
|
||||||
res.status(status).json({
|
// Entscheidung, was nach draussen geht, faellt jetzt zentral.
|
||||||
success: false,
|
antworteAufFehler(res, error, fallback, 500);
|
||||||
error: error instanceof Error ? error.message : fallback,
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Gesamtübersicht (Hauptmenü) ----
|
// ---- Gesamtübersicht (Hauptmenü) ----
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as customerService from '../services/customer.service.js';
|
import * as customerService from '../services/customer.service.js';
|
||||||
import * as authService from '../services/auth.service.js';
|
import * as authService from '../services/auth.service.js';
|
||||||
@@ -155,10 +156,7 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
|||||||
: sanitizeCustomerStrict(customer as any);
|
: sanitizeCustomerStrict(customer as any);
|
||||||
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Kunden');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Kunden',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,10 +274,7 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
|
|||||||
res.json({ success: true, data: sanitized } as ApiResponse);
|
res.json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update customer error:', error);
|
console.error('Update customer error:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Kunden');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Kunden',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,10 +291,7 @@ export async function deleteCustomer(req: Request, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Kunde gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Kunde gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Kunden');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Kunden',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,10 +320,7 @@ export async function createAddress(req: AuthRequest, res: Response): Promise<vo
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: address } as ApiResponse);
|
res.status(201).json({ success: true, data: address } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Adresse');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Adresse',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,10 +381,7 @@ export async function updateAddress(req: AuthRequest, res: Response): Promise<vo
|
|||||||
|
|
||||||
res.json({ success: true, data: address } as ApiResponse);
|
res.json({ success: true, data: address } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Adresse');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Adresse',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,10 +400,7 @@ export async function deleteAddress(req: AuthRequest, res: Response): Promise<vo
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Adresse gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Adresse gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Adresse');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Adresse',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,10 +431,7 @@ export async function createBankCard(req: AuthRequest, res: Response): Promise<v
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: card } as ApiResponse);
|
res.status(201).json({ success: true, data: card } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Bankkarte');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Bankkarte',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,10 +490,7 @@ export async function updateBankCard(req: AuthRequest, res: Response): Promise<v
|
|||||||
|
|
||||||
res.json({ success: true, data: card } as ApiResponse);
|
res.json({ success: true, data: card } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Bankkarte');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Bankkarte',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,10 +509,7 @@ export async function deleteBankCard(req: AuthRequest, res: Response): Promise<v
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Bankkarte gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Bankkarte gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Bankkarte');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Bankkarte',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,10 +539,7 @@ export async function createDocument(req: AuthRequest, res: Response): Promise<v
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: doc } as ApiResponse);
|
res.status(201).json({ success: true, data: doc } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Ausweises');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Ausweises',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,10 +601,7 @@ export async function updateDocument(req: AuthRequest, res: Response): Promise<v
|
|||||||
|
|
||||||
res.json({ success: true, data: doc } as ApiResponse);
|
res.json({ success: true, data: doc } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Ausweises');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Ausweises',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,10 +620,7 @@ export async function deleteDocument(req: AuthRequest, res: Response): Promise<v
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Ausweis gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Ausweis gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Ausweises');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Ausweises',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,10 +653,7 @@ export async function createMeter(req: AuthRequest, res: Response): Promise<void
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: meter } as ApiResponse);
|
res.status(201).json({ success: true, data: meter } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Zählers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Zählers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,10 +708,7 @@ export async function updateMeter(req: AuthRequest, res: Response): Promise<void
|
|||||||
|
|
||||||
res.json({ success: true, data: meter } as ApiResponse);
|
res.json({ success: true, data: meter } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Zählers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Zählers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,10 +724,7 @@ export async function deleteMeter(req: AuthRequest, res: Response): Promise<void
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Zähler gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Zähler gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Zählers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Zählers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,10 +770,7 @@ export async function addMeterReading(req: AuthRequest, res: Response): Promise<
|
|||||||
|
|
||||||
res.status(201).json({ success: true, data: reading } as ApiResponse);
|
res.status(201).json({ success: true, data: reading } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Hinzufügen des Zählerstands');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Zählerstands',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,10 +798,7 @@ export async function updateMeterReading(req: AuthRequest, res: Response): Promi
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: reading } as ApiResponse);
|
res.json({ success: true, data: reading } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Zählerstands');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Zählerstands',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,10 +815,7 @@ export async function deleteMeterReading(req: AuthRequest, res: Response): Promi
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: null } as ApiResponse);
|
res.json({ success: true, data: null } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Zählerstands');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Zählerstands',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,10 +870,7 @@ export async function reportMeterReading(req: AuthRequest, res: Response): Promi
|
|||||||
|
|
||||||
res.status(201).json({ success: true, data: reading } as ApiResponse);
|
res.status(201).json({ success: true, data: reading } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Melden des Zählerstands');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Melden des Zählerstands',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -978,10 +922,7 @@ export async function markReadingTransferred(req: AuthRequest, res: Response): P
|
|||||||
|
|
||||||
res.json({ success: true, data: reading } as ApiResponse);
|
res.json({ success: true, data: reading } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1091,10 +1032,7 @@ export async function updatePortalSettings(req: Request, res: Response): Promise
|
|||||||
|
|
||||||
res.json({ success: true, data: settings } as ApiResponse);
|
res.json({ success: true, data: settings } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Portal-Einstellungen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Portal-Einstellungen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1108,10 +1046,7 @@ export async function generatePortalPassword(req: Request, res: Response): Promi
|
|||||||
const password = generateSecurePassword({ length: 16 });
|
const password = generateSecurePassword({ length: 16 });
|
||||||
res.json({ success: true, data: { password } } as ApiResponse);
|
res.json({ success: true, data: { password } } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Generieren des Passworts', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Generieren des Passworts',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1199,10 +1134,7 @@ export async function sendPortalCredentials(req: AuthRequest, res: Response): Pr
|
|||||||
|
|
||||||
res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet (Einmalpasswort)` } as ApiResponse);
|
res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet (Einmalpasswort)` } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Versenden der Zugangsdaten', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Versenden der Zugangsdaten',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1228,10 +1160,7 @@ export async function setPortalPassword(req: Request, res: Response): Promise<vo
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
|
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Setzen des Passworts');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Setzen des Passworts',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1304,10 +1233,7 @@ export async function addRepresentative(req: AuthRequest, res: Response): Promis
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: representative } as ApiResponse);
|
res.status(201).json({ success: true, data: representative } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Hinzufügen des Vertreters');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Vertreters',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1326,10 +1252,7 @@ export async function removeRepresentative(req: AuthRequest, res: Response): Pro
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Vertreter entfernt' } as ApiResponse);
|
res.json({ success: true, message: 'Vertreter entfernt' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Entfernen des Vertreters');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Entfernen des Vertreters',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1354,11 +1277,7 @@ export async function getSalutationPreference(req: AuthRequest, res: Response):
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Pentest R104.1: ApiError-Statuscodes durchreichen (404 statt 500
|
// Pentest R104.1: ApiError-Statuscodes durchreichen (404 statt 500
|
||||||
// bei nicht-existierendem Kunden).
|
// bei nicht-existierendem Kunden).
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
antworteAufFehler(res, error, 'Fehler beim Laden der Anrede-Präferenz', 500);
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Laden der Anrede-Präferenz',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1387,11 +1306,7 @@ export async function setSalutationPreference(req: AuthRequest, res: Response):
|
|||||||
const result = await customerService.getSalutationPreference(userId, customerId);
|
const result = await customerService.getSalutationPreference(userId, customerId);
|
||||||
res.json({ success: true, data: result } as ApiResponse);
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
antworteAufFehler(res, error, 'Fehler beim Speichern der Anrede-Präferenz', 500);
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Anrede-Präferenz',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1412,11 +1327,7 @@ export async function clearSalutationPreference(req: AuthRequest, res: Response)
|
|||||||
const result = await customerService.getSalutationPreference(userId, customerId);
|
const result = await customerService.getSalutationPreference(userId, customerId);
|
||||||
res.json({ success: true, data: result } as ApiResponse);
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
antworteAufFehler(res, error, 'Fehler beim Zurücksetzen der Anrede-Präferenz', 500);
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen der Anrede-Präferenz',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import * as referralService from '../services/customerReferral.service.js';
|
import * as referralService from '../services/customerReferral.service.js';
|
||||||
@@ -98,11 +99,7 @@ export async function createReferral(req: AuthRequest, res: Response): Promise<v
|
|||||||
|
|
||||||
res.status(201).json({ success: true, data: created } as ApiResponse);
|
res.status(201).json({ success: true, data: created } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = (error as referralService.ReferralError)?.status ?? 500;
|
antworteAufFehler(res, error, 'Fehler beim Anlegen der Werbe-Beziehung', 500);
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Anlegen der Werbe-Beziehung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,11 +150,7 @@ export async function updateReferral(req: AuthRequest, res: Response): Promise<v
|
|||||||
|
|
||||||
res.json({ success: true, data: updated } as ApiResponse);
|
res.json({ success: true, data: updated } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = (error as referralService.ReferralError)?.status ?? 500;
|
antworteAufFehler(res, error, 'Fehler beim Ändern der Werbe-Beziehung', 500);
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Ändern der Werbe-Beziehung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// ==================== EMAIL PROVIDER CONTROLLER ====================
|
// ==================== EMAIL PROVIDER CONTROLLER ====================
|
||||||
|
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
|
import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
@@ -60,10 +61,7 @@ export async function createProviderConfig(req: Request, res: Response): Promise
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: config } as ApiResponse);
|
res.status(201).json({ success: true, data: config } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Email-Providers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Email-Providers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +78,7 @@ export async function updateProviderConfig(req: Request, res: Response): Promise
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: config } as ApiResponse);
|
res.json({ success: true, data: config } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Email-Providers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Email-Providers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +94,7 @@ export async function deleteProviderConfig(req: Request, res: Response): Promise
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Email-Provider gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Email-Provider gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Email-Providers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Email-Providers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,10 +150,7 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
|||||||
const result = await emailProviderService.testProviderConnection({ id, testData });
|
const result = await emailProviderService.testProviderConnection({ id, testData });
|
||||||
res.json({ success: result.success, data: result } as ApiResponse);
|
res.json({ success: result.success, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Verbindungstest fehlgeschlagen', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Verbindungstest fehlgeschlagen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,10 +331,7 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('testMailAccess error:', error);
|
console.error('testMailAccess error:', error);
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Test', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Test',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,10 +341,7 @@ export async function checkEmailExists(req: Request, res: Response): Promise<voi
|
|||||||
const result = await emailProviderService.checkEmailExists(localPart);
|
const result = await emailProviderService.checkEmailExists(localPart);
|
||||||
res.json({ success: true, data: result } as ApiResponse);
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler bei der E-Mail-Prüfung', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler bei der E-Mail-Prüfung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,10 +360,7 @@ export async function provisionEmail(req: Request, res: Response): Promise<void>
|
|||||||
const result = await emailProviderService.provisionEmail(localPart, customerEmail);
|
const result = await emailProviderService.provisionEmail(localPart, customerEmail);
|
||||||
res.json({ success: result.success, data: result } as ApiResponse);
|
res.json({ success: result.success, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler bei der E-Mail-Provisionierung', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler bei der E-Mail-Provisionierung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,10 +370,7 @@ export async function deprovisionEmail(req: Request, res: Response): Promise<voi
|
|||||||
const result = await emailProviderService.deprovisionEmail(localPart);
|
const result = await emailProviderService.deprovisionEmail(localPart);
|
||||||
res.json({ success: result.success, data: result } as ApiResponse);
|
res.json({ success: result.success, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der E-Mail', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der E-Mail',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
import * as factoryDefaultsService from '../services/factoryDefaults.service.js';
|
import * as factoryDefaultsService from '../services/factoryDefaults.service.js';
|
||||||
import { createAuditLog } from '../services/audit.service.js';
|
import { createAuditLog } from '../services/audit.service.js';
|
||||||
@@ -31,10 +32,7 @@ export async function exportFactoryDefaults(req: AuthRequest, res: Response) {
|
|||||||
res.send(buffer);
|
res.send(buffer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Factory-Defaults-Export:', error);
|
console.error('Fehler beim Factory-Defaults-Export:', error);
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Export', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Export',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,9 +91,6 @@ export async function importFactoryDefaults(req: AuthRequest, res: Response) {
|
|||||||
res.json({ success: true, data: result });
|
res.json({ success: true, data: result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Factory-Defaults-Import:', error);
|
console.error('Fehler beim Factory-Defaults-Import:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Import');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Import',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
import * as gdprService from '../services/gdpr.service.js';
|
import * as gdprService from '../services/gdpr.service.js';
|
||||||
import * as consentService from '../services/consent.service.js';
|
import * as consentService from '../services/consent.service.js';
|
||||||
@@ -54,10 +55,7 @@ export async function exportCustomerData(req: AuthRequest, res: Response) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Datenexport:', error);
|
console.error('Fehler beim Datenexport:', error);
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Datenexport', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Datenexport',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,10 +96,7 @@ export async function createDeletionRequest(req: AuthRequest, res: Response) {
|
|||||||
res.status(201).json({ success: true, data: request });
|
res.status(201).json({ success: true, data: request });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Erstellen der Löschanfrage:', error);
|
console.error('Fehler beim Erstellen der Löschanfrage:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,10 +178,7 @@ export async function processDeletionRequest(req: AuthRequest, res: Response) {
|
|||||||
res.json({ success: true, data: result });
|
res.json({ success: true, data: result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Bearbeiten der Löschanfrage:', error);
|
console.error('Fehler beim Bearbeiten der Löschanfrage:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Bearbeiten');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Bearbeiten',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,10 +338,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
|||||||
res.json({ success: true, data: consent });
|
res.json({ success: true, data: consent });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Aktualisieren der Einwilligung:', error);
|
console.error('Fehler beim Aktualisieren der Einwilligung:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,10 +686,7 @@ export async function sendConsentLink(req: AuthRequest, res: Response) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Senden des Consent-Links:', error);
|
console.error('Fehler beim Senden des Consent-Links:', error);
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Senden', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Senden',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,10 +805,7 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Senden der Vollmacht-Anfrage:', error);
|
console.error('Fehler beim Senden der Vollmacht-Anfrage:', error);
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Senden', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Senden',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,10 +858,7 @@ export async function grantAuthorization(req: AuthRequest, res: Response) {
|
|||||||
res.json({ success: true, data: auth });
|
res.json({ success: true, data: auth });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Erteilen der Vollmacht:', error);
|
console.error('Fehler beim Erteilen der Vollmacht:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erteilen der Vollmacht');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erteilen der Vollmacht',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,10 +884,7 @@ export async function withdrawAuthorization(req: AuthRequest, res: Response) {
|
|||||||
res.json({ success: true, data: auth });
|
res.json({ success: true, data: auth });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Widerrufen der Vollmacht:', error);
|
console.error('Fehler beim Widerrufen der Vollmacht:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Widerrufen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Widerrufen',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1012,10 +989,7 @@ export async function uploadAuthorizationDocument(req: AuthRequest, res: Respons
|
|||||||
res.json({ success: true, data: auth });
|
res.json({ success: true, data: auth });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Upload des Vollmacht-Dokuments:', error);
|
console.error('Fehler beim Upload des Vollmacht-Dokuments:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Upload');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Upload',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1039,10 +1013,7 @@ export async function deleteAuthorizationDocument(req: AuthRequest, res: Respons
|
|||||||
res.json({ success: true, data: auth });
|
res.json({ success: true, data: auth });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Löschen des Vollmacht-Dokuments:', error);
|
console.error('Fehler beim Löschen des Vollmacht-Dokuments:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as invoiceService from '../services/invoice.service.js';
|
import * as invoiceService from '../services/invoice.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
@@ -101,10 +102,7 @@ export async function addInvoice(req: AuthRequest, res: Response): Promise<void>
|
|||||||
res.status(201).json({ success: true, data: invoice } as ApiResponse);
|
res.status(201).json({ success: true, data: invoice } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('addInvoice error:', error);
|
console.error('addInvoice error:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Hinzufügen der Rechnung');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen der Rechnung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +134,7 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise<vo
|
|||||||
res.json({ success: true, data: invoice } as ApiResponse);
|
res.json({ success: true, data: invoice } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('updateInvoice error:', error);
|
console.error('updateInvoice error:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Rechnung');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Rechnung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,10 +158,7 @@ export async function deleteInvoice(req: AuthRequest, res: Response): Promise<vo
|
|||||||
res.json({ success: true, data: null } as ApiResponse);
|
res.json({ success: true, data: null } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('deleteInvoice error:', error);
|
console.error('deleteInvoice error:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Rechnung');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Rechnung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,9 +200,6 @@ export async function addInvoiceByContract(req: AuthRequest, res: Response): Pro
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: invoice } as ApiResponse);
|
res.status(201).json({ success: true, data: invoice } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Hinzufügen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||||
import * as appSettingService from '../services/appSetting.service.js';
|
import * as appSettingService from '../services/appSetting.service.js';
|
||||||
@@ -141,10 +142,7 @@ export async function testAlert(_req: AuthRequest, res: Response): Promise<void>
|
|||||||
res.status(500).json({ success: false, error: result.error || 'Versand fehlgeschlagen' } as ApiResponse);
|
res.status(500).json({ success: false, error: result.error || 'Versand fehlgeschlagen' } as ApiResponse);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Test-Alert fehlgeschlagen', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Test-Alert fehlgeschlagen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,9 +199,6 @@ export async function runDigestNow(_req: AuthRequest, res: Response): Promise<vo
|
|||||||
const result = await sendDigest({ force: true });
|
const result = await sendDigest({ force: true });
|
||||||
res.json({ success: true, data: result } as ApiResponse);
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Digest fehlgeschlagen', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Digest fehlgeschlagen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
import * as pdfTemplateService from '../services/pdfTemplate.service.js';
|
import * as pdfTemplateService from '../services/pdfTemplate.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
@@ -57,10 +58,7 @@ export async function createTemplate(req: AuthRequest, res: Response) {
|
|||||||
|
|
||||||
res.status(201).json({ success: true, data: { ...template, pdfFields } });
|
res.status(201).json({ success: true, data: { ...template, pdfFields } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,10 +85,7 @@ export async function updateTemplate(req: AuthRequest, res: Response) {
|
|||||||
|
|
||||||
res.json({ success: true, data: template });
|
res.json({ success: true, data: template });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,10 +114,7 @@ export async function extractFields(req: AuthRequest, res: Response) {
|
|||||||
const result = await pdfTemplateService.extractPdfFields(template.templatePath);
|
const result = await pdfTemplateService.extractPdfFields(template.templatePath);
|
||||||
res.json({ success: true, data: result.fields, totalPages: result.totalPages });
|
res.json({ success: true, data: result.fields, totalPages: result.totalPages });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Auslesen der PDF-Felder');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Auslesen der PDF-Felder',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +129,7 @@ export async function getAnnotatedPreview(req: AuthRequest, res: Response) {
|
|||||||
res.setHeader('Content-Disposition', 'inline; filename="preview.pdf"');
|
res.setHeader('Content-Disposition', 'inline; filename="preview.pdf"');
|
||||||
res.send(pdfBuffer);
|
res.send(pdfBuffer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler' });
|
antworteAufFehler(res, error, 'Fehler', 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +146,7 @@ export async function getRequiredInputs(req: AuthRequest, res: Response) {
|
|||||||
const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId);
|
const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId);
|
||||||
res.json({ success: true, data: inputs });
|
res.json({ success: true, data: inputs });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler' });
|
antworteAufFehler(res, error, 'Fehler', 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,9 +187,6 @@ export async function generatePdf(req: AuthRequest, res: Response) {
|
|||||||
res.send(pdfBuffer);
|
res.send(pdfBuffer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PDF generate error:', error);
|
console.error('PDF generate error:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Generieren');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Generieren',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as platformService from '../services/platform.service.js';
|
import * as platformService from '../services/platform.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
@@ -46,10 +47,7 @@ export async function createPlatform(req: Request, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: platform } as ApiResponse);
|
res.status(201).json({ success: true, data: platform } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Vertriebsplattform');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Vertriebsplattform',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +62,7 @@ export async function updatePlatform(req: Request, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: platform } as ApiResponse);
|
res.json({ success: true, data: platform } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Vertriebsplattform');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Vertriebsplattform',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +78,6 @@ export async function deletePlatform(req: Request, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Vertriebsplattform gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Vertriebsplattform gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Vertriebsplattform');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Vertriebsplattform',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as providerService from '../services/provider.service.js';
|
import * as providerService from '../services/provider.service.js';
|
||||||
@@ -99,10 +100,7 @@ export async function createProvider(req: Request, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: provider } as ApiResponse);
|
res.status(201).json({ success: true, data: provider } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Anbieters');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Anbieters',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +134,7 @@ export async function updateProvider(req: Request, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: provider } as ApiResponse);
|
res.json({ success: true, data: provider } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Anbieters');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Anbieters',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,9 +150,6 @@ export async function deleteProvider(req: Request, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Anbieter gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Anbieter gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Anbieters');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Anbieters',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
@@ -93,11 +94,7 @@ export async function createEmail(req: Request, res: Response): Promise<void> {
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: email } as ApiResponse);
|
res.status(201).json({ success: true, data: email } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error instanceof ApiError ? error.statusCode : 400;
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse', 400);
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,11 +116,7 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: email } as ApiResponse);
|
res.json({ success: true, data: email } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error instanceof ApiError ? error.statusCode : 400;
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse', 400);
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,10 +133,7 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Stressfrei-Wechseln Adresse gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Stressfrei-Wechseln Adresse gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Stressfrei-Wechseln Adresse');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Stressfrei-Wechseln Adresse',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,10 +170,7 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise<v
|
|||||||
message: 'Weiterleitungen aktualisiert',
|
message: 'Weiterleitungen aktualisiert',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Synchronisieren der Weiterleitungen', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Synchronisieren der Weiterleitungen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,11 +215,7 @@ export async function updateAdditionalForwards(req: AuthRequest, res: Response):
|
|||||||
message: 'Weiterleitungen aktualisiert',
|
message: 'Weiterleitungen aktualisiert',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Weiterleitungen', 500);
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Weiterleitungen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,9 +238,6 @@ export async function resetPassword(req: AuthRequest, res: Response): Promise<vo
|
|||||||
message: 'Passwort wurde zurückgesetzt',
|
message: 'Passwort wurde zurückgesetzt',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Zurücksetzen des Passworts', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen des Passworts',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as tariffService from '../services/tariff.service.js';
|
import * as tariffService from '../services/tariff.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
@@ -48,10 +49,7 @@ export async function createTariff(req: Request, res: Response): Promise<void> {
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: tariff } as ApiResponse);
|
res.status(201).json({ success: true, data: tariff } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Tarifs');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Tarifs',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +64,7 @@ export async function updateTariff(req: Request, res: Response): Promise<void> {
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: tariff } as ApiResponse);
|
res.json({ success: true, data: tariff } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Tarifs');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Tarifs',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +80,6 @@ export async function deleteTariff(req: Request, res: Response): Promise<void> {
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Tarif gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Tarif gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Tarifs');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Tarifs',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as userService from '../services/user.service.js';
|
import * as userService from '../services/user.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
import { AUDIT_OPS_ROLLE } from '../services/user.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||||
import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
|
import { pickUserCreate, pickUserUpdate, pickRoleUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
|
||||||
|
import { RechteEskalationError, RollenSperrError, UngueltigeEingabeError } from '../services/rechte.service.js';
|
||||||
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
|
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
@@ -55,6 +58,11 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
||||||
const data = pickUserCreate(req.body) as any;
|
const data = pickUserCreate(req.body) as any;
|
||||||
|
const boolFehler = pruefeBooleanFelder(data, ['isActive', 'isServiceAccount', 'hasGdprAccess', 'hasDeveloperAccess', 'hasAuditOpsAccess']);
|
||||||
|
if (boolFehler) {
|
||||||
|
res.status(400).json({ success: false, error: boolFehler } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Email-Format prüfen, sonst landet "x@y\nBcc:..." in der DB
|
// Email-Format prüfen, sonst landet "x@y\nBcc:..." in der DB
|
||||||
// (Pentest 29.4 – SMTP-Header-Injection).
|
// (Pentest 29.4 – SMTP-Header-Injection).
|
||||||
if (!isValidEmail(data?.email) || !data?.email) {
|
if (!isValidEmail(data?.email) || !data?.email) {
|
||||||
@@ -85,7 +93,12 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
|||||||
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse);
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const user = await userService.createUser(data);
|
const handelnder = handelnderOderNull(req);
|
||||||
|
if (handelnder === null) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = await userService.createUser(data, handelnder);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'User',
|
req, action: 'CREATE', resourceType: 'User',
|
||||||
resourceId: user.id.toString(),
|
resourceId: user.id.toString(),
|
||||||
@@ -93,10 +106,7 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: user } as ApiResponse);
|
res.status(201).json({ success: true, data: user } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufRollenFehler(res, error, 'Fehler beim Erstellen des Benutzers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Benutzers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +140,11 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
||||||
const data = pickUserUpdate(req.body) as Record<string, unknown>;
|
const data = pickUserUpdate(req.body) as Record<string, unknown>;
|
||||||
|
const boolFehler = pruefeBooleanFelder(data, ['isActive', 'isServiceAccount', 'hasGdprAccess', 'hasDeveloperAccess', 'hasAuditOpsAccess']);
|
||||||
|
if (boolFehler) {
|
||||||
|
res.status(400).json({ success: false, error: boolFehler } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4).
|
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4).
|
||||||
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
|
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
|
||||||
if (data?.email !== undefined && !isValidEmail(data.email)) {
|
if (data?.email !== undefined && !isValidEmail(data.email)) {
|
||||||
@@ -162,9 +177,60 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
...beforeUser,
|
...beforeUser,
|
||||||
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
|
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
|
||||||
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
|
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
|
||||||
|
hasAuditOpsAccess: beforeUser.roles.some((ur) => ur.role.name === AUDIT_OPS_ROLLE),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Die drei Haken vergeben versteckte Rollen: DSGVO, Developer und
|
||||||
|
// Audit-Betrieb. Sie sind kein gewoehnliches Benutzerfeld - "Developer"
|
||||||
|
// traegt ALLE Rechte, "Audit-Betrieb" entscheidet, wer die
|
||||||
|
// Beweisgrundlage ersetzen darf.
|
||||||
|
//
|
||||||
|
// Bis 09/2026 stand hier, eine Rechte-Huerde scheitere am
|
||||||
|
// Henne-Ei-Problem: Nach der Aufteilung haelt zunaechst niemand
|
||||||
|
// `audit:admin`, also koennte ihn auch niemand vergeben. Das Argument war
|
||||||
|
// richtig, aber der Schluss zu weit. Ohne Huerde genuegte `users:create`,
|
||||||
|
// um sich zum Vollzugriff zu befoerdern - nicht einmal am eigenen Konto,
|
||||||
|
// sondern ueber ein frisch angelegtes zweites mit gesetztem Haken.
|
||||||
|
//
|
||||||
|
// Die Huerde steckt jetzt in der Teilmengenregel (rechte.service.ts):
|
||||||
|
// Wer einen Haken setzt, muss die dahinterliegenden Rechte selbst
|
||||||
|
// halten. Das Henne-Ei-Problem loest nicht die Weboberflaeche, sondern
|
||||||
|
// die Kommandozeile - `npx tsx prisma/rolle-zuweisen.ts` auf der
|
||||||
|
// Maschine. Das ist die richtige Grenze: "Datenbankzugriff" und
|
||||||
|
// "Protokoll neu berechnen" sollten Shell-Zugang voraussetzen, nicht ein
|
||||||
|
// Haekchen im Browser. Ein gestohlener Admin-Zugang hat den Container
|
||||||
|
// nicht.
|
||||||
|
const setztAuditBetrieb =
|
||||||
|
data.hasAuditOpsAccess !== undefined &&
|
||||||
|
before !== null &&
|
||||||
|
data.hasAuditOpsAccess !== (before as any).hasAuditOpsAccess;
|
||||||
|
const aktiviertAuditBetrieb = data.hasAuditOpsAccess === true;
|
||||||
|
|
||||||
|
const setztDsgvo =
|
||||||
|
data.hasGdprAccess !== undefined &&
|
||||||
|
before !== null &&
|
||||||
|
data.hasGdprAccess !== (before as any).hasGdprAccess;
|
||||||
|
const setztDeveloper =
|
||||||
|
data.hasDeveloperAccess !== undefined &&
|
||||||
|
before !== null &&
|
||||||
|
data.hasDeveloperAccess !== (before as any).hasDeveloperAccess;
|
||||||
|
|
||||||
|
// Keine Selbstbedienung. Die Teilmengenregel allein reicht dafuer nicht:
|
||||||
|
// Wer die Rechte bereits haelt, koennte sie sich formal selbst erneut
|
||||||
|
// zuweisen - und ein Vorgang, bei dem Antragsteller und Genehmigender
|
||||||
|
// dieselbe Person sind, hinterlaesst keine ueberpruefbare Spur.
|
||||||
|
if ((setztAuditBetrieb || setztDsgvo || setztDeveloper) && req.user?.userId === userId) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'DSGVO-Zugriff, Audit-Betrieb und Entwicklerzugriff lassen sich nicht am eigenen ' +
|
||||||
|
'Konto ändern. Bitte von einer anderen Person mit den entsprechenden Rechten ' +
|
||||||
|
'vornehmen lassen.',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Das Dienstkonto-Kennzeichen SENKT die Alarmstufe der Anmeldungen dieses
|
// Das Dienstkonto-Kennzeichen SENKT die Alarmstufe der Anmeldungen dieses
|
||||||
// Kontos (Pentest R184). Damit ist es selbst ein Hebel zur Waesche: Wer sein
|
// Kontos (Pentest R184). Damit ist es selbst ein Hebel zur Waesche: Wer sein
|
||||||
// Konto so markiert, laesst die eigenen auffaelligen Anmeldungen als
|
// Konto so markiert, laesst die eigenen auffaelligen Anmeldungen als
|
||||||
@@ -228,7 +294,12 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await userService.updateUser(userId, data as any);
|
const handelnder = handelnderOderNull(req);
|
||||||
|
if (handelnder === null) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = await userService.updateUser(userId, data as any, handelnder);
|
||||||
if (user) {
|
if (user) {
|
||||||
// Audit: Geänderte Felder ermitteln und loggen
|
// Audit: Geänderte Felder ermitteln und loggen
|
||||||
if (before) {
|
if (before) {
|
||||||
@@ -236,6 +307,7 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
const fieldLabels: Record<string, string> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
||||||
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
||||||
|
hasAuditOpsAccess: 'Audit-Betrieb (versiegeln, aufräumen, Aufbewahrung)',
|
||||||
isServiceAccount: 'Dienstkonto (Anmeldungen als Routine)',
|
isServiceAccount: 'Dienstkonto (Anmeldungen als Routine)',
|
||||||
};
|
};
|
||||||
for (const [key, newVal] of Object.entries(data)) {
|
for (const [key, newVal] of Object.entries(data)) {
|
||||||
@@ -265,7 +337,7 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
: `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
|
: `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
|
||||||
// Die Aenderung dieses Kennzeichens wird wie ihre Wirkung eingestuft:
|
// Die Aenderung dieses Kennzeichens wird wie ihre Wirkung eingestuft:
|
||||||
// Sie beeinflusst, wie kuenftige Anmeldungen bewertet werden.
|
// Sie beeinflusst, wie kuenftige Anmeldungen bewertet werden.
|
||||||
sensitivity: setztDienstkonto ? 'CRITICAL' : undefined,
|
sensitivity: setztDienstkonto || setztAuditBetrieb ? 'CRITICAL' : undefined,
|
||||||
details: Object.keys(changes).length > 0 ? changes : undefined,
|
details: Object.keys(changes).length > 0 ? changes : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -291,6 +363,67 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
details: { betroffenesKonto: user.email, aktiviert: aktiviertDienstkonto },
|
details: { betroffenesKonto: user.email, aktiviert: aktiviertDienstkonto },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DSGVO und Entwicklerzugriff bekamen bis 09/2026 KEINEN Eintrag im
|
||||||
|
// Alarmkanal - nur Audit-Betrieb und das Dienstkonto-Kennzeichen.
|
||||||
|
// Dabei traegt die Developer-Rolle saemtliche Rechte des Systems:
|
||||||
|
// Der weitreichendste Haken war der leiseste.
|
||||||
|
if (setztDeveloper) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PERMISSION_CHANGED',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
message: data.hasDeveloperAccess === true
|
||||||
|
? `Konto ${user.email} hat ab jetzt Entwicklerzugriff – das schliesst die ` +
|
||||||
|
'Datenbankwerkzeuge und damit saemtliche Rechte des Systems ein.'
|
||||||
|
: `Konto ${user.email} hat keinen Entwicklerzugriff mehr.`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { betroffenesKonto: user.email, aktiviert: data.hasDeveloperAccess === true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setztDsgvo) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PERMISSION_CHANGED',
|
||||||
|
severity: 'HIGH',
|
||||||
|
message: data.hasGdprAccess === true
|
||||||
|
? `Konto ${user.email} darf ab jetzt das Audit-Protokoll lesen und exportieren ` +
|
||||||
|
'sowie Auskunft und Loeschung nach DSGVO ausfuehren.'
|
||||||
|
: `Konto ${user.email} hat keinen DSGVO-Zugriff mehr – es kann damit keine ` +
|
||||||
|
'Auskunft nach Art. 15 und keine Loeschung nach Art. 17 mehr ausfuehren.',
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { betroffenesKonto: user.email, aktiviert: data.hasGdprAccess === true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setztAuditBetrieb) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PERMISSION_CHANGED',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
message: aktiviertAuditBetrieb
|
||||||
|
? `Konto ${user.email} darf ab jetzt am Audit-Protokoll EINGREIFEN: versiegeln, ` +
|
||||||
|
'neu berechnen, aufräumen, Aufbewahrung ändern. Damit kann es die Grundlage ' +
|
||||||
|
'ersetzen, gegen die Manipulation nachgewiesen wird.'
|
||||||
|
: `Konto ${user.email} darf nicht mehr am Audit-Protokoll eingreifen.`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: {
|
||||||
|
betroffenesKonto: user.email,
|
||||||
|
aktiviert: aktiviertAuditBetrieb,
|
||||||
|
selbstvergabe: user.id === req.user?.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'User',
|
req, action: 'UPDATE', resourceType: 'User',
|
||||||
@@ -301,10 +434,7 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
res.json({ success: true, data: user } as ApiResponse);
|
res.json({ success: true, data: user } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufRollenFehler(res, error, 'Fehler beim Aktualisieren des Benutzers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Benutzers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +491,15 @@ export async function setUserPassword(req: Request, res: Response): Promise<void
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const user = await userService.updateUser(userId, { password } as any);
|
const handelnderPw = handelnderOderNull(req);
|
||||||
|
if (handelnderPw === null) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reines Kennwort-Setzen: Der Zuwachs ist leer, die Teilmengenregel
|
||||||
|
// laeuft ins Leere. Der Handelnde wird trotzdem uebergeben, damit es
|
||||||
|
// keine Signatur gibt, die ihn weglassen darf.
|
||||||
|
const user = await userService.updateUser(userId, { password } as any, handelnderPw);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
|
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
@@ -383,10 +521,7 @@ export async function setUserPassword(req: Request, res: Response): Promise<void
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
|
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Setzen des Passworts');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Setzen des Passworts',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,14 +537,70 @@ export async function deleteUser(req: Request, res: Response): Promise<void> {
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Benutzer gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Benutzer gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Benutzers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Benutzers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Roles
|
// Roles
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meldet jede Aenderung am Rollenmodell in den Alarmkanal.
|
||||||
|
*
|
||||||
|
* Die Rollenpflege war bis 09/2026 der einzige Eingriff in die Rechtevergabe
|
||||||
|
* ohne SecurityEvent - Aenderungen an einzelnen Konten wurden gemeldet, das
|
||||||
|
* Umschreiben einer Rolle, die an zwanzig Konten haengt, nicht. Damit war
|
||||||
|
* ausgerechnet der wirksamste Weg der leiseste.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Die ID des Handelnden - oder null.
|
||||||
|
*
|
||||||
|
* `userId` fehlt bei Kundenportal-Anmeldungen. Ohne sie laesst sich die
|
||||||
|
* Teilmengenregel nicht auswerten, und "nicht auswertbar" darf nicht
|
||||||
|
* stillschweigend zu "erlaubt" werden. Deshalb wird hier abgelehnt statt
|
||||||
|
* durchgewinkt.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Weist Boolean-Felder ab, die keine Booleans sind.
|
||||||
|
*
|
||||||
|
* `isActive` und `isServiceAccount` steuern beide ein Gate, das strikt auf
|
||||||
|
* `boolean` prueft - ein `"ja"` rutschte daran vorbei, ohne das Gate
|
||||||
|
* auszuloesen, und lief danach in einen Prisma-Fehler. Kein Bypass, weil der
|
||||||
|
* Schreibvorgang scheiterte, aber ein 500 fuer eine Eingabe, die schlicht
|
||||||
|
* falsch war. Dieselbe Bauart wie R193-02 bei den drei Haken: Ein Gate, das
|
||||||
|
* strenger liest als der Rest, uebersieht genau das, was dazwischen passt.
|
||||||
|
*/
|
||||||
|
function pruefeBooleanFelder(daten: Record<string, unknown>, felder: string[]): string | null {
|
||||||
|
for (const feld of felder) {
|
||||||
|
const wert = daten[feld];
|
||||||
|
if (wert !== undefined && typeof wert !== 'boolean') {
|
||||||
|
return `${feld} muss true oder false sein (empfangen: ${JSON.stringify(wert)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handelnderOderNull(req: AuthRequest): number | null {
|
||||||
|
return typeof req.user?.userId === 'number' ? req.user.userId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function meldeRollenAenderung(
|
||||||
|
req: AuthRequest,
|
||||||
|
nachricht: string,
|
||||||
|
details: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PERMISSION_CHANGED',
|
||||||
|
severity: 'HIGH',
|
||||||
|
message: nachricht,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getRoles(req: Request, res: Response): Promise<void> {
|
export async function getRoles(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const roles = await userService.getAllRoles();
|
const roles = await userService.getAllRoles();
|
||||||
@@ -441,43 +632,164 @@ export async function getRole(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRole(req: Request, res: Response): Promise<void> {
|
/**
|
||||||
|
* Prueft Name und Rechte-IDs aus dem Request.
|
||||||
|
*
|
||||||
|
* Gibt einen Fehlertext zurueck oder null. Bis 09/2026 gab es das gar nicht:
|
||||||
|
* Ein leerer Name landete in der Datenbank, doppelte IDs liefen in einen
|
||||||
|
* Primaerschluesselkonflikt und eine erfundene ID in einen
|
||||||
|
* Fremdschluesselfehler - beides kam als HTTP 500 zurueck und sah damit nach
|
||||||
|
* einem Serverfehler aus, obwohl es eine ungueltige Eingabe war.
|
||||||
|
*/
|
||||||
|
function pruefeRollenEingabe(
|
||||||
|
daten: Partial<Record<string, unknown>>,
|
||||||
|
nameNoetig: boolean,
|
||||||
|
): string | null {
|
||||||
|
if (nameNoetig || daten.name !== undefined) {
|
||||||
|
if (typeof daten.name !== 'string' || daten.name.trim().length === 0) {
|
||||||
|
return 'Name der Rolle fehlt';
|
||||||
|
}
|
||||||
|
if (daten.name.trim().length > 100) {
|
||||||
|
return 'Name der Rolle ist zu lang (maximal 100 Zeichen)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (daten.description !== undefined && typeof daten.description !== 'string') {
|
||||||
|
return 'Beschreibung muss Text sein';
|
||||||
|
}
|
||||||
|
if (nameNoetig || daten.permissionIds !== undefined) {
|
||||||
|
const ids = daten.permissionIds;
|
||||||
|
if (!Array.isArray(ids)) return 'permissionIds muss eine Liste sein';
|
||||||
|
if (!ids.every((id) => Number.isInteger(id) && (id as number) >= 1)) {
|
||||||
|
return 'permissionIds darf nur positive ganze Zahlen enthalten';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bildet die Fehler aus der Rechteprüfung auf HTTP-Codes ab. */
|
||||||
|
function antworteAufRollenFehler(res: Response, error: unknown, fallback: string): void {
|
||||||
|
// 403 statt 400: "Sie duerfen das nicht" ist etwas anderes als "Ihre
|
||||||
|
// Eingabe ist kaputt". Ohne die Unterscheidung kann die Oberflaeche keinen
|
||||||
|
// brauchbaren Hinweis geben.
|
||||||
|
if (error instanceof RechteEskalationError || error instanceof RollenSperrError) {
|
||||||
|
res.status(403).json({ success: false, error: error.message } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof UngueltigeEingabeError) {
|
||||||
|
res.status(400).json({ success: false, error: error.message } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ab hier gilt: Nur was wir SELBST formuliert haben, geht nach draussen.
|
||||||
|
//
|
||||||
|
// Vorher wurde jede `error.message` durchgereicht. Damit landete erst der
|
||||||
|
// vollstaendige Prisma-Aufruf samt Serverpfad beim Client, und danach der
|
||||||
|
// Wortlaut eines TypeError ("object is not iterable…") bei
|
||||||
|
// `{"roleIds":{}}` (Pentest R190-01 und R192-01). Beide Male dieselbe
|
||||||
|
// Ursache: eine Fehlermeldung, die fuer Entwickler geschrieben ist, an
|
||||||
|
// einen Empfaenger, fuer den sie nicht gedacht war.
|
||||||
|
//
|
||||||
|
// Die Unterscheidung ist die Fehlerklasse, keine Heuristik auf dem Text:
|
||||||
|
// Ein blankes `Error` werfen wir absichtlich und mit einer Meldung fuer
|
||||||
|
// Menschen ("Der letzte Admin kann nicht…"). TypeError, RangeError und
|
||||||
|
// Verwandte sind Programmierfehler, Prisma-Fehler kommen von aussen -
|
||||||
|
// beides sagt dem Aufrufer nichts Nuetzliches und dem Angreifer zu viel.
|
||||||
|
const istAbsichtlich = error instanceof Error && error.constructor === Error;
|
||||||
|
if (istAbsichtlich) {
|
||||||
|
res.status(400).json({ success: false, error: error.message } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unerwartet: Das ist ein Serverfehler, kein Eingabefehler - und ein 400
|
||||||
|
// waere hier die naechste Meldung, die sich falsch ausgibt. Einzelheiten
|
||||||
|
// ins Protokoll, damit sie nicht verlorengehen.
|
||||||
|
console.error(`[${fallback}] unerwarteter Fehler:`, error);
|
||||||
|
res.status(500).json({ success: false, error: fallback } as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRole(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const role = await userService.createRole(req.body);
|
const daten = pickRoleUpdate(req.body);
|
||||||
|
const fehler = pruefeRollenEingabe(daten, true);
|
||||||
|
if (fehler) {
|
||||||
|
res.status(400).json({ success: false, error: fehler } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handelnder = handelnderOderNull(req);
|
||||||
|
if (handelnder === null) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await userService.createRole(
|
||||||
|
{
|
||||||
|
name: daten.name as string,
|
||||||
|
description: daten.description as string | undefined,
|
||||||
|
permissionIds: daten.permissionIds as number[],
|
||||||
|
},
|
||||||
|
handelnder,
|
||||||
|
);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'Role',
|
req, action: 'CREATE', resourceType: 'Role',
|
||||||
resourceId: role.id.toString(),
|
resourceId: role.id.toString(),
|
||||||
label: `Rolle ${role.name} angelegt`,
|
label: `Rolle ${role.name} angelegt`,
|
||||||
});
|
});
|
||||||
|
meldeRollenAenderung(req, `Rolle "${role.name}" angelegt`, {
|
||||||
|
rolle: role.name,
|
||||||
|
rechte: role.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`),
|
||||||
|
});
|
||||||
res.status(201).json({ success: true, data: role } as ApiResponse);
|
res.status(201).json({ success: true, data: role } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufRollenFehler(res, error, 'Fehler beim Erstellen der Rolle');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Rolle',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRole(req: Request, res: Response): Promise<void> {
|
export async function updateRole(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const role = await userService.updateRole(parseInt(req.params.id), req.body);
|
const daten = pickRoleUpdate(req.body);
|
||||||
if (role) {
|
const fehler = pruefeRollenEingabe(daten, false);
|
||||||
await logChange({
|
if (fehler) {
|
||||||
req, action: 'UPDATE', resourceType: 'Role',
|
res.status(400).json({ success: false, error: fehler } as ApiResponse);
|
||||||
resourceId: role.id.toString(),
|
return;
|
||||||
label: `Rolle ${role.name} aktualisiert`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handelnder = handelnderOderNull(req);
|
||||||
|
if (handelnder === null) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await userService.updateRole(
|
||||||
|
parseInt(req.params.id),
|
||||||
|
{
|
||||||
|
name: daten.name as string | undefined,
|
||||||
|
description: daten.description as string | undefined,
|
||||||
|
permissionIds: daten.permissionIds as number[] | undefined,
|
||||||
|
},
|
||||||
|
handelnder,
|
||||||
|
);
|
||||||
|
if (!role) {
|
||||||
|
res.status(404).json({ success: false, error: 'Rolle nicht gefunden' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await logChange({
|
||||||
|
req, action: 'UPDATE', resourceType: 'Role',
|
||||||
|
resourceId: role.id.toString(),
|
||||||
|
label: `Rolle ${role.name} aktualisiert`,
|
||||||
|
});
|
||||||
|
meldeRollenAenderung(req, `Rechte der Rolle "${role.name}" geändert`, {
|
||||||
|
rolle: role.name,
|
||||||
|
rechte: role.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`),
|
||||||
|
traegerAbgemeldet: daten.permissionIds !== undefined,
|
||||||
|
});
|
||||||
res.json({ success: true, data: role } as ApiResponse);
|
res.json({ success: true, data: role } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufRollenFehler(res, error, 'Fehler beim Aktualisieren der Rolle');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Rolle',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRole(req: Request, res: Response): Promise<void> {
|
export async function deleteRole(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const roleId = parseInt(req.params.id);
|
const roleId = parseInt(req.params.id);
|
||||||
const role = await userService.getRoleById(roleId);
|
const role = await userService.getRoleById(roleId);
|
||||||
@@ -487,12 +799,12 @@ export async function deleteRole(req: Request, res: Response): Promise<void> {
|
|||||||
resourceId: roleId.toString(),
|
resourceId: roleId.toString(),
|
||||||
label: `Rolle ${role?.name || roleId} gelöscht`,
|
label: `Rolle ${role?.name || roleId} gelöscht`,
|
||||||
});
|
});
|
||||||
|
meldeRollenAenderung(req, `Rolle "${role?.name || roleId}" gelöscht`, {
|
||||||
|
rolle: role?.name,
|
||||||
|
});
|
||||||
res.json({ success: true, message: 'Rolle gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Rolle gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufRollenFehler(res, error, 'Fehler beim Löschen der Rolle');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Rolle',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+65
-54
@@ -1,4 +1,4 @@
|
|||||||
import express from 'express';
|
import express, { Router } from 'express';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
@@ -69,9 +69,11 @@ import { startContractStatusScheduler } from './services/contractStatusScheduler
|
|||||||
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
|
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
|
||||||
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
||||||
import monitoringRoutes from './routes/monitoring.routes.js';
|
import monitoringRoutes from './routes/monitoring.routes.js';
|
||||||
|
import { registriereIdPruefung } from './middleware/routeIds.js';
|
||||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||||
import { auditMiddleware } from './middleware/audit.js';
|
import { auditMiddleware } from './middleware/audit.js';
|
||||||
import { starteHeartbeatMonitor } from './services/heartbeatMonitor.service.js';
|
import { starteHeartbeatMonitor } from './services/heartbeatMonitor.service.js';
|
||||||
|
import { pruefePflichtrechte } from './services/pflichtrechte.service.js';
|
||||||
import { authenticate } from './middleware/auth.js';
|
import { authenticate } from './middleware/auth.js';
|
||||||
|
|
||||||
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
||||||
@@ -326,69 +328,76 @@ app.use('/api', (_req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
|
// HIER STAND eine Pfad-Heuristik gegen abgeschnittene IDs (Pentest Runde 7,
|
||||||
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
|
// 2026-05-17): Sie blockte Segmente der Form `^\d+[a-zA-Z]+$` – also `6abc`,
|
||||||
// wurde – kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
|
// weil `parseInt('6abc')` die 6 ergibt und `/api/customers/6abc` still als
|
||||||
// Validierung. Pentest Runde 7 (2026-05-17), LOW.
|
// Kunde 6 gelesen wurde. Ihr eigener Kommentar nannte den Grund fuer die
|
||||||
|
// Heuristik: „`app.param()` greift nicht auf in Sub-Router gemounteten Routes".
|
||||||
//
|
//
|
||||||
// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb
|
// Genau das loest `mounte()` weiter unten – die Pruefung wird an jedem Router
|
||||||
// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$` –
|
// registriert, nicht am App-Objekt. Damit ist die Heuristik abgeloest, und
|
||||||
// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie
|
// zwar in beide Richtungen:
|
||||||
// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings
|
//
|
||||||
// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt
|
// – Sie war zu eng: `/api/users/abc` ging durch (keine Ziffer vorn) und
|
||||||
// nicht geblockt.
|
// endete als 500. Die Parameter-Pruefung kennt dagegen die tatsaechlichen
|
||||||
const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/;
|
// ID-Parameter und laesst nur kanonische Zahlen zu.
|
||||||
app.use('/api', (req, res, next) => {
|
// – Sie war zu weit: Ein Einstellungs-Schluessel `12abc` unter
|
||||||
for (const seg of req.path.split('/')) {
|
// `/api/settings/:key` wurde geblockt, obwohl `:key` gar keine ID ist.
|
||||||
if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) {
|
//
|
||||||
res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' });
|
// Und sie antwortete 400, wo die Parameter-Pruefung 404 gibt – zwei Antworten
|
||||||
return;
|
// fuer dieselbe Eingabeklasse. Siehe middleware/routeIds.ts.
|
||||||
}
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148).
|
// Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148).
|
||||||
// Großzügige Obergrenze pro IP – ergänzt die feineren Limiter (Login etc.),
|
// Großzügige Obergrenze pro IP – ergänzt die feineren Limiter (Login etc.),
|
||||||
// die als erste greifen. Siehe middleware/rateLimit.ts für die Begründung.
|
// die als erste greifen. Siehe middleware/rateLimit.ts für die Begründung.
|
||||||
app.use('/api', apiBackstopRateLimiter);
|
app.use('/api', apiBackstopRateLimiter);
|
||||||
|
|
||||||
|
// Router einhängen – IMMER über `mounte`, nie über `app.use` direkt.
|
||||||
|
//
|
||||||
|
// `mounte` haengt die zentrale Pruefung numerischer Pfad-Parameter an (R188)
|
||||||
|
// und montiert danach. Wer hier kuenftig `app.use` schreibt, umgeht sie
|
||||||
|
// stillschweigend – deshalb steht die Pruefung im selben Handgriff wie das
|
||||||
|
// Einhaengen und nicht in einer zweiten Liste, die man vergessen kann.
|
||||||
|
const mounte = (pfad: string, router: Router): void => {
|
||||||
|
app.use(pfad, registriereIdPruefung(router));
|
||||||
|
};
|
||||||
|
|
||||||
// Öffentliche Routes (OHNE Authentifizierung)
|
// Öffentliche Routes (OHNE Authentifizierung)
|
||||||
app.use('/api/public/consent', consentPublicRoutes);
|
mounte('/api/public/consent', consentPublicRoutes);
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.use('/api/auth', authRoutes);
|
mounte('/api/auth', authRoutes);
|
||||||
app.use('/api/customers', customerRoutes);
|
mounte('/api/customers', customerRoutes);
|
||||||
app.use('/api/addresses', addressRoutes);
|
mounte('/api/addresses', addressRoutes);
|
||||||
app.use('/api/bank-cards', bankcardRoutes);
|
mounte('/api/bank-cards', bankcardRoutes);
|
||||||
app.use('/api/documents', documentRoutes);
|
mounte('/api/documents', documentRoutes);
|
||||||
app.use('/api/meters', meterRoutes);
|
mounte('/api/meters', meterRoutes);
|
||||||
app.use('/api/stressfrei-emails', stressfreiEmailRoutes);
|
mounte('/api/stressfrei-emails', stressfreiEmailRoutes);
|
||||||
app.use('/api/contracts', contractRoutes);
|
mounte('/api/contracts', contractRoutes);
|
||||||
app.use('/api/credit-notes', creditNoteRoutes);
|
mounte('/api/credit-notes', creditNoteRoutes);
|
||||||
app.use('/api/company-profile', companyProfileRoutes);
|
mounte('/api/company-profile', companyProfileRoutes);
|
||||||
app.use('/api/platforms', platformRoutes);
|
mounte('/api/platforms', platformRoutes);
|
||||||
app.use('/api/cancellation-periods', cancellationPeriodRoutes);
|
mounte('/api/cancellation-periods', cancellationPeriodRoutes);
|
||||||
app.use('/api/contract-durations', contractDurationRoutes);
|
mounte('/api/contract-durations', contractDurationRoutes);
|
||||||
app.use('/api/providers', providerRoutes);
|
mounte('/api/providers', providerRoutes);
|
||||||
app.use('/api/tariffs', tariffRoutes);
|
mounte('/api/tariffs', tariffRoutes);
|
||||||
app.use('/api/users', userRoutes);
|
mounte('/api/users', userRoutes);
|
||||||
app.use('/api/upload', uploadRoutes);
|
mounte('/api/upload', uploadRoutes);
|
||||||
app.use('/api/developer', developerRoutes);
|
mounte('/api/developer', developerRoutes);
|
||||||
app.use('/api/contract-categories', contractCategoryRoutes);
|
mounte('/api/contract-categories', contractCategoryRoutes);
|
||||||
app.use('/api', contractTaskRoutes);
|
mounte('/api', contractTaskRoutes);
|
||||||
app.use('/api/settings', appSettingRoutes);
|
mounte('/api/settings', appSettingRoutes);
|
||||||
app.use('/api/email-providers', emailProviderRoutes);
|
mounte('/api/email-providers', emailProviderRoutes);
|
||||||
app.use('/api', cachedEmailRoutes);
|
mounte('/api', cachedEmailRoutes);
|
||||||
app.use('/api/energy-details', invoiceRoutes);
|
mounte('/api/energy-details', invoiceRoutes);
|
||||||
app.use('/api', contractHistoryRoutes);
|
mounte('/api', contractHistoryRoutes);
|
||||||
app.use('/api/audit-logs', auditLogRoutes);
|
mounte('/api/audit-logs', auditLogRoutes);
|
||||||
app.use('/api/gdpr', gdprRoutes);
|
mounte('/api/gdpr', gdprRoutes);
|
||||||
app.use('/api/email-logs', emailLogRoutes);
|
mounte('/api/email-logs', emailLogRoutes);
|
||||||
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
mounte('/api/pdf-templates', pdfTemplateRoutes);
|
||||||
app.use('/api/birthdays', birthdayRoutes);
|
mounte('/api/birthdays', birthdayRoutes);
|
||||||
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
mounte('/api/factory-defaults', factoryDefaultsRoutes);
|
||||||
app.use('/api/monitoring', monitoringRoutes);
|
mounte('/api/monitoring', monitoringRoutes);
|
||||||
|
|
||||||
// Health check – BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
|
// Health check – BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
|
||||||
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
|
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
|
||||||
@@ -491,6 +500,8 @@ const LISTEN_ADDR = process.env.LISTEN_ADDR
|
|||||||
// Wachhund auf ausbleibende Dienstkonto-Anmeldungen (Pentest R182/R183):
|
// Wachhund auf ausbleibende Dienstkonto-Anmeldungen (Pentest R182/R183):
|
||||||
// Ein stillgelegtes Gegenbuch soll auffallen, nicht als Ruhe durchgehen.
|
// Ein stillgelegtes Gegenbuch soll auffallen, nicht als Ruhe durchgehen.
|
||||||
starteHeartbeatMonitor();
|
starteHeartbeatMonitor();
|
||||||
|
// Erkennt die ABWESENHEIT der DSGVO-/Audit-Faehigkeit (R189-01).
|
||||||
|
void pruefePflichtrechte();
|
||||||
|
|
||||||
app.listen(PORT as number, LISTEN_ADDR, () => {
|
app.listen(PORT as number, LISTEN_ADDR, () => {
|
||||||
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
|
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { PORTAL_RECHTE } from '../config/rechte-katalog.js';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { AuthRequest, JwtPayload } from '../types/index.js';
|
import { AuthRequest, JwtPayload } from '../types/index.js';
|
||||||
@@ -128,7 +129,18 @@ export function requirePermission(...requiredPermissions: string[]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userPermissions = req.user.permissions || [];
|
// Portal-Zugaenge bekommen hier nur, was in PORTAL_RECHTE steht -
|
||||||
|
// unabhaengig davon, was ihr Token behauptet.
|
||||||
|
//
|
||||||
|
// Das ist ein Riegel, kein Filter: Die Trennung zwischen Kundenportal und
|
||||||
|
// Rollensystem ist Absicht (siehe config/rechte-katalog.ts). Ein Kunde
|
||||||
|
// darf niemals operative Rechte bekommen - die Portalansicht ist dafuer
|
||||||
|
// nicht gebaut und prueft es nicht. Bisher haette eine einzige unbedachte
|
||||||
|
// Zeile in der Token-Erzeugung gereicht, um das zu kippen; ab hier
|
||||||
|
// braeuchte es zwei, an zwei Stellen, in Kenntnis dieses Kommentars.
|
||||||
|
const userPermissions = req.user.isCustomerPortal
|
||||||
|
? (req.user.permissions || []).filter((p) => PORTAL_RECHTE.includes(p))
|
||||||
|
: req.user.permissions || [];
|
||||||
|
|
||||||
// Check if user has any of the required permissions
|
// Check if user has any of the required permissions
|
||||||
const hasPermission = requiredPermissions.some((perm) =>
|
const hasPermission = requiredPermissions.some((perm) =>
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Request, Response, NextFunction, Router, RequestParamHandler } from 'express';
|
||||||
|
import { ApiResponse } from '../types/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numerische Pfad-Parameter zentral pruefen (Pentest R188).
|
||||||
|
*
|
||||||
|
* Ausgangslage: In den Controllern stand 181-mal `parseInt(req.params.<x>)`
|
||||||
|
* ohne Pruefung. Das hatte zwei Folgen, und die zweite ist die unangenehmere:
|
||||||
|
*
|
||||||
|
* 1. Ein nicht-numerisches Segment ergab `NaN`, Prisma lief auf und der
|
||||||
|
* Handler antwortete mit **500**. `GET /api/users/permissions` etwa
|
||||||
|
* trifft `/:id` und war damit ein Serverfehler statt „gibt es nicht".
|
||||||
|
*
|
||||||
|
* 2. `parseInt` liest so weit, wie es kann: `parseInt('12abc')` ist **12**.
|
||||||
|
* `GET /api/users/12abc` lieferte also Benutzer 12 aus. Eine schlampig
|
||||||
|
* geratene ID wurde stillschweigend zu einer gueltigen gemacht.
|
||||||
|
*
|
||||||
|
* Die naheliegende Antwort waere gewesen, an allen 181 Stellen einen Guard
|
||||||
|
* einzusetzen. Genau daran haben wir uns in dieser Reihe mehrfach die Finger
|
||||||
|
* verbrannt: Was an vielen Stellen gepflegt werden muss, laeuft auseinander
|
||||||
|
* (zuletzt R186-01, drei Filterlisten, die dasselbe bedeuten sollten). Deshalb
|
||||||
|
* eine Stelle statt 181 – und eine neue Route ist automatisch mit abgedeckt.
|
||||||
|
*
|
||||||
|
* Umgesetzt ueber `router.param()`: Express ruft den Callback, bevor der
|
||||||
|
* Handler laeuft, und nur fuer Routen, die den Parameter wirklich benutzen.
|
||||||
|
*
|
||||||
|
* **Antwort ist 404, nicht 400.** Ein Pfad-Segment, das keine ID sein kann,
|
||||||
|
* benennt keine Ressource – das ist „nicht gefunden", nicht „falsch gefragt".
|
||||||
|
* Der Codebestand hat es an der einzigen bereits abgesicherten Stelle
|
||||||
|
* (`provider.controller.ts`, Pentest Mai 2026) genauso entschieden. Nebenbei
|
||||||
|
* verraet eine einheitliche 404 einem Probierenden nicht, ob ein Endpunkt
|
||||||
|
* existiert und eine Zahl erwartet oder ob es den Pfad gar nicht gibt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameternamen, die eine Datenbank-ID bezeichnen – abgeglichen mit allen
|
||||||
|
* Routendateien. Bewusst NICHT dabei und deshalb unberuehrt: `consentType`,
|
||||||
|
* `filename`, `hash`, `key`, `localPart`, `name`, `tableName`.
|
||||||
|
*
|
||||||
|
* Die Namensregel „heisst `id` oder endet auf `Id`" gilt im gesamten Bestand;
|
||||||
|
* eine neue Route mit `:fooId` gehoert hier ergaenzt.
|
||||||
|
*/
|
||||||
|
const ID_PARAMETER = [
|
||||||
|
'id',
|
||||||
|
'contractId',
|
||||||
|
'contractMeterId',
|
||||||
|
'customerId',
|
||||||
|
'documentId',
|
||||||
|
'ecdId',
|
||||||
|
'emailId',
|
||||||
|
'entryId',
|
||||||
|
'invoiceId',
|
||||||
|
'meterId',
|
||||||
|
'phoneNumberId',
|
||||||
|
'providerId',
|
||||||
|
'readingId',
|
||||||
|
'referralId',
|
||||||
|
'representativeId',
|
||||||
|
'simCardId',
|
||||||
|
'subtaskId',
|
||||||
|
'taskId',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Obergrenze von MySQL INT – darueber gibt es keine Zeile, nur einen Fehler. */
|
||||||
|
const MAX_ID = 2147483647;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gueltig ist ausschliesslich eine kanonische positive Ganzzahl.
|
||||||
|
*
|
||||||
|
* Fuehrende Nullen werden abgelehnt: `007` und `7` wuerden dieselbe Zeile
|
||||||
|
* bezeichnen, und zwei Schreibweisen fuer dieselbe Ressource sind eine
|
||||||
|
* unnoetige Einladung – etwa fuer Zaehler, Zwischenspeicher oder Sperren, die
|
||||||
|
* auf dem Pfad als Schluessel arbeiten.
|
||||||
|
*/
|
||||||
|
export function istGueltigeId(wert: string): boolean {
|
||||||
|
if (!/^[1-9][0-9]*$/.test(wert)) return false;
|
||||||
|
const n = Number(wert);
|
||||||
|
return Number.isSafeInteger(n) && n <= MAX_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pruefeIdParameter: RequestParamHandler = (
|
||||||
|
_req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
wert: unknown,
|
||||||
|
) => {
|
||||||
|
if (typeof wert === 'string' && istGueltigeId(wert)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Bewusst ohne Angabe, WELCHER Parameter beanstandet wurde: Die Meldung
|
||||||
|
// soll nicht zur Landkarte werden, welche Endpunkte welche IDs erwarten.
|
||||||
|
res.status(404).json({ success: false, error: 'Nicht gefunden' } as ApiResponse);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haengt die Pruefung an einen Router. Namen, die der Router gar nicht
|
||||||
|
* verwendet, kosten nichts – Express ruft den Callback nur fuer Parameter,
|
||||||
|
* die in einer getroffenen Route vorkommen.
|
||||||
|
*/
|
||||||
|
export function registriereIdPruefung<T extends Router>(router: T): T {
|
||||||
|
for (const name of ID_PARAMETER) {
|
||||||
|
router.param(name, pruefeIdParameter);
|
||||||
|
}
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -61,11 +61,17 @@ router.post(
|
|||||||
backupController.createBackup
|
backupController.createBackup
|
||||||
);
|
);
|
||||||
|
|
||||||
// Backup wiederherstellen
|
// Backup wiederherstellen.
|
||||||
|
//
|
||||||
|
// Ebenfalls UND-verknuepft: Ein Restore ersetzt auch Benutzer, Rollen und
|
||||||
|
// Rechtezuordnungen. Wer eine aeltere Sicherung einspielt, in der er selbst
|
||||||
|
// mehr durfte, hat sich damit befoerdert - derselbe Umweg wie beim
|
||||||
|
// Werksreset, nur leiser.
|
||||||
router.post(
|
router.post(
|
||||||
'/backup/:name/restore',
|
'/backup/:name/restore',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings:update'),
|
requirePermission('settings:update'),
|
||||||
|
requirePermission('roles:manage'),
|
||||||
backupController.restoreBackup
|
backupController.restoreBackup
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -94,11 +100,23 @@ router.post(
|
|||||||
backupController.uploadBackup
|
backupController.uploadBackup
|
||||||
);
|
);
|
||||||
|
|
||||||
// Werkseinstellungen (alles löschen)
|
// Werkseinstellungen (alles löschen).
|
||||||
|
//
|
||||||
|
// Verlangt UND-verknuepft `settings:update` und `roles:manage` - zwei
|
||||||
|
// requirePermission hintereinander, weil ein einzelner Aufruf mit mehreren
|
||||||
|
// Rechten ODER bedeutet.
|
||||||
|
//
|
||||||
|
// Grund: Der Werksreset loescht alle Rollen und Rechtezuordnungen und legt
|
||||||
|
// ein frisches admin@admin.com an. Eine Rolle mit nur `settings:update`
|
||||||
|
// haette damit die gesamte Rechtevergabe zuruecksetzen und sich anschliessend
|
||||||
|
// ueber das neue Konto anmelden koennen - eine Rechteerhoehung ueber den
|
||||||
|
// Umweg "alles wegwerfen". Wer das Rechtemodell platt machen darf, muss es
|
||||||
|
// auch pflegen duerfen.
|
||||||
router.post(
|
router.post(
|
||||||
'/factory-reset',
|
'/factory-reset',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings:update'),
|
requirePermission('settings:update'),
|
||||||
|
requirePermission('roles:manage'),
|
||||||
backupController.factoryReset
|
backupController.factoryReset
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
// Kuendigungsfristen haben eigene Rechte (`cancellation-periods:*`).
|
||||||
|
//
|
||||||
|
// Bis 09/2026 gateten die Schreibwege auf `platforms:*` und die Lesewege gar
|
||||||
|
// nicht. `cancellation-periods:*` stand im Katalog und bewachte nichts.
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as cancellationPeriodController from '../controllers/cancellation-period.controller.js';
|
import * as cancellationPeriodController from '../controllers/cancellation-period.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', authenticate, cancellationPeriodController.getCancellationPeriods);
|
router.get('/', authenticate, requirePermission('cancellation-periods:read'), cancellationPeriodController.getCancellationPeriods);
|
||||||
router.post('/', authenticate, requirePermission('platforms:create'), cancellationPeriodController.createCancellationPeriod);
|
router.post('/', authenticate, requirePermission('cancellation-periods:create'), cancellationPeriodController.createCancellationPeriod);
|
||||||
router.get('/:id', authenticate, cancellationPeriodController.getCancellationPeriod);
|
router.get('/:id', authenticate, requirePermission('cancellation-periods:read'), cancellationPeriodController.getCancellationPeriod);
|
||||||
router.put('/:id', authenticate, requirePermission('platforms:update'), cancellationPeriodController.updateCancellationPeriod);
|
router.put('/:id', authenticate, requirePermission('cancellation-periods:update'), cancellationPeriodController.updateCancellationPeriod);
|
||||||
router.delete('/:id', authenticate, requirePermission('platforms:delete'), cancellationPeriodController.deleteCancellationPeriod);
|
router.delete('/:id', authenticate, requirePermission('cancellation-periods:delete'), cancellationPeriodController.deleteCancellationPeriod);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
// Vertragslaufzeiten haben eigene Rechte (`contract-durations:*`).
|
||||||
|
//
|
||||||
|
// Bis 09/2026 gateten die Schreibwege auf `platforms:*` und die Lesewege gar
|
||||||
|
// nicht. `contract-durations:*` stand im Katalog und bewachte nichts.
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as contractDurationController from '../controllers/contract-duration.controller.js';
|
import * as contractDurationController from '../controllers/contract-duration.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', authenticate, contractDurationController.getContractDurations);
|
router.get('/', authenticate, requirePermission('contract-durations:read'), contractDurationController.getContractDurations);
|
||||||
router.post('/', authenticate, requirePermission('platforms:create'), contractDurationController.createContractDuration);
|
router.post('/', authenticate, requirePermission('contract-durations:create'), contractDurationController.createContractDuration);
|
||||||
router.get('/:id', authenticate, contractDurationController.getContractDuration);
|
router.get('/:id', authenticate, requirePermission('contract-durations:read'), contractDurationController.getContractDuration);
|
||||||
router.put('/:id', authenticate, requirePermission('platforms:update'), contractDurationController.updateContractDuration);
|
router.put('/:id', authenticate, requirePermission('contract-durations:update'), contractDurationController.updateContractDuration);
|
||||||
router.delete('/:id', authenticate, requirePermission('platforms:delete'), contractDurationController.deleteContractDuration);
|
router.delete('/:id', authenticate, requirePermission('contract-durations:delete'), contractDurationController.deleteContractDuration);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Lesen für alle authentifizierten Benutzer
|
// Lesen verlangt jetzt `contract-categories:read`, vorher genuegte
|
||||||
router.get('/', authenticate, contractCategoryController.getContractCategories);
|
// Angemeldetsein. Das Recht stand im Katalog und bewachte nichts; die
|
||||||
router.get('/:id', authenticate, contractCategoryController.getContractCategory);
|
// Migration vergibt es an jede Rolle, die die Anwendung benutzt, damit
|
||||||
|
// niemand seine Listen verliert.
|
||||||
|
router.get('/', authenticate, requirePermission('contract-categories:read'), contractCategoryController.getContractCategories);
|
||||||
|
router.get('/:id', authenticate, requirePermission('contract-categories:read'), contractCategoryController.getContractCategory);
|
||||||
|
|
||||||
// Ändern/Löschen: `contract-categories:*` – wird per seed.ts an Admin-
|
// Ändern/Löschen: `contract-categories:*` – wird per seed.ts an Admin-
|
||||||
// Rollen vergeben. Vorher stand hier `developer:access` mit dem
|
// Rollen vergeben. Vorher stand hier `developer:access` mit dem
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
// Die Providerkonfiguration hat eigene Rechte (`email-providers:*`).
|
||||||
|
//
|
||||||
|
// Bis 09/2026 gateten diese Routen auf `settings:read`/`settings:update` -
|
||||||
|
// wer irgendeine Einstellung aendern durfte, konnte damit auch die
|
||||||
|
// Zugangsdaten des Mailservers auslesen und aendern. `email-providers:*`
|
||||||
|
// stand derweil im Katalog und bewachte nichts.
|
||||||
|
//
|
||||||
|
// BEWUSST NICHT umgestellt: /domain und /public-settings (ungegatet, werden
|
||||||
|
// vom Kundenportal beim Postfach-Antrag gebraucht) sowie check/provision/
|
||||||
|
// deprovision - das sind kundenbezogene Vorgaenge auf `customers:*`, keine
|
||||||
|
// Providerkonfiguration. `email-providers:*` waere dort die falsche Domaene.
|
||||||
// ==================== EMAIL PROVIDER ROUTES ====================
|
// ==================== EMAIL PROVIDER ROUTES ====================
|
||||||
|
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
@@ -7,15 +18,15 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Provider Config CRUD (Admin-only)
|
// Provider Config CRUD (Admin-only)
|
||||||
router.get('/configs', authenticate, requirePermission('settings:read'), emailProviderController.getProviderConfigs);
|
router.get('/configs', authenticate, requirePermission('email-providers:read'), emailProviderController.getProviderConfigs);
|
||||||
router.get('/configs/:id', authenticate, requirePermission('settings:read'), emailProviderController.getProviderConfig);
|
router.get('/configs/:id', authenticate, requirePermission('email-providers:read'), emailProviderController.getProviderConfig);
|
||||||
router.post('/configs', authenticate, requirePermission('settings:update'), emailProviderController.createProviderConfig);
|
router.post('/configs', authenticate, requirePermission('email-providers:create'), emailProviderController.createProviderConfig);
|
||||||
router.put('/configs/:id', authenticate, requirePermission('settings:update'), emailProviderController.updateProviderConfig);
|
router.put('/configs/:id', authenticate, requirePermission('email-providers:update'), emailProviderController.updateProviderConfig);
|
||||||
router.delete('/configs/:id', authenticate, requirePermission('settings:update'), emailProviderController.deleteProviderConfig);
|
router.delete('/configs/:id', authenticate, requirePermission('email-providers:delete'), emailProviderController.deleteProviderConfig);
|
||||||
|
|
||||||
// Email Operations
|
// Email Operations
|
||||||
router.post('/test-connection', authenticate, requirePermission('settings:update'), emailProviderController.testConnection);
|
router.post('/test-connection', authenticate, requirePermission('email-providers:update'), emailProviderController.testConnection);
|
||||||
router.post('/test-mail-access', authenticate, requirePermission('settings:update'), emailProviderController.testMailAccess);
|
router.post('/test-mail-access', authenticate, requirePermission('email-providers:update'), emailProviderController.testMailAccess);
|
||||||
router.get('/domain', authenticate, emailProviderController.getProviderDomain);
|
router.get('/domain', authenticate, emailProviderController.getProviderDomain);
|
||||||
router.get('/public-settings', authenticate, emailProviderController.getPublicSettings);
|
router.get('/public-settings', authenticate, emailProviderController.getPublicSettings);
|
||||||
router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists);
|
router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
// Lesen verlangt jetzt `platforms:read`, vorher genuegte Angemeldetsein.
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as platformController from '../controllers/platform.controller.js';
|
import * as platformController from '../controllers/platform.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', authenticate, platformController.getPlatforms);
|
router.get('/', authenticate, requirePermission('platforms:read'), platformController.getPlatforms);
|
||||||
router.post('/', authenticate, requirePermission('platforms:create'), platformController.createPlatform);
|
router.post('/', authenticate, requirePermission('platforms:create'), platformController.createPlatform);
|
||||||
router.get('/:id', authenticate, platformController.getPlatform);
|
router.get('/:id', authenticate, requirePermission('platforms:read'), platformController.getPlatform);
|
||||||
router.put('/:id', authenticate, requirePermission('platforms:update'), platformController.updatePlatform);
|
router.put('/:id', authenticate, requirePermission('platforms:update'), platformController.updatePlatform);
|
||||||
router.delete('/:id', authenticate, requirePermission('platforms:delete'), platformController.deletePlatform);
|
router.delete('/:id', authenticate, requirePermission('platforms:delete'), platformController.deletePlatform);
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ router.get('/:id', authenticate, requirePermission('providers:read'), providerCo
|
|||||||
router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider);
|
router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider);
|
||||||
router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
|
router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
|
||||||
|
|
||||||
// Nested tariff routes
|
// Tarife unter dem Anbieter - eigene Rechte, siehe tariff.routes.ts
|
||||||
router.get('/:providerId/tariffs', authenticate, requirePermission('providers:read'), tariffController.getTariffs);
|
router.get('/:providerId/tariffs', authenticate, requirePermission('tariffs:read'), tariffController.getTariffs);
|
||||||
router.post('/:providerId/tariffs', authenticate, requirePermission('providers:create'), tariffController.createTariff);
|
router.post('/:providerId/tariffs', authenticate, requirePermission('tariffs:create'), tariffController.createTariff);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
// Tarife haben eigene Rechte (`tariffs:*`).
|
||||||
|
//
|
||||||
|
// Bis 09/2026 gateten diese Routen auf `providers:*`, waehrend `tariffs:*`
|
||||||
|
// im Katalog stand und nichts bewachte - man konnte "Tarife bearbeiten"
|
||||||
|
// anhaken, ohne dass es etwas bewirkte, und wer Anbieter pflegen durfte,
|
||||||
|
// durfte automatisch auch alle Tarife aendern.
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as tariffController from '../controllers/tariff.controller.js';
|
import * as tariffController from '../controllers/tariff.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
@@ -5,8 +11,8 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Standalone tariff routes (for update/delete by tariff id)
|
// Standalone tariff routes (for update/delete by tariff id)
|
||||||
router.get('/:id', authenticate, requirePermission('providers:read'), tariffController.getTariff);
|
router.get('/:id', authenticate, requirePermission('tariffs:read'), tariffController.getTariff);
|
||||||
router.put('/:id', authenticate, requirePermission('providers:update'), tariffController.updateTariff);
|
router.put('/:id', authenticate, requirePermission('tariffs:update'), tariffController.updateTariff);
|
||||||
router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff);
|
router.delete('/:id', authenticate, requirePermission('tariffs:delete'), tariffController.deleteTariff);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -16,14 +16,26 @@ router.delete('/:id', authenticate, requirePermission('users:delete'), userContr
|
|||||||
// davor, damit ein gestohlener JWT das Admin-Passwort nicht brute-forcen kann.
|
// davor, damit ein gestohlener JWT das Admin-Passwort nicht brute-forcen kann.
|
||||||
router.post('/:id/password', staffPasswordReAuthLimiter, authenticate, requirePermission('users:update'), userController.setUserPassword);
|
router.post('/:id/password', staffPasswordReAuthLimiter, authenticate, requirePermission('users:update'), userController.setUserPassword);
|
||||||
|
|
||||||
// Roles
|
// Rollen und Rechte.
|
||||||
router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles);
|
//
|
||||||
router.post('/roles', authenticate, requirePermission('users:create'), userController.createRole);
|
// Die Schreibwege haengen an `roles:manage`, nicht mehr an `users:*`:
|
||||||
router.get('/roles/:id', authenticate, requirePermission('users:read'), userController.getRole);
|
// "Konten anlegen" und "festlegen, was ein Konto darf" sind zwei
|
||||||
router.put('/roles/:id', authenticate, requirePermission('users:update'), userController.updateRole);
|
// verschiedene Befugnisse. Wer beides hatte, konnte sich eine Rolle mit
|
||||||
router.delete('/roles/:id', authenticate, requirePermission('users:delete'), userController.deleteRole);
|
// `developer:access` bauen und zuweisen - die Rollenverwaltung war damit
|
||||||
|
// faktisch eine Rechteerhoehung mit Zwischenschritt. Die zweite Haelfte des
|
||||||
|
// Riegels ist die Teilmengenregel in rechte.service.ts.
|
||||||
|
//
|
||||||
|
// Die LESEwege bleiben zusaetzlich mit `users:read` erreichbar: Das
|
||||||
|
// Benutzerformular braucht die Rollenliste zum Zuweisen, ohne dass der
|
||||||
|
// Bearbeiter Rollen pflegen koennen muss. `requirePermission` ist
|
||||||
|
// ODER-verknuepft, das traegt ohne Zusatzcode.
|
||||||
|
router.get('/roles/list', authenticate, requirePermission('roles:manage', 'users:read'), userController.getRoles);
|
||||||
|
router.post('/roles', authenticate, requirePermission('roles:manage'), userController.createRole);
|
||||||
|
router.get('/roles/:id', authenticate, requirePermission('roles:manage', 'users:read'), userController.getRole);
|
||||||
|
router.put('/roles/:id', authenticate, requirePermission('roles:manage'), userController.updateRole);
|
||||||
|
router.delete('/roles/:id', authenticate, requirePermission('roles:manage'), userController.deleteRole);
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
router.get('/permissions/list', authenticate, requirePermission('users:read'), userController.getPermissions);
|
router.get('/permissions/list', authenticate, requirePermission('roles:manage', 'users:read'), userController.getPermissions);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -909,12 +909,30 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
* nicht mehr (siehe ausfuehrliche Begruendung an der Berechnung unten).
|
* nicht mehr (siehe ausfuehrliche Begruendung an der Berechnung unten).
|
||||||
*/
|
*/
|
||||||
attestedGaps: number[];
|
attestedGaps: number[];
|
||||||
|
/**
|
||||||
|
* Protokollierte Neuberechnungen der Kette (Rehash), aelteste zuerst.
|
||||||
|
*
|
||||||
|
* Ein Rehash verknuepft alle Eintraege neu. Danach ist die Kette
|
||||||
|
* zwangslaeufig stimmig - auch ueber Loeschungen hinweg, die vorher als
|
||||||
|
* Luecken sichtbar waren. „Lueckenlos verkettet“ heisst nach einem Rehash
|
||||||
|
* also nur noch: seit dem Rehash. Wer das nicht mitliest, nimmt eine
|
||||||
|
* Entwarnung mit, die es nicht gibt.
|
||||||
|
*/
|
||||||
|
rehashes: Array<{
|
||||||
|
id: number;
|
||||||
|
zeitpunkt: string;
|
||||||
|
neuBerechnet: number | null;
|
||||||
|
/** Marker mit gueltiger HMAC-Signatur? Siehe Kommentar an der Auswertung. */
|
||||||
|
signiert: boolean;
|
||||||
|
/** Befund unmittelbar VOR dem Rehash - was also uebertuencht wurde. */
|
||||||
|
vorbefund: { manipuliert: number; luecken: number } | null;
|
||||||
|
}>;
|
||||||
/**
|
/**
|
||||||
* Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
|
* Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
|
||||||
* `kein_siegel` = nie erstellt. `entfernt` = Blaetter vorhanden, aber kein
|
* `kein_siegel` = nie erstellt. `entfernt` = Blaetter vorhanden, aber kein
|
||||||
* gueltiger Marker mehr – der Anker wurde herausgeloest (Pentest R174-01).
|
* gueltiger Marker mehr – der Anker wurde herausgeloest (Pentest R174-01).
|
||||||
*/
|
*/
|
||||||
backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
|
backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
|
||||||
/** Altbestands-Zeilen, deren Inhalt vom Siegel abweicht. */
|
/** Altbestands-Zeilen, deren Inhalt vom Siegel abweicht. */
|
||||||
backlogTampered: number[];
|
backlogTampered: number[];
|
||||||
/** Gesiegelte Zeilen, die nicht mehr existieren – Beweismaterial entfernt. */
|
/** Gesiegelte Zeilen, die nicht mehr existieren – Beweismaterial entfernt. */
|
||||||
@@ -1044,6 +1062,68 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
const gapErklaert = (prevId: number, curId: number) =>
|
const gapErklaert = (prevId: number, curId: number) =>
|
||||||
deletionRanges.some((r) => r.from <= curId && r.to >= prevId);
|
deletionRanges.some((r) => r.from <= curId && r.to >= prevId);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Protokollierte Neuberechnungen (Rehash) einsammeln.
|
||||||
|
//
|
||||||
|
// Ein Rehash macht die Kette rechnerisch stimmig - auch dort, wo vorher
|
||||||
|
// Loeschungen als Luecken sichtbar waren. Genau das ist auf Staging passiert:
|
||||||
|
// zwei Cleanups mit Aufbewahrung 0 entfernten 3155 Eintraege, der folgende
|
||||||
|
// Rehash liess rund 700 Kettenluecken verschwinden, und die Pruefung meldete
|
||||||
|
// danach „unveraendert und lueckenlos verkettet“. Wahr - und trotzdem das
|
||||||
|
// Gegenteil dessen, was ein Leser mitnimmt.
|
||||||
|
//
|
||||||
|
// Umgekehrte Beweislast als bei Manifest und Siegel: Dort zaehlen NUR
|
||||||
|
// signierte Traeger, weil ein gefaelschter Marker Luecken wegerklaeren
|
||||||
|
// koennte - Misstrauen ist die sichere Richtung. Hier erzeugt ein Marker eine
|
||||||
|
// WARNUNG. Wuerden wir nur signierte gelten lassen, koennte jemand einen
|
||||||
|
// Rehash unsichtbar machen, indem er dessen Signatur zerstoert. Deshalb
|
||||||
|
// zaehlt hier jeder auswertbare Marker; ob er signiert ist, wird nur
|
||||||
|
// mitgeteilt.
|
||||||
|
const rehashKandidaten = await prisma.auditLog.findMany({
|
||||||
|
where: { resourceType: 'AuditLog', action: 'UPDATE', endpoint: '/api/audit-logs/rehash' },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
const rehashSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter(
|
||||||
|
(k): k is string => !!k,
|
||||||
|
);
|
||||||
|
const rehashes: Array<{
|
||||||
|
id: number;
|
||||||
|
zeitpunkt: string;
|
||||||
|
neuBerechnet: number | null;
|
||||||
|
signiert: boolean;
|
||||||
|
vorbefund: { manipuliert: number; luecken: number } | null;
|
||||||
|
}> = [];
|
||||||
|
for (const row of rehashKandidaten) {
|
||||||
|
let neuBerechnet: number | null = null;
|
||||||
|
let vorbefund: { manipuliert: number; luecken: number } | null = null;
|
||||||
|
try {
|
||||||
|
const nach = JSON.parse(row.changesAfter || '{}');
|
||||||
|
if (typeof nach.neuBerechnet !== 'number') continue; // kein Rehash-Marker
|
||||||
|
neuBerechnet = nach.neuBerechnet;
|
||||||
|
const vor = JSON.parse(row.changesBefore || '{}');
|
||||||
|
if (Array.isArray(vor.manipuliert) && Array.isArray(vor.ketten_luecken)) {
|
||||||
|
vorbefund = {
|
||||||
|
manipuliert: vor.manipuliert.length,
|
||||||
|
luecken: vor.ketten_luecken.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const signiert =
|
||||||
|
row.hashVersion >= 3 &&
|
||||||
|
rehashSchluessel.some(
|
||||||
|
(k) => row.hash === generateHashV3(row as unknown as AuditHashV2Input, k),
|
||||||
|
);
|
||||||
|
rehashes.push({
|
||||||
|
id: row.id,
|
||||||
|
zeitpunkt: row.createdAt.toISOString(),
|
||||||
|
neuBerechnet,
|
||||||
|
signiert,
|
||||||
|
vorbefund,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const tamperedEntries: number[] = [];
|
const tamperedEntries: number[] = [];
|
||||||
const chainGaps: number[] = [];
|
const chainGaps: number[] = [];
|
||||||
const unexplainedGaps: number[] = [];
|
const unexplainedGaps: number[] = [];
|
||||||
@@ -1061,7 +1141,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
const backlogMissing: number[] = [];
|
const backlogMissing: number[] = [];
|
||||||
// Luecken, die das Siegel als bereits vorhanden beglaubigt (siehe unten).
|
// Luecken, die das Siegel als bereits vorhanden beglaubigt (siehe unten).
|
||||||
const beglaubigteLuecken = new Set<number>();
|
const beglaubigteLuecken = new Set<number>();
|
||||||
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' | 'nicht_noetig' = 'kein_siegel';
|
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig' = 'kein_siegel';
|
||||||
let backlogSealRoot: string | null = null;
|
let backlogSealRoot: string | null = null;
|
||||||
|
|
||||||
const siegelKandidaten = await prisma.auditLog.findMany({
|
const siegelKandidaten = await prisma.auditLog.findMany({
|
||||||
@@ -1164,6 +1244,17 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
? 'intakt'
|
? 'intakt'
|
||||||
: 'gebrochen';
|
: 'gebrochen';
|
||||||
|
|
||||||
|
// Ein Siegel ueber NULL Blaettern ist rechnerisch tadellos und schuetzt
|
||||||
|
// nichts: es gab zum Zeitpunkt des Siegelns keinen Altbestand. „intakt“
|
||||||
|
// zu melden waere formal richtig und trotzdem irrefuehrend - es liest
|
||||||
|
// sich als Schutzzusage. Der Pentester hat Stagings Leersiegel genau so
|
||||||
|
// missverstanden, und das ist der rote Faden im Kleinen: ein Signal, das
|
||||||
|
// beruhigt, wo nichts abgesichert ist. Eigener Zustand, gleiche Wertung
|
||||||
|
// wie „nicht noetig“ - kein Befund, aber auch keine Zusage.
|
||||||
|
if (backlogSealStatus === 'intakt' && blaetter.length === 0) {
|
||||||
|
backlogSealStatus = 'leer';
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Beglaubigte Alt-Luecken
|
// Beglaubigte Alt-Luecken
|
||||||
//
|
//
|
||||||
@@ -1206,6 +1297,34 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
}
|
}
|
||||||
tamperedEntries.push(...backlogTampered, ...backlogMissing);
|
tamperedEntries.push(...backlogTampered, ...backlogMissing);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Fehlender ANFANG des Protokolls.
|
||||||
|
//
|
||||||
|
// Die Verkettung wird zeilenweise gegen die Vorgaengerin geprueft - die
|
||||||
|
// erste Zeile hat keine, also fiel bisher NICHTS auf, wenn ein
|
||||||
|
// zusammenhaengender Anfang entfernt wurde. Kein Kettenbruch, kein Befund,
|
||||||
|
// `valid` blieb gruen. Das ist die stillste Loeschung von allen: Wer die
|
||||||
|
// aeltesten Eintraege loswerden will, muss nur vorne anfangen.
|
||||||
|
//
|
||||||
|
// Erkennbar ist es trotzdem: Die allererste Zeile eines Protokolls wird ohne
|
||||||
|
// Vorgaenger geschrieben und traegt deshalb einen leeren `previousHash`.
|
||||||
|
// Traegt die erste vorhandene Zeile einen Wert, hat es eine Vorgaengerin
|
||||||
|
// gegeben - und die ist weg.
|
||||||
|
//
|
||||||
|
// Nur bei Pruefung des GESAMTEN Bereichs; bei `fromId` ist ein gefuellter
|
||||||
|
// `previousHash` selbstverstaendlich und kein Befund.
|
||||||
|
if (fromId === undefined && logs.length > 0 && logs[0].previousHash) {
|
||||||
|
chainGaps.push(logs[0].id);
|
||||||
|
// Ein protokolliertes Loeschungs-Manifest erklaert auch diese Luecke; die
|
||||||
|
// Pruefung laeuft ueber denselben Weg wie bei jeder anderen.
|
||||||
|
if (!gapErklaert(0, logs[0].id)) {
|
||||||
|
unexplainedGaps.push(logs[0].id);
|
||||||
|
if (erwarteteVersion(logs[0].id) === 3) {
|
||||||
|
tamperedEntries.push(logs[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < logs.length; i++) {
|
for (let i = 0; i < logs.length; i++) {
|
||||||
const log = logs[i];
|
const log = logs[i];
|
||||||
|
|
||||||
@@ -1325,6 +1444,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
// unsichtbar (R174-01).
|
// unsichtbar (R174-01).
|
||||||
const siegelInOrdnung =
|
const siegelInOrdnung =
|
||||||
backlogSealStatus === 'intakt' ||
|
backlogSealStatus === 'intakt' ||
|
||||||
|
backlogSealStatus === 'leer' ||
|
||||||
backlogSealStatus === 'kein_siegel' ||
|
backlogSealStatus === 'kein_siegel' ||
|
||||||
backlogSealStatus === 'nicht_noetig';
|
backlogSealStatus === 'nicht_noetig';
|
||||||
|
|
||||||
@@ -1337,6 +1457,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
unexplainedGaps,
|
unexplainedGaps,
|
||||||
unverifiableEntries,
|
unverifiableEntries,
|
||||||
attestedGaps,
|
attestedGaps,
|
||||||
|
rehashes,
|
||||||
backlogSealStatus,
|
backlogSealStatus,
|
||||||
backlogTampered,
|
backlogTampered,
|
||||||
backlogMissing,
|
backlogMissing,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { PORTAL_RECHTE } from '../config/rechte-katalog.js';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
@@ -420,10 +421,9 @@ export async function customerLogin(email: string, password: string) {
|
|||||||
const representedCustomerIds = grantedRepresentingFor.map((rep) => rep.customer.id);
|
const representedCustomerIds = grantedRepresentingFor.map((rep) => rep.customer.id);
|
||||||
|
|
||||||
// Kundenportal-Berechtigungen (eingeschränkt)
|
// Kundenportal-Berechtigungen (eingeschränkt)
|
||||||
const customerPermissions = [
|
// Feste Portal-Rechte aus config/rechte-katalog.ts. Bewusst getrennt vom
|
||||||
'contracts:read', // Eigene Verträge lesen
|
// Rollensystem - siehe den Kommentar dort.
|
||||||
'customers:read', // Eigene Kundendaten lesen
|
const customerPermissions = [...PORTAL_RECHTE];
|
||||||
];
|
|
||||||
|
|
||||||
const payload: JwtPayload = {
|
const payload: JwtPayload = {
|
||||||
email: customer.portalEmail!,
|
email: customer.portalEmail!,
|
||||||
@@ -791,10 +791,7 @@ export async function getCustomerPortalUser(customerId: number) {
|
|||||||
|
|
||||||
if (!customer || !customer.portalEnabled) return null;
|
if (!customer || !customer.portalEnabled) return null;
|
||||||
|
|
||||||
const customerPermissions = [
|
const customerPermissions = [...PORTAL_RECHTE];
|
||||||
'contracts:read',
|
|
||||||
'customers:read',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Selbe Live-Vollmacht-Filterung wie in customerLogin (Pentest Runde 10):
|
// Selbe Live-Vollmacht-Filterung wie in customerLogin (Pentest Runde 10):
|
||||||
// ohne sie zeigt /me dem Vertreter weiterhin widerrufene Beziehungen.
|
// ohne sie zeigt /me dem Vertreter weiterhin widerrufene Beziehungen.
|
||||||
|
|||||||
@@ -10,6 +10,31 @@ import * as path from 'path';
|
|||||||
import archiver from 'archiver';
|
import archiver from 'archiver';
|
||||||
import AdmZip from 'adm-zip';
|
import AdmZip from 'adm-zip';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { synchronisiereRechteUndRollen } from './rollen-sync.service.js';
|
||||||
|
import { ROLLE_ADMIN, ROLLE_DSGVO } from '../config/rechte-katalog.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zufaelliges Initial-Kennwort fuer den Bootstrap-Admin nach einem
|
||||||
|
* Werksreset. Gleiche Regeln wie in prisma/seed.ts: 28 Zeichen, mindestens
|
||||||
|
* eines aus jeder Klasse, kryptografisch sichere Auswahl - Math.random() ist
|
||||||
|
* vorhersagbar und reicht dafuer nicht (Pentest 2026-05-20).
|
||||||
|
*/
|
||||||
|
function erzeugeInitialKennwort(): string {
|
||||||
|
const gross = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||||
|
const klein = 'abcdefghijkmnopqrstuvwxyz';
|
||||||
|
const ziffern = '23456789';
|
||||||
|
const sonder = '!@#$%&*+=?';
|
||||||
|
const alle = gross + klein + ziffern + sonder;
|
||||||
|
const waehle = (s: string) => s[crypto.randomInt(0, s.length)];
|
||||||
|
const zeichen = [waehle(gross), waehle(klein), waehle(ziffern), waehle(sonder)];
|
||||||
|
for (let i = zeichen.length; i < 28; i++) zeichen.push(waehle(alle));
|
||||||
|
for (let i = zeichen.length - 1; i > 0; i--) {
|
||||||
|
const j = crypto.randomInt(0, i + 1);
|
||||||
|
[zeichen[i], zeichen[j]] = [zeichen[j], zeichen[i]];
|
||||||
|
}
|
||||||
|
return zeichen.join('');
|
||||||
|
}
|
||||||
|
|
||||||
// Verzeichnisse
|
// Verzeichnisse
|
||||||
const BACKUPS_DIR = path.join(__dirname, '../../prisma/backups');
|
const BACKUPS_DIR = path.join(__dirname, '../../prisma/backups');
|
||||||
@@ -1187,110 +1212,40 @@ export async function factoryReset(): Promise<{ success: boolean; error?: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grundlegende Stammdaten neu anlegen (aus Seed)
|
// ==================== RECHTE UND ROLLEN ====================
|
||||||
// Berechtigungen - muss mit seed.ts übereinstimmen!
|
// Aus derselben Definition wie Seed und Container-Start
|
||||||
const resourcePermissions: Record<string, string[]> = {
|
// (config/rechte-katalog.ts).
|
||||||
// Haupt-Ressourcen (CRUD)
|
//
|
||||||
customers: ['create', 'read', 'update', 'delete'],
|
// Hier stand bis 09/2026 eine DRITTE Kopie des Katalogs, mit dem
|
||||||
contracts: ['create', 'read', 'update', 'delete'],
|
// Kommentar "muss mit seed.ts uebereinstimmen!" darueber - und sie stimmte
|
||||||
users: ['create', 'read', 'update', 'delete'],
|
// nicht: Die Lookup-Tabellen hatten nur `read`, `email-providers`,
|
||||||
platforms: ['create', 'read', 'update', 'delete'],
|
// `audit` und `gdpr` fehlten ganz, und von den Rollen wurden nur fuenf
|
||||||
providers: ['create', 'read', 'update', 'delete'],
|
// angelegt. DSGVO, Audit-Betrieb und Gegenbuch gab es nach einem
|
||||||
tariffs: ['create', 'read', 'update', 'delete'],
|
// Werksreset nicht mehr.
|
||||||
// Lookup-Tabellen (nur lesen)
|
//
|
||||||
'contract-categories': ['read'],
|
// Die Folge war kein Schoenheitsfehler: Ohne die DSGVO-Rolle kann
|
||||||
'cancellation-periods': ['read'],
|
// niemand eine Auskunft nach Art. 15 oder eine Loeschung nach Art. 17
|
||||||
'contract-durations': ['read'],
|
// ausfuehren. Ein Werksreset setzte damit stillschweigend die
|
||||||
// Einstellungen (nur lesen/ändern)
|
// Handlungsfaehigkeit fuer Betroffenenrechte aus, und aufgefallen waere
|
||||||
settings: ['read', 'update'],
|
// es erst, wenn eine Frist laeuft.
|
||||||
// Spezial-Permissions
|
await synchronisiereRechteUndRollen(prisma, (zeile) =>
|
||||||
developer: ['access'],
|
console.log(`[FactoryReset] ${zeile}`),
|
||||||
emails: ['delete'],
|
);
|
||||||
};
|
|
||||||
|
|
||||||
for (const [resource, actions] of Object.entries(resourcePermissions)) {
|
const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_ADMIN } });
|
||||||
for (const action of actions) {
|
const gdprRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_DSGVO } });
|
||||||
await prisma.permission.create({
|
console.log('[FactoryReset] Rechte und Rollen erstellt');
|
||||||
data: { resource, action },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log('[FactoryReset] Berechtigungen erstellt');
|
|
||||||
|
|
||||||
// Admin-Rolle mit allen Berechtigungen (außer developer:access)
|
// Standard Admin-Benutzer erstellen.
|
||||||
const allPermissions = await prisma.permission.findMany();
|
//
|
||||||
const adminRole = await prisma.role.create({
|
// Das Kennwort war hier bis 09/2026 fest auf "admin" verdrahtet, mit
|
||||||
data: {
|
// bcrypt-Cost 10. Genau das verbietet seed.ts seit Pentest Runde 12
|
||||||
name: 'Admin',
|
// ausdruecklich - und wieder war die Haertung nur in einer der beiden
|
||||||
description: 'Voller Zugriff auf alle Funktionen',
|
// Kopien angekommen. Wer einen Werksreset ausloest, bekommt jetzt ein
|
||||||
permissions: {
|
// zufaelliges Kennwort, das genau einmal im Log erscheint.
|
||||||
create: allPermissions
|
|
||||||
.filter(p => !(p.resource === 'developer' && p.action === 'access'))
|
|
||||||
.map(p => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Developer-Rolle - ALLE Berechtigungen inkl. developer:access
|
|
||||||
await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: 'Developer',
|
|
||||||
description: 'Voller Zugriff inkl. Entwickler-Tools',
|
|
||||||
permissions: {
|
|
||||||
create: allPermissions.map(p => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mitarbeiter-Rolle - customers, contracts + read-only auf Stammdaten
|
|
||||||
const employeePermIds = allPermissions
|
|
||||||
.filter(p =>
|
|
||||||
p.resource === 'customers' ||
|
|
||||||
p.resource === 'contracts' ||
|
|
||||||
(p.action === 'read' && ['platforms', 'providers', 'tariffs', 'contract-categories', 'cancellation-periods', 'contract-durations'].includes(p.resource))
|
|
||||||
)
|
|
||||||
.map(p => p.id);
|
|
||||||
await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: 'Mitarbeiter',
|
|
||||||
description: 'Kann Kunden und Verträge verwalten',
|
|
||||||
permissions: {
|
|
||||||
create: employeePermIds.map(id => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Nur-Lesen Rolle
|
|
||||||
const readOnlyResources = ['customers', 'contracts', 'platforms', 'providers', 'tariffs', 'contract-categories', 'cancellation-periods', 'contract-durations'];
|
|
||||||
const readOnlyPermIds = allPermissions
|
|
||||||
.filter(p => p.action === 'read' && readOnlyResources.includes(p.resource))
|
|
||||||
.map(p => p.id);
|
|
||||||
await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: 'Mitarbeiter (Nur-Lesen)',
|
|
||||||
description: 'Kann nur lesen, keine Änderungen',
|
|
||||||
permissions: {
|
|
||||||
create: readOnlyPermIds.map(id => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Kunden-Rolle
|
|
||||||
await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: 'Kunde',
|
|
||||||
description: 'Kann nur eigene Daten lesen',
|
|
||||||
permissions: {
|
|
||||||
create: readOnlyPermIds.map(id => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('[FactoryReset] Rollen erstellt');
|
|
||||||
|
|
||||||
// Standard Admin-Benutzer erstellen
|
|
||||||
console.log('[FactoryReset] Erstelle Admin-Benutzer...');
|
console.log('[FactoryReset] Erstelle Admin-Benutzer...');
|
||||||
const hashedPassword = await bcrypt.hash('admin', 10);
|
const adminPlainPassword = erzeugeInitialKennwort();
|
||||||
console.log('[FactoryReset] Passwort gehasht, Admin-Rolle ID:', adminRole.id);
|
const hashedPassword = await bcrypt.hash(adminPlainPassword, 12);
|
||||||
|
|
||||||
const adminUser = await prisma.user.create({
|
const adminUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -1298,11 +1253,21 @@ export async function factoryReset(): Promise<{ success: boolean; error?: string
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
firstName: 'Admin',
|
firstName: 'Admin',
|
||||||
lastName: 'User',
|
lastName: 'User',
|
||||||
|
// Admin UND DSGVO (Pentest R189-01): Die Admin-Rolle traegt die
|
||||||
|
// Datenschutzrechte bewusst nicht, also braucht dieses eine
|
||||||
|
// Bootstrap-Konto beide - sonst ist nach dem Reset niemand
|
||||||
|
// handlungsfaehig.
|
||||||
roles: {
|
roles: {
|
||||||
create: [{ roleId: adminRole.id }],
|
create: [{ roleId: adminRole.id }, { roleId: gdprRole.id }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
console.log('========================================================');
|
||||||
|
console.log(' Admin-User: admin@admin.com');
|
||||||
|
console.log(` Initial-Passwort: ${adminPlainPassword}`);
|
||||||
|
console.log(' ⚠️ Dieses Passwort wird hier EINMAL ausgegeben!');
|
||||||
|
console.log(' Bitte sofort nach dem ersten Login ändern.');
|
||||||
|
console.log('========================================================');
|
||||||
console.log('[FactoryReset] Admin-Benutzer erstellt mit ID:', adminUser.id);
|
console.log('[FactoryReset] Admin-Benutzer erstellt mit ID:', adminUser.id);
|
||||||
|
|
||||||
// Standard Kündigungsfristen (wie in seed.ts)
|
// Standard Kündigungsfristen (wie in seed.ts)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// "Geworben / angeworben": wer hat wen an Board geholt.
|
// "Geworben / angeworben": wer hat wen an Board geholt.
|
||||||
|
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { FachlicherFehler } from '../utils/apiError.js';
|
||||||
|
|
||||||
// Erlaubte Beziehungsarten (Whitelist). Server vertraut dem Frontend-Dropdown
|
// Erlaubte Beziehungsarten (Whitelist). Server vertraut dem Frontend-Dropdown
|
||||||
// nicht – ein handgebauter Request mit beliebigem String wird abgewiesen.
|
// nicht – ein handgebauter Request mit beliebigem String wird abgewiesen.
|
||||||
@@ -64,10 +65,11 @@ export async function getReferralsForCustomer(customerId: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ReferralError extends Error {
|
export class ReferralError extends FachlicherFehler {
|
||||||
status: number;
|
/** Beibehalten, weil bestehende Aufrufer `.status` lesen. */
|
||||||
|
readonly status: number;
|
||||||
constructor(message: string, status = 400) {
|
constructor(message: string, status = 400) {
|
||||||
super(message);
|
super(message, status);
|
||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { SYSTEMROLLEN, pruefePortalRechte } from '../config/rechte-katalog.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft beim Start, ob die gesetzlich gebundenen Rechte ueberhaupt jemand
|
||||||
|
* ausueben kann (Pentest R189-01).
|
||||||
|
*
|
||||||
|
* Hintergrund: `gdpr:export`, `gdpr:delete` und `gdpr:admin` haengen an den
|
||||||
|
* Rollen DSGVO und Developer – nicht an der Admin-Rolle. Nach einem frischen
|
||||||
|
* Seed war keine der beiden einem Konto zugewiesen. Ergebnis: Eine Auskunft
|
||||||
|
* nach Art. 15 oder eine Loeschung nach Art. 17 konnte NIEMAND ausfuehren,
|
||||||
|
* obwohl mehrere „Admin"-Konten existierten. Ein Ausfall mit Fristwirkung,
|
||||||
|
* ausgeloest durch nichts weiter als eine Neuinstallation.
|
||||||
|
*
|
||||||
|
* Der Seed weist das Recht jetzt zu – aber nur bei NEUINSTALLATION. Auf einer
|
||||||
|
* laufenden Datenbank aendert er nichts, und niemand merkt es, bis es darauf
|
||||||
|
* ankommt. Deshalb diese Wache: Sie erkennt die ABWESENHEIT einer Faehigkeit,
|
||||||
|
* so wie der Heartbeat das Ausbleiben eines Dienstkontos erkennt.
|
||||||
|
*
|
||||||
|
* Bewusst nur eine Meldung, keine automatische Vergabe: Rechte zu verteilen,
|
||||||
|
* ohne dass ein Mensch es veranlasst hat, waere der groessere Fehler.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Rechte, deren Fehlen ein rechtliches und kein technisches Problem ist. */
|
||||||
|
const PFLICHTRECHTE: Array<{ resource: string; action: string; wofuer: string }> = [
|
||||||
|
{ resource: 'gdpr', action: 'export', wofuer: 'Auskunft nach Art. 15 DSGVO' },
|
||||||
|
{ resource: 'gdpr', action: 'delete', wofuer: 'Löschung nach Art. 17 DSGVO' },
|
||||||
|
{ resource: 'audit', action: 'read', wofuer: 'Prüfung des Audit-Protokolls' },
|
||||||
|
// Ohne dieses Recht laesst sich keine Rolle mehr anlegen oder aendern - die
|
||||||
|
// Rechtevergabe waere eingefroren. Kein Rechtsproblem wie die beiden
|
||||||
|
// darueber, aber dieselbe Bauart: eine Faehigkeit, deren Fehlen erst
|
||||||
|
// auffaellt, wenn man sie braucht.
|
||||||
|
{ resource: 'roles', action: 'manage', wofuer: 'Pflege der Rollen und Rechte' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function pruefePflichtrechte(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const fehlend: string[] = [];
|
||||||
|
|
||||||
|
for (const recht of PFLICHTRECHTE) {
|
||||||
|
const traeger = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
roles: {
|
||||||
|
some: {
|
||||||
|
role: {
|
||||||
|
permissions: {
|
||||||
|
some: {
|
||||||
|
permission: { resource: recht.resource, action: recht.action },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (traeger === 0) {
|
||||||
|
fehlend.push(`${recht.resource}:${recht.action} (${recht.wofuer})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wache ueber die Portal-Trennung.
|
||||||
|
//
|
||||||
|
// Ein schreibendes Recht in PORTAL_RECHTE waere kein Schoenheitsfehler,
|
||||||
|
// sondern eine stille Ausweitung der Kundenrechte - jeder Portal-Kunde
|
||||||
|
// haette es sofort. Diese Meldung soll den Tag ueberleben, an dem jemand
|
||||||
|
// "nur kurz" etwas hinzufuegt.
|
||||||
|
const portalBeanstandet = pruefePortalRechte();
|
||||||
|
if (portalBeanstandet.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
'\n' +
|
||||||
|
'========================================================================\n' +
|
||||||
|
' ACHTUNG: Das Kundenportal traegt Rechte, die nicht Lesen sind:\n' +
|
||||||
|
portalBeanstandet.map((r) => ` – ${r}\n`).join('') +
|
||||||
|
'\n' +
|
||||||
|
' Kunden sollen im Portal NIEMALS operative Rechte haben. Die Ansicht\n' +
|
||||||
|
' ist dafuer nicht gebaut und prueft es nicht. Bitte PORTAL_RECHTE in\n' +
|
||||||
|
' config/rechte-katalog.ts pruefen.\n' +
|
||||||
|
'========================================================================\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zweite Wache: Stimmen die Systemrollen noch?
|
||||||
|
//
|
||||||
|
// Die versteckten Rollen werden ueber ihren NAMEN gefunden
|
||||||
|
// (`findFirst({ where: { name: 'DSGVO' } })`). Fehlt eine, oder ist ihr
|
||||||
|
// isSystem-Flag von Hand entfernt worden, laeuft der Notfallpfad ins
|
||||||
|
// Leere - lautlos. Das hier ist die Meldung, die es dann geben soll.
|
||||||
|
const rollenHinweise: string[] = [];
|
||||||
|
for (const spec of SYSTEMROLLEN) {
|
||||||
|
const rolle = await prisma.role.findUnique({ where: { name: spec.name } });
|
||||||
|
if (!rolle) {
|
||||||
|
rollenHinweise.push(`Systemrolle „${spec.name}" fehlt.`);
|
||||||
|
} else if (!rolle.isSystem) {
|
||||||
|
rollenHinweise.push(
|
||||||
|
`Systemrolle „${spec.name}" ist nicht als Systemrolle gekennzeichnet – ` +
|
||||||
|
'sie ist damit über die Rollenverwaltung änderbar.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rollenHinweise.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
'\n' +
|
||||||
|
'========================================================================\n' +
|
||||||
|
' ACHTUNG: Die Systemrollen stimmen nicht mit dem Katalog überein:\n' +
|
||||||
|
rollenHinweise.map((h) => ` – ${h}\n`).join('') +
|
||||||
|
'\n' +
|
||||||
|
' Beheben: npx tsx prisma/sync-roles.ts\n' +
|
||||||
|
'========================================================================\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fehlend.length === 0) return;
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
'\n' +
|
||||||
|
'========================================================================\n' +
|
||||||
|
' ACHTUNG: Für folgende Rechte gibt es KEIN aktives Konto:\n' +
|
||||||
|
fehlend.map((f) => ` – ${f}\n`).join('') +
|
||||||
|
'\n' +
|
||||||
|
' Das ist kein Fehler der Anwendung, sondern eine Lücke in der\n' +
|
||||||
|
' Rechtevergabe – und sie fällt erst auf, wenn eine Frist läuft.\n' +
|
||||||
|
'\n' +
|
||||||
|
' Beheben: In der Benutzerverwaltung bei einem verantwortlichen Konto\n' +
|
||||||
|
' den Haken „DSGVO-Zugriff" setzen (Audit-Protokoll lesen und\n' +
|
||||||
|
' Datenschutz-Verwaltung). Für Eingriffe am Protokoll – versiegeln,\n' +
|
||||||
|
' aufräumen, Aufbewahrung ändern – zusätzlich „Audit-Betrieb".\n' +
|
||||||
|
'\n' +
|
||||||
|
' Geht das nicht, weil niemand mehr die nötigen Rechte hat: Rechte\n' +
|
||||||
|
' lassen sich über die Oberfläche nur weitergeben, nicht erschaffen.\n' +
|
||||||
|
' Für die Erstvergabe gibt es den Weg über die Kommandozeile:\n' +
|
||||||
|
' docker compose exec backend \\\n' +
|
||||||
|
' npx tsx prisma/rolle-zuweisen.ts <e-mail> DSGVO\n' +
|
||||||
|
' („--liste" zeigt die vorhandenen Rollen.)\n' +
|
||||||
|
'========================================================================\n',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Eine Wache darf den Start nicht verhindern. Aber schweigen darf sie
|
||||||
|
// auch nicht: „nicht geprüft" ist nicht dasselbe wie „nichts gefunden".
|
||||||
|
console.warn(
|
||||||
|
'[Pflichtrechte] Prüfung nicht möglich – der Zustand der Rechtevergabe ist ' +
|
||||||
|
'damit UNBEKANNT, nicht in Ordnung:',
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* Rechteaufloesung und die Regel, die Selbst-Erhoehung verhindert.
|
||||||
|
*
|
||||||
|
* Die Regel lautet: Niemand kann ein Recht weitergeben, das er selbst nicht
|
||||||
|
* besitzt. Ohne sie genuegte `users:create`, um sich zum Vollzugriff zu
|
||||||
|
* befoerdern - eine Rolle mit `developer:access` anlegen und sich zuweisen,
|
||||||
|
* oder gleich ein zweites Konto mit dem Haken "Entwicklerzugriff" erzeugen.
|
||||||
|
* Die Rollenverwaltung war damit faktisch eine Rechteerhoehung mit
|
||||||
|
* Zwischenschritt.
|
||||||
|
*
|
||||||
|
* Zwei Dinge sind hier bewusst so gebaut:
|
||||||
|
*
|
||||||
|
* 1. Die effektiven Rechte werden IMMER frisch aus der Datenbank gelesen,
|
||||||
|
* nie aus `req.user.permissions`. Der JWT-Claim ist bis zu 15 Minuten alt
|
||||||
|
* (JWT_EXPIRES_IN). Fuer ein Gate ist das vertretbar - fuer die Frage
|
||||||
|
* "darf dieser Mensch dieses Recht weitergeben" nicht: Ein Konto, dem
|
||||||
|
* gerade `gdpr:admin` entzogen wurde, koennte es im Fenster noch
|
||||||
|
* weiterreichen und damit den Entzug ueberdauern.
|
||||||
|
*
|
||||||
|
* 2. Geprueft wird immer nur der ZUWACHS. Rechte entziehen bleibt jederzeit
|
||||||
|
* erlaubt, auch solche, die der Handelnde selbst nicht hat - sonst
|
||||||
|
* koennte ein Admin einen uebernommenen Developer-Zugang nicht mehr
|
||||||
|
* entschaerfen, und die Regel wuerde den Angreifer schuetzen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { FachlicherFehler } from '../utils/apiError.js';
|
||||||
|
|
||||||
|
/** Groesste Zahl, die in eine INT-Spalte passt. Darueber gibt es keine ID. */
|
||||||
|
const INT_MAX = 2147483647;
|
||||||
|
import {
|
||||||
|
ROLLE_DEVELOPER,
|
||||||
|
ROLLE_DSGVO,
|
||||||
|
ROLLE_AUDIT_BETRIEB,
|
||||||
|
rechteDerSystemrolle,
|
||||||
|
} from '../config/rechte-katalog.js';
|
||||||
|
|
||||||
|
/** Der Handelnde wollte Rechte vergeben, die er selbst nicht besitzt. */
|
||||||
|
export class RechteEskalationError extends FachlicherFehler {
|
||||||
|
constructor(public readonly fehlend: string[]) {
|
||||||
|
super(
|
||||||
|
'Sie können nur Rechte vergeben, die Sie selbst besitzen. ' +
|
||||||
|
`Nicht vergeben werden können: ${fehlend.join(', ')}`,
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Eingabe nennt etwas, das es nicht gibt - eine unbekannte Rechte- oder
|
||||||
|
* Rollen-ID. Eigene Klasse, damit daraus ein 400 wird und nicht ein 500 aus
|
||||||
|
* einem Fremdschluesselfehler tief in Prisma.
|
||||||
|
*/
|
||||||
|
export class UngueltigeEingabeError extends FachlicherFehler {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Vorgang zielte auf eine Systemrolle, die von der Anwendung gepflegt wird. */
|
||||||
|
export class RollenSperrError extends FachlicherFehler {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die effektiven Rechte eines Kontos, frisch aus der Datenbank.
|
||||||
|
*
|
||||||
|
* Das ist dieselbe Aufloesung, die auth.service.ts beim Anmelden und beim
|
||||||
|
* Erneuern des Tokens vornimmt - hier an einer Stelle, statt in vier Kopien.
|
||||||
|
*/
|
||||||
|
export async function effektiveRechte(userId: number): Promise<Set<string>> {
|
||||||
|
const konto = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: {
|
||||||
|
roles: {
|
||||||
|
include: { role: { include: { permissions: { include: { permission: true } } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const rechte = new Set<string>();
|
||||||
|
if (!konto) return rechte;
|
||||||
|
for (const ur of konto.roles) {
|
||||||
|
for (const rp of ur.role.permissions) {
|
||||||
|
rechte.add(`${rp.permission.resource}:${rp.permission.action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rechte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vereinigung der Rechte mehrerer Rollen. */
|
||||||
|
export async function rechteVonRollen(roleIds: number[]): Promise<Set<string>> {
|
||||||
|
const rechte = new Set<string>();
|
||||||
|
if (roleIds.length === 0) return rechte;
|
||||||
|
const rollen = await prisma.role.findMany({
|
||||||
|
where: { id: { in: roleIds } },
|
||||||
|
include: { permissions: { include: { permission: true } } },
|
||||||
|
});
|
||||||
|
for (const r of rollen) {
|
||||||
|
for (const rp of r.permissions) {
|
||||||
|
rechte.add(`${rp.permission.resource}:${rp.permission.action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rechte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loest Rechte-IDs auf. Wirft bei unbekannter ID einen sprechenden Fehler -
|
||||||
|
* bisher lief eine erfundene ID in einen Fremdschluesselfehler und kam als
|
||||||
|
* HTTP 500 zurueck, also als "unser Fehler" statt "Ihre Eingabe".
|
||||||
|
*/
|
||||||
|
export async function rechteVonPermissionIds(ids: number[]): Promise<Set<string>> {
|
||||||
|
const rechte = new Set<string>();
|
||||||
|
if (ids.length === 0) return rechte;
|
||||||
|
const eindeutig = [...new Set(ids)];
|
||||||
|
const zuGross = eindeutig.filter((id) => id > INT_MAX);
|
||||||
|
const abfragbar = eindeutig.filter((id) => id <= INT_MAX);
|
||||||
|
const gefunden = abfragbar.length
|
||||||
|
? await prisma.permission.findMany({ where: { id: { in: abfragbar } } })
|
||||||
|
: [];
|
||||||
|
if (gefunden.length !== abfragbar.length || zuGross.length > 0) {
|
||||||
|
const bekannt = new Set(gefunden.map((p) => p.id));
|
||||||
|
const unbekannt = [...abfragbar.filter((id) => !bekannt.has(id)), ...zuGross];
|
||||||
|
throw new UngueltigeEingabeError(`Unbekannte Rechte-ID: ${unbekannt.join(', ')}`);
|
||||||
|
}
|
||||||
|
for (const p of gefunden) rechte.add(`${p.resource}:${p.action}`);
|
||||||
|
return rechte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Kernregel. Wirft RechteEskalationError, wenn der Handelnde etwas
|
||||||
|
* weitergeben will, das er selbst nicht haelt.
|
||||||
|
*/
|
||||||
|
export async function pruefeTeilmenge(
|
||||||
|
handelnderId: number,
|
||||||
|
benoetigt: Iterable<string>,
|
||||||
|
): Promise<void> {
|
||||||
|
const zuPruefen = [...benoetigt];
|
||||||
|
if (zuPruefen.length === 0) return;
|
||||||
|
|
||||||
|
const eigene = await effektiveRechte(handelnderId);
|
||||||
|
const fehlend = zuPruefen.filter((r) => !eigene.has(r)).sort();
|
||||||
|
if (fehlend.length > 0) throw new RechteEskalationError(fehlend);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beendet die Sitzungen aller Traeger einer Rolle.
|
||||||
|
*
|
||||||
|
* Noetig, weil die Rechte im Zugangstoken stehen: Ohne das behielte jeder
|
||||||
|
* Traeger bis zu 15 Minuten lang die alten Rechte, und ein Entzug waere
|
||||||
|
* genau so lange wirkungslos. `updateUser` machte das laengst - die
|
||||||
|
* Rollenpflege nicht, und dort wiegt es schwerer, weil sie viele Konten auf
|
||||||
|
* einmal betrifft.
|
||||||
|
*
|
||||||
|
* Muss bei Loeschungen VOR dem Loeschen laufen: Danach sind die Traeger
|
||||||
|
* durch den Cascade nicht mehr ermittelbar.
|
||||||
|
*/
|
||||||
|
export async function meldeTraegerAb(roleId: number): Promise<number> {
|
||||||
|
const traeger = await prisma.userRole.findMany({
|
||||||
|
where: { roleId },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
if (traeger.length === 0) return 0;
|
||||||
|
await prisma.user.updateMany({
|
||||||
|
where: { id: { in: traeger.map((t) => t.userId) } },
|
||||||
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
|
return traeger.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Rechte, die hinter den drei Haken im Benutzerformular stehen.
|
||||||
|
*
|
||||||
|
* Die Haken sind keine Datenbankspalten, sondern Kurzschrift fuer die
|
||||||
|
* versteckten Rollen DSGVO, Developer und Audit-Betrieb. Sie muessen unter
|
||||||
|
* dieselbe Teilmengenregel wie die Rollenzuweisung fallen, sonst ist der
|
||||||
|
* Rest Theater: Die Umgehung braucht nur `users:create` - ein zweites Konto
|
||||||
|
* mit dem Haken "Entwicklerzugriff" anlegen und sich damit anmelden. Die
|
||||||
|
* Developer-Rolle traegt ALLE Rechte. Eine reine Selbstvergabe-Sperre
|
||||||
|
* griffe dagegen nicht, denn der Angreifer vergibt sich nichts selbst.
|
||||||
|
*
|
||||||
|
* Nur das EINSCHALTEN wird geprueft. Wer einen Haken entfernt, nimmt Rechte
|
||||||
|
* weg - das darf jeder duerfen, der das Konto verwalten darf.
|
||||||
|
*/
|
||||||
|
export function rechteDerHaken(haken: Haken): string[] {
|
||||||
|
const rechte: string[] = [];
|
||||||
|
if (haken.hasDeveloperAccess === true) rechte.push(...rechteDerSystemrolle(ROLLE_DEVELOPER));
|
||||||
|
if (haken.hasGdprAccess === true) rechte.push(...rechteDerSystemrolle(ROLLE_DSGVO));
|
||||||
|
if (haken.hasAuditOpsAccess === true) rechte.push(...rechteDerSystemrolle(ROLLE_AUDIT_BETRIEB));
|
||||||
|
return [...new Set(rechte)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft Rollen-IDs und gibt sie doppelt-frei zurueck.
|
||||||
|
*
|
||||||
|
* Muss VOR jedem Schreibvorgang laufen. Ohne das kam eine Dublette
|
||||||
|
* (`[4,4]`) oder eine erfundene ID erst beim Schreiben zum Vorschein - und
|
||||||
|
* beim Rollentausch geschah das NACH dem Loeschen der alten Zuordnungen.
|
||||||
|
* Ergebnis war ein HTTP 400, das wie "Eingabe abgelehnt, nichts passiert"
|
||||||
|
* aussah, waehrend das Konto in Wahrheit ohne jede Rolle dastand.
|
||||||
|
*
|
||||||
|
* Besonders bitter am letzten Admin: Die Sperre prueft die ABSICHT (die
|
||||||
|
* Admin-Rolle steht ja in der Anfrage) und liess den Vorgang durch - der
|
||||||
|
* Schreibvorgang scheiterte danach. Aussperrung, obwohl die Sperre gegriffen
|
||||||
|
* zu haben schien (Pentest R190-01).
|
||||||
|
*
|
||||||
|
* `rechteVonRollen` fing das nicht ab: Eine unbekannte ID findet einfach
|
||||||
|
* keine Rolle, bringt also keine Rechte mit und faellt durch die
|
||||||
|
* Teilmengenregel nicht auf. Das war richtig fuer die Rechtefrage und
|
||||||
|
* falsch als Eingabepruefung - zwei verschiedene Aufgaben.
|
||||||
|
*/
|
||||||
|
export async function normalisiereRollenIds(roleIds: number[]): Promise<number[]> {
|
||||||
|
// Erst pruefen, dass es ueberhaupt eine Liste ist. Ohne das lief ein
|
||||||
|
// `{"roleIds":{}}` in einen TypeError beim Aufspreizen, und dessen
|
||||||
|
// Wortlaut ging an den Client (Pentest R192-01).
|
||||||
|
if (!Array.isArray(roleIds)) {
|
||||||
|
throw new UngueltigeEingabeError('roleIds muss eine Liste sein');
|
||||||
|
}
|
||||||
|
const eindeutig = [...new Set(roleIds)];
|
||||||
|
if (eindeutig.length === 0) return [];
|
||||||
|
if (!eindeutig.every((id) => Number.isInteger(id) && id >= 1)) {
|
||||||
|
throw new UngueltigeEingabeError('roleIds darf nur positive ganze Zahlen enthalten');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zahlen jenseits des INT-Bereichs gar nicht erst abfragen: Prisma bricht
|
||||||
|
// dort mit einem Fremdfehler ab, und der kam als allgemeines "Fehler beim
|
||||||
|
// Aktualisieren" zurueck - eine andere Antwort auf dieselbe Eingabeklasse
|
||||||
|
// (Pentest R191, kosmetisch). Eine ID, die nicht in die Spalte passt,
|
||||||
|
// benennt keine Rolle; sie ist schlicht unbekannt.
|
||||||
|
const zuGross = eindeutig.filter((id) => id > INT_MAX);
|
||||||
|
const abfragbar = eindeutig.filter((id) => id <= INT_MAX);
|
||||||
|
|
||||||
|
const gefunden = abfragbar.length
|
||||||
|
? await prisma.role.findMany({ where: { id: { in: abfragbar } }, select: { id: true } })
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (gefunden.length !== abfragbar.length || zuGross.length > 0) {
|
||||||
|
const bekannt = new Set(gefunden.map((r) => r.id));
|
||||||
|
const unbekannt = [...abfragbar.filter((id) => !bekannt.has(id)), ...zuGross];
|
||||||
|
throw new UngueltigeEingabeError(`Unbekannte Rollen-ID: ${unbekannt.join(', ')}`);
|
||||||
|
}
|
||||||
|
return eindeutig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die drei Haken, nachdem sie geprueft wurden. Nur noch echte Booleans. */
|
||||||
|
export interface Haken {
|
||||||
|
hasDeveloperAccess?: boolean;
|
||||||
|
hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HAKEN_FELDER = ['hasDeveloperAccess', 'hasGdprAccess', 'hasAuditOpsAccess'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft die drei Haken-Felder streng auf Boolean.
|
||||||
|
*
|
||||||
|
* Der Grund ist ein Angriff, keine Formalie (Pentest R193-02): Der Guard
|
||||||
|
* fragte `=== true`, die Zuweisung fragte auf Truthiness. Zwei verschiedene
|
||||||
|
* Vorstellungen davon, was "der Haken ist gesetzt" bedeutet - und dazwischen
|
||||||
|
* passte ein Bypass. Mit `{"hasDeveloperAccess": "ja"}` sah der Guard keinen
|
||||||
|
* Rechtezuwachs und liess durch, waehrend die Zuweisung den String als
|
||||||
|
* "gesetzt" las und die Developer-Rolle vergab. Damit war `developer:access`
|
||||||
|
* wieder ueber ein Browser-Feld erreichbar, obwohl es ausdruecklich nur ueber
|
||||||
|
* die Kommandozeile vergeben werden sollte.
|
||||||
|
*
|
||||||
|
* Die Lehre ist dieselbe wie bei R190-01 und R191-01: Eine Absicherung, die
|
||||||
|
* nur eine von zwei Kopien erreicht, ist keine. Deshalb gibt es ab hier nur
|
||||||
|
* noch EINE Lesart - alles, was kein echtes true oder false ist, wird
|
||||||
|
* abgewiesen, statt irgendwie ausgelegt zu werden.
|
||||||
|
*/
|
||||||
|
export function normalisiereHaken(roh: Record<string, unknown>): Haken {
|
||||||
|
const gepruef: Haken = {};
|
||||||
|
for (const feld of HAKEN_FELDER) {
|
||||||
|
const wert = roh[feld];
|
||||||
|
if (wert === undefined) continue;
|
||||||
|
if (typeof wert !== 'boolean') {
|
||||||
|
throw new UngueltigeEingabeError(
|
||||||
|
`${feld} muss true oder false sein (empfangen: ${JSON.stringify(wert)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
gepruef[feld] = wert;
|
||||||
|
}
|
||||||
|
return gepruef;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Bringt Rechtekatalog und Systemrollen in der Datenbank auf den Stand des
|
||||||
|
* Codes. Idempotent - laeuft bei jedem Container-Start.
|
||||||
|
*
|
||||||
|
* Verbraucher: `prisma/sync-roles.ts` (Container-Start), `prisma/seed.ts`
|
||||||
|
* (Erstinstallation) und `factoryReset` in `backup.service.ts`. Alle drei
|
||||||
|
* benutzen dieselbe Definition aus `config/rechte-katalog.ts`; vorher hatte
|
||||||
|
* jeder seine eigene, und die dritte wich ab.
|
||||||
|
*
|
||||||
|
* Was hier NICHT passiert: Stammdaten, Benutzer, Vertraege. Das Skript ist
|
||||||
|
* auf einer laufenden Produktionsdatenbank sicher.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
RECHTE_KATALOG,
|
||||||
|
SYSTEMROLLEN,
|
||||||
|
alsRechtString,
|
||||||
|
} from '../config/rechte-katalog.js';
|
||||||
|
|
||||||
|
type Protokoll = (zeile: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Rechte einer Rolle exakt auf `permissionIds` - fehlende kommen
|
||||||
|
* dazu, ueberzaehlige fliegen raus.
|
||||||
|
*
|
||||||
|
* Der Vollersatz ist Absicht: Er ist der Grund, warum eine per Adminer an
|
||||||
|
* einer Systemrolle vorgenommene Aenderung den naechsten Container-Start
|
||||||
|
* nicht ueberlebt.
|
||||||
|
*/
|
||||||
|
async function synchronisiereRollenrechte(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
roleId: number,
|
||||||
|
permissionIds: number[],
|
||||||
|
log: Protokoll,
|
||||||
|
): Promise<void> {
|
||||||
|
const vorhanden = await prisma.rolePermission.findMany({
|
||||||
|
where: { roleId },
|
||||||
|
select: { permissionId: true },
|
||||||
|
});
|
||||||
|
const vorhandenIds = new Set(vorhanden.map((e) => e.permissionId));
|
||||||
|
const zielIds = new Set(permissionIds);
|
||||||
|
|
||||||
|
const fehlend = permissionIds.filter((id) => !vorhandenIds.has(id));
|
||||||
|
if (fehlend.length > 0) {
|
||||||
|
await prisma.rolePermission.createMany({
|
||||||
|
data: fehlend.map((permissionId) => ({ roleId, permissionId })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
log(` → +${fehlend.length} Rechte an Rolle #${roleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ueberzaehlig = vorhanden
|
||||||
|
.filter((e) => !zielIds.has(e.permissionId))
|
||||||
|
.map((e) => e.permissionId);
|
||||||
|
if (ueberzaehlig.length > 0) {
|
||||||
|
await prisma.rolePermission.deleteMany({
|
||||||
|
where: { roleId, permissionId: { in: ueberzaehlig } },
|
||||||
|
});
|
||||||
|
log(` → -${ueberzaehlig.length} Rechte von Rolle #${roleId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt alle Rechte aus dem Katalog an und bringt die Systemrollen auf Stand.
|
||||||
|
* Setzt dabei auch `isSystem` und `isHidden` - das ist die eigentliche
|
||||||
|
* Absicherung gegen eine von Hand verstellte Datenbank, die Migration setzt
|
||||||
|
* die Flags nur einmalig.
|
||||||
|
*/
|
||||||
|
export async function synchronisiereRechteUndRollen(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
log: Protokoll = (z) => console.log(z),
|
||||||
|
): Promise<void> {
|
||||||
|
log('[rollen-sync] Rechtekatalog upserten…');
|
||||||
|
for (const recht of RECHTE_KATALOG) {
|
||||||
|
await prisma.permission.upsert({
|
||||||
|
where: { resource_action: { resource: recht.resource, action: recht.action } },
|
||||||
|
update: {},
|
||||||
|
create: { resource: recht.resource, action: recht.action },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const alleRechte = await prisma.permission.findMany();
|
||||||
|
log(`[rollen-sync] ${alleRechte.length} Rechte in der Datenbank`);
|
||||||
|
|
||||||
|
// Auflösung Katalog → Datenbank-IDs. Rechte, die in der Datenbank stehen,
|
||||||
|
// aber nicht im Katalog, bleiben unangetastet und werden auch keiner Rolle
|
||||||
|
// zugeteilt: Der Katalog ist die Wahrheit, nicht der Altbestand.
|
||||||
|
const idFuerRecht = new Map<string, number>();
|
||||||
|
for (const p of alleRechte) idFuerRecht.set(alsRechtString(p), p.id);
|
||||||
|
|
||||||
|
for (const spec of SYSTEMROLLEN) {
|
||||||
|
const rechteIds = RECHTE_KATALOG.filter(spec.rechte)
|
||||||
|
.map((r) => idFuerRecht.get(alsRechtString(r)))
|
||||||
|
.filter((id): id is number => id !== undefined);
|
||||||
|
|
||||||
|
const rolle = await prisma.role.upsert({
|
||||||
|
where: { name: spec.name },
|
||||||
|
update: {
|
||||||
|
description: spec.description,
|
||||||
|
isSystem: true,
|
||||||
|
isHidden: spec.isHidden,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
name: spec.name,
|
||||||
|
description: spec.description,
|
||||||
|
isSystem: true,
|
||||||
|
isHidden: spec.isHidden,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await synchronisiereRollenrechte(prisma, rolle.id, rechteIds, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
log('[rollen-sync] fertig.');
|
||||||
|
}
|
||||||
@@ -1,6 +1,33 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { paginate, buildPaginationResponse } from '../utils/helpers.js';
|
import { paginate, buildPaginationResponse } from '../utils/helpers.js';
|
||||||
|
import {
|
||||||
|
pruefeTeilmenge,
|
||||||
|
rechteVonPermissionIds,
|
||||||
|
rechteVonRollen,
|
||||||
|
rechteDerHaken,
|
||||||
|
normalisiereHaken,
|
||||||
|
UngueltigeEingabeError,
|
||||||
|
effektiveRechte,
|
||||||
|
normalisiereRollenIds,
|
||||||
|
meldeTraegerAb,
|
||||||
|
RollenSperrError,
|
||||||
|
} from './rechte.service.js';
|
||||||
|
import type { Haken } from './rechte.service.js';
|
||||||
|
import {
|
||||||
|
istSystemrollenName,
|
||||||
|
RECHTE_KATALOG,
|
||||||
|
ROLLE_DEVELOPER,
|
||||||
|
ROLLE_DSGVO,
|
||||||
|
ROLLE_AUDIT_BETRIEB,
|
||||||
|
} from '../config/rechte-katalog.js';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-Export aus dem Katalog. Der Name ist an mehreren Stellen im Umlauf;
|
||||||
|
* die Wahrheit steht jetzt an einer Stelle.
|
||||||
|
*/
|
||||||
|
export const AUDIT_OPS_ROLLE = ROLLE_AUDIT_BETRIEB;
|
||||||
|
|
||||||
export interface UserFilters {
|
export interface UserFilters {
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -65,9 +92,10 @@ export async function getAllUsers(filters: UserFilters) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Get hidden role IDs
|
// Get hidden role IDs
|
||||||
const [developerRole, gdprRole] = await Promise.all([
|
const [developerRole, gdprRole, auditOpsRole] = await Promise.all([
|
||||||
prisma.role.findFirst({ where: { name: 'Developer' } }),
|
prisma.role.findFirst({ where: { name: 'Developer' } }),
|
||||||
prisma.role.findFirst({ where: { name: 'DSGVO' } }),
|
prisma.role.findFirst({ where: { name: 'DSGVO' } }),
|
||||||
|
prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -78,11 +106,15 @@ export async function getAllUsers(filters: UserFilters) {
|
|||||||
const hasGdprAccess = gdprRole
|
const hasGdprAccess = gdprRole
|
||||||
? u.roles.some((ur) => ur.roleId === gdprRole.id)
|
? u.roles.some((ur) => ur.roleId === gdprRole.id)
|
||||||
: false;
|
: false;
|
||||||
|
const hasAuditOpsAccess = auditOpsRole
|
||||||
|
? u.roles.some((ur) => ur.roleId === auditOpsRole.id)
|
||||||
|
: false;
|
||||||
return {
|
return {
|
||||||
...u,
|
...u,
|
||||||
roles: u.roles.map((r) => r.role),
|
roles: u.roles.map((r) => r.role),
|
||||||
hasDeveloperAccess,
|
hasDeveloperAccess,
|
||||||
hasGdprAccess,
|
hasGdprAccess,
|
||||||
|
hasAuditOpsAccess,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
pagination: buildPaginationResponse(page, limit, total),
|
pagination: buildPaginationResponse(page, limit, total),
|
||||||
@@ -137,6 +169,79 @@ export async function getUserById(id: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Faehigkeiten, ohne die sich das System nicht mehr verwalten laesst.
|
||||||
|
*
|
||||||
|
* Bis 09/2026 galt hier "wer `users:delete` hat, ist Admin". Das war ein
|
||||||
|
* Zufallsmerkmal: Wer Konten anlegen und bearbeiten darf, aber nicht loeschen,
|
||||||
|
* verwaltet genauso - zaehlte aber nicht. Umgekehrt zaehlte jemand mit nur
|
||||||
|
* `users:delete` als Admin, obwohl er kein Konto anlegen kann. Ein
|
||||||
|
* Sicherheitsnetz an einem Merkmal, das mit der geschuetzten Eigenschaft nur
|
||||||
|
* lose zusammenhing (Pentest R193, Nachrangpunkt 2).
|
||||||
|
*
|
||||||
|
* Bewusst NICHT auf die Rolle "Admin" umgestellt, wie zunaechst vorgeschlagen:
|
||||||
|
* Eine selbst gebaute Rolle mit `users:update` verwaltet tatsaechlich, und ein
|
||||||
|
* Namenskriterium wuerde sie uebersehen - man koennte dann den letzten
|
||||||
|
* Admin loeschen, obwohl die Verwaltungsfaehigkeit erhalten bliebe. Geschuetzt
|
||||||
|
* wird die FAEHIGKEIT, nicht ihr ueblicher Traeger.
|
||||||
|
*
|
||||||
|
* `users:update` ist das Minimum, um Konten wieder in Ordnung zu bringen
|
||||||
|
* (Rollen zuweisen). `roles:manage` ist das Minimum, um Rollen zu definieren.
|
||||||
|
* Faellt eines davon ersatzlos weg, hilft nur noch die Kommandozeile.
|
||||||
|
*/
|
||||||
|
const UNVERZICHTBARE_FAEHIGKEITEN = [
|
||||||
|
{ recht: 'users:update', wofuer: 'Benutzer verwalten' },
|
||||||
|
{ recht: 'roles:manage', wofuer: 'Rollen und Rechte pflegen' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Die Rechte eines Kontos aus seinen geladenen Rollen. */
|
||||||
|
function rechteAusRollen(
|
||||||
|
rollen: Array<{ role: { permissions: Array<{ permission: { resource: string; action: string } }> } }>,
|
||||||
|
): Set<string> {
|
||||||
|
const rechte = new Set<string>();
|
||||||
|
for (const ur of rollen) {
|
||||||
|
for (const rp of ur.role.permissions) {
|
||||||
|
rechte.add(`${rp.permission.resource}:${rp.permission.action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rechte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verhindert, dass die letzte Person mit einer unverzichtbaren Faehigkeit
|
||||||
|
* sie verliert - durch Rollenwechsel, Deaktivierung oder Loeschung.
|
||||||
|
*
|
||||||
|
* `kuenftigeRechte === null` heisst: Das Konto faellt ganz weg.
|
||||||
|
*/
|
||||||
|
async function pruefeLetzterVerwalter(
|
||||||
|
zielId: number,
|
||||||
|
bisherigeRechte: Set<string>,
|
||||||
|
kuenftigeRechte: Set<string> | null,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const { recht, wofuer } of UNVERZICHTBARE_FAEHIGKEITEN) {
|
||||||
|
if (!bisherigeRechte.has(recht)) continue; // hatte es nie
|
||||||
|
if (kuenftigeRechte?.has(recht)) continue; // behaelt es
|
||||||
|
|
||||||
|
const [resource, action] = recht.split(':');
|
||||||
|
const andere = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
id: { not: zielId },
|
||||||
|
isActive: true,
|
||||||
|
roles: { some: { role: { permissions: { some: { permission: { resource, action } } } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (andere === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Dieses Konto ist das letzte, das „${wofuer}" kann (${recht}). ` +
|
||||||
|
'Ohne es liesse sich das System nur noch über die Kommandozeile ' +
|
||||||
|
'wieder in Ordnung bringen. Bitte zuerst einem anderen aktiven Konto ' +
|
||||||
|
'die entsprechende Rolle geben.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createUser(data: {
|
export async function createUser(data: {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -146,50 +251,73 @@ export async function createUser(data: {
|
|||||||
customerId?: number;
|
customerId?: number;
|
||||||
hasDeveloperAccess?: boolean;
|
hasDeveloperAccess?: boolean;
|
||||||
hasGdprAccess?: boolean;
|
hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean;
|
||||||
whatsappNumber?: string;
|
whatsappNumber?: string;
|
||||||
telegramUsername?: string;
|
telegramUsername?: string;
|
||||||
signalNumber?: string;
|
signalNumber?: string;
|
||||||
}) {
|
},
|
||||||
const hashedPassword = await bcrypt.hash(data.password, 10);
|
handelnderId: number,
|
||||||
|
) {
|
||||||
|
// Was das neue Konto koennen wird - aus den Rollen UND aus den Haken.
|
||||||
|
// `handelnderId` ist Pflichtparameter, damit kein Aufrufer die Pruefung
|
||||||
|
// vergessen kann; der Compiler erzwingt sie.
|
||||||
|
// Erst pruefen, dann rechnen: Eine unbekannte oder doppelte Rollen-ID
|
||||||
|
// faellt der Teilmengenregel nicht auf (sie bringt keine Rechte mit) und
|
||||||
|
// schluege erst beim Schreiben fehl.
|
||||||
|
const rollenIds = await normalisiereRollenIds(data.roleIds);
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
// Einmal pruefen, dann ueberall dieselbe Lesart: Guard und Zuweisung
|
||||||
data: {
|
// arbeiten auf DEMSELBEN Objekt (Pentest R193-02).
|
||||||
email: data.email,
|
const haken = normalisiereHaken(data as Record<string, unknown>);
|
||||||
password: hashedPassword,
|
|
||||||
firstName: data.firstName,
|
await pruefeTeilmenge(handelnderId, [
|
||||||
lastName: data.lastName,
|
...(await rechteVonRollen(rollenIds)),
|
||||||
customerId: data.customerId,
|
...rechteDerHaken(haken),
|
||||||
whatsappNumber: data.whatsappNumber || null,
|
]);
|
||||||
telegramUsername: data.telegramUsername || null,
|
|
||||||
signalNumber: data.signalNumber || null,
|
// Cost 12 wie in prisma/seed.ts (OWASP 2026). Hier stand 10 - dieselbe
|
||||||
roles: {
|
// Haertung, die im Seed laengst galt, war an dieser Stelle nie
|
||||||
create: data.roleIds.map((roleId) => ({ roleId })),
|
// angekommen. Bestehende Kennwoerter bleiben pruefbar, der Cost steckt im
|
||||||
|
// Hash.
|
||||||
|
const hashedPassword = await bcrypt.hash(data.password, 12);
|
||||||
|
|
||||||
|
const user = await prisma.$transaction(async (tx) => {
|
||||||
|
const angelegt = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
email: data.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
customerId: data.customerId,
|
||||||
|
whatsappNumber: data.whatsappNumber || null,
|
||||||
|
telegramUsername: data.telegramUsername || null,
|
||||||
|
signalNumber: data.signalNumber || null,
|
||||||
|
roles: {
|
||||||
|
create: rollenIds.map((roleId) => ({ roleId })),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
select: {
|
||||||
select: {
|
id: true,
|
||||||
id: true,
|
email: true,
|
||||||
email: true,
|
firstName: true,
|
||||||
firstName: true,
|
lastName: true,
|
||||||
lastName: true,
|
isActive: true,
|
||||||
isActive: true,
|
isServiceAccount: true,
|
||||||
isServiceAccount: true,
|
customerId: true,
|
||||||
customerId: true,
|
roles: {
|
||||||
roles: {
|
include: { role: true },
|
||||||
include: { role: true },
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
|
|
||||||
|
// Die Haken in derselben Transaktion wie das Konto (Pentest R191-01).
|
||||||
|
// Sonst koennte ein halb ausgestattetes Konto zurueckbleiben: angelegt,
|
||||||
|
// aber ohne die zugesagte versteckte Rolle.
|
||||||
|
await setzeHaken(tx, angelegt.id, haken);
|
||||||
|
|
||||||
|
return angelegt;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Entwicklerzugriff setzen falls aktiviert
|
|
||||||
if (data.hasDeveloperAccess) {
|
|
||||||
await setUserDeveloperAccess(user.id, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// DSGVO-Zugriff setzen falls aktiviert
|
|
||||||
if (data.hasGdprAccess) {
|
|
||||||
await setUserGdprAccess(user.id, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,12 +334,45 @@ export async function updateUser(
|
|||||||
customerId?: number;
|
customerId?: number;
|
||||||
hasDeveloperAccess?: boolean;
|
hasDeveloperAccess?: boolean;
|
||||||
hasGdprAccess?: boolean;
|
hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean;
|
||||||
whatsappNumber?: string;
|
whatsappNumber?: string;
|
||||||
telegramUsername?: string;
|
telegramUsername?: string;
|
||||||
signalNumber?: string;
|
signalNumber?: string;
|
||||||
}
|
},
|
||||||
|
handelnderId: number,
|
||||||
) {
|
) {
|
||||||
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, ...userData } = data;
|
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess, ...userData } = data;
|
||||||
|
|
||||||
|
// Rollen-IDs pruefen, bevor irgendetwas geschrieben oder entschieden wird.
|
||||||
|
// Die Letzter-Admin-Sperre weiter unten arbeitet mit dieser Liste - mit
|
||||||
|
// einer unbekannten ID darin haette sie eine Absicht bestaetigt, die der
|
||||||
|
// Schreibvorgang danach nicht einloesen konnte (Pentest R190-01).
|
||||||
|
const gepruefteRollenIds =
|
||||||
|
roleIds !== undefined ? await normalisiereRollenIds(roleIds) : undefined;
|
||||||
|
|
||||||
|
// Die Haken einmal pruefen - danach gibt es nur noch diese eine Lesart,
|
||||||
|
// fuer den Guard wie fuer die Zuweisung (Pentest R193-02).
|
||||||
|
const haken = normalisiereHaken({ hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess });
|
||||||
|
|
||||||
|
// Teilmengenregel auf dem ZUWACHS, nicht auf dem Endzustand.
|
||||||
|
//
|
||||||
|
// Wer nur den Nachnamen eines hoeher privilegierten Kollegen korrigiert,
|
||||||
|
// schickt das Formular unveraendert mit - inklusive gesetzter Haken. Wuerde
|
||||||
|
// hier der Endzustand geprueft, waere jede solche Korrektur ein 403.
|
||||||
|
// Geprueft wird deshalb nur, was das Zielkonto NEU dazubekommt.
|
||||||
|
{
|
||||||
|
const bisher = await effektiveRechte(id);
|
||||||
|
const zuwachs = new Set<string>();
|
||||||
|
if (roleIds !== undefined) {
|
||||||
|
for (const r of await rechteVonRollen(gepruefteRollenIds!)) {
|
||||||
|
if (!bisher.has(r)) zuwachs.add(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of rechteDerHaken(haken)) {
|
||||||
|
if (!bisher.has(r)) zuwachs.add(r);
|
||||||
|
}
|
||||||
|
await pruefeTeilmenge(handelnderId, zuwachs);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this would remove the last admin
|
// Check if this would remove the last admin
|
||||||
const isBeingDeactivated = userData.isActive === false;
|
const isBeingDeactivated = userData.isActive === false;
|
||||||
@@ -236,74 +397,31 @@ export async function updateUser(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isCurrentlyAdmin = currentUser?.roles.some((ur) =>
|
// Geschuetzt wird die FAEHIGKEIT, nicht ihr ueblicher Traeger -
|
||||||
ur.role.permissions.some(
|
// siehe UNVERZICHTBARE_FAEHIGKEITEN.
|
||||||
(rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete'
|
const bisherigeRechte = rechteAusRollen(currentUser?.roles ?? []);
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isCurrentlyAdmin) {
|
let kuenftigeRechte: Set<string> | null;
|
||||||
// Check if user will still be admin after role change
|
if (isBeingDeactivated) {
|
||||||
let willStillBeAdmin = false;
|
// Ein deaktiviertes Konto kann nichts mehr, egal welche Rollen formal
|
||||||
if (rolesAreBeingChanged) {
|
// drankleben.
|
||||||
const newRoles = await prisma.role.findMany({
|
kuenftigeRechte = null;
|
||||||
where: { id: { in: roleIds } },
|
} else if (rolesAreBeingChanged) {
|
||||||
include: {
|
const neueRollen = await prisma.role.findMany({
|
||||||
permissions: {
|
where: { id: { in: gepruefteRollenIds! } },
|
||||||
include: { permission: true },
|
include: { permissions: { include: { permission: true } } },
|
||||||
},
|
});
|
||||||
},
|
kuenftigeRechte = rechteAusRollen(neueRollen.map((role) => ({ role })));
|
||||||
});
|
} else {
|
||||||
willStillBeAdmin = newRoles.some((role) =>
|
kuenftigeRechte = bisherigeRechte;
|
||||||
role.permissions.some(
|
|
||||||
(rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
willStillBeAdmin = true; // Roles not being changed
|
|
||||||
}
|
|
||||||
|
|
||||||
// If user is losing admin status or being deactivated, check for other admins
|
|
||||||
if (!willStillBeAdmin || isBeingDeactivated) {
|
|
||||||
const otherAdminCount = await prisma.user.count({
|
|
||||||
where: {
|
|
||||||
id: { not: id },
|
|
||||||
isActive: true,
|
|
||||||
roles: {
|
|
||||||
some: {
|
|
||||||
role: {
|
|
||||||
permissions: {
|
|
||||||
some: {
|
|
||||||
permission: {
|
|
||||||
resource: 'users',
|
|
||||||
action: 'delete',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (otherAdminCount === 0) {
|
|
||||||
if (isBeingDeactivated) {
|
|
||||||
throw new Error(
|
|
||||||
'Dieser Benutzer ist der letzte Administrator und kann nicht deaktiviert werden'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
'Die Admin-Rolle kann nicht entfernt werden, da dies der letzte Administrator ist'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await pruefeLetzterVerwalter(id, bisherigeRechte, kuenftigeRechte);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash password if provided
|
// Hash password if provided
|
||||||
if (password) {
|
if (password) {
|
||||||
(userData as Record<string, unknown>).password = await bcrypt.hash(password, 10);
|
(userData as Record<string, unknown>).password = await bcrypt.hash(password, 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfen ob Rollen geändert werden (für Zwangslogout)
|
// Prüfen ob Rollen geändert werden (für Zwangslogout)
|
||||||
@@ -320,142 +438,123 @@ export async function updateUser(
|
|||||||
!currentRoleIds.every((id, i) => id === newRoleIds[i]);
|
!currentRoleIds.every((id, i) => id === newRoleIds[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user - bei Rollenänderung Token invalidieren
|
// Die gesamte Schreibphase in EINER Transaktion.
|
||||||
await prisma.user.update({
|
//
|
||||||
where: { id },
|
// Zwei Stufen, beide aus dem Pentest:
|
||||||
data: {
|
//
|
||||||
...userData,
|
// R190-01: deleteMany und createMany standen nackt nebeneinander.
|
||||||
// Token invalidieren wenn Rollen geändert werden
|
// Scheiterte das Anlegen, war das Loeschen schon passiert - das Konto
|
||||||
...(rolesChanged && { tokenInvalidatedAt: new Date() }),
|
// hatte gar keine Rolle mehr, bei einer Antwort, die wie "abgelehnt,
|
||||||
},
|
// nichts geschehen" aussah.
|
||||||
});
|
//
|
||||||
|
// R191-01: Danach lag zwar der Rollentausch in einer Transaktion, die
|
||||||
// Update roles if provided
|
// drei Haken aber liefen als eigene Schreibvorgaenge hinterher. Ein
|
||||||
if (roleIds) {
|
// Datenbankfehler dort hinterliess den Rollenstand gesetzt und die Haken
|
||||||
await prisma.userRole.deleteMany({ where: { userId: id } });
|
// halb - kein Verlust und keine Rechteerhoehung, aber ein Zwischenstand,
|
||||||
await prisma.userRole.createMany({
|
// den niemand angefordert hat. Ein PUT ist ein Vorgang, also gehoert er
|
||||||
data: roleIds.map((roleId) => ({ userId: id, roleId })),
|
// in eine Klammer.
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...userData,
|
||||||
|
// Token invalidieren wenn Rollen geändert werden
|
||||||
|
...(rolesChanged && { tokenInvalidatedAt: new Date() }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Handle developer access
|
if (gepruefteRollenIds !== undefined) {
|
||||||
if (hasDeveloperAccess !== undefined) {
|
await tx.userRole.deleteMany({ where: { userId: id } });
|
||||||
await setUserDeveloperAccess(id, hasDeveloperAccess);
|
await tx.userRole.createMany({
|
||||||
}
|
data: gepruefteRollenIds.map((roleId) => ({ userId: id, roleId })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Handle GDPR access
|
// Nach dem Rollentausch: Der loescht ALLE Zuordnungen, auch die
|
||||||
if (hasGdprAccess !== undefined) {
|
// versteckten Rollen. Die Haken setzen sie anschliessend wieder.
|
||||||
await setUserGdprAccess(id, hasGdprAccess);
|
await setzeHaken(tx, id, haken);
|
||||||
}
|
});
|
||||||
|
|
||||||
return getUserById(id);
|
return getUserById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to set developer access for a user
|
/**
|
||||||
async function setUserDeveloperAccess(userId: number, enabled: boolean) {
|
* Setzt oder entfernt eine der drei versteckten Rollen (DSGVO, Developer,
|
||||||
// Get or create developer:access permission
|
* Audit-Betrieb) - innerhalb der uebergebenen Transaktion.
|
||||||
let developerPerm = await prisma.permission.findFirst({
|
*
|
||||||
where: { resource: 'developer', action: 'access' },
|
* Ersetzt drei fast gleiche Funktionen, die jede fuer sich die Rolle bei
|
||||||
});
|
* Bedarf ANLEGTEN, falls sie fehlte. Das war als Notfallpfad gedacht und
|
||||||
|
* zugleich eine vierte Stelle, die festlegte, was "Developer" bedeutet -
|
||||||
if (!developerPerm) {
|
* mit einem anderen Rechtesatz als der Katalog (nur `developer:access`
|
||||||
developerPerm = await prisma.permission.create({
|
* statt allem) und ohne `isSystem`. Eine so entstandene Rolle waere ueber
|
||||||
data: { resource: 'developer', action: 'access' },
|
* die Rollenverwaltung aenderbar gewesen.
|
||||||
});
|
*
|
||||||
|
* Den Notfallpfad braucht es nicht mehr: `synchronisiereRechteUndRollen`
|
||||||
|
* legt die Rollen bei jedem Containerstart an, und die Startwache meldet,
|
||||||
|
* wenn eine fehlt. Fehlt sie hier trotzdem, ist Abbrechen mit klarer Ansage
|
||||||
|
* ehrlicher, als stillschweigend etwas Aehnliches zu erfinden.
|
||||||
|
*/
|
||||||
|
async function setzeVersteckteRolle(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
userId: number,
|
||||||
|
rollenName: string,
|
||||||
|
aktiv: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
if (typeof aktiv !== 'boolean') {
|
||||||
|
throw new UngueltigeEingabeError(`Haken für „${rollenName}" muss true oder false sein`);
|
||||||
|
}
|
||||||
|
const rolle = await tx.role.findUnique({ where: { name: rollenName } });
|
||||||
|
if (!rolle) {
|
||||||
|
throw new Error(
|
||||||
|
`Die Rolle „${rollenName}" fehlt in der Datenbank. Sie wird beim ` +
|
||||||
|
'Containerstart angelegt – einmalig nachholen mit: npx tsx prisma/sync-roles.ts',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get or create Developer role
|
const vorhanden = await tx.userRole.findUnique({
|
||||||
let developerRole = await prisma.role.findFirst({
|
where: { userId_roleId: { userId, roleId: rolle.id } },
|
||||||
where: { name: 'Developer' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!developerRole) {
|
// Strikt auf `true`, nicht auf Truthiness. Hier war die zweite Haelfte des
|
||||||
developerRole = await prisma.role.create({
|
// Bypasses aus R193-02: Der Guard fragte `=== true`, diese Zeile fragte
|
||||||
data: {
|
// "irgendwie wahr". `normalisiereHaken` laesst inzwischen nichts anderes
|
||||||
name: 'Developer',
|
// mehr durch - aber die Lesart darf sich auch dann nicht unterscheiden,
|
||||||
description: 'Entwicklerzugriff auf Datenbanktools',
|
// wenn jemand diese Funktion kuenftig von woanders aufruft.
|
||||||
permissions: {
|
if (aktiv === true && !vorhanden) {
|
||||||
create: [{ permissionId: developerPerm.id }],
|
await tx.userRole.create({ data: { userId, roleId: rolle.id } });
|
||||||
},
|
} else if (aktiv === false && vorhanden) {
|
||||||
},
|
await tx.userRole.delete({
|
||||||
|
where: { userId_roleId: { userId, roleId: rolle.id } },
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
return; // nichts geaendert - dann auch niemanden abmelden
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user already has Developer role
|
// Rechteaenderung wirkt sofort, nicht erst nach Ablauf des Tokens.
|
||||||
const hasRole = await prisma.userRole.findFirst({
|
await tx.user.update({
|
||||||
where: { userId, roleId: developerRole.id },
|
where: { id: userId },
|
||||||
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (enabled && !hasRole) {
|
/** Die drei Haken auf ihre versteckten Rollen abbilden. */
|
||||||
await prisma.userRole.create({
|
async function setzeHaken(
|
||||||
data: { userId, roleId: developerRole.id },
|
tx: Prisma.TransactionClient,
|
||||||
});
|
userId: number,
|
||||||
// Token invalidieren bei Rechteänderung
|
haken: Haken,
|
||||||
await prisma.user.update({
|
): Promise<void> {
|
||||||
where: { id: userId },
|
if (haken.hasDeveloperAccess !== undefined) {
|
||||||
data: { tokenInvalidatedAt: new Date() },
|
await setzeVersteckteRolle(tx, userId, ROLLE_DEVELOPER, haken.hasDeveloperAccess);
|
||||||
});
|
}
|
||||||
} else if (!enabled && hasRole) {
|
if (haken.hasGdprAccess !== undefined) {
|
||||||
await prisma.userRole.delete({
|
await setzeVersteckteRolle(tx, userId, ROLLE_DSGVO, haken.hasGdprAccess);
|
||||||
where: { userId_roleId: { userId, roleId: developerRole.id } },
|
}
|
||||||
});
|
if (haken.hasAuditOpsAccess !== undefined) {
|
||||||
// Token invalidieren bei Rechteänderung
|
await setzeVersteckteRolle(tx, userId, AUDIT_OPS_ROLLE, haken.hasAuditOpsAccess);
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { tokenInvalidatedAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to set GDPR access for a user
|
|
||||||
async function setUserGdprAccess(userId: number, enabled: boolean) {
|
|
||||||
// Get or create DSGVO role
|
|
||||||
let gdprRole = await prisma.role.findFirst({
|
|
||||||
where: { name: 'DSGVO' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!gdprRole) {
|
|
||||||
// Create DSGVO role with all audit:* and gdpr:* permissions
|
|
||||||
const gdprPermissions = await prisma.permission.findMany({
|
|
||||||
where: {
|
|
||||||
OR: [{ resource: 'audit' }, { resource: 'gdpr' }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
gdprRole = await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: 'DSGVO',
|
|
||||||
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
|
|
||||||
permissions: {
|
|
||||||
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user already has DSGVO role
|
|
||||||
const hasRole = await prisma.userRole.findFirst({
|
|
||||||
where: { userId, roleId: gdprRole.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (enabled && !hasRole) {
|
|
||||||
await prisma.userRole.create({
|
|
||||||
data: { userId, roleId: gdprRole.id },
|
|
||||||
});
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { tokenInvalidatedAt: new Date() },
|
|
||||||
});
|
|
||||||
} else if (!enabled && hasRole) {
|
|
||||||
await prisma.userRole.delete({
|
|
||||||
where: { userId_roleId: { userId, roleId: gdprRole.id } },
|
|
||||||
});
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { tokenInvalidatedAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteUser(id: number) {
|
export async function deleteUser(id: number) {
|
||||||
// Check if user is an admin
|
// Check if user is an admin
|
||||||
@@ -480,42 +579,9 @@ export async function deleteUser(id: number) {
|
|||||||
throw new Error('Benutzer nicht gefunden');
|
throw new Error('Benutzer nicht gefunden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has admin permissions (users:delete means admin)
|
// Dasselbe Kriterium wie beim Bearbeiten: Geschuetzt wird die Faehigkeit,
|
||||||
const isAdmin = user.roles.some((ur) =>
|
// das System zu verwalten - nicht die Rolle, die sie ueblicherweise traegt.
|
||||||
ur.role.permissions.some(
|
await pruefeLetzterVerwalter(id, rechteAusRollen(user.roles), null);
|
||||||
(rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isAdmin) {
|
|
||||||
// Count other admins (users with users:delete permission)
|
|
||||||
const adminCount = await prisma.user.count({
|
|
||||||
where: {
|
|
||||||
id: { not: id },
|
|
||||||
isActive: true,
|
|
||||||
roles: {
|
|
||||||
some: {
|
|
||||||
role: {
|
|
||||||
permissions: {
|
|
||||||
some: {
|
|
||||||
permission: {
|
|
||||||
resource: 'users',
|
|
||||||
action: 'delete',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (adminCount === 0) {
|
|
||||||
throw new Error(
|
|
||||||
'Dieser Benutzer ist der letzte Administrator und kann nicht gelöscht werden'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return prisma.user.delete({ where: { id } });
|
return prisma.user.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
@@ -546,17 +612,42 @@ export async function getRoleById(id: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRole(data: {
|
/**
|
||||||
name: string;
|
* Legt eine Rolle an.
|
||||||
description?: string;
|
*
|
||||||
permissionIds: number[];
|
* `handelnderId` ist Pflichtparameter, nicht optional: So kann ein Controller
|
||||||
}) {
|
* die Eskalationspruefung nicht vergessen, und der Compiler erzwingt sie bei
|
||||||
|
* jedem kuenftigen Aufrufer. Bis 09/2026 ging hier `req.body` ungefiltert
|
||||||
|
* durch - wer `users:create` hatte, konnte eine Rolle mit `developer:access`
|
||||||
|
* bauen und sie sich anschliessend selbst zuweisen.
|
||||||
|
*/
|
||||||
|
export async function createRole(
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
permissionIds: number[];
|
||||||
|
},
|
||||||
|
handelnderId: number,
|
||||||
|
) {
|
||||||
|
const name = data.name.trim();
|
||||||
|
|
||||||
|
// Kein zweites "Admin". Getrimmt und ohne Ruecksicht auf Gross-/
|
||||||
|
// Kleinschreibung verglichen, sonst liesse sich " admin " anlegen, das in
|
||||||
|
// einer Liste wie das Original aussieht.
|
||||||
|
if (istSystemrollenName(name)) {
|
||||||
|
throw new RollenSperrError(
|
||||||
|
`„${name}" ist der Name einer Systemrolle und kann nicht neu vergeben werden.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pruefeTeilmenge(handelnderId, await rechteVonPermissionIds(data.permissionIds));
|
||||||
|
|
||||||
return prisma.role.create({
|
return prisma.role.create({
|
||||||
data: {
|
data: {
|
||||||
name: data.name,
|
name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
permissions: {
|
permissions: {
|
||||||
create: data.permissionIds.map((permissionId) => ({ permissionId })),
|
create: [...new Set(data.permissionIds)].map((permissionId) => ({ permissionId })),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -567,15 +658,50 @@ export async function createRole(data: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aendert eine Rolle. Systemrollen sind gesperrt.
|
||||||
|
*
|
||||||
|
* Geprueft wird nur der ZUWACHS gegenueber dem bisherigen Rechtesatz der
|
||||||
|
* Rolle - wer Rechte wegnimmt, braucht sie nicht selbst zu besitzen.
|
||||||
|
*/
|
||||||
export async function updateRole(
|
export async function updateRole(
|
||||||
id: number,
|
id: number,
|
||||||
data: {
|
data: {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
permissionIds?: number[];
|
permissionIds?: number[];
|
||||||
}
|
},
|
||||||
|
handelnderId: number,
|
||||||
) {
|
) {
|
||||||
|
const bestehend = await prisma.role.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { permissions: { include: { permission: true } } },
|
||||||
|
});
|
||||||
|
if (!bestehend) return null;
|
||||||
|
|
||||||
|
if (bestehend.isSystem) {
|
||||||
|
throw new RollenSperrError(
|
||||||
|
`„${bestehend.name}" ist eine Systemrolle. Sie wird von der Anwendung ` +
|
||||||
|
'gepflegt und kann hier nicht geändert werden.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.name !== undefined && istSystemrollenName(data.name)) {
|
||||||
|
throw new RollenSperrError(
|
||||||
|
`„${data.name.trim()}" ist der Name einer Systemrolle und kann nicht vergeben werden.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { permissionIds, ...roleData } = data;
|
const { permissionIds, ...roleData } = data;
|
||||||
|
if (roleData.name !== undefined) roleData.name = roleData.name.trim();
|
||||||
|
|
||||||
|
if (permissionIds) {
|
||||||
|
const bisher = new Set(
|
||||||
|
bestehend.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`),
|
||||||
|
);
|
||||||
|
const kuenftig = await rechteVonPermissionIds(permissionIds);
|
||||||
|
const zuwachs = [...kuenftig].filter((r) => !bisher.has(r));
|
||||||
|
await pruefeTeilmenge(handelnderId, zuwachs);
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.role.update({
|
await prisma.role.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -585,28 +711,69 @@ export async function updateRole(
|
|||||||
if (permissionIds) {
|
if (permissionIds) {
|
||||||
await prisma.rolePermission.deleteMany({ where: { roleId: id } });
|
await prisma.rolePermission.deleteMany({ where: { roleId: id } });
|
||||||
await prisma.rolePermission.createMany({
|
await prisma.rolePermission.createMany({
|
||||||
data: permissionIds.map((permissionId) => ({ roleId: id, permissionId })),
|
// Dedupliziert: Doppelte IDs im Body liefen vorher in einen
|
||||||
|
// Primaerschluesselkonflikt und kamen als HTTP 500 zurueck.
|
||||||
|
data: [...new Set(permissionIds)].map((permissionId) => ({ roleId: id, permissionId })),
|
||||||
|
skipDuplicates: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rechteaenderung wirkt sofort, nicht erst nach Ablauf des Tokens.
|
||||||
|
await meldeTraegerAb(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return getRoleById(id);
|
return getRoleById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRole(id: number) {
|
export async function deleteRole(id: number) {
|
||||||
// Check if role is assigned to any users
|
const bestehend = await prisma.role.findUnique({ where: { id } });
|
||||||
|
if (!bestehend) {
|
||||||
|
throw new Error('Rolle nicht gefunden');
|
||||||
|
}
|
||||||
|
if (bestehend.isSystem) {
|
||||||
|
throw new RollenSperrError(
|
||||||
|
`„${bestehend.name}" ist eine Systemrolle und kann nicht gelöscht werden.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vor dem Loeschen abmelden - danach sind die Traeger durch den Cascade
|
||||||
|
// nicht mehr ermittelbar. Greift heute nur theoretisch, weil zugewiesene
|
||||||
|
// Rollen ohnehin abgelehnt werden; die Reihenfolge bleibt trotzdem die
|
||||||
|
// richtige, falls diese Sperre je gelockert wird.
|
||||||
const count = await prisma.userRole.count({ where: { roleId: id } });
|
const count = await prisma.userRole.count({ where: { roleId: id } });
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Rolle kann nicht gelöscht werden, da sie ${count} Benutzern zugewiesen ist`
|
`Rolle kann nicht gelöscht werden, da sie ${count} Benutzern zugewiesen ist`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await meldeTraegerAb(id);
|
||||||
|
|
||||||
return prisma.role.delete({ where: { id } });
|
return prisma.role.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permission operations
|
// Permission operations
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die vergebbaren Rechte - angereichert um Klartext und Gruppe.
|
||||||
|
*
|
||||||
|
* Gefiltert auf den Katalog: In der Datenbank koennen Rechte aus frueheren
|
||||||
|
* Schemata liegen (`customers:access`, `settings:create` und aehnliche), die
|
||||||
|
* nirgends geprueft werden. Ungefiltert wuerden sie in der Rollenoberflaeche
|
||||||
|
* als anhakbare Kaestchen erscheinen, die nichts bewirken - genau der
|
||||||
|
* Zustand, den dieser Umbau beseitigt. Geloescht werden sie nicht: Ein
|
||||||
|
* Lesefilter ist die kleinere Behauptung als ein DELETE, und der
|
||||||
|
* Rechte-Report meldet sie ohnehin.
|
||||||
|
*/
|
||||||
export async function getAllPermissions() {
|
export async function getAllPermissions() {
|
||||||
return prisma.permission.findMany({
|
const alle = await prisma.permission.findMany({
|
||||||
orderBy: [{ resource: 'asc' }, { action: 'asc' }],
|
orderBy: [{ resource: 'asc' }, { action: 'asc' }],
|
||||||
});
|
});
|
||||||
|
const beschreibung = new Map(
|
||||||
|
RECHTE_KATALOG.map((r) => [`${r.resource}:${r.action}`, r]),
|
||||||
|
);
|
||||||
|
return alle
|
||||||
|
.filter((p) => beschreibung.has(`${p.resource}:${p.action}`))
|
||||||
|
.map((p) => {
|
||||||
|
const k = beschreibung.get(`${p.resource}:${p.action}`)!;
|
||||||
|
return { ...p, bezeichnung: k.bezeichnung, gruppe: k.gruppe };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,33 @@
|
|||||||
* obwohl die Fehlermeldung "Dokument vor wenigen Sekunden bereits
|
* obwohl die Fehlermeldung "Dokument vor wenigen Sekunden bereits
|
||||||
* angelegt" eindeutig eine 400-Class-Situation ist.
|
* angelegt" eindeutig eine 400-Class-Situation ist.
|
||||||
*/
|
*/
|
||||||
export class ApiError extends Error {
|
/**
|
||||||
|
* Basisklasse fuer Fehler, deren Wortlaut fuer den AUFRUFER bestimmt ist.
|
||||||
|
*
|
||||||
|
* Der Unterschied ist der Kern von R190-01 und R192-01: Bis 09/2026 reichten
|
||||||
|
* die Controller jede `error.message` durch. Damit landete erst der
|
||||||
|
* vollstaendige Prisma-Aufruf samt Serverpfad beim Client und dann der
|
||||||
|
* Wortlaut eines TypeError. Beide Male dieselbe Ursache - eine Meldung, die
|
||||||
|
* fuer Entwickler geschrieben ist, an einen Empfaenger, fuer den sie nicht
|
||||||
|
* gedacht war.
|
||||||
|
*
|
||||||
|
* Ab hier gilt: Was von dieser Klasse abstammt (oder ein blankes `Error` ist,
|
||||||
|
* das wir absichtlich geworfen haben), geht nach draussen. Alles andere -
|
||||||
|
* TypeError, Prisma, was auch immer - wird protokolliert und durch eine
|
||||||
|
* allgemeine Auskunft ersetzt.
|
||||||
|
*/
|
||||||
|
export class FachlicherFehler extends Error {
|
||||||
readonly statusCode: number;
|
readonly statusCode: number;
|
||||||
constructor(statusCode: number, message: string) {
|
constructor(message: string, statusCode = 400) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'ApiError';
|
this.name = new.target.name;
|
||||||
this.statusCode = statusCode;
|
this.statusCode = statusCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ApiError extends FachlicherFehler {
|
||||||
|
constructor(statusCode: number, message: string) {
|
||||||
|
super(message, statusCode);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Eine Stelle, an der entschieden wird, was ein Fehler dem Aufrufer sagt.
|
||||||
|
*
|
||||||
|
* Vorher stand in 26 Controllern 124-mal dieselbe Zeile:
|
||||||
|
*
|
||||||
|
* error: error instanceof Error ? error.message : 'Irgendein Fallback'
|
||||||
|
*
|
||||||
|
* Das reicht JEDE Fehlermeldung durch - auch die, die fuer Entwickler
|
||||||
|
* geschrieben sind. Konkret gefunden wurden ein vollstaendiger Prisma-Aufruf
|
||||||
|
* samt Serverpfad (R190-01) und der Wortlaut eines TypeError (R192-01). Kein
|
||||||
|
* Loch fuer sich genommen, aber eine Landkarte: Pfade, Spaltennamen,
|
||||||
|
* eingesetzte Bibliotheken.
|
||||||
|
*
|
||||||
|
* 124 Einzelkorrekturen waeren die falsche Antwort gewesen - das ist die
|
||||||
|
* Falle aus R186-01 und R188, wo dieselbe Regel in mehreren Kopien lebte und
|
||||||
|
* auseinanderlief. Deshalb eine Funktion.
|
||||||
|
*
|
||||||
|
* Die Unterscheidung laeuft ueber die Fehlerklasse, nicht ueber eine
|
||||||
|
* Heuristik auf dem Text:
|
||||||
|
*
|
||||||
|
* - `FachlicherFehler` (und Abkoemmlinge wie `ApiError`) - absichtlich
|
||||||
|
* geworfen, Wortlaut fuer den Aufrufer, eigener Statuscode.
|
||||||
|
* - blankes `Error` - ebenfalls absichtlich geworfen, historisch die
|
||||||
|
* ueblichste Form ("Der letzte Admin kann nicht..."). Geht mit dem
|
||||||
|
* uebergebenen Status hinaus.
|
||||||
|
* - alles andere (TypeError, RangeError, Prisma...) - Programmierfehler oder
|
||||||
|
* Fremdfehler. Geht als 500 mit der allgemeinen Auskunft hinaus, die
|
||||||
|
* Einzelheiten ins Protokoll. 500 und nicht 400, weil ein
|
||||||
|
* Eingabefehler-Code hier die naechste Meldung waere, die sich falsch
|
||||||
|
* ausgibt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { FachlicherFehler } from './apiError.js';
|
||||||
|
|
||||||
|
export function antworteAufFehler(
|
||||||
|
res: Response,
|
||||||
|
error: unknown,
|
||||||
|
fallback: string,
|
||||||
|
status = 400,
|
||||||
|
): void {
|
||||||
|
if (error instanceof FachlicherFehler) {
|
||||||
|
res.status(error.statusCode).json({ success: false, error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.constructor === Error) {
|
||||||
|
res.status(status).json({ success: false, error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`[${fallback}] unerwarteter Fehler:`, error);
|
||||||
|
res.status(500).json({ success: false, error: fallback });
|
||||||
|
}
|
||||||
@@ -654,6 +654,10 @@ const USER_UPDATABLE_FIELDS = [
|
|||||||
// stehen, damit pick() sie nicht aus dem Request entfernt.
|
// stehen, damit pick() sie nicht aus dem Request entfernt.
|
||||||
'hasGdprAccess',
|
'hasGdprAccess',
|
||||||
'hasDeveloperAccess',
|
'hasDeveloperAccess',
|
||||||
|
// Eingreifende Audit-Rechte (versiegeln/aufraeumen/Aufbewahrung), bewusst
|
||||||
|
// getrennt von hasGdprAccess: Aufsicht und Eingriff sind zwei Rollen
|
||||||
|
// (Pentest R186). Mappt auf die versteckte Rolle 'Audit-Betrieb'.
|
||||||
|
'hasAuditOpsAccess',
|
||||||
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
|
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
|
||||||
// Nicht: password – wird über dedizierten Endpoint POST /users/:id/password
|
// Nicht: password – wird über dedizierten Endpoint POST /users/:id/password
|
||||||
// gesetzt (Pentest Runde 12 (2026-05-18) – MITTEL: generisches User-Update
|
// gesetzt (Pentest Runde 12 (2026-05-18) – MITTEL: generisches User-Update
|
||||||
@@ -841,6 +845,18 @@ export function pickUserCreate(body: unknown): Partial<Record<string, unknown>>
|
|||||||
return pick((body as object) || {}, USER_CREATE_FIELDS, { stripHtmlFromStrings: true });
|
return pick((body as object) || {}, USER_CREATE_FIELDS, { stripHtmlFromStrings: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rollen-Whitelist.
|
||||||
|
//
|
||||||
|
// `createRole`/`updateRole` reichten `req.body` bis 09/2026 ungefiltert an
|
||||||
|
// Prisma durch - dasselbe Muster wie R110, nur an der Stelle, an der
|
||||||
|
// festgelegt wird, wer was darf. `isSystem` und `isHidden` duerfen NIEMALS
|
||||||
|
// aus dem Request kommen: Sie sind die Sperre selbst, nicht ihr Gegenstand.
|
||||||
|
const ROLE_UPDATABLE_FIELDS = ['name', 'description', 'permissionIds'] as const;
|
||||||
|
|
||||||
|
export function pickRoleUpdate(body: unknown): Partial<Record<string, unknown>> {
|
||||||
|
return pick((body as object) || {}, ROLE_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== KATALOG-/CONFIG-WHITELISTS (Pentest R110) ====================
|
// ==================== KATALOG-/CONFIG-WHITELISTS (Pentest R110) ====================
|
||||||
// Pentest 2026-07-11 (MEDIUM, R110): sieben Update-Endpunkte reichten
|
// Pentest 2026-07-11 (MEDIUM, R110): sieben Update-Endpunkte reichten
|
||||||
// `req.body` ungefiltert an Prisma durch – gleiches Muster wie das
|
// `req.body` ungefiltert an Prisma durch – gleiches Muster wie das
|
||||||
|
|||||||
+501
@@ -97,6 +97,507 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- [x] **🧹 Aufräumrunde vor Etappe 2: drei Nachrangpunkte** (2026-09-09)
|
||||||
|
|
||||||
|
**1. R192-01 projektweit — interne Fehlermeldungen an den Client**
|
||||||
|
- Das Muster `error instanceof Error ? error.message` stand **124-mal in 26
|
||||||
|
Controllern**. Jede dieser Stellen konnte Serverpfade, Spaltennamen und
|
||||||
|
Bibliotheksinterna ausliefern — kein Loch für sich, aber eine Landkarte.
|
||||||
|
- Zentral gelöst statt 124-mal einzeln (die Falle aus R186-01/R188):
|
||||||
|
`src/utils/fehlerAntwort.ts` mit `antworteAufFehler()`. Jetzt **123
|
||||||
|
Aufrufe in 26 Dateien**, eine Regel.
|
||||||
|
- Die Unterscheidung läuft über die **Fehlerklasse**: Neue Basisklasse
|
||||||
|
`FachlicherFehler` (in `utils/apiError.ts`) für alles, dessen Wortlaut
|
||||||
|
für den Aufrufer bestimmt ist — `ApiError`, `RechteEskalationError` (403),
|
||||||
|
`RollenSperrError` (403), `UngueltigeEingabeError` (400), `FilterFehler`,
|
||||||
|
`ReferralError` stammen jetzt davon ab. Ein blankes `Error` gilt weiter
|
||||||
|
als absichtlich. Alles andere (TypeError, Prisma, JWT-Bibliothek) → **500**
|
||||||
|
mit allgemeiner Auskunft, Einzelheiten ins Protokoll.
|
||||||
|
- Mit gefunden: Sechs Stellen in `cachedEmail.controller.ts` **interpolierten**
|
||||||
|
die interne Meldung in den Antworttext (`Fehler beim Speichern: ${msg}`) —
|
||||||
|
die hätte kein Klassenfilter erwischt, der nur das Feld ersetzt.
|
||||||
|
Und `POST /auth/refresh` gab den Wortlaut der JWT-Bibliothek zurück
|
||||||
|
(„jwt malformed", „invalid signature") — der sagt einem Angreifer, **woran**
|
||||||
|
sein Token gescheitert ist. Geht jetzt nur noch in den Alarmkanal.
|
||||||
|
|
||||||
|
**2. Admin-Heuristik — geschützt wird jetzt die Fähigkeit, nicht ihr Träger**
|
||||||
|
- Bisher galt „wer `users:delete` hat, ist Admin". Ein Zufallsmerkmal: Wer
|
||||||
|
Konten anlegen und bearbeiten darf, aber nicht löschen, verwaltet genauso —
|
||||||
|
zählte aber nicht.
|
||||||
|
- **Bewusst nicht auf die Rolle „Admin" umgestellt**, wie zunächst
|
||||||
|
vorgeschlagen: Eine selbst gebaute Rolle mit `users:update` verwaltet
|
||||||
|
tatsächlich, ein Namenskriterium würde sie übersehen — man könnte dann den
|
||||||
|
letzten Admin löschen, obwohl die Verwaltungsfähigkeit erhalten bliebe.
|
||||||
|
- Neu: `UNVERZICHTBARE_FAEHIGKEITEN` = `users:update` („Benutzer verwalten")
|
||||||
|
und `roles:manage` („Rollen und Rechte pflegen"). Wer die letzte Person mit
|
||||||
|
einer davon ist, kann sie nicht verlieren — durch Rollenwechsel,
|
||||||
|
Deaktivierung oder Löschung. Die Meldung nennt die Fähigkeit beim Namen
|
||||||
|
statt „letzter Administrator".
|
||||||
|
- Nachgeprüft: Mit drei Trägern ist alles erlaubt; ist einer der letzte,
|
||||||
|
→ 400 mit Klartext. Deaktivierung zählt als Verlust (ein inaktives Konto
|
||||||
|
kann nichts, egal welche Rollen drankleben).
|
||||||
|
- Nebenbei: `updateUser` hashte beim Passwort-Zurücksetzen noch mit Cost 10 —
|
||||||
|
der letzte Rest der Inkonsistenz aus R191-01.
|
||||||
|
|
||||||
|
**3. Portal-Kunden — Trennung festgenagelt, nicht aufgelöst**
|
||||||
|
- Korrektur meiner eigenen Einordnung: Ich hatte das als „noch zu migrieren"
|
||||||
|
geführt. Falsch. Kunden bekommen **niemals** operative Rechte; die
|
||||||
|
Portalansicht ist dafür nicht gebaut und prüft es nicht.
|
||||||
|
- Die zwei Kopien des festen Arrays in `auth.service.ts` sind jetzt eine
|
||||||
|
Konstante `PORTAL_RECHTE` mit einem Kommentar, der die Trennung als
|
||||||
|
Absicht benennt.
|
||||||
|
- **Harter Riegel im Gate:** `requirePermission` schneidet die Rechte eines
|
||||||
|
Portal-Zugangs auf `PORTAL_RECHTE` zu — unabhängig davon, was sein Token
|
||||||
|
behauptet. Heute wirkungslos (sie halten ohnehin nur diese zwei), morgen
|
||||||
|
die Sicherung: Bisher hätte eine unbedachte Zeile in der Token-Erzeugung
|
||||||
|
gereicht, um die Trennung zu kippen.
|
||||||
|
- **Startwache:** `pruefePortalRechte()` meldet beim Start jedes Recht in
|
||||||
|
`PORTAL_RECHTE`, das nicht Lesen ist. Damit überlebt die Regel den Tag, an
|
||||||
|
dem jemand „nur kurz" etwas hinzufügt.
|
||||||
|
- Dateien: `utils/fehlerAntwort.ts` (neu), `utils/apiError.ts`,
|
||||||
|
`config/rechte-katalog.ts`, `middleware/auth.ts`, `services/auth.service.ts`,
|
||||||
|
`services/user.service.ts`, `services/rechte.service.ts`,
|
||||||
|
`services/pflichtrechte.service.ts`, 26 Controller
|
||||||
|
|
||||||
|
- [x] **🚨 R193-02: Truthiness-Bypass bei den drei Haken (HIGH)** (2026-09-09)
|
||||||
|
- Befund der Pentesterin, **Prod-Blocker**: Der Eskalations-Guard fragte
|
||||||
|
`=== true`, die Zuweisung fragte auf Truthiness. Zwischen diesen beiden
|
||||||
|
Lesarten passte der Angriff: `{"hasDeveloperAccess":"ja"}` sah für den
|
||||||
|
Guard nach keinem Rechtezuwachs aus (also kein 403) und für die Zuweisung
|
||||||
|
nach „gesetzt" (also Rolle drauf). Ein Konto mit `users:update`, das
|
||||||
|
`developer:access` **nicht** hält, konnte damit einem anderen Konto
|
||||||
|
Vollzugriff geben — und sich anmelden.
|
||||||
|
- Damit war die Kernentscheidung aus Etappe 1 ausgehebelt: `developer:access`
|
||||||
|
und `audit:admin` sollten ausdrücklich **nur über die Kommandozeile**
|
||||||
|
erstmalig vergebbar sein. Der Bypass machte sie wieder über ein
|
||||||
|
Browser-Feld erreichbar.
|
||||||
|
- Es ist exakt das Muster, das ich der Pentesterin eine Runde vorher selbst
|
||||||
|
beschrieben hatte: *„Eine Absicherung, die nur in einer von zwei Kopien
|
||||||
|
ankommt, ist keine."* Nur dass es diesmal nicht zwei Dateien waren,
|
||||||
|
sondern zwei **Auffassungen desselben Wertes** in derselben Datei.
|
||||||
|
- Fix: `normalisiereHaken()` prüft die drei Felder einmal streng auf Boolean
|
||||||
|
(alles andere 400), und **Guard und Zuweisung arbeiten danach auf
|
||||||
|
demselben geprüften Objekt** — es gibt nur noch eine Lesart. Zusätzlich
|
||||||
|
liest `setzeVersteckteRolle` strikt `=== true` / `=== false`, damit sich
|
||||||
|
die Lesart auch dann nicht spaltet, wenn die Funktion künftig von
|
||||||
|
woanders gerufen wird.
|
||||||
|
- **Gleiche Bauart nebenan gefunden:** `isActive` und `isServiceAccount`
|
||||||
|
steuern ebenfalls ein Gate, das strikt auf `boolean` prüft — ein `"ja"`
|
||||||
|
rutschte daran vorbei, ohne das Gate (u. a. die `audit:admin`-Hürde für
|
||||||
|
das Dienstkonto-Kennzeichen) auszulösen, und lief dann in einen
|
||||||
|
Prisma-Fehler. Kein Bypass, weil der Schreibvorgang scheiterte, aber ein
|
||||||
|
500 für eine schlicht falsche Eingabe. Jetzt alle fünf Boolean-Felder
|
||||||
|
einheitlich geprüft → 400.
|
||||||
|
- Nachgeprüft: Ihre vier Vektoren plus 30 weitere Kombinationen
|
||||||
|
(`1`, `"true"`, `"false"`, `{}`, `[1]`, `null`, `"0"`, `-1`, `2.5` × drei
|
||||||
|
Haken) → **alle 400, Rollen unverändert**. Derselbe Angriff über
|
||||||
|
`POST /users` → 400, **kein Konto angelegt**. Gegenprobe: Ein Täter, der
|
||||||
|
`gdpr:*` selbst hält, setzt den Haken mit echtem `true` weiterhin
|
||||||
|
erfolgreich (200), und `hasDeveloperAccess:true` bleibt 403.
|
||||||
|
- Dateien: `src/services/rechte.service.ts`, `src/services/user.service.ts`,
|
||||||
|
`src/controllers/user.controller.ts`
|
||||||
|
|
||||||
|
- [x] **🤐 R192-01: Fehlermeldungen, die für Entwickler geschrieben sind** (2026-09-09)
|
||||||
|
- Befund der Pentesterin: `PUT /users/:id {"roleIds":{}}` gab 400 mit dem
|
||||||
|
rohen JS-Fehler `object is not iterable (cannot read property
|
||||||
|
Symbol(Symbol.iterator))`. Kein Wipe, keine Eskalation — dieselbe Familie
|
||||||
|
wie der Prisma-Pfad-Leak aus R190-01.
|
||||||
|
- Zwei Ursachen, beide behoben:
|
||||||
|
1. `normalisiereRollenIds` spreizte `roleIds` ohne `Array.isArray`-Guard.
|
||||||
|
2. Die Fehlerabbildung reichte **jede** `error.message` durch.
|
||||||
|
- Die Unterscheidung läuft jetzt über die **Fehlerklasse**, nicht über eine
|
||||||
|
Heuristik auf dem Text: Ein blankes `Error` werfen wir absichtlich und mit
|
||||||
|
einer Meldung für Menschen („Der letzte Admin kann nicht…"). `TypeError`
|
||||||
|
und Verwandte sind Programmierfehler, Prisma-Fehler kommen von außen —
|
||||||
|
beides sagt dem Aufrufer nichts Nützliches und einem Angreifer zu viel.
|
||||||
|
Unerwartetes wird jetzt **500** statt 400: Ein Eingabefehler-Code wäre die
|
||||||
|
nächste Meldung, die sich falsch ausgibt.
|
||||||
|
- Gegengeprüft, dass mit dem Leck nicht auch das Nützliche wegfällt:
|
||||||
|
„Rolle kann nicht gelöscht werden, da sie 1 Benutzern zugewiesen ist" → 400,
|
||||||
|
Systemrollen-Sperre → 403 mit Klartext, Eskalation → 403 mit den konkret
|
||||||
|
fehlenden Rechten. `{}`, `"abc"`, `5`, `true` als `roleIds` → 400
|
||||||
|
„roleIds muss eine Liste sein", Zustand unverändert. R190-01/R191-01
|
||||||
|
grün.
|
||||||
|
- **⚠️ Offen, größer als der Befund:** Das Muster
|
||||||
|
`error instanceof Error ? error.message` steht **124-mal in 26
|
||||||
|
Controller-Dateien**. Behoben ist es nur im Benutzer-/Rollenpfad. Das ist
|
||||||
|
dieselbe Lage wie bei R188 (181 ungeprüfte IDs) und gehört genauso
|
||||||
|
zentral gelöst statt 124-mal einzeln — eigene Runde.
|
||||||
|
- Dateien: `src/services/rechte.service.ts`, `src/controllers/user.controller.ts`
|
||||||
|
|
||||||
|
- [x] **🔗 R191-01: Ein PUT, eine Klammer** (2026-09-09)
|
||||||
|
- Befund der Pentesterin nach dem R190-01-Fix: Nur der Rollentausch lag in
|
||||||
|
der Transaktion, die drei Haken (DSGVO/Developer/Audit-Betrieb) liefen
|
||||||
|
**danach** in eigenen Schreibvorgängen. Ein Multi-Feld-PUT war damit nicht
|
||||||
|
gesamt-atomar — bricht ein Haken-Write ab, steht der Rollenstand schon und
|
||||||
|
die Haken halb. Kein Verlust, keine Eskalation, aber ein Zwischenstand, den
|
||||||
|
niemand angefordert hat.
|
||||||
|
- Fix: Die **gesamte** Schreibphase von `updateUser` in einer
|
||||||
|
`$transaction` — Benutzerdaten, Rollentausch, alle drei Haken.
|
||||||
|
`createUser` ebenso: Konto und Haken zusammen.
|
||||||
|
- **Beim Umbau mitgefunden:** Die drei Haken-Helfer **legten die Rolle bei
|
||||||
|
Bedarf selbst an**, falls sie fehlte. Das war eine *vierte* Stelle, die
|
||||||
|
definierte, was „Developer" bedeutet — mit einem anderen Rechtesatz als der
|
||||||
|
Katalog (nur `developer:access` statt allem) und **ohne `isSystem`**. Eine
|
||||||
|
so entstandene Rolle wäre über die Rollenverwaltung änderbar gewesen. Der
|
||||||
|
Notfallpfad ist weg: `sync-roles` legt die Rollen bei jedem Start an, die
|
||||||
|
Startwache meldet ihr Fehlen, und fehlt sie hier doch, bricht der Vorgang
|
||||||
|
mit klarer Ansage ab statt etwas Ähnliches zu erfinden. Drei fast gleiche
|
||||||
|
Funktionen wurden dabei eine.
|
||||||
|
- **int4-Überlauf** (ihr kosmetischer Nebenbefund): IDs jenseits des
|
||||||
|
INT-Bereichs werden gar nicht mehr abgefragt, sondern als unbekannt
|
||||||
|
gemeldet — `9999999999` sagt jetzt „Unbekannte Rollen-ID" statt
|
||||||
|
„Fehler beim Aktualisieren". Gleiche Behandlung für Rechte-IDs.
|
||||||
|
- **`createUser` hasht jetzt mit Cost 12** statt 10, wie `seed.ts`. Wieder
|
||||||
|
eine Härtung, die nur in einer von zwei Kopien angekommen war.
|
||||||
|
- Nachgeprüft: Rollback bewiesen, indem die DSGVO-Rolle vorübergehend
|
||||||
|
umbenannt und ein `{roleIds:[23], hasGdprAccess:true}` geschickt wurde →
|
||||||
|
400 mit Klartext, Rollen **unverändert** (kein Teil-Write). Ihre
|
||||||
|
Typvektoren `"22"`, `4.5`, `1e3`, `true`, `null`, Riesenzahl → alle 400,
|
||||||
|
kein Wipe. R190-01-Regression erneut grün.
|
||||||
|
- Dateien: `src/services/user.service.ts`, `src/services/rechte.service.ts`
|
||||||
|
|
||||||
|
- [x] **🧨 R190-01: Rollentausch zerstörte, wo er ablehnte** (2026-09-09)
|
||||||
|
- Befund der Pentesterin: `updateUser` machte `deleteMany` + `createMany`
|
||||||
|
**ohne Transaktion und ohne `skipDuplicates`**. Eine doppelte roleId
|
||||||
|
(`[4,4]`) oder eine erfundene (`[99999]`) kam durch die Teilmengenregel –
|
||||||
|
sie bringt ja keine Rechte mit – und schlug erst beim Schreiben fehl,
|
||||||
|
**nach** dem Löschen. Antwort: HTTP 400. Konto danach: **keine Rolle**.
|
||||||
|
- Die Antwort log über sich selbst: „400" liest sich als *abgelehnt, nichts
|
||||||
|
passiert*. Zerstört wurde trotzdem.
|
||||||
|
- **Die Letzter-Admin-Sperre wurde damit umgangen**, ohne sie anzugreifen:
|
||||||
|
Sie prüft die *Absicht* (die Admin-Rolle steht im Body → „bleibt Admin"),
|
||||||
|
der Schreibvorgang scheiterte danach. Aussperrung, Rückweg nur per CLI.
|
||||||
|
Auch versehentlich auslösbar durch doppelte IDs aus dem Frontend.
|
||||||
|
- **Es war meine Härtung, die ich nicht mitgenommen habe.** `updateRole`
|
||||||
|
hatte Dedup und `skipDuplicates` seit Etappe 1 – das Geschwister
|
||||||
|
`updateUser`/`createUser` nicht. Genau das Muster, das ich in derselben
|
||||||
|
Runde dreimal angeprangert habe: eine Absicherung, die nur in einer von
|
||||||
|
zwei Kopien ankommt.
|
||||||
|
- Fix: `normalisiereRollenIds()` prüft **vor** jeder Entscheidung und jedem
|
||||||
|
Schreibvorgang gegen die Datenbank (unbekannte ID → 400 mit Klartext),
|
||||||
|
dedupliziert, und der Tausch läuft in `$transaction` mit
|
||||||
|
`skipDuplicates`. Die Letzter-Admin-Sperre arbeitet damit auf einer Liste,
|
||||||
|
die auch einlösbar ist.
|
||||||
|
- **Nebenbefund beim Nachstellen**: Die rohe Prisma-Fehlermeldung ging
|
||||||
|
wortwörtlich an den Client – inklusive Dateipfad des Servers. Wird jetzt
|
||||||
|
protokolliert statt ausgeliefert.
|
||||||
|
- Nachgestellt und gegengeprüft: `[22,22]` → 200, Rolle erhalten;
|
||||||
|
`[99999]` und `[22,99999]` → 400, Rollen **unverändert**; `[-1]` → 400;
|
||||||
|
`[22,23]` → 200 korrekt gesetzt. `createUser` ebenso, abgelehnte Anlagen
|
||||||
|
hinterlassen kein Konto.
|
||||||
|
- Bestätigt dicht gemeldet: Restore/Factory-Reset ohne `roles:manage` → 403,
|
||||||
|
roleIds+Haken gleichzeitig → 403 ohne Teil-Write, alle vier Vergabewege →
|
||||||
|
403, isSystem-Sperre inkl. `" aDmIn "` → 403.
|
||||||
|
- Dateien: `src/services/rechte.service.ts`, `src/services/user.service.ts`,
|
||||||
|
`src/controllers/user.controller.ts`
|
||||||
|
|
||||||
|
- [x] **🔑 Rechtemodell Etappe 1: Katalog begradigt, Selbst-Erhöhung geschlossen** (2026-09-04)
|
||||||
|
- Vorarbeit für die Rollen-Oberfläche (Etappe 2). Eine Checkbox-Liste über
|
||||||
|
einem Katalog, der nicht stimmt, wäre schlimmer als gar keine.
|
||||||
|
- **18 von 50 Rechten bewachten nichts.** `tariffs:*`,
|
||||||
|
`cancellation-periods:*`, `contract-durations:*` und `email-providers:*`
|
||||||
|
standen im Katalog und waren anhakbar – die Routen prüften in Wahrheit
|
||||||
|
`providers:*`, `platforms:*` und `settings:*`. Jetzt gaten die Routen auf
|
||||||
|
ihre eigenen Rechte; eine rein additive Migration vererbt jedes neue Recht
|
||||||
|
an jede Rolle, die bisher das Sammelrecht hatte. **Nachgerechnet: keine
|
||||||
|
Rolle hat etwas verloren.**
|
||||||
|
- **Der Katalog stand dreifach und divergent** – `seed.ts`, `sync-roles.ts`
|
||||||
|
und, abweichend, `factoryReset`. Die dritte Kopie kannte weder `audit:*`
|
||||||
|
noch `gdpr:*` und legte DSGVO, Audit-Betrieb und Gegenbuch gar nicht an:
|
||||||
|
**Nach einem Werksreset konnte niemand mehr eine Auskunft nach Art. 15
|
||||||
|
ausführen**, und aufgefallen wäre es erst, wenn eine Frist läuft. Jetzt
|
||||||
|
eine Quelle: `backend/src/config/rechte-katalog.ts`.
|
||||||
|
- **Nebenbefund im selben Code:** `factoryReset` setzte das Admin-Kennwort
|
||||||
|
fest auf `"admin"` mit bcrypt-Cost 10 – genau das, was `seed.ts` seit
|
||||||
|
Pentest Runde 12 verbietet. Dieselbe Härtung war nur in einer der beiden
|
||||||
|
Kopien angekommen. Jetzt zufällig, Cost 12, einmalig im Log.
|
||||||
|
- **Selbst-Erhöhung geschlossen** (der offene Punkt aus dem Audit-Betrieb-
|
||||||
|
Eintrag vom 26.08.): Neues Recht `roles:manage`, getrennt von `users:*`.
|
||||||
|
Dazu die Teilmengenregel in `rechte.service.ts` – niemand kann ein Recht
|
||||||
|
weitergeben, das er selbst nicht hält. Sie greift auf **allen vier** Wegen:
|
||||||
|
Rolle anlegen, Rolle ändern, Rollen zuweisen und die drei Haken
|
||||||
|
(DSGVO/Developer/Audit-Betrieb). Der Haken-Weg war der wichtigste: Er
|
||||||
|
brauchte nur `users:create` – ein zweites Konto mit „Entwicklerzugriff"
|
||||||
|
anlegen und sich damit anmelden, und die Developer-Rolle trägt *alle*
|
||||||
|
Rechte.
|
||||||
|
- Geprüft wird der **Zuwachs**, nicht der Endzustand: Rechte entziehen bleibt
|
||||||
|
jederzeit erlaubt, sonst könnte ein Admin einen übernommenen
|
||||||
|
Developer-Zugang nicht mehr entschärfen. Und die Prüfung liest die Rechte
|
||||||
|
des Handelnden **frisch aus der Datenbank**, nicht aus dem JWT – der ist
|
||||||
|
bis zu 15 Minuten alt, und ein gerade entzogenes Recht darf nicht im
|
||||||
|
Nachlauf noch weitergereicht werden.
|
||||||
|
- **Systemrollen sind gesperrt** (`Role.isSystem`): Admin ließ sich bisher
|
||||||
|
umbenennen oder leeren – und die versteckten Rollen hängen an ihrem
|
||||||
|
*Namen*. `Role.isHidden` ersetzt die im Frontend hartkodierte Namensliste,
|
||||||
|
in der „Gegenbuch" fehlte.
|
||||||
|
- **Rechteänderung wirkt sofort**: `updateRole`/`deleteRole` melden alle
|
||||||
|
Träger ab. Vorher behielten sie bis zu 15 Minuten die alten Rechte.
|
||||||
|
- **Werksreset und Backup-Restore** verlangen jetzt zusätzlich
|
||||||
|
`roles:manage`. Beide löschen alle Rollen und legen ein frisches
|
||||||
|
`admin@admin.com` an – eine Rolle mit nur `settings:update` hätte damit die
|
||||||
|
ganze Rechtevergabe zurücksetzen und sich anschließend anmelden können.
|
||||||
|
- **Aussperr-Ausweg**: `npx tsx prisma/rolle-zuweisen.ts <email> <Rolle>`.
|
||||||
|
Umgeht die Regel bewusst – wer Shell-Zugang hat, hat ohnehin die Datenbank;
|
||||||
|
ein gestohlener Web-Zugang hat ihn nicht. Schreibt einen Eintrag über die
|
||||||
|
Hash-Kette (nicht roh, sonst risse er eine Lücke) und meldet ab.
|
||||||
|
Die Startwache nennt diesen Weg jetzt im Klartext.
|
||||||
|
- **Rollenpflege ist nicht mehr der leiseste Eingriff**: Sie war der einzige
|
||||||
|
Weg in die Rechtevergabe ohne SecurityEvent, obwohl sie viele Konten auf
|
||||||
|
einmal trifft. Jetzt `PERMISSION_CHANGED` – ebenso für die Haken DSGVO und
|
||||||
|
Entwicklerzugriff, die bisher stumm waren.
|
||||||
|
- **Nachgeprüft** (Dev-Datenbank + frische Wegwerf-Datenbank): 7 Eskalations-
|
||||||
|
wege → alle 403, 2 Gegenproben → erlaubt; 6 Sperrtests auf Systemrollen →
|
||||||
|
alle 403, eigene Rollen weiter änder- und löschbar; Stammdaten-Lesen für
|
||||||
|
Mitarbeiter unverändert 200; Rechteänderung → sofort 401; Migration und
|
||||||
|
`sync-roles` dreimal hintereinander → identischer Bericht; `db:seed` auf
|
||||||
|
bestehender Datenbank → Rollenmatrix unverändert; frische Installation →
|
||||||
|
alle 8 Systemrollen, keine Hinweise.
|
||||||
|
- **Neu**: `prisma/rechte-report.ts` – Bestandsaufnahme zum Vorher/Nachher-
|
||||||
|
Vergleich. Läuft auf beiden Seiten der Migration. Meldet auch Waisen: acht
|
||||||
|
Rechte aus alten Schemata (`*:access`, `settings:create/delete`) liegen
|
||||||
|
noch in der Datenbank und bewachen nichts. Nicht gelöscht, sondern am
|
||||||
|
Lesezugriff gefiltert – ein Filter ist die kleinere Behauptung als ein
|
||||||
|
DELETE.
|
||||||
|
- **Nachtrag (Staging-Deploy)**: Die drei CLI-Skripte bauten sich einen
|
||||||
|
eigenen `PrismaClient` und gingen damit an `src/lib/prisma.ts` vorbei – dort
|
||||||
|
wird `DATABASE_URL` aus den `DB_*`-Teilen zusammengesetzt, wenn sie fehlt.
|
||||||
|
Genau das ist bei `docker compose exec` der Fall: Der Entrypoint exportiert
|
||||||
|
sie nur in den Serverprozess. Folge: „Environment variable not found".
|
||||||
|
Betroffen war auch `rolle-zuweisen.ts` – der Aussperr-Ausweg hätte in dem
|
||||||
|
Moment versagt, für den er gebaut ist. Jetzt nutzen alle drei den
|
||||||
|
gemeinsamen Client.
|
||||||
|
- **Beim Deploy**: Die Migration beendet alle Sitzungen einmalig
|
||||||
|
(`tokenInvalidatedAt`). Nötig, weil die Rechte im Token stehen – sonst
|
||||||
|
liefen bis zu 15 Minuten 403er. Alle müssen sich einmal neu anmelden.
|
||||||
|
- Dateien: `src/config/rechte-katalog.ts`, `src/services/rechte.service.ts`,
|
||||||
|
`src/services/rollen-sync.service.ts`, `prisma/rechte-report.ts`,
|
||||||
|
`prisma/rolle-zuweisen.ts` (alle neu); zwei Migrationen;
|
||||||
|
`user.service.ts`, `user.controller.ts`, `backup.service.ts`,
|
||||||
|
`pflichtrechte.service.ts`, `sanitize.ts`, 8 Route-Dateien, 5 Frontend-Seiten
|
||||||
|
|
||||||
|
- [x] **🔢 R188: Ungültige IDs im Pfad – zentral statt 181-mal** (2026-09-03)
|
||||||
|
- Meldung der Pentesterin: `GET /api/users/:id` gibt bei nicht-numerischer
|
||||||
|
ID **500** statt 400 (`/api/users/permissions` trifft `/:id`). Ihr Patch
|
||||||
|
setzt einen Guard in `getUser`.
|
||||||
|
- **Berechtigt – und größer als gemeldet.** Ihr Fix schließt nebenbei etwas,
|
||||||
|
das sie nicht beansprucht: `parseInt('12abc')` ergibt **12**, also lieferte
|
||||||
|
`GET /api/users/12abc` bisher Benutzer 12 aus. Und es waren **181**
|
||||||
|
ungeprüfte Stellen in 19 Controllern, nicht eine.
|
||||||
|
- 181 Einzel-Guards wären genau der Fehler aus R186-01 gewesen (drei
|
||||||
|
Filterlisten, die auseinanderliefen). Stattdessen `router.param()`, an
|
||||||
|
**einer** Stelle für alle 33 Router registriert – über einen `mounte()`-
|
||||||
|
Helfer, der Prüfung und Einhängen zusammenbindet. Wer künftig `app.use`
|
||||||
|
schreibt statt `mounte`, umgeht sie nicht versehentlich, sondern sichtbar.
|
||||||
|
- **Antwort ist 404, nicht 400:** Ein Pfadsegment, das keine ID sein kann,
|
||||||
|
benennt keine Ressource. Der bestehende Präzedenzfall
|
||||||
|
(`provider.controller.ts`, Pentest Mai 2026) hatte es genauso entschieden.
|
||||||
|
- **Eine ältere Heuristik abgelöst** (Pentest Runde 7): Sie blockte
|
||||||
|
`^\d+[a-zA-Z]+$` im Pfad – ihr eigener Kommentar nannte den Grund,
|
||||||
|
„`app.param()` greift nicht auf in Sub-Router gemounteten Routes", und
|
||||||
|
genau das löst `mounte()`. Sie war **zu eng** (`/users/abc` ging durch →
|
||||||
|
500) und **zu weit** (ein Einstellungs-Schlüssel `12abc` unter `:key` wurde
|
||||||
|
geblockt, obwohl das keine ID ist), und sie antwortete 400, wo jetzt 404
|
||||||
|
steht – zwei Antworten für dieselbe Eingabeklasse.
|
||||||
|
- Getestet über HTTP: `abc`, `permissions`, `12abc`, `6abc`, `0`, `-1`,
|
||||||
|
`007`, `1e3`, Überlauf, `1;DROP` → alle **404**; `1` → 200. Quer über
|
||||||
|
sechs Controller gleich. Nicht-ID-Parameter (`roles/list`,
|
||||||
|
`permissions/list`, `settings/:key`) unverändert.
|
||||||
|
- Dateien: `backend/src/middleware/routeIds.ts` (neu), `backend/src/index.ts`
|
||||||
|
|
||||||
|
- [x] **⚖️ R189-01: DSGVO-Rechte hingen an keinem Konto** (2026-09-03)
|
||||||
|
- Meldung: `gdpr:*` und `audit:read/export` hängen an den Rollen DSGVO und
|
||||||
|
Developer – die Admin-Rolle hat sie **nicht**, und nach einem frischen Seed
|
||||||
|
war DSGVO **keinem Konto** zugewiesen. Auskunft nach Art. 15 und Löschung
|
||||||
|
nach Art. 17 konnte damit niemand ausführen. Ausfall mit Fristwirkung.
|
||||||
|
- Seed weist `admin@admin.com` jetzt zusätzlich die DSGVO-Rolle zu. Die
|
||||||
|
Admin-**Rolle** bekommt diese Rechte weiterhin nicht – die Trennung aus
|
||||||
|
R186 bleibt, nur das eine Bootstrap-Konto trägt beides.
|
||||||
|
- Label ehrlich gemacht: „Voller Zugriff auf alle **Fachfunktionen** (ohne
|
||||||
|
Audit & Datenschutz – dafür die separaten Rollen DSGVO und Audit-Betrieb)".
|
||||||
|
- **Eigener Fund beim Prüfen ihres Patches:** `seed.ts` vergab an die
|
||||||
|
DSGVO-Rolle weiterhin `audit:*` **komplett**, inklusive `audit:admin` –
|
||||||
|
also die Bündelung, die in `fc6f39e` aufgelöst wurde. Ich hatte damals zwei
|
||||||
|
Listen gefunden (`sync-roles.ts`, `user.service.ts`) und die dritte
|
||||||
|
übersehen. Gerettet hat es nur die Reihenfolge im Containerstart; ein
|
||||||
|
einzelnes `npm run db:seed` brachte sie zurück. Korrigiert.
|
||||||
|
- **Der Seed hilft aber nur bei Neuinstallation** (`update: {}`). Deshalb
|
||||||
|
zusätzlich eine Wache beim Start: Gibt es für `gdpr:export`, `gdpr:delete`
|
||||||
|
oder `audit:read` **kein aktives Konto**, steht das mit Handlungsanweisung
|
||||||
|
im Log. Erkennt die *Abwesenheit* einer Fähigkeit – dasselbe Muster wie der
|
||||||
|
Heartbeat. Bewusst nur melden, nicht automatisch vergeben.
|
||||||
|
- Getestet: frischer Seed → Admin hat `audit:read, audit:export, gdpr:*` und
|
||||||
|
**kein** `audit:admin`. Rolle entzogen → Warnung erscheint mit allen drei
|
||||||
|
Rechten. Rolle zurück → still.
|
||||||
|
- Dateien: `backend/prisma/seed.ts`, `backend/prisma/sync-roles.ts`,
|
||||||
|
`backend/src/services/pflichtrechte.service.ts` (neu), `backend/src/index.ts`
|
||||||
|
|
||||||
|
- [x] **🕳️ Entfernter Protokoll-ANFANG wurde nicht erkannt** (2026-08-26)
|
||||||
|
- Gefunden bei der Vorbereitung des Prod-Siegels: Die Verkettung wird
|
||||||
|
zeilenweise gegen die Vorgängerin geprüft – die **erste** Zeile hat keine,
|
||||||
|
also fiel bisher **nichts** auf, wenn ein zusammenhängender Anfang des
|
||||||
|
Protokolls entfernt wurde. Kein Kettenbruch, kein Befund, `valid` blieb
|
||||||
|
grün. Die stillste Löschung von allen: Wer die ältesten Einträge loswerden
|
||||||
|
will, muss nur vorne anfangen.
|
||||||
|
- Erkennbar ist es trotzdem: Die allererste Zeile wird ohne Vorgänger
|
||||||
|
geschrieben und trägt einen leeren `previousHash`. Trägt die erste
|
||||||
|
**vorhandene** Zeile einen Wert, hat es eine Vorgängerin gegeben – und die
|
||||||
|
ist weg. Wird jetzt als Kettenlücke an dieser Zeile geführt, mit derselben
|
||||||
|
Manifest- und Beglaubigungslogik wie jede andere Lücke.
|
||||||
|
- Nur bei Prüfung des Gesamtbereichs; mit `fromId` ist ein gefüllter
|
||||||
|
`previousHash` selbstverständlich (gegengeprüft, kein Fehlalarm).
|
||||||
|
- Getestet über HTTP: vollständiges Protokoll → `valid:true`; erste drei
|
||||||
|
Zeilen entfernt → **vorher** unverändert `valid:true`, **jetzt**
|
||||||
|
`valid:false`, `chainGaps:[4]`, wegen Hash-Version 3 zusätzlich als
|
||||||
|
Manipulation eskaliert.
|
||||||
|
- Auf Prod ausgeschlossen: Das Protokoll beginnt bei ID 1 (07.05.2026,
|
||||||
|
Inbetriebnahme), kein Cleanup, keine Neuberechnung.
|
||||||
|
- Datei: `backend/src/services/audit.service.ts`
|
||||||
|
|
||||||
|
- [x] **⏱️ Neuberechnung der Kette ist jetzt eine eigene Gegenbuch-Alarmbedingung (Pentest R186, Frage b)** (2026-08-26)
|
||||||
|
- Der Tester fragte präzise: Löst `cleanup` → `rehash` **ohne** erneutes
|
||||||
|
Siegeln, über **nicht versiegelten** Inhalt, etwas **Automatisches** aus –
|
||||||
|
oder nur die Prosa in `verify`, die ein Mensch lesen muss?
|
||||||
|
- **Gemessen statt behauptet:** Es löste bereits aus, aber als *Nebenwirkung*.
|
||||||
|
Ein Rehash ändert jeden Hash, also stimmt der beglaubigte Kettenkopf nicht
|
||||||
|
mehr und der bestehende Vergleich schlug an. Funktioniert – ist aber ein
|
||||||
|
Zufallstreffer: Verschöbe sich der Anker, wäre der Melder lautlos weg. Und
|
||||||
|
die Meldung hieß „Eintrag wurde verändert" statt „die Kette wurde neu
|
||||||
|
berechnet", also Wirkung statt Ursache.
|
||||||
|
- Jetzt hängt der Alarm an der Sache selbst: Das Gegenbuch führt
|
||||||
|
`rehashCount`/`rehashLast` im Buch mit und meldet jede neue Neuberechnung
|
||||||
|
seit der letzten Beglaubigung mit **exit 2** – samt Zeitpunkt, Zeilenzahl
|
||||||
|
und dem Befund, der unmittelbar davor galt. Dieselbe Lehre wie R184-01
|
||||||
|
(Gate am Auslöser, nicht an der Wirkung) und R185-01 (Wurzelwechsel statt
|
||||||
|
`valid`).
|
||||||
|
- **Reihenfolge geändert, und das war nötig:** Der neue Melder steht **vor**
|
||||||
|
dem Kopf-Hash-Vergleich, sonst hätte immer die unpräzisere Meldung
|
||||||
|
gewonnen. Und der Kopf-Vergleich wird nach einer *bestätigten*
|
||||||
|
Neuberechnung übersprungen – sonst wäre die Bestätigung wertlos gewesen,
|
||||||
|
weil ein Rehash den Kopf zwangsläufig ändert. Beim Bauen aufgefallen, nicht
|
||||||
|
im Entwurf.
|
||||||
|
- `NOTARY_REHASH_ACK` wird mit der **ID des Rehash-Eintrags** bestätigt, nicht
|
||||||
|
mit `true`. IDs steigen streng – ein stehen gelassener Wert passt beim
|
||||||
|
nächsten Vorgang nicht mehr. Analog zu `NOTARY_SEAL_ACK`.
|
||||||
|
- Ein **fehlender** beglaubigter Eintrag alarmiert weiterhin immer: Bestätigt
|
||||||
|
wird die Neuberechnung, nicht das Verschwinden von Zeilen.
|
||||||
|
- Bestandsbücher ohne das neue Feld alarmieren nicht rückwirkend, sondern
|
||||||
|
setzen die Grundlage – mit Ausgabe, statt es stillschweigend zu tun.
|
||||||
|
- **Getestet mit echtem Gegenbuch gegen ein echtes CRM** (SSH-signiertes
|
||||||
|
lokales Buch): Wäsche ohne Datenbankzugriff (3 Zeilen gelöscht → Rehash)
|
||||||
|
→ CRM meldet `valid:true`, `chainGaps:[]`; Gegenbuch **exit 2** und nennt
|
||||||
|
die Neuberechnung als Ursache. Falsche Ack-ID → weiter exit 2, richtige →
|
||||||
|
exit 0 mit neuer Grundlage, Folgelauf ruhig.
|
||||||
|
- Dateien: `tools/audit-notary/notary.mjs`,
|
||||||
|
`tools/audit-notary/{docker-compose.yml,.env.example,README.md}`
|
||||||
|
|
||||||
|
- [x] **🔍 `verify` meldet jetzt, dass die Kette neu berechnet wurde** (2026-08-26)
|
||||||
|
- **Im Betrieb entdeckt, nicht im Test.** Das Staging-Gegenbuch meldete
|
||||||
|
„Der beglaubigte Eintrag 5352 existiert nicht mehr". Rekonstruktion aus dem
|
||||||
|
Protokoll: am 22.08. wurden die Aufbewahrungsfristen auf **0** gesetzt,
|
||||||
|
zwei Cleanups löschten **3.155** Einträge (id 1–5356), danach lief ein
|
||||||
|
**Rehash**. Seither meldet die Prüfung „Alle Einträge sind unverändert und
|
||||||
|
**lückenlos** verkettet".
|
||||||
|
- Wahr – und praktisch das Gegenteil dessen, was ein Leser mitnimmt. Der
|
||||||
|
Rehash verknüpft alles neu; die rund **700** Kettenlücken, die davor
|
||||||
|
bestanden, sind seitdem unsichtbar. Nachweisbar im Vorbefund, den der
|
||||||
|
Rehash selbst mitschreibt (R170-01) – nur schaute den nie jemand an.
|
||||||
|
- Das Gegenbuch war der einzige Zeuge. Innerhalb des CRM war die Löschung
|
||||||
|
nicht mehr feststellbar.
|
||||||
|
- `verifyIntegrity` sammelt jetzt die Rehash-Marker; die Antwort enthält
|
||||||
|
`rehashes[]` (Zeitpunkt, Anzahl, Signatur, Vorbefund). Die Meldung nennt
|
||||||
|
sie **immer**, auch im grünen Fall, und der Einstiegssatz lautet dann
|
||||||
|
„…lückenlos verkettet – allerdings erst seit der letzten Neuberechnung".
|
||||||
|
- **`valid` bleibt unberührt.** Ein Rehash ist eine legitime Maßnahme; ihn
|
||||||
|
dauerhaft als Befund zu führen wäre der Dauer-Alarm, den wir mit den
|
||||||
|
beglaubigten Lücken gerade beseitigt haben. Melden, nicht alarmieren.
|
||||||
|
- **Umgekehrte Beweislast als bei Manifest und Siegel:** Dort zählen nur
|
||||||
|
signierte Träger, weil ein gefälschter Marker Lücken *wegerklären* könnte.
|
||||||
|
Hier erzeugt ein Marker eine *Warnung* – würden nur signierte zählen,
|
||||||
|
könnte man einen Rehash unsichtbar machen, indem man seine Signatur
|
||||||
|
zerstört. Deshalb zählt jeder auswertbare Marker; fehlende Signatur wird
|
||||||
|
zusätzlich gemeldet.
|
||||||
|
- Getestet über HTTP gegen eine Wegwerf-DB, mit echtem Rehash über den
|
||||||
|
regulären Endpunkt: sauberer Vorzustand → „es wurde nichts überdeckt";
|
||||||
|
Löschung + Rehash (der Staging-Ablauf im Kleinen) → `chainGaps` fällt von
|
||||||
|
1 auf 0, die Meldung nennt Zeitpunkt und Vorbefund; zerstörte Signatur →
|
||||||
|
Marker wird weiterhin gemeldet, mit Hinweis.
|
||||||
|
- Dateien: `backend/src/services/audit.service.ts`,
|
||||||
|
`backend/src/controllers/auditLog.controller.ts`,
|
||||||
|
`frontend/src/services/api.ts`,
|
||||||
|
`frontend/src/pages/settings/AuditIntegrityCard.tsx`
|
||||||
|
|
||||||
|
- [x] **📄 `status.txt` nennt jetzt den Grund, nicht nur das Etikett** (2026-08-26)
|
||||||
|
- In der Statusdatei des Gegenbuchs stand für **jeden** Exit-2 derselbe Satz:
|
||||||
|
„BEFUND – Widerspruch zwischen CRM und Gegenbuch". Ein echter Widerspruch
|
||||||
|
sah damit genauso aus wie eine quittierpflichtige Erstsiegelung.
|
||||||
|
- Aufgefallen im Betrieb: Der Betreiber las „BEFUND" auf Staging und konnte
|
||||||
|
nicht entscheiden, ob er handeln muss – obwohl die Kette dort `valid:true`
|
||||||
|
meldet und der Alarm nur den erwarteten Siegelwechsel betraf. Wer nur die
|
||||||
|
Statusdatei liest (und dafür ist sie da – „damit eine Überwachung sie
|
||||||
|
abgreifen kann, ohne Logs zu durchsuchen"), bekam ein Etikett ohne Inhalt.
|
||||||
|
- Der Lauf wird jetzt mitgeschnitten; die erste `ALARM:`-Zeile landet als
|
||||||
|
`Grund:` in `status.txt`. Ohne Alarm die erste Ausgabezeile.
|
||||||
|
- Datei: `tools/audit-notary/entrypoint.sh`
|
||||||
|
|
||||||
|
- [x] **⚖️ Aufsicht und Eingriff getrennt: neuer Haken „Audit-Betrieb"** (2026-08-26)
|
||||||
|
- Die DSGVO-Rolle trug `audit:*` **komplett**, also auch `audit:admin`. Ein
|
||||||
|
DSGVO-Beauftragter konnte damit `seal-backlog`, `rehash` und `cleanup` –
|
||||||
|
seine eigene Beweisgrundlage ersetzen. Wer das Protokoll beaufsichtigt,
|
||||||
|
darf es nicht umschreiben können. Dieselbe Klasse wie R184-02: falsche
|
||||||
|
Domäne, zu breit gebündelt.
|
||||||
|
- **Der naheliegende Fix wäre falsch gewesen.** `audit:admin` einfach aus
|
||||||
|
der DSGVO-Rolle zu streichen hätte es heimatlos gemacht: Die Admin-Rolle
|
||||||
|
ist ausdrücklich **ohne** `audit`/`gdpr` gebaut, einzige verbleibende
|
||||||
|
Quelle wäre der Entwicklerzugriff – der *alles* gibt. Prod versiegeln
|
||||||
|
hätte dann Vollzugriff vorausgesetzt.
|
||||||
|
- Deshalb eine eigene versteckte Rolle **`Audit-Betrieb`**
|
||||||
|
(`audit:read` + `audit:admin`), zugewiesen über eine Checkbox wie
|
||||||
|
DSGVO/Entwickler. DSGVO behält `audit:read` + `audit:export` + `gdpr:*`.
|
||||||
|
**Für kein bestehendes Konto weitet sich etwas aus** – im Gegenteil, es
|
||||||
|
wird enger, und wer eingreifen können soll, bekommt es ausdrücklich.
|
||||||
|
- **Keine zusätzliche Rechte-Hürde davor**, weil das am Henne-Ei-Problem
|
||||||
|
scheitert: Nach der Aufteilung hält zunächst niemand `audit:admin`, könnte
|
||||||
|
ihn also auch niemand vergeben. Stattdessen wird die Vergabe **laut** –
|
||||||
|
CRITICAL im Protokoll **und** `PERMISSION_CHANGED`/CRITICAL im Alarmkanal,
|
||||||
|
inklusive Kennzeichen, ob sich jemand den Haken selbst gesetzt hat.
|
||||||
|
- Nebenbei geschlossen: `setUserGdprAccess()` legte die DSGVO-Rolle im
|
||||||
|
Notfallpfad mit `audit:*` komplett an – eine zweite Liste, die dasselbe
|
||||||
|
bedeuten sollte und die Bündelung stillschweigend zurückgebracht hätte.
|
||||||
|
- **⚠️ Beim Deploy:** Bestehende DSGVO-Konten verlieren `audit:admin`. Wer
|
||||||
|
Prod versiegeln will, muss sich vorher „Audit-Betrieb" ankreuzen.
|
||||||
|
- **Gemeldet, nicht geändert:** Jeder mit `users:update` (also Admin) kann
|
||||||
|
sich DSGVO oder Entwicklerzugriff selbst vergeben – Entwickler heißt
|
||||||
|
*alle* Rechte. Das ist vorbestehend und gehört ins Rollenmodell-Thema des
|
||||||
|
Pentesters.
|
||||||
|
- Dateien: `backend/prisma/sync-roles.ts`,
|
||||||
|
`backend/src/services/user.service.ts`,
|
||||||
|
`backend/src/controllers/user.controller.ts`,
|
||||||
|
`backend/src/utils/sanitize.ts`,
|
||||||
|
`frontend/src/pages/users/UserList.tsx`,
|
||||||
|
`frontend/src/services/api.ts`, `tools/audit-notary/README.md`
|
||||||
|
|
||||||
|
- [x] **🏷️ Siegel über null Blättern meldet nicht mehr „intakt"** (2026-08-26)
|
||||||
|
- Ein Bestandssiegel, das zum Zeitpunkt des Siegelns keinen Altbestand
|
||||||
|
vorfand, ist rechnerisch tadellos und schützt **nichts**. Gemeldet wurde
|
||||||
|
trotzdem `intakt` – formal richtig, aber es liest sich als Schutzzusage.
|
||||||
|
- Der Pentester hat Stagings Leersiegel genau so missverstanden und hielt es
|
||||||
|
für zahnhaltig. Das ist der rote Faden im Kleinen: ein Signal, das
|
||||||
|
beruhigt, wo nichts abgesichert ist.
|
||||||
|
- Eigener Zustand `leer` mit eigenem Text („besteht ein Bestandssiegel, das
|
||||||
|
aber NICHTS umschließt … kein Fehler, aber auch keine Zusage").
|
||||||
|
Wertung wie `nicht_noetig`: kippt `valid` nicht.
|
||||||
|
- Dateien: `backend/src/services/audit.service.ts`,
|
||||||
|
`backend/src/controllers/auditLog.controller.ts`,
|
||||||
|
`frontend/src/services/api.ts`,
|
||||||
|
`frontend/src/pages/settings/AuditIntegrityCard.tsx`
|
||||||
|
|
||||||
- [x] **🔒 `audit:export` gatete nichts – Export hing an `audit:read`** (2026-08-26)
|
- [x] **🔒 `audit:export` gatete nichts – Export hing an `audit:read`** (2026-08-26)
|
||||||
- **Bei der Gegenprobe zur neuen Rolle `Gegenbuch` gefunden:** Ein Konto mit
|
- **Bei der Gegenprobe zur neuen Rolle `Gegenbuch` gefunden:** Ein Konto mit
|
||||||
ausschließlich `audit:read` bekam auf `GET /audit-logs/export` **200**.
|
ausschließlich `audit:read` bekam auf `GET /audit-logs/export` **200**.
|
||||||
|
|||||||
@@ -26,28 +26,28 @@ export default function Settings() {
|
|||||||
icon: Clock,
|
icon: Clock,
|
||||||
title: 'Kündigungsfristen',
|
title: 'Kündigungsfristen',
|
||||||
description: 'Konfigurieren Sie die verfügbaren Kündigungsfristen für Verträge.',
|
description: 'Konfigurieren Sie die verfügbaren Kündigungsfristen für Verträge.',
|
||||||
show: hasPermission('platforms:read'),
|
show: hasPermission('cancellation-periods:read'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/contract-durations',
|
to: '/settings/contract-durations',
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
title: 'Vertragslaufzeiten',
|
title: 'Vertragslaufzeiten',
|
||||||
description: 'Konfigurieren Sie die verfügbaren Laufzeiten für Verträge.',
|
description: 'Konfigurieren Sie die verfügbaren Laufzeiten für Verträge.',
|
||||||
show: hasPermission('platforms:read'),
|
show: hasPermission('contract-durations:read'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/providers',
|
to: '/settings/providers',
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
title: 'Anbieter & Tarife',
|
title: 'Anbieter & Tarife',
|
||||||
description: 'Verwalten Sie Anbieter und deren Tarife für Verträge.',
|
description: 'Verwalten Sie Anbieter und deren Tarife für Verträge.',
|
||||||
show: hasPermission('providers:read') || hasPermission('platforms:read'),
|
show: hasPermission('providers:read') || hasPermission('tariffs:read'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/contract-categories',
|
to: '/settings/contract-categories',
|
||||||
icon: FileType,
|
icon: FileType,
|
||||||
title: 'Vertragstypen',
|
title: 'Vertragstypen',
|
||||||
description: 'Konfigurieren Sie die verfügbaren Vertragstypen (Strom, Gas, Mobilfunk, etc.).',
|
description: 'Konfigurieren Sie die verfügbaren Vertragstypen (Strom, Gas, Mobilfunk, etc.).',
|
||||||
show: hasPermission('platforms:read'),
|
show: hasPermission('contract-categories:read'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/credit-note-number-range',
|
to: '/settings/credit-note-number-range',
|
||||||
@@ -180,6 +180,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
{hasPermission('email-providers:read') && (
|
||||||
<Link
|
<Link
|
||||||
to="/settings/email-providers"
|
to="/settings/email-providers"
|
||||||
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
||||||
@@ -197,6 +198,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
to="/settings/database-backup"
|
to="/settings/database-backup"
|
||||||
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const AMPEL_TITEL: Record<Ampel, string> = {
|
|||||||
|
|
||||||
const SIEGEL_TEXT: Record<IntegrityResult['backlogSealStatus'], string> = {
|
const SIEGEL_TEXT: Record<IntegrityResult['backlogSealStatus'], string> = {
|
||||||
intakt: 'versiegelt und intakt',
|
intakt: 'versiegelt und intakt',
|
||||||
|
leer: 'Siegel vorhanden, aber ohne Inhalt',
|
||||||
kein_siegel: 'nicht versiegelt',
|
kein_siegel: 'nicht versiegelt',
|
||||||
nicht_noetig: 'nicht nötig (alles signiert)',
|
nicht_noetig: 'nicht nötig (alles signiert)',
|
||||||
gebrochen: 'GEBROCHEN',
|
gebrochen: 'GEBROCHEN',
|
||||||
@@ -125,7 +126,12 @@ export default function AuditIntegrityCard() {
|
|||||||
<div className={`flex items-start gap-4 p-4 rounded-lg border ${stil.box}`}>
|
<div className={`flex items-start gap-4 p-4 rounded-lg border ${stil.box}`}>
|
||||||
<AmpelIcon ampel={ampel} />
|
<AmpelIcon ampel={ampel} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className={`font-medium ${stil.titel}`}>{AMPEL_TITEL[ampel]}</p>
|
<p className={`font-medium ${stil.titel}`}>
|
||||||
|
{AMPEL_TITEL[ampel]}
|
||||||
|
{ampel === 'ok' && ergebnis.rehashes.length > 0 && (
|
||||||
|
<span className="font-normal"> – seit der letzten Neuberechnung</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
<p className={`text-sm mt-1 ${stil.text}`}>{ergebnis.message}</p>
|
<p className={`text-sm mt-1 ${stil.text}`}>{ergebnis.message}</p>
|
||||||
<p className="text-sm text-gray-500 mt-2">
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
{ergebnis.checkedCount.toLocaleString('de-DE')} Einträge geprüft ·
|
{ergebnis.checkedCount.toLocaleString('de-DE')} Einträge geprüft ·
|
||||||
@@ -155,9 +161,36 @@ export default function AuditIntegrityCard() {
|
|||||||
<Zahl label="Ohne Schlüssel nicht prüfbar" ids={ergebnis.unverifiableEntries} ton="text-gray-700" />
|
<Zahl label="Ohne Schlüssel nicht prüfbar" ids={ergebnis.unverifiableEntries} ton="text-gray-700" />
|
||||||
{ergebnis.tamperedEntries.length === 0 &&
|
{ergebnis.tamperedEntries.length === 0 &&
|
||||||
ergebnis.chainGaps.length === 0 &&
|
ergebnis.chainGaps.length === 0 &&
|
||||||
ergebnis.unverifiableEntries.length === 0 && (
|
ergebnis.unverifiableEntries.length === 0 &&
|
||||||
|
ergebnis.rehashes.length === 0 && (
|
||||||
<p className="text-sm text-gray-500 italic">Nichts zu berichten.</p>
|
<p className="text-sm text-gray-500 italic">Nichts zu berichten.</p>
|
||||||
)}
|
)}
|
||||||
|
{ergebnis.rehashes.length > 0 && (
|
||||||
|
<div className="pt-2 border-t mt-2">
|
||||||
|
<p className="text-sm font-medium text-gray-700">Neuberechnungen der Kette</p>
|
||||||
|
{ergebnis.rehashes.map((r) => (
|
||||||
|
<p key={r.id} className="text-sm text-gray-600 mt-1">
|
||||||
|
{new Date(r.zeitpunkt).toLocaleString('de-DE')} · Eintrag {r.id}
|
||||||
|
{r.neuBerechnet !== null && ` · ${r.neuBerechnet} Einträge neu verkettet`}
|
||||||
|
{r.vorbefund && (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{' '}· davor: {r.vorbefund.manipuliert} beanstandet,{' '}
|
||||||
|
{r.vorbefund.luecken} Lücken
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!r.signiert && (
|
||||||
|
<span className="text-red-700"> · ohne gültige Signatur</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Ein Rehash verknüpft alle Einträge neu. Was davor als Lücke oder
|
||||||
|
Beanstandung sichtbar war, ist danach nicht mehr in der Kette zu sehen –
|
||||||
|
nur noch im Vorbefund des jeweiligen Eintrags. Die Aussage dieser Prüfung
|
||||||
|
reicht deshalb bis zur letzten Neuberechnung zurück, nicht weiter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-xs text-gray-500 pt-2">
|
<p className="text-xs text-gray-500 pt-2">
|
||||||
<strong>Verändert</strong> heißt: der Inhalt einer bestehenden Zeile passt nicht
|
<strong>Verändert</strong> heißt: der Inhalt einer bestehenden Zeile passt nicht
|
||||||
mehr zu ihrer Prüfsumme – das ist ernst.
|
mehr zu ihrer Prüfsumme – das ist ernst.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function CancellationPeriodList() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-2xl font-bold flex-1">Kündigungsfristen</h1>
|
<h1 className="text-2xl font-bold flex-1">Kündigungsfristen</h1>
|
||||||
{hasPermission('platforms:create') && (
|
{hasPermission('cancellation-periods:create') && (
|
||||||
<Button onClick={() => setShowModal(true)}>
|
<Button onClick={() => setShowModal(true)}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Neue Frist
|
Neue Frist
|
||||||
@@ -101,12 +101,12 @@ export default function CancellationPeriodList() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-right">
|
<td className="py-3 px-4 text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
{hasPermission('platforms:update') && (
|
{hasPermission('cancellation-periods:update') && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(period)}>
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(period)}>
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{hasPermission('platforms:delete') && (
|
{hasPermission('cancellation-periods:delete') && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function ContractCategoryList() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-2xl font-bold flex-1">Vertragstypen</h1>
|
<h1 className="text-2xl font-bold flex-1">Vertragstypen</h1>
|
||||||
{hasPermission('developer:access') && (
|
{hasPermission('contract-categories:create') && (
|
||||||
<Button onClick={() => setShowModal(true)}>
|
<Button onClick={() => setShowModal(true)}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Neuer Vertragstyp
|
Neuer Vertragstyp
|
||||||
@@ -144,12 +144,12 @@ export default function ContractCategoryList() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 ml-4">
|
<div className="flex gap-2 ml-4">
|
||||||
{hasPermission('developer:access') && (
|
{hasPermission('contract-categories:update') && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(category)} title="Bearbeiten">
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(category)} title="Bearbeiten">
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{hasPermission('developer:access') && (
|
{hasPermission('contract-categories:delete') && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function ContractDurationList() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-2xl font-bold flex-1">Vertragslaufzeiten</h1>
|
<h1 className="text-2xl font-bold flex-1">Vertragslaufzeiten</h1>
|
||||||
{hasPermission('platforms:create') && (
|
{hasPermission('contract-durations:create') && (
|
||||||
<Button onClick={() => setShowModal(true)}>
|
<Button onClick={() => setShowModal(true)}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Neue Laufzeit
|
Neue Laufzeit
|
||||||
@@ -101,12 +101,12 @@ export default function ContractDurationList() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-right">
|
<td className="py-3 px-4 text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
{hasPermission('platforms:update') && (
|
{hasPermission('contract-durations:update') && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(duration)}>
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(duration)}>
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{hasPermission('platforms:delete') && (
|
{hasPermission('contract-durations:delete') && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ function ProviderRow({
|
|||||||
<div className="border-t bg-gray-50 p-4">
|
<div className="border-t bg-gray-50 p-4">
|
||||||
<div className="flex justify-between items-center mb-3">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<h4 className="font-medium text-gray-700">Tarife</h4>
|
<h4 className="font-medium text-gray-700">Tarife</h4>
|
||||||
{hasPermission('providers:create') && (
|
{hasPermission('tariffs:create') && (
|
||||||
<Button size="sm" onClick={() => setShowTariffModal(true)}>
|
<Button size="sm" onClick={() => setShowTariffModal(true)}>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
Tarif hinzufügen
|
Tarif hinzufügen
|
||||||
@@ -227,7 +227,7 @@ function ProviderRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{hasPermission('providers:update') && (
|
{hasPermission('tariffs:update') && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -240,7 +240,7 @@ function ProviderRow({
|
|||||||
<Edit className="w-3 h-3" />
|
<Edit className="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{hasPermission('providers:delete') && (
|
{hasPermission('tariffs:delete') && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default function UserList() {
|
|||||||
<td className="py-3 px-4">{user.email}</td>
|
<td className="py-3 px-4">{user.email}</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="flex gap-1 flex-wrap">
|
||||||
{user.roles?.filter((role: any) => !['Developer', 'Kunde', 'DSGVO'].includes(role.name)).map((role: any) => (
|
{user.roles?.filter((role: any) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(role.name)).map((role: any) => (
|
||||||
<Badge key={role.id || role.name} variant="info">
|
<Badge key={role.id || role.name} variant="info">
|
||||||
{role.name}
|
{role.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -246,6 +246,7 @@ function UserModal({
|
|||||||
roleIds: [] as number[],
|
roleIds: [] as number[],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
hasDeveloperAccess: false,
|
hasDeveloperAccess: false,
|
||||||
|
hasAuditOpsAccess: false,
|
||||||
hasGdprAccess: false,
|
hasGdprAccess: false,
|
||||||
isServiceAccount: false,
|
isServiceAccount: false,
|
||||||
whatsappNumber: '',
|
whatsappNumber: '',
|
||||||
@@ -264,9 +265,10 @@ function UserModal({
|
|||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO'].includes(r.name)).map((r: any) => r.id) || [],
|
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(r.name)).map((r: any) => r.id) || [],
|
||||||
isActive: (user as any).isActive ?? true,
|
isActive: (user as any).isActive ?? true,
|
||||||
hasDeveloperAccess: (user as any).hasDeveloperAccess ?? false,
|
hasDeveloperAccess: (user as any).hasDeveloperAccess ?? false,
|
||||||
|
hasAuditOpsAccess: (user as any).hasAuditOpsAccess ?? false,
|
||||||
hasGdprAccess: (user as any).hasGdprAccess ?? false,
|
hasGdprAccess: (user as any).hasGdprAccess ?? false,
|
||||||
isServiceAccount: (user as any).isServiceAccount ?? false,
|
isServiceAccount: (user as any).isServiceAccount ?? false,
|
||||||
whatsappNumber: (user as any).whatsappNumber || '',
|
whatsappNumber: (user as any).whatsappNumber || '',
|
||||||
@@ -283,6 +285,7 @@ function UserModal({
|
|||||||
roleIds: [],
|
roleIds: [],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
hasDeveloperAccess: false,
|
hasDeveloperAccess: false,
|
||||||
|
hasAuditOpsAccess: false,
|
||||||
hasGdprAccess: false,
|
hasGdprAccess: false,
|
||||||
isServiceAccount: false,
|
isServiceAccount: false,
|
||||||
whatsappNumber: '',
|
whatsappNumber: '',
|
||||||
@@ -326,6 +329,7 @@ function UserModal({
|
|||||||
roleIds: formData.roleIds,
|
roleIds: formData.roleIds,
|
||||||
isActive: formData.isActive,
|
isActive: formData.isActive,
|
||||||
hasDeveloperAccess: formData.hasDeveloperAccess,
|
hasDeveloperAccess: formData.hasDeveloperAccess,
|
||||||
|
hasAuditOpsAccess: formData.hasAuditOpsAccess,
|
||||||
hasGdprAccess: formData.hasGdprAccess,
|
hasGdprAccess: formData.hasGdprAccess,
|
||||||
isServiceAccount: formData.isServiceAccount,
|
isServiceAccount: formData.isServiceAccount,
|
||||||
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
||||||
@@ -369,6 +373,7 @@ function UserModal({
|
|||||||
lastName: formData.lastName,
|
lastName: formData.lastName,
|
||||||
roleIds: formData.roleIds,
|
roleIds: formData.roleIds,
|
||||||
hasDeveloperAccess: formData.hasDeveloperAccess,
|
hasDeveloperAccess: formData.hasDeveloperAccess,
|
||||||
|
hasAuditOpsAccess: formData.hasAuditOpsAccess,
|
||||||
hasGdprAccess: formData.hasGdprAccess,
|
hasGdprAccess: formData.hasGdprAccess,
|
||||||
isServiceAccount: formData.isServiceAccount,
|
isServiceAccount: formData.isServiceAccount,
|
||||||
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
||||||
@@ -489,7 +494,7 @@ function UserModal({
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rollen</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Rollen</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{roles.filter((role) => !['Developer', 'Kunde', 'DSGVO'].includes(role.name)).map((role) => (
|
{roles.filter((role) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(role.name)).map((role) => (
|
||||||
<label key={role.id} className="flex items-center gap-2">
|
<label key={role.id} className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -533,7 +538,28 @@ function UserModal({
|
|||||||
<Shield className="w-4 h-4 text-blue-600" />
|
<Shield className="w-4 h-4 text-blue-600" />
|
||||||
DSGVO-Zugriff
|
DSGVO-Zugriff
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-500">(Audit-Logs, Datenschutz)</span>
|
<span className="text-sm text-gray-500">(Audit-Protokoll lesen, Datenschutz)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.hasAuditOpsAccess}
|
||||||
|
onChange={(e) => setFormData({ ...formData, hasAuditOpsAccess: e.target.checked })}
|
||||||
|
className="mt-1 rounded border-amber-300 text-amber-600 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Shield className="w-4 h-4 text-amber-600" />
|
||||||
|
Audit-Betrieb
|
||||||
|
<span className="text-sm text-gray-500">(versiegeln, aufräumen, Aufbewahrung)</span>
|
||||||
|
</span>
|
||||||
|
<span className="block text-sm text-gray-500 mt-0.5">
|
||||||
|
Erlaubt Eingriffe am Audit-Protokoll: Bestandssiegel setzen oder
|
||||||
|
ersetzen, aufräumen, Aufbewahrungsfristen ändern. Bewusst getrennt
|
||||||
|
vom DSGVO-Zugriff – wer das Protokoll beaufsichtigt, soll seine
|
||||||
|
eigene Beweisgrundlage nicht ersetzen können.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1548,11 +1548,12 @@ export const userApi = {
|
|||||||
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
|
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean; isServiceAccount?: boolean; confirm?: string; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string }) => {
|
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean; isServiceAccount?: boolean; confirm?: string; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string }) => {
|
||||||
const res = await api.post<ApiResponse<User>>('/users', data);
|
const res = await api.post<ApiResponse<User>>('/users', data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
update: async (id: number, data: Partial<User> & { password?: string; roleIds?: number[]; isServiceAccount?: boolean; confirm?: string }) => {
|
update: async (id: number, data: Partial<User> & { password?: string; roleIds?: number[]; isServiceAccount?: boolean; hasGdprAccess?: boolean; hasDeveloperAccess?: boolean; hasAuditOpsAccess?: boolean; confirm?: string }) => {
|
||||||
const res = await api.put<ApiResponse<User>>(`/users/${id}`, data);
|
const res = await api.put<ApiResponse<User>>(`/users/${id}`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
@@ -1748,9 +1749,21 @@ export interface IntegrityResult {
|
|||||||
unexplainedGaps: number[];
|
unexplainedGaps: number[];
|
||||||
/** Alt-Luecken, die das Bestandssiegel als bereits vorhanden beglaubigt. */
|
/** Alt-Luecken, die das Bestandssiegel als bereits vorhanden beglaubigt. */
|
||||||
attestedGaps: number[];
|
attestedGaps: number[];
|
||||||
|
/**
|
||||||
|
* Protokollierte Neuberechnungen der Kette. Nach einem Rehash ist die Kette
|
||||||
|
* zwangslaeufig stimmig - auch ueber Loeschungen hinweg. Die Aussage der
|
||||||
|
* Pruefung reicht dann nur bis zur letzten Neuberechnung zurueck.
|
||||||
|
*/
|
||||||
|
rehashes: Array<{
|
||||||
|
id: number;
|
||||||
|
zeitpunkt: string;
|
||||||
|
neuBerechnet: number | null;
|
||||||
|
signiert: boolean;
|
||||||
|
vorbefund: { manipuliert: number; luecken: number } | null;
|
||||||
|
}>;
|
||||||
/** Signierte Zeilen, die ohne AUDIT_HMAC_KEY nicht pruefbar sind. */
|
/** Signierte Zeilen, die ohne AUDIT_HMAC_KEY nicht pruefbar sind. */
|
||||||
unverifiableEntries: number[];
|
unverifiableEntries: number[];
|
||||||
backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
|
backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
|
||||||
backlogTampered: number[];
|
backlogTampered: number[];
|
||||||
backlogMissing: number[];
|
backlogMissing: number[];
|
||||||
backlogSealCount: number;
|
backlogSealCount: number;
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ PROD_ADOPT_ACK=
|
|||||||
# durchwinken.
|
# durchwinken.
|
||||||
PROD_SEAL_ACK=
|
PROD_SEAL_ACK=
|
||||||
|
|
||||||
|
# Normalerweise leer lassen.
|
||||||
|
# Gültige Werte: die ID des Rehash-Eintrags | (leer)
|
||||||
|
# Das Gegenbuch schlägt Alarm, wenn die Hash-Kette neu berechnet wurde. Ein
|
||||||
|
# Rehash verknüpft alle Einträge neu – Lücken, die eine Löschung sichtbar
|
||||||
|
# gemacht hätten, verschwinden dabei aus der Kette, und die Prüfung im CRM
|
||||||
|
# meldet danach wieder „lückenlos". War die Neuberechnung geplant, hier die
|
||||||
|
# ID eintragen, die der Alarm nennt, einmal laufen lassen und wieder leeren.
|
||||||
|
PROD_REHASH_ACK=
|
||||||
|
|
||||||
# Wo das Buch liegt – relativ zu diesem Verzeichnis.
|
# Wo das Buch liegt – relativ zu diesem Verzeichnis.
|
||||||
# DIESES VERZEICHNIS GEHÖRT INS BACKUP (enthält Buch und Signaturschlüssel).
|
# DIESES VERZEICHNIS GEHÖRT INS BACKUP (enthält Buch und Signaturschlüssel).
|
||||||
PROD_DIR=./data/prod
|
PROD_DIR=./data/prod
|
||||||
@@ -83,4 +92,5 @@ STAGING_INTERVAL=3600
|
|||||||
STAGING_GENESIS_ACK=
|
STAGING_GENESIS_ACK=
|
||||||
STAGING_ADOPT_ACK=
|
STAGING_ADOPT_ACK=
|
||||||
STAGING_SEAL_ACK=
|
STAGING_SEAL_ACK=
|
||||||
|
STAGING_REHASH_ACK=
|
||||||
STAGING_DIR=./data/staging
|
STAGING_DIR=./data/staging
|
||||||
|
|||||||
+195
-40
@@ -11,6 +11,37 @@ kurzen Kontrollwert vom CRM, prüft ihn gegen seine eigene Historie und schreibt
|
|||||||
ihn signiert fort. Wird später im CRM etwas nachträglich verändert,
|
ihn signiert fort. Wird später im CRM etwas nachträglich verändert,
|
||||||
widerspricht das dem Gegenbuch.
|
widerspricht das dem Gegenbuch.
|
||||||
|
|
||||||
|
## Was du hier suchst
|
||||||
|
|
||||||
|
**Einrichten**
|
||||||
|
[Betriebsart wählen](#zwei-betriebsarten--erst-hier-entscheiden) ·
|
||||||
|
[mit Docker](#einrichten-mit-docker-empfohlen) ·
|
||||||
|
[erster Start](#erster-start--drei-schritte) ·
|
||||||
|
[Zugangskonto im CRM](#zugang-einrichten-das-brauchst-du-vorher) ·
|
||||||
|
[ohne Docker](#einrichten-ohne-docker)
|
||||||
|
|
||||||
|
**Im Betrieb**
|
||||||
|
[**Altbestand versiegeln**](#den-altbestand-versiegeln-einmalig-im-crm) ·
|
||||||
|
[Rückgabecodes](#rückgabecodes) ·
|
||||||
|
[Überwachung](#überwachung) ·
|
||||||
|
[wo die Daten liegen](#wo-die-daten-liegen)
|
||||||
|
|
||||||
|
**Wenn Alarm kommt**
|
||||||
|
[Siegelwurzel hat gewechselt](#wenn-sich-die-siegelwurzel-ändert-alarm-und-warum) ·
|
||||||
|
[Kette wurde neu berechnet](#wenn-die-kette-neu-berechnet-wurde-ebenfalls-alarm) ·
|
||||||
|
[Zurückspulen erkannt](#der-rewind-wächter) ·
|
||||||
|
[Gedächtnis fehlt](#wenn-das-gedächtnis-trotzdem-fehlt)
|
||||||
|
|
||||||
|
**Zum Nachlesen**
|
||||||
|
[was erkannt wird](#was-das-skript-erkennt) ·
|
||||||
|
[was nicht – ehrlich](#was-es-nicht-leistet--ehrlich) ·
|
||||||
|
[Prüfmodus für Auditoren](#prüfmodus-für-auditoren)
|
||||||
|
|
||||||
|
> **Nur schnell versiegeln?** Der Abschnitt
|
||||||
|
> [Altbestand versiegeln](#den-altbestand-versiegeln-einmalig-im-crm) ist
|
||||||
|
> eigenständig und gilt **auch ohne Gegenbuch** – es ist ein Vorgang im CRM,
|
||||||
|
> nicht hier.
|
||||||
|
|
||||||
## Zwei Betriebsarten – erst hier entscheiden
|
## Zwei Betriebsarten – erst hier entscheiden
|
||||||
|
|
||||||
| | **Lokal** (Normalfall) | **Mit externem Repository** |
|
| | **Lokal** (Normalfall) | **Mit externem Repository** |
|
||||||
@@ -86,57 +117,128 @@ soll nicht versehentlich passieren.
|
|||||||
getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln.
|
getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln.
|
||||||
Welche laufen, steuert `COMPOSE_PROFILES` in der `.env`.
|
Welche laufen, steuert `COMPOSE_PROFILES` in der `.env`.
|
||||||
|
|
||||||
### Wenn der erste Lauf `exit=2` meldet: den Altbestand versiegeln
|
### Den Altbestand versiegeln (einmalig, im CRM)
|
||||||
|
|
||||||
Ein CRM, das schon länger läuft, hat fast immer einen **Altbestand** – Einträge
|
Ein CRM, das schon länger läuft, hat fast immer einen **Altbestand** – Einträge
|
||||||
aus der Zeit, bevor das Protokoll signiert wurde. Solange der nicht versiegelt
|
aus der Zeit, bevor das Protokoll signiert wurde. Solange der nicht versiegelt
|
||||||
ist, meldet die Prüfung `valid: false`, und das Gegenbuch schlägt zu Recht
|
ist, meldet die Prüfung `valid: false`, das Gegenbuch schlägt zu Recht Alarm –
|
||||||
Alarm. Typischerweise steht dann im Log:
|
und, wichtig: **es beglaubigt so lange gar nichts.** Es bricht vor dem Anhängen
|
||||||
|
ab, weil ein Checkpoint über einen ungeklärten Zustand diesen mitbeglaubigen
|
||||||
|
würde. Ein unversiegeltes CRM ist also nicht „bewacht mit Warnung", sondern
|
||||||
|
unbewacht.
|
||||||
|
|
||||||
Hinweis: Der Altbestand ist nicht versiegelt – Änderungen daran wären
|
Das Siegeln ist **einmalig** und passiert im CRM, nicht hier.
|
||||||
nicht erkennbar.
|
|
||||||
|
|
||||||
Das ist **einmalig** zu erledigen, im CRM, nicht hier:
|
#### Wer darf das
|
||||||
|
|
||||||
|
Das Recht `audit:admin`. Das bekommt man über den Haken **„Audit-Betrieb"** in
|
||||||
|
der Benutzerverwaltung – **nicht** über die Admin-Rolle (die hat bewusst keine
|
||||||
|
Audit-Rechte) und **nicht** über den DSGVO-Haken (der darf lesen, nicht
|
||||||
|
eingreifen). Das Gegenbuch-Dienstkonto hat es erst recht nicht.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
TOKEN=$(curl -s -X POST https://<crm>/api/auth/login \
|
CRM=https://<crm>
|
||||||
-H 'Content-Type: application/json' \
|
read -s -p "Passwort: " PASS; echo
|
||||||
-d '{"email":"…","password":"…"}' | jq -r '.data.token')
|
AT=$(curl -s -X POST $CRM/api/auth/login -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"email\":\"…\",\"password\":\"$PASS\"}" | jq -r '.data.token')
|
||||||
curl -s -X POST https://<crm>/api/audit-logs/seal-backlog \
|
curl -s -X POST $CRM/api/auth/login -H 'Content-Type: application/json' \
|
||||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
-d "{\"email\":\"…\",\"password\":\"$PASS\"}" | jq -r '.data.user.permissions[]' | grep audit
|
||||||
-d '{"confirm":"SEAL"}' | jq
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Dafür braucht es das Recht `audit:admin` – das Gegenbuch-Dienstkonto hat es
|
Muss `audit:read` **und** `audit:admin` zeigen.
|
||||||
absichtlich **nicht**. Nimm dein Administratorkonto.
|
|
||||||
|
|
||||||
**Zuerst nachsehen, was versiegelt wird.** Das Siegel schreibt den aktuellen
|
#### Erst nachsehen, was du festschreibst
|
||||||
Zustand fest, samt aller vorhandenen Lücken. Wer blind siegelt, beglaubigt
|
|
||||||
gegebenenfalls auch eine Lücke, die von einer Löschung stammt. Deshalb vorher:
|
Das Siegel hält den **aktuellen** Zustand fest, samt aller vorhandenen Lücken.
|
||||||
|
Wer blind siegelt, beglaubigt womöglich eine Lücke, die von einer Löschung
|
||||||
|
stammt. Der Schritt ist praktisch einwegs: Danach ist die Beglaubigung tragend,
|
||||||
|
und ein Siegel wieder zu entfernen erzeugt den Zustand `entfernt` – also selbst
|
||||||
|
einen Befund.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST https://<crm>/api/audit-logs/verify \
|
B="Authorization: Bearer $AT"
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
curl -s -X POST $CRM/api/audit-logs/verify -H "$B" \
|
||||||
| jq '.data | {valid, chainGaps, unexplainedGaps, tamperedEntries}'
|
| jq '.data | {valid, checkedCount, rehashes, chainGaps, unexplainedGaps,
|
||||||
|
tamperedEntries, backlogSealStatus}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Stehen dort Lücken, sieh dir die betroffenen IDs und ihre Nachbarn an
|
Vier Dinge müssen stimmen:
|
||||||
(`GET /api/audit-logs/<id>`). Liegen sie jeweils **innerhalb einer Sekunde**
|
|
||||||
zusammen mit ihren Nachbarn und fehlt kein Eintrag (kein `404`), sind es
|
|
||||||
Schreibkollisionen aus paralleln Anfragen – harmlos. Fehlt dagegen ein Eintrag
|
|
||||||
oder steht etwas in `tamperedEntries`, **erst klären, dann siegeln**.
|
|
||||||
|
|
||||||
Nach dem Siegeln meldet die Prüfung wieder `valid: true`, und die bekannten
|
| Feld | Erwartung | Warum |
|
||||||
Alt-Lücken erscheinen als **beglaubigt**:
|
|---|---|---|
|
||||||
|
| `rehashes` | `[]` | Eine Neuberechnung verknüpft alles neu – danach ist die Kette *zwangsläufig* stimmig, auch über Löschungen hinweg. Steht hier etwas, sagt `chainGaps` nichts über die Zeit davor aus. |
|
||||||
|
| `tamperedEntries` | `[]` | Veränderter Inhalt gehört geklärt, nicht beglaubigt. |
|
||||||
|
| `chainGaps` | erklärbar | siehe unten |
|
||||||
|
| `backlogSealStatus` | `kein_siegel` | sonst ist es kein Erst-Siegeln (siehe nächster Abschnitt) |
|
||||||
|
|
||||||
Alle Einträge sind unverändert. Seit dem Bestandssiegel ist keine neue
|
**Lücken einordnen.** Sieh dir die betroffenen IDs und ihre Nachbarn an:
|
||||||
Lücke entstanden. 6 Lücken stammen aus der Zeit vor dem Bestandssiegel
|
|
||||||
und sind darin als Vorbefund beglaubigt (IDs …).
|
|
||||||
|
|
||||||
Sie verschwinden also nicht aus dem Bericht – sie zählen nur nicht mehr als
|
```bash
|
||||||
offener Befund. Jede **neue** Lücke, jede veränderte oder entfernte Altzeile
|
for id in <lücke-1> <nachbar> …; do
|
||||||
und jedes gebrochene Siegel lösen weiterhin sofort Alarm aus.
|
printf '%-6s ' "$id"
|
||||||
|
curl -s -o /tmp/r.json -w 'HTTP %{http_code} ' "$CRM/api/audit-logs/$id" -H "$B"
|
||||||
|
jq -r 'if .success then (.data|"\(.createdAt[0:19]) v\(.hashVersion) \(.action)/\(.resourceType)") else .error end' /tmp/r.json
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Den HTTP-Code mit ausgeben, immer.** Zugangstoken laufen nach 15 Minuten ab.
|
||||||
|
> Ohne den Code liest sich eine abgelehnte Anfrage (401) wie ein fehlender
|
||||||
|
> Eintrag – ein Prüfwerkzeug, das ein verweigertes Lesen als Löschung meldet.
|
||||||
|
> Uns ist genau das passiert, mitten in der Vorbereitung eines Prod-Siegels.
|
||||||
|
|
||||||
|
Liegen die Lücken jeweils **innerhalb einer Sekunde** zusammen mit ihren
|
||||||
|
Nachbarn, betreffen nur `Authentication` und fehlt kein Eintrag, sind es
|
||||||
|
Schreibkollisionen aus parallelen Anfragen – harmlos, historisch, nicht mehr
|
||||||
|
reproduzierbar. Fehlt dagegen ein Eintrag oder passt eine Lücke nicht in dieses
|
||||||
|
Bild: **erst klären, dann siegeln.**
|
||||||
|
|
||||||
|
*(Ein abgeschnittener **Anfang** des Protokolls wird seit dem Anfangs-Detektor
|
||||||
|
mitgeprüft: Die erste Zeile eines Protokolls trägt einen leeren `previousHash`;
|
||||||
|
trägt die erste vorhandene Zeile einen Wert, fehlt eine Vorgängerin. Das
|
||||||
|
erscheint als ganz niedrige Lücke und kippt `valid` – du musst es nicht selbst
|
||||||
|
suchen.)*
|
||||||
|
|
||||||
|
#### Siegeln
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST $CRM/api/audit-logs/seal-backlog -H "$B" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"confirm":"SEAL"}' | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
`SEAL` ist das Erst-Siegeln. Besteht bereits ein Siegel, verlangt der Endpunkt
|
||||||
|
stattdessen `RESEAL` – siehe nächster Abschnitt. Notiere `root` aus der Antwort.
|
||||||
|
|
||||||
|
#### Danach: drei Punkte gegenprüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST $CRM/api/audit-logs/verify -H "$B" \
|
||||||
|
| jq '.data | {valid, chainGaps, attestedGaps, backlogSealStatus, backlogSealCount}'
|
||||||
|
|
||||||
|
SID=$(curl -s "$CRM/api/audit-logs?resourceType=AuditBacklogSeal&limit=1" -H "$B" | jq -r '.data[0].id')
|
||||||
|
curl -s "$CRM/api/audit-logs/$SID" -H "$B" \
|
||||||
|
| jq '{vorbefund:.data.changesBefore.befund.ketten_luecken, siegel:.data.changesAfter}'
|
||||||
|
```
|
||||||
|
|
||||||
|
1. `valid: true`, und `chainGaps` = `attestedGaps` = die bekannten Lücken
|
||||||
|
2. `backlogSealStatus: "intakt"` – **nicht** `leer`. `leer` heißt: Das Siegel
|
||||||
|
umschließt nichts, es gab keinen Altbestand. Kein Fehler, aber auch keine
|
||||||
|
Zusage.
|
||||||
|
3. Die Lücken stehen im `changesBefore.befund.ketten_luecken` des Markers –
|
||||||
|
genau daran hängt die Beglaubigung.
|
||||||
|
|
||||||
|
Die Lücken verschwinden also **nicht** aus dem Bericht. Sie zählen nur nicht
|
||||||
|
mehr als offener Befund. Jede **neue** Lücke, jede veränderte oder entfernte
|
||||||
|
Altzeile und jedes gebrochene Siegel lösen weiterhin sofort Alarm aus.
|
||||||
|
|
||||||
|
#### Zuletzt: das Gegenbuch quittieren
|
||||||
|
|
||||||
|
Der nächste Lauf meldet **einmal** `exit=2` („Erstmals ein Bestandssiegel
|
||||||
|
gesetzt") – erwartet, kein Befund. Wurzel aus `data/<instanz>/status.txt` in
|
||||||
|
`PROD_SEAL_ACK` bzw. `STAGING_SEAL_ACK` eintragen, `docker compose up -d`, nach
|
||||||
|
dem Lauf mit `exit=0` wieder leeren. Details im nächsten Abschnitt.
|
||||||
|
|
||||||
|
Ab da schreibt das Gegenbuch wieder Checkpoints fort – und setzt im selben Lauf
|
||||||
|
die Grundlage für die Rehash-Überwachung.
|
||||||
|
|
||||||
### Wenn sich die Siegelwurzel ändert: Alarm, und warum
|
### Wenn sich die Siegelwurzel ändert: Alarm, und warum
|
||||||
|
|
||||||
@@ -177,6 +279,58 @@ Im CRM selbst ist erneutes Siegeln zusätzlich gegatet: es verlangt
|
|||||||
`{"confirm":"RESEAL"}` statt `{"confirm":"SEAL"}` — ein Austausch der
|
`{"confirm":"RESEAL"}` statt `{"confirm":"SEAL"}` — ein Austausch der
|
||||||
Beweisgrundlage soll nicht dasselbe Wort haben wie das Einrichten.
|
Beweisgrundlage soll nicht dasselbe Wort haben wie das Einrichten.
|
||||||
|
|
||||||
|
### Wenn die Kette neu berechnet wurde: ebenfalls Alarm
|
||||||
|
|
||||||
|
Ein **Rehash** verknüpft alle Einträge neu. Danach ist die Kette
|
||||||
|
zwangsläufig stimmig — auch über Löschungen hinweg, die vorher als Lücken
|
||||||
|
sichtbar gewesen wären. Die Reihenfolge `cleanup` → `rehash` macht aus einem
|
||||||
|
beschnittenen Protokoll ein scheinbar makelloses, und die Prüfung im CRM meldet
|
||||||
|
danach wieder „lückenlos verkettet". Beides braucht nur `audit:admin`, keinen
|
||||||
|
Datenbankzugriff.
|
||||||
|
|
||||||
|
Genau das ist im Betrieb vorgekommen: Auf einer Testinstanz wurden 3.155
|
||||||
|
Einträge gelöscht und anschließend neu berechnet. Die Prüfung war danach grün,
|
||||||
|
die 656 Kettenlücken standen nur noch im Vorbefund des Rehash-Eintrags — den
|
||||||
|
niemand liest. Das Gegenbuch war der einzige Zeuge.
|
||||||
|
|
||||||
|
Deshalb ist eine neue Neuberechnung seit der letzten Beglaubigung ein **Alarm**
|
||||||
|
(exit 2). Er nennt Zeitpunkt, Zahl der betroffenen Zeilen und den Befund, der
|
||||||
|
unmittelbar davor galt:
|
||||||
|
|
||||||
|
```
|
||||||
|
ALARM: Die Hash-Kette wurde neu berechnet (1 neuer Vorgang seit der letzten Beglaubigung).
|
||||||
|
zuletzt: 2026-08-26T16:28:25.467Z (Eintrag 13, 9 Zeilen)
|
||||||
|
Befund unmittelbar davor: 1 beanstandet, 1 Lücken
|
||||||
|
…
|
||||||
|
NOTARY_REHASH_ACK=13
|
||||||
|
```
|
||||||
|
|
||||||
|
**War die Neuberechnung gewollt**, bestätigst du sie mit der genannten ID:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# in der .env
|
||||||
|
PROD_REHASH_ACK=13
|
||||||
|
docker compose up -d
|
||||||
|
PROD_REHASH_ACK= # danach wieder leeren
|
||||||
|
```
|
||||||
|
|
||||||
|
Auch hier wird nicht mit `true` bestätigt, sondern mit einem Wert, der zum
|
||||||
|
Vorgang gehört. IDs steigen streng — ein stehen gelassener Wert passt bei der
|
||||||
|
nächsten Neuberechnung nicht mehr.
|
||||||
|
|
||||||
|
**Warum ein eigener Melder, wo doch schon der Kettenkopf verglichen wird?**
|
||||||
|
Eine Neuberechnung ändert jeden Hash, der beglaubigte Kopf stimmt also
|
||||||
|
ohnehin nicht mehr — der Alarm käme auch so. Aber als *Nebenwirkung*, nicht als
|
||||||
|
gebaute Warnung: Verschöbe sich der Anker irgendwann, wäre der Melder lautlos
|
||||||
|
weg. Und die Meldung hieße „Eintrag wurde verändert" statt „die Kette wurde neu
|
||||||
|
berechnet" — die Wirkung statt der Ursache. Deshalb hängt der Alarm an der
|
||||||
|
Sache selbst und steht **vor** dem Kopf-Vergleich; nach einer bestätigten
|
||||||
|
Neuberechnung wird dieser übersprungen, weil der veränderte Kopf dann die
|
||||||
|
erwartete Folge ist.
|
||||||
|
|
||||||
|
Ein fehlender beglaubigter Eintrag bleibt davon unberührt und alarmiert immer:
|
||||||
|
Bestätigt wird die Neuberechnung, nicht das Verschwinden von Zeilen.
|
||||||
|
|
||||||
### Zugang einrichten (das brauchst du vorher)
|
### Zugang einrichten (das brauchst du vorher)
|
||||||
|
|
||||||
Das Gegenbuch braucht ein **eigenes Benutzerkonto** im CRM – kein Token. Der
|
Das Gegenbuch braucht ein **eigenes Benutzerkonto** im CRM – kein Token. Der
|
||||||
@@ -195,12 +349,12 @@ Im CRM, als Administrator:
|
|||||||
4. E-Mail und Passwort in die `.env` des Gegenbuchs eintragen
|
4. E-Mail und Passwort in die `.env` des Gegenbuchs eintragen
|
||||||
(`PROD_CRM_EMAIL` / `PROD_CRM_PASSWORD`).
|
(`PROD_CRM_EMAIL` / `PROD_CRM_PASSWORD`).
|
||||||
|
|
||||||
> **Nicht den DSGVO-Haken benutzen.** Der sieht naheliegend aus („Audit-Logs,
|
> **Weder den DSGVO- noch den Audit-Betrieb-Haken setzen.** Der DSGVO-Haken
|
||||||
> Datenschutz"), vergibt aber `audit:` **komplett** – einschließlich
|
> gibt zusätzlich Leserechte auf personenbezogene Daten und den Export; der
|
||||||
> `audit:admin` mit `seal-backlog`, `rehash` und `cleanup`. Ein Einbruch auf
|
> Haken „Audit-Betrieb" gibt `audit:admin` mit `seal-backlog`, `rehash` und
|
||||||
> dieser Maschine hätte damit nicht nur den Wächter, sondern gleich die Mittel,
|
> `cleanup`. Ein Einbruch auf dieser Maschine hätte damit nicht nur den
|
||||||
> das Bewachte umzuschreiben. Das Passwort steht hier im Klartext in der
|
> Wächter, sondern gleich die Mittel, das Bewachte umzuschreiben. Das Passwort
|
||||||
> `.env`; es muss so wenig wert sein wie möglich.
|
> steht hier im Klartext in der `.env`; es muss so wenig wert sein wie möglich.
|
||||||
|
|
||||||
Mit `audit:read` allein kann dieses Konto **nur Prüfwerte lesen** – keine
|
Mit `audit:read` allein kann dieses Konto **nur Prüfwerte lesen** – keine
|
||||||
Kundendaten, keine Verträge, nichts ändern und nichts versiegeln. Selbst wenn
|
Kundendaten, keine Verträge, nichts ändern und nichts versiegeln. Selbst wenn
|
||||||
@@ -556,6 +710,7 @@ an, bis er geklärt ist.
|
|||||||
| Bestandssiegel-Blätter entfernt | beglaubigte Blattzahl auf null gefallen |
|
| Bestandssiegel-Blätter entfernt | beglaubigte Blattzahl auf null gefallen |
|
||||||
| **Altzeile gelöscht und neu gesiegelt** (Wäsche) | **Wurzel des Bestandssiegels hat gewechselt – `valid` allein bleibt dabei `true`** |
|
| **Altzeile gelöscht und neu gesiegelt** (Wäsche) | **Wurzel des Bestandssiegels hat gewechselt – `valid` allein bleibt dabei `true`** |
|
||||||
| Erstmals gesiegelt, ohne dass es jemand veranlasst hat | vorher keine Wurzel beglaubigt, jetzt eine |
|
| Erstmals gesiegelt, ohne dass es jemand veranlasst hat | vorher keine Wurzel beglaubigt, jetzt eine |
|
||||||
|
| **`cleanup` + `rehash` ohne erneutes Siegeln** (Wäsche ohne Datenbankzugriff) | **neue Neuberechnung seit der letzten Beglaubigung – `valid` und Kette sind danach makellos** |
|
||||||
|
|
||||||
Bei jedem dieser Fälle bricht das Skript mit **Exit-Code 2** ab und **hängt
|
Bei jedem dieser Fälle bricht das Skript mit **Exit-Code 2** ab und **hängt
|
||||||
nichts an** – der manipulierte Zustand wird also nicht als neue Wahrheit
|
nichts an** – der manipulierte Zustand wird also nicht als neue Wahrheit
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ services:
|
|||||||
NOTARY_ADOPT_ACK: ${PROD_ADOPT_ACK:-}
|
NOTARY_ADOPT_ACK: ${PROD_ADOPT_ACK:-}
|
||||||
# Nur nach einem GEWOLLTEN Siegelwechsel, mit der neuen Wurzel:
|
# Nur nach einem GEWOLLTEN Siegelwechsel, mit der neuen Wurzel:
|
||||||
NOTARY_SEAL_ACK: ${PROD_SEAL_ACK:-}
|
NOTARY_SEAL_ACK: ${PROD_SEAL_ACK:-}
|
||||||
|
# Nur nach einer GEWOLLTEN Neuberechnung, mit der ID des Rehash-Eintrags:
|
||||||
|
NOTARY_REHASH_ACK: ${PROD_REHASH_ACK:-}
|
||||||
volumes:
|
volumes:
|
||||||
# Buch, Schlüssel, Beobachtungsspeicher und Statusdatei.
|
# Buch, Schlüssel, Beobachtungsspeicher und Statusdatei.
|
||||||
# Dieses Verzeichnis ist das Gegenbuch – es gehört ins Backup.
|
# Dieses Verzeichnis ist das Gegenbuch – es gehört ins Backup.
|
||||||
@@ -56,5 +58,6 @@ services:
|
|||||||
NOTARY_GENESIS_ACK: ${STAGING_GENESIS_ACK:-}
|
NOTARY_GENESIS_ACK: ${STAGING_GENESIS_ACK:-}
|
||||||
NOTARY_ADOPT_ACK: ${STAGING_ADOPT_ACK:-}
|
NOTARY_ADOPT_ACK: ${STAGING_ADOPT_ACK:-}
|
||||||
NOTARY_SEAL_ACK: ${STAGING_SEAL_ACK:-}
|
NOTARY_SEAL_ACK: ${STAGING_SEAL_ACK:-}
|
||||||
|
NOTARY_REHASH_ACK: ${STAGING_REHASH_ACK:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ${STAGING_DIR:-./data/staging}:/gegenbuch
|
- ${STAGING_DIR:-./data/staging}:/gegenbuch
|
||||||
|
|||||||
@@ -70,8 +70,16 @@ fi
|
|||||||
# Logs zu durchsuchen.
|
# Logs zu durchsuchen.
|
||||||
while true; do
|
while true; do
|
||||||
ZEIT=$(date -Iseconds)
|
ZEIT=$(date -Iseconds)
|
||||||
node /opt/notary/notary.mjs
|
# Ausgabe mitschneiden, um den GRUND in die Statusdatei zu bekommen.
|
||||||
|
#
|
||||||
|
# Vorher stand dort nur das Etikett ("BEFUND"), und das ist fuer jeden
|
||||||
|
# Exit-2 dasselbe - ein echter Widerspruch sieht darin aus wie eine
|
||||||
|
# quittierpflichtige Erstsiegelung. Wer nur die Statusdatei liest, kann
|
||||||
|
# nicht entscheiden, ob er handeln muss. Genau die Sorte Signal, gegen die
|
||||||
|
# dieses Werkzeug gebaut ist.
|
||||||
|
node /opt/notary/notary.mjs > /tmp/lauf.log 2>&1
|
||||||
CODE=$?
|
CODE=$?
|
||||||
|
cat /tmp/lauf.log
|
||||||
case $CODE in
|
case $CODE in
|
||||||
0) LAGE="in Ordnung" ;;
|
0) LAGE="in Ordnung" ;;
|
||||||
1) LAGE="Betriebsfehler (Konfiguration, CRM nicht erreichbar, Commit)" ;;
|
1) LAGE="Betriebsfehler (Konfiguration, CRM nicht erreichbar, Commit)" ;;
|
||||||
@@ -82,6 +90,18 @@ while true; do
|
|||||||
*) LAGE="unbekannter Code" ;;
|
*) LAGE="unbekannter Code" ;;
|
||||||
esac
|
esac
|
||||||
echo "[$INSTANZ] $ZEIT exit=$CODE ($LAGE)"
|
echo "[$INSTANZ] $ZEIT exit=$CODE ($LAGE)"
|
||||||
printf '%s exit=%s %s\n' "$ZEIT" "$CODE" "$LAGE" > /gegenbuch/status.txt
|
# Die aussagekraeftigste Zeile mitnehmen, nicht einfach die erste.
|
||||||
|
#
|
||||||
|
# Bei Alarm ist das die ALARM-Zeile. Sonst die Zusammenfassung ("OK: …") -
|
||||||
|
# denn ein Lauf kann davor beilaeufige Hinweise ausgeben (etwa das einmalige
|
||||||
|
# Setzen einer Ueberwachungs-Grundlage), und die verdeckten sonst das
|
||||||
|
# Ergebnis. Erst wenn beides fehlt, die erste Zeile ueberhaupt.
|
||||||
|
GRUND=$(grep -m1 '^ALARM: ' /tmp/lauf.log | sed 's/^ALARM: //')
|
||||||
|
[ -z "$GRUND" ] && GRUND=$(grep -m1 '^OK: ' /tmp/lauf.log)
|
||||||
|
[ -z "$GRUND" ] && GRUND=$(grep -m1 -v '^[[:space:]]*$' /tmp/lauf.log)
|
||||||
|
{
|
||||||
|
printf '%s exit=%s %s\n' "$ZEIT" "$CODE" "$LAGE"
|
||||||
|
[ -n "$GRUND" ] && printf 'Grund: %s\n' "$GRUND"
|
||||||
|
} > /gegenbuch/status.txt
|
||||||
sleep "$INTERVALL"
|
sleep "$INTERVALL"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -153,6 +153,17 @@ const SEAL_ACK = (process.env.NOTARY_SEAL_ACK || '').trim();
|
|||||||
const siegelBestaetigt = (wurzel) =>
|
const siegelBestaetigt = (wurzel) =>
|
||||||
SEAL_ACK.length >= 16 && !!wurzel && wurzel.startsWith(SEAL_ACK);
|
SEAL_ACK.length >= 16 && !!wurzel && wurzel.startsWith(SEAL_ACK);
|
||||||
|
|
||||||
|
// Bestaetigung fuer eine Neuberechnung der Kette (Pentest R186, Frage (b)).
|
||||||
|
//
|
||||||
|
// Bestaetigt wird mit der ID des juengsten Rehash-Eintrags. IDs steigen
|
||||||
|
// streng, ein stehen gelassener Wert passt also beim naechsten Rehash nicht
|
||||||
|
// mehr - dasselbe selbstentwertende Prinzip wie bei NOTARY_SEAL_ACK, nur mit
|
||||||
|
// einem Wert, den man vorlesen kann.
|
||||||
|
const REHASH_ACK = (process.env.NOTARY_REHASH_ACK || '').trim();
|
||||||
|
// Wurde in diesem Lauf eine Neuberechnung ausdruecklich bestaetigt? Dann ist
|
||||||
|
// der veraenderte Kettenkopf die erwartete Folge und kein eigener Befund.
|
||||||
|
let rehashBestaetigt = false;
|
||||||
|
|
||||||
if (!CRM_URL) {
|
if (!CRM_URL) {
|
||||||
console.error('CRM_URL muss gesetzt sein.');
|
console.error('CRM_URL muss gesetzt sein.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -755,6 +766,17 @@ const letzter = bisher[bisher.length - 1];
|
|||||||
if (!CRM_TOKEN) await anmelden();
|
if (!CRM_TOKEN) await anmelden();
|
||||||
const aktuell = await hole('/api/audit-logs/checkpoint');
|
const aktuell = await hole('/api/audit-logs/checkpoint');
|
||||||
|
|
||||||
|
// Die Vollpruefung wird IMMER geholt, auch beim allerersten Lauf: Aus ihr
|
||||||
|
// stammt die Zahl der protokollierten Neuberechnungen, und die gehoert schon
|
||||||
|
// in den ersten Eintrag - sonst gaebe es beim zweiten Lauf keine Grundlage,
|
||||||
|
// gegen die sich ein neuer Rehash abheben koennte.
|
||||||
|
const pruefung = await hole('/api/audit-logs/verify', 'POST');
|
||||||
|
const rehashListe = Array.isArray(pruefung?.rehashes) ? pruefung.rehashes : null;
|
||||||
|
const rehashAnzahl = rehashListe ? rehashListe.length : null;
|
||||||
|
const rehashLetzte = rehashListe && rehashListe.length
|
||||||
|
? rehashListe[rehashListe.length - 1].id
|
||||||
|
: null;
|
||||||
|
|
||||||
if (letzter) {
|
if (letzter) {
|
||||||
if (aktuell.maxId !== null && aktuell.maxId < letzter.maxId) {
|
if (aktuell.maxId !== null && aktuell.maxId < letzter.maxId) {
|
||||||
alarm(
|
alarm(
|
||||||
@@ -767,7 +789,6 @@ if (letzter) {
|
|||||||
// Eintraege endgueltig loeschen und die Luecken per Tombstone als „erklaert“
|
// Eintraege endgueltig loeschen und die Luecken per Tombstone als „erklaert“
|
||||||
// ausweisen – die Meldung liest sich dann harmlos. Fuer das Gegenbuch zaehlt
|
// ausweisen – die Meldung liest sich dann harmlos. Fuer das Gegenbuch zaehlt
|
||||||
// das Feld, nicht der Satz.
|
// das Feld, nicht der Satz.
|
||||||
const pruefung = await hole('/api/audit-logs/verify', 'POST');
|
|
||||||
if (pruefung && pruefung.valid === false) {
|
if (pruefung && pruefung.valid === false) {
|
||||||
alarm(
|
alarm(
|
||||||
'Die Prüfung im CRM meldet die Kette als NICHT unversehrt (valid: false).\n' +
|
'Die Prüfung im CRM meldet die Kette als NICHT unversehrt (valid: false).\n' +
|
||||||
@@ -779,8 +800,77 @@ if (letzter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`);
|
const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`);
|
||||||
|
// Ein fehlender beglaubigter Eintrag ist IMMER ein Befund - auch wenn eine
|
||||||
|
// Neuberechnung bestaetigt wurde. Bestaetigt wird der Rehash, nicht das
|
||||||
|
// Verschwinden von Zeilen.
|
||||||
if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`);
|
if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`);
|
||||||
if (rueck.atHash !== letzter.chainHead) {
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Neuberechnung der Kette (Rehash) als EIGENE Alarmbedingung.
|
||||||
|
//
|
||||||
|
// Ein Rehash wurde bisher nur als Nebenwirkung gefangen: Er aendert jeden
|
||||||
|
// Hash, also stimmt der beglaubigte Kettenkopf nicht mehr und der Vergleich
|
||||||
|
// weiter oben schlaegt an. Das funktioniert - aber es ist ein Zufallstreffer
|
||||||
|
// und keine gebaute Warnung. Verschoebe sich der Anker irgendwann (anderer
|
||||||
|
// beglaubigter Wert, anderer Vergleich), waere der Melder lautlos weg.
|
||||||
|
//
|
||||||
|
// Deshalb haengt der Alarm jetzt an der Waffe selbst, nicht an ihrer Spur -
|
||||||
|
// dieselbe Lehre wie R184-01 (Gate am Ausloeser statt an der Wirkung) und
|
||||||
|
// R185-01 (Wurzelwechsel statt `valid`).
|
||||||
|
//
|
||||||
|
// Das Restrisiko, das dieser Melder abdeckt: `cleanup` + `rehash` OHNE
|
||||||
|
// erneutes Siegeln, ueber Inhalt, der gar nicht versiegelt ist. Dort greift
|
||||||
|
// weder das Bestandssiegel noch der Wurzelwechsel.
|
||||||
|
if (rehashAnzahl !== null && typeof letzter.rehashCount === 'number') {
|
||||||
|
if (rehashAnzahl > letzter.rehashCount) {
|
||||||
|
const neue = rehashAnzahl - letzter.rehashCount;
|
||||||
|
if (REHASH_ACK && String(rehashLetzte) === REHASH_ACK) {
|
||||||
|
rehashBestaetigt = true;
|
||||||
|
console.log(
|
||||||
|
`Neuberechnung bestätigt (NOTARY_REHASH_ACK=${REHASH_ACK}). ` +
|
||||||
|
'NOTARY_REHASH_ACK danach wieder leeren.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const l = rehashListe[rehashListe.length - 1];
|
||||||
|
const vb = l.vorbefund;
|
||||||
|
alarm(
|
||||||
|
`Die Hash-Kette wurde neu berechnet (${neue} neue${neue === 1 ? 'r' : ''} Vorgang` +
|
||||||
|
`${neue === 1 ? '' : 'e'} seit der letzten Beglaubigung).\n` +
|
||||||
|
` zuletzt: ${l.zeitpunkt} (Eintrag ${l.id}, ${l.neuBerechnet} Zeilen)\n` +
|
||||||
|
(vb
|
||||||
|
? ` Befund unmittelbar davor: ${vb.manipuliert} beanstandet, ${vb.luecken} Lücken\n`
|
||||||
|
: ' Der Zustand davor ist nicht mehr feststellbar.\n') +
|
||||||
|
(l.signiert ? '' : ' ACHTUNG: Dieser Rehash-Eintrag trägt keine gültige Signatur.\n') +
|
||||||
|
'Ein Rehash verknüpft alle Einträge neu. Lücken, die vorher eine Löschung\n' +
|
||||||
|
'sichtbar gemacht hätten, verschwinden dabei aus der Kette – die Prüfung im CRM\n' +
|
||||||
|
'meldet danach wieder „lückenlos". Genau deshalb ist das hier ein Alarm und\n' +
|
||||||
|
'kein Hinweis.\n' +
|
||||||
|
'War es geplant, bestätigen und danach wieder leeren:\n' +
|
||||||
|
` NOTARY_REHASH_ACK=${rehashLetzte}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (rehashAnzahl !== null && typeof letzter.rehashCount !== 'number') {
|
||||||
|
// Gegenbuch aus der Zeit vor diesem Melder: Es gibt keine Grundlage, gegen
|
||||||
|
// die sich etwas abheben koennte. Stillschweigend zu alarmieren waere ein
|
||||||
|
// Fehlalarm bei jedem Bestandsbuch; stillschweigend zu uebernehmen waere
|
||||||
|
// eine unsichtbare Annahme. Also: uebernehmen und es sagen.
|
||||||
|
console.log(
|
||||||
|
`Grundlage für die Rehash-Überwachung wird gesetzt: ${rehashAnzahl} protokollierte ` +
|
||||||
|
'Neuberechnung(en) im CRM. Ab dem nächsten Lauf fällt jede weitere auf.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Der Kopf-Hash-Vergleich steht bewusst NACH der Rehash-Pruefung.
|
||||||
|
//
|
||||||
|
// Zwei Gruende: Erstens benennt der Rehash-Alarm die Ursache, waehrend
|
||||||
|
// „Eintrag wurde veraendert“ nur die Wirkung beschreibt - bei gleicher Lage
|
||||||
|
// ist die praezisere Meldung die nuetzlichere. Zweitens aendert eine
|
||||||
|
// Neuberechnung ZWANGSLAEUFIG jeden Hash; stuende dieser Vergleich davor
|
||||||
|
// oder ungeschuetzt dahinter, liesse sich eine bestaetigte Neuberechnung nie
|
||||||
|
// aufloesen und die Bestaetigung waere wertlos.
|
||||||
|
if (rueck.atHash !== letzter.chainHead && !rehashBestaetigt) {
|
||||||
alarm(
|
alarm(
|
||||||
`Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` +
|
`Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` +
|
||||||
` beglaubigt: ${letzter.chainHead}\n jetzt : ${rueck.atHash}`,
|
` beglaubigt: ${letzter.chainHead}\n jetzt : ${rueck.atHash}`,
|
||||||
@@ -910,6 +1000,9 @@ const eintrag = {
|
|||||||
chainHead: aktuell.chainHead,
|
chainHead: aktuell.chainHead,
|
||||||
sealRoot: aktuell.sealRoot,
|
sealRoot: aktuell.sealRoot,
|
||||||
sealLeafCount: aktuell.sealLeafCount,
|
sealLeafCount: aktuell.sealLeafCount,
|
||||||
|
// Grundlage fuer die Rehash-Ueberwachung des naechsten Laufs.
|
||||||
|
rehashCount: rehashAnzahl,
|
||||||
|
rehashLast: rehashLetzte,
|
||||||
};
|
};
|
||||||
const neuerInhalt = [...bisher, eintrag].map((e) => JSON.stringify(e)).join('\n') + '\n';
|
const neuerInhalt = [...bisher, eintrag].map((e) => JSON.stringify(e)).join('\n') + '\n';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user