FDN-02/FDN-03/FDN-07/FDN-08: Migrations-Rollback, Objekt-Storage-Interface, go.sum-Fix, Observability
- FDN-02: Rollback-fähige Down-Migrationen (024-026), archivdms seed dev CLI - FDN-03: internal/objectstore Interface + lokaler WORM-Treiber, signierte Download-URLs - FDN-07: go.mod/go.sum vervollständigt (fehlender go-ldap/v3-Eintrag), CI-Pipeline (.gitea/workflows/ci.yml, bereits in FDN-01 committet) damit lauffähig - FDN-08: Request-ID-Middleware, /metrics-Endpoint, Panic-Recovery, Login/Logout/Me technisches Logging inkl. Access-Log je Anfrage
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
// Signed, time-limited download URLs for archived documents (FDN-03,
|
||||
// internal/objectstore). Same principle as the external share links
|
||||
// (share_handlers.go / public_share_handlers.go) — unguessable credential in
|
||||
// the link, mandatory hard expiry, IP rate limiting, audit trail — but
|
||||
// stateless: the credential is an HMAC-SHA256 signature over
|
||||
// tenant|document|expiry instead of a DB row.
|
||||
//
|
||||
// POST /api/documents/{id}/signed-url issue a link (authenticated, tenant-scoped)
|
||||
// GET /public/files?t=&d=&exp=&sig= redeem it (no session, signature is the credential)
|
||||
//
|
||||
// Use share links when a document is handed to an external party with its own
|
||||
// lifecycle (revoke, password, access cap); use signed URLs for short-lived
|
||||
// machine access to the file itself (viewer, export job) without a session
|
||||
// cookie. Nothing here bypasses tenant scoping: the tenant id is taken from
|
||||
// the signed payload and every lookup still filters on it.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/objectstore"
|
||||
)
|
||||
|
||||
// createSignedURLRequest is the optional POST body. TTLMinutes overrides the
|
||||
// configured default validity (storage.signed_url_ttl_minutes); it is capped
|
||||
// at 24 hours so no effectively unbounded link can be minted.
|
||||
type createSignedURLRequest struct {
|
||||
TTLMinutes int `json:"ttl_minutes,omitempty"`
|
||||
}
|
||||
|
||||
// createSignedURLResponse is returned to the caller.
|
||||
type createSignedURLResponse struct {
|
||||
URL string `json:"url"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// maxSignedURLTTL caps a caller-supplied validity.
|
||||
const maxSignedURLTTL = 24 * time.Hour
|
||||
|
||||
// handleCreateDocumentSignedURL handles POST /api/documents/{id}/signed-url.
|
||||
func (s *Server) handleCreateDocumentSignedURL(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess.TenantID == nil {
|
||||
writeError(w, http.StatusForbidden, "tenant context required")
|
||||
return
|
||||
}
|
||||
if s.objects == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "Dateispeicher nicht konfiguriert")
|
||||
return
|
||||
}
|
||||
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid document id")
|
||||
return
|
||||
}
|
||||
var req createSignedURLRequest
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&req) // body is optional
|
||||
}
|
||||
ttl := time.Duration(req.TTLMinutes) * time.Minute
|
||||
if ttl <= 0 {
|
||||
ttl = s.storageCfg.ResolvedSignedURLTTL()
|
||||
}
|
||||
if ttl > maxSignedURLTTL {
|
||||
ttl = maxSignedURLTTL
|
||||
}
|
||||
|
||||
// IDOR guard: the document must belong to the caller's tenant. GetDocument
|
||||
// filters WHERE id = $1 AND tenant_id = $2.
|
||||
doc, err := s.store.GetDocument(r.Context(), docID, *sess.TenantID)
|
||||
if err != nil {
|
||||
s.logShare(r, audit.EventSignedURLCreated, sess.TenantID, sess.Username,
|
||||
"signed_url doc:"+strconv.FormatInt(docID, 10)+" err:not_found", false)
|
||||
writeError(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
link, err := s.objects.SignedURL(*sess.TenantID, doc.ID, ttl)
|
||||
if err != nil {
|
||||
s.logShare(r, audit.EventSignedURLCreated, sess.TenantID, sess.Username,
|
||||
"signed_url doc:"+strconv.FormatInt(docID, 10)+" err:"+err.Error(), false)
|
||||
writeError(w, http.StatusInternalServerError, "Link konnte nicht erstellt werden")
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(ttl)
|
||||
s.logShare(r, audit.EventSignedURLCreated, sess.TenantID, sess.Username,
|
||||
"signed_url doc:"+strconv.FormatInt(docID, 10)+" ttl:"+ttl.String(), true)
|
||||
writeJSON(w, http.StatusOK, createSignedURLResponse{URL: link, ExpiresAt: expiresAt})
|
||||
}
|
||||
|
||||
// handleSignedFileDownload handles GET /public/files?t=&d=&exp=&sig=. Served
|
||||
// WITHOUT the s.auth wrapper by design: the signature is the credential.
|
||||
// Order: rate-limit -> verify signature -> verify expiry -> tenant-scoped
|
||||
// document lookup -> stream from the WORM store. Every outcome is audit-logged
|
||||
// (EventSignedURLAccessed), successes and failures alike.
|
||||
func (s *Server) handleSignedFileDownload(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.remoteIP(r)
|
||||
if !s.shareLimiter.allow(ip) {
|
||||
writeError(w, http.StatusTooManyRequests, "too many requests")
|
||||
return
|
||||
}
|
||||
if s.objects == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "Dateispeicher nicht konfiguriert")
|
||||
return
|
||||
}
|
||||
ref, err := s.objects.VerifySignedURL(r.URL.Query(), time.Now())
|
||||
if err != nil {
|
||||
s.logSignedAccess(r, nil, "signed_url_access err:"+err.Error(), false)
|
||||
if errors.Is(err, objectstore.ErrSignatureExpired) {
|
||||
writeError(w, http.StatusGone, "Der Link ist abgelaufen.")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusForbidden, "Der Link ist ungültig.")
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := s.store.GetDocument(r.Context(), ref.DocumentID, ref.TenantID)
|
||||
if err != nil {
|
||||
s.logSignedAccess(r, &ref.TenantID,
|
||||
"signed_url_access doc:"+strconv.FormatInt(ref.DocumentID, 10)+" err:not_found", false)
|
||||
writeError(w, http.StatusNotFound, "Dokument nicht gefunden.")
|
||||
return
|
||||
}
|
||||
f, err := s.objects.Open(r.Context(), ref.TenantID, doc.StoragePath)
|
||||
if err != nil {
|
||||
s.reqLog(r.Context()).Error("signed url file open failed", "document_id", doc.ID, "tenant_id", ref.TenantID, "err", err)
|
||||
s.logSignedAccess(r, &ref.TenantID,
|
||||
"signed_url_access doc:"+strconv.FormatInt(doc.ID, 10)+" err:"+err.Error(), false)
|
||||
if errors.Is(err, objectstore.ErrObjectNotFound) {
|
||||
writeError(w, http.StatusNotFound, "Datei nicht im Archiv vorhanden.")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Download fehlgeschlagen.")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
s.logSignedAccess(r, &ref.TenantID, "signed_url_access doc:"+strconv.FormatInt(doc.ID, 10), true)
|
||||
|
||||
ext := filepath.Ext(doc.StoragePath)
|
||||
w.Header().Set("Content-Type", detectMimeType("", ext, doc.StoragePath))
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+safeDownloadName(doc.Title, ext)+"\"")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if _, err := io.Copy(w, f); err != nil {
|
||||
s.reqLog(r.Context()).Warn("signed url stream interrupted", "document_id", doc.ID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// logSignedAccess records a redemption attempt in the audit log. There is no
|
||||
// session here, so the username column carries the anonymous marker.
|
||||
func (s *Server) logSignedAccess(r *http.Request, tenantID *int64, detail string, ok bool) {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventSignedURLAccessed, Username: "anonymous", TenantID: tenantID,
|
||||
IPAddress: s.remoteIP(r), Success: ok, Detail: detail,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user