Rechtemodell Etappe 1: toter Katalog begradigt, Selbst-Erhoehung geschlossen
Vorarbeit fuer die Rollen-Oberflaeche. Eine Checkbox-Liste ueber einem Katalog, der nicht stimmt, waere 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 prueften in Wahrheit providers:*, platforms:* und settings:*. Die Routen gaten jetzt granular; 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 ausfuehren, und aufgefallen waere es erst, wenn eine Frist laeuft. Jetzt eine Quelle: src/config/rechte-katalog.ts. 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 Haertung war nur in einer der beiden Kopien angekommen. Jetzt zufaellig, Cost 12, einmalig im Log. Selbst-Erhoehung geschlossen: neues Recht roles:manage, getrennt von users:*, dazu die Teilmengenregel in rechte.service.ts - niemand kann ein Recht weitergeben, das er selbst nicht haelt. Sie greift auf allen vier Wegen: Rolle anlegen, Rolle aendern, Rollen zuweisen und die drei Haken. Der Haken-Weg war der wichtigste: Er brauchte nur users:create, ein zweites Konto mit "Entwicklerzugriff" anlegen und sich damit anmelden - die Developer-Rolle traegt alle Rechte. Geprueft wird der Zuwachs, nicht der Endzustand, damit Entziehen erlaubt bleibt; und die Rechte des Handelnden kommen frisch aus der Datenbank, nicht aus dem bis zu 15 Minuten alten Token. Systemrollen gesperrt (Role.isSystem): Admin liess sich bisher umbenennen oder leeren - und die versteckten Rollen haengen an ihrem Namen. Role.isHidden ersetzt die im Frontend hartkodierte Namensliste, in der "Gegenbuch" fehlte. Rechteaenderung wirkt sofort: updateRole/deleteRole melden alle Traeger ab. Werksreset und Backup-Restore verlangen zusaetzlich roles:manage - beide loeschen alle Rollen und legen ein frisches admin@admin.com an. Rollenpflege war der einzige Eingriff in die Rechtevergabe ohne SecurityEvent, obwohl sie viele Konten auf einmal trifft. Jetzt PERMISSION_CHANGED - ebenso fuer die bisher stummen Haken DSGVO und Entwicklerzugriff. Aussperr-Ausweg: prisma/rolle-zuweisen.ts. Umgeht die Regel bewusst - wer Shell-Zugang hat, hat ohnehin die Datenbank; ein gestohlener Web-Zugang hat ihn nicht. Schreibt ueber die Hash-Kette, nicht roh. Die Startwache nennt den Befehl im Klartext. Geprueft gegen Dev-DB und frische Wegwerf-DB: 7 Eskalationswege alle 403, 2 Gegenproben erlaubt; 6 Sperrtests auf Systemrollen alle 403, eigene Rollen weiter aenderbar; Stammdaten-Lesen unveraendert 200; Rechteaenderung sofort 401; Migration und sync-roles dreimal identisch; db:seed auf bestehender DB ohne Wirkung auf die Rollenmatrix; frische Installation mit allen 8 Systemrollen und ohne Hinweise. Beim Deploy: Die Migration beendet alle Sitzungen einmalig. Noetig, weil die Rechte im Token stehen - sonst liefen bis zu 15 Minuten 403er. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained 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,157 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { ALLE_RECHTE, SYSTEMROLLEN_NAMEN } from '../src/config/rechte-katalog.js';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,141 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { createAuditLog } from '../src/services/audit.service.js';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
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())
|
||||||
|
|||||||
+14
-224
@@ -1,238 +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'],
|
|
||||||
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'],
|
|
||||||
// Konfiguration (CRUD)
|
|
||||||
'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 }[] = [];
|
|
||||||
for (const [resource, actions] of Object.entries(resourcePermissions)) {
|
|
||||||
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 Fachfunktionen (ohne Audit & Datenschutz – dafür die separaten Rollen DSGVO und Audit-Betrieb)',
|
|
||||||
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 - Datenschutz-Verwaltung plus LESENDER Zugriff aufs Audit-Protokoll.
|
|
||||||
//
|
//
|
||||||
// Bewusst OHNE `audit:admin`: Wer das Protokoll beaufsichtigt, darf seine
|
// Bis 09/2026 stand hier eine zweite, eigene Kopie - und sie wich ab: Die
|
||||||
// eigene Beweisgrundlage nicht ersetzen koennen (Pentest R186). Die
|
// DSGVO-Rolle bekam `audit:*` komplett, also auch `audit:admin`. Gerettet
|
||||||
// eingreifenden Rechte liegen in der Rolle `Audit-Betrieb`.
|
// hat das nur die Reihenfolge im Container-Start (sync-roles lief danach
|
||||||
//
|
// und raeumte Ueberzaehliges weg); ein einzelnes `npm run db:seed` brachte
|
||||||
// Diese Liste stand hier bis 09/2026 noch auf `audit:*` komplett und war
|
// die Buendelung zurueck. Drei Beschreibungen desselben Sachverhalts sind
|
||||||
// damit die dritte Stelle, die denselben Rechtesatz beschrieb - neben
|
// zwei zu viel.
|
||||||
// `sync-roles.ts` und dem Notfallpfad in `user.service.ts`. Gerettet hat es
|
await synchronisiereRechteUndRollen(prisma);
|
||||||
// nur die Reihenfolge im Container-Start (sync-roles laeuft danach und
|
|
||||||
// raeumt Ueberzaehliges weg); ein einzelnes `npm run db:seed` brachte die
|
|
||||||
// Buendelung zurueck.
|
|
||||||
const gdprPermissions = allPermissions.filter(
|
|
||||||
(p) =>
|
|
||||||
p.resource === 'gdpr' ||
|
|
||||||
(p.resource === 'audit' && (p.action === 'read' || p.action === 'export'))
|
|
||||||
);
|
|
||||||
const gdprRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'DSGVO' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'DSGVO',
|
|
||||||
description: 'DSGVO-Zugriff: Audit-Logs lesen 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 adminRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_ADMIN } });
|
||||||
const employeePermIds = allPermissions
|
const gdprRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_DSGVO } });
|
||||||
.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-
|
||||||
|
|||||||
+13
-191
@@ -1,205 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* 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';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { synchronisiereRechteUndRollen } from '../src/services/rollen-sync.service.js';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
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: Datenschutz-Verwaltung plus LESENDEN Zugriff aufs Audit-Protokoll.
|
|
||||||
//
|
|
||||||
// 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. Wer prueft und wer eingreift,
|
|
||||||
// sind zwei Rollen (Pentest R186, im Anschluss an R184-02: falsche Domaene,
|
|
||||||
// zu breit gebuendelt).
|
|
||||||
const gdprPermIds = allPermissions
|
|
||||||
.filter(
|
|
||||||
(p) =>
|
|
||||||
p.resource === 'gdpr' ||
|
|
||||||
(p.resource === 'audit' && (p.action === 'read' || p.action === 'export')),
|
|
||||||
)
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
// Audit-Betrieb: die eingreifenden Rechte am Protokoll - versiegeln,
|
|
||||||
// neu berechnen, aufraeumen, Aufbewahrung aendern. Plus `audit:read`, denn
|
|
||||||
// siegeln zu duerfen ohne das Ergebnis sehen zu koennen waere unbrauchbar.
|
|
||||||
//
|
|
||||||
// Eigene versteckte Rolle statt einem Anhaengsel an Admin: So weitet sich
|
|
||||||
// durch die Umstellung fuer KEIN bestehendes Konto etwas aus. Wer eingreifen
|
|
||||||
// koennen soll, bekommt es ausdruecklich - und diese Zuweisung ist selbst
|
|
||||||
// ein sichtbarer Vorgang.
|
|
||||||
const auditBetriebPermIds = allPermissions
|
|
||||||
.filter(
|
|
||||||
(p) => p.resource === 'audit' && (p.action === 'read' || p.action === 'admin'),
|
|
||||||
)
|
|
||||||
.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 Fachfunktionen (ohne Audit & Datenschutz – dafür die separaten Rollen DSGVO und Audit-Betrieb)', permIds: adminPermIds },
|
|
||||||
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
|
|
||||||
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung', permIds: gdprPermIds },
|
|
||||||
{ name: 'Audit-Betrieb', description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern', permIds: auditBetriebPermIds },
|
|
||||||
{ 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,293 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { logChange } from '../services/audit.service.js';
|
|||||||
import { AUDIT_OPS_ROLLE } from '../services/user.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 } 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
|
||||||
@@ -86,7 +87,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(),
|
||||||
@@ -94,10 +100,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,22 +170,56 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Der Audit-Betrieb-Haken vergibt die EINGREIFENDEN Rechte am Protokoll
|
// Die drei Haken vergeben versteckte Rollen: DSGVO, Developer und
|
||||||
// (versiegeln, neu berechnen, aufraeumen, Aufbewahrung). Ihn zu setzen ist
|
// Audit-Betrieb. Sie sind kein gewoehnliches Benutzerfeld - "Developer"
|
||||||
// kein gewoehnliches Benutzer-Update: Er entscheidet, wer die
|
// traegt ALLE Rechte, "Audit-Betrieb" entscheidet, wer die
|
||||||
// Beweisgrundlage ersetzen darf.
|
// Beweisgrundlage ersetzen darf.
|
||||||
//
|
//
|
||||||
// Eine eigene Rechte-Huerde davorzusetzen scheitert am Henne-Ei-Problem -
|
// Bis 09/2026 stand hier, eine Rechte-Huerde scheitere am
|
||||||
// nach der Aufteilung haelt zunaechst niemand `audit:admin`, und dann
|
// Henne-Ei-Problem: Nach der Aufteilung haelt zunaechst niemand
|
||||||
// koennte ihn auch niemand vergeben. Stattdessen wird die Vergabe LAUT:
|
// `audit:admin`, also koennte ihn auch niemand vergeben. Das Argument war
|
||||||
// CRITICAL im Protokoll und zusaetzlich in den Alarmkanal, so wie beim
|
// richtig, aber der Schluss zu weit. Ohne Huerde genuegte `users:create`,
|
||||||
// Dienstkonto-Kennzeichen (R184-01).
|
// 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 =
|
const setztAuditBetrieb =
|
||||||
data.hasAuditOpsAccess !== undefined &&
|
data.hasAuditOpsAccess !== undefined &&
|
||||||
before !== null &&
|
before !== null &&
|
||||||
data.hasAuditOpsAccess !== (before as any).hasAuditOpsAccess;
|
data.hasAuditOpsAccess !== (before as any).hasAuditOpsAccess;
|
||||||
const aktiviertAuditBetrieb = data.hasAuditOpsAccess === true;
|
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
|
||||||
@@ -246,7 +283,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) {
|
||||||
@@ -311,6 +353,45 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
if (setztAuditBetrieb) {
|
||||||
const ctx = contextFromRequest(req);
|
const ctx = contextFromRequest(req);
|
||||||
emitSecurityEvent({
|
emitSecurityEvent({
|
||||||
@@ -342,10 +423,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +480,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;
|
||||||
@@ -451,6 +537,45 @@ export async function deleteUser(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
|
*/
|
||||||
|
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();
|
||||||
@@ -482,43 +607,138 @@ 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;
|
||||||
|
}
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 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);
|
||||||
@@ -528,12 +748,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { SYSTEMROLLEN } from '../config/rechte-katalog.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prueft beim Start, ob die gesetzlich gebundenen Rechte ueberhaupt jemand
|
* Prueft beim Start, ob die gesetzlich gebundenen Rechte ueberhaupt jemand
|
||||||
@@ -25,6 +26,11 @@ const PFLICHTRECHTE: Array<{ resource: string; action: string; wofuer: string }>
|
|||||||
{ resource: 'gdpr', action: 'export', wofuer: 'Auskunft nach Art. 15 DSGVO' },
|
{ resource: 'gdpr', action: 'export', wofuer: 'Auskunft nach Art. 15 DSGVO' },
|
||||||
{ resource: 'gdpr', action: 'delete', wofuer: 'Löschung nach Art. 17 DSGVO' },
|
{ resource: 'gdpr', action: 'delete', wofuer: 'Löschung nach Art. 17 DSGVO' },
|
||||||
{ resource: 'audit', action: 'read', wofuer: 'Prüfung des Audit-Protokolls' },
|
{ 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> {
|
export async function pruefePflichtrechte(): Promise<void> {
|
||||||
@@ -53,6 +59,37 @@ export async function pruefePflichtrechte(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
if (fehlend.length === 0) return;
|
||||||
|
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -68,6 +105,13 @@ export async function pruefePflichtrechte(): Promise<void> {
|
|||||||
' den Haken „DSGVO-Zugriff" setzen (Audit-Protokoll lesen und\n' +
|
' den Haken „DSGVO-Zugriff" setzen (Audit-Protokoll lesen und\n' +
|
||||||
' Datenschutz-Verwaltung). Für Eingriffe am Protokoll – versiegeln,\n' +
|
' Datenschutz-Verwaltung). Für Eingriffe am Protokoll – versiegeln,\n' +
|
||||||
' aufräumen, Aufbewahrung ändern – zusätzlich „Audit-Betrieb".\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',
|
'========================================================================\n',
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
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 Error {
|
||||||
|
constructor(public readonly fehlend: string[]) {
|
||||||
|
super(
|
||||||
|
'Sie können nur Rechte vergeben, die Sie selbst besitzen. ' +
|
||||||
|
`Nicht vergeben werden können: ${fehlend.join(', ')}`,
|
||||||
|
);
|
||||||
|
this.name = 'RechteEskalationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Vorgang zielte auf eine Systemrolle, die von der Anwendung gepflegt wird. */
|
||||||
|
export class RollenSperrError extends Error {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = 'RollenSperrError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 gefunden = await prisma.permission.findMany({ where: { id: { in: ids } } });
|
||||||
|
if (gefunden.length !== new Set(ids).size) {
|
||||||
|
const bekannt = new Set(gefunden.map((p) => p.id));
|
||||||
|
const unbekannt = [...new Set(ids)].filter((id) => !bekannt.has(id));
|
||||||
|
throw new Error(`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: {
|
||||||
|
hasDeveloperAccess?: boolean;
|
||||||
|
hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean;
|
||||||
|
}): 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)];
|
||||||
|
}
|
||||||
@@ -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,16 @@
|
|||||||
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,
|
||||||
|
effektiveRechte,
|
||||||
|
meldeTraegerAb,
|
||||||
|
RollenSperrError,
|
||||||
|
} from './rechte.service.js';
|
||||||
|
import { istSystemrollenName, RECHTE_KATALOG } from '../config/rechte-katalog.js';
|
||||||
|
|
||||||
export interface UserFilters {
|
export interface UserFilters {
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -155,7 +165,17 @@ export async function createUser(data: {
|
|||||||
whatsappNumber?: string;
|
whatsappNumber?: string;
|
||||||
telegramUsername?: string;
|
telegramUsername?: string;
|
||||||
signalNumber?: string;
|
signalNumber?: string;
|
||||||
}) {
|
},
|
||||||
|
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.
|
||||||
|
await pruefeTeilmenge(handelnderId, [
|
||||||
|
...(await rechteVonRollen(data.roleIds)),
|
||||||
|
...rechteDerHaken(data),
|
||||||
|
]);
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(data.password, 10);
|
const hashedPassword = await bcrypt.hash(data.password, 10);
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
@@ -220,10 +240,31 @@ export async function updateUser(
|
|||||||
whatsappNumber?: string;
|
whatsappNumber?: string;
|
||||||
telegramUsername?: string;
|
telegramUsername?: string;
|
||||||
signalNumber?: string;
|
signalNumber?: string;
|
||||||
}
|
},
|
||||||
|
handelnderId: number,
|
||||||
) {
|
) {
|
||||||
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess, ...userData } = data;
|
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess, ...userData } = data;
|
||||||
|
|
||||||
|
// 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(roleIds)) {
|
||||||
|
if (!bisher.has(r)) zuwachs.add(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of rechteDerHaken({ hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess })) {
|
||||||
|
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;
|
||||||
const rolesAreBeingChanged = roleIds !== undefined;
|
const rolesAreBeingChanged = roleIds !== undefined;
|
||||||
@@ -622,17 +663,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: {
|
||||||
@@ -643,15 +709,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 },
|
||||||
@@ -661,28 +762,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 };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -845,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
|
||||||
|
|||||||
@@ -97,6 +97,83 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
- **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)
|
- [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
|
- Meldung der Pentesterin: `GET /api/users/:id` gibt bei nicht-numerischer
|
||||||
ID **500** statt 400 (`/api/users/permissions` trifft `/:id`). Ihr Patch
|
ID **500** statt 400 (`/api/users/permissions` trifft `/:id`). Ihr Patch
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user