diff --git a/internal/api/dsgvo_handlers.go b/internal/api/dsgvo_handlers.go index 7959289..fb25ed8 100644 --- a/internal/api/dsgvo_handlers.go +++ b/internal/api/dsgvo_handlers.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -76,16 +77,23 @@ func (s *Server) handleCreateDSGVORequest(w http.ResponseWriter, r *http.Request PageSize: dsgvoMaxHits, Page: 1, } + // Bug-6: reject invalid date formats instead of silently ignoring them. if req.DateFrom != "" { - if t, err := time.Parse(time.DateOnly, req.DateFrom); err == nil { - searchReq.DateFrom = &t + t, err := time.Parse(time.DateOnly, req.DateFrom) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid date_from format (expected YYYY-MM-DD)") + return } + searchReq.DateFrom = &t } if req.DateTo != "" { - if t, err := time.Parse(time.DateOnly, req.DateTo); err == nil { - t = t.Add(24*time.Hour - time.Second) - searchReq.DateTo = &t + t, err := time.Parse(time.DateOnly, req.DateTo) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid date_to format (expected YYYY-MM-DD)") + return } + t = t.Add(24*time.Hour - time.Second) + searchReq.DateTo = &t } searchIdx := s.idx @@ -95,6 +103,17 @@ func (s *Server) handleCreateDSGVORequest(w http.ResponseWriter, r *http.Request result, err := searchIdx.Search(searchReq) if err != nil { + // Bug-3: failed searches must also be audited. + if s.audlog != nil { + s.audlog.Log(audit.Entry{ + EventType: audit.EventDSGVORequest, + Username: sess.Username, + TenantID: sess.TenantID, + IPAddress: s.remoteIP(r), + Success: false, + Detail: fmt.Sprintf("dsgvo: search failed address=%q error=%v", address, err), + }) + } writeError(w, http.StatusInternalServerError, "search failed") return } @@ -110,8 +129,10 @@ func (s *Server) handleCreateDSGVORequest(w http.ResponseWriter, r *http.Request for _, h := range result.Hits { ids = append(ids, h.ID) } - meta, err := s.store.GetDSGVOMailMeta(r.Context(), ids) + meta, err := s.store.GetDSGVOMailMeta(r.Context(), ids, tenantID) if err != nil { + // Bug-2: do not leave an orphaned "open" request behind on failure. + s.failDSGVORequest(r.Context(), dsReq.ID) writeError(w, http.StatusInternalServerError, "metadata lookup failed") return } @@ -150,6 +171,8 @@ func (s *Server) handleCreateDSGVORequest(w http.ResponseWriter, r *http.Request status := dsgvoStatus(summary) if err := s.store.UpdateDSGVOResult(r.Context(), dsReq.ID, status, &summary); err != nil { + // Bug-2: avoid a stuck "open" request when the result cannot be stored. + s.failDSGVORequest(r.Context(), dsReq.ID) writeError(w, http.StatusInternalServerError, "could not store result") return } @@ -171,6 +194,14 @@ func (s *Server) handleCreateDSGVORequest(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusOK, dsReq) } +// failDSGVORequest marks a request as failed so no orphaned "open" entry +// remains when evaluation aborts after creation (PROJ-50 Bug-2). Errors here +// are intentionally swallowed: the original failure is already being reported +// to the caller, and a best-effort cleanup must not mask it. +func (s *Server) failDSGVORequest(ctx context.Context, id int64) { + _ = s.store.MarkDSGVORequestFailed(ctx, id) +} + // dsgvoStatus computes the request status from the result summary. func dsgvoStatus(s storage.DSGVOResultSummary) string { if s.TotalHits == 0 { diff --git a/internal/storage/dsgvo_requests.go b/internal/storage/dsgvo_requests.go index 68d9c64..b4aae36 100644 --- a/internal/storage/dsgvo_requests.go +++ b/internal/storage/dsgvo_requests.go @@ -12,6 +12,7 @@ const ( DSGVOStatusOpen = "open" // angelegt, noch nicht verarbeitet DSGVOStatusPartial = "partial" // teilweise abgelehnt (Mischung) DSGVOStatusCompleted = "completed" // abgeschlossen (0 Treffer, alle löschbar/abgelehnt, oder gelöscht) + DSGVOStatusFailed = "failed" // Auswertung nach Anlage fehlgeschlagen (PROJ-50 Bug-2) ) // DSGVOAffectedMail is a single mail affected by a DSGVO erasure request. @@ -101,6 +102,21 @@ func (s *Store) UpdateDSGVOResult(ctx context.Context, id int64, status string, return nil } +// MarkDSGVORequestFailed flags a request as failed without touching the result +// summary. Used to clean up a freshly created request whose evaluation aborted +// (PROJ-50 Bug-2), so no orphaned "open" entry remains. +func (s *Store) MarkDSGVORequestFailed(ctx context.Context, id int64) error { + if s.db == nil { + return fmt.Errorf("storage: no db") + } + _, err := s.db.Exec(ctx, + `UPDATE dsgvo_requests SET status=$1 WHERE id=$2`, DSGVOStatusFailed, id) + if err != nil { + return fmt.Errorf("storage: mark dsgvo failed: %w", err) + } + return nil +} + // ListDSGVORequests returns all requests in the given tenant scope, newest first. // A nil tenantID returns requests with NULL tenant_id (global/superadmin scope). func (s *Store) ListDSGVORequests(ctx context.Context, tenantID *int64) ([]DSGVORequest, error) { @@ -168,12 +184,27 @@ type DSGVOMailMeta struct { // GetDSGVOMailMeta batch-loads subject, received_at and retain_until for the // given mail IDs. Missing IDs are omitted. Used by the DSGVO workflow to avoid // loading and parsing every raw mail file. -func (s *Store) GetDSGVOMailMeta(ctx context.Context, ids []string) (map[string]DSGVOMailMeta, error) { +// +// tenantID scopes the lookup to a single tenant to prevent cross-tenant leakage +// (PROJ-50 Bug-1): a DSGVO request belongs to exactly one tenant, so only mails +// of that tenant may be evaluated. A nil tenantID is the global/superadmin scope +// and matches mails of every tenant. +func (s *Store) GetDSGVOMailMeta(ctx context.Context, ids []string, tenantID *int64) (map[string]DSGVOMailMeta, error) { if s.db == nil || len(ids) == 0 { return map[string]DSGVOMailMeta{}, nil } - rows, err := s.db.Query(ctx, - `SELECT id, subject, received_at, retain_until FROM emails WHERE id = ANY($1)`, ids) + var ( + query string + args []interface{} + ) + if tenantID == nil { + query = `SELECT id, subject, received_at, retain_until FROM emails WHERE id = ANY($1)` + args = []interface{}{ids} + } else { + query = `SELECT id, subject, received_at, retain_until FROM emails WHERE id = ANY($1) AND tenant_id = $2` + args = []interface{}{ids, *tenantID} + } + rows, err := s.db.Query(ctx, query, args...) if err != nil { return nil, fmt.Errorf("storage: dsgvo mail meta: %w", err) }