// 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 ) 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.reqLog(r.Context()).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.reqLog(r.Context()).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.reqLog(r.Context()).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.reqLog(r.Context()).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) }