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,391 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user