CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
329 lines
14 KiB
Go
329 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/apiwiring"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/cmis"
|
|
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/dbadmission"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/einvoice"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/folderapi"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/legalhold"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/mailimport"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/moduleauth"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/policyclient"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/retentionclient"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/retentiondestroy"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/searchsync"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/shared"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/trigger"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/upload"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/workerruntime"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/workflowevent"
|
|
)
|
|
|
|
type uploadStack struct {
|
|
svc *upload.Service
|
|
storage *storage.Service
|
|
crypto *dmscrypto.Service
|
|
}
|
|
|
|
func newUploadStack(pool *pgxpool.Pool, stagingDir string) uploadStack {
|
|
storageDir := os.Getenv("NEXARCH_DMS_APP_STORAGE_DIR")
|
|
signingSecret := os.Getenv("NEXARCH_DMS_APP_STORAGE_SIGNING_SECRET")
|
|
publicBaseURL := os.Getenv("NEXARCH_DMS_APP_STORAGE_PUBLIC_BASE_URL")
|
|
driver := storage.NewLocalDriver(storageDir, []byte(signingSecret), publicBaseURL, signedURLMaxTTL(os.Getenv("NEXARCH_DMS_APP_SIGNED_URL_MAX_TTL")))
|
|
usageReporter := storage.NewHTTPUsageReporter(
|
|
os.Getenv("NEXARCH_DMS_APP_USAGE_ENDPOINT_URL"),
|
|
os.Getenv("NEXARCH_DMS_APP_USAGE_CLIENT_ID"),
|
|
os.Getenv("NEXARCH_DMS_APP_USAGE_CLIENT_SECRET"),
|
|
nil,
|
|
)
|
|
tenantSlug := os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG")
|
|
storageSvc := storage.NewService(driver, usageReporter, tenantSlug)
|
|
|
|
kekProvider := dmscrypto.NewHTTPKEKProvider(
|
|
os.Getenv("NEXARCH_DMS_APP_KEK_ENDPOINT_URL"),
|
|
os.Getenv("NEXARCH_DMS_APP_KEK_CLIENT_ID"),
|
|
os.Getenv("NEXARCH_DMS_APP_KEK_CLIENT_SECRET"),
|
|
nil,
|
|
)
|
|
cryptoSvc := dmscrypto.NewService(kekProvider)
|
|
|
|
maxSize := int64(0)
|
|
if raw := os.Getenv("NEXARCH_DMS_APP_UPLOAD_MAX_SIZE_BYTES"); raw != "" {
|
|
if v, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
|
maxSize = v
|
|
}
|
|
}
|
|
var allowedMimeTypes []string
|
|
if raw := os.Getenv("NEXARCH_DMS_APP_UPLOAD_ALLOWED_MIME_TYPES"); raw != "" {
|
|
allowedMimeTypes = strings.Split(raw, ",")
|
|
}
|
|
validator := upload.NewValidator(maxSize, allowedMimeTypes)
|
|
|
|
sessions := upload.NewSessionStore(pool)
|
|
staging := upload.NewStaging(stagingDir)
|
|
svc := upload.NewService(pool, sessions, staging, storageSvc, cryptoSvc, validator, tenantSlug)
|
|
enqueueTypes := []string{"thumbnail.generate", "ocr.plan", "einvoice.read"}
|
|
if os.Getenv("NEXARCH_DMS_APP_MANTICORE_ADDR") != "" {
|
|
enqueueTypes = append(enqueueTypes, searchsync.JobType)
|
|
}
|
|
if raw := os.Getenv("NEXARCH_DMS_APP_ENQUEUE_JOB_TYPES"); raw != "" {
|
|
enqueueTypes = strings.Split(raw, ",")
|
|
}
|
|
workerruntime.AttachEnqueuers(svc, pool, jobqueue.NewQueue(pool, 5*time.Minute), enqueueTypes)
|
|
hasScan := false
|
|
for _, jt := range enqueueTypes {
|
|
hasScan = hasScan || jt == "malwarescan.scan"
|
|
}
|
|
if !hasScan {
|
|
log.Print("dms-app: Virenscan wird beim Import nicht eingereiht (NEXARCH_DMS_APP_ENQUEUE_JOB_TYPES ohne malwarescan.scan): importierte Dokumente werden ungescannt angezeigt")
|
|
}
|
|
// INT-20: Trigger feuern synchron nach Complete, keine Job-Queue noetig.
|
|
svc.SetTriggerFirer(&trigger.Adapter{Pool: pool, Vault: trigger.CryptoVault{Crypto: cryptoSvc}, TenantSlug: tenantSlug})
|
|
// INT-13: nur einreihen (Postgres-Jobqueue), die Zustellung uebernimmt
|
|
// der Worker aus workerruntime.New (workflowevent.JobType).
|
|
svc.SetWorkflowEventPublisher(workflowevent.NewPublisher(jobqueue.NewQueue(pool, 5*time.Minute)))
|
|
return uploadStack{svc: svc, storage: storageSvc, crypto: cryptoSvc}
|
|
}
|
|
|
|
// workflowEventConfig liest die INT-13-Anbindung aus der Umgebung
|
|
// (Standard aus, siehe workflowevent.Config.Enabled).
|
|
func workflowEventConfig() workflowevent.Config {
|
|
return workflowevent.Config{
|
|
BaseURL: os.Getenv("NEXARCH_DMS_APP_WORKFLOW_URL"),
|
|
ServiceToken: os.Getenv("NEXARCH_DMS_APP_WORKFLOW_SERVICE_TOKEN"),
|
|
TenantSlug: os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG"),
|
|
}
|
|
}
|
|
|
|
func envSeconds(name string, def time.Duration) time.Duration {
|
|
if v, err := strconv.Atoi(os.Getenv(name)); err == nil && v > 0 {
|
|
return time.Duration(v) * time.Second
|
|
}
|
|
return def
|
|
}
|
|
|
|
func envInt(name string, def int) int {
|
|
if v, err := strconv.Atoi(os.Getenv(name)); err == nil && v > 0 {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// admissionGate baut das Zulassungs-Gate (FDN-28) aus der Umgebung.
|
|
// NEXARCH_DMS_APP_ADMISSION_MAX_CONCURRENT=0 schaltet die Kontrolle ab
|
|
// (Deps.Admission bleibt nil, Verhalten wie vor diesem Ticket).
|
|
func admissionGate() *dbadmission.Gate {
|
|
max := envInt("NEXARCH_DMS_APP_ADMISSION_MAX_CONCURRENT", 40)
|
|
if os.Getenv("NEXARCH_DMS_APP_ADMISSION_MAX_CONCURRENT") == "0" {
|
|
return nil
|
|
}
|
|
wait := envSeconds("NEXARCH_DMS_APP_ADMISSION_MAX_WAIT_SECONDS", 2*time.Second)
|
|
return dbadmission.NewGate(max, wait)
|
|
}
|
|
|
|
func envBool(name string, def bool) bool {
|
|
v := strings.TrimSpace(strings.ToLower(os.Getenv(name)))
|
|
if v == "" {
|
|
return def
|
|
}
|
|
return v == "true" || v == "1" || v == "yes"
|
|
}
|
|
|
|
func splitIDs(raw string) []string {
|
|
var out []string
|
|
for _, id := range strings.Split(raw, ",") {
|
|
if id = strings.TrimSpace(id); id != "" {
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// setupAPI haengt alle dokument-eigenen Endpunkte ein (INT-01).
|
|
func setupAPI(api *http.ServeMux, dsn, prefix string) error {
|
|
pool, err := pgxpool.New(context.Background(), dsn)
|
|
if err != nil {
|
|
return fmt.Errorf("datenbankverbindung: %w", err)
|
|
}
|
|
stagingDir := os.Getenv("NEXARCH_DMS_APP_STAGING_DIR")
|
|
if stagingDir == "" {
|
|
stagingDir = os.Getenv("NEXARCH_DMS_APP_WATCHFOLDER_STAGING_DIR")
|
|
}
|
|
if stagingDir == "" {
|
|
return fmt.Errorf("NEXARCH_DMS_APP_STAGING_DIR nicht gesetzt")
|
|
}
|
|
stack := newUploadStack(pool, stagingDir)
|
|
tenantSlug := os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG")
|
|
return apiwiring.Mount(api, apiwiring.Deps{
|
|
ObjectACL: objectACLClientFor(tenantSlug),
|
|
Pool: pool,
|
|
Storage: stack.storage,
|
|
Crypto: stack.crypto,
|
|
TenantSlug: tenantSlug,
|
|
Upload: stack.svc,
|
|
DownloadSecret: []byte(os.Getenv("NEXARCH_DMS_APP_DOWNLOAD_SIGNING_SECRET")),
|
|
DownloadTTL: envSeconds("NEXARCH_DMS_APP_DOWNLOAD_URL_TTL_SECONDS", 5*time.Minute),
|
|
TrashRetention: envSeconds("NEXARCH_DMS_APP_TRASH_RETENTION_SECONDS", 30*24*time.Hour),
|
|
Auditors: splitIDs(os.Getenv("NEXARCH_DMS_APP_AUDITOR_USER_IDS")),
|
|
SearchEnqueuer: searchEnqueuerFor(pool),
|
|
LegalHolds: legalHoldCheckerFor(os.Getenv("NEXARCH_DMS_APP_RETENTION_BASE_URL"), os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG"), os.Getenv("NEXARCH_DMS_APP_RETENTION_SERVICE_TOKEN")),
|
|
PathPrefix: prefix,
|
|
MailStore: &mailimport.Store{Pool: pool, Crypto: stack.crypto, TenantSlug: tenantSlug},
|
|
EinvoiceEnqueuer: &einvoice.Enqueuer{Pool: pool, Queue: jobqueue.NewQueue(pool, 5*time.Minute)},
|
|
ShareLinksEnabled: envBool("NEXARCH_DMS_APP_SHARE_LINKS_ENABLED", true),
|
|
ShareLinkDefaultTTL: envSeconds("NEXARCH_DMS_APP_SHARE_LINK_DEFAULT_TTL_SECONDS", 7*24*time.Hour),
|
|
ShareLinkMaxTTL: envSeconds("NEXARCH_DMS_APP_SHARE_LINK_MAX_TTL_SECONDS", 14*24*time.Hour),
|
|
ShareLinkMaxAttempts: envInt("NEXARCH_DMS_APP_SHARE_LINK_MAX_ATTEMPTS", 5),
|
|
ShareLinkLockDuration: envSeconds("NEXARCH_DMS_APP_SHARE_LINK_LOCKOUT_SECONDS", 15*time.Minute),
|
|
})
|
|
}
|
|
|
|
// objectACLClientFor baut den Core-Client fuer die Objektrechte-Pruefung
|
|
// (RBAC-11, DOC-23), sofern NEXARCH_DMS_APP_POLICY_URL gesetzt ist (dieselben
|
|
// Zugangsdaten wie die Modulrecht-Pruefung in newChainConfig). Wichtig: bei
|
|
// Deaktivierung wird das nil-Interface (nicht ein nil *policyclient.Client)
|
|
// zurueckgegeben, sonst waere apiwiring.Deps.ObjectACL != nil trotz
|
|
// deaktivierter Pruefung wahr (typisierter Nil-Zeiger in einem Interface).
|
|
func objectACLClientFor(tenantSlug string) folderapi.ObjectACLClient {
|
|
policyURL := os.Getenv("NEXARCH_DMS_APP_POLICY_URL")
|
|
if policyURL == "" {
|
|
return nil
|
|
}
|
|
token := os.Getenv("NEXARCH_DMS_APP_POLICY_SERVICE_TOKEN")
|
|
if token == "" || tenantSlug == "" {
|
|
return nil
|
|
}
|
|
return policyclient.New(policyURL, token, envSeconds("NEXARCH_DMS_APP_POLICY_TTL_SECONDS", 30*time.Second))
|
|
}
|
|
|
|
func legalHoldCheckerFor(baseURL, tenant, serviceToken string) legalhold.Checker {
|
|
if baseURL == "" {
|
|
log.Print("dms-app: NEXARCH_DMS_APP_RETENTION_BASE_URL nicht gesetzt, endgueltiges Loeschen wird verweigert (fail closed, INT-11)")
|
|
return nil
|
|
}
|
|
return &legalhold.ArchiveChecker{Client: retentionclient.New(baseURL, tenant).WithServiceToken(serviceToken)}
|
|
}
|
|
|
|
// setupCMIS haengt die optionale CMIS-Schnittstelle ein (NEXARCH_DMS_APP_CMIS_ENABLED=true);
|
|
// der Backend-Handler wird erst gesetzt, wenn die Identitaetskette steht.
|
|
func setupCMIS(api *http.ServeMux, prefix string) *cmis.LateHandler {
|
|
if os.Getenv("NEXARCH_DMS_APP_CMIS_ENABLED") != "true" {
|
|
log.Print("dms-app: CMIS-Schnittstelle deaktiviert (NEXARCH_DMS_APP_CMIS_ENABLED != true)")
|
|
return nil
|
|
}
|
|
repoID := os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG")
|
|
if repoID == "" {
|
|
log.Print("dms-app: CMIS uebersprungen (NEXARCH_DMS_APP_TENANT_SLUG nicht gesetzt)")
|
|
return nil
|
|
}
|
|
maxBytes, _ := strconv.ParseInt(os.Getenv("NEXARCH_DMS_APP_CMIS_MAX_CONTENT_BYTES"), 10, 64)
|
|
late := &cmis.LateHandler{}
|
|
if err := cmis.Mount(api, cmis.Config{
|
|
RepositoryID: repoID, Version: shared.Version, RESTPrefix: prefix, Backend: late,
|
|
PublicBaseURL: os.Getenv("NEXARCH_DMS_APP_CMIS_PUBLIC_BASE_URL"), MaxContentBytes: maxBytes,
|
|
}); err != nil {
|
|
log.Fatalf("dms-app: cmis: %v", err)
|
|
}
|
|
log.Printf("dms-app: CMIS-Browser-Binding unter %s%s/browser eingebunden", prefix, "/cmis")
|
|
return late
|
|
}
|
|
|
|
func searchEnqueuerFor(pool *pgxpool.Pool) *searchsync.Enqueuer {
|
|
if os.Getenv("NEXARCH_DMS_APP_MANTICORE_ADDR") == "" {
|
|
return nil
|
|
}
|
|
return &searchsync.Enqueuer{Queue: jobqueue.NewQueue(pool, 5*time.Minute)}
|
|
}
|
|
|
|
// identityChain baut aus der Umgebung die Authentifizierung und optional die
|
|
// Rechteentscheidung; ohne Identitaetsquelle startet der Dienst nicht.
|
|
func identityChain(next http.Handler, prefix string) (http.Handler, error) {
|
|
jwksURL := os.Getenv("NEXARCH_DMS_APP_CORE_JWKS_URL")
|
|
allowCaller := os.Getenv("NEXARCH_DMS_APP_ALLOW_CALLER_IDENTITY") == "true"
|
|
if jwksURL == "" && !allowCaller {
|
|
return nil, fmt.Errorf("keine identitaetsquelle: NEXARCH_DMS_APP_CORE_JWKS_URL setzen (oder uebergangsweise NEXARCH_DMS_APP_ALLOW_CALLER_IDENTITY=true)")
|
|
}
|
|
cfg := apiwiring.ChainConfig{
|
|
Prefix: prefix, AllowCaller: allowCaller, HSTSMaxAge: envSeconds("NEXARCH_DMS_APP_HSTS_MAX_AGE_SECONDS", 0),
|
|
Admission: admissionGate(),
|
|
AdmissionRetryAfter: envSeconds("NEXARCH_DMS_APP_ADMISSION_RETRY_AFTER_SECONDS", 5*time.Second),
|
|
}
|
|
if policyURL := os.Getenv("NEXARCH_DMS_APP_POLICY_URL"); policyURL != "" {
|
|
token := os.Getenv("NEXARCH_DMS_APP_POLICY_SERVICE_TOKEN")
|
|
if token == "" {
|
|
return nil, fmt.Errorf("NEXARCH_DMS_APP_POLICY_URL ohne NEXARCH_DMS_APP_POLICY_SERVICE_TOKEN")
|
|
}
|
|
role := os.Getenv("NEXARCH_DMS_APP_POLICY_DEFAULT_ROLE")
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
admins := map[string]bool{}
|
|
for _, id := range splitIDs(os.Getenv("NEXARCH_DMS_APP_ADMIN_USER_IDS")) {
|
|
admins[id] = true
|
|
}
|
|
tenantSlug := os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG")
|
|
if tenantSlug == "" {
|
|
return nil, fmt.Errorf("NEXARCH_DMS_APP_POLICY_URL ohne NEXARCH_DMS_APP_TENANT_SLUG: die Rechteentscheidung braucht den Mandanten")
|
|
}
|
|
cfg.Policy = &policyclient.EnforceConfig{
|
|
Client: policyclient.New(policyURL, token, envSeconds("NEXARCH_DMS_APP_POLICY_TTL_SECONDS", 30*time.Second)),
|
|
Prefix: prefix, DefaultRole: role, Tenant: tenantSlug, AdminRole: "tenant_admin", Admins: admins,
|
|
}
|
|
} else {
|
|
log.Print("dms-app: NEXARCH_DMS_APP_POLICY_URL nicht gesetzt, keine zentrale Rechteentscheidung (nur Eigentuemer-/Admin-Regeln des Guards)")
|
|
}
|
|
if jwksURL != "" {
|
|
cfg.Verifier = newModuleVerifier(os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG"), jwksURL)
|
|
}
|
|
if allowCaller {
|
|
log.Print("dms-app: WARNUNG: caller-Parameter als Identitaet zugelassen (NEXARCH_DMS_APP_ALLOW_CALLER_IDENTITY=true), nicht authentifiziert")
|
|
}
|
|
return apiwiring.Chain(next, cfg), nil
|
|
}
|
|
|
|
// newModuleVerifier bindet das Modul-JWT an Zielmodul und Aussteller (QA-15
|
|
// F-07). Standard streng; NEXARCH_DMS_APP_JWT_AUDIENCE_ENFORCE=false erlaubt im
|
|
// Ausrollfenster noch Tokens ohne "aud" (Core zuerst umstellen, dann DMS).
|
|
func newModuleVerifier(tenantSlug, jwksURL string) *moduleauth.Verifier {
|
|
audience := os.Getenv("NEXARCH_DMS_APP_JWT_AUDIENCE")
|
|
if audience == "" {
|
|
audience = "dms"
|
|
}
|
|
issuer := os.Getenv("NEXARCH_DMS_APP_JWT_ISSUER")
|
|
if issuer == "" {
|
|
issuer = "nexarch-core"
|
|
}
|
|
enforce := os.Getenv("NEXARCH_DMS_APP_JWT_AUDIENCE_ENFORCE") != "false"
|
|
if !enforce {
|
|
log.Print("dms-app: WARNUNG: NEXARCH_DMS_APP_JWT_AUDIENCE_ENFORCE=false, Tokens ohne aud werden noch angenommen (nur fuer das Ausrollfenster)")
|
|
}
|
|
return moduleauth.NewVerifier(envSeconds("NEXARCH_DMS_APP_JWKS_TTL_SECONDS", 5*time.Minute), tenantSlug,
|
|
moduleauth.HTTPKeyFetcher(jwksURL, nil)).Expect(audience, issuer, enforce)
|
|
}
|
|
|
|
func callbackOptions(tenantSlug string) retentiondestroy.CallbackOptions {
|
|
opts := retentiondestroy.CallbackOptions{
|
|
InstanceTenant: tenantSlug,
|
|
RequireServiceToken: os.Getenv("NEXARCH_DMS_APP_CALLBACK_REQUIRE_SERVICE_TOKEN") == "true",
|
|
}
|
|
if jwksURL := os.Getenv("NEXARCH_DMS_APP_CORE_JWKS_URL"); jwksURL != "" {
|
|
opts.Verifier = newModuleVerifier(tenantSlug, jwksURL)
|
|
}
|
|
return opts
|
|
}
|