Compare commits
10
Commits
23505afc05
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cca242119b | ||
|
|
3a50a40ad5 | ||
|
|
e76b4ace88 | ||
|
|
ed676ca4eb | ||
|
|
eb0580ac54 | ||
|
|
2ca6ed2f70 | ||
|
|
df442bb1a0 | ||
|
|
31c4c209e4 | ||
|
|
fc6f39eba0 | ||
|
|
909e523634 |
@@ -398,6 +398,27 @@ lässt das Siegel aus.
|
|||||||
| Muss ich ihn irgendwo eintragen außer in der `.env`? | Nein. Einmal setzen, Backup anlegen, fertig. |
|
| Muss ich ihn irgendwo eintragen außer in der `.env`? | Nein. Einmal setzen, Backup anlegen, fertig. |
|
||||||
| Verlangsamt das etwas? | Nein, spürbar nicht. |
|
| Verlangsamt das etwas? | Nein, spürbar nicht. |
|
||||||
|
|
||||||
|
**Wichtig bei einer bestehenden Installation: den Altbestand versiegeln**
|
||||||
|
|
||||||
|
Das Siegel gilt nur für Einträge, die **ab** dem Setzen des Schlüssels
|
||||||
|
geschrieben werden. Alles, was vorher im Protokoll steht, bleibt ungeschützt –
|
||||||
|
Änderungen daran wären nicht erkennbar. Die Integritätsprüfung sagt das auch:
|
||||||
|
|
||||||
|
Hinweis: Der Altbestand ist nicht versiegelt – Änderungen daran wären
|
||||||
|
nicht erkennbar. Behebbar mit POST /api/audit-logs/seal-backlog
|
||||||
|
{"confirm":"SEAL"}.
|
||||||
|
|
||||||
|
Das ist ein **einmaliger** Schritt: Er zieht ein Siegel über den vorhandenen
|
||||||
|
Bestand, sodass spätere Änderungen daran auffallen. Der Ablauf – wer es darf
|
||||||
|
(Haken **„Audit-Betrieb"** in der Benutzerverwaltung), was vorher zu prüfen ist
|
||||||
|
und wie man das Ergebnis gegenprüft – steht Schritt für Schritt unter
|
||||||
|
**[Den Altbestand versiegeln](tools/audit-notary/README.md#den-altbestand-versiegeln-einmalig-im-crm)**.
|
||||||
|
Die Anleitung liegt beim Gegenbuch, gilt aber **auch ohne** – es ist ein
|
||||||
|
Vorgang im CRM.
|
||||||
|
|
||||||
|
Den Zustand siehst du jederzeit unter **Einstellungen → Audit-Protokoll**, ganz
|
||||||
|
oben.
|
||||||
|
|
||||||
**Schlüssel wechseln (`AUDIT_HMAC_KEY_OLD`)**
|
**Schlüssel wechseln (`AUDIT_HMAC_KEY_OLD`)**
|
||||||
|
|
||||||
Möchtest du den Schlüssel austauschen – etwa weil du vermutest, dass er in
|
Möchtest du den Schlüssel austauschen – etwa weil du vermutest, dass er in
|
||||||
|
|||||||
+29
-5
@@ -106,7 +106,7 @@ async function main() {
|
|||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
name: 'Admin',
|
name: 'Admin',
|
||||||
description: 'Voller Zugriff auf alle Funktionen',
|
description: 'Voller Zugriff auf alle Fachfunktionen (ohne Audit & Datenschutz – dafür die separaten Rollen DSGVO und Audit-Betrieb)',
|
||||||
permissions: {
|
permissions: {
|
||||||
create: adminPermissions.map((p) => ({ permissionId: p.id })),
|
create: adminPermissions.map((p) => ({ permissionId: p.id })),
|
||||||
},
|
},
|
||||||
@@ -129,16 +129,29 @@ async function main() {
|
|||||||
});
|
});
|
||||||
await syncRolePermissions(developerRole.id, developerPermissions.map((p) => p.id));
|
await syncRolePermissions(developerRole.id, developerPermissions.map((p) => p.id));
|
||||||
|
|
||||||
// DSGVO - audit and gdpr permissions (hidden role, controlled via hasGdprAccess)
|
// DSGVO - Datenschutz-Verwaltung plus LESENDER Zugriff aufs Audit-Protokoll.
|
||||||
|
//
|
||||||
|
// Bewusst OHNE `audit:admin`: Wer das Protokoll beaufsichtigt, darf seine
|
||||||
|
// eigene Beweisgrundlage nicht ersetzen koennen (Pentest R186). Die
|
||||||
|
// eingreifenden Rechte liegen in der Rolle `Audit-Betrieb`.
|
||||||
|
//
|
||||||
|
// Diese Liste stand hier bis 09/2026 noch auf `audit:*` komplett und war
|
||||||
|
// damit die dritte Stelle, die denselben Rechtesatz beschrieb - neben
|
||||||
|
// `sync-roles.ts` und dem Notfallpfad in `user.service.ts`. Gerettet hat es
|
||||||
|
// 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(
|
const gdprPermissions = allPermissions.filter(
|
||||||
(p) => p.resource === 'audit' || p.resource === 'gdpr'
|
(p) =>
|
||||||
|
p.resource === 'gdpr' ||
|
||||||
|
(p.resource === 'audit' && (p.action === 'read' || p.action === 'export'))
|
||||||
);
|
);
|
||||||
const gdprRole = await prisma.role.upsert({
|
const gdprRole = await prisma.role.upsert({
|
||||||
where: { name: 'DSGVO' },
|
where: { name: 'DSGVO' },
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
name: 'DSGVO',
|
name: 'DSGVO',
|
||||||
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
|
description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung',
|
||||||
permissions: {
|
permissions: {
|
||||||
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
|
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
|
||||||
},
|
},
|
||||||
@@ -265,8 +278,19 @@ async function main() {
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
firstName: 'Admin',
|
firstName: 'Admin',
|
||||||
lastName: 'User',
|
lastName: 'User',
|
||||||
|
// Zusaetzlich die DSGVO-Rolle (Pentest R189-01).
|
||||||
|
//
|
||||||
|
// Ohne sie kann nach einem frischen Seed NIEMAND eine Auskunft nach
|
||||||
|
// Art. 15 oder eine Loeschung nach Art. 17 ausfuehren - die Rechte
|
||||||
|
// haengen an DSGVO und Developer, und beide waren keinem Konto
|
||||||
|
// zugewiesen. Ein Ausfall mit Fristwirkung, ausgeloest durch nichts
|
||||||
|
// weiter als eine Neuinstallation.
|
||||||
|
//
|
||||||
|
// Die Trennung bleibt: Die Admin-ROLLE bekommt diese Rechte weiterhin
|
||||||
|
// nicht. Nur dieses eine Bootstrap-Konto traegt beide, damit ueberhaupt
|
||||||
|
// jemand handlungsfaehig ist.
|
||||||
roles: {
|
roles: {
|
||||||
create: [{ roleId: adminRole.id }],
|
create: [{ roleId: adminRole.id }, { roleId: gdprRole.id }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -93,9 +93,34 @@ async function main() {
|
|||||||
// Developer: alles
|
// Developer: alles
|
||||||
const developerPermIds = allPermissions.map((p) => p.id);
|
const developerPermIds = allPermissions.map((p) => p.id);
|
||||||
|
|
||||||
// DSGVO: audit + gdpr komplett
|
// 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
|
const gdprPermIds = allPermissions
|
||||||
.filter((p) => p.resource === 'audit' || p.resource === 'gdpr')
|
.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);
|
.map((p) => p.id);
|
||||||
|
|
||||||
// Gegenbuch: NUR audit:read.
|
// Gegenbuch: NUR audit:read.
|
||||||
@@ -150,9 +175,10 @@ async function main() {
|
|||||||
.map((p) => p.id);
|
.map((p) => p.id);
|
||||||
|
|
||||||
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
|
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
|
||||||
{ name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds },
|
{ 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: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
|
||||||
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung', permIds: gdprPermIds },
|
{ 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', description: 'Kann Kunden und Verträge verwalten', permIds: employeePermIds },
|
||||||
{ name: 'Mitarbeiter (Nur-Lesen)', description: 'Kann nur lesen, keine Änderungen', permIds: readOnlyPermIds },
|
{ 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: 'Gegenbuch', description: 'Darf das Audit-Protokoll nur lesen und prüfen – sonst nichts', permIds: gegenbuchPermIds },
|
||||||
|
|||||||
@@ -262,6 +262,10 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
|||||||
? ' ⚠ Bestandssiegel ENTFERNT: Es liegen versiegelte Blattwerte vor, aber kein gültiger ' +
|
? ' ⚠ Bestandssiegel ENTFERNT: Es liegen versiegelte Blattwerte vor, aber kein gültiger ' +
|
||||||
'Siegel-Marker mehr. Der Marker wurde gelöscht oder unbrauchbar gemacht – Änderungen am ' +
|
'Siegel-Marker mehr. Der Marker wurde gelöscht oder unbrauchbar gemacht – Änderungen am ' +
|
||||||
'Altbestand wären dadurch wieder unsichtbar. Das ist KEIN Normalzustand.'
|
'Altbestand wären dadurch wieder unsichtbar. Das ist KEIN Normalzustand.'
|
||||||
|
: result.backlogSealStatus === 'leer'
|
||||||
|
? ' Hinweis: Es besteht ein Bestandssiegel, das aber NICHTS umschließt – ' +
|
||||||
|
'zum Zeitpunkt des Siegelns gab es keine unsignierten Alteinträge. Es sichert ' +
|
||||||
|
'also nichts ab. Das ist kein Fehler, aber auch keine Zusage.'
|
||||||
: result.backlogSealStatus === 'nicht_noetig'
|
: result.backlogSealStatus === 'nicht_noetig'
|
||||||
? ' Ein Bestandssiegel wird hier nicht gebraucht: Es gibt keine unsignierten Alteinträge.'
|
? ' Ein Bestandssiegel wird hier nicht gebraucht: Es gibt keine unsignierten Alteinträge.'
|
||||||
: result.backlogSealStatus === 'kein_siegel'
|
: result.backlogSealStatus === 'kein_siegel'
|
||||||
@@ -269,6 +273,49 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
|||||||
'Behebbar mit POST /api/audit-logs/seal-backlog {"confirm":"SEAL"}.'
|
'Behebbar mit POST /api/audit-logs/seal-backlog {"confirm":"SEAL"}.'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// Eine Neuberechnung der Kette gehoert IMMER erwaehnt – auch und gerade,
|
||||||
|
// wenn sonst alles grün ist. Nach einem Rehash ist die Kette
|
||||||
|
// zwangslaeufig stimmig, auch ueber Loeschungen hinweg. „Lueckenlos
|
||||||
|
// verkettet“ heisst dann nur noch „seit dem Rehash“, und wer das nicht
|
||||||
|
// mitliest, nimmt eine Entwarnung mit, die es so nicht gibt.
|
||||||
|
const rehashText = (() => {
|
||||||
|
if (result.rehashes.length === 0) return '';
|
||||||
|
const letzter = result.rehashes[result.rehashes.length - 1];
|
||||||
|
const datum = new Date(letzter.zeitpunkt).toLocaleString('de-DE', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
const wieOft =
|
||||||
|
result.rehashes.length === 1
|
||||||
|
? 'Die Kette wurde einmal neu berechnet'
|
||||||
|
: `Die Kette wurde ${result.rehashes.length}× neu berechnet, zuletzt`;
|
||||||
|
// Nur benennen, was es zu benennen gibt. Bei sauberem Vorzustand ist die
|
||||||
|
// Auskunft „nichts uebertuencht“ selbst eine nuetzliche Information –
|
||||||
|
// ein Rehash ueber einer unbeanstandeten Kette wiegt anders als einer
|
||||||
|
// ueber 700 Luecken.
|
||||||
|
const vb = letzter.vorbefund;
|
||||||
|
const uebertuencht = !vb
|
||||||
|
? ' Der Zustand vor dieser Neuberechnung ist nicht mehr feststellbar.'
|
||||||
|
: vb.manipuliert === 0 && vb.luecken === 0
|
||||||
|
? ' Die Kette war unmittelbar davor unbeanstandet – es wurde nichts überdeckt.'
|
||||||
|
: ' Unmittelbar davor: ' +
|
||||||
|
`${vb.manipuliert} beanstandete${vb.manipuliert === 1 ? 'r Eintrag' : ' Einträge'}` +
|
||||||
|
` und ${vb.luecken} Lücke${vb.luecken === 1 ? '' : 'n'}. ` +
|
||||||
|
'Diese Spuren sind seitdem nicht mehr in der Kette sichtbar, sondern nur noch im ' +
|
||||||
|
`Vorbefund des Rehash-Eintrags (id ${letzter.id}).`;
|
||||||
|
const ohneSignatur = result.rehashes.some((r) => !r.signiert)
|
||||||
|
? ' Achtung: Mindestens ein Rehash-Eintrag trägt keine gültige Signatur.'
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
` ⚠ ${wieOft} am ${datum}` +
|
||||||
|
(letzter.neuBerechnet !== null ? ` (${letzter.neuBerechnet} Einträge)` : '') +
|
||||||
|
'. Ein Rehash verknüpft alle Einträge neu – die Aussage dieser Prüfung ' +
|
||||||
|
'reicht deshalb nur bis dorthin zurück, nicht weiter.' +
|
||||||
|
uebertuencht +
|
||||||
|
ohneSignatur
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
// Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es
|
// Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es
|
||||||
// ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03).
|
// ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03).
|
||||||
const mehrfach =
|
const mehrfach =
|
||||||
@@ -299,7 +346,10 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
|||||||
: beglaubigt > 0
|
: beglaubigt > 0
|
||||||
? 'Alle Einträge sind unverändert. Seit dem Bestandssiegel ist keine neue Lücke ' +
|
? 'Alle Einträge sind unverändert. Seit dem Bestandssiegel ist keine neue Lücke ' +
|
||||||
'entstanden.'
|
'entstanden.'
|
||||||
: 'Alle Einträge sind unverändert und lückenlos verkettet.';
|
: result.rehashes.length > 0
|
||||||
|
? 'Alle Einträge sind unverändert und lückenlos verkettet – allerdings erst ' +
|
||||||
|
'seit der letzten Neuberechnung.'
|
||||||
|
: 'Alle Einträge sind unverändert und lückenlos verkettet.';
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -315,8 +365,10 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
|||||||
unexplainedGaps: result.unexplainedGaps,
|
unexplainedGaps: result.unexplainedGaps,
|
||||||
// Alt-Lücken, die das Bestandssiegel als bereits vorhanden beglaubigt.
|
// Alt-Lücken, die das Bestandssiegel als bereits vorhanden beglaubigt.
|
||||||
attestedGaps: result.attestedGaps,
|
attestedGaps: result.attestedGaps,
|
||||||
|
// Protokollierte Neuberechnungen – begrenzen die Reichweite der Aussage.
|
||||||
|
rehashes: result.rehashes,
|
||||||
tampered: tampered > 0,
|
tampered: tampered > 0,
|
||||||
message: message + beglaubigtText + keinSchluessel + siegel + mehrfach,
|
message: message + beglaubigtText + rehashText + keinSchluessel + siegel + mehrfach,
|
||||||
unverifiableEntries: result.unverifiableEntries,
|
unverifiableEntries: result.unverifiableEntries,
|
||||||
// Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
|
// Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
|
||||||
backlogSealStatus: result.backlogSealStatus,
|
backlogSealStatus: result.backlogSealStatus,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs';
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as userService from '../services/user.service.js';
|
import * as userService from '../services/user.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
import { AUDIT_OPS_ROLLE } from '../services/user.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||||
import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
|
import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
|
||||||
@@ -162,9 +163,26 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
...beforeUser,
|
...beforeUser,
|
||||||
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
|
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
|
||||||
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
|
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
|
||||||
|
hasAuditOpsAccess: beforeUser.roles.some((ur) => ur.role.name === AUDIT_OPS_ROLLE),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Der Audit-Betrieb-Haken vergibt die EINGREIFENDEN Rechte am Protokoll
|
||||||
|
// (versiegeln, neu berechnen, aufraeumen, Aufbewahrung). Ihn zu setzen ist
|
||||||
|
// kein gewoehnliches Benutzer-Update: Er entscheidet, wer die
|
||||||
|
// Beweisgrundlage ersetzen darf.
|
||||||
|
//
|
||||||
|
// Eine eigene Rechte-Huerde davorzusetzen scheitert am Henne-Ei-Problem -
|
||||||
|
// nach der Aufteilung haelt zunaechst niemand `audit:admin`, und dann
|
||||||
|
// koennte ihn auch niemand vergeben. Stattdessen wird die Vergabe LAUT:
|
||||||
|
// CRITICAL im Protokoll und zusaetzlich in den Alarmkanal, so wie beim
|
||||||
|
// Dienstkonto-Kennzeichen (R184-01).
|
||||||
|
const setztAuditBetrieb =
|
||||||
|
data.hasAuditOpsAccess !== undefined &&
|
||||||
|
before !== null &&
|
||||||
|
data.hasAuditOpsAccess !== (before as any).hasAuditOpsAccess;
|
||||||
|
const aktiviertAuditBetrieb = data.hasAuditOpsAccess === true;
|
||||||
|
|
||||||
// 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
|
||||||
@@ -236,6 +254,7 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
const fieldLabels: Record<string, string> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
||||||
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
||||||
|
hasAuditOpsAccess: 'Audit-Betrieb (versiegeln, aufräumen, Aufbewahrung)',
|
||||||
isServiceAccount: 'Dienstkonto (Anmeldungen als Routine)',
|
isServiceAccount: 'Dienstkonto (Anmeldungen als Routine)',
|
||||||
};
|
};
|
||||||
for (const [key, newVal] of Object.entries(data)) {
|
for (const [key, newVal] of Object.entries(data)) {
|
||||||
@@ -265,7 +284,7 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
: `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
|
: `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
|
||||||
// Die Aenderung dieses Kennzeichens wird wie ihre Wirkung eingestuft:
|
// Die Aenderung dieses Kennzeichens wird wie ihre Wirkung eingestuft:
|
||||||
// Sie beeinflusst, wie kuenftige Anmeldungen bewertet werden.
|
// Sie beeinflusst, wie kuenftige Anmeldungen bewertet werden.
|
||||||
sensitivity: setztDienstkonto ? 'CRITICAL' : undefined,
|
sensitivity: setztDienstkonto || setztAuditBetrieb ? 'CRITICAL' : undefined,
|
||||||
details: Object.keys(changes).length > 0 ? changes : undefined,
|
details: Object.keys(changes).length > 0 ? changes : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -291,6 +310,28 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
details: { betroffenesKonto: user.email, aktiviert: aktiviertDienstkonto },
|
details: { betroffenesKonto: user.email, aktiviert: aktiviertDienstkonto },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (setztAuditBetrieb) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PERMISSION_CHANGED',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
message: aktiviertAuditBetrieb
|
||||||
|
? `Konto ${user.email} darf ab jetzt am Audit-Protokoll EINGREIFEN: versiegeln, ` +
|
||||||
|
'neu berechnen, aufräumen, Aufbewahrung ändern. Damit kann es die Grundlage ' +
|
||||||
|
'ersetzen, gegen die Manipulation nachgewiesen wird.'
|
||||||
|
: `Konto ${user.email} darf nicht mehr am Audit-Protokoll eingreifen.`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: {
|
||||||
|
betroffenesKonto: user.email,
|
||||||
|
aktiviert: aktiviertAuditBetrieb,
|
||||||
|
selbstvergabe: user.id === req.user?.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'User',
|
req, action: 'UPDATE', resourceType: 'User',
|
||||||
|
|||||||
+65
-54
@@ -1,4 +1,4 @@
|
|||||||
import express from 'express';
|
import express, { Router } from 'express';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
@@ -69,9 +69,11 @@ import { startContractStatusScheduler } from './services/contractStatusScheduler
|
|||||||
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
|
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
|
||||||
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
||||||
import monitoringRoutes from './routes/monitoring.routes.js';
|
import monitoringRoutes from './routes/monitoring.routes.js';
|
||||||
|
import { registriereIdPruefung } from './middleware/routeIds.js';
|
||||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||||
import { auditMiddleware } from './middleware/audit.js';
|
import { auditMiddleware } from './middleware/audit.js';
|
||||||
import { starteHeartbeatMonitor } from './services/heartbeatMonitor.service.js';
|
import { starteHeartbeatMonitor } from './services/heartbeatMonitor.service.js';
|
||||||
|
import { pruefePflichtrechte } from './services/pflichtrechte.service.js';
|
||||||
import { authenticate } from './middleware/auth.js';
|
import { authenticate } from './middleware/auth.js';
|
||||||
|
|
||||||
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
||||||
@@ -326,69 +328,76 @@ app.use('/api', (_req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
|
// HIER STAND eine Pfad-Heuristik gegen abgeschnittene IDs (Pentest Runde 7,
|
||||||
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
|
// 2026-05-17): Sie blockte Segmente der Form `^\d+[a-zA-Z]+$` – also `6abc`,
|
||||||
// wurde – kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
|
// weil `parseInt('6abc')` die 6 ergibt und `/api/customers/6abc` still als
|
||||||
// Validierung. Pentest Runde 7 (2026-05-17), LOW.
|
// Kunde 6 gelesen wurde. Ihr eigener Kommentar nannte den Grund fuer die
|
||||||
|
// Heuristik: „`app.param()` greift nicht auf in Sub-Router gemounteten Routes".
|
||||||
//
|
//
|
||||||
// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb
|
// Genau das loest `mounte()` weiter unten – die Pruefung wird an jedem Router
|
||||||
// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$` –
|
// registriert, nicht am App-Objekt. Damit ist die Heuristik abgeloest, und
|
||||||
// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie
|
// zwar in beide Richtungen:
|
||||||
// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings
|
//
|
||||||
// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt
|
// – Sie war zu eng: `/api/users/abc` ging durch (keine Ziffer vorn) und
|
||||||
// nicht geblockt.
|
// endete als 500. Die Parameter-Pruefung kennt dagegen die tatsaechlichen
|
||||||
const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/;
|
// ID-Parameter und laesst nur kanonische Zahlen zu.
|
||||||
app.use('/api', (req, res, next) => {
|
// – Sie war zu weit: Ein Einstellungs-Schluessel `12abc` unter
|
||||||
for (const seg of req.path.split('/')) {
|
// `/api/settings/:key` wurde geblockt, obwohl `:key` gar keine ID ist.
|
||||||
if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) {
|
//
|
||||||
res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' });
|
// Und sie antwortete 400, wo die Parameter-Pruefung 404 gibt – zwei Antworten
|
||||||
return;
|
// fuer dieselbe Eingabeklasse. Siehe middleware/routeIds.ts.
|
||||||
}
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148).
|
// Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148).
|
||||||
// Großzügige Obergrenze pro IP – ergänzt die feineren Limiter (Login etc.),
|
// Großzügige Obergrenze pro IP – ergänzt die feineren Limiter (Login etc.),
|
||||||
// die als erste greifen. Siehe middleware/rateLimit.ts für die Begründung.
|
// die als erste greifen. Siehe middleware/rateLimit.ts für die Begründung.
|
||||||
app.use('/api', apiBackstopRateLimiter);
|
app.use('/api', apiBackstopRateLimiter);
|
||||||
|
|
||||||
|
// Router einhängen – IMMER über `mounte`, nie über `app.use` direkt.
|
||||||
|
//
|
||||||
|
// `mounte` haengt die zentrale Pruefung numerischer Pfad-Parameter an (R188)
|
||||||
|
// und montiert danach. Wer hier kuenftig `app.use` schreibt, umgeht sie
|
||||||
|
// stillschweigend – deshalb steht die Pruefung im selben Handgriff wie das
|
||||||
|
// Einhaengen und nicht in einer zweiten Liste, die man vergessen kann.
|
||||||
|
const mounte = (pfad: string, router: Router): void => {
|
||||||
|
app.use(pfad, registriereIdPruefung(router));
|
||||||
|
};
|
||||||
|
|
||||||
// Öffentliche Routes (OHNE Authentifizierung)
|
// Öffentliche Routes (OHNE Authentifizierung)
|
||||||
app.use('/api/public/consent', consentPublicRoutes);
|
mounte('/api/public/consent', consentPublicRoutes);
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.use('/api/auth', authRoutes);
|
mounte('/api/auth', authRoutes);
|
||||||
app.use('/api/customers', customerRoutes);
|
mounte('/api/customers', customerRoutes);
|
||||||
app.use('/api/addresses', addressRoutes);
|
mounte('/api/addresses', addressRoutes);
|
||||||
app.use('/api/bank-cards', bankcardRoutes);
|
mounte('/api/bank-cards', bankcardRoutes);
|
||||||
app.use('/api/documents', documentRoutes);
|
mounte('/api/documents', documentRoutes);
|
||||||
app.use('/api/meters', meterRoutes);
|
mounte('/api/meters', meterRoutes);
|
||||||
app.use('/api/stressfrei-emails', stressfreiEmailRoutes);
|
mounte('/api/stressfrei-emails', stressfreiEmailRoutes);
|
||||||
app.use('/api/contracts', contractRoutes);
|
mounte('/api/contracts', contractRoutes);
|
||||||
app.use('/api/credit-notes', creditNoteRoutes);
|
mounte('/api/credit-notes', creditNoteRoutes);
|
||||||
app.use('/api/company-profile', companyProfileRoutes);
|
mounte('/api/company-profile', companyProfileRoutes);
|
||||||
app.use('/api/platforms', platformRoutes);
|
mounte('/api/platforms', platformRoutes);
|
||||||
app.use('/api/cancellation-periods', cancellationPeriodRoutes);
|
mounte('/api/cancellation-periods', cancellationPeriodRoutes);
|
||||||
app.use('/api/contract-durations', contractDurationRoutes);
|
mounte('/api/contract-durations', contractDurationRoutes);
|
||||||
app.use('/api/providers', providerRoutes);
|
mounte('/api/providers', providerRoutes);
|
||||||
app.use('/api/tariffs', tariffRoutes);
|
mounte('/api/tariffs', tariffRoutes);
|
||||||
app.use('/api/users', userRoutes);
|
mounte('/api/users', userRoutes);
|
||||||
app.use('/api/upload', uploadRoutes);
|
mounte('/api/upload', uploadRoutes);
|
||||||
app.use('/api/developer', developerRoutes);
|
mounte('/api/developer', developerRoutes);
|
||||||
app.use('/api/contract-categories', contractCategoryRoutes);
|
mounte('/api/contract-categories', contractCategoryRoutes);
|
||||||
app.use('/api', contractTaskRoutes);
|
mounte('/api', contractTaskRoutes);
|
||||||
app.use('/api/settings', appSettingRoutes);
|
mounte('/api/settings', appSettingRoutes);
|
||||||
app.use('/api/email-providers', emailProviderRoutes);
|
mounte('/api/email-providers', emailProviderRoutes);
|
||||||
app.use('/api', cachedEmailRoutes);
|
mounte('/api', cachedEmailRoutes);
|
||||||
app.use('/api/energy-details', invoiceRoutes);
|
mounte('/api/energy-details', invoiceRoutes);
|
||||||
app.use('/api', contractHistoryRoutes);
|
mounte('/api', contractHistoryRoutes);
|
||||||
app.use('/api/audit-logs', auditLogRoutes);
|
mounte('/api/audit-logs', auditLogRoutes);
|
||||||
app.use('/api/gdpr', gdprRoutes);
|
mounte('/api/gdpr', gdprRoutes);
|
||||||
app.use('/api/email-logs', emailLogRoutes);
|
mounte('/api/email-logs', emailLogRoutes);
|
||||||
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
mounte('/api/pdf-templates', pdfTemplateRoutes);
|
||||||
app.use('/api/birthdays', birthdayRoutes);
|
mounte('/api/birthdays', birthdayRoutes);
|
||||||
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
mounte('/api/factory-defaults', factoryDefaultsRoutes);
|
||||||
app.use('/api/monitoring', monitoringRoutes);
|
mounte('/api/monitoring', monitoringRoutes);
|
||||||
|
|
||||||
// Health check – BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
|
// Health check – BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
|
||||||
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
|
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
|
||||||
@@ -491,6 +500,8 @@ const LISTEN_ADDR = process.env.LISTEN_ADDR
|
|||||||
// Wachhund auf ausbleibende Dienstkonto-Anmeldungen (Pentest R182/R183):
|
// Wachhund auf ausbleibende Dienstkonto-Anmeldungen (Pentest R182/R183):
|
||||||
// Ein stillgelegtes Gegenbuch soll auffallen, nicht als Ruhe durchgehen.
|
// Ein stillgelegtes Gegenbuch soll auffallen, nicht als Ruhe durchgehen.
|
||||||
starteHeartbeatMonitor();
|
starteHeartbeatMonitor();
|
||||||
|
// Erkennt die ABWESENHEIT der DSGVO-/Audit-Faehigkeit (R189-01).
|
||||||
|
void pruefePflichtrechte();
|
||||||
|
|
||||||
app.listen(PORT as number, LISTEN_ADDR, () => {
|
app.listen(PORT as number, LISTEN_ADDR, () => {
|
||||||
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
|
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Request, Response, NextFunction, Router, RequestParamHandler } from 'express';
|
||||||
|
import { ApiResponse } from '../types/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numerische Pfad-Parameter zentral pruefen (Pentest R188).
|
||||||
|
*
|
||||||
|
* Ausgangslage: In den Controllern stand 181-mal `parseInt(req.params.<x>)`
|
||||||
|
* ohne Pruefung. Das hatte zwei Folgen, und die zweite ist die unangenehmere:
|
||||||
|
*
|
||||||
|
* 1. Ein nicht-numerisches Segment ergab `NaN`, Prisma lief auf und der
|
||||||
|
* Handler antwortete mit **500**. `GET /api/users/permissions` etwa
|
||||||
|
* trifft `/:id` und war damit ein Serverfehler statt „gibt es nicht".
|
||||||
|
*
|
||||||
|
* 2. `parseInt` liest so weit, wie es kann: `parseInt('12abc')` ist **12**.
|
||||||
|
* `GET /api/users/12abc` lieferte also Benutzer 12 aus. Eine schlampig
|
||||||
|
* geratene ID wurde stillschweigend zu einer gueltigen gemacht.
|
||||||
|
*
|
||||||
|
* Die naheliegende Antwort waere gewesen, an allen 181 Stellen einen Guard
|
||||||
|
* einzusetzen. Genau daran haben wir uns in dieser Reihe mehrfach die Finger
|
||||||
|
* verbrannt: Was an vielen Stellen gepflegt werden muss, laeuft auseinander
|
||||||
|
* (zuletzt R186-01, drei Filterlisten, die dasselbe bedeuten sollten). Deshalb
|
||||||
|
* eine Stelle statt 181 – und eine neue Route ist automatisch mit abgedeckt.
|
||||||
|
*
|
||||||
|
* Umgesetzt ueber `router.param()`: Express ruft den Callback, bevor der
|
||||||
|
* Handler laeuft, und nur fuer Routen, die den Parameter wirklich benutzen.
|
||||||
|
*
|
||||||
|
* **Antwort ist 404, nicht 400.** Ein Pfad-Segment, das keine ID sein kann,
|
||||||
|
* benennt keine Ressource – das ist „nicht gefunden", nicht „falsch gefragt".
|
||||||
|
* Der Codebestand hat es an der einzigen bereits abgesicherten Stelle
|
||||||
|
* (`provider.controller.ts`, Pentest Mai 2026) genauso entschieden. Nebenbei
|
||||||
|
* verraet eine einheitliche 404 einem Probierenden nicht, ob ein Endpunkt
|
||||||
|
* existiert und eine Zahl erwartet oder ob es den Pfad gar nicht gibt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameternamen, die eine Datenbank-ID bezeichnen – abgeglichen mit allen
|
||||||
|
* Routendateien. Bewusst NICHT dabei und deshalb unberuehrt: `consentType`,
|
||||||
|
* `filename`, `hash`, `key`, `localPart`, `name`, `tableName`.
|
||||||
|
*
|
||||||
|
* Die Namensregel „heisst `id` oder endet auf `Id`" gilt im gesamten Bestand;
|
||||||
|
* eine neue Route mit `:fooId` gehoert hier ergaenzt.
|
||||||
|
*/
|
||||||
|
const ID_PARAMETER = [
|
||||||
|
'id',
|
||||||
|
'contractId',
|
||||||
|
'contractMeterId',
|
||||||
|
'customerId',
|
||||||
|
'documentId',
|
||||||
|
'ecdId',
|
||||||
|
'emailId',
|
||||||
|
'entryId',
|
||||||
|
'invoiceId',
|
||||||
|
'meterId',
|
||||||
|
'phoneNumberId',
|
||||||
|
'providerId',
|
||||||
|
'readingId',
|
||||||
|
'referralId',
|
||||||
|
'representativeId',
|
||||||
|
'simCardId',
|
||||||
|
'subtaskId',
|
||||||
|
'taskId',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Obergrenze von MySQL INT – darueber gibt es keine Zeile, nur einen Fehler. */
|
||||||
|
const MAX_ID = 2147483647;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gueltig ist ausschliesslich eine kanonische positive Ganzzahl.
|
||||||
|
*
|
||||||
|
* Fuehrende Nullen werden abgelehnt: `007` und `7` wuerden dieselbe Zeile
|
||||||
|
* bezeichnen, und zwei Schreibweisen fuer dieselbe Ressource sind eine
|
||||||
|
* unnoetige Einladung – etwa fuer Zaehler, Zwischenspeicher oder Sperren, die
|
||||||
|
* auf dem Pfad als Schluessel arbeiten.
|
||||||
|
*/
|
||||||
|
export function istGueltigeId(wert: string): boolean {
|
||||||
|
if (!/^[1-9][0-9]*$/.test(wert)) return false;
|
||||||
|
const n = Number(wert);
|
||||||
|
return Number.isSafeInteger(n) && n <= MAX_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pruefeIdParameter: RequestParamHandler = (
|
||||||
|
_req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
wert: unknown,
|
||||||
|
) => {
|
||||||
|
if (typeof wert === 'string' && istGueltigeId(wert)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Bewusst ohne Angabe, WELCHER Parameter beanstandet wurde: Die Meldung
|
||||||
|
// soll nicht zur Landkarte werden, welche Endpunkte welche IDs erwarten.
|
||||||
|
res.status(404).json({ success: false, error: 'Nicht gefunden' } as ApiResponse);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haengt die Pruefung an einen Router. Namen, die der Router gar nicht
|
||||||
|
* verwendet, kosten nichts – Express ruft den Callback nur fuer Parameter,
|
||||||
|
* die in einer getroffenen Route vorkommen.
|
||||||
|
*/
|
||||||
|
export function registriereIdPruefung<T extends Router>(router: T): T {
|
||||||
|
for (const name of ID_PARAMETER) {
|
||||||
|
router.param(name, pruefeIdParameter);
|
||||||
|
}
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -909,12 +909,30 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
* nicht mehr (siehe ausfuehrliche Begruendung an der Berechnung unten).
|
* nicht mehr (siehe ausfuehrliche Begruendung an der Berechnung unten).
|
||||||
*/
|
*/
|
||||||
attestedGaps: number[];
|
attestedGaps: number[];
|
||||||
|
/**
|
||||||
|
* Protokollierte Neuberechnungen der Kette (Rehash), aelteste zuerst.
|
||||||
|
*
|
||||||
|
* Ein Rehash verknuepft alle Eintraege neu. Danach ist die Kette
|
||||||
|
* zwangslaeufig stimmig - auch ueber Loeschungen hinweg, die vorher als
|
||||||
|
* Luecken sichtbar waren. „Lueckenlos verkettet“ heisst nach einem Rehash
|
||||||
|
* also nur noch: seit dem Rehash. Wer das nicht mitliest, nimmt eine
|
||||||
|
* Entwarnung mit, die es nicht gibt.
|
||||||
|
*/
|
||||||
|
rehashes: Array<{
|
||||||
|
id: number;
|
||||||
|
zeitpunkt: string;
|
||||||
|
neuBerechnet: number | null;
|
||||||
|
/** Marker mit gueltiger HMAC-Signatur? Siehe Kommentar an der Auswertung. */
|
||||||
|
signiert: boolean;
|
||||||
|
/** Befund unmittelbar VOR dem Rehash - was also uebertuencht wurde. */
|
||||||
|
vorbefund: { manipuliert: number; luecken: number } | null;
|
||||||
|
}>;
|
||||||
/**
|
/**
|
||||||
* Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
|
* Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
|
||||||
* `kein_siegel` = nie erstellt. `entfernt` = Blaetter vorhanden, aber kein
|
* `kein_siegel` = nie erstellt. `entfernt` = Blaetter vorhanden, aber kein
|
||||||
* gueltiger Marker mehr – der Anker wurde herausgeloest (Pentest R174-01).
|
* gueltiger Marker mehr – der Anker wurde herausgeloest (Pentest R174-01).
|
||||||
*/
|
*/
|
||||||
backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
|
backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
|
||||||
/** Altbestands-Zeilen, deren Inhalt vom Siegel abweicht. */
|
/** Altbestands-Zeilen, deren Inhalt vom Siegel abweicht. */
|
||||||
backlogTampered: number[];
|
backlogTampered: number[];
|
||||||
/** Gesiegelte Zeilen, die nicht mehr existieren – Beweismaterial entfernt. */
|
/** Gesiegelte Zeilen, die nicht mehr existieren – Beweismaterial entfernt. */
|
||||||
@@ -1044,6 +1062,68 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
const gapErklaert = (prevId: number, curId: number) =>
|
const gapErklaert = (prevId: number, curId: number) =>
|
||||||
deletionRanges.some((r) => r.from <= curId && r.to >= prevId);
|
deletionRanges.some((r) => r.from <= curId && r.to >= prevId);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Protokollierte Neuberechnungen (Rehash) einsammeln.
|
||||||
|
//
|
||||||
|
// Ein Rehash macht die Kette rechnerisch stimmig - auch dort, wo vorher
|
||||||
|
// Loeschungen als Luecken sichtbar waren. Genau das ist auf Staging passiert:
|
||||||
|
// zwei Cleanups mit Aufbewahrung 0 entfernten 3155 Eintraege, der folgende
|
||||||
|
// Rehash liess rund 700 Kettenluecken verschwinden, und die Pruefung meldete
|
||||||
|
// danach „unveraendert und lueckenlos verkettet“. Wahr - und trotzdem das
|
||||||
|
// Gegenteil dessen, was ein Leser mitnimmt.
|
||||||
|
//
|
||||||
|
// Umgekehrte Beweislast als bei Manifest und Siegel: Dort zaehlen NUR
|
||||||
|
// signierte Traeger, weil ein gefaelschter Marker Luecken wegerklaeren
|
||||||
|
// koennte - Misstrauen ist die sichere Richtung. Hier erzeugt ein Marker eine
|
||||||
|
// WARNUNG. Wuerden wir nur signierte gelten lassen, koennte jemand einen
|
||||||
|
// Rehash unsichtbar machen, indem er dessen Signatur zerstoert. Deshalb
|
||||||
|
// zaehlt hier jeder auswertbare Marker; ob er signiert ist, wird nur
|
||||||
|
// mitgeteilt.
|
||||||
|
const rehashKandidaten = await prisma.auditLog.findMany({
|
||||||
|
where: { resourceType: 'AuditLog', action: 'UPDATE', endpoint: '/api/audit-logs/rehash' },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
const rehashSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter(
|
||||||
|
(k): k is string => !!k,
|
||||||
|
);
|
||||||
|
const rehashes: Array<{
|
||||||
|
id: number;
|
||||||
|
zeitpunkt: string;
|
||||||
|
neuBerechnet: number | null;
|
||||||
|
signiert: boolean;
|
||||||
|
vorbefund: { manipuliert: number; luecken: number } | null;
|
||||||
|
}> = [];
|
||||||
|
for (const row of rehashKandidaten) {
|
||||||
|
let neuBerechnet: number | null = null;
|
||||||
|
let vorbefund: { manipuliert: number; luecken: number } | null = null;
|
||||||
|
try {
|
||||||
|
const nach = JSON.parse(row.changesAfter || '{}');
|
||||||
|
if (typeof nach.neuBerechnet !== 'number') continue; // kein Rehash-Marker
|
||||||
|
neuBerechnet = nach.neuBerechnet;
|
||||||
|
const vor = JSON.parse(row.changesBefore || '{}');
|
||||||
|
if (Array.isArray(vor.manipuliert) && Array.isArray(vor.ketten_luecken)) {
|
||||||
|
vorbefund = {
|
||||||
|
manipuliert: vor.manipuliert.length,
|
||||||
|
luecken: vor.ketten_luecken.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const signiert =
|
||||||
|
row.hashVersion >= 3 &&
|
||||||
|
rehashSchluessel.some(
|
||||||
|
(k) => row.hash === generateHashV3(row as unknown as AuditHashV2Input, k),
|
||||||
|
);
|
||||||
|
rehashes.push({
|
||||||
|
id: row.id,
|
||||||
|
zeitpunkt: row.createdAt.toISOString(),
|
||||||
|
neuBerechnet,
|
||||||
|
signiert,
|
||||||
|
vorbefund,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const tamperedEntries: number[] = [];
|
const tamperedEntries: number[] = [];
|
||||||
const chainGaps: number[] = [];
|
const chainGaps: number[] = [];
|
||||||
const unexplainedGaps: number[] = [];
|
const unexplainedGaps: number[] = [];
|
||||||
@@ -1061,7 +1141,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
const backlogMissing: number[] = [];
|
const backlogMissing: number[] = [];
|
||||||
// Luecken, die das Siegel als bereits vorhanden beglaubigt (siehe unten).
|
// Luecken, die das Siegel als bereits vorhanden beglaubigt (siehe unten).
|
||||||
const beglaubigteLuecken = new Set<number>();
|
const beglaubigteLuecken = new Set<number>();
|
||||||
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' | 'nicht_noetig' = 'kein_siegel';
|
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig' = 'kein_siegel';
|
||||||
let backlogSealRoot: string | null = null;
|
let backlogSealRoot: string | null = null;
|
||||||
|
|
||||||
const siegelKandidaten = await prisma.auditLog.findMany({
|
const siegelKandidaten = await prisma.auditLog.findMany({
|
||||||
@@ -1164,6 +1244,17 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
? 'intakt'
|
? 'intakt'
|
||||||
: 'gebrochen';
|
: 'gebrochen';
|
||||||
|
|
||||||
|
// Ein Siegel ueber NULL Blaettern ist rechnerisch tadellos und schuetzt
|
||||||
|
// nichts: es gab zum Zeitpunkt des Siegelns keinen Altbestand. „intakt“
|
||||||
|
// zu melden waere formal richtig und trotzdem irrefuehrend - es liest
|
||||||
|
// sich als Schutzzusage. Der Pentester hat Stagings Leersiegel genau so
|
||||||
|
// missverstanden, und das ist der rote Faden im Kleinen: ein Signal, das
|
||||||
|
// beruhigt, wo nichts abgesichert ist. Eigener Zustand, gleiche Wertung
|
||||||
|
// wie „nicht noetig“ - kein Befund, aber auch keine Zusage.
|
||||||
|
if (backlogSealStatus === 'intakt' && blaetter.length === 0) {
|
||||||
|
backlogSealStatus = 'leer';
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Beglaubigte Alt-Luecken
|
// Beglaubigte Alt-Luecken
|
||||||
//
|
//
|
||||||
@@ -1206,6 +1297,34 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
}
|
}
|
||||||
tamperedEntries.push(...backlogTampered, ...backlogMissing);
|
tamperedEntries.push(...backlogTampered, ...backlogMissing);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Fehlender ANFANG des Protokolls.
|
||||||
|
//
|
||||||
|
// Die Verkettung wird zeilenweise gegen die Vorgaengerin geprueft - die
|
||||||
|
// erste Zeile hat keine, also fiel bisher NICHTS auf, wenn ein
|
||||||
|
// zusammenhaengender Anfang entfernt wurde. Kein Kettenbruch, kein Befund,
|
||||||
|
// `valid` blieb gruen. Das ist die stillste Loeschung von allen: Wer die
|
||||||
|
// aeltesten Eintraege loswerden will, muss nur vorne anfangen.
|
||||||
|
//
|
||||||
|
// Erkennbar ist es trotzdem: Die allererste Zeile eines Protokolls wird ohne
|
||||||
|
// Vorgaenger geschrieben und traegt deshalb einen leeren `previousHash`.
|
||||||
|
// Traegt die erste vorhandene Zeile einen Wert, hat es eine Vorgaengerin
|
||||||
|
// gegeben - und die ist weg.
|
||||||
|
//
|
||||||
|
// Nur bei Pruefung des GESAMTEN Bereichs; bei `fromId` ist ein gefuellter
|
||||||
|
// `previousHash` selbstverstaendlich und kein Befund.
|
||||||
|
if (fromId === undefined && logs.length > 0 && logs[0].previousHash) {
|
||||||
|
chainGaps.push(logs[0].id);
|
||||||
|
// Ein protokolliertes Loeschungs-Manifest erklaert auch diese Luecke; die
|
||||||
|
// Pruefung laeuft ueber denselben Weg wie bei jeder anderen.
|
||||||
|
if (!gapErklaert(0, logs[0].id)) {
|
||||||
|
unexplainedGaps.push(logs[0].id);
|
||||||
|
if (erwarteteVersion(logs[0].id) === 3) {
|
||||||
|
tamperedEntries.push(logs[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < logs.length; i++) {
|
for (let i = 0; i < logs.length; i++) {
|
||||||
const log = logs[i];
|
const log = logs[i];
|
||||||
|
|
||||||
@@ -1325,6 +1444,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
// unsichtbar (R174-01).
|
// unsichtbar (R174-01).
|
||||||
const siegelInOrdnung =
|
const siegelInOrdnung =
|
||||||
backlogSealStatus === 'intakt' ||
|
backlogSealStatus === 'intakt' ||
|
||||||
|
backlogSealStatus === 'leer' ||
|
||||||
backlogSealStatus === 'kein_siegel' ||
|
backlogSealStatus === 'kein_siegel' ||
|
||||||
backlogSealStatus === 'nicht_noetig';
|
backlogSealStatus === 'nicht_noetig';
|
||||||
|
|
||||||
@@ -1337,6 +1457,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
unexplainedGaps,
|
unexplainedGaps,
|
||||||
unverifiableEntries,
|
unverifiableEntries,
|
||||||
attestedGaps,
|
attestedGaps,
|
||||||
|
rehashes,
|
||||||
backlogSealStatus,
|
backlogSealStatus,
|
||||||
backlogTampered,
|
backlogTampered,
|
||||||
backlogMissing,
|
backlogMissing,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft beim Start, ob die gesetzlich gebundenen Rechte ueberhaupt jemand
|
||||||
|
* ausueben kann (Pentest R189-01).
|
||||||
|
*
|
||||||
|
* Hintergrund: `gdpr:export`, `gdpr:delete` und `gdpr:admin` haengen an den
|
||||||
|
* Rollen DSGVO und Developer – nicht an der Admin-Rolle. Nach einem frischen
|
||||||
|
* Seed war keine der beiden einem Konto zugewiesen. Ergebnis: Eine Auskunft
|
||||||
|
* nach Art. 15 oder eine Loeschung nach Art. 17 konnte NIEMAND ausfuehren,
|
||||||
|
* obwohl mehrere „Admin"-Konten existierten. Ein Ausfall mit Fristwirkung,
|
||||||
|
* ausgeloest durch nichts weiter als eine Neuinstallation.
|
||||||
|
*
|
||||||
|
* Der Seed weist das Recht jetzt zu – aber nur bei NEUINSTALLATION. Auf einer
|
||||||
|
* laufenden Datenbank aendert er nichts, und niemand merkt es, bis es darauf
|
||||||
|
* ankommt. Deshalb diese Wache: Sie erkennt die ABWESENHEIT einer Faehigkeit,
|
||||||
|
* so wie der Heartbeat das Ausbleiben eines Dienstkontos erkennt.
|
||||||
|
*
|
||||||
|
* Bewusst nur eine Meldung, keine automatische Vergabe: Rechte zu verteilen,
|
||||||
|
* ohne dass ein Mensch es veranlasst hat, waere der groessere Fehler.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Rechte, deren Fehlen ein rechtliches und kein technisches Problem ist. */
|
||||||
|
const PFLICHTRECHTE: Array<{ resource: string; action: string; wofuer: string }> = [
|
||||||
|
{ resource: 'gdpr', action: 'export', wofuer: 'Auskunft nach Art. 15 DSGVO' },
|
||||||
|
{ resource: 'gdpr', action: 'delete', wofuer: 'Löschung nach Art. 17 DSGVO' },
|
||||||
|
{ resource: 'audit', action: 'read', wofuer: 'Prüfung des Audit-Protokolls' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function pruefePflichtrechte(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const fehlend: string[] = [];
|
||||||
|
|
||||||
|
for (const recht of PFLICHTRECHTE) {
|
||||||
|
const traeger = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
roles: {
|
||||||
|
some: {
|
||||||
|
role: {
|
||||||
|
permissions: {
|
||||||
|
some: {
|
||||||
|
permission: { resource: recht.resource, action: recht.action },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (traeger === 0) {
|
||||||
|
fehlend.push(`${recht.resource}:${recht.action} (${recht.wofuer})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fehlend.length === 0) return;
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
'\n' +
|
||||||
|
'========================================================================\n' +
|
||||||
|
' ACHTUNG: Für folgende Rechte gibt es KEIN aktives Konto:\n' +
|
||||||
|
fehlend.map((f) => ` – ${f}\n`).join('') +
|
||||||
|
'\n' +
|
||||||
|
' Das ist kein Fehler der Anwendung, sondern eine Lücke in der\n' +
|
||||||
|
' Rechtevergabe – und sie fällt erst auf, wenn eine Frist läuft.\n' +
|
||||||
|
'\n' +
|
||||||
|
' Beheben: In der Benutzerverwaltung bei einem verantwortlichen Konto\n' +
|
||||||
|
' den Haken „DSGVO-Zugriff" setzen (Audit-Protokoll lesen und\n' +
|
||||||
|
' Datenschutz-Verwaltung). Für Eingriffe am Protokoll – versiegeln,\n' +
|
||||||
|
' aufräumen, Aufbewahrung ändern – zusätzlich „Audit-Betrieb".\n' +
|
||||||
|
'========================================================================\n',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Eine Wache darf den Start nicht verhindern. Aber schweigen darf sie
|
||||||
|
// auch nicht: „nicht geprüft" ist nicht dasselbe wie „nichts gefunden".
|
||||||
|
console.warn(
|
||||||
|
'[Pflichtrechte] Prüfung nicht möglich – der Zustand der Rechtevergabe ist ' +
|
||||||
|
'damit UNBEKANNT, nicht in Ordnung:',
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,9 +65,10 @@ export async function getAllUsers(filters: UserFilters) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Get hidden role IDs
|
// Get hidden role IDs
|
||||||
const [developerRole, gdprRole] = await Promise.all([
|
const [developerRole, gdprRole, auditOpsRole] = await Promise.all([
|
||||||
prisma.role.findFirst({ where: { name: 'Developer' } }),
|
prisma.role.findFirst({ where: { name: 'Developer' } }),
|
||||||
prisma.role.findFirst({ where: { name: 'DSGVO' } }),
|
prisma.role.findFirst({ where: { name: 'DSGVO' } }),
|
||||||
|
prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -78,11 +79,15 @@ export async function getAllUsers(filters: UserFilters) {
|
|||||||
const hasGdprAccess = gdprRole
|
const hasGdprAccess = gdprRole
|
||||||
? u.roles.some((ur) => ur.roleId === gdprRole.id)
|
? u.roles.some((ur) => ur.roleId === gdprRole.id)
|
||||||
: false;
|
: false;
|
||||||
|
const hasAuditOpsAccess = auditOpsRole
|
||||||
|
? u.roles.some((ur) => ur.roleId === auditOpsRole.id)
|
||||||
|
: false;
|
||||||
return {
|
return {
|
||||||
...u,
|
...u,
|
||||||
roles: u.roles.map((r) => r.role),
|
roles: u.roles.map((r) => r.role),
|
||||||
hasDeveloperAccess,
|
hasDeveloperAccess,
|
||||||
hasGdprAccess,
|
hasGdprAccess,
|
||||||
|
hasAuditOpsAccess,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
pagination: buildPaginationResponse(page, limit, total),
|
pagination: buildPaginationResponse(page, limit, total),
|
||||||
@@ -146,6 +151,7 @@ export async function createUser(data: {
|
|||||||
customerId?: number;
|
customerId?: number;
|
||||||
hasDeveloperAccess?: boolean;
|
hasDeveloperAccess?: boolean;
|
||||||
hasGdprAccess?: boolean;
|
hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean;
|
||||||
whatsappNumber?: string;
|
whatsappNumber?: string;
|
||||||
telegramUsername?: string;
|
telegramUsername?: string;
|
||||||
signalNumber?: string;
|
signalNumber?: string;
|
||||||
@@ -190,6 +196,10 @@ export async function createUser(data: {
|
|||||||
await setUserGdprAccess(user.id, true);
|
await setUserGdprAccess(user.id, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.hasAuditOpsAccess) {
|
||||||
|
await setUserAuditOpsAccess(user.id, true);
|
||||||
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,12 +216,13 @@ export async function updateUser(
|
|||||||
customerId?: number;
|
customerId?: number;
|
||||||
hasDeveloperAccess?: boolean;
|
hasDeveloperAccess?: boolean;
|
||||||
hasGdprAccess?: boolean;
|
hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean;
|
||||||
whatsappNumber?: string;
|
whatsappNumber?: string;
|
||||||
telegramUsername?: string;
|
telegramUsername?: string;
|
||||||
signalNumber?: string;
|
signalNumber?: string;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, ...userData } = data;
|
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess, ...userData } = data;
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -348,6 +359,10 @@ export async function updateUser(
|
|||||||
await setUserGdprAccess(id, hasGdprAccess);
|
await setUserGdprAccess(id, hasGdprAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasAuditOpsAccess !== undefined) {
|
||||||
|
await setUserAuditOpsAccess(id, hasAuditOpsAccess);
|
||||||
|
}
|
||||||
|
|
||||||
return getUserById(id);
|
return getUserById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,6 +422,16 @@ async function setUserDeveloperAccess(userId: number, enabled: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name der versteckten Rolle fuer eingreifende Audit-Rechte.
|
||||||
|
*
|
||||||
|
* Getrennt von `DSGVO`, weil Aufsicht und Eingriff nicht dieselbe Rolle sein
|
||||||
|
* duerfen: Wer das Protokoll beaufsichtigt, darf seine Beweisgrundlage nicht
|
||||||
|
* ersetzen koennen (Pentest R186). Die Rolle wird von `sync-roles.ts` beim
|
||||||
|
* Containerstart angelegt.
|
||||||
|
*/
|
||||||
|
export const AUDIT_OPS_ROLLE = 'Audit-Betrieb';
|
||||||
|
|
||||||
// Helper to set GDPR access for a user
|
// Helper to set GDPR access for a user
|
||||||
async function setUserGdprAccess(userId: number, enabled: boolean) {
|
async function setUserGdprAccess(userId: number, enabled: boolean) {
|
||||||
// Get or create DSGVO role
|
// Get or create DSGVO role
|
||||||
@@ -415,17 +440,24 @@ async function setUserGdprAccess(userId: number, enabled: boolean) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!gdprRole) {
|
if (!gdprRole) {
|
||||||
// Create DSGVO role with all audit:* and gdpr:* permissions
|
// Rechte-Satz identisch zu sync-roles.ts: gdpr komplett, vom Audit-
|
||||||
|
// Protokoll nur LESEN und EXPORTIEREN. Ohne `audit:admin` - sonst
|
||||||
|
// brächte dieser Notfallpfad genau die Bündelung zurück, die wir gerade
|
||||||
|
// aufgelöst haben (zwei Listen, die dasselbe bedeuten sollen, laufen
|
||||||
|
// auseinander).
|
||||||
const gdprPermissions = await prisma.permission.findMany({
|
const gdprPermissions = await prisma.permission.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ resource: 'audit' }, { resource: 'gdpr' }],
|
OR: [
|
||||||
|
{ resource: 'gdpr' },
|
||||||
|
{ resource: 'audit', action: { in: ['read', 'export'] } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
gdprRole = await prisma.role.create({
|
gdprRole = await prisma.role.create({
|
||||||
data: {
|
data: {
|
||||||
name: 'DSGVO',
|
name: 'DSGVO',
|
||||||
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
|
description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung',
|
||||||
permissions: {
|
permissions: {
|
||||||
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
|
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
|
||||||
},
|
},
|
||||||
@@ -457,6 +489,50 @@ async function setUserGdprAccess(userId: number, enabled: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eingreifende Audit-Rechte setzen: versiegeln, neu berechnen, aufraeumen,
|
||||||
|
* Aufbewahrung aendern. Bewusst getrennt vom DSGVO-Haken (siehe
|
||||||
|
* `AUDIT_OPS_ROLLE`).
|
||||||
|
*/
|
||||||
|
async function setUserAuditOpsAccess(userId: number, enabled: boolean) {
|
||||||
|
let rolle = await prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } });
|
||||||
|
|
||||||
|
if (!rolle) {
|
||||||
|
// Rechte-Satz identisch zu sync-roles.ts.
|
||||||
|
const rechte = await prisma.permission.findMany({
|
||||||
|
where: { resource: 'audit', action: { in: ['read', 'admin'] } },
|
||||||
|
});
|
||||||
|
rolle = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
name: AUDIT_OPS_ROLLE,
|
||||||
|
description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern',
|
||||||
|
permissions: { create: rechte.map((p) => ({ permissionId: p.id })) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hatRolle = await prisma.userRole.findFirst({
|
||||||
|
where: { userId, roleId: rolle.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (enabled && !hatRolle) {
|
||||||
|
await prisma.userRole.create({ data: { userId, roleId: rolle.id } });
|
||||||
|
// Token invalidieren bei Rechteaenderung
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
|
} else if (!enabled && hatRolle) {
|
||||||
|
await prisma.userRole.delete({
|
||||||
|
where: { userId_roleId: { userId, roleId: rolle.id } },
|
||||||
|
});
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteUser(id: number) {
|
export async function deleteUser(id: number) {
|
||||||
// Check if user is an admin
|
// Check if user is an admin
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
|
|||||||
@@ -654,6 +654,10 @@ const USER_UPDATABLE_FIELDS = [
|
|||||||
// stehen, damit pick() sie nicht aus dem Request entfernt.
|
// stehen, damit pick() sie nicht aus dem Request entfernt.
|
||||||
'hasGdprAccess',
|
'hasGdprAccess',
|
||||||
'hasDeveloperAccess',
|
'hasDeveloperAccess',
|
||||||
|
// Eingreifende Audit-Rechte (versiegeln/aufraeumen/Aufbewahrung), bewusst
|
||||||
|
// getrennt von hasGdprAccess: Aufsicht und Eingriff sind zwei Rollen
|
||||||
|
// (Pentest R186). Mappt auf die versteckte Rolle 'Audit-Betrieb'.
|
||||||
|
'hasAuditOpsAccess',
|
||||||
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
|
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
|
||||||
// Nicht: password – wird über dedizierten Endpoint POST /users/:id/password
|
// Nicht: password – wird über dedizierten Endpoint POST /users/:id/password
|
||||||
// gesetzt (Pentest Runde 12 (2026-05-18) – MITTEL: generisches User-Update
|
// gesetzt (Pentest Runde 12 (2026-05-18) – MITTEL: generisches User-Update
|
||||||
|
|||||||
+216
@@ -97,6 +97,222 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🔢 R188: Ungültige IDs im Pfad – zentral statt 181-mal** (2026-09-03)
|
||||||
|
- Meldung der Pentesterin: `GET /api/users/:id` gibt bei nicht-numerischer
|
||||||
|
ID **500** statt 400 (`/api/users/permissions` trifft `/:id`). Ihr Patch
|
||||||
|
setzt einen Guard in `getUser`.
|
||||||
|
- **Berechtigt – und größer als gemeldet.** Ihr Fix schließt nebenbei etwas,
|
||||||
|
das sie nicht beansprucht: `parseInt('12abc')` ergibt **12**, also lieferte
|
||||||
|
`GET /api/users/12abc` bisher Benutzer 12 aus. Und es waren **181**
|
||||||
|
ungeprüfte Stellen in 19 Controllern, nicht eine.
|
||||||
|
- 181 Einzel-Guards wären genau der Fehler aus R186-01 gewesen (drei
|
||||||
|
Filterlisten, die auseinanderliefen). Stattdessen `router.param()`, an
|
||||||
|
**einer** Stelle für alle 33 Router registriert – über einen `mounte()`-
|
||||||
|
Helfer, der Prüfung und Einhängen zusammenbindet. Wer künftig `app.use`
|
||||||
|
schreibt statt `mounte`, umgeht sie nicht versehentlich, sondern sichtbar.
|
||||||
|
- **Antwort ist 404, nicht 400:** Ein Pfadsegment, das keine ID sein kann,
|
||||||
|
benennt keine Ressource. Der bestehende Präzedenzfall
|
||||||
|
(`provider.controller.ts`, Pentest Mai 2026) hatte es genauso entschieden.
|
||||||
|
- **Eine ältere Heuristik abgelöst** (Pentest Runde 7): Sie blockte
|
||||||
|
`^\d+[a-zA-Z]+$` im Pfad – ihr eigener Kommentar nannte den Grund,
|
||||||
|
„`app.param()` greift nicht auf in Sub-Router gemounteten Routes", und
|
||||||
|
genau das löst `mounte()`. Sie war **zu eng** (`/users/abc` ging durch →
|
||||||
|
500) und **zu weit** (ein Einstellungs-Schlüssel `12abc` unter `:key` wurde
|
||||||
|
geblockt, obwohl das keine ID ist), und sie antwortete 400, wo jetzt 404
|
||||||
|
steht – zwei Antworten für dieselbe Eingabeklasse.
|
||||||
|
- Getestet über HTTP: `abc`, `permissions`, `12abc`, `6abc`, `0`, `-1`,
|
||||||
|
`007`, `1e3`, Überlauf, `1;DROP` → alle **404**; `1` → 200. Quer über
|
||||||
|
sechs Controller gleich. Nicht-ID-Parameter (`roles/list`,
|
||||||
|
`permissions/list`, `settings/:key`) unverändert.
|
||||||
|
- Dateien: `backend/src/middleware/routeIds.ts` (neu), `backend/src/index.ts`
|
||||||
|
|
||||||
|
- [x] **⚖️ R189-01: DSGVO-Rechte hingen an keinem Konto** (2026-09-03)
|
||||||
|
- Meldung: `gdpr:*` und `audit:read/export` hängen an den Rollen DSGVO und
|
||||||
|
Developer – die Admin-Rolle hat sie **nicht**, und nach einem frischen Seed
|
||||||
|
war DSGVO **keinem Konto** zugewiesen. Auskunft nach Art. 15 und Löschung
|
||||||
|
nach Art. 17 konnte damit niemand ausführen. Ausfall mit Fristwirkung.
|
||||||
|
- Seed weist `admin@admin.com` jetzt zusätzlich die DSGVO-Rolle zu. Die
|
||||||
|
Admin-**Rolle** bekommt diese Rechte weiterhin nicht – die Trennung aus
|
||||||
|
R186 bleibt, nur das eine Bootstrap-Konto trägt beides.
|
||||||
|
- Label ehrlich gemacht: „Voller Zugriff auf alle **Fachfunktionen** (ohne
|
||||||
|
Audit & Datenschutz – dafür die separaten Rollen DSGVO und Audit-Betrieb)".
|
||||||
|
- **Eigener Fund beim Prüfen ihres Patches:** `seed.ts` vergab an die
|
||||||
|
DSGVO-Rolle weiterhin `audit:*` **komplett**, inklusive `audit:admin` –
|
||||||
|
also die Bündelung, die in `fc6f39e` aufgelöst wurde. Ich hatte damals zwei
|
||||||
|
Listen gefunden (`sync-roles.ts`, `user.service.ts`) und die dritte
|
||||||
|
übersehen. Gerettet hat es nur die Reihenfolge im Containerstart; ein
|
||||||
|
einzelnes `npm run db:seed` brachte sie zurück. Korrigiert.
|
||||||
|
- **Der Seed hilft aber nur bei Neuinstallation** (`update: {}`). Deshalb
|
||||||
|
zusätzlich eine Wache beim Start: Gibt es für `gdpr:export`, `gdpr:delete`
|
||||||
|
oder `audit:read` **kein aktives Konto**, steht das mit Handlungsanweisung
|
||||||
|
im Log. Erkennt die *Abwesenheit* einer Fähigkeit – dasselbe Muster wie der
|
||||||
|
Heartbeat. Bewusst nur melden, nicht automatisch vergeben.
|
||||||
|
- Getestet: frischer Seed → Admin hat `audit:read, audit:export, gdpr:*` und
|
||||||
|
**kein** `audit:admin`. Rolle entzogen → Warnung erscheint mit allen drei
|
||||||
|
Rechten. Rolle zurück → still.
|
||||||
|
- Dateien: `backend/prisma/seed.ts`, `backend/prisma/sync-roles.ts`,
|
||||||
|
`backend/src/services/pflichtrechte.service.ts` (neu), `backend/src/index.ts`
|
||||||
|
|
||||||
|
- [x] **🕳️ Entfernter Protokoll-ANFANG wurde nicht erkannt** (2026-08-26)
|
||||||
|
- Gefunden bei der Vorbereitung des Prod-Siegels: Die Verkettung wird
|
||||||
|
zeilenweise gegen die Vorgängerin geprüft – die **erste** Zeile hat keine,
|
||||||
|
also fiel bisher **nichts** auf, wenn ein zusammenhängender Anfang des
|
||||||
|
Protokolls entfernt wurde. Kein Kettenbruch, kein Befund, `valid` blieb
|
||||||
|
grün. Die stillste Löschung von allen: Wer die ältesten Einträge loswerden
|
||||||
|
will, muss nur vorne anfangen.
|
||||||
|
- Erkennbar ist es trotzdem: Die allererste Zeile wird ohne Vorgänger
|
||||||
|
geschrieben und trägt einen leeren `previousHash`. Trägt die erste
|
||||||
|
**vorhandene** Zeile einen Wert, hat es eine Vorgängerin gegeben – und die
|
||||||
|
ist weg. Wird jetzt als Kettenlücke an dieser Zeile geführt, mit derselben
|
||||||
|
Manifest- und Beglaubigungslogik wie jede andere Lücke.
|
||||||
|
- Nur bei Prüfung des Gesamtbereichs; mit `fromId` ist ein gefüllter
|
||||||
|
`previousHash` selbstverständlich (gegengeprüft, kein Fehlalarm).
|
||||||
|
- Getestet über HTTP: vollständiges Protokoll → `valid:true`; erste drei
|
||||||
|
Zeilen entfernt → **vorher** unverändert `valid:true`, **jetzt**
|
||||||
|
`valid:false`, `chainGaps:[4]`, wegen Hash-Version 3 zusätzlich als
|
||||||
|
Manipulation eskaliert.
|
||||||
|
- Auf Prod ausgeschlossen: Das Protokoll beginnt bei ID 1 (07.05.2026,
|
||||||
|
Inbetriebnahme), kein Cleanup, keine Neuberechnung.
|
||||||
|
- Datei: `backend/src/services/audit.service.ts`
|
||||||
|
|
||||||
|
- [x] **⏱️ Neuberechnung der Kette ist jetzt eine eigene Gegenbuch-Alarmbedingung (Pentest R186, Frage b)** (2026-08-26)
|
||||||
|
- Der Tester fragte präzise: Löst `cleanup` → `rehash` **ohne** erneutes
|
||||||
|
Siegeln, über **nicht versiegelten** Inhalt, etwas **Automatisches** aus –
|
||||||
|
oder nur die Prosa in `verify`, die ein Mensch lesen muss?
|
||||||
|
- **Gemessen statt behauptet:** Es löste bereits aus, aber als *Nebenwirkung*.
|
||||||
|
Ein Rehash ändert jeden Hash, also stimmt der beglaubigte Kettenkopf nicht
|
||||||
|
mehr und der bestehende Vergleich schlug an. Funktioniert – ist aber ein
|
||||||
|
Zufallstreffer: Verschöbe sich der Anker, wäre der Melder lautlos weg. Und
|
||||||
|
die Meldung hieß „Eintrag wurde verändert" statt „die Kette wurde neu
|
||||||
|
berechnet", also Wirkung statt Ursache.
|
||||||
|
- Jetzt hängt der Alarm an der Sache selbst: Das Gegenbuch führt
|
||||||
|
`rehashCount`/`rehashLast` im Buch mit und meldet jede neue Neuberechnung
|
||||||
|
seit der letzten Beglaubigung mit **exit 2** – samt Zeitpunkt, Zeilenzahl
|
||||||
|
und dem Befund, der unmittelbar davor galt. Dieselbe Lehre wie R184-01
|
||||||
|
(Gate am Auslöser, nicht an der Wirkung) und R185-01 (Wurzelwechsel statt
|
||||||
|
`valid`).
|
||||||
|
- **Reihenfolge geändert, und das war nötig:** Der neue Melder steht **vor**
|
||||||
|
dem Kopf-Hash-Vergleich, sonst hätte immer die unpräzisere Meldung
|
||||||
|
gewonnen. Und der Kopf-Vergleich wird nach einer *bestätigten*
|
||||||
|
Neuberechnung übersprungen – sonst wäre die Bestätigung wertlos gewesen,
|
||||||
|
weil ein Rehash den Kopf zwangsläufig ändert. Beim Bauen aufgefallen, nicht
|
||||||
|
im Entwurf.
|
||||||
|
- `NOTARY_REHASH_ACK` wird mit der **ID des Rehash-Eintrags** bestätigt, nicht
|
||||||
|
mit `true`. IDs steigen streng – ein stehen gelassener Wert passt beim
|
||||||
|
nächsten Vorgang nicht mehr. Analog zu `NOTARY_SEAL_ACK`.
|
||||||
|
- Ein **fehlender** beglaubigter Eintrag alarmiert weiterhin immer: Bestätigt
|
||||||
|
wird die Neuberechnung, nicht das Verschwinden von Zeilen.
|
||||||
|
- Bestandsbücher ohne das neue Feld alarmieren nicht rückwirkend, sondern
|
||||||
|
setzen die Grundlage – mit Ausgabe, statt es stillschweigend zu tun.
|
||||||
|
- **Getestet mit echtem Gegenbuch gegen ein echtes CRM** (SSH-signiertes
|
||||||
|
lokales Buch): Wäsche ohne Datenbankzugriff (3 Zeilen gelöscht → Rehash)
|
||||||
|
→ CRM meldet `valid:true`, `chainGaps:[]`; Gegenbuch **exit 2** und nennt
|
||||||
|
die Neuberechnung als Ursache. Falsche Ack-ID → weiter exit 2, richtige →
|
||||||
|
exit 0 mit neuer Grundlage, Folgelauf ruhig.
|
||||||
|
- Dateien: `tools/audit-notary/notary.mjs`,
|
||||||
|
`tools/audit-notary/{docker-compose.yml,.env.example,README.md}`
|
||||||
|
|
||||||
|
- [x] **🔍 `verify` meldet jetzt, dass die Kette neu berechnet wurde** (2026-08-26)
|
||||||
|
- **Im Betrieb entdeckt, nicht im Test.** Das Staging-Gegenbuch meldete
|
||||||
|
„Der beglaubigte Eintrag 5352 existiert nicht mehr". Rekonstruktion aus dem
|
||||||
|
Protokoll: am 22.08. wurden die Aufbewahrungsfristen auf **0** gesetzt,
|
||||||
|
zwei Cleanups löschten **3.155** Einträge (id 1–5356), danach lief ein
|
||||||
|
**Rehash**. Seither meldet die Prüfung „Alle Einträge sind unverändert und
|
||||||
|
**lückenlos** verkettet".
|
||||||
|
- Wahr – und praktisch das Gegenteil dessen, was ein Leser mitnimmt. Der
|
||||||
|
Rehash verknüpft alles neu; die rund **700** Kettenlücken, die davor
|
||||||
|
bestanden, sind seitdem unsichtbar. Nachweisbar im Vorbefund, den der
|
||||||
|
Rehash selbst mitschreibt (R170-01) – nur schaute den nie jemand an.
|
||||||
|
- Das Gegenbuch war der einzige Zeuge. Innerhalb des CRM war die Löschung
|
||||||
|
nicht mehr feststellbar.
|
||||||
|
- `verifyIntegrity` sammelt jetzt die Rehash-Marker; die Antwort enthält
|
||||||
|
`rehashes[]` (Zeitpunkt, Anzahl, Signatur, Vorbefund). Die Meldung nennt
|
||||||
|
sie **immer**, auch im grünen Fall, und der Einstiegssatz lautet dann
|
||||||
|
„…lückenlos verkettet – allerdings erst seit der letzten Neuberechnung".
|
||||||
|
- **`valid` bleibt unberührt.** Ein Rehash ist eine legitime Maßnahme; ihn
|
||||||
|
dauerhaft als Befund zu führen wäre der Dauer-Alarm, den wir mit den
|
||||||
|
beglaubigten Lücken gerade beseitigt haben. Melden, nicht alarmieren.
|
||||||
|
- **Umgekehrte Beweislast als bei Manifest und Siegel:** Dort zählen nur
|
||||||
|
signierte Träger, weil ein gefälschter Marker Lücken *wegerklären* könnte.
|
||||||
|
Hier erzeugt ein Marker eine *Warnung* – würden nur signierte zählen,
|
||||||
|
könnte man einen Rehash unsichtbar machen, indem man seine Signatur
|
||||||
|
zerstört. Deshalb zählt jeder auswertbare Marker; fehlende Signatur wird
|
||||||
|
zusätzlich gemeldet.
|
||||||
|
- Getestet über HTTP gegen eine Wegwerf-DB, mit echtem Rehash über den
|
||||||
|
regulären Endpunkt: sauberer Vorzustand → „es wurde nichts überdeckt";
|
||||||
|
Löschung + Rehash (der Staging-Ablauf im Kleinen) → `chainGaps` fällt von
|
||||||
|
1 auf 0, die Meldung nennt Zeitpunkt und Vorbefund; zerstörte Signatur →
|
||||||
|
Marker wird weiterhin gemeldet, mit Hinweis.
|
||||||
|
- Dateien: `backend/src/services/audit.service.ts`,
|
||||||
|
`backend/src/controllers/auditLog.controller.ts`,
|
||||||
|
`frontend/src/services/api.ts`,
|
||||||
|
`frontend/src/pages/settings/AuditIntegrityCard.tsx`
|
||||||
|
|
||||||
|
- [x] **📄 `status.txt` nennt jetzt den Grund, nicht nur das Etikett** (2026-08-26)
|
||||||
|
- In der Statusdatei des Gegenbuchs stand für **jeden** Exit-2 derselbe Satz:
|
||||||
|
„BEFUND – Widerspruch zwischen CRM und Gegenbuch". Ein echter Widerspruch
|
||||||
|
sah damit genauso aus wie eine quittierpflichtige Erstsiegelung.
|
||||||
|
- Aufgefallen im Betrieb: Der Betreiber las „BEFUND" auf Staging und konnte
|
||||||
|
nicht entscheiden, ob er handeln muss – obwohl die Kette dort `valid:true`
|
||||||
|
meldet und der Alarm nur den erwarteten Siegelwechsel betraf. Wer nur die
|
||||||
|
Statusdatei liest (und dafür ist sie da – „damit eine Überwachung sie
|
||||||
|
abgreifen kann, ohne Logs zu durchsuchen"), bekam ein Etikett ohne Inhalt.
|
||||||
|
- Der Lauf wird jetzt mitgeschnitten; die erste `ALARM:`-Zeile landet als
|
||||||
|
`Grund:` in `status.txt`. Ohne Alarm die erste Ausgabezeile.
|
||||||
|
- Datei: `tools/audit-notary/entrypoint.sh`
|
||||||
|
|
||||||
|
- [x] **⚖️ Aufsicht und Eingriff getrennt: neuer Haken „Audit-Betrieb"** (2026-08-26)
|
||||||
|
- Die DSGVO-Rolle trug `audit:*` **komplett**, also auch `audit:admin`. Ein
|
||||||
|
DSGVO-Beauftragter konnte damit `seal-backlog`, `rehash` und `cleanup` –
|
||||||
|
seine eigene Beweisgrundlage ersetzen. Wer das Protokoll beaufsichtigt,
|
||||||
|
darf es nicht umschreiben können. Dieselbe Klasse wie R184-02: falsche
|
||||||
|
Domäne, zu breit gebündelt.
|
||||||
|
- **Der naheliegende Fix wäre falsch gewesen.** `audit:admin` einfach aus
|
||||||
|
der DSGVO-Rolle zu streichen hätte es heimatlos gemacht: Die Admin-Rolle
|
||||||
|
ist ausdrücklich **ohne** `audit`/`gdpr` gebaut, einzige verbleibende
|
||||||
|
Quelle wäre der Entwicklerzugriff – der *alles* gibt. Prod versiegeln
|
||||||
|
hätte dann Vollzugriff vorausgesetzt.
|
||||||
|
- Deshalb eine eigene versteckte Rolle **`Audit-Betrieb`**
|
||||||
|
(`audit:read` + `audit:admin`), zugewiesen über eine Checkbox wie
|
||||||
|
DSGVO/Entwickler. DSGVO behält `audit:read` + `audit:export` + `gdpr:*`.
|
||||||
|
**Für kein bestehendes Konto weitet sich etwas aus** – im Gegenteil, es
|
||||||
|
wird enger, und wer eingreifen können soll, bekommt es ausdrücklich.
|
||||||
|
- **Keine zusätzliche Rechte-Hürde davor**, weil das am Henne-Ei-Problem
|
||||||
|
scheitert: Nach der Aufteilung hält zunächst niemand `audit:admin`, könnte
|
||||||
|
ihn also auch niemand vergeben. Stattdessen wird die Vergabe **laut** –
|
||||||
|
CRITICAL im Protokoll **und** `PERMISSION_CHANGED`/CRITICAL im Alarmkanal,
|
||||||
|
inklusive Kennzeichen, ob sich jemand den Haken selbst gesetzt hat.
|
||||||
|
- Nebenbei geschlossen: `setUserGdprAccess()` legte die DSGVO-Rolle im
|
||||||
|
Notfallpfad mit `audit:*` komplett an – eine zweite Liste, die dasselbe
|
||||||
|
bedeuten sollte und die Bündelung stillschweigend zurückgebracht hätte.
|
||||||
|
- **⚠️ Beim Deploy:** Bestehende DSGVO-Konten verlieren `audit:admin`. Wer
|
||||||
|
Prod versiegeln will, muss sich vorher „Audit-Betrieb" ankreuzen.
|
||||||
|
- **Gemeldet, nicht geändert:** Jeder mit `users:update` (also Admin) kann
|
||||||
|
sich DSGVO oder Entwicklerzugriff selbst vergeben – Entwickler heißt
|
||||||
|
*alle* Rechte. Das ist vorbestehend und gehört ins Rollenmodell-Thema des
|
||||||
|
Pentesters.
|
||||||
|
- Dateien: `backend/prisma/sync-roles.ts`,
|
||||||
|
`backend/src/services/user.service.ts`,
|
||||||
|
`backend/src/controllers/user.controller.ts`,
|
||||||
|
`backend/src/utils/sanitize.ts`,
|
||||||
|
`frontend/src/pages/users/UserList.tsx`,
|
||||||
|
`frontend/src/services/api.ts`, `tools/audit-notary/README.md`
|
||||||
|
|
||||||
|
- [x] **🏷️ Siegel über null Blättern meldet nicht mehr „intakt"** (2026-08-26)
|
||||||
|
- Ein Bestandssiegel, das zum Zeitpunkt des Siegelns keinen Altbestand
|
||||||
|
vorfand, ist rechnerisch tadellos und schützt **nichts**. Gemeldet wurde
|
||||||
|
trotzdem `intakt` – formal richtig, aber es liest sich als Schutzzusage.
|
||||||
|
- Der Pentester hat Stagings Leersiegel genau so missverstanden und hielt es
|
||||||
|
für zahnhaltig. Das ist der rote Faden im Kleinen: ein Signal, das
|
||||||
|
beruhigt, wo nichts abgesichert ist.
|
||||||
|
- Eigener Zustand `leer` mit eigenem Text („besteht ein Bestandssiegel, das
|
||||||
|
aber NICHTS umschließt … kein Fehler, aber auch keine Zusage").
|
||||||
|
Wertung wie `nicht_noetig`: kippt `valid` nicht.
|
||||||
|
- Dateien: `backend/src/services/audit.service.ts`,
|
||||||
|
`backend/src/controllers/auditLog.controller.ts`,
|
||||||
|
`frontend/src/services/api.ts`,
|
||||||
|
`frontend/src/pages/settings/AuditIntegrityCard.tsx`
|
||||||
|
|
||||||
- [x] **🔒 `audit:export` gatete nichts – Export hing an `audit:read`** (2026-08-26)
|
- [x] **🔒 `audit:export` gatete nichts – Export hing an `audit:read`** (2026-08-26)
|
||||||
- **Bei der Gegenprobe zur neuen Rolle `Gegenbuch` gefunden:** Ein Konto mit
|
- **Bei der Gegenprobe zur neuen Rolle `Gegenbuch` gefunden:** Ein Konto mit
|
||||||
ausschließlich `audit:read` bekam auf `GET /audit-logs/export` **200**.
|
ausschließlich `audit:read` bekam auf `GET /audit-logs/export` **200**.
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const AMPEL_TITEL: Record<Ampel, string> = {
|
|||||||
|
|
||||||
const SIEGEL_TEXT: Record<IntegrityResult['backlogSealStatus'], string> = {
|
const SIEGEL_TEXT: Record<IntegrityResult['backlogSealStatus'], string> = {
|
||||||
intakt: 'versiegelt und intakt',
|
intakt: 'versiegelt und intakt',
|
||||||
|
leer: 'Siegel vorhanden, aber ohne Inhalt',
|
||||||
kein_siegel: 'nicht versiegelt',
|
kein_siegel: 'nicht versiegelt',
|
||||||
nicht_noetig: 'nicht nötig (alles signiert)',
|
nicht_noetig: 'nicht nötig (alles signiert)',
|
||||||
gebrochen: 'GEBROCHEN',
|
gebrochen: 'GEBROCHEN',
|
||||||
@@ -125,7 +126,12 @@ export default function AuditIntegrityCard() {
|
|||||||
<div className={`flex items-start gap-4 p-4 rounded-lg border ${stil.box}`}>
|
<div className={`flex items-start gap-4 p-4 rounded-lg border ${stil.box}`}>
|
||||||
<AmpelIcon ampel={ampel} />
|
<AmpelIcon ampel={ampel} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className={`font-medium ${stil.titel}`}>{AMPEL_TITEL[ampel]}</p>
|
<p className={`font-medium ${stil.titel}`}>
|
||||||
|
{AMPEL_TITEL[ampel]}
|
||||||
|
{ampel === 'ok' && ergebnis.rehashes.length > 0 && (
|
||||||
|
<span className="font-normal"> – seit der letzten Neuberechnung</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
<p className={`text-sm mt-1 ${stil.text}`}>{ergebnis.message}</p>
|
<p className={`text-sm mt-1 ${stil.text}`}>{ergebnis.message}</p>
|
||||||
<p className="text-sm text-gray-500 mt-2">
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
{ergebnis.checkedCount.toLocaleString('de-DE')} Einträge geprüft ·
|
{ergebnis.checkedCount.toLocaleString('de-DE')} Einträge geprüft ·
|
||||||
@@ -155,9 +161,36 @@ export default function AuditIntegrityCard() {
|
|||||||
<Zahl label="Ohne Schlüssel nicht prüfbar" ids={ergebnis.unverifiableEntries} ton="text-gray-700" />
|
<Zahl label="Ohne Schlüssel nicht prüfbar" ids={ergebnis.unverifiableEntries} ton="text-gray-700" />
|
||||||
{ergebnis.tamperedEntries.length === 0 &&
|
{ergebnis.tamperedEntries.length === 0 &&
|
||||||
ergebnis.chainGaps.length === 0 &&
|
ergebnis.chainGaps.length === 0 &&
|
||||||
ergebnis.unverifiableEntries.length === 0 && (
|
ergebnis.unverifiableEntries.length === 0 &&
|
||||||
|
ergebnis.rehashes.length === 0 && (
|
||||||
<p className="text-sm text-gray-500 italic">Nichts zu berichten.</p>
|
<p className="text-sm text-gray-500 italic">Nichts zu berichten.</p>
|
||||||
)}
|
)}
|
||||||
|
{ergebnis.rehashes.length > 0 && (
|
||||||
|
<div className="pt-2 border-t mt-2">
|
||||||
|
<p className="text-sm font-medium text-gray-700">Neuberechnungen der Kette</p>
|
||||||
|
{ergebnis.rehashes.map((r) => (
|
||||||
|
<p key={r.id} className="text-sm text-gray-600 mt-1">
|
||||||
|
{new Date(r.zeitpunkt).toLocaleString('de-DE')} · Eintrag {r.id}
|
||||||
|
{r.neuBerechnet !== null && ` · ${r.neuBerechnet} Einträge neu verkettet`}
|
||||||
|
{r.vorbefund && (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{' '}· davor: {r.vorbefund.manipuliert} beanstandet,{' '}
|
||||||
|
{r.vorbefund.luecken} Lücken
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!r.signiert && (
|
||||||
|
<span className="text-red-700"> · ohne gültige Signatur</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Ein Rehash verknüpft alle Einträge neu. Was davor als Lücke oder
|
||||||
|
Beanstandung sichtbar war, ist danach nicht mehr in der Kette zu sehen –
|
||||||
|
nur noch im Vorbefund des jeweiligen Eintrags. Die Aussage dieser Prüfung
|
||||||
|
reicht deshalb bis zur letzten Neuberechnung zurück, nicht weiter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-xs text-gray-500 pt-2">
|
<p className="text-xs text-gray-500 pt-2">
|
||||||
<strong>Verändert</strong> heißt: der Inhalt einer bestehenden Zeile passt nicht
|
<strong>Verändert</strong> heißt: der Inhalt einer bestehenden Zeile passt nicht
|
||||||
mehr zu ihrer Prüfsumme – das ist ernst.
|
mehr zu ihrer Prüfsumme – das ist ernst.
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default function UserList() {
|
|||||||
<td className="py-3 px-4">{user.email}</td>
|
<td className="py-3 px-4">{user.email}</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="flex gap-1 flex-wrap">
|
||||||
{user.roles?.filter((role: any) => !['Developer', 'Kunde', 'DSGVO'].includes(role.name)).map((role: any) => (
|
{user.roles?.filter((role: any) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(role.name)).map((role: any) => (
|
||||||
<Badge key={role.id || role.name} variant="info">
|
<Badge key={role.id || role.name} variant="info">
|
||||||
{role.name}
|
{role.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -246,6 +246,7 @@ function UserModal({
|
|||||||
roleIds: [] as number[],
|
roleIds: [] as number[],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
hasDeveloperAccess: false,
|
hasDeveloperAccess: false,
|
||||||
|
hasAuditOpsAccess: false,
|
||||||
hasGdprAccess: false,
|
hasGdprAccess: false,
|
||||||
isServiceAccount: false,
|
isServiceAccount: false,
|
||||||
whatsappNumber: '',
|
whatsappNumber: '',
|
||||||
@@ -264,9 +265,10 @@ function UserModal({
|
|||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO'].includes(r.name)).map((r: any) => r.id) || [],
|
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(r.name)).map((r: any) => r.id) || [],
|
||||||
isActive: (user as any).isActive ?? true,
|
isActive: (user as any).isActive ?? true,
|
||||||
hasDeveloperAccess: (user as any).hasDeveloperAccess ?? false,
|
hasDeveloperAccess: (user as any).hasDeveloperAccess ?? false,
|
||||||
|
hasAuditOpsAccess: (user as any).hasAuditOpsAccess ?? false,
|
||||||
hasGdprAccess: (user as any).hasGdprAccess ?? false,
|
hasGdprAccess: (user as any).hasGdprAccess ?? false,
|
||||||
isServiceAccount: (user as any).isServiceAccount ?? false,
|
isServiceAccount: (user as any).isServiceAccount ?? false,
|
||||||
whatsappNumber: (user as any).whatsappNumber || '',
|
whatsappNumber: (user as any).whatsappNumber || '',
|
||||||
@@ -283,6 +285,7 @@ function UserModal({
|
|||||||
roleIds: [],
|
roleIds: [],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
hasDeveloperAccess: false,
|
hasDeveloperAccess: false,
|
||||||
|
hasAuditOpsAccess: false,
|
||||||
hasGdprAccess: false,
|
hasGdprAccess: false,
|
||||||
isServiceAccount: false,
|
isServiceAccount: false,
|
||||||
whatsappNumber: '',
|
whatsappNumber: '',
|
||||||
@@ -326,6 +329,7 @@ function UserModal({
|
|||||||
roleIds: formData.roleIds,
|
roleIds: formData.roleIds,
|
||||||
isActive: formData.isActive,
|
isActive: formData.isActive,
|
||||||
hasDeveloperAccess: formData.hasDeveloperAccess,
|
hasDeveloperAccess: formData.hasDeveloperAccess,
|
||||||
|
hasAuditOpsAccess: formData.hasAuditOpsAccess,
|
||||||
hasGdprAccess: formData.hasGdprAccess,
|
hasGdprAccess: formData.hasGdprAccess,
|
||||||
isServiceAccount: formData.isServiceAccount,
|
isServiceAccount: formData.isServiceAccount,
|
||||||
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
||||||
@@ -369,6 +373,7 @@ function UserModal({
|
|||||||
lastName: formData.lastName,
|
lastName: formData.lastName,
|
||||||
roleIds: formData.roleIds,
|
roleIds: formData.roleIds,
|
||||||
hasDeveloperAccess: formData.hasDeveloperAccess,
|
hasDeveloperAccess: formData.hasDeveloperAccess,
|
||||||
|
hasAuditOpsAccess: formData.hasAuditOpsAccess,
|
||||||
hasGdprAccess: formData.hasGdprAccess,
|
hasGdprAccess: formData.hasGdprAccess,
|
||||||
isServiceAccount: formData.isServiceAccount,
|
isServiceAccount: formData.isServiceAccount,
|
||||||
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
||||||
@@ -489,7 +494,7 @@ function UserModal({
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rollen</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Rollen</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{roles.filter((role) => !['Developer', 'Kunde', 'DSGVO'].includes(role.name)).map((role) => (
|
{roles.filter((role) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(role.name)).map((role) => (
|
||||||
<label key={role.id} className="flex items-center gap-2">
|
<label key={role.id} className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -533,7 +538,28 @@ function UserModal({
|
|||||||
<Shield className="w-4 h-4 text-blue-600" />
|
<Shield className="w-4 h-4 text-blue-600" />
|
||||||
DSGVO-Zugriff
|
DSGVO-Zugriff
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-500">(Audit-Logs, Datenschutz)</span>
|
<span className="text-sm text-gray-500">(Audit-Protokoll lesen, Datenschutz)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.hasAuditOpsAccess}
|
||||||
|
onChange={(e) => setFormData({ ...formData, hasAuditOpsAccess: e.target.checked })}
|
||||||
|
className="mt-1 rounded border-amber-300 text-amber-600 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Shield className="w-4 h-4 text-amber-600" />
|
||||||
|
Audit-Betrieb
|
||||||
|
<span className="text-sm text-gray-500">(versiegeln, aufräumen, Aufbewahrung)</span>
|
||||||
|
</span>
|
||||||
|
<span className="block text-sm text-gray-500 mt-0.5">
|
||||||
|
Erlaubt Eingriffe am Audit-Protokoll: Bestandssiegel setzen oder
|
||||||
|
ersetzen, aufräumen, Aufbewahrungsfristen ändern. Bewusst getrennt
|
||||||
|
vom DSGVO-Zugriff – wer das Protokoll beaufsichtigt, soll seine
|
||||||
|
eigene Beweisgrundlage nicht ersetzen können.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1548,11 +1548,12 @@ export const userApi = {
|
|||||||
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
|
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean; isServiceAccount?: boolean; confirm?: string; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string }) => {
|
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean; isServiceAccount?: boolean; confirm?: string; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string }) => {
|
||||||
const res = await api.post<ApiResponse<User>>('/users', data);
|
const res = await api.post<ApiResponse<User>>('/users', data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
update: async (id: number, data: Partial<User> & { password?: string; roleIds?: number[]; isServiceAccount?: boolean; confirm?: string }) => {
|
update: async (id: number, data: Partial<User> & { password?: string; roleIds?: number[]; isServiceAccount?: boolean; hasGdprAccess?: boolean; hasDeveloperAccess?: boolean; hasAuditOpsAccess?: boolean; confirm?: string }) => {
|
||||||
const res = await api.put<ApiResponse<User>>(`/users/${id}`, data);
|
const res = await api.put<ApiResponse<User>>(`/users/${id}`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
@@ -1748,9 +1749,21 @@ export interface IntegrityResult {
|
|||||||
unexplainedGaps: number[];
|
unexplainedGaps: number[];
|
||||||
/** Alt-Luecken, die das Bestandssiegel als bereits vorhanden beglaubigt. */
|
/** Alt-Luecken, die das Bestandssiegel als bereits vorhanden beglaubigt. */
|
||||||
attestedGaps: number[];
|
attestedGaps: number[];
|
||||||
|
/**
|
||||||
|
* Protokollierte Neuberechnungen der Kette. Nach einem Rehash ist die Kette
|
||||||
|
* zwangslaeufig stimmig - auch ueber Loeschungen hinweg. Die Aussage der
|
||||||
|
* Pruefung reicht dann nur bis zur letzten Neuberechnung zurueck.
|
||||||
|
*/
|
||||||
|
rehashes: Array<{
|
||||||
|
id: number;
|
||||||
|
zeitpunkt: string;
|
||||||
|
neuBerechnet: number | null;
|
||||||
|
signiert: boolean;
|
||||||
|
vorbefund: { manipuliert: number; luecken: number } | null;
|
||||||
|
}>;
|
||||||
/** Signierte Zeilen, die ohne AUDIT_HMAC_KEY nicht pruefbar sind. */
|
/** Signierte Zeilen, die ohne AUDIT_HMAC_KEY nicht pruefbar sind. */
|
||||||
unverifiableEntries: number[];
|
unverifiableEntries: number[];
|
||||||
backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
|
backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
|
||||||
backlogTampered: number[];
|
backlogTampered: number[];
|
||||||
backlogMissing: number[];
|
backlogMissing: number[];
|
||||||
backlogSealCount: number;
|
backlogSealCount: number;
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ PROD_ADOPT_ACK=
|
|||||||
# durchwinken.
|
# durchwinken.
|
||||||
PROD_SEAL_ACK=
|
PROD_SEAL_ACK=
|
||||||
|
|
||||||
|
# Normalerweise leer lassen.
|
||||||
|
# Gültige Werte: die ID des Rehash-Eintrags | (leer)
|
||||||
|
# Das Gegenbuch schlägt Alarm, wenn die Hash-Kette neu berechnet wurde. Ein
|
||||||
|
# Rehash verknüpft alle Einträge neu – Lücken, die eine Löschung sichtbar
|
||||||
|
# gemacht hätten, verschwinden dabei aus der Kette, und die Prüfung im CRM
|
||||||
|
# meldet danach wieder „lückenlos". War die Neuberechnung geplant, hier die
|
||||||
|
# ID eintragen, die der Alarm nennt, einmal laufen lassen und wieder leeren.
|
||||||
|
PROD_REHASH_ACK=
|
||||||
|
|
||||||
# Wo das Buch liegt – relativ zu diesem Verzeichnis.
|
# Wo das Buch liegt – relativ zu diesem Verzeichnis.
|
||||||
# DIESES VERZEICHNIS GEHÖRT INS BACKUP (enthält Buch und Signaturschlüssel).
|
# DIESES VERZEICHNIS GEHÖRT INS BACKUP (enthält Buch und Signaturschlüssel).
|
||||||
PROD_DIR=./data/prod
|
PROD_DIR=./data/prod
|
||||||
@@ -83,4 +92,5 @@ STAGING_INTERVAL=3600
|
|||||||
STAGING_GENESIS_ACK=
|
STAGING_GENESIS_ACK=
|
||||||
STAGING_ADOPT_ACK=
|
STAGING_ADOPT_ACK=
|
||||||
STAGING_SEAL_ACK=
|
STAGING_SEAL_ACK=
|
||||||
|
STAGING_REHASH_ACK=
|
||||||
STAGING_DIR=./data/staging
|
STAGING_DIR=./data/staging
|
||||||
|
|||||||
+195
-40
@@ -11,6 +11,37 @@ kurzen Kontrollwert vom CRM, prüft ihn gegen seine eigene Historie und schreibt
|
|||||||
ihn signiert fort. Wird später im CRM etwas nachträglich verändert,
|
ihn signiert fort. Wird später im CRM etwas nachträglich verändert,
|
||||||
widerspricht das dem Gegenbuch.
|
widerspricht das dem Gegenbuch.
|
||||||
|
|
||||||
|
## Was du hier suchst
|
||||||
|
|
||||||
|
**Einrichten**
|
||||||
|
[Betriebsart wählen](#zwei-betriebsarten--erst-hier-entscheiden) ·
|
||||||
|
[mit Docker](#einrichten-mit-docker-empfohlen) ·
|
||||||
|
[erster Start](#erster-start--drei-schritte) ·
|
||||||
|
[Zugangskonto im CRM](#zugang-einrichten-das-brauchst-du-vorher) ·
|
||||||
|
[ohne Docker](#einrichten-ohne-docker)
|
||||||
|
|
||||||
|
**Im Betrieb**
|
||||||
|
[**Altbestand versiegeln**](#den-altbestand-versiegeln-einmalig-im-crm) ·
|
||||||
|
[Rückgabecodes](#rückgabecodes) ·
|
||||||
|
[Überwachung](#überwachung) ·
|
||||||
|
[wo die Daten liegen](#wo-die-daten-liegen)
|
||||||
|
|
||||||
|
**Wenn Alarm kommt**
|
||||||
|
[Siegelwurzel hat gewechselt](#wenn-sich-die-siegelwurzel-ändert-alarm-und-warum) ·
|
||||||
|
[Kette wurde neu berechnet](#wenn-die-kette-neu-berechnet-wurde-ebenfalls-alarm) ·
|
||||||
|
[Zurückspulen erkannt](#der-rewind-wächter) ·
|
||||||
|
[Gedächtnis fehlt](#wenn-das-gedächtnis-trotzdem-fehlt)
|
||||||
|
|
||||||
|
**Zum Nachlesen**
|
||||||
|
[was erkannt wird](#was-das-skript-erkennt) ·
|
||||||
|
[was nicht – ehrlich](#was-es-nicht-leistet--ehrlich) ·
|
||||||
|
[Prüfmodus für Auditoren](#prüfmodus-für-auditoren)
|
||||||
|
|
||||||
|
> **Nur schnell versiegeln?** Der Abschnitt
|
||||||
|
> [Altbestand versiegeln](#den-altbestand-versiegeln-einmalig-im-crm) ist
|
||||||
|
> eigenständig und gilt **auch ohne Gegenbuch** – es ist ein Vorgang im CRM,
|
||||||
|
> nicht hier.
|
||||||
|
|
||||||
## Zwei Betriebsarten – erst hier entscheiden
|
## Zwei Betriebsarten – erst hier entscheiden
|
||||||
|
|
||||||
| | **Lokal** (Normalfall) | **Mit externem Repository** |
|
| | **Lokal** (Normalfall) | **Mit externem Repository** |
|
||||||
@@ -86,57 +117,128 @@ soll nicht versehentlich passieren.
|
|||||||
getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln.
|
getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln.
|
||||||
Welche laufen, steuert `COMPOSE_PROFILES` in der `.env`.
|
Welche laufen, steuert `COMPOSE_PROFILES` in der `.env`.
|
||||||
|
|
||||||
### Wenn der erste Lauf `exit=2` meldet: den Altbestand versiegeln
|
### Den Altbestand versiegeln (einmalig, im CRM)
|
||||||
|
|
||||||
Ein CRM, das schon länger läuft, hat fast immer einen **Altbestand** – Einträge
|
Ein CRM, das schon länger läuft, hat fast immer einen **Altbestand** – Einträge
|
||||||
aus der Zeit, bevor das Protokoll signiert wurde. Solange der nicht versiegelt
|
aus der Zeit, bevor das Protokoll signiert wurde. Solange der nicht versiegelt
|
||||||
ist, meldet die Prüfung `valid: false`, und das Gegenbuch schlägt zu Recht
|
ist, meldet die Prüfung `valid: false`, das Gegenbuch schlägt zu Recht Alarm –
|
||||||
Alarm. Typischerweise steht dann im Log:
|
und, wichtig: **es beglaubigt so lange gar nichts.** Es bricht vor dem Anhängen
|
||||||
|
ab, weil ein Checkpoint über einen ungeklärten Zustand diesen mitbeglaubigen
|
||||||
|
würde. Ein unversiegeltes CRM ist also nicht „bewacht mit Warnung", sondern
|
||||||
|
unbewacht.
|
||||||
|
|
||||||
Hinweis: Der Altbestand ist nicht versiegelt – Änderungen daran wären
|
Das Siegeln ist **einmalig** und passiert im CRM, nicht hier.
|
||||||
nicht erkennbar.
|
|
||||||
|
|
||||||
Das ist **einmalig** zu erledigen, im CRM, nicht hier:
|
#### Wer darf das
|
||||||
|
|
||||||
|
Das Recht `audit:admin`. Das bekommt man über den Haken **„Audit-Betrieb"** in
|
||||||
|
der Benutzerverwaltung – **nicht** über die Admin-Rolle (die hat bewusst keine
|
||||||
|
Audit-Rechte) und **nicht** über den DSGVO-Haken (der darf lesen, nicht
|
||||||
|
eingreifen). Das Gegenbuch-Dienstkonto hat es erst recht nicht.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
TOKEN=$(curl -s -X POST https://<crm>/api/auth/login \
|
CRM=https://<crm>
|
||||||
-H 'Content-Type: application/json' \
|
read -s -p "Passwort: " PASS; echo
|
||||||
-d '{"email":"…","password":"…"}' | jq -r '.data.token')
|
AT=$(curl -s -X POST $CRM/api/auth/login -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"email\":\"…\",\"password\":\"$PASS\"}" | jq -r '.data.token')
|
||||||
curl -s -X POST https://<crm>/api/audit-logs/seal-backlog \
|
curl -s -X POST $CRM/api/auth/login -H 'Content-Type: application/json' \
|
||||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
-d "{\"email\":\"…\",\"password\":\"$PASS\"}" | jq -r '.data.user.permissions[]' | grep audit
|
||||||
-d '{"confirm":"SEAL"}' | jq
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Dafür braucht es das Recht `audit:admin` – das Gegenbuch-Dienstkonto hat es
|
Muss `audit:read` **und** `audit:admin` zeigen.
|
||||||
absichtlich **nicht**. Nimm dein Administratorkonto.
|
|
||||||
|
|
||||||
**Zuerst nachsehen, was versiegelt wird.** Das Siegel schreibt den aktuellen
|
#### Erst nachsehen, was du festschreibst
|
||||||
Zustand fest, samt aller vorhandenen Lücken. Wer blind siegelt, beglaubigt
|
|
||||||
gegebenenfalls auch eine Lücke, die von einer Löschung stammt. Deshalb vorher:
|
Das Siegel hält den **aktuellen** Zustand fest, samt aller vorhandenen Lücken.
|
||||||
|
Wer blind siegelt, beglaubigt womöglich eine Lücke, die von einer Löschung
|
||||||
|
stammt. Der Schritt ist praktisch einwegs: Danach ist die Beglaubigung tragend,
|
||||||
|
und ein Siegel wieder zu entfernen erzeugt den Zustand `entfernt` – also selbst
|
||||||
|
einen Befund.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST https://<crm>/api/audit-logs/verify \
|
B="Authorization: Bearer $AT"
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
curl -s -X POST $CRM/api/audit-logs/verify -H "$B" \
|
||||||
| jq '.data | {valid, chainGaps, unexplainedGaps, tamperedEntries}'
|
| jq '.data | {valid, checkedCount, rehashes, chainGaps, unexplainedGaps,
|
||||||
|
tamperedEntries, backlogSealStatus}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Stehen dort Lücken, sieh dir die betroffenen IDs und ihre Nachbarn an
|
Vier Dinge müssen stimmen:
|
||||||
(`GET /api/audit-logs/<id>`). Liegen sie jeweils **innerhalb einer Sekunde**
|
|
||||||
zusammen mit ihren Nachbarn und fehlt kein Eintrag (kein `404`), sind es
|
|
||||||
Schreibkollisionen aus paralleln Anfragen – harmlos. Fehlt dagegen ein Eintrag
|
|
||||||
oder steht etwas in `tamperedEntries`, **erst klären, dann siegeln**.
|
|
||||||
|
|
||||||
Nach dem Siegeln meldet die Prüfung wieder `valid: true`, und die bekannten
|
| Feld | Erwartung | Warum |
|
||||||
Alt-Lücken erscheinen als **beglaubigt**:
|
|---|---|---|
|
||||||
|
| `rehashes` | `[]` | Eine Neuberechnung verknüpft alles neu – danach ist die Kette *zwangsläufig* stimmig, auch über Löschungen hinweg. Steht hier etwas, sagt `chainGaps` nichts über die Zeit davor aus. |
|
||||||
|
| `tamperedEntries` | `[]` | Veränderter Inhalt gehört geklärt, nicht beglaubigt. |
|
||||||
|
| `chainGaps` | erklärbar | siehe unten |
|
||||||
|
| `backlogSealStatus` | `kein_siegel` | sonst ist es kein Erst-Siegeln (siehe nächster Abschnitt) |
|
||||||
|
|
||||||
Alle Einträge sind unverändert. Seit dem Bestandssiegel ist keine neue
|
**Lücken einordnen.** Sieh dir die betroffenen IDs und ihre Nachbarn an:
|
||||||
Lücke entstanden. 6 Lücken stammen aus der Zeit vor dem Bestandssiegel
|
|
||||||
und sind darin als Vorbefund beglaubigt (IDs …).
|
|
||||||
|
|
||||||
Sie verschwinden also nicht aus dem Bericht – sie zählen nur nicht mehr als
|
```bash
|
||||||
offener Befund. Jede **neue** Lücke, jede veränderte oder entfernte Altzeile
|
for id in <lücke-1> <nachbar> …; do
|
||||||
und jedes gebrochene Siegel lösen weiterhin sofort Alarm aus.
|
printf '%-6s ' "$id"
|
||||||
|
curl -s -o /tmp/r.json -w 'HTTP %{http_code} ' "$CRM/api/audit-logs/$id" -H "$B"
|
||||||
|
jq -r 'if .success then (.data|"\(.createdAt[0:19]) v\(.hashVersion) \(.action)/\(.resourceType)") else .error end' /tmp/r.json
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Den HTTP-Code mit ausgeben, immer.** Zugangstoken laufen nach 15 Minuten ab.
|
||||||
|
> Ohne den Code liest sich eine abgelehnte Anfrage (401) wie ein fehlender
|
||||||
|
> Eintrag – ein Prüfwerkzeug, das ein verweigertes Lesen als Löschung meldet.
|
||||||
|
> Uns ist genau das passiert, mitten in der Vorbereitung eines Prod-Siegels.
|
||||||
|
|
||||||
|
Liegen die Lücken jeweils **innerhalb einer Sekunde** zusammen mit ihren
|
||||||
|
Nachbarn, betreffen nur `Authentication` und fehlt kein Eintrag, sind es
|
||||||
|
Schreibkollisionen aus parallelen Anfragen – harmlos, historisch, nicht mehr
|
||||||
|
reproduzierbar. Fehlt dagegen ein Eintrag oder passt eine Lücke nicht in dieses
|
||||||
|
Bild: **erst klären, dann siegeln.**
|
||||||
|
|
||||||
|
*(Ein abgeschnittener **Anfang** des Protokolls wird seit dem Anfangs-Detektor
|
||||||
|
mitgeprüft: Die erste Zeile eines Protokolls trägt einen leeren `previousHash`;
|
||||||
|
trägt die erste vorhandene Zeile einen Wert, fehlt eine Vorgängerin. Das
|
||||||
|
erscheint als ganz niedrige Lücke und kippt `valid` – du musst es nicht selbst
|
||||||
|
suchen.)*
|
||||||
|
|
||||||
|
#### Siegeln
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST $CRM/api/audit-logs/seal-backlog -H "$B" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"confirm":"SEAL"}' | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
`SEAL` ist das Erst-Siegeln. Besteht bereits ein Siegel, verlangt der Endpunkt
|
||||||
|
stattdessen `RESEAL` – siehe nächster Abschnitt. Notiere `root` aus der Antwort.
|
||||||
|
|
||||||
|
#### Danach: drei Punkte gegenprüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST $CRM/api/audit-logs/verify -H "$B" \
|
||||||
|
| jq '.data | {valid, chainGaps, attestedGaps, backlogSealStatus, backlogSealCount}'
|
||||||
|
|
||||||
|
SID=$(curl -s "$CRM/api/audit-logs?resourceType=AuditBacklogSeal&limit=1" -H "$B" | jq -r '.data[0].id')
|
||||||
|
curl -s "$CRM/api/audit-logs/$SID" -H "$B" \
|
||||||
|
| jq '{vorbefund:.data.changesBefore.befund.ketten_luecken, siegel:.data.changesAfter}'
|
||||||
|
```
|
||||||
|
|
||||||
|
1. `valid: true`, und `chainGaps` = `attestedGaps` = die bekannten Lücken
|
||||||
|
2. `backlogSealStatus: "intakt"` – **nicht** `leer`. `leer` heißt: Das Siegel
|
||||||
|
umschließt nichts, es gab keinen Altbestand. Kein Fehler, aber auch keine
|
||||||
|
Zusage.
|
||||||
|
3. Die Lücken stehen im `changesBefore.befund.ketten_luecken` des Markers –
|
||||||
|
genau daran hängt die Beglaubigung.
|
||||||
|
|
||||||
|
Die Lücken verschwinden also **nicht** aus dem Bericht. Sie zählen nur nicht
|
||||||
|
mehr als offener Befund. Jede **neue** Lücke, jede veränderte oder entfernte
|
||||||
|
Altzeile und jedes gebrochene Siegel lösen weiterhin sofort Alarm aus.
|
||||||
|
|
||||||
|
#### Zuletzt: das Gegenbuch quittieren
|
||||||
|
|
||||||
|
Der nächste Lauf meldet **einmal** `exit=2` („Erstmals ein Bestandssiegel
|
||||||
|
gesetzt") – erwartet, kein Befund. Wurzel aus `data/<instanz>/status.txt` in
|
||||||
|
`PROD_SEAL_ACK` bzw. `STAGING_SEAL_ACK` eintragen, `docker compose up -d`, nach
|
||||||
|
dem Lauf mit `exit=0` wieder leeren. Details im nächsten Abschnitt.
|
||||||
|
|
||||||
|
Ab da schreibt das Gegenbuch wieder Checkpoints fort – und setzt im selben Lauf
|
||||||
|
die Grundlage für die Rehash-Überwachung.
|
||||||
|
|
||||||
### Wenn sich die Siegelwurzel ändert: Alarm, und warum
|
### Wenn sich die Siegelwurzel ändert: Alarm, und warum
|
||||||
|
|
||||||
@@ -177,6 +279,58 @@ Im CRM selbst ist erneutes Siegeln zusätzlich gegatet: es verlangt
|
|||||||
`{"confirm":"RESEAL"}` statt `{"confirm":"SEAL"}` — ein Austausch der
|
`{"confirm":"RESEAL"}` statt `{"confirm":"SEAL"}` — ein Austausch der
|
||||||
Beweisgrundlage soll nicht dasselbe Wort haben wie das Einrichten.
|
Beweisgrundlage soll nicht dasselbe Wort haben wie das Einrichten.
|
||||||
|
|
||||||
|
### Wenn die Kette neu berechnet wurde: ebenfalls Alarm
|
||||||
|
|
||||||
|
Ein **Rehash** verknüpft alle Einträge neu. Danach ist die Kette
|
||||||
|
zwangsläufig stimmig — auch über Löschungen hinweg, die vorher als Lücken
|
||||||
|
sichtbar gewesen wären. Die Reihenfolge `cleanup` → `rehash` macht aus einem
|
||||||
|
beschnittenen Protokoll ein scheinbar makelloses, und die Prüfung im CRM meldet
|
||||||
|
danach wieder „lückenlos verkettet". Beides braucht nur `audit:admin`, keinen
|
||||||
|
Datenbankzugriff.
|
||||||
|
|
||||||
|
Genau das ist im Betrieb vorgekommen: Auf einer Testinstanz wurden 3.155
|
||||||
|
Einträge gelöscht und anschließend neu berechnet. Die Prüfung war danach grün,
|
||||||
|
die 656 Kettenlücken standen nur noch im Vorbefund des Rehash-Eintrags — den
|
||||||
|
niemand liest. Das Gegenbuch war der einzige Zeuge.
|
||||||
|
|
||||||
|
Deshalb ist eine neue Neuberechnung seit der letzten Beglaubigung ein **Alarm**
|
||||||
|
(exit 2). Er nennt Zeitpunkt, Zahl der betroffenen Zeilen und den Befund, der
|
||||||
|
unmittelbar davor galt:
|
||||||
|
|
||||||
|
```
|
||||||
|
ALARM: Die Hash-Kette wurde neu berechnet (1 neuer Vorgang seit der letzten Beglaubigung).
|
||||||
|
zuletzt: 2026-08-26T16:28:25.467Z (Eintrag 13, 9 Zeilen)
|
||||||
|
Befund unmittelbar davor: 1 beanstandet, 1 Lücken
|
||||||
|
…
|
||||||
|
NOTARY_REHASH_ACK=13
|
||||||
|
```
|
||||||
|
|
||||||
|
**War die Neuberechnung gewollt**, bestätigst du sie mit der genannten ID:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# in der .env
|
||||||
|
PROD_REHASH_ACK=13
|
||||||
|
docker compose up -d
|
||||||
|
PROD_REHASH_ACK= # danach wieder leeren
|
||||||
|
```
|
||||||
|
|
||||||
|
Auch hier wird nicht mit `true` bestätigt, sondern mit einem Wert, der zum
|
||||||
|
Vorgang gehört. IDs steigen streng — ein stehen gelassener Wert passt bei der
|
||||||
|
nächsten Neuberechnung nicht mehr.
|
||||||
|
|
||||||
|
**Warum ein eigener Melder, wo doch schon der Kettenkopf verglichen wird?**
|
||||||
|
Eine Neuberechnung ändert jeden Hash, der beglaubigte Kopf stimmt also
|
||||||
|
ohnehin nicht mehr — der Alarm käme auch so. Aber als *Nebenwirkung*, nicht als
|
||||||
|
gebaute Warnung: Verschöbe sich der Anker irgendwann, wäre der Melder lautlos
|
||||||
|
weg. Und die Meldung hieße „Eintrag wurde verändert" statt „die Kette wurde neu
|
||||||
|
berechnet" — die Wirkung statt der Ursache. Deshalb hängt der Alarm an der
|
||||||
|
Sache selbst und steht **vor** dem Kopf-Vergleich; nach einer bestätigten
|
||||||
|
Neuberechnung wird dieser übersprungen, weil der veränderte Kopf dann die
|
||||||
|
erwartete Folge ist.
|
||||||
|
|
||||||
|
Ein fehlender beglaubigter Eintrag bleibt davon unberührt und alarmiert immer:
|
||||||
|
Bestätigt wird die Neuberechnung, nicht das Verschwinden von Zeilen.
|
||||||
|
|
||||||
### Zugang einrichten (das brauchst du vorher)
|
### Zugang einrichten (das brauchst du vorher)
|
||||||
|
|
||||||
Das Gegenbuch braucht ein **eigenes Benutzerkonto** im CRM – kein Token. Der
|
Das Gegenbuch braucht ein **eigenes Benutzerkonto** im CRM – kein Token. Der
|
||||||
@@ -195,12 +349,12 @@ Im CRM, als Administrator:
|
|||||||
4. E-Mail und Passwort in die `.env` des Gegenbuchs eintragen
|
4. E-Mail und Passwort in die `.env` des Gegenbuchs eintragen
|
||||||
(`PROD_CRM_EMAIL` / `PROD_CRM_PASSWORD`).
|
(`PROD_CRM_EMAIL` / `PROD_CRM_PASSWORD`).
|
||||||
|
|
||||||
> **Nicht den DSGVO-Haken benutzen.** Der sieht naheliegend aus („Audit-Logs,
|
> **Weder den DSGVO- noch den Audit-Betrieb-Haken setzen.** Der DSGVO-Haken
|
||||||
> Datenschutz"), vergibt aber `audit:` **komplett** – einschließlich
|
> gibt zusätzlich Leserechte auf personenbezogene Daten und den Export; der
|
||||||
> `audit:admin` mit `seal-backlog`, `rehash` und `cleanup`. Ein Einbruch auf
|
> Haken „Audit-Betrieb" gibt `audit:admin` mit `seal-backlog`, `rehash` und
|
||||||
> dieser Maschine hätte damit nicht nur den Wächter, sondern gleich die Mittel,
|
> `cleanup`. Ein Einbruch auf dieser Maschine hätte damit nicht nur den
|
||||||
> das Bewachte umzuschreiben. Das Passwort steht hier im Klartext in der
|
> Wächter, sondern gleich die Mittel, das Bewachte umzuschreiben. Das Passwort
|
||||||
> `.env`; es muss so wenig wert sein wie möglich.
|
> steht hier im Klartext in der `.env`; es muss so wenig wert sein wie möglich.
|
||||||
|
|
||||||
Mit `audit:read` allein kann dieses Konto **nur Prüfwerte lesen** – keine
|
Mit `audit:read` allein kann dieses Konto **nur Prüfwerte lesen** – keine
|
||||||
Kundendaten, keine Verträge, nichts ändern und nichts versiegeln. Selbst wenn
|
Kundendaten, keine Verträge, nichts ändern und nichts versiegeln. Selbst wenn
|
||||||
@@ -556,6 +710,7 @@ an, bis er geklärt ist.
|
|||||||
| Bestandssiegel-Blätter entfernt | beglaubigte Blattzahl auf null gefallen |
|
| Bestandssiegel-Blätter entfernt | beglaubigte Blattzahl auf null gefallen |
|
||||||
| **Altzeile gelöscht und neu gesiegelt** (Wäsche) | **Wurzel des Bestandssiegels hat gewechselt – `valid` allein bleibt dabei `true`** |
|
| **Altzeile gelöscht und neu gesiegelt** (Wäsche) | **Wurzel des Bestandssiegels hat gewechselt – `valid` allein bleibt dabei `true`** |
|
||||||
| Erstmals gesiegelt, ohne dass es jemand veranlasst hat | vorher keine Wurzel beglaubigt, jetzt eine |
|
| Erstmals gesiegelt, ohne dass es jemand veranlasst hat | vorher keine Wurzel beglaubigt, jetzt eine |
|
||||||
|
| **`cleanup` + `rehash` ohne erneutes Siegeln** (Wäsche ohne Datenbankzugriff) | **neue Neuberechnung seit der letzten Beglaubigung – `valid` und Kette sind danach makellos** |
|
||||||
|
|
||||||
Bei jedem dieser Fälle bricht das Skript mit **Exit-Code 2** ab und **hängt
|
Bei jedem dieser Fälle bricht das Skript mit **Exit-Code 2** ab und **hängt
|
||||||
nichts an** – der manipulierte Zustand wird also nicht als neue Wahrheit
|
nichts an** – der manipulierte Zustand wird also nicht als neue Wahrheit
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ services:
|
|||||||
NOTARY_ADOPT_ACK: ${PROD_ADOPT_ACK:-}
|
NOTARY_ADOPT_ACK: ${PROD_ADOPT_ACK:-}
|
||||||
# Nur nach einem GEWOLLTEN Siegelwechsel, mit der neuen Wurzel:
|
# Nur nach einem GEWOLLTEN Siegelwechsel, mit der neuen Wurzel:
|
||||||
NOTARY_SEAL_ACK: ${PROD_SEAL_ACK:-}
|
NOTARY_SEAL_ACK: ${PROD_SEAL_ACK:-}
|
||||||
|
# Nur nach einer GEWOLLTEN Neuberechnung, mit der ID des Rehash-Eintrags:
|
||||||
|
NOTARY_REHASH_ACK: ${PROD_REHASH_ACK:-}
|
||||||
volumes:
|
volumes:
|
||||||
# Buch, Schlüssel, Beobachtungsspeicher und Statusdatei.
|
# Buch, Schlüssel, Beobachtungsspeicher und Statusdatei.
|
||||||
# Dieses Verzeichnis ist das Gegenbuch – es gehört ins Backup.
|
# Dieses Verzeichnis ist das Gegenbuch – es gehört ins Backup.
|
||||||
@@ -56,5 +58,6 @@ services:
|
|||||||
NOTARY_GENESIS_ACK: ${STAGING_GENESIS_ACK:-}
|
NOTARY_GENESIS_ACK: ${STAGING_GENESIS_ACK:-}
|
||||||
NOTARY_ADOPT_ACK: ${STAGING_ADOPT_ACK:-}
|
NOTARY_ADOPT_ACK: ${STAGING_ADOPT_ACK:-}
|
||||||
NOTARY_SEAL_ACK: ${STAGING_SEAL_ACK:-}
|
NOTARY_SEAL_ACK: ${STAGING_SEAL_ACK:-}
|
||||||
|
NOTARY_REHASH_ACK: ${STAGING_REHASH_ACK:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ${STAGING_DIR:-./data/staging}:/gegenbuch
|
- ${STAGING_DIR:-./data/staging}:/gegenbuch
|
||||||
|
|||||||
@@ -70,8 +70,16 @@ fi
|
|||||||
# Logs zu durchsuchen.
|
# Logs zu durchsuchen.
|
||||||
while true; do
|
while true; do
|
||||||
ZEIT=$(date -Iseconds)
|
ZEIT=$(date -Iseconds)
|
||||||
node /opt/notary/notary.mjs
|
# Ausgabe mitschneiden, um den GRUND in die Statusdatei zu bekommen.
|
||||||
|
#
|
||||||
|
# Vorher stand dort nur das Etikett ("BEFUND"), und das ist fuer jeden
|
||||||
|
# Exit-2 dasselbe - ein echter Widerspruch sieht darin aus wie eine
|
||||||
|
# quittierpflichtige Erstsiegelung. Wer nur die Statusdatei liest, kann
|
||||||
|
# nicht entscheiden, ob er handeln muss. Genau die Sorte Signal, gegen die
|
||||||
|
# dieses Werkzeug gebaut ist.
|
||||||
|
node /opt/notary/notary.mjs > /tmp/lauf.log 2>&1
|
||||||
CODE=$?
|
CODE=$?
|
||||||
|
cat /tmp/lauf.log
|
||||||
case $CODE in
|
case $CODE in
|
||||||
0) LAGE="in Ordnung" ;;
|
0) LAGE="in Ordnung" ;;
|
||||||
1) LAGE="Betriebsfehler (Konfiguration, CRM nicht erreichbar, Commit)" ;;
|
1) LAGE="Betriebsfehler (Konfiguration, CRM nicht erreichbar, Commit)" ;;
|
||||||
@@ -82,6 +90,18 @@ while true; do
|
|||||||
*) LAGE="unbekannter Code" ;;
|
*) LAGE="unbekannter Code" ;;
|
||||||
esac
|
esac
|
||||||
echo "[$INSTANZ] $ZEIT exit=$CODE ($LAGE)"
|
echo "[$INSTANZ] $ZEIT exit=$CODE ($LAGE)"
|
||||||
printf '%s exit=%s %s\n' "$ZEIT" "$CODE" "$LAGE" > /gegenbuch/status.txt
|
# Die aussagekraeftigste Zeile mitnehmen, nicht einfach die erste.
|
||||||
|
#
|
||||||
|
# Bei Alarm ist das die ALARM-Zeile. Sonst die Zusammenfassung ("OK: …") -
|
||||||
|
# denn ein Lauf kann davor beilaeufige Hinweise ausgeben (etwa das einmalige
|
||||||
|
# Setzen einer Ueberwachungs-Grundlage), und die verdeckten sonst das
|
||||||
|
# Ergebnis. Erst wenn beides fehlt, die erste Zeile ueberhaupt.
|
||||||
|
GRUND=$(grep -m1 '^ALARM: ' /tmp/lauf.log | sed 's/^ALARM: //')
|
||||||
|
[ -z "$GRUND" ] && GRUND=$(grep -m1 '^OK: ' /tmp/lauf.log)
|
||||||
|
[ -z "$GRUND" ] && GRUND=$(grep -m1 -v '^[[:space:]]*$' /tmp/lauf.log)
|
||||||
|
{
|
||||||
|
printf '%s exit=%s %s\n' "$ZEIT" "$CODE" "$LAGE"
|
||||||
|
[ -n "$GRUND" ] && printf 'Grund: %s\n' "$GRUND"
|
||||||
|
} > /gegenbuch/status.txt
|
||||||
sleep "$INTERVALL"
|
sleep "$INTERVALL"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -153,6 +153,17 @@ const SEAL_ACK = (process.env.NOTARY_SEAL_ACK || '').trim();
|
|||||||
const siegelBestaetigt = (wurzel) =>
|
const siegelBestaetigt = (wurzel) =>
|
||||||
SEAL_ACK.length >= 16 && !!wurzel && wurzel.startsWith(SEAL_ACK);
|
SEAL_ACK.length >= 16 && !!wurzel && wurzel.startsWith(SEAL_ACK);
|
||||||
|
|
||||||
|
// Bestaetigung fuer eine Neuberechnung der Kette (Pentest R186, Frage (b)).
|
||||||
|
//
|
||||||
|
// Bestaetigt wird mit der ID des juengsten Rehash-Eintrags. IDs steigen
|
||||||
|
// streng, ein stehen gelassener Wert passt also beim naechsten Rehash nicht
|
||||||
|
// mehr - dasselbe selbstentwertende Prinzip wie bei NOTARY_SEAL_ACK, nur mit
|
||||||
|
// einem Wert, den man vorlesen kann.
|
||||||
|
const REHASH_ACK = (process.env.NOTARY_REHASH_ACK || '').trim();
|
||||||
|
// Wurde in diesem Lauf eine Neuberechnung ausdruecklich bestaetigt? Dann ist
|
||||||
|
// der veraenderte Kettenkopf die erwartete Folge und kein eigener Befund.
|
||||||
|
let rehashBestaetigt = false;
|
||||||
|
|
||||||
if (!CRM_URL) {
|
if (!CRM_URL) {
|
||||||
console.error('CRM_URL muss gesetzt sein.');
|
console.error('CRM_URL muss gesetzt sein.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -755,6 +766,17 @@ const letzter = bisher[bisher.length - 1];
|
|||||||
if (!CRM_TOKEN) await anmelden();
|
if (!CRM_TOKEN) await anmelden();
|
||||||
const aktuell = await hole('/api/audit-logs/checkpoint');
|
const aktuell = await hole('/api/audit-logs/checkpoint');
|
||||||
|
|
||||||
|
// Die Vollpruefung wird IMMER geholt, auch beim allerersten Lauf: Aus ihr
|
||||||
|
// stammt die Zahl der protokollierten Neuberechnungen, und die gehoert schon
|
||||||
|
// in den ersten Eintrag - sonst gaebe es beim zweiten Lauf keine Grundlage,
|
||||||
|
// gegen die sich ein neuer Rehash abheben koennte.
|
||||||
|
const pruefung = await hole('/api/audit-logs/verify', 'POST');
|
||||||
|
const rehashListe = Array.isArray(pruefung?.rehashes) ? pruefung.rehashes : null;
|
||||||
|
const rehashAnzahl = rehashListe ? rehashListe.length : null;
|
||||||
|
const rehashLetzte = rehashListe && rehashListe.length
|
||||||
|
? rehashListe[rehashListe.length - 1].id
|
||||||
|
: null;
|
||||||
|
|
||||||
if (letzter) {
|
if (letzter) {
|
||||||
if (aktuell.maxId !== null && aktuell.maxId < letzter.maxId) {
|
if (aktuell.maxId !== null && aktuell.maxId < letzter.maxId) {
|
||||||
alarm(
|
alarm(
|
||||||
@@ -767,7 +789,6 @@ if (letzter) {
|
|||||||
// Eintraege endgueltig loeschen und die Luecken per Tombstone als „erklaert“
|
// Eintraege endgueltig loeschen und die Luecken per Tombstone als „erklaert“
|
||||||
// ausweisen – die Meldung liest sich dann harmlos. Fuer das Gegenbuch zaehlt
|
// ausweisen – die Meldung liest sich dann harmlos. Fuer das Gegenbuch zaehlt
|
||||||
// das Feld, nicht der Satz.
|
// das Feld, nicht der Satz.
|
||||||
const pruefung = await hole('/api/audit-logs/verify', 'POST');
|
|
||||||
if (pruefung && pruefung.valid === false) {
|
if (pruefung && pruefung.valid === false) {
|
||||||
alarm(
|
alarm(
|
||||||
'Die Prüfung im CRM meldet die Kette als NICHT unversehrt (valid: false).\n' +
|
'Die Prüfung im CRM meldet die Kette als NICHT unversehrt (valid: false).\n' +
|
||||||
@@ -779,8 +800,77 @@ if (letzter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`);
|
const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`);
|
||||||
|
// Ein fehlender beglaubigter Eintrag ist IMMER ein Befund - auch wenn eine
|
||||||
|
// Neuberechnung bestaetigt wurde. Bestaetigt wird der Rehash, nicht das
|
||||||
|
// Verschwinden von Zeilen.
|
||||||
if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`);
|
if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`);
|
||||||
if (rueck.atHash !== letzter.chainHead) {
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Neuberechnung der Kette (Rehash) als EIGENE Alarmbedingung.
|
||||||
|
//
|
||||||
|
// Ein Rehash wurde bisher nur als Nebenwirkung gefangen: Er aendert jeden
|
||||||
|
// Hash, also stimmt der beglaubigte Kettenkopf nicht mehr und der Vergleich
|
||||||
|
// weiter oben schlaegt an. Das funktioniert - aber es ist ein Zufallstreffer
|
||||||
|
// und keine gebaute Warnung. Verschoebe sich der Anker irgendwann (anderer
|
||||||
|
// beglaubigter Wert, anderer Vergleich), waere der Melder lautlos weg.
|
||||||
|
//
|
||||||
|
// Deshalb haengt der Alarm jetzt an der Waffe selbst, nicht an ihrer Spur -
|
||||||
|
// dieselbe Lehre wie R184-01 (Gate am Ausloeser statt an der Wirkung) und
|
||||||
|
// R185-01 (Wurzelwechsel statt `valid`).
|
||||||
|
//
|
||||||
|
// Das Restrisiko, das dieser Melder abdeckt: `cleanup` + `rehash` OHNE
|
||||||
|
// erneutes Siegeln, ueber Inhalt, der gar nicht versiegelt ist. Dort greift
|
||||||
|
// weder das Bestandssiegel noch der Wurzelwechsel.
|
||||||
|
if (rehashAnzahl !== null && typeof letzter.rehashCount === 'number') {
|
||||||
|
if (rehashAnzahl > letzter.rehashCount) {
|
||||||
|
const neue = rehashAnzahl - letzter.rehashCount;
|
||||||
|
if (REHASH_ACK && String(rehashLetzte) === REHASH_ACK) {
|
||||||
|
rehashBestaetigt = true;
|
||||||
|
console.log(
|
||||||
|
`Neuberechnung bestätigt (NOTARY_REHASH_ACK=${REHASH_ACK}). ` +
|
||||||
|
'NOTARY_REHASH_ACK danach wieder leeren.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const l = rehashListe[rehashListe.length - 1];
|
||||||
|
const vb = l.vorbefund;
|
||||||
|
alarm(
|
||||||
|
`Die Hash-Kette wurde neu berechnet (${neue} neue${neue === 1 ? 'r' : ''} Vorgang` +
|
||||||
|
`${neue === 1 ? '' : 'e'} seit der letzten Beglaubigung).\n` +
|
||||||
|
` zuletzt: ${l.zeitpunkt} (Eintrag ${l.id}, ${l.neuBerechnet} Zeilen)\n` +
|
||||||
|
(vb
|
||||||
|
? ` Befund unmittelbar davor: ${vb.manipuliert} beanstandet, ${vb.luecken} Lücken\n`
|
||||||
|
: ' Der Zustand davor ist nicht mehr feststellbar.\n') +
|
||||||
|
(l.signiert ? '' : ' ACHTUNG: Dieser Rehash-Eintrag trägt keine gültige Signatur.\n') +
|
||||||
|
'Ein Rehash verknüpft alle Einträge neu. Lücken, die vorher eine Löschung\n' +
|
||||||
|
'sichtbar gemacht hätten, verschwinden dabei aus der Kette – die Prüfung im CRM\n' +
|
||||||
|
'meldet danach wieder „lückenlos". Genau deshalb ist das hier ein Alarm und\n' +
|
||||||
|
'kein Hinweis.\n' +
|
||||||
|
'War es geplant, bestätigen und danach wieder leeren:\n' +
|
||||||
|
` NOTARY_REHASH_ACK=${rehashLetzte}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (rehashAnzahl !== null && typeof letzter.rehashCount !== 'number') {
|
||||||
|
// Gegenbuch aus der Zeit vor diesem Melder: Es gibt keine Grundlage, gegen
|
||||||
|
// die sich etwas abheben koennte. Stillschweigend zu alarmieren waere ein
|
||||||
|
// Fehlalarm bei jedem Bestandsbuch; stillschweigend zu uebernehmen waere
|
||||||
|
// eine unsichtbare Annahme. Also: uebernehmen und es sagen.
|
||||||
|
console.log(
|
||||||
|
`Grundlage für die Rehash-Überwachung wird gesetzt: ${rehashAnzahl} protokollierte ` +
|
||||||
|
'Neuberechnung(en) im CRM. Ab dem nächsten Lauf fällt jede weitere auf.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Der Kopf-Hash-Vergleich steht bewusst NACH der Rehash-Pruefung.
|
||||||
|
//
|
||||||
|
// Zwei Gruende: Erstens benennt der Rehash-Alarm die Ursache, waehrend
|
||||||
|
// „Eintrag wurde veraendert“ nur die Wirkung beschreibt - bei gleicher Lage
|
||||||
|
// ist die praezisere Meldung die nuetzlichere. Zweitens aendert eine
|
||||||
|
// Neuberechnung ZWANGSLAEUFIG jeden Hash; stuende dieser Vergleich davor
|
||||||
|
// oder ungeschuetzt dahinter, liesse sich eine bestaetigte Neuberechnung nie
|
||||||
|
// aufloesen und die Bestaetigung waere wertlos.
|
||||||
|
if (rueck.atHash !== letzter.chainHead && !rehashBestaetigt) {
|
||||||
alarm(
|
alarm(
|
||||||
`Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` +
|
`Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` +
|
||||||
` beglaubigt: ${letzter.chainHead}\n jetzt : ${rueck.atHash}`,
|
` beglaubigt: ${letzter.chainHead}\n jetzt : ${rueck.atHash}`,
|
||||||
@@ -910,6 +1000,9 @@ const eintrag = {
|
|||||||
chainHead: aktuell.chainHead,
|
chainHead: aktuell.chainHead,
|
||||||
sealRoot: aktuell.sealRoot,
|
sealRoot: aktuell.sealRoot,
|
||||||
sealLeafCount: aktuell.sealLeafCount,
|
sealLeafCount: aktuell.sealLeafCount,
|
||||||
|
// Grundlage fuer die Rehash-Ueberwachung des naechsten Laufs.
|
||||||
|
rehashCount: rehashAnzahl,
|
||||||
|
rehashLast: rehashLetzte,
|
||||||
};
|
};
|
||||||
const neuerInhalt = [...bisher, eintrag].map((e) => JSON.stringify(e)).join('\n') + '\n';
|
const neuerInhalt = [...bisher, eintrag].map((e) => JSON.stringify(e)).join('\n') + '\n';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user