Files
sysops 472ba6a087 feat(PROJ-53): Konfigurierbare Listenanzahl pro Seite
- users.list_page_size (Default 25), PATCH /api/auth/preferences,
  Whitelist 25/50/100/200, Wert in login/me-Response
- Settings-UI mit Select, /search nutzt gespeicherte Seitengröße
- /api/search page_size serverseitig auf max. 500 gecappt

fix(PROJ-46): login_attempts-Migration nutzte s.db statt s.pool
(Backend kompilierte nicht)

feat(PROJ-50): DSGVO-Löschersuchen Backend (dsgvo_requests, Handler,
cc_addr/bcc_addr Indexerweiterung) — noch nicht QA'd/deployed
2026-06-14 22:25:02 +02:00

222 lines
7.5 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)
)
// 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
}
// 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.
func (s *Store) GetDSGVOMailMeta(ctx context.Context, ids []string) (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)
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
}