Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
444 lines
17 KiB
Go
444 lines
17 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// Trash + staged deletion (Papierkorb + gestaffeltes Löschkonzept).
|
|
//
|
|
// A document with deleted_at IS NOT NULL sits in the trash. Its WORM file on
|
|
// disk stays physically untouched (chmod 0440) until a delete request reaches
|
|
// status='executed'. Final deletion requires a two-person rule (Vier-Augen-
|
|
// Prinzip): one user requests it, a *different* user with domain_admin role
|
|
// confirms it. Both the request and the confirm re-check retain_until, since
|
|
// time may have passed or a new retention lock may have appeared in between.
|
|
//
|
|
// On execution the physical file is removed but the DB row is kept as a
|
|
// tombstone (GoBD Nachvollziehbarkeit): storage_path and ocr_text are cleared,
|
|
// content_hash is retained so the former existence + integrity fingerprint of
|
|
// the document stays provable.
|
|
|
|
var (
|
|
// ErrDocumentNotInTrash is returned when a trash operation targets a
|
|
// document that is not (or no longer) soft-deleted for the tenant.
|
|
ErrDocumentNotInTrash = errors.New("storage: document not found in trash for tenant")
|
|
|
|
// ErrAlreadyInTrash is returned by SoftDeleteDocument when the document is
|
|
// already soft-deleted.
|
|
ErrAlreadyInTrash = errors.New("storage: document already in trash")
|
|
|
|
// ErrDeleteRequestExists is returned when a pending delete request already
|
|
// exists for the document.
|
|
ErrDeleteRequestExists = errors.New("storage: a pending delete request already exists for this document")
|
|
|
|
// ErrDeleteRequestNotFound is returned when a delete request lookup does
|
|
// not match a pending request owned by the tenant/document.
|
|
ErrDeleteRequestNotFound = errors.New("storage: delete request not found or not pending")
|
|
|
|
// ErrSelfConfirm is returned when the confirming user is the same as the
|
|
// requesting user (Vier-Augen-Prinzip violation).
|
|
ErrSelfConfirm = errors.New("storage: delete request must be confirmed by a different user")
|
|
|
|
// ErrRetentionActive is returned when retain_until still blocks final
|
|
// deletion (retain_until IS NOT NULL AND retain_until >= now()).
|
|
ErrRetentionActive = errors.New("storage: document is under retention and cannot be finally deleted")
|
|
)
|
|
|
|
// DeleteRequest is a single staged-deletion request row.
|
|
type DeleteRequest struct {
|
|
ID int64 `json:"id"`
|
|
DocumentID int64 `json:"document_id"`
|
|
TenantID int64 `json:"tenant_id"`
|
|
RequestedBy int64 `json:"requested_by"`
|
|
RequestedAt time.Time `json:"requested_at"`
|
|
ConfirmedBy *int64 `json:"confirmed_by,omitempty"`
|
|
ConfirmedAt *time.Time `json:"confirmed_at,omitempty"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// TrashDocument is a soft-deleted document plus its trash metadata.
|
|
type TrashDocument struct {
|
|
Document
|
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
|
DeletedBy *int64 `json:"deleted_by,omitempty"`
|
|
}
|
|
|
|
// ExecutedDelete carries the result of a confirmed + executed final deletion,
|
|
// used by the handler to build the two audit entries (requester + confirmer)
|
|
// and, crucially, the GoBD-taugliche Löschprotokoll (ecoDMS-Muster): who
|
|
// requested, who confirmed, when, which document (title + content_hash as the
|
|
// tamper-evident fingerprint of the now-removed WORM file) and the retention
|
|
// state at execution time (RetainUntil, expected nil or already elapsed).
|
|
type ExecutedDelete struct {
|
|
RequestID int64
|
|
RequestedBy int64
|
|
RequestedAt time.Time
|
|
StoragePath string
|
|
ContentHash string
|
|
Title string
|
|
RetainUntil *time.Time
|
|
}
|
|
|
|
func (s *Store) initTrashSchema(ctx context.Context) error {
|
|
_, err := s.db.Exec(ctx, `
|
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS deleted_by BIGINT;
|
|
|
|
CREATE TABLE IF NOT EXISTS document_delete_requests (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
tenant_id BIGINT NOT NULL,
|
|
requested_by BIGINT NOT NULL,
|
|
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
confirmed_by BIGINT,
|
|
confirmed_at TIMESTAMPTZ,
|
|
status TEXT NOT NULL DEFAULT 'pending'
|
|
CHECK (status IN ('pending','confirmed','executed','cancelled','blocked_retention')),
|
|
UNIQUE(document_id, status)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_ddr_tenant_status ON document_delete_requests(tenant_id, status);
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: create trash schema: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SoftDeleteDocument moves a document into the trash (sets deleted_at/deleted_by).
|
|
// The WORM file is left untouched. Returns ErrAlreadyInTrash if the document is
|
|
// already trashed, ErrDocumentNotInTrash if it does not exist for the tenant.
|
|
func (s *Store) SoftDeleteDocument(ctx context.Context, id, tenantID, userID int64) error {
|
|
tag, err := s.db.Exec(ctx, `
|
|
UPDATE documents SET deleted_at = now(), deleted_by = $3, updated_at = now()
|
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
|
`, id, tenantID, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: soft delete document: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
// Distinguish "does not exist" from "already trashed" for a clearer error.
|
|
var deleted bool
|
|
qerr := s.db.QueryRow(ctx,
|
|
`SELECT deleted_at IS NOT NULL FROM documents WHERE id = $1 AND tenant_id = $2`,
|
|
id, tenantID).Scan(&deleted)
|
|
if qerr != nil {
|
|
return ErrDocumentNotInTrash
|
|
}
|
|
if deleted {
|
|
return ErrAlreadyInTrash
|
|
}
|
|
return ErrDocumentNotInTrash
|
|
}
|
|
// Trashed documents must not surface in search. The WORM file and DB row
|
|
// stay put (restore re-indexes); only the index entry is dropped.
|
|
// Best-effort.
|
|
s.DeleteFromIndex(ctx, id, tenantID)
|
|
return nil
|
|
}
|
|
|
|
// ListTrash returns all soft-deleted documents for a tenant, newest deletion
|
|
// first.
|
|
func (s *Store) ListTrash(ctx context.Context, tenantID int64) ([]TrashDocument, error) {
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT id, tenant_id, title, COALESCE(doc_type, ''), COALESCE(correspondent, ''), doc_type_id, correspondent_id, storage_path, content_hash,
|
|
COALESCE(ocr_text, ''), retain_until, COALESCE(source, ''), COALESCE(source_ref, ''), created_at, updated_at, deleted_at, deleted_by
|
|
FROM documents WHERE tenant_id = $1 AND deleted_at IS NOT NULL ORDER BY deleted_at DESC
|
|
`, tenantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: list trash: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]TrashDocument, 0)
|
|
for rows.Next() {
|
|
var d TrashDocument
|
|
if err := rows.Scan(&d.ID, &d.TenantID, &d.Title, &d.DocType, &d.Correspondent, &d.DocTypeID, &d.CorrespondentID, &d.StoragePath, &d.ContentHash,
|
|
&d.OCRText, &d.RetainUntil, &d.Source, &d.SourceRef, &d.CreatedAt, &d.UpdatedAt, &d.DeletedAt, &d.DeletedBy); err != nil {
|
|
return nil, fmt.Errorf("storage: scan trash document: %w", err)
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// RestoreDocument brings a document back out of the trash: clears deleted_at/
|
|
// deleted_by and cancels any pending delete request. Returns
|
|
// ErrDocumentNotInTrash if the document is not currently trashed.
|
|
func (s *Store) RestoreDocument(ctx context.Context, id, tenantID, userID int64) error {
|
|
tx, err := s.db.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: restore begin: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
tag, err := tx.Exec(ctx, `
|
|
UPDATE documents SET deleted_at = NULL, deleted_by = NULL, updated_at = now()
|
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NOT NULL
|
|
`, id, tenantID)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: restore document: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrDocumentNotInTrash
|
|
}
|
|
|
|
// Cancel any pending request so a later trash cycle can start clean.
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE document_delete_requests SET status = 'cancelled', confirmed_by = $3, confirmed_at = now()
|
|
WHERE document_id = $1 AND tenant_id = $2 AND status = 'pending'
|
|
`, id, tenantID, userID); err != nil {
|
|
return fmt.Errorf("storage: cancel pending requests on restore: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("storage: restore commit: %w", err)
|
|
}
|
|
// Back out of the trash -> re-index so it is findable again. Best-effort.
|
|
s.SyncIndex(ctx, id)
|
|
return nil
|
|
}
|
|
|
|
// CreateDeleteRequest records a final-deletion request (User A). It re-checks
|
|
// retention: if retain_until still blocks deletion, a row with
|
|
// status='blocked_retention' is written (for Nachvollziehbarkeit of the attempt)
|
|
// and ErrRetentionActive is returned. Otherwise a 'pending' request is created.
|
|
// The document must currently be in the trash.
|
|
func (s *Store) CreateDeleteRequest(ctx context.Context, docID, tenantID, userID int64) (*DeleteRequest, error) {
|
|
tx, err := s.db.Begin(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: delete request begin: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var retainUntil *time.Time
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT retain_until FROM documents
|
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NOT NULL
|
|
FOR UPDATE
|
|
`, docID, tenantID).Scan(&retainUntil)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrDocumentNotInTrash
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: load document for delete request: %w", err)
|
|
}
|
|
|
|
if retentionActive(retainUntil) {
|
|
req, berr := upsertBlockedRequest(ctx, tx, docID, tenantID, userID)
|
|
if berr != nil {
|
|
return nil, berr
|
|
}
|
|
if cerr := tx.Commit(ctx); cerr != nil {
|
|
return nil, fmt.Errorf("storage: delete request commit (blocked): %w", cerr)
|
|
}
|
|
return req, ErrRetentionActive
|
|
}
|
|
|
|
var req DeleteRequest
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO document_delete_requests (document_id, tenant_id, requested_by, status)
|
|
VALUES ($1, $2, $3, 'pending')
|
|
ON CONFLICT (document_id, status) DO NOTHING
|
|
RETURNING id, document_id, tenant_id, requested_by, requested_at, confirmed_by, confirmed_at, status
|
|
`, docID, tenantID, userID).Scan(&req.ID, &req.DocumentID, &req.TenantID, &req.RequestedBy, &req.RequestedAt, &req.ConfirmedBy, &req.ConfirmedAt, &req.Status)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrDeleteRequestExists
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: insert delete request: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("storage: delete request commit: %w", err)
|
|
}
|
|
return &req, nil
|
|
}
|
|
|
|
// upsertBlockedRequest records/refreshes a blocked_retention request row.
|
|
func upsertBlockedRequest(ctx context.Context, tx pgx.Tx, docID, tenantID, userID int64) (*DeleteRequest, error) {
|
|
var req DeleteRequest
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO document_delete_requests (document_id, tenant_id, requested_by, status)
|
|
VALUES ($1, $2, $3, 'blocked_retention')
|
|
ON CONFLICT (document_id, status)
|
|
DO UPDATE SET requested_by = EXCLUDED.requested_by, requested_at = now()
|
|
RETURNING id, document_id, tenant_id, requested_by, requested_at, confirmed_by, confirmed_at, status
|
|
`, docID, tenantID, userID).Scan(&req.ID, &req.DocumentID, &req.TenantID, &req.RequestedBy, &req.RequestedAt, &req.ConfirmedBy, &req.ConfirmedAt, &req.Status)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: record blocked_retention request: %w", err)
|
|
}
|
|
return &req, nil
|
|
}
|
|
|
|
// ListDeleteRequests returns all delete requests for a document (status/history),
|
|
// scoped to tenant, newest first.
|
|
func (s *Store) ListDeleteRequests(ctx context.Context, docID, tenantID int64) ([]DeleteRequest, error) {
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT id, document_id, tenant_id, requested_by, requested_at, confirmed_by, confirmed_at, status
|
|
FROM document_delete_requests
|
|
WHERE document_id = $1 AND tenant_id = $2
|
|
ORDER BY requested_at DESC, id DESC
|
|
`, docID, tenantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: list delete requests: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]DeleteRequest, 0)
|
|
for rows.Next() {
|
|
var req DeleteRequest
|
|
if err := rows.Scan(&req.ID, &req.DocumentID, &req.TenantID, &req.RequestedBy, &req.RequestedAt, &req.ConfirmedBy, &req.ConfirmedAt, &req.Status); err != nil {
|
|
return nil, fmt.Errorf("storage: scan delete request: %w", err)
|
|
}
|
|
out = append(out, req)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// CancelDeleteRequest withdraws a pending delete request (status='cancelled').
|
|
func (s *Store) CancelDeleteRequest(ctx context.Context, docID, reqID, tenantID, userID int64) error {
|
|
tag, err := s.db.Exec(ctx, `
|
|
UPDATE document_delete_requests
|
|
SET status = 'cancelled', confirmed_by = $4, confirmed_at = now()
|
|
WHERE id = $1 AND document_id = $2 AND tenant_id = $3 AND status = 'pending'
|
|
`, reqID, docID, tenantID, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: cancel delete request: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrDeleteRequestNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ConfirmDeleteRequest confirms a pending request (User B) and, if retention
|
|
// still allows it, executes the final deletion: it removes the physical WORM
|
|
// file and turns the DB row into a tombstone (storage_path/ocr_text cleared,
|
|
// content_hash kept). The confirmer must differ from the requester
|
|
// (ErrSelfConfirm) and must be enforced to hold domain_admin by the caller.
|
|
//
|
|
// If retain_until now blocks deletion, the request is moved to
|
|
// status='blocked_retention' (recording the attempt) and ErrRetentionActive is
|
|
// returned. On any other outcome the returned *ExecutedDelete is nil.
|
|
func (s *Store) ConfirmDeleteRequest(ctx context.Context, docID, reqID, tenantID, confirmerID int64) (*ExecutedDelete, error) {
|
|
tx, err := s.db.Begin(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: confirm begin: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var (
|
|
requestedBy int64
|
|
requestedAt time.Time
|
|
)
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT requested_by, requested_at FROM document_delete_requests
|
|
WHERE id = $1 AND document_id = $2 AND tenant_id = $3 AND status = 'pending'
|
|
FOR UPDATE
|
|
`, reqID, docID, tenantID).Scan(&requestedBy, &requestedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrDeleteRequestNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: load delete request: %w", err)
|
|
}
|
|
|
|
if confirmerID == requestedBy {
|
|
// Vier-Augen-Prinzip: refuse and leave the request pending.
|
|
return nil, ErrSelfConfirm
|
|
}
|
|
|
|
var (
|
|
retainUntil *time.Time
|
|
storagePath string
|
|
contentHash string
|
|
title string
|
|
)
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT retain_until, storage_path, COALESCE(content_hash, ''), COALESCE(title, '') FROM documents
|
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NOT NULL
|
|
FOR UPDATE
|
|
`, docID, tenantID).Scan(&retainUntil, &storagePath, &contentHash, &title)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrDocumentNotInTrash
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: load document for confirm: %w", err)
|
|
}
|
|
|
|
if retentionActive(retainUntil) {
|
|
// Record the blocked attempt on the (previously pending) request.
|
|
if _, uerr := tx.Exec(ctx, `
|
|
UPDATE document_delete_requests
|
|
SET status = 'blocked_retention', confirmed_by = $2, confirmed_at = now()
|
|
WHERE id = $1
|
|
`, reqID, confirmerID); uerr != nil {
|
|
return nil, fmt.Errorf("storage: record blocked confirm: %w", uerr)
|
|
}
|
|
if cerr := tx.Commit(ctx); cerr != nil {
|
|
return nil, fmt.Errorf("storage: confirm commit (blocked): %w", cerr)
|
|
}
|
|
return nil, ErrRetentionActive
|
|
}
|
|
|
|
// Mark executed and tombstone the document (keep content_hash).
|
|
if _, uerr := tx.Exec(ctx, `
|
|
UPDATE document_delete_requests
|
|
SET status = 'executed', confirmed_by = $2, confirmed_at = now()
|
|
WHERE id = $1
|
|
`, reqID, confirmerID); uerr != nil {
|
|
return nil, fmt.Errorf("storage: mark request executed: %w", uerr)
|
|
}
|
|
if _, uerr := tx.Exec(ctx, `
|
|
UPDATE documents
|
|
SET storage_path = '', ocr_text = NULL, updated_at = now()
|
|
WHERE id = $1 AND tenant_id = $2
|
|
`, docID, tenantID); uerr != nil {
|
|
return nil, fmt.Errorf("storage: tombstone document: %w", uerr)
|
|
}
|
|
|
|
// Remove the physical WORM file. The store directory is writable by the
|
|
// process even though the file itself is 0440, so os.Remove succeeds. A
|
|
// missing file is tolerated (already gone) — anything else aborts the tx so
|
|
// the DB stays consistent with the filesystem.
|
|
if storagePath != "" {
|
|
if rerr := os.Remove(storagePath); rerr != nil && !os.IsNotExist(rerr) {
|
|
return nil, fmt.Errorf("storage: remove WORM file: %w", rerr)
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("storage: confirm commit: %w", err)
|
|
}
|
|
// GoBD-critical: a finally-deleted document must not remain findable in the
|
|
// search index, even though the search endpoint only lands in a later
|
|
// phase. Best-effort, but consistency is enforced from day one.
|
|
s.DeleteFromIndex(ctx, docID, tenantID)
|
|
return &ExecutedDelete{
|
|
RequestID: reqID,
|
|
RequestedBy: requestedBy,
|
|
RequestedAt: requestedAt,
|
|
StoragePath: storagePath,
|
|
ContentHash: contentHash,
|
|
Title: title,
|
|
RetainUntil: retainUntil,
|
|
}, nil
|
|
}
|
|
|
|
// retentionActive reports whether retain_until still blocks final deletion:
|
|
// a NULL retain_until never blocks; a date in the future (>= today) blocks.
|
|
func retentionActive(retainUntil *time.Time) bool {
|
|
if retainUntil == nil {
|
|
return false
|
|
}
|
|
// retain_until is a DATE; deletion is allowed once it is strictly in the
|
|
// past (retain_until < now()). Equal-to-today still blocks.
|
|
return !retainUntil.Before(time.Now())
|
|
}
|