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:
2026-08-11 21:27:53 +02:00
parent 40ed80da71
commit 9a24ea29e1
274 changed files with 53708 additions and 0 deletions
+237
View File
@@ -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"})
}