FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
// 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"})
|
||||
}
|
||||
Reference in New Issue
Block a user