Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
392 lines
15 KiB
Go
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)
|
|
}
|