Die DSGVO-Rolle trug audit:* komplett, also auch audit:admin. Ein DSGVO-Beauftragter konnte damit seal-backlog, rehash und cleanup - seine eigene Beweisgrundlage ersetzen. Wer das Protokoll beaufsichtigt, darf es nicht umschreiben koennen. Dieselbe Klasse wie R184-02: falsche Domaene, zu breit gebuendelt. Der naheliegende Fix waere falsch gewesen. audit:admin einfach aus der DSGVO-Rolle zu streichen haette es heimatlos gemacht: Die Admin-Rolle ist ausdruecklich ohne audit/gdpr gebaut, einzige verbleibende Quelle waere der Entwicklerzugriff - der alles gibt. Prod versiegeln haette dann Vollzugriff vorausgesetzt. Deshalb eine eigene versteckte Rolle "Audit-Betrieb" (audit:read + audit:admin), zugewiesen ueber eine Checkbox wie DSGVO/Entwickler. DSGVO behaelt audit:read + audit:export + gdpr:*. Fuer kein bestehendes Konto weitet sich etwas aus; es wird enger, und wer eingreifen koennen soll, bekommt es ausdruecklich. Keine zusaetzliche Rechte-Huerde davor, weil das am Henne-Ei-Problem scheitert: Nach der Aufteilung haelt zunaechst niemand audit:admin, koennte ihn also auch niemand vergeben. Stattdessen wird die Vergabe laut - CRITICAL im Protokoll und PERMISSION_CHANGED/CRITICAL im Alarmkanal, samt Kennzeichen, ob sich jemand den Haken selbst gesetzt hat. Nebenbei geschlossen: setUserGdprAccess() legte die DSGVO-Rolle im Notfallpfad mit audit:* komplett an - eine zweite Liste, die dasselbe bedeuten sollte und die Buendelung stillschweigend zurueckgebracht haette. ACHTUNG beim Deploy: Bestehende DSGVO-Konten verlieren audit:admin. Wer Prod versiegeln will, muss sich vorher "Audit-Betrieb" ankreuzen. 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 Funktionen', 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();
|
||
});
|