fix(PROJ-50): Cross-Tenant-Lücke + Fehlerbehandlung in DSGVO-Handlern behoben
Bug-1: GetDSGVOMailMeta mit tenant_id-Filter (Defense-in-Depth an DB-Schicht). Bug-2: Verwaiste open-Einträge bei Auswertungsfehler werden auf failed markiert. Bug-3: Fehlgeschlagene Suchen werden im Audit-Log protokolliert. Bug-6: Ungültige date_from/date_to-Eingaben liefern HTTP 400. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
dcb88317ac
commit
0552ce49e2
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user