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
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user