Compare commits

...
Author SHA1 Message Date
sysopsandClaude Sonnet 5 c3bf8100b1 SRC-01: manticore-suchindex-fuer-mails
Manticore-RT-Index für Mail-Suchdokumente (Betreff, Text, Anhangstext,
Metadaten), statisches versioniertes Schema.

- fields.go: statische Feld-/Index-Namen-Whitelist, einzige Quelle für
  Feldnamen im Paket (vermeidet known-issues-archivmail.md #11/#12:
  Sprintf/Join-basierte SQL-Klauselbildung).
- migrations/0001_mail_documents.sql: statisches Schema, per go:embed
  eingebettet, über /sql?mode=raw angelegt (kein String-Zusammenbau).
- client.go: Index/Search über die strukturierte Manticore-HTTP-JSON-API,
  Tenant-Filter über strukturiertes equals-Feld statt WHERE-Interpolation.

Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-01-PRUEFPROTOKOLL.md):
1. TestNoDynamicSQLClauseBuilding: automatisierter Quelltext-Scan bestätigt
   keine Sprintf/Join-SQL-Klauselbildung.
2. TestSearch_MaliciousInputDoesNotAlterFieldNames: Injection-artige
   Eingaben verändern nachweislich keine Feldnamen im gesendeten Payload.
3. TestSearch_FindsExpectedDocument: Funktionstest gegen echtes Manticore
   auf 192.168.1.131 liefert erwartete Treffer.
Zusätzlich TestSearch_TenantIsolation für Akzeptanzkriterium 3.

Kein Umbau: storage/crypto/encstorage/dedup unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 09:40:29 +02:00
7 changed files with 486 additions and 0 deletions
+60
View File
@@ -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.
+181
View File
@@ -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"`
}
+73
View File
@@ -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])
}
}
+29
View File
@@ -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}
+98
View File
@@ -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")
}
}
}
@@ -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
)
@@ -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")
}
}