// Trash + staged-deletion HTTP handlers (see internal/storage/trash.go): // // GET /api/trash list soft-deleted docs // POST /api/trash/{id}/restore restore from trash // POST /api/trash/{id}/delete-requests request final deletion (User A) // GET /api/trash/{id}/delete-requests request status/history // POST /api/trash/{id}/delete-requests/{reqId}/confirm confirm + execute (User B, domain_admin) // DELETE /api/trash/{id}/delete-requests/{reqId} withdraw a pending request // // The soft-delete itself lives on DELETE /api/documents/{id} // (handleDeleteDocument). Ownership is enforced in the store layer via // id+tenant_id; the two-person rule (requester != confirmer) is enforced in // ConfirmDeleteRequest. Every phase emits its own append-only audit entry. package api import ( "encoding/json" "errors" "net/http" "strconv" "time" "archivdms/internal/audit" "archivdms/internal/storage" ) // handleListTrash handles GET /api/trash. func (s *Server) handleListTrash(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusForbidden, "tenant context required") return } docs, err := s.store.ListTrash(r.Context(), *sess.TenantID) if err != nil { writeError(w, http.StatusInternalServerError, "list trash failed") return } writeJSON(w, http.StatusOK, docs) } // handleRestoreDocument handles POST /api/trash/{id}/restore. func (s *Server) handleRestoreDocument(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusForbidden, "tenant context required") return } id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid document id") return } if err := s.store.RestoreDocument(r.Context(), id, *sess.TenantID, sess.UserID); err != nil { status := http.StatusInternalServerError msg := "restore failed" if errors.Is(err, storage.ErrDocumentNotInTrash) { status = http.StatusNotFound msg = "document not found in trash" } s.audlog.Log(audit.Entry{EventType: audit.EventDocumentRestore, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()}) writeError(w, status, msg) return } s.audlog.Log(audit.Entry{EventType: audit.EventDocumentRestore, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true}) writeJSON(w, http.StatusOK, map[string]string{"status": "restored"}) } // handleListDeleteRequests handles GET /api/trash/{id}/delete-requests. func (s *Server) handleListDeleteRequests(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusForbidden, "tenant context required") return } id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid document id") return } reqs, err := s.store.ListDeleteRequests(r.Context(), id, *sess.TenantID) if err != nil { writeError(w, http.StatusInternalServerError, "list delete requests failed") return } writeJSON(w, http.StatusOK, reqs) } // handleCreateDeleteRequest handles POST /api/trash/{id}/delete-requests. // User A requests final deletion; retention is re-checked, and a blocked // attempt is still recorded (409). func (s *Server) handleCreateDeleteRequest(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusForbidden, "tenant context required") return } id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid document id") return } req, err := s.store.CreateDeleteRequest(r.Context(), id, *sess.TenantID, sess.UserID) if err != nil { if errors.Is(err, storage.ErrRetentionActive) { s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteBlocked, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "delete request blocked by retention"}) writeError(w, http.StatusConflict, "document is under retention and cannot be deleted yet") return } status := http.StatusInternalServerError msg := "create delete request failed" if errors.Is(err, storage.ErrDocumentNotInTrash) { status = http.StatusNotFound msg = "document not found in trash" } else if errors.Is(err, storage.ErrDeleteRequestExists) { status = http.StatusConflict msg = "a pending delete request already exists" } s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteRequest, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()}) writeError(w, status, msg) return } s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteRequest, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true, Detail: "request id:" + strconv.FormatInt(req.ID, 10)}) writeJSON(w, http.StatusCreated, req) } // handleCancelDeleteRequest handles DELETE /api/trash/{id}/delete-requests/{reqId}. func (s *Server) handleCancelDeleteRequest(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusForbidden, "tenant context required") return } id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) reqID, err2 := strconv.ParseInt(r.PathValue("reqId"), 10, 64) if err != nil || err2 != nil { writeError(w, http.StatusBadRequest, "invalid id") return } if err := s.store.CancelDeleteRequest(r.Context(), id, reqID, *sess.TenantID, sess.UserID); err != nil { status := http.StatusInternalServerError msg := "cancel delete request failed" if errors.Is(err, storage.ErrDeleteRequestNotFound) { status = http.StatusNotFound msg = "pending delete request not found" } s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteRequest, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "cancel req:" + r.PathValue("reqId") + " err:" + err.Error()}) writeError(w, status, msg) return } s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteRequest, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true, Detail: "cancelled req:" + r.PathValue("reqId")}) writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"}) } // handleConfirmDeleteRequest handles // POST /api/trash/{id}/delete-requests/{reqId}/confirm (domain_admin, User B). // The store enforces requester != confirmer and re-checks retention before it // removes the physical WORM file and tombstones the DB row. func (s *Server) handleConfirmDeleteRequest(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusForbidden, "tenant context required") return } id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) reqID, err2 := strconv.ParseInt(r.PathValue("reqId"), 10, 64) if err != nil || err2 != nil { writeError(w, http.StatusBadRequest, "invalid id") return } docIDStr := r.PathValue("id") exec, err := s.store.ConfirmDeleteRequest(r.Context(), id, reqID, *sess.TenantID, sess.UserID) if err != nil { if errors.Is(err, storage.ErrRetentionActive) { s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteBlocked, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docIDStr, Success: false, Detail: "confirm blocked by retention req:" + r.PathValue("reqId")}) writeError(w, http.StatusConflict, "document is under retention and cannot be deleted yet") return } status := http.StatusInternalServerError msg := "confirm delete request failed" if errors.Is(err, storage.ErrSelfConfirm) { status = http.StatusForbidden msg = "delete request must be confirmed by a different user" } else if errors.Is(err, storage.ErrDeleteRequestNotFound) { status = http.StatusNotFound msg = "pending delete request not found" } else if errors.Is(err, storage.ErrDocumentNotInTrash) { status = http.StatusNotFound msg = "document not found in trash" } s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteConfirm, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docIDStr, Success: false, Detail: "req:" + r.PathValue("reqId") + " err:" + err.Error()}) writeError(w, status, msg) return } // Two-person rule: emit a confirm entry (User B) and an execute entry that // records the requester (User A) whose request was finally carried out. s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteConfirm, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docIDStr, Success: true, Detail: "confirmed req:" + strconv.FormatInt(exec.RequestID, 10)}) // GoBD-taugliches Löschprotokoll (ecoDMS-Muster): the execute entry carries // a structured, self-contained record of the final, irreversible deletion — // who requested it (User A) and when, who confirmed/executed it (User B, the // current session) and when, which document (title + content_hash as the // tamper-evident fingerprint of the removed WORM file), the retention state // at execution time, and the legal basis (Vier-Augen-Prinzip + elapsed/absent // retention). Encoded as JSON in the audit Detail field so the append-only // audit_log (DB + JSON-Lines mirror) remains the single source of truth // without a dedicated table. protokoll := map[string]any{ "loeschprotokoll": true, "document_id": docIDStr, "title": exec.Title, "content_hash": exec.ContentHash, "worm_file_removed": exec.StoragePath, "request_id": exec.RequestID, "requested_by_user_id": exec.RequestedBy, "requested_at": exec.RequestedAt.UTC().Format(time.RFC3339), "confirmed_by_user_id": sess.UserID, "confirmed_by_username": sess.Username, "executed_at": time.Now().UTC().Format(time.RFC3339), "rechtsgrundlage": "Vier-Augen-Prinzip erfuellt; Aufbewahrungsfrist (retain_until) abgelaufen oder nicht gesetzt", } if exec.RetainUntil != nil { protokoll["retain_until"] = exec.RetainUntil.UTC().Format(time.RFC3339) } else { protokoll["retain_until"] = nil } detail := "executed req:" + strconv.FormatInt(exec.RequestID, 10) + " requested_by_user_id:" + strconv.FormatInt(exec.RequestedBy, 10) + " removed:" + exec.StoragePath if b, jerr := json.Marshal(protokoll); jerr == nil { detail = string(b) } s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteExecute, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docIDStr, Success: true, Detail: detail}) writeJSON(w, http.StatusOK, map[string]string{"status": "executed"}) }