Files
archivmail/internal/storage/dsgvo_requests.go
T
sysopsandClaude Sonnet 4.6 0552ce49e2 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>
2026-06-30 18:17:25 +02:00

253 lines
8.7 KiB
Go

package storage
import (
"context"
"encoding/json"
"fmt"
"time"
)
// DSGVO request status values (PROJ-50).
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.
type DSGVOAffectedMail struct {
MailID string `json:"mail_id"`
Subject string `json:"subject"`
Date *time.Time `json:"date,omitempty"`
RetainUntil *time.Time `json:"retain_until,omitempty"`
Deletable bool `json:"deletable"` // true wenn retain_until abgelaufen/nicht gesetzt
Deleted bool `json:"deleted"` // true wenn bereits über Löschmechanismus entfernt
Reason string `json:"reason,omitempty"` // z.B. "Aufbewahrungspflicht bis 2030-12-31"
}
// DSGVOResultSummary is the aggregated outcome stored as JSON on the request.
type DSGVOResultSummary struct {
TotalHits int `json:"total_hits"`
Rejected int `json:"rejected"` // gesetzlich gesperrt
Deletable int `json:"deletable"` // löschbar
Deleted int `json:"deleted"` // bereits gelöscht
Truncated bool `json:"truncated"` // Treffer über Limit, Ergebnis unvollständig
Mails []DSGVOAffectedMail `json:"mails"`
}
// DSGVORequest represents a documented DSGVO erasure request (Art. 17).
type DSGVORequest struct {
ID int64 `json:"id"`
TenantID *int64 `json:"tenant_id"`
RequestedAddress string `json:"requested_address"`
RequestedBy string `json:"requested_by"`
CreatedAt time.Time `json:"created_at"`
Status string `json:"status"`
ResultSummary *DSGVOResultSummary `json:"result_summary,omitempty"`
}
// initDSGVOSchema creates the dsgvo_requests table. Safe to call repeatedly.
func (s *Store) initDSGVOSchema(ctx context.Context) {
_, _ = s.db.Exec(ctx, `CREATE TABLE IF NOT EXISTS dsgvo_requests (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT,
requested_address TEXT NOT NULL,
requested_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status TEXT NOT NULL DEFAULT 'open',
result_summary JSONB
)`)
_, _ = s.db.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_dsgvo_requests_tenant ON dsgvo_requests (tenant_id)`)
}
// CreateDSGVORequest inserts a new request and returns its generated ID.
func (s *Store) CreateDSGVORequest(ctx context.Context, tenantID *int64, address, requestedBy string) (*DSGVORequest, error) {
if s.db == nil {
return nil, fmt.Errorf("storage: no db")
}
req := &DSGVORequest{
TenantID: tenantID,
RequestedAddress: address,
RequestedBy: requestedBy,
Status: DSGVOStatusOpen,
}
err := s.db.QueryRow(ctx,
`INSERT INTO dsgvo_requests (tenant_id, requested_address, requested_by, status)
VALUES ($1,$2,$3,$4) RETURNING id, created_at`,
tenantID, address, requestedBy, DSGVOStatusOpen,
).Scan(&req.ID, &req.CreatedAt)
if err != nil {
return nil, fmt.Errorf("storage: create dsgvo request: %w", err)
}
return req, nil
}
// UpdateDSGVOResult persists the computed result summary and status.
func (s *Store) UpdateDSGVOResult(ctx context.Context, id int64, status string, summary *DSGVOResultSummary) error {
if s.db == nil {
return fmt.Errorf("storage: no db")
}
js, err := json.Marshal(summary)
if err != nil {
return fmt.Errorf("storage: marshal dsgvo summary: %w", err)
}
_, err = s.db.Exec(ctx,
`UPDATE dsgvo_requests SET status=$1, result_summary=$2 WHERE id=$3`,
status, js, id,
)
if err != nil {
return fmt.Errorf("storage: update dsgvo result: %w", err)
}
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) {
if s.db == nil {
return nil, fmt.Errorf("storage: no db")
}
var (
query string
args []interface{}
)
if tenantID == nil {
query = `SELECT id, tenant_id, requested_address, requested_by, created_at, status, result_summary
FROM dsgvo_requests WHERE tenant_id IS NULL ORDER BY created_at DESC`
} else {
query = `SELECT id, tenant_id, requested_address, requested_by, created_at, status, result_summary
FROM dsgvo_requests WHERE tenant_id = $1 ORDER BY created_at DESC`
args = append(args, *tenantID)
}
rows, err := s.db.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("storage: list dsgvo requests: %w", err)
}
defer rows.Close()
var out []DSGVORequest
for rows.Next() {
req, err := scanDSGVORow(rows)
if err != nil {
return nil, err
}
out = append(out, *req)
}
return out, rows.Err()
}
// GetDSGVORequest returns a single request by ID within the given tenant scope.
// Returns an error if the request does not exist or belongs to another tenant.
func (s *Store) GetDSGVORequest(ctx context.Context, id int64, tenantID *int64) (*DSGVORequest, error) {
if s.db == nil {
return nil, fmt.Errorf("storage: no db")
}
var (
query string
args []interface{}
)
if tenantID == nil {
query = `SELECT id, tenant_id, requested_address, requested_by, created_at, status, result_summary
FROM dsgvo_requests WHERE id=$1 AND tenant_id IS NULL`
args = append(args, id)
} else {
query = `SELECT id, tenant_id, requested_address, requested_by, created_at, status, result_summary
FROM dsgvo_requests WHERE id=$1 AND tenant_id=$2`
args = append(args, id, *tenantID)
}
row := s.db.QueryRow(ctx, query, args...)
return scanDSGVORow(row)
}
// DSGVOMailMeta holds the metadata needed to evaluate a DSGVO request per mail.
type DSGVOMailMeta struct {
Subject string
ReceivedAt time.Time
RetainUntil *time.Time
}
// 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.
//
// 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
}
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)
}
defer rows.Close()
out := make(map[string]DSGVOMailMeta, len(ids))
for rows.Next() {
var (
id string
subject *string
recv time.Time
until *time.Time
)
if err := rows.Scan(&id, &subject, &recv, &until); err != nil {
return nil, fmt.Errorf("storage: scan dsgvo mail meta: %w", err)
}
m := DSGVOMailMeta{ReceivedAt: recv, RetainUntil: until}
if subject != nil {
m.Subject = *subject
}
out[id] = m
}
return out, rows.Err()
}
type dsgvoScanner interface {
Scan(dest ...interface{}) error
}
func scanDSGVORow(row dsgvoScanner) (*DSGVORequest, error) {
var (
req DSGVORequest
js []byte
)
if err := row.Scan(&req.ID, &req.TenantID, &req.RequestedAddress, &req.RequestedBy,
&req.CreatedAt, &req.Status, &js); err != nil {
return nil, fmt.Errorf("storage: scan dsgvo request: %w", err)
}
if len(js) > 0 {
var sum DSGVOResultSummary
if err := json.Unmarshal(js, &sum); err == nil {
req.ResultSummary = &sum
}
}
return &req, nil
}