From fb5fdab9cf73c40cfd035338f47900a5ebffb8a2 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 17 Sep 2026 17:00:40 +0200 Subject: [PATCH] =?UTF-8?q?CMP-03:=20modul=C3=BCbergreifende=20eDiscovery-?= =?UTF-8?q?Suche=20&=20pr=C3=BCfsummengesicherter=20Export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ediscovery.ModuleAdapter ist die schmale Schnittstelle, ueber die ein Modul (DMS, Mail, ...) Treffer liefert und Originalinhalte herausgibt - dieselbe Adapter-Disziplin wie RET-01/RET-05, Archive kennt die Fachobjekte anderer Module nicht. Reale Module binden das spaeter per HTTP-Client an ihre eigene REST-API (Mail hat mit INT-01/mailapi bereits einen Endpunkt dafuer, DMS noch nicht) - diese Kachel prueft die Aggregations-/Exportlogik gegen zwei fiktive Testmodule (Ticket-Vorgabe). Search fragt alle Module ab und liefert eine gemeinsame Trefferliste; ein einzelnes fehlschlagendes Modul verschluckt nicht die Treffer der uebrigen. Export baut ein ZIP (Standardbibliothek archive/zip, keine neue Abhaengigkeit) mit Originaldateien, manifest.json und checksums.txt - Pruefsummen nach echtem Entpacken verifiziert. Suchlauf und Export werden ueber audittrail.Recorder (CMP-04) an Core AUD-08 gemeldet - die Unveraenderlichkeit des Protokolleintrags nutzt den bereits in AUD-07 verifizierten Append-only-Schutz, statt ein eigenes Protokoll zu bauen. Co-Authored-By: Claude Sonnet 5 --- archive/docs/CMP-03-PRUEFPROTOKOLL.md | 30 +++ archive/internal/ediscovery/ediscovery.go | 152 ++++++++++++++ .../internal/ediscovery/ediscovery_test.go | 197 ++++++++++++++++++ 3 files changed, 379 insertions(+) create mode 100644 archive/docs/CMP-03-PRUEFPROTOKOLL.md create mode 100644 archive/internal/ediscovery/ediscovery.go create mode 100644 archive/internal/ediscovery/ediscovery_test.go diff --git a/archive/docs/CMP-03-PRUEFPROTOKOLL.md b/archive/docs/CMP-03-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..8821b0bf --- /dev/null +++ b/archive/docs/CMP-03-PRUEFPROTOKOLL.md @@ -0,0 +1,30 @@ +# CMP-03 – Prüfprotokoll + +Stand: 2026-09-17. Testhost 192.168.1.131. + +## Prüfung 1: Testsuche über zwei fiktive Module liefert erwartete kombinierte Trefferzahl + +`go test ./internal/ediscovery/... -run TestSearch_CombinesHitsFromMultipleModules -v` — PASS. +Zwei fiktive Testmodule (`dms_document` mit 2 Objekten, `mail_message` mit 1 Objekt) liefern +zusammen 3 Treffer in einer gemeinsamen Liste. Zusätzlich `TestSearch_OneFailingModuleDoesNotSwallowOtherHits` +— PASS: ein fehlschlagendes drittes Modul verschluckt die Treffer der funktionierenden Module nicht. + +## Prüfung 2: Prüfsumme jeder exportierten Datei stimmt nach Entpacken überein + +`go test ./internal/ediscovery/... -run TestExport_ChecksumsMatchAfterUnzip -v` — PASS. Export von +3 Objekten aus 2 fiktiven Modulen zu einem echten ZIP (Go-Standardbibliothek `archive/zip`), +Wiederherstellung über einen echten `zip.Reader`, SHA-256 jeder entpackten Datei gegen den +`manifest.json`-Eintrag verglichen — stimmt für alle 3 Dateien überein. `checksums.txt` ist +zusätzlich im Export enthalten. + +## Prüfung 3: Protokolleintrag zum Suchlauf ist nachträglich nicht veränderbar + +Suchlauf UND Export werden über `audittrail.Recorder` (CMP-04) an Core AUD-08 gemeldet +(`ediscovery.search_executed` bzw. `ediscovery.export_created`, mit Kriterien/Trefferzahl bzw. +Objektanzahl als Metadaten) — bestätigt in `TestSearch_CombinesHitsFromMultipleModules` und +`TestExport_ChecksumsMatchAfterUnzip` (Assertion auf den beim Mock-Core empfangenen Aktionsnamen). + +Die Unveränderlichkeit selbst ist bereits durch Core AUD-07 (technische Durchsetzung Append-only, +DB-Trigger auf `audit_events`, siehe `docs/TESTSTRATEGIE-CORE.md`) real gegen Postgres verifiziert +— diese Kachel muss das nicht erneut beweisen, sondern nur nachweisen, dass sie tatsächlich über +diesen Pfad protokolliert statt ein eigenes, separates Protokoll zu führen. Nachweis erbracht. diff --git a/archive/internal/ediscovery/ediscovery.go b/archive/internal/ediscovery/ediscovery.go new file mode 100644 index 00000000..b2ad468c --- /dev/null +++ b/archive/internal/ediscovery/ediscovery.go @@ -0,0 +1,152 @@ +// Package ediscovery implementiert CMP-03: modulübergreifende Suche über +// registrierte Objekttypen (DMS, Mail, ...) für Rechtsverfahren, mit +// prüfsummengesichertem ZIP-Export der Treffer. Archive kennt die +// Fachobjekte anderer Module nicht im Detail (dieselbe Adapter-Disziplin +// wie RET-05/RET-01) — ModuleAdapter ist die schmale Schnittstelle, über +// die ein Modul Treffer liefert und Originalinhalte herausgibt. +package ediscovery + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/audittrail" +) + +// ObjectRef benennt EIN Treffer-Objekt eines Moduls — nur Typ, Referenz +// und Anzeigename, keine Fachdaten (Archive kennt die Fachobjekte anderer +// Module nicht). +type ObjectRef struct { + ModuleType string + Reference string + Filename string +} + +// ModuleAdapter ist die Schnittstelle, über die ein Modul (DMS, Mail, ...) +// an der eDiscovery-Suche teilnimmt. Reale Module binden das später über +// einen HTTP-Client an ihre eigene REST-API (analog rbacclient/kekclient) - +// diese Kachel selbst prüft die Aggregations-/Exportlogik gegen fiktive +// Testmodule (Prüfung 1). +type ModuleAdapter interface { + ModuleType() string + Search(ctx context.Context, criteria string) ([]ObjectRef, error) + FetchContent(ctx context.Context, reference string) ([]byte, error) +} + +// Search fragt ALLE übergebenen Module ab und liefert eine gemeinsame +// Trefferliste (Akzeptanzkriterium 1). Ein einzelnes fehlschlagendes +// Modul bricht die Gesamtsuche nicht ab — sein Fehler wird zurückgegeben, +// die übrigen Treffer bleiben erhalten (kein Alles-oder-nichts bei +// mehreren angebundenen Modulen). +func Search(ctx context.Context, recorder *audittrail.Recorder, tenantSlug, actor, criteria string, adapters []ModuleAdapter) ([]ObjectRef, []error) { + var hits []ObjectRef + var errs []error + for _, a := range adapters { + refs, err := a.Search(ctx, criteria) + if err != nil { + errs = append(errs, fmt.Errorf("ediscovery: modul %q: %w", a.ModuleType(), err)) + continue + } + hits = append(hits, refs...) + } + + if recorder != nil { + _ = recorder.Record(ctx, tenantSlug, actor, "ediscovery.search_executed", criteria, map[string]any{ + "kriterien": criteria, + "trefferzahl": len(hits), + "module_fehler": len(errs), + }) + } + return hits, errs +} + +// ManifestEntry ist EIN Eintrag der im Export enthaltenen manifest.json - +// Metadaten UND Prüfsumme je exportierter Datei (Akzeptanzkriterium 2). +type ManifestEntry struct { + ModuleType string `json:"module_type"` + Reference string `json:"reference"` + Filename string `json:"filename"` + ChecksumSHA256 string `json:"checksum_sha256"` + SizeBytes int `json:"size_bytes"` +} + +// Export lädt den Originalinhalt jedes Treffers über das passende Modul- +// Adapter herunter und baut ein ZIP mit den Originaldateien +// (unter "/"), einer manifest.json (Metadaten je +// Datei) und einer checksums.txt (Prüfsummenliste, Akzeptanzkriterium 2). +// adaptersByModuleType muss für jeden in refs vorkommenden ModuleType +// einen Eintrag haben. +func Export(ctx context.Context, recorder *audittrail.Recorder, tenantSlug, actor string, adaptersByModuleType map[string]ModuleAdapter, refs []ObjectRef) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + var manifest []ManifestEntry + var checksumLines bytes.Buffer + + for _, ref := range refs { + adapter, ok := adaptersByModuleType[ref.ModuleType] + if !ok { + return nil, fmt.Errorf("ediscovery: kein adapter fuer modultyp %q registriert", ref.ModuleType) + } + content, err := adapter.FetchContent(ctx, ref.Reference) + if err != nil { + return nil, fmt.Errorf("ediscovery: inhalt fuer %s/%s abrufen: %w", ref.ModuleType, ref.Reference, err) + } + + path := ref.ModuleType + "/" + ref.Filename + fw, err := zw.Create(path) + if err != nil { + return nil, fmt.Errorf("ediscovery: zip-eintrag %q anlegen: %w", path, err) + } + if _, err := fw.Write(content); err != nil { + return nil, fmt.Errorf("ediscovery: zip-eintrag %q schreiben: %w", path, err) + } + + sum := sha256.Sum256(content) + checksum := hex.EncodeToString(sum[:]) + manifest = append(manifest, ManifestEntry{ + ModuleType: ref.ModuleType, Reference: ref.Reference, Filename: ref.Filename, + ChecksumSHA256: checksum, SizeBytes: len(content), + }) + fmt.Fprintf(&checksumLines, "%s %s\n", checksum, path) + } + + manifestJSON, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return nil, fmt.Errorf("ediscovery: manifest kodieren: %w", err) + } + if err := writeZipEntry(zw, "manifest.json", manifestJSON); err != nil { + return nil, err + } + if err := writeZipEntry(zw, "checksums.txt", checksumLines.Bytes()); err != nil { + return nil, err + } + + if err := zw.Close(); err != nil { + return nil, fmt.Errorf("ediscovery: zip abschliessen: %w", err) + } + + if recorder != nil { + _ = recorder.Record(ctx, tenantSlug, actor, "ediscovery.export_created", fmt.Sprintf("%d objekte", len(refs)), map[string]any{ + "anzahl_objekte": len(refs), + }) + } + + return buf.Bytes(), nil +} + +func writeZipEntry(zw *zip.Writer, name string, content []byte) error { + fw, err := zw.Create(name) + if err != nil { + return fmt.Errorf("ediscovery: zip-eintrag %q anlegen: %w", name, err) + } + if _, err := fw.Write(content); err != nil { + return fmt.Errorf("ediscovery: zip-eintrag %q schreiben: %w", name, err) + } + return nil +} diff --git a/archive/internal/ediscovery/ediscovery_test.go b/archive/internal/ediscovery/ediscovery_test.go new file mode 100644 index 00000000..98e9e1e2 --- /dev/null +++ b/archive/internal/ediscovery/ediscovery_test.go @@ -0,0 +1,197 @@ +package ediscovery + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/auditclient" + "gitea.perlbach24.de/scripte/nexarch/archive/internal/audittrail" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// fakeModule ist ein fiktives Testmodul (Prüfung 1: "Testsuche über zwei +// fiktive Module") — hält seine Objekte im Speicher, keine echte DMS-/ +// Mail-Anbindung. +type fakeModule struct { + moduleType string + objects map[string][]byte // reference -> content +} + +func (m *fakeModule) ModuleType() string { return m.moduleType } + +func (m *fakeModule) Search(ctx context.Context, criteria string) ([]ObjectRef, error) { + var refs []ObjectRef + for ref := range m.objects { + refs = append(refs, ObjectRef{ModuleType: m.moduleType, Reference: ref, Filename: ref + ".bin"}) + } + return refs, nil +} + +func (m *fakeModule) FetchContent(ctx context.Context, reference string) ([]byte, error) { + return m.objects[reference], nil +} + +func newTestRecorder(t *testing.T) (*audittrail.Recorder, *[]string) { + t.Helper() + var receivedActions []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Action string `json:"action"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + receivedActions = append(receivedActions, body.Action) + w.WriteHeader(http.StatusAccepted) + })) + t.Cleanup(srv.Close) + // Kein echter Pool noetig, solange Core erreichbar ist (kein Puffer-Fall). + return audittrail.NewRecorder(auditclient.New(srv.URL, "tok"), (*pgxpool.Pool)(nil)), &receivedActions +} + +// Akzeptanzkriterium 1 + Pruefung 1: Testsuche ueber zwei fiktive Module +// liefert die erwartete kombinierte Trefferzahl in einer gemeinsamen Liste. +func TestSearch_CombinesHitsFromMultipleModules(t *testing.T) { + moduleA := &fakeModule{moduleType: "dms_document", objects: map[string][]byte{ + "doc-1": []byte("dms-inhalt-1"), "doc-2": []byte("dms-inhalt-2"), + }} + moduleB := &fakeModule{moduleType: "mail_message", objects: map[string][]byte{ + "mail-1": []byte("mail-inhalt-1"), + }} + recorder, actions := newTestRecorder(t) + + hits, errs := Search(context.Background(), recorder, "tenant_a", "pruefer:eDiscovery", "vertrag", []ModuleAdapter{moduleA, moduleB}) + + if len(errs) != 0 { + t.Fatalf("erwartet keine modul-fehler, hatte: %v", errs) + } + if len(hits) != 3 { + t.Fatalf("erwartet 3 kombinierte treffer (2 dms + 1 mail), hatte %d", len(hits)) + } + byModule := map[string]int{} + for _, h := range hits { + byModule[h.ModuleType]++ + } + if byModule["dms_document"] != 2 || byModule["mail_message"] != 1 { + t.Fatalf("erwartete verteilung 2/1, hatte %+v", byModule) + } + + if len(*actions) != 1 || (*actions)[0] != "ediscovery.search_executed" { + t.Fatalf("erwartet 1 protokollierter suchlauf, hatte: %v", *actions) + } +} + +// Ein fehlschlagendes Modul darf die uebrigen Treffer nicht verschlucken. +type failingModule struct{} + +func (failingModule) ModuleType() string { return "broken_module" } +func (failingModule) Search(ctx context.Context, criteria string) ([]ObjectRef, error) { + return nil, errBoom +} +func (failingModule) FetchContent(ctx context.Context, reference string) ([]byte, error) { + return nil, errBoom +} + +var errBoom = &testError{"modul nicht erreichbar"} + +type testError struct{ msg string } + +func (e *testError) Error() string { return e.msg } + +func TestSearch_OneFailingModuleDoesNotSwallowOtherHits(t *testing.T) { + moduleA := &fakeModule{moduleType: "dms_document", objects: map[string][]byte{"doc-1": []byte("x")}} + recorder, _ := newTestRecorder(t) + + hits, errs := Search(context.Background(), recorder, "tenant_a", "pruefer", "irrelevant", []ModuleAdapter{moduleA, failingModule{}}) + + if len(hits) != 1 { + t.Fatalf("erwartet 1 treffer vom funktionierenden modul, hatte %d", len(hits)) + } + if len(errs) != 1 { + t.Fatalf("erwartet 1 fehler vom kaputten modul, hatte %d", len(errs)) + } +} + +// Akzeptanzkriterium 2 + Pruefung 2: Export enthaelt Originalobjekte, +// Metadaten und eine Pruefsummenliste — Pruefsumme jeder Datei stimmt nach +// Entpacken ueberein. +func TestExport_ChecksumsMatchAfterUnzip(t *testing.T) { + moduleA := &fakeModule{moduleType: "dms_document", objects: map[string][]byte{ + "doc-1": []byte("vertragsinhalt eins"), "doc-2": []byte("vertragsinhalt zwei, laenger"), + }} + moduleB := &fakeModule{moduleType: "mail_message", objects: map[string][]byte{ + "mail-1": []byte("mail-anhang-inhalt"), + }} + recorder, actions := newTestRecorder(t) + + refs := []ObjectRef{ + {ModuleType: "dms_document", Reference: "doc-1", Filename: "doc-1.bin"}, + {ModuleType: "dms_document", Reference: "doc-2", Filename: "doc-2.bin"}, + {ModuleType: "mail_message", Reference: "mail-1", Filename: "mail-1.bin"}, + } + adapters := map[string]ModuleAdapter{"dms_document": moduleA, "mail_message": moduleB} + + zipBytes, err := Export(context.Background(), recorder, "tenant_a", "pruefer:eDiscovery", adapters, refs) + if err != nil { + t.Fatalf("Export: %v", err) + } + + zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes))) + if err != nil { + t.Fatalf("zip oeffnen: %v", err) + } + + var manifest []ManifestEntry + filesByPath := map[string][]byte{} + for _, f := range zr.File { + rc, err := f.Open() + if err != nil { + t.Fatalf("zip-eintrag %q oeffnen: %v", f.Name, err) + } + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(rc); err != nil { + t.Fatalf("zip-eintrag %q lesen: %v", f.Name, err) + } + _ = rc.Close() + filesByPath[f.Name] = buf.Bytes() + + if f.Name == "manifest.json" { + if err := json.Unmarshal(buf.Bytes(), &manifest); err != nil { + t.Fatalf("manifest.json dekodieren: %v", err) + } + } + } + + if len(manifest) != 3 { + t.Fatalf("erwartet 3 manifest-eintraege, hatte %d", len(manifest)) + } + for _, entry := range manifest { + path := entry.ModuleType + "/" + entry.Filename + content, ok := filesByPath[path] + if !ok { + t.Fatalf("erwartete datei %q nicht im zip gefunden", path) + } + sum := sha256.Sum256(content) + actual := hex.EncodeToString(sum[:]) + if actual != entry.ChecksumSHA256 { + t.Errorf("pruefsumme fuer %q stimmt nach entpacken nicht ueberein: manifest=%q tatsaechlich=%q", path, entry.ChecksumSHA256, actual) + } + if len(content) != entry.SizeBytes { + t.Errorf("groesse fuer %q stimmt nicht: manifest=%d tatsaechlich=%d", path, entry.SizeBytes, len(content)) + } + } + + if _, ok := filesByPath["checksums.txt"]; !ok { + t.Error("erwarte checksums.txt im export") + } + + if len(*actions) != 1 || (*actions)[0] != "ediscovery.export_created" { + t.Fatalf("erwartet 1 protokollierter export, hatte: %v", *actions) + } +}