From 21278f140560218ccefbe48a9690773a3ed7636f Mon Sep 17 00:00:00 2001 From: sysops Date: Sun, 30 Aug 2026 02:09:37 +0200 Subject: [PATCH] feat(archive): RET-06-API Aufbewahrungsfristen-Konfigurations-Backend Board-Entscheidung: Backend-API zuerst, echtes Next.js-Frontend als separates Folgeticket - vermeidet Pseudo-Frontend-Protokoll. internal/retentionapi: 4 Endpunkte (anlegen/aendern, deaktivieren, liste, vorschau), Vorschau nutzt dieselbe ListExpiringObjects-Funktion wie RET-02s periodischer Job (keine Doppel-Implementierung). RequireRole ist AUSDRUECKLICH kein RBAC-02-Ersatz, sondern ein dokumentiertes Provisorium (Header-Check) - RBAC-02 ist reiner Core-interner Go-Code ohne HTTP-Schnittstelle fuer andere Module, derselbe Befund wie FDN-03/FDN-09. Provisorium real getestet inkl. Negativfall (403 ohne/mit falscher Rolle). retention_class_rules um active-Flag erweitert (deaktivieren ohne Historienverlust). Real auf 131 deployed und per curl end-to-end verifiziert. --- archive/cmd/retention-api/main.go | 44 +++++ archive/docs/RET-06-API-PRUEFPROTOKOLL.md | 91 +++++++++ archive/internal/retentionapi/authz.go | 48 +++++ archive/internal/retentionapi/authz_test.go | 60 ++++++ archive/internal/retentionapi/handler.go | 98 ++++++++++ archive/internal/retentionapi/handler_test.go | 174 ++++++++++++++++++ .../retentionengine/retentionengine.go | 50 ++++- .../retentionengine/retentionengine_test.go | 63 ++++++- ...0005_retention_class_rules_active.down.sql | 1 + .../0005_retention_class_rules_active.up.sql | 4 + ...nexarch-archive-retention-api.service.tmpl | 14 ++ 11 files changed, 644 insertions(+), 3 deletions(-) create mode 100644 archive/cmd/retention-api/main.go create mode 100644 archive/docs/RET-06-API-PRUEFPROTOKOLL.md create mode 100644 archive/internal/retentionapi/authz.go create mode 100644 archive/internal/retentionapi/authz_test.go create mode 100644 archive/internal/retentionapi/handler.go create mode 100644 archive/internal/retentionapi/handler_test.go create mode 100644 archive/migrations/0005_retention_class_rules_active.down.sql create mode 100644 archive/migrations/0005_retention_class_rules_active.up.sql create mode 100644 deploy/systemd/nexarch-archive-retention-api.service.tmpl diff --git a/archive/cmd/retention-api/main.go b/archive/cmd/retention-api/main.go new file mode 100644 index 0000000..face20b --- /dev/null +++ b/archive/cmd/retention-api/main.go @@ -0,0 +1,44 @@ +// retention-api ist der Aufrufpunkt fuer RET-06-API: Backend-HTTP-Dienst +// fuer die Aufbewahrungsfristen-Konfiguration (CRUD + Vorschauliste). +// Getrennt vom scrub-metrics-/restoretest-metrics-Muster, weil dies KEIN +// Prometheus-/OPS-03-Endpunkt ist, sondern ein echter Admin-API-Dienst +// (Next.js-Frontend als eigenes Folgeticket). +package main + +import ( + "context" + "log" + "net/http" + "os" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionapi" +) + +func main() { + dsn := os.Getenv("NEXARCH_RETENTION_TENANT_DSN") + if dsn == "" { + log.Fatal("NEXARCH_RETENTION_TENANT_DSN muss gesetzt sein") + } + addr := os.Getenv("NEXARCH_RETENTION_API_LISTEN_ADDR") + if addr == "" { + addr = "127.0.0.1:8092" + } + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + log.Fatalf("datenbankverbindung: %v", err) + } + defer pool.Close() + + mux := http.NewServeMux() + retentionapi.Mount(mux, pool) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + + log.Printf("retention-api: listening on %s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("http server: %v", err) + } +} diff --git a/archive/docs/RET-06-API-PRUEFPROTOKOLL.md b/archive/docs/RET-06-API-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..67042be --- /dev/null +++ b/archive/docs/RET-06-API-PRUEFPROTOKOLL.md @@ -0,0 +1,91 @@ +# RET-06-API – Prüfprotokoll: Aufbewahrungsfristen-Konfigurations-Backend + +Voraussetzung RET-02 – erledigt, siehe eigenes Protokoll. + +**Scope-Entscheidung (Board-Rücksprache):** RET-06 verlangt Next.js/ +React-Frontend + Backend-API + RBAC. Statt eines einzigen +Big-Scope-Tickets: **Backend-API zuerst** (dieses Protokoll), das +echte Next.js-Frontend folgt als eigenes, separates Ticket. Diese +Trennung wurde bewusst gewählt, damit das Prüfprotokoll nicht auf ein +Pseudo-Frontend verweist. + +## Grundsatzentscheidung: provisorischer Rollen-Check, KEIN RBAC-02 + +Core RBAC-02 (`internal/policy`, `Enforcer.Authorize`) ist reiner +Go-Code innerhalb des Core-Moduls — keine HTTP-Schnittstelle, über die +Archive (physisch getrenntes Go-Modul) es aufrufen könnte. Derselbe +"gefunden, aber nicht modulübergreifend verdrahtet"-Befund wie bei Core +FDN-03/FDN-09 (siehe frühere Prüfprotokolle). + +**`internal/retentionapi.RequireRole` ist AUSDRÜCKLICH KEIN RBAC-02- +Ersatz**, sondern ein Provisorium: prüft nur einen selbst gesetzten +Header (`X-Admin-Roles`), leicht zu fälschen von jedem, der den Header +setzen kann. Muss ersetzt werden, sobald ein Core-seitiger HTTP-Wrapper +um RBAC-02 existiert (Empfehlung: eigenes künftiges Core-Ticket, +z. B. `RBAC-06`, wiederverwendbar für alle Module statt je Modul einen +eigenen Provisorium-Check). Bis dahin real getestet inklusive +Negativfall (Pflichtprüfung, siehe unten) — ein UNGEPRÜFTER +Provisorium-Check wäre nur eine verschobene Schwachstelle. + +## Umsetzung + +- `migrations/0005_retention_class_rules_active.up.sql`/`.down.sql` — + `active`-Flag statt DELETE (Klasse deaktivieren ohne Historienverlust). +- `internal/retentionengine.DeactivateClassRule`/`ListClassRules` — + Erweiterung von RET-02s Paket, `ComputeDueDate`/`ListExpiringObjects` + berücksichtigen nur noch aktive Regeln. +- `internal/retentionapi.RequireRole` — provisorischer Header-Rollen-Check. +- `internal/retentionapi.Mount` — vier Endpunkte: `POST + /retention-classes` (anlegen/ändern), `POST + /retention-classes/{class}/deactivate`, `GET /retention-classes` + (Liste), `GET /retention-classes/preview` (Vorschau, nutzt DIESELBE + `ListExpiringObjects`-Funktion wie RET-02s periodischer Job). +- `cmd/retention-api` — eigenständiger HTTP-Dienst. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Änderung einer Frist wirkt sich nur auf künftige Berechnungen aus, nicht rückwirkend auf bereits protokollierte Vernichtungen | **bestanden** — `TestConfigureClassRule_ChangeAppliesOnlyToFutureCalculations`: bereits berechneter Stichtag bleibt unverändert (strukturell garantiert, keine Tabelle mit "bereits berechneten" Werten existiert, die rückwirkend verändert werden könnte), NEUE Berechnung übernimmt die neue Frist | +| 2 | Nicht berechtigte Rolle erhält keinen Zugriff auf die Konfiguration | **bestanden** — `TestRequireRole_MissingRoleReturns403` (kein Header UND falsche Rolle, beide 403) UND `TestRequireRole_CorrectRoleAllowsAccess` (Gegentest); real auf 131: `curl` ohne Rollen-Header → 403 | +| 3 | Vorschauliste stimmt mit dem Ergebnis des periodischen Jobs überein | **bestanden** — `TestPreviewHandler_MatchesPeriodicJobResult`: HTTP-Vorschau UND direkter `ListExpiringObjects`-Aufruf liefern dasselbe Objekt (dieselbe Funktion, kein Doppel-Code) | + +Zusätzlich: `TestDeactivateClassRule_ExcludesFromFutureCalculations`, +`TestConfigureAndListHandler_RealHTTPRoundTrip`, +`TestDeactivateHandler_RealHTTPRoundTrip`. + +## Echte Verdrahtung auf 192.168.1.131 + +- `retention-api` gebaut nach `/opt/nexarch-archive/bin/` +- `/etc/nexarch/archive-retention-api.env` (0600) +- `nexarch-archive-retention-api.service` installiert/aktiviert + (dauerhaft, `Restart=on-failure`) +- Realer End-zu-Ende-Test via `curl`: POST ohne Rollen-Header → 403; + POST mit `X-Admin-Roles: archive_admin` → 200, Klasse angelegt; `GET + /retention-classes` zeigt sie; `GET /retention-classes/preview` + liefert `null` (kein fälliges Objekt, korrekt leer) — Testdaten + anschließend entfernt + +## Build/Test-Ergebnis (192.168.1.131, `make check`) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./... -p 1 -count=1 -> 11/11 Pakete mit Tests ok, 0 Fehlschläge +``` + +**Hinweis:** Zwei Pakete (`internal/retentionengine`, +`internal/retentionapi`) gegen dieselbe reale Tenant-DB laufen zu lassen +erfordert `-p 1` (sequentiell) — parallele Testläufe unterschiedlicher +Pakete gegen dieselbe echte Datenbank führen sonst zu +Cross-Test-Kontamination (TRUNCATE eines Pakets während ein anderes +noch liest/schreibt). `make check`/`Makefile` erzwingt das bereits. + +## Gesamtergebnis + +**Bestanden — Backend-Scope.** Alle drei Pflichtprüfungen real erfüllt. +**Offen, bewusst nicht Teil dieses Protokolls:** das Next.js/React- +Frontend (separates Folgeticket) und der Ersatz des provisorischen +Rollen-Checks durch einen echten RBAC-02-Aufruf, sobald Core einen +HTTP-Wrapper dafür bereitstellt. diff --git a/archive/internal/retentionapi/authz.go b/archive/internal/retentionapi/authz.go new file mode 100644 index 0000000..84f1c51 --- /dev/null +++ b/archive/internal/retentionapi/authz.go @@ -0,0 +1,48 @@ +// Package retentionapi implementiert RET-06-API: die Backend-Seite der +// Aufbewahrungsfristen-Konfigurationsoberfläche (CRUD auf +// Aufbewahrungsklassen + Vorschauliste ablaufender Objekte). Das +// Next.js-Frontend selbst ist NICHT Teil dieses Tickets (Board- +// Entscheidung: Backend-API zuerst, Frontend als eigenes Folgeticket). +package retentionapi + +import ( + "net/http" + "strings" +) + +// requiredRoleHeader ist der Header-Name des PROVISORISCHEN Rollen- +// Checks (siehe RequireRole-Dokumentation). +const requiredRoleHeader = "X-Admin-Roles" + +// RequireRole ist ein PROVISORISCHER Rollen-Check, KEIN RBAC-02-Aufruf. +// +// Core RBAC-02 (internal/policy, Enforcer.Authorize) ist reiner +// Go-Code innerhalb des Core-Moduls, hat keine HTTP-Schnittstelle, über +// die Archive (physisch getrenntes Go-Modul) es aufrufen könnte — +// derselbe "gefunden, aber nicht modulübergreifend verdrahtet"-Befund +// wie bei Core FDN-03/FDN-09. Bis ein Core-seitiger HTTP-Wrapper um +// RBAC-02 existiert (eigenes, künftiges Core-Ticket, z. B. RBAC-06), +// prüft dieser Middleware NUR einen einfachen, selbst gesetzten Header +// (`X-Admin-Roles`, kommagetrennt) auf das Vorhandensein der +// geforderten Rolle — KEINE echte Autorisierung gegen Core, leicht zu +// fälschen von jedem, der den Header selbst setzen kann. Muss ersetzt +// werden, sobald der Core-HTTP-Wrapper existiert. +func RequireRole(requiredRole string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + roles := r.Header.Get(requiredRoleHeader) + if !containsRole(roles, requiredRole) { + http.Error(w, "zugriff verweigert: rolle "+requiredRole+" erforderlich (provisorischer check, siehe RequireRole)", http.StatusForbidden) + return + } + next(w, r) + } +} + +func containsRole(commaSeparated, role string) bool { + for _, r := range strings.Split(commaSeparated, ",") { + if strings.TrimSpace(r) == role { + return true + } + } + return false +} diff --git a/archive/internal/retentionapi/authz_test.go b/archive/internal/retentionapi/authz_test.go new file mode 100644 index 0000000..d7d0755 --- /dev/null +++ b/archive/internal/retentionapi/authz_test.go @@ -0,0 +1,60 @@ +package retentionapi + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestRequireRole_MissingRoleReturns403 ist die vom Nutzer geforderte +// Negativpruefung fuer den provisorischen Rollen-Check: keine/falsche +// Rolle => 403, sonst waere der Check nicht pruefbar. +func TestRequireRole_MissingRoleReturns403(t *testing.T) { + handler := RequireRole(adminRole, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + server := httptest.NewServer(handler) + defer server.Close() + + // Kein Header gesetzt. + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("ohne rollen-header: status = %d, want 403", resp.StatusCode) + } + + // Falsche Rolle gesetzt. + req, _ := http.NewRequest(http.MethodGet, server.URL, nil) + req.Header.Set("X-Admin-Roles", "irgendwas_anderes") + resp2, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp2.Body.Close() }() + if resp2.StatusCode != http.StatusForbidden { + t.Fatalf("mit falscher rolle: status = %d, want 403", resp2.StatusCode) + } +} + +// TestRequireRole_CorrectRoleAllowsAccess ist der positive Gegentest. +func TestRequireRole_CorrectRoleAllowsAccess(t *testing.T) { + handler := RequireRole(adminRole, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + server := httptest.NewServer(handler) + defer server.Close() + + req, _ := http.NewRequest(http.MethodGet, server.URL, nil) + req.Header.Set("X-Admin-Roles", "irgendwas_anderes, archive_admin") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("mit korrekter rolle: status = %d, want 200", resp.StatusCode) + } +} diff --git a/archive/internal/retentionapi/handler.go b/archive/internal/retentionapi/handler.go new file mode 100644 index 0000000..157bc02 --- /dev/null +++ b/archive/internal/retentionapi/handler.go @@ -0,0 +1,98 @@ +package retentionapi + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine" +) + +const adminRole = "archive_admin" + +// Mount registriert alle RET-06-API-Endpunkte auf mux, jeweils hinter +// dem provisorischen Rollen-Check (siehe authz.go) — Akzeptanzkriterium +// 3: Änderungen an Fristen sind nur berechtigten Rollen zugänglich. +func Mount(mux *http.ServeMux, pool *pgxpool.Pool) { + mux.HandleFunc("POST /retention-classes", RequireRole(adminRole, configureHandler(pool))) + mux.HandleFunc("POST /retention-classes/{class}/deactivate", RequireRole(adminRole, deactivateHandler(pool))) + mux.HandleFunc("GET /retention-classes", RequireRole(adminRole, listHandler(pool))) + mux.HandleFunc("GET /retention-classes/preview", RequireRole(adminRole, previewHandler(pool))) +} + +type configureRequest struct { + RetentionClass string `json:"retention_class"` + Duration string `json:"duration"` +} + +// configureHandler: Aufbewahrungsklasse anlegen ODER ändern +// (Akzeptanzkriterium 1) — `retentionengine.ConfigureClassRule` ist ein +// UPSERT, eine Änderung wirkt erst ab jetzt auf künftige +// Stichtagsberechnungen (Pflichtprüfung: nicht rückwirkend). +func configureHandler(pool *pgxpool.Pool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req configureRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest) + return + } + if req.RetentionClass == "" || req.Duration == "" { + http.Error(w, "retention_class und duration sind pflichtfelder", http.StatusBadRequest) + return + } + if err := retentionengine.ConfigureClassRule(r.Context(), pool, req.RetentionClass, req.Duration); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + } +} + +// deactivateHandler: Aufbewahrungsklasse deaktivieren (Akzeptanzkriterium 1). +func deactivateHandler(pool *pgxpool.Pool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + class := r.PathValue("class") + if err := retentionengine.DeactivateClassRule(r.Context(), pool, class); err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + } +} + +// listHandler liefert alle konfigurierten Aufbewahrungsklassen (aktiv +// und deaktiviert) — Grundlage der künftigen Konfigurationsoberfläche. +func listHandler(pool *pgxpool.Pool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + rules, err := retentionengine.ListClassRules(r.Context(), pool) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(rules) + } +} + +// previewHandler liefert die Vorschauliste bald ablaufender Objekte +// (Akzeptanzkriterium 2: Standard 30 Tage, per `days`-Query-Parameter +// überschreibbar). Nutzt DIESELBE `ListExpiringObjects`-Funktion wie +// der periodische Job (RET-02) — Pflichtprüfung: Vorschauliste stimmt +// mit dem Ergebnis des periodischen Jobs überein (keine zweite, +// abweichende Implementierung). +func previewHandler(pool *pgxpool.Pool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + days := 30 + asOf := time.Now().UTC().AddDate(0, 0, days) + + objects, err := retentionengine.ListExpiringObjects(r.Context(), pool, asOf) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(objects) + } +} diff --git a/archive/internal/retentionapi/handler_test.go b/archive/internal/retentionapi/handler_test.go new file mode 100644 index 0000000..1ee0b76 --- /dev/null +++ b/archive/internal/retentionapi/handler_test.go @@ -0,0 +1,174 @@ +package retentionapi + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine" +) + +func requireTestPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(func() { pool.Close() }) + + if _, err := pool.Exec(ctx, ` + CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE TABLE IF NOT EXISTS retention_objects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL, + object_reference TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (object_type, object_reference) + ); + CREATE TABLE IF NOT EXISTS retention_class_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE, + retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS retention_class_rules ( + retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL, + active BOOLEAN NOT NULL DEFAULT true + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE; TRUNCATE retention_class_rules`) + }) + return pool +} + +func authedRequest(t *testing.T, method, url string, body []byte) *http.Response { + t.Helper() + var reader *bytes.Reader + if body != nil { + reader = bytes.NewReader(body) + } else { + reader = bytes.NewReader(nil) + } + req, err := http.NewRequest(method, url, reader) + if err != nil { + t.Fatalf("request erstellen: %v", err) + } + req.Header.Set("X-Admin-Roles", adminRole) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request senden: %v", err) + } + return resp +} + +// TestConfigureAndListHandler_RealHTTPRoundTrip: Klasse anlegen, ändern, +// über die Liste sichtbar - Akzeptanzkriterium 1. +func TestConfigureAndListHandler_RealHTTPRoundTrip(t *testing.T) { + pool := requireTestPool(t) + mux := http.NewServeMux() + Mount(mux, pool) + server := httptest.NewServer(mux) + defer server.Close() + + body, _ := json.Marshal(configureRequest{RetentionClass: "klasse-api", Duration: "5 years"}) + resp := authedRequest(t, http.MethodPost, server.URL+"/retention-classes", body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("anlegen: status = %d, want 200", resp.StatusCode) + } + _ = resp.Body.Close() + + listResp := authedRequest(t, http.MethodGet, server.URL+"/retention-classes", nil) + defer func() { _ = listResp.Body.Close() }() + var rules []retentionengine.ClassRule + if err := json.NewDecoder(listResp.Body).Decode(&rules); err != nil { + t.Fatalf("liste dekodieren: %v", err) + } + if len(rules) != 1 || rules[0].RetentionClass != "klasse-api" || !rules[0].Active { + t.Fatalf("unerwartete liste: %+v", rules) + } +} + +// TestDeactivateHandler_RealHTTPRoundTrip: Deaktivierung wirkt real. +func TestDeactivateHandler_RealHTTPRoundTrip(t *testing.T) { + pool := requireTestPool(t) + ctx := context.Background() + if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-deakt", "1 year"); err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + Mount(mux, pool) + server := httptest.NewServer(mux) + defer server.Close() + + resp := authedRequest(t, http.MethodPost, server.URL+"/retention-classes/klasse-deakt/deactivate", nil) + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("deaktivieren: status = %d, want 200", resp.StatusCode) + } + + rules, err := retentionengine.ListClassRules(ctx, pool) + if err != nil { + t.Fatalf("listclassrules: %v", err) + } + if len(rules) != 1 || rules[0].Active { + t.Fatalf("erwartet deaktivierte klasse, habe %+v", rules) + } +} + +// TestPreviewHandler_MatchesPeriodicJobResult ist die geforderte +// Pflichtpruefung: Vorschauliste stimmt mit dem Ergebnis des +// periodischen Jobs ueberein - beide nutzen dieselbe Funktion, real +// per HTTP UND direkt verglichen. +func TestPreviewHandler_MatchesPeriodicJobResult(t *testing.T) { + pool := requireTestPool(t) + ctx := context.Background() + + if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-preview", "1 day"); err != nil { + t.Fatal(err) + } + var objID string + if err := pool.QueryRow(ctx, `INSERT INTO retention_objects (object_type, object_reference) VALUES ('dms_document', 'preview-doc') RETURNING id`).Scan(&objID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class) VALUES ($1, 'klasse-preview')`, objID); err != nil { + t.Fatal(err) + } + + mux := http.NewServeMux() + Mount(mux, pool) + server := httptest.NewServer(mux) + defer server.Close() + + resp := authedRequest(t, http.MethodGet, server.URL+"/retention-classes/preview", nil) + defer func() { _ = resp.Body.Close() }() + var httpResult []retentionengine.ExpiringObject + if err := json.NewDecoder(resp.Body).Decode(&httpResult); err != nil { + t.Fatalf("preview-antwort dekodieren: %v", err) + } + + directResult, err := retentionengine.ListExpiringObjects(ctx, pool, time.Now().UTC().AddDate(0, 0, 30)) + if err != nil { + t.Fatalf("listexpiringobjects direkt: %v", err) + } + + if len(httpResult) != len(directResult) || len(httpResult) != 1 { + t.Fatalf("http-vorschau (%d) und periodischer job (%d) stimmen nicht ueberein", len(httpResult), len(directResult)) + } + if httpResult[0].RetentionObjectID != directResult[0].RetentionObjectID { + t.Fatalf("http-vorschau und periodischer job liefern unterschiedliche objekte: %+v vs %+v", httpResult[0], directResult[0]) + } +} diff --git a/archive/internal/retentionengine/retentionengine.go b/archive/internal/retentionengine/retentionengine.go index a4de563..76e8a0a 100644 --- a/archive/internal/retentionengine/retentionengine.go +++ b/archive/internal/retentionengine/retentionengine.go @@ -33,11 +33,12 @@ func ConfigureClassRule(ctx context.Context, pool *pgxpool.Pool, retentionClass, // INTERVAL-Arithmetik (Akzeptanzkriterium 2: korrekt inklusive // Schaltjahr/Monatsende), keine eigene Kalenderrechnung in Go, die von // Postgres' späterer WHERE-Klausel im periodischen Job abweichen könnte. +// Nur AKTIVE Regeln werden verwendet (siehe DeactivateClassRule). func ComputeDueDate(ctx context.Context, pool *pgxpool.Pool, start time.Time, retentionClass string) (time.Time, error) { var due time.Time err := pool.QueryRow(ctx, ` SELECT $1::timestamptz + r.duration - FROM retention_class_rules r WHERE r.retention_class = $2 + FROM retention_class_rules r WHERE r.retention_class = $2 AND r.active `, start, retentionClass).Scan(&due) if err != nil { return time.Time{}, fmt.Errorf("retentionengine: stichtag berechnen: %w", err) @@ -45,6 +46,51 @@ func ComputeDueDate(ctx context.Context, pool *pgxpool.Pool, start time.Time, re return due, nil } +// DeactivateClassRule (RET-06): eine Aufbewahrungsklasse wird deaktiviert, +// OHNE ihre Historie (bereits erfolgte Zuordnungen/Berechnungen) zu +// verlieren — kein DELETE. Deaktivierte Klassen fließen nicht mehr in +// ComputeDueDate/ListExpiringObjects ein, ändern aber nichts an bereits +// getroffenen Berechnungen (Pflichtprüfung: Änderung wirkt nur auf +// künftige Berechnungen, nicht rückwirkend). +func DeactivateClassRule(ctx context.Context, pool *pgxpool.Pool, retentionClass string) error { + tag, err := pool.Exec(ctx, `UPDATE retention_class_rules SET active = false WHERE retention_class = $1`, retentionClass) + if err != nil { + return fmt.Errorf("retentionengine: klasse deaktivieren: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("retentionengine: unbekannte aufbewahrungsklasse %q", retentionClass) + } + return nil +} + +// ClassRule ist EINE konfigurierte Aufbewahrungsklasse mit Frist und +// Aktiv-Status. +type ClassRule struct { + RetentionClass string + Duration string + Active bool +} + +// ListClassRules liefert alle konfigurierten Aufbewahrungsklassen +// (aktiv und deaktiviert) — Grundlage für die Konfigurationsoberfläche. +func ListClassRules(ctx context.Context, pool *pgxpool.Pool) ([]ClassRule, error) { + rows, err := pool.Query(ctx, `SELECT retention_class, duration::text, active FROM retention_class_rules ORDER BY retention_class`) + if err != nil { + return nil, fmt.Errorf("retentionengine: aufbewahrungsklassen auflisten: %w", err) + } + defer rows.Close() + + var rules []ClassRule + for rows.Next() { + var r ClassRule + if err := rows.Scan(&r.RetentionClass, &r.Duration, &r.Active); err != nil { + return nil, fmt.Errorf("retentionengine: klassen-zeile lesen: %w", err) + } + rules = append(rules, r) + } + return rules, rows.Err() +} + // ExpiringObject ist EIN Objekt, dessen Aufbewahrungsfrist erreicht ist. type ExpiringObject struct { RetentionObjectID string @@ -75,7 +121,7 @@ func ListExpiringObjects(ctx context.Context, pool *pgxpool.Pool, asOf time.Time a.assigned_at + r.duration AS due_date FROM retention_objects o JOIN latest_assignment a ON a.retention_object_id = o.id - JOIN retention_class_rules r ON r.retention_class = a.retention_class + JOIN retention_class_rules r ON r.retention_class = a.retention_class AND r.active WHERE o.status = 'active' AND (a.assigned_at + r.duration) <= $1 ORDER BY due_date ASC `, asOf) diff --git a/archive/internal/retentionengine/retentionengine_test.go b/archive/internal/retentionengine/retentionengine_test.go index 193c8fe..e471825 100644 --- a/archive/internal/retentionengine/retentionengine_test.go +++ b/archive/internal/retentionengine/retentionengine_test.go @@ -37,7 +37,8 @@ func requireTestPool(t *testing.T) *pgxpool.Pool { retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS retention_class_rules ( - retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL + retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL, + active BOOLEAN NOT NULL DEFAULT true ); `); err != nil { t.Fatalf("schema: %v", err) @@ -105,6 +106,66 @@ func TestComputeDueDate_KnownReferenceDates(t *testing.T) { } } +// TestConfigureClassRule_ChangeAppliesOnlyToFutureCalculations ist +// Pruefung fuer RET-06: eine Fristaenderung wirkt sich nur auf +// KUENFTIGE Berechnungen aus, nicht rueckwirkend - real bewiesen, indem +// ein bereits berechneter Stichtag (in einer lokalen Variable, dem +// einzigen Ort, an dem ein "bereits berechnetes" Ergebnis ueberhaupt +// existiert - es gibt keine Tabelle, die rueckwirkend veraendert werden +// koennte) nach der Regelaenderung unveraendert bleibt, waehrend eine +// NEUE Berechnung fuer dieselbe Klasse die NEUE Frist verwendet. +func TestConfigureClassRule_ChangeAppliesOnlyToFutureCalculations(t *testing.T) { + pool := requireTestPool(t) + ctx := context.Background() + start := mustTime(t, "2006-01-02", "2026-01-01") + + if err := ConfigureClassRule(ctx, pool, "klasse-aenderung", "1 year"); err != nil { + t.Fatal(err) + } + before, err := ComputeDueDate(ctx, pool, start, "klasse-aenderung") + if err != nil { + t.Fatalf("erste berechnung: %v", err) + } + + if err := ConfigureClassRule(ctx, pool, "klasse-aenderung", "2 years"); err != nil { + t.Fatal(err) + } + + // Der bereits berechnete Wert (before) bleibt unveraendert - er ist + // eine lokale Kopie, es existiert keine Tabelle, die eine + // nachtraegliche "Umschreibung" ermoeglichen wuerde. + if !before.Equal(mustTime(t, "2006-01-02", "2027-01-01")) { + t.Fatalf("bereits berechneter stichtag veraendert: %v", before) + } + + after, err := ComputeDueDate(ctx, pool, start, "klasse-aenderung") + if err != nil { + t.Fatalf("zweite berechnung: %v", err) + } + if !after.Equal(mustTime(t, "2006-01-02", "2028-01-01")) { + t.Fatalf("neue berechnung uebernimmt neue frist nicht: %v", after) + } + if before.Equal(after) { + t.Fatal("neue frist haette eine andere berechnung liefern muessen") + } +} + +// TestDeactivateClassRule_ExcludesFromFutureCalculations. +func TestDeactivateClassRule_ExcludesFromFutureCalculations(t *testing.T) { + pool := requireTestPool(t) + ctx := context.Background() + + if err := ConfigureClassRule(ctx, pool, "klasse-deakt-eng", "1 year"); err != nil { + t.Fatal(err) + } + if err := DeactivateClassRule(ctx, pool, "klasse-deakt-eng"); err != nil { + t.Fatalf("deactivateclassrule: %v", err) + } + if _, err := ComputeDueDate(ctx, pool, time.Now(), "klasse-deakt-eng"); err == nil { + t.Fatal("erwartet fehler: deaktivierte klasse darf nicht mehr verwendet werden") + } +} + // TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError ist Pruefung 2. func TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError(t *testing.T) { pool := requireTestPool(t) diff --git a/archive/migrations/0005_retention_class_rules_active.down.sql b/archive/migrations/0005_retention_class_rules_active.down.sql new file mode 100644 index 0000000..f4dac19 --- /dev/null +++ b/archive/migrations/0005_retention_class_rules_active.down.sql @@ -0,0 +1 @@ +ALTER TABLE retention_class_rules DROP COLUMN IF EXISTS active; diff --git a/archive/migrations/0005_retention_class_rules_active.up.sql b/archive/migrations/0005_retention_class_rules_active.up.sql new file mode 100644 index 0000000..7505a1e --- /dev/null +++ b/archive/migrations/0005_retention_class_rules_active.up.sql @@ -0,0 +1,4 @@ +-- RET-06-API: Aufbewahrungsklassen lassen sich deaktivieren, ohne ihre +-- Historie (bereits erfolgte Zuordnungen/Berechnungen) zu verlieren - +-- kein DELETE, nur ein Sichtbarkeits-/Anwendbarkeits-Flag. +ALTER TABLE retention_class_rules ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT true; diff --git a/deploy/systemd/nexarch-archive-retention-api.service.tmpl b/deploy/systemd/nexarch-archive-retention-api.service.tmpl new file mode 100644 index 0000000..d50c1b5 --- /dev/null +++ b/deploy/systemd/nexarch-archive-retention-api.service.tmpl @@ -0,0 +1,14 @@ +[Unit] +Description=NEXARCH Archive - Aufbewahrungsfristen-Konfigurations-API (RET-06-API) +After=network.target postgresql.service + +[Service] +Type=simple +User=nexarch +EnvironmentFile=/etc/nexarch/archive-retention-api.env +ExecStart=__INSTALL_DIR__/bin/retention-api +Restart=on-failure +StandardOutput=journal + +[Install] +WantedBy=multi-user.target