Beide Findings der Pentesterin waren berechtigt. Ihre Patches liessen sich nicht anwenden (Basis791711c, seitdem 42 Commits, sync-roles.ts kollidiert), und an zwei Stellen greifen sie zu kurz. R188 - ungueltige IDs im Pfad ----------------------------- Gemeldet: GET /api/users/:id gibt bei nicht-numerischer ID 500 statt 400. Ihr Fix schliesst nebenbei mehr, als sie beansprucht: parseInt('12abc') ergibt 12, also lieferte /api/users/12abc bisher Benutzer 12 aus. Es waren aber 181 ungepruefte Stellen in 19 Controllern, nicht eine. 181 Einzel-Guards waeren genau der Fehler aus R186-01 gewesen - drei Filterlisten, die dasselbe bedeuten sollten und auseinanderliefen. Stattdessen router.param(), an einer Stelle fuer alle 33 Router registriert, ueber einen mounte()-Helfer, der Pruefung und Einhaengen zusammenbindet. Antwort ist 404, nicht 400: Ein Pfadsegment, das keine ID sein kann, benennt keine Ressource. Der bestehende Praezedenzfall in provider.controller.ts (Pentest Mai 2026) hatte es genauso entschieden. Dabei eine aeltere Heuristik abgeloest (Pentest Runde 7). Ihr eigener Kommentar nannte den Grund fuer sie - "app.param() greift nicht auf in Sub-Router gemounteten Routes" - und genau das loest mounte(). Sie war zu eng (/users/abc ging durch und endete als 500) und zu weit (ein Einstellungs-Schluessel 12abc unter :key wurde geblockt, obwohl das keine ID ist), und sie antwortete 400, wo jetzt 404 steht. R189-01 - DSGVO-Rechte ohne Traeger ------------------------------------ Gemeldet: gdpr:* und audit:read/export haengen an DSGVO und Developer, die Admin-Rolle hat sie nicht, und nach einem frischen Seed war DSGVO keinem Konto zugewiesen. Auskunft nach Art. 15 und Loeschung nach Art. 17 konnte niemand ausfuehren. Seed weist admin@admin.com jetzt zusaetzlich die DSGVO-Rolle zu; die Admin-Rolle selbst bleibt ohne diese Rechte, die Trennung aus R186 bleibt also erhalten. Label ehrlich gemacht. Beim Pruefen ihres Patches ein eigener Fund: seed.ts vergab an die DSGVO-Rolle weiterhin audit:* komplett, inklusive audit:admin - die Buendelung, diefc6f39eaufgeloest hat. Ich hatte damals zwei Listen gefunden und die dritte uebersehen. Gerettet hat es nur die Reihenfolge im Containerstart; ein einzelnes `npm run db:seed` brachte sie zurueck. Der Seed hilft nur bei Neuinstallation (update: {}). Deshalb zusaetzlich eine Wache beim Start: Gibt es fuer gdpr:export, gdpr:delete oder audit:read kein aktives Konto, steht das mit Handlungsanweisung im Log - Erkennung der ABWESENHEIT einer Faehigkeit, wie beim Heartbeat. Bewusst nur melden, nicht automatisch vergeben. Getestet ueber HTTP gegen eine Wegwerf-Instanz: alle ID-Varianten quer ueber sechs Controller, Nicht-ID-Parameter unveraendert, frischer Seed, Wache mit und ohne vergebene Rechte. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
208 lines
8.1 KiB
TypeScript
208 lines
8.1 KiB
TypeScript
/**
|
||
* Idempotenter Permissions+Rollen-Sync für den Container-Start.
|
||
*
|
||
* Hintergrund: seed.ts läuft nur auf leeren DBs (USER_COUNT=0). Wer das
|
||
* System schon installiert hat, bekommt nachträglich hinzugefügte
|
||
* Permissions oder neue Rollenzuordnungen NICHT — die DSGVO-Rolle kann
|
||
* dann z.B. ohne audit:read landen, obwohl Settings.tsx das voraussetzt.
|
||
*
|
||
* Dieses Skript synchronisiert ausschließlich:
|
||
* - Permission-Katalog (resource/action-Paare aus dem Code)
|
||
* - Roll-Zuordnungen (Admin, Developer, DSGVO, Mitarbeiter,
|
||
* Mitarbeiter (Nur-Lesen), Kunde)
|
||
*
|
||
* KEINE Stammdaten, KEINE User, KEINE Verträge — das Skript ist auf
|
||
* laufenden Prod-DBs sicher.
|
||
*/
|
||
|
||
import { PrismaClient } from '@prisma/client';
|
||
|
||
const prisma = new PrismaClient();
|
||
|
||
const RESOURCE_PERMISSIONS: Record<string, string[]> = {
|
||
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) => {
|
||
console.error('[sync-roles] Fehler:', e);
|
||
process.exit(1);
|
||
})
|
||
.finally(async () => {
|
||
await prisma.$disconnect();
|
||
});
|