472ba6a087
- 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
222 lines
7.5 KiB
Go
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
|
|
}
|