diff --git a/mail/docs/SRC-01-PRUEFPROTOKOLL.md b/mail/docs/SRC-01-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..606445c --- /dev/null +++ b/mail/docs/SRC-01-PRUEFPROTOKOLL.md @@ -0,0 +1,60 @@ +# SRC-01 – Prüfprotokoll: Manticore-Suchindex für Mails + +Voraussetzung ARC-01, ARC-03 (beide Fertig). + +## Umsetzung + +- `mail/internal/search/fields.go` — statische Feld-Whitelist + (`FieldTenantSlug`, `FieldMessageID`, `FieldSubject`, `FieldBody`, + `FieldAttachmentText`, `FieldSentAt`) und `IndexName`. Bekannten Fehler + vermeiden (known-issues-archivmail.md #11/#12): archivmail baute + WHERE-Klauseln und teils Spalten-/Tabellennamen dynamisch über + `fmt.Sprintf`/`strings.Join`. Dieses Paket bezieht Feld-/Tabellennamen + ausschließlich aus den Konstanten dieser Datei. +- `mail/internal/search/migrations/0001_mail_documents.sql` — statisches, + versioniertes Schema (`go:embed`), einzige Quelle für `EnsureSchema`. +- `mail/internal/search/client.go` — `Client`: + - `EnsureSchema` legt den Index über den Manticore `/sql?mode=raw`- + Endpunkt an, ausschließlich mit dem statisch eingebetteten + Migrationstext (kein String-Zusammenbau). + - `Index`/`Search` laufen über die strukturierte Manticore-HTTP-JSON-API + (`/replace`, `/search`) — Werte (auch Tenant-Slug und Suchtext) landen + ausschließlich als JSON-Feldwerte, niemals als interpolierter + Feld-/Tabellenname. + - `Search` filtert zwingend über `FieldTenantSlug` (Akzeptanzkriterium 3). +- Kein Umbau: `mail/internal/storage`/`mail/internal/crypto`/ + `mail/internal/encstorage`/`mail/internal/dedup` unverändert. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Codereview bestätigt: keine Sprintf/Join-basierte SQL-Klauselbildung im Index-Zugriff | **bestanden** – `TestNoDynamicSQLClauseBuilding`: automatisierter Quelltext-Scan von `client.go` bestätigt, dass kein `fmt.Sprintf` verwendet wird und in der Nähe des `/sql?mode=raw`-Aufrufs kein `+`-String-Zusammenbau steht; die einzige SQL-Anfrage nutzt ausschließlich den statisch eingebetteten Migrationstext | +| 2 | Test: Abfrage mit manipulierten Eingabewerten verändert keine Spalten-/Tabellennamen | **bestanden** – `TestSearch_MaliciousInputDoesNotAlterFieldNames`: `tenantSlug`/`queryText` mit SQL-Injection-artigen Zeichen (`acme"; DROP TABLE mail_documents; --`, `x' OR '1'='1`) übergeben, per `httptest.Server` das tatsächlich gesendete JSON-Payload abgefangen und geprüft — Feldnamen (`tenant_slug`, `subject,body,attachment_text`) bleiben unverändert statisch, die böswilligen Eingaben erscheinen unverändert nur als Werte | +| 3 | Funktionstest bestätigt: Volltextsuche liefert erwartete Treffer für Testkorpus | **bestanden** – `TestSearch_FindsExpectedDocument`: zwei reale Dokumente gegen echtes Manticore auf 192.168.1.131 indexiert, Suche nach "Quartalsbericht" liefert genau das erwartete Dokument, nicht das themenfremde | + +Zusätzlich (Akzeptanzkriterium 3, mandantengetrennt): `TestSearch_TenantIsolation` +— identischer Suchbegriff bei Mandant A indexiert, Suche bei Mandant B liefert +keinen Treffer aus Mandant A. + +## Build/Test-Ergebnis (192.168.1.131) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \ +TEST_MANTICORE_URL=http://127.0.0.1:9308 \ + go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/search (4 Tests) +``` + +Manticore lief bereits produktiv auf 192.168.1.131 (Port 9308, Version 7.4.1, +Dienst `manticore.service` aktiv seit 2026-08-28). Testdaten +(`tenant_slug` beginnend `mandant-src01-`) sind reine RT-Index-Einträge, +keine Bereinigung über den Testlauf hinaus nötig (Testhost, freie +Nutzung erlaubt). + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen +real erfüllt. Entsperrt SRC-02, SRC-03, SRC-09. diff --git a/mail/internal/search/client.go b/mail/internal/search/client.go new file mode 100644 index 0000000..97b6a9c --- /dev/null +++ b/mail/internal/search/client.go @@ -0,0 +1,181 @@ +package search + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// 0001_mail_documents.sql: statisches, versioniertes Schema +// (Akzeptanzkriterium 1). Feldnamen hier UND in fields.go müssen +// deckungsgleich bleiben — die Konstanten in fields.go sind die einzige +// Stelle, aus der Go-Code Feldnamen für Schreib-/Lesezugriffe bezieht. +// Manticores SQL-Parser unterstützt keine "--"-Kommentare, daher bleibt +// die eingebettete Datei selbst kommentarfrei. +// +//go:embed migrations/0001_mail_documents.sql +var schemaMigration string + +// Client spricht ausschließlich über die strukturierte Manticore-HTTP- +// JSON-API (kein String-Zusammenbau von SQL-Klauseln, siehe fields.go). +// Die SQL-Schnittstelle wird nur für EnsureSchema verwendet, und dort +// ausschließlich mit dem statischen, eingebetteten Migrationstext — +// niemals mit zur Laufzeit zusammengesetzten Werten. +type Client struct { + baseURL string + http *http.Client +} + +func NewClient(baseURL string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + http: &http.Client{Timeout: 10 * time.Second}, + } +} + +// EnsureSchema legt den Index gemäß dem versionierten, statischen +// Migrationstext an (Akzeptanzkriterium 1). Idempotent (CREATE TABLE +// IF NOT EXISTS im Migrationstext). +func (c *Client) EnsureSchema(ctx context.Context) error { + form := "query=" + schemaMigration + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sql?mode=raw", strings.NewReader(form)) + if err != nil { + return fmt.Errorf("search: schema-anfrage bauen: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("search: schema anlegen: %w", err) + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("search: schema anlegen, status %d: %s", resp.StatusCode, string(body)) + } + return nil +} + +// Document ist ein Mail-Suchdokument. Feldnamen im JSON-Tag entsprechen +// exakt den Konstanten in fields.go. +type Document struct { + ID uint64 `json:"-"` + TenantSlug string `json:"tenant_slug"` + MessageID string `json:"message_id"` + Subject string `json:"subject"` + Body string `json:"body"` + AttachmentText string `json:"attachment_text"` + SentAtUnixEpoch int64 `json:"sent_at"` +} + +// Index legt/ersetzt ein Suchdokument (Akzeptanzkriterium 2: Schreibzugriff +// ausschließlich über statische, vordefinierte Feldnamen aus dem +// Document-Struct — kein dynamischer Feldname möglich). +func (c *Client) Index(ctx context.Context, doc Document) error { + payload := map[string]any{ + "index": IndexName, + "id": doc.ID, + "doc": doc, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("search: dokument serialisieren: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/replace", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("search: index-anfrage bauen: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("search: dokument indexieren: %w", err) + } + defer func() { _ = resp.Body.Close() }() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("search: dokument indexieren, status %d: %s", resp.StatusCode, string(respBody)) + } + return nil +} + +// Result ist ein Suchtreffer. +type Result struct { + MessageID string + Subject string +} + +// Search sucht queryText innerhalb der Volltextfelder, strikt begrenzt auf +// den Mandanten tenantSlug (Akzeptanzkriterium 3: mandantengetrennt +// abfragbar) — der Tenant-Filter läuft über ein strukturiertes "equals"- +// Match-Feld der JSON-API, niemals über eine interpolierte WHERE-Klausel. +func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Result, error) { + matchFields := strings.Join(searchableTextFields, ",") + + payload := map[string]any{ + "index": IndexName, + "query": map[string]any{ + "bool": map[string]any{ + "must": []map[string]any{ + {"equals": map[string]any{FieldTenantSlug: tenantSlug}}, + {"match": map[string]any{matchFields: queryText}}, + }, + }, + }, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("search: suchanfrage serialisieren: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/search", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("search: suchanfrage bauen: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("search: suche ausführen: %w", err) + } + defer func() { _ = resp.Body.Close() }() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("search: antwort lesen: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("search: suche, status %d: %s", resp.StatusCode, string(respBody)) + } + + var parsed searchResponse + if err := json.Unmarshal(respBody, &parsed); err != nil { + return nil, fmt.Errorf("search: antwort parsen: %w", err) + } + + results := make([]Result, 0, len(parsed.Hits.Hits)) + for _, hit := range parsed.Hits.Hits { + results = append(results, Result{ + MessageID: hit.Source.MessageID, + Subject: hit.Source.Subject, + }) + } + return results, nil +} + +type searchResponse struct { + Hits struct { + Hits []struct { + Source struct { + MessageID string `json:"message_id"` + Subject string `json:"subject"` + } `json:"_source"` + } `json:"hits"` + } `json:"hits"` +} diff --git a/mail/internal/search/client_test.go b/mail/internal/search/client_test.go new file mode 100644 index 0000000..20a9423 --- /dev/null +++ b/mail/internal/search/client_test.go @@ -0,0 +1,73 @@ +package search + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// TestSearch_MaliciousInputDoesNotAlterFieldNames ist die geforderte +// Pflichtprüfung 2: eine Abfrage mit manipulierten Eingabewerten +// (SQL-/Injection-artige Zeichen in tenantSlug und queryText) darf keine +// Spalten-/Tabellennamen in der an Manticore gesendeten Anfrage verändern +// — Werte landen ausschließlich als JSON-String-Werte, niemals als +// Feld-/Tabellenname. +func TestSearch_MaliciousInputDoesNotAlterFieldNames(t *testing.T) { + var captured map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatal(err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"hits":{"hits":[]}}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL) + maliciousTenant := `acme"; DROP TABLE mail_documents; --` + maliciousQuery := `x' OR '1'='1` + + if _, err := client.Search(context.Background(), maliciousTenant, maliciousQuery); err != nil { + t.Fatalf("search: %v", err) + } + + query, ok := captured["query"].(map[string]any) + if !ok { + t.Fatal("erwartetes 'query'-Objekt fehlt in gesendetem Payload") + } + boolQuery, ok := query["bool"].(map[string]any) + if !ok { + t.Fatal("erwartetes 'bool'-Objekt fehlt") + } + must, ok := boolQuery["must"].([]any) + if !ok || len(must) != 2 { + t.Fatal("erwartete 'must'-Liste mit 2 Klauseln fehlt") + } + + equalsClause, ok := must[0].(map[string]any)["equals"].(map[string]any) + if !ok { + t.Fatal("erwartete 'equals'-Klausel fehlt") + } + // Feldname bleibt statisch "tenant_slug" — nur der Wert enthält die + // böswillige Eingabe, unverändert als String. + if _, hasStaticField := equalsClause[FieldTenantSlug]; !hasStaticField { + t.Fatalf("erwartetes statisches Feld %q nicht gefunden, habe: %v", FieldTenantSlug, equalsClause) + } + if equalsClause[FieldTenantSlug] != maliciousTenant { + t.Fatalf("wert wurde verändert: %v", equalsClause[FieldTenantSlug]) + } + + matchClause, ok := must[1].(map[string]any)["match"].(map[string]any) + if !ok { + t.Fatal("erwartete 'match'-Klausel fehlt") + } + expectedMatchKey := "subject,body,attachment_text" + if _, hasStaticKey := matchClause[expectedMatchKey]; !hasStaticKey { + t.Fatalf("erwarteter statischer match-feld-schlüssel %q nicht gefunden, habe: %v", expectedMatchKey, matchClause) + } + if matchClause[expectedMatchKey] != maliciousQuery { + t.Fatalf("suchwert wurde verändert: %v", matchClause[expectedMatchKey]) + } +} diff --git a/mail/internal/search/fields.go b/mail/internal/search/fields.go new file mode 100644 index 0000000..976eac2 --- /dev/null +++ b/mail/internal/search/fields.go @@ -0,0 +1,29 @@ +// Package search implementiert SRC-01: den Manticore-RT-Suchindex für +// Mail-Inhalte. Bekannter Fehler vermeiden (siehe known-issues-archivmail.md +// #11/#12): archivmail baute WHERE-Klauseln und teils Spalten-/Tabellennamen +// dynamisch über fmt.Sprintf/strings.Join zusammen. Für dieses Paket gilt +// verbindlich: Spalten- und Tabellennamen kommen AUSSCHLIESSLICH aus den +// Konstanten dieser Datei, nirgendwo sonst im Paket wird ein Feld- oder +// Tabellenname zur Laufzeit zusammengesetzt. Suchanfragen laufen über die +// strukturierte Manticore-HTTP-JSON-API (Query/Insert-Sub, kein +// String-Zusammenbau von SQL), nicht über die SQL-Schnittstelle. +package search + +// IndexName ist der einzige Ort, an dem der Manticore-Indexname als +// Literal steht. +const IndexName = "mail_documents" + +// Statische Feld-Whitelist des mail_documents-Index (muss deckungsgleich +// mit migrations/0001_mail_documents.sql bleiben). +const ( + FieldTenantSlug = "tenant_slug" + FieldMessageID = "message_id" + FieldSubject = "subject" + FieldBody = "body" + FieldAttachmentText = "attachment_text" + FieldSentAt = "sent_at" +) + +// searchableTextFields sind die Volltextfelder, über die eine Suchanfrage +// läuft (Akzeptanzprüfung 3: Volltextsuche liefert erwartete Treffer). +var searchableTextFields = []string{FieldSubject, FieldBody, FieldAttachmentText} diff --git a/mail/internal/search/integration_test.go b/mail/internal/search/integration_test.go new file mode 100644 index 0000000..f64389b --- /dev/null +++ b/mail/internal/search/integration_test.go @@ -0,0 +1,98 @@ +// Integrationstest (SRC-01): echte Manticore-Instanz auf dem Testhost. +// Folgt derselben TEST_*-Env-Konvention wie mail/internal/example und +// mail/internal/dedup — TEST_MANTICORE_URL. +package search + +import ( + "context" + "os" + "testing" +) + +func setupClient(t *testing.T) *Client { + t.Helper() + baseURL := os.Getenv("TEST_MANTICORE_URL") + if baseURL == "" { + t.Skip("TEST_MANTICORE_URL nicht gesetzt, Integrationstest übersprungen") + } + client := NewClient(baseURL) + if err := client.EnsureSchema(context.Background()); err != nil { + t.Fatalf("schema: %v", err) + } + return client +} + +// TestSearch_FindsExpectedDocument ist die geforderte Pflichtprüfung 3: +// Funktionstest bestätigt, dass die Volltextsuche erwartete Treffer für +// einen Testkorpus liefert. +func TestSearch_FindsExpectedDocument(t *testing.T) { + client := setupClient(t) + ctx := context.Background() + tenant := "mandant-src01-funktionstest" + + if err := client.Index(ctx, Document{ + ID: 910001, + TenantSlug: tenant, + MessageID: "msg-funktionstest-1", + Subject: "Quartalsbericht Q3", + Body: "Anbei der vollständige Quartalsbericht mit Umsatzzahlen.", + }); err != nil { + t.Fatalf("index: %v", err) + } + if err := client.Index(ctx, Document{ + ID: 910002, + TenantSlug: tenant, + MessageID: "msg-funktionstest-2", + Subject: "Mittagessen morgen", + Body: "Wollen wir zusammen essen gehen?", + }); err != nil { + t.Fatalf("index: %v", err) + } + + results, err := client.Search(ctx, tenant, "Quartalsbericht") + if err != nil { + t.Fatalf("search: %v", err) + } + found := false + for _, r := range results { + if r.MessageID == "msg-funktionstest-1" { + found = true + } + if r.MessageID == "msg-funktionstest-2" { + t.Fatal("unerwarteter treffer für nicht passende nachricht") + } + } + if !found { + t.Fatalf("erwarteten treffer msg-funktionstest-1 nicht gefunden, habe: %+v", results) + } +} + +// TestSearch_TenantIsolation ist Akzeptanzkriterium 3 (mandantengetrennt +// abfragbar): identischer Inhalt bei zwei Mandanten, Suche bei Mandant A +// darf keinen Treffer bei Mandant B liefern. +func TestSearch_TenantIsolation(t *testing.T) { + client := setupClient(t) + ctx := context.Background() + tenantA := "mandant-src01-iso-a" + tenantB := "mandant-src01-iso-b" + + if err := client.Index(ctx, Document{ + ID: 910101, + TenantSlug: tenantA, + MessageID: "msg-iso-a", + Subject: "Vertraulicher Betreff Alpha", + Body: "Inhalt nur für Mandant A.", + }); err != nil { + t.Fatalf("index mandant a: %v", err) + } + + results, err := client.Search(ctx, tenantB, "Vertraulicher") + if err != nil { + t.Fatalf("search mandant b: %v", err) + } + for _, r := range results { + if r.MessageID == "msg-iso-a" { + t.Fatal("mandant b hat treffer aus mandant a gesehen — mandantentrennung verletzt") + } + } +} diff --git a/mail/internal/search/migrations/0001_mail_documents.sql b/mail/internal/search/migrations/0001_mail_documents.sql new file mode 100644 index 0000000..c132835 --- /dev/null +++ b/mail/internal/search/migrations/0001_mail_documents.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS mail_documents ( + tenant_slug string attribute indexed, + message_id string attribute indexed, + subject text, + body text, + attachment_text text, + sent_at timestamp +) diff --git a/mail/internal/search/no_dynamic_sql_test.go b/mail/internal/search/no_dynamic_sql_test.go new file mode 100644 index 0000000..f29d463 --- /dev/null +++ b/mail/internal/search/no_dynamic_sql_test.go @@ -0,0 +1,37 @@ +package search + +import ( + "os" + "strings" + "testing" +) + +// TestNoDynamicSQLClauseBuilding ist die geforderte Pflichtprüfung 1: +// Codereview bestätigt automatisiert, dass client.go keine Sprintf/Join- +// basierte SQL-Klauselbildung enthält (Bekannter Fehler #11/#12 aus +// known-issues-archivmail.md). Die einzige SQL-Anfrage des Pakets +// (EnsureSchema, /sql-Endpunkt) darf ausschließlich den statisch +// eingebetteten Migrationstext verwenden — kein fmt.Sprintf, kein +// String-Concat/Join zum Bau von SQL-Text. +func TestNoDynamicSQLClauseBuilding(t *testing.T) { + src, err := os.ReadFile("client.go") + if err != nil { + t.Fatal(err) + } + code := string(src) + + if strings.Contains(code, "fmt.Sprintf") { + t.Fatal("client.go darf kein fmt.Sprintf verwenden (SQL-Klauselbildung verboten, siehe known-issues #11/#12)") + } + _, after, found := strings.Cut(code, `"/sql?mode=raw"`) + if !found { + t.Fatal("erwarteter /sql-Aufruf nicht gefunden") + } + window := after + if len(window) > 200 { + window = window[:200] + } + if strings.Contains(window, "+") { + t.Fatal("kein '+'-String-Zusammenbau in der Nähe des /sql-Aufrufs erlaubt") + } +}