Files
archivdms/internal/api/accounting_handlers.go
T
patrick 9a24ea29e1 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.
2026-08-11 21:27:53 +02:00

392 lines
15 KiB
Go

// Buchhaltungs-Pull-API: a reduced, read-only, machine-to-machine export path
// so an accounting system (DATEV-Vorerfassung, Kanzlei-Software, ...) can pull
// belegdatum-scored documents out of archivdms without a browser session.
//
// Two clearly separated halves:
//
// 1. Key administration — normal JWT-cookie/session endpoints (domain_admin+,
// same pattern as retention_rule_handlers.go):
//
// POST /api/accounting/api-keys create, returns the plaintext key ONCE
// GET /api/accounting/api-keys list (label/timestamps only, no key)
// DELETE /api/accounting/api-keys/{id} revoke (never hard-deleted)
//
// 2. The pull endpoints themselves — NOT wrapped in s.auth. They use
// s.accountingAuth (Authorization: Bearer <key>) instead:
//
// GET /api/v1/accounting/documents keyset-paginated metadata
// GET /api/v1/accounting/documents/{id}/file streams the WORM file
//
// TENANT ISOLATION (critical — this is the only non-browser access path):
// s.accountingAuth resolves the raw bearer key to a tenant id via
// storage.ResolveAccountingAPIKey and puts ONLY that id into the request
// context (accountingTenantKey). The pull handlers read the tenant id
// exclusively from that context via accountingCtxFromRequest; there is no code
// path in which a tenant_id from the query string, a header or a body is
// consulted. The store functions they call (ListAccountingDocuments,
// GetAccountingDocumentFile) take tenantID as a mandatory first argument and
// have no unscoped variant. A document belonging to another tenant is
// indistinguishable from a nonexistent one (404, never 403).
package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
const (
accountingTenantKey contextKey = "accounting_tenant_id"
accountingKeyIDKey contextKey = "accounting_key_id"
)
// accountingMaxLimit caps the page size a client may request.
const accountingMaxLimit = 500
// accountingDefaultLimit is used when no (or an invalid) limit is given.
const accountingDefaultLimit = 100
// --- key administration (session-authenticated, domain_admin+) ---
// createAccountingKeyRequest is the JSON body for POST /api/accounting/api-keys.
type createAccountingKeyRequest struct {
Label string `json:"label"`
}
// createAccountingKeyResponse is the ONLY place the plaintext key is ever
// returned. It is not persisted anywhere in plaintext and cannot be retrieved
// again.
type createAccountingKeyResponse struct {
Key storage.AccountingAPIKey `json:"key"`
// PlaintextKey is shown exactly once — the caller must store it now.
PlaintextKey string `json:"plaintext_key"`
}
// handleCreateAccountingAPIKey handles POST /api/accounting/api-keys (domain_admin+).
func (s *Server) handleCreateAccountingAPIKey(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
var req createAccountingKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
label := strings.TrimSpace(req.Label)
if label == "" {
writeError(w, http.StatusBadRequest, "label is required")
return
}
userID := sess.UserID
key, plaintext, err := s.store.CreateAccountingAPIKey(r.Context(), *sess.TenantID, label, &userID)
if err != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingKeyCreated, Username: sess.Username, IPAddress: s.remoteIP(r),
TenantID: sess.TenantID, Success: false,
Detail: "accounting_key_create label:" + label + " err:" + err.Error(),
})
writeError(w, http.StatusInternalServerError, "create accounting api key failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingKeyCreated, Username: sess.Username, IPAddress: s.remoteIP(r),
TenantID: sess.TenantID, Success: true,
Detail: "accounting_key_create id:" + strconv.FormatInt(key.ID, 10) + " label:" + label,
})
writeJSON(w, http.StatusCreated, createAccountingKeyResponse{Key: *key, PlaintextKey: plaintext})
}
// handleListAccountingAPIKeys handles GET /api/accounting/api-keys (domain_admin+).
// Never returns the plaintext key or its hash.
func (s *Server) handleListAccountingAPIKeys(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
keys, err := s.store.ListAccountingAPIKeys(r.Context(), *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list accounting api keys failed")
return
}
if keys == nil {
keys = []storage.AccountingAPIKey{}
}
writeJSON(w, http.StatusOK, keys)
}
// handleRevokeAccountingAPIKey handles DELETE /api/accounting/api-keys/{id}
// (domain_admin+). Revoke only — the row stays so the audit trail of past
// pulls remains resolvable.
func (s *Server) handleRevokeAccountingAPIKey(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 id")
return
}
if err := s.store.RevokeAccountingAPIKey(r.Context(), id, *sess.TenantID); err != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingKeyRevoked, Username: sess.Username, IPAddress: s.remoteIP(r),
TenantID: sess.TenantID, Success: false,
Detail: "accounting_key_revoke id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(),
})
if errors.Is(err, storage.ErrAccountingKeyNotFound) {
writeError(w, http.StatusNotFound, "accounting api key not found")
return
}
writeError(w, http.StatusInternalServerError, "revoke accounting api key failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingKeyRevoked, Username: sess.Username, IPAddress: s.remoteIP(r),
TenantID: sess.TenantID, Success: true,
Detail: "accounting_key_revoke id:" + strconv.FormatInt(id, 10),
})
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
// --- bearer-key middleware for the pull endpoints ---
// accountingAuth is the API-key middleware for the pull endpoints. It is
// deliberately separate from s.authMiddleware (JWT cookie): no session, no
// role, no user — just a tenant-scoped machine credential.
//
// It puts the tenant id resolved FROM THE KEY into the request context. This is
// the single source of truth for tenant scoping downstream; handlers must never
// read a tenant id from the request itself.
func (s *Server) accountingAuth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := s.remoteIP(r)
// Per-IP rate limit blunts key guessing on this unauthenticated-until-
// resolved path (same limiter type as the public share endpoints).
if !s.accountingLimiter.allow(ip) {
writeError(w, http.StatusTooManyRequests, "too many requests")
return
}
rawKey := extractBearerToken(r)
if rawKey == "" {
w.Header().Set("WWW-Authenticate", "Bearer")
writeError(w, http.StatusUnauthorized, "missing bearer api key")
return
}
tenantID, keyID, err := s.store.ResolveAccountingAPIKey(r.Context(), rawKey)
if err != nil {
if !errors.Is(err, storage.ErrAccountingKeyNotFound) {
s.logger.Error("accounting api key resolve failed", "err", err)
}
// Unknown, revoked and broken keys are indistinguishable.
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: ip,
Success: false, Detail: "accounting_auth rejected path:" + r.URL.Path,
})
w.Header().Set("WWW-Authenticate", "Bearer")
writeError(w, http.StatusUnauthorized, "invalid api key")
return
}
ctx := context.WithValue(r.Context(), accountingTenantKey, tenantID)
ctx = context.WithValue(ctx, accountingKeyIDKey, keyID)
h(w, r.WithContext(ctx))
}
}
// accountingCtxFromRequest returns the tenant id and key id that
// accountingAuth resolved. ok is false only if the handler was somehow reached
// without the middleware — handlers then must refuse to do anything.
func accountingCtxFromRequest(ctx context.Context) (tenantID, keyID int64, ok bool) {
t, tOK := ctx.Value(accountingTenantKey).(int64)
k, kOK := ctx.Value(accountingKeyIDKey).(int64)
if !tOK || !kOK {
return 0, 0, false
}
return t, k, true
}
// --- pull endpoints (bearer-key authenticated) ---
// handleAccountingListDocuments handles
// GET /api/v1/accounting/documents?since=&until=&doc_type_id=&min_date_score=&cursor=&limit=
//
// since/until are dates (YYYY-MM-DD or RFC3339) bounding document_date;
// min_date_score gates on the belegdatum confidence (e.g. 0.75); cursor/limit
// drive keyset pagination over (created_at, id). Any tenant_id query parameter
// is ignored — scoping comes from the API key alone.
func (s *Server) handleAccountingListDocuments(w http.ResponseWriter, r *http.Request) {
tenantID, keyID, ok := accountingCtxFromRequest(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "invalid api key")
return
}
q := r.URL.Query()
filter := storage.AccountingDocumentFilter{
Cursor: q.Get("cursor"),
Limit: accountingDefaultLimit,
}
if v := strings.TrimSpace(q.Get("since")); v != "" {
t, err := parseAccountingDate(v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid since (expected YYYY-MM-DD or RFC3339)")
return
}
filter.Since = &t
}
if v := strings.TrimSpace(q.Get("until")); v != "" {
t, err := parseAccountingDate(v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid until (expected YYYY-MM-DD or RFC3339)")
return
}
filter.Until = &t
}
if v := strings.TrimSpace(q.Get("doc_type_id")); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid doc_type_id")
return
}
filter.DocTypeID = &id
}
if v := strings.TrimSpace(q.Get("min_date_score")); v != "" {
score, err := strconv.ParseFloat(v, 64)
if err != nil || score < 0 || score > 1 {
writeError(w, http.StatusBadRequest, "invalid min_date_score (expected 0..1)")
return
}
filter.MinDateScore = &score
}
if v := strings.TrimSpace(q.Get("limit")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "invalid limit")
return
}
if n > accountingMaxLimit {
n = accountingMaxLimit
}
filter.Limit = n
}
page, err := s.store.ListAccountingDocuments(r.Context(), tenantID, filter)
if err != nil {
if errors.Is(err, storage.ErrInvalidAccountingCursor) {
writeError(w, http.StatusBadRequest, "invalid cursor")
return
}
s.logger.Error("accounting list failed", "tenant_id", tenantID, "err", err)
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: s.remoteIP(r),
TenantID: &tenantID, Success: false,
Detail: "accounting_pull list key:" + strconv.FormatInt(keyID, 10) + " err:" + err.Error(),
})
writeError(w, http.StatusInternalServerError, "list documents failed")
return
}
if page.Documents == nil {
page.Documents = []storage.AccountingDocument{}
}
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: s.remoteIP(r),
TenantID: &tenantID, Success: true,
Detail: "accounting_pull list key:" + strconv.FormatInt(keyID, 10) +
" count:" + strconv.Itoa(len(page.Documents)) +
" range:" + accountingIDRange(page.Documents),
})
writeJSON(w, http.StatusOK, page)
}
// handleAccountingDocumentFile handles GET /api/v1/accounting/documents/{id}/file.
// Streams the archived WORM file through the handler — storage_path is never
// exposed. Scoped to the API key's tenant; a foreign or unknown document both
// yield 404 (no existence leak, mirroring handleGetDocumentFile).
func (s *Server) handleAccountingDocumentFile(w http.ResponseWriter, r *http.Request) {
tenantID, keyID, ok := accountingCtxFromRequest(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "invalid api key")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
ref, err := s.store.GetAccountingDocumentFile(r.Context(), id, tenantID)
if err != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: s.remoteIP(r),
TenantID: &tenantID, DocumentID: strconv.FormatInt(id, 10), Success: false,
Detail: "accounting_pull file key:" + strconv.FormatInt(keyID, 10) + " not_found",
})
writeError(w, http.StatusNotFound, "document not found")
return
}
f, err := os.Open(ref.StoragePath())
if err != nil {
s.logger.Error("accounting file open failed", "document_id", ref.DocumentID, "tenant_id", tenantID, "err", err)
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: s.remoteIP(r),
TenantID: &tenantID, DocumentID: strconv.FormatInt(id, 10), Success: false,
Detail: "accounting_pull file key:" + strconv.FormatInt(keyID, 10) + " open_failed",
})
writeError(w, http.StatusInternalServerError, "file unavailable")
return
}
defer f.Close()
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: s.remoteIP(r),
TenantID: &tenantID, DocumentID: strconv.FormatInt(id, 10), Success: true,
Detail: "accounting_pull file key:" + strconv.FormatInt(keyID, 10),
})
ext := filepath.Ext(ref.StoragePath())
w.Header().Set("Content-Type", detectMimeType("", ext, ref.StoragePath()))
w.Header().Set("Content-Disposition", "attachment; filename=\""+safeDownloadName(ref.Title, ext)+"\"")
w.Header().Set("X-Content-Type-Options", "nosniff")
if _, err := io.Copy(w, f); err != nil {
s.logger.Warn("accounting file stream interrupted", "document_id", ref.DocumentID, "err", err)
}
}
// parseAccountingDate accepts either a plain date (YYYY-MM-DD, interpreted as
// UTC midnight) or a full RFC3339 timestamp.
func parseAccountingDate(v string) (time.Time, error) {
if t, err := time.Parse("2006-01-02", v); err == nil {
return t, nil
}
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return time.Time{}, err
}
return t, nil
}
// accountingIDRange renders "first-last" document ids of a page for the audit
// Detail, so a later GoBD audit can reconstruct what a pull actually returned.
func accountingIDRange(docs []storage.AccountingDocument) string {
if len(docs) == 0 {
return "-"
}
return strconv.FormatInt(docs[0].ID, 10) + "-" + strconv.FormatInt(docs[len(docs)-1].ID, 10)
}