From cc74970930f6fc6864403d41e2a36d6ac7567540 Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 21 Sep 2026 22:34:24 +0200 Subject: [PATCH 1/4] RET-13: legal-hold-abfrage-endpunkt GET /legal-hold in moduleadapter-api (legalholdapi), DMS-Register sendet tenant_slug, Vertragstest scripts/legalhold-contract-test.sh Co-Authored-By: Claude Sonnet 5 --- archive/cmd/moduleadapter-api/main.go | 12 +- archive/internal/legalholdapi/legalholdapi.go | 159 ++++++++++ .../legalholdapi/legalholdapi_test.go | 295 ++++++++++++++++++ .../retentionclient/contract_archive_test.go | 61 ++++ .../retentionclient/retentionclient.go | 7 +- .../retentionclient/retentionclient_test.go | 18 ++ scripts/legalhold-contract-test.sh | 89 ++++++ 7 files changed, 637 insertions(+), 4 deletions(-) create mode 100644 archive/internal/legalholdapi/legalholdapi.go create mode 100644 archive/internal/legalholdapi/legalholdapi_test.go create mode 100644 dms/internal/retentionclient/contract_archive_test.go create mode 100755 scripts/legalhold-contract-test.sh diff --git a/archive/cmd/moduleadapter-api/main.go b/archive/cmd/moduleadapter-api/main.go index 131d1206..51a8370e 100644 --- a/archive/cmd/moduleadapter-api/main.go +++ b/archive/cmd/moduleadapter-api/main.go @@ -1,7 +1,8 @@ // moduleadapter-api ist der Aufrufpunkt fuer RET-09: startet den bereits // fertigen archive/internal/moduleadapter.RegisterHandler (RET-05) als // eigenstaendigen HTTP-Dienst. REINES WIRING — keine Aenderung an -// moduleadapter.go/handler.go, keine neue Logik. +// moduleadapter.go/handler.go, keine neue Logik. RET-13 haengt die +// schreibgeschuetzte Sperrabfrage /legal-hold an denselben Dienst. package main import ( @@ -12,6 +13,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "gitea.perlbach24.de/scripte/nexarch/archive/internal/legalholdapi" "gitea.perlbach24.de/scripte/nexarch/archive/internal/moduleadapter" "gitea.perlbach24.de/scripte/nexarch/archive/internal/tenantregistryclient" ) @@ -42,6 +44,14 @@ func main() { mux := http.NewServeMux() mux.HandleFunc("/register", moduleadapter.RegisterHandler(pool, verifier)) + instanceTenant := os.Getenv("NEXARCH_MODULEADAPTER_TENANT_SLUG") + if instanceTenant == "" { + log.Print("moduleadapter-api: NEXARCH_MODULEADAPTER_TENANT_SLUG nicht gesetzt, /legal-hold antwortet 503") + } + mux.HandleFunc("/legal-hold", legalholdapi.Handler(pool, verifier, legalholdapi.Config{ + InstanceTenant: instanceTenant, + ServiceToken: os.Getenv("NEXARCH_MODULEADAPTER_SERVICE_TOKEN"), + })) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) log.Printf("moduleadapter-api: listening on %s", addr) diff --git a/archive/internal/legalholdapi/legalholdapi.go b/archive/internal/legalholdapi/legalholdapi.go new file mode 100644 index 00000000..81468be9 --- /dev/null +++ b/archive/internal/legalholdapi/legalholdapi.go @@ -0,0 +1,159 @@ +// Package legalholdapi ist RET-13: die schreibgeschützte HTTP-Abfrage der +// Aufbewahrungssperre (RET-03) für Module, die Objekte endgültig löschen. +package legalholdapi + +import ( + "context" + "crypto/subtle" + "encoding/json" + "log" + "net/http" + "strings" + "unicode/utf8" + + "github.com/jackc/pgx/v5/pgxpool" +) + +const ( + HeaderTenant = "X-Nexarch-Tenant" + HeaderServiceToken = "X-Service-Token" + + maxTenantLen = 128 + maxParamLen = 512 + maxReasonLen = 1000 +) + +// TenantVerifier ist die Tenant-Prüfung gegen die Core-Registry (RET-12). +type TenantVerifier interface { + VerifyTenant(ctx context.Context, tenantSlug string) (bool, error) +} + +// Config bindet die Instanz an genau einen Mandanten: Ihre Datenbank +// gehört diesem Mandanten (Modell C). ServiceToken ist optional; leer +// bedeutet, dass nur der Tenant-Kontext geprüft wird. +type Config struct { + InstanceTenant string + ServiceToken string +} + +type response struct { + OnHold bool `json:"on_hold"` + Reason string `json:"reason"` + Registered bool `json:"registered"` +} + +// Handler beantwortet GET /legal-hold?object_type=..&object_reference=.. +// mit 200 {"on_hold","reason","registered"}. Jeder unklare Fall ist ein +// Fehlerstatus, nie ein „nicht gesperrt“. +func Handler(pool *pgxpool.Pool, verifier TenantVerifier, cfg Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !validToken(cfg.InstanceTenant, maxTenantLen) { + log.Printf("legalholdapi: instanz-tenant nicht konfiguriert, anfrage abgelehnt") + http.Error(w, "sperrabfrage nicht konfiguriert", http.StatusServiceUnavailable) + return + } + if cfg.ServiceToken != "" && + subtle.ConstantTimeCompare([]byte(r.Header.Get(HeaderServiceToken)), []byte(cfg.ServiceToken)) != 1 { + log.Printf("legalholdapi: anfrage ohne gueltiges service-token abgelehnt") + http.Error(w, "nicht autorisiert", http.StatusUnauthorized) + return + } + + tenant := strings.TrimSpace(r.Header.Get(HeaderTenant)) + if !validToken(tenant, maxTenantLen) { + http.Error(w, "header "+HeaderTenant+" fehlt oder ist ungueltig", http.StatusBadRequest) + return + } + if tenant != cfg.InstanceTenant { + log.Printf("legalholdapi: anfrage abgelehnt - fremder tenant-kontext %q", tenant) + http.Error(w, "tenant-kontext passt nicht zu dieser instanz", http.StatusForbidden) + return + } + known, err := verifier.VerifyTenant(r.Context(), tenant) + if err != nil { + log.Printf("legalholdapi: tenant-verifikation fehlgeschlagen fuer tenant=%q: %v", tenant, err) + http.Error(w, "tenant-verifikation nicht moeglich", http.StatusServiceUnavailable) + return + } + if !known { + log.Printf("legalholdapi: anfrage abgelehnt - unbekannter tenant %q", tenant) + http.Error(w, "unbekannter tenant-kontext", http.StatusForbidden) + return + } + + q := r.URL.Query() + objectType, objectReference := q.Get("object_type"), q.Get("object_reference") + if !validParam(objectType) || !validParam(objectReference) { + http.Error(w, "object_type und object_reference sind pflichtparameter", http.StatusBadRequest) + return + } + + res, err := lookup(r.Context(), pool, objectType, objectReference) + if err != nil { + log.Printf("legalholdapi: datenbankfehler bei sperrabfrage: %v", err) + http.Error(w, "sperrstatus nicht pruefbar", http.StatusServiceUnavailable) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(res) + } +} + +func lookup(ctx context.Context, pool *pgxpool.Pool, objectType, objectReference string) (response, error) { + var res response + var reason string + err := pool.QueryRow(ctx, ` + SELECT o.id IS NOT NULL, h.id IS NOT NULL, COALESCE(h.reason, '') + FROM (SELECT 1) x + LEFT JOIN retention_objects o ON o.object_type = $1 AND o.object_reference = $2 + LEFT JOIN legal_holds h ON h.retention_object_id = o.id AND h.released_at IS NULL + `, objectType, objectReference).Scan(&res.Registered, &res.OnHold, &reason) + if err != nil { + return response{}, err + } + res.Reason = truncate(reason, maxReasonLen) + return res, nil +} + +func validToken(s string, max int) bool { + if s == "" || len(s) > max { + return false + } + for _, r := range s { + if r < 0x21 || r == 0x7f { + return false + } + } + return true +} + +func validParam(s string) bool { + if s == "" || len(s) > maxParamLen || !utf8.ValidString(s) { + return false + } + for _, r := range s { + if r < 0x20 || r == 0x7f { + return false + } + } + return true +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + cut := max + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] +} diff --git a/archive/internal/legalholdapi/legalholdapi_test.go b/archive/internal/legalholdapi/legalholdapi_test.go new file mode 100644 index 00000000..ff84ef9b --- /dev/null +++ b/archive/internal/legalholdapi/legalholdapi_test.go @@ -0,0 +1,295 @@ +package legalholdapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type fakeVerifier struct { + valid map[string]bool + err error +} + +func (f fakeVerifier) VerifyTenant(_ context.Context, slug string) (bool, error) { + if f.err != nil { + return false, f.err + } + return f.valid[slug], nil +} + +const schemaDDL = ` + 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 legal_holds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE, + reason TEXT NOT NULL, set_by TEXT NOT NULL, set_at TIMESTAMPTZ NOT NULL DEFAULT now(), + released_at TIMESTAMPTZ, released_by TEXT + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_legal_holds_active + ON legal_holds (retention_object_id) WHERE released_at IS NULL; +` + +func poolFor(t *testing.T, envName string) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv(envName) + if dsn == "" { + t.Skip(envName + " nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + if _, err := pool.Exec(ctx, schemaDDL); err != nil { + t.Fatalf("schema: %v", err) + } + clean := func() { + _, _ = pool.Exec(context.Background(), `TRUNCATE legal_holds, retention_objects CASCADE`) + } + clean() + t.Cleanup(clean) + return pool +} + +func seedObject(t *testing.T, pool *pgxpool.Pool, objectType, ref string) string { + t.Helper() + var id string + if err := pool.QueryRow(context.Background(), + `INSERT INTO retention_objects (object_type, object_reference) VALUES ($1, $2) RETURNING id`, + objectType, ref).Scan(&id); err != nil { + t.Fatalf("objekt anlegen: %v", err) + } + return id +} + +func seedHold(t *testing.T, pool *pgxpool.Pool, objectID, reason string, released bool) { + t.Helper() + q := `INSERT INTO legal_holds (retention_object_id, reason, set_by) VALUES ($1, $2, 'tester')` + if released { + q = `INSERT INTO legal_holds (retention_object_id, reason, set_by, released_at, released_by) VALUES ($1, $2, 'tester', now(), 'tester')` + } + if _, err := pool.Exec(context.Background(), q, objectID, reason); err != nil { + t.Fatalf("sperre anlegen: %v", err) + } +} + +func get(t *testing.T, h http.Handler, tenant, objectType, ref string, header http.Header) (int, string) { + t.Helper() + q := url.Values{} + if objectType != "" { + q.Set("object_type", objectType) + } + if ref != "" { + q.Set("object_reference", ref) + } + req := httptest.NewRequest(http.MethodGet, "/legal-hold?"+q.Encode(), nil) + if tenant != "" { + req.Header.Set(HeaderTenant, tenant) + } + for k, v := range header { + req.Header[k] = v + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Code, rec.Body.String() +} + +func okVerifier() fakeVerifier { return fakeVerifier{valid: map[string]bool{"mandant-a": true, "mandant-b": true}} } + +func decode(t *testing.T, body string) response { + t.Helper() + var r response + if err := json.Unmarshal([]byte(body), &r); err != nil { + t.Fatalf("antwort dekodieren: %v (%s)", err, body) + } + return r +} + +// Pruefung 1 / AK1: gesperrt, freigegeben, nie gesperrt, unbekannt. +func TestHandler_HoldStates(t *testing.T) { + pool := poolFor(t, "TEST_TENANT_DSN") + h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}) + + held := seedObject(t, pool, "document", "doc-held") + seedHold(t, pool, held, "Rechtsstreit Az. 12/26", false) + released := seedObject(t, pool, "document", "doc-released") + seedHold(t, pool, released, "alt", true) + seedObject(t, pool, "document", "doc-never") + + cases := []struct { + ref string + onHold bool + registered bool + reason string + }{ + {"doc-held", true, true, "Rechtsstreit Az. 12/26"}, + {"doc-released", false, true, ""}, + {"doc-never", false, true, ""}, + {"doc-unknown", false, false, ""}, + } + for _, c := range cases { + code, body := get(t, h, "mandant-a", "document", c.ref, nil) + if code != http.StatusOK { + t.Fatalf("%s: status = %d (%s), want 200", c.ref, code, body) + } + got := decode(t, body) + if got.OnHold != c.onHold || got.Registered != c.registered || got.Reason != c.reason { + t.Errorf("%s: %+v, want on_hold=%v registered=%v reason=%q", c.ref, got, c.onHold, c.registered, c.reason) + } + } +} + +// Sperre wirkt nur auf den passenden object_type. +func TestHandler_ObjectTypeIsPartOfIdentity(t *testing.T) { + pool := poolFor(t, "TEST_TENANT_DSN") + h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}) + id := seedObject(t, pool, "document", "gemeinsame-ref") + seedHold(t, pool, id, "grund", false) + seedObject(t, pool, "message", "gemeinsame-ref") + + _, body := get(t, h, "mandant-a", "message", "gemeinsame-ref", nil) + if got := decode(t, body); got.OnHold { + t.Errorf("message/gemeinsame-ref faelschlich gesperrt: %+v", got) + } +} + +// Pruefung 2 / AK2: nie "nicht gesperrt" bei Unklarheit. +func TestHandler_FailClosed(t *testing.T) { + pool := poolFor(t, "TEST_TENANT_DSN") + id := seedObject(t, pool, "document", "doc-held") + seedHold(t, pool, id, "grund", false) + + closed, err := pgxpool.New(context.Background(), os.Getenv("TEST_TENANT_DSN")) + if err != nil { + t.Fatal(err) + } + closed.Close() + + tok := func(v string) http.Header { return http.Header{HeaderServiceToken: {v}} } + cases := []struct { + name string + h http.Handler + tenant string + typ string + ref string + header http.Header + want int + }{ + {"kein tenant-header", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}), "", "document", "doc-held", nil, 400}, + {"ungueltiger tenant-header", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}), "mandant a\x7f", "document", "doc-held", nil, 400}, + {"fremder tenant", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}), "mandant-b", "document", "doc-held", nil, 403}, + {"unbekannter tenant im register", Handler(pool, fakeVerifier{valid: map[string]bool{}}, Config{InstanceTenant: "mandant-a"}), "mandant-a", "document", "doc-held", nil, 403}, + {"register nicht erreichbar", Handler(pool, fakeVerifier{err: errors.New("down")}, Config{InstanceTenant: "mandant-a"}), "mandant-a", "document", "doc-held", nil, 503}, + {"datenbank nicht erreichbar", Handler(closed, okVerifier(), Config{InstanceTenant: "mandant-a"}), "mandant-a", "document", "doc-held", nil, 503}, + {"instanz ohne tenant-konfiguration", Handler(pool, okVerifier(), Config{}), "mandant-a", "document", "doc-held", nil, 503}, + {"fehlender parameter", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}), "mandant-a", "document", "", nil, 400}, + {"zu langer parameter", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}), "mandant-a", "document", strings.Repeat("x", maxParamLen+1), nil, 400}, + {"service-token fehlt", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", ServiceToken: "geheim"}), "mandant-a", "document", "doc-held", nil, 401}, + {"service-token falsch", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", ServiceToken: "geheim"}), "mandant-a", "document", "doc-held", tok("falsch"), 401}, + } + for _, c := range cases { + code, body := get(t, c.h, c.tenant, c.typ, c.ref, c.header) + if code != c.want { + t.Errorf("%s: status = %d, want %d", c.name, code, c.want) + } + if strings.Contains(body, `"on_hold"`) { + t.Errorf("%s: Fehlerantwort enthaelt eine Sperrangabe: %s", c.name, body) + } + } + + h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", ServiceToken: "geheim"}) + code, body := get(t, h, "mandant-a", "document", "doc-held", tok("geheim")) + if code != http.StatusOK || !decode(t, body).OnHold { + t.Errorf("richtiges service-token: status = %d, body = %s", code, body) + } +} + +func TestHandler_OnlyGET(t *testing.T) { + pool := poolFor(t, "TEST_TENANT_DSN") + h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}) + for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} { + req := httptest.NewRequest(m, "/legal-hold?object_type=document&object_reference=x", nil) + req.Header.Set(HeaderTenant, "mandant-a") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("%s: status = %d, want 405", m, rec.Code) + } + } +} + +// AK3: die Abfrage aendert keine Sperre und keinen Objektstatus. +func TestHandler_IsReadOnly(t *testing.T) { + pool := poolFor(t, "TEST_TENANT_DSN") + h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}) + id := seedObject(t, pool, "document", "doc-held") + seedHold(t, pool, id, "grund", false) + + snapshot := func() string { + var s string + if err := pool.QueryRow(context.Background(), ` + SELECT (SELECT count(*) FROM retention_objects)::text || '|' || + (SELECT count(*) FROM legal_holds)::text || '|' || + (SELECT count(*) FROM legal_holds WHERE released_at IS NULL)::text || '|' || + COALESCE((SELECT string_agg(status, ',') FROM retention_objects), '') + `).Scan(&s); err != nil { + t.Fatal(err) + } + return s + } + before := snapshot() + for i := 0; i < 5; i++ { + get(t, h, "mandant-a", "document", "doc-held", nil) + get(t, h, "mandant-a", "document", "doc-unknown", nil) + } + if after := snapshot(); after != before { + t.Errorf("datenbankzustand hat sich geaendert: %q -> %q", before, after) + } +} + +// Mandantentrennung: zwei Instanzen mit je eigener Datenbank. +func TestHandler_TenantIsolationWithTwoDatabases(t *testing.T) { + poolA := poolFor(t, "TEST_TENANT_DSN") + poolB := poolFor(t, "TEST_TENANT_DSN_B") + hA := Handler(poolA, okVerifier(), Config{InstanceTenant: "mandant-a"}) + hB := Handler(poolB, okVerifier(), Config{InstanceTenant: "mandant-b"}) + + idA := seedObject(t, poolA, "document", "doc-1") + seedHold(t, poolA, idA, "nur in A", false) + seedObject(t, poolB, "document", "doc-1") + + if _, body := get(t, hA, "mandant-a", "document", "doc-1", nil); !decode(t, body).OnHold { + t.Error("instanz A: doc-1 muss gesperrt sein") + } + if _, body := get(t, hB, "mandant-b", "document", "doc-1", nil); decode(t, body).OnHold { + t.Error("instanz B: doc-1 darf nicht gesperrt sein (Sperre liegt in Datenbank A)") + } + if code, _ := get(t, hB, "mandant-a", "document", "doc-1", nil); code != http.StatusForbidden { + t.Errorf("instanz B mit tenant-header von A: status = %d, want 403", code) + } +} + +func TestTruncate_KeepsValidUTF8(t *testing.T) { + in := strings.Repeat("ä", 700) + out := truncate(in, maxReasonLen) + if len(out) > maxReasonLen || strings.ContainsRune(out, '�') { + t.Errorf("truncate: laenge %d, ungueltige zeichen enthalten", len(out)) + } +} diff --git a/dms/internal/retentionclient/contract_archive_test.go b/dms/internal/retentionclient/contract_archive_test.go new file mode 100644 index 00000000..d6fccb36 --- /dev/null +++ b/dms/internal/retentionclient/contract_archive_test.go @@ -0,0 +1,61 @@ +package retentionclient + +import ( + "context" + "os" + "strings" + "testing" +) + +// Vertragstest gegen den ECHTEN Archive-Dienst (moduleadapter-api, RET-05/12/13). +// Voraussetzung und Ablauf: scripts/legalhold-contract-test.sh. +func archiveClient(t *testing.T) (*Client, string) { + t.Helper() + base := os.Getenv("TEST_ARCHIVE_URL") + tenant := os.Getenv("TEST_ARCHIVE_TENANT") + if base == "" || tenant == "" { + t.Skip("TEST_ARCHIVE_URL/TEST_ARCHIVE_TENANT nicht gesetzt, Vertragstest uebersprungen") + } + return New(base, tenant), tenant +} + +func TestArchiveContract_RegisterAndLegalHold(t *testing.T) { + c, _ := archiveClient(t) + ctx := context.Background() + + if _, err := c.Register(ctx, "dms", "dms_document", "klasse-vertrag", "http://dms.example.test/cb"); err != nil { + t.Fatalf("register gegen echtes Archive: %v", err) + } + + cases := []struct { + ref string + onHold bool + reason string + }{ + {"contract-held", true, "Vertragstest Sperre"}, + {"contract-released", false, ""}, + {"contract-free", false, ""}, + {"contract-unknown", false, ""}, + } + for _, tc := range cases { + got, err := c.CheckLegalHold(ctx, "document", tc.ref) + if err != nil { + t.Fatalf("%s: %v", tc.ref, err) + } + if got.OnHold != tc.onHold || got.Reason != tc.reason { + t.Errorf("%s: %+v, want on_hold=%v reason=%q", tc.ref, got, tc.onHold, tc.reason) + } + } +} + +func TestArchiveContract_ForeignTenantIsAnError(t *testing.T) { + c, tenant := archiveClient(t) + foreign := New(c.BaseURL, tenant+"-fremd") + _, err := foreign.CheckLegalHold(context.Background(), "document", "contract-held") + if err == nil { + t.Fatal("fremder Tenant bekam eine Sperrauskunft") + } + if !strings.Contains(err.Error(), "403") { + t.Errorf("erwartet Status 403, habe %v", err) + } +} diff --git a/dms/internal/retentionclient/retentionclient.go b/dms/internal/retentionclient/retentionclient.go index c6691450..c310bc43 100644 --- a/dms/internal/retentionclient/retentionclient.go +++ b/dms/internal/retentionclient/retentionclient.go @@ -40,6 +40,7 @@ func (c *Client) NewRequest(ctx context.Context, tc modulecall.Context, method, } type registerRequest struct { + TenantSlug string `json:"tenant_slug"` ModuleName string `json:"module_name"` ObjectType string `json:"object_type"` RetentionClass string `json:"retention_class"` @@ -61,7 +62,7 @@ type Registration struct { // (Akzeptanzkriterium 3). func (c *Client) Register(ctx context.Context, moduleName, objectType, retentionClass, callbackURL string) (Registration, error) { body, err := json.Marshal(registerRequest{ - ModuleName: moduleName, ObjectType: objectType, RetentionClass: retentionClass, CallbackURL: callbackURL, + TenantSlug: c.Tenant, ModuleName: moduleName, ObjectType: objectType, RetentionClass: retentionClass, CallbackURL: callbackURL, }) if err != nil { return Registration{}, fmt.Errorf("retentionclient: request kodieren: %w", err) @@ -95,8 +96,8 @@ func (c *Client) httpClient() *http.Client { return http.DefaultClient } -// LegalHoldStatus ist die angenommene Antwort der Archive-Sperrabfrage -// (Vertrag siehe INT-11-Pruefprotokoll; die Gegenseite existiert noch nicht). +// LegalHoldStatus ist die Antwort der Archive-Sperrabfrage (Archive RET-13, +// Vertragstest gegen den echten Handler: contract_archive_test.go). type LegalHoldStatus struct { OnHold bool `json:"on_hold"` Reason string `json:"reason"` diff --git a/dms/internal/retentionclient/retentionclient_test.go b/dms/internal/retentionclient/retentionclient_test.go index e29f4694..08295335 100644 --- a/dms/internal/retentionclient/retentionclient_test.go +++ b/dms/internal/retentionclient/retentionclient_test.go @@ -3,10 +3,12 @@ package retentionclient import ( "context" "errors" + "io" "net/http" "net/http/httptest" "os" "regexp" + "strings" "testing" "gitea.perlbach24.de/scripte/nexarch/dms/internal/modulecall" @@ -53,3 +55,19 @@ func TestOnlyOneRequestConstructionSite(t *testing.T) { t.Fatalf("%d stellen bauen requests, erwartet genau 1 (NewRequest mit Tenant-Kontext)", n) } } + +func TestRegisterBodyCarriesTenantSlug(t *testing.T) { + var body string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + body = string(b) + _, _ = w.Write([]byte(`{"ID":"1"}`)) + })) + defer srv.Close() + if _, err := New(srv.URL, "acme").Register(context.Background(), "dms", "dms_document", "k", "http://cb"); err != nil { + t.Fatal(err) + } + if !strings.Contains(body, `"tenant_slug":"acme"`) { + t.Fatalf("register-body ohne tenant_slug: %s", body) + } +} diff --git a/scripts/legalhold-contract-test.sh b/scripts/legalhold-contract-test.sh new file mode 100755 index 00000000..79ff4ff6 --- /dev/null +++ b/scripts/legalhold-contract-test.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# RET-13 Vertragstest: DMS-retentionclient gegen den echten Archive-Dienst +# (archive/cmd/moduleadapter-api) mit echter Datenbank und den echten Migrationen. +# Voraussetzung: TEST_TENANT_DSN_B zeigt auf eine Wegwerf-Datenbank im Entwicklungs- +# Cluster (Port 5433). Das Schema public dieser Datenbank wird verworfen. +# Nur Loopback, Registry-Stub statt Core. +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +: "${TEST_TENANT_DSN_B:?TEST_TENANT_DSN_B nicht gesetzt}" +if [[ "$TEST_TENANT_DSN_B" != *:5433/* ]]; then + echo "abgebrochen: die Datenbank muss im Wegwerf-Cluster auf Port 5433 liegen" >&2 + exit 2 +fi + +tenant=acme-contract +workdir=$(mktemp -d) +pids=() +cleanup() { + for p in "${pids[@]:-}"; do kill "$p" 2>/dev/null || true; done + rm -rf "$workdir" +} +trap cleanup EXIT + +sql() { psql "$TEST_TENANT_DSN_B" -v ON_ERROR_STOP=1 -q "$@"; } +freeport() { python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1])'; } + +sql -c "DROP SCHEMA public CASCADE" -c "CREATE SCHEMA public" -c "CREATE EXTENSION IF NOT EXISTS pgcrypto" +for f in $(ls "$root"/archive/migrations/*.up.sql | sort); do sql -f "$f" >/dev/null; done +sql <<'SQL' >/dev/null +INSERT INTO retention_objects (object_type, object_reference) VALUES + ('document','contract-held'), ('document','contract-released'), ('document','contract-free'); +INSERT INTO legal_holds (retention_object_id, reason, set_by) + SELECT id, 'Vertragstest Sperre', 'tester' FROM retention_objects WHERE object_reference = 'contract-held'; +INSERT INTO legal_holds (retention_object_id, reason, set_by, released_at, released_by) + SELECT id, 'alt', 'tester', now(), 'tester' FROM retention_objects WHERE object_reference = 'contract-released'; +SQL + +rport=$(freeport) +cat > "$workdir/registry.py" < "$workdir/archive.log" 2>&1 & pids+=($!) + +for _ in $(seq 1 50); do + curl -fsS "http://127.0.0.1:$aport/healthz" >/dev/null 2>&1 && break + sleep 0.2 +done +curl -fsS "http://127.0.0.1:$aport/healthz" >/dev/null || { cat "$workdir/archive.log"; echo "archive-dienst startet nicht" >&2; exit 1; } + +status() { curl -s -o "$workdir/body" -w '%{http_code}' "$@"; } +fail=0 +check() { # name expected actual + if [[ "$2" == "$3" ]]; then echo "PASS $1 ($3)"; else echo "FAIL $1: erwartet $2, erhalten $3"; fail=1; fi +} +url="http://127.0.0.1:$aport/legal-hold?object_type=document&object_reference=contract-held" +check "ohne tenant-header" 400 "$(status "$url")" +check "fremder tenant-header" 403 "$(status -H "X-Nexarch-Tenant: anderer-mandant" "$url")" +if grep -q on_hold "$workdir/body"; then echo "FAIL Fehlerantwort enthaelt on_hold"; fail=1; fi +check "richtiger tenant" 200 "$(status -H "X-Nexarch-Tenant: $tenant" "$url")" +grep -q '"on_hold":true' "$workdir/body" || { echo "FAIL on_hold:true fehlt"; fail=1; } +check "POST ist nicht erlaubt" 405 "$(status -X POST -H "X-Nexarch-Tenant: $tenant" "$url")" + +(cd "$root/dms" && TEST_ARCHIVE_URL="http://127.0.0.1:$aport" TEST_ARCHIVE_TENANT="$tenant" \ + go test -count=1 -v -run 'ArchiveContract' ./internal/retentionclient/) || fail=1 + +kill "${pids[0]}" 2>/dev/null || true +wait "${pids[0]}" 2>/dev/null || true +sleep 0.3 +check "tenant-register nicht erreichbar" 503 "$(status -H "X-Nexarch-Tenant: $tenant" "$url")" +if grep -q on_hold "$workdir/body"; then echo "FAIL Registry-Ausfall liefert Sperrangabe"; fail=1; fi + +if [[ $fail -ne 0 ]]; then echo "VERTRAGSTEST FEHLGESCHLAGEN"; tail -20 "$workdir/archive.log"; exit 1; fi +echo "VERTRAGSTEST BESTANDEN" From 65303d6ae605477e89c8a3b398ba8a98b52dd384 Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 21 Sep 2026 22:35:47 +0200 Subject: [PATCH 2/4] RET-13: gofmt --- archive/internal/legalholdapi/legalholdapi_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/archive/internal/legalholdapi/legalholdapi_test.go b/archive/internal/legalholdapi/legalholdapi_test.go index ff84ef9b..f74fa05d 100644 --- a/archive/internal/legalholdapi/legalholdapi_test.go +++ b/archive/internal/legalholdapi/legalholdapi_test.go @@ -111,7 +111,9 @@ func get(t *testing.T, h http.Handler, tenant, objectType, ref string, header ht return rec.Code, rec.Body.String() } -func okVerifier() fakeVerifier { return fakeVerifier{valid: map[string]bool{"mandant-a": true, "mandant-b": true}} } +func okVerifier() fakeVerifier { + return fakeVerifier{valid: map[string]bool{"mandant-a": true, "mandant-b": true}} +} func decode(t *testing.T, body string) response { t.Helper() From d1ecb6d1727b749f100115acf8d5f1484834c5ca Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 21 Sep 2026 22:37:23 +0200 Subject: [PATCH 3/4] RET-13: Vertragstest mit eigener temporaerer Datenbank --- scripts/legalhold-contract-test.sh | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/scripts/legalhold-contract-test.sh b/scripts/legalhold-contract-test.sh index 79ff4ff6..a3da70b0 100755 --- a/scripts/legalhold-contract-test.sh +++ b/scripts/legalhold-contract-test.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # RET-13 Vertragstest: DMS-retentionclient gegen den echten Archive-Dienst # (archive/cmd/moduleadapter-api) mit echter Datenbank und den echten Migrationen. -# Voraussetzung: TEST_TENANT_DSN_B zeigt auf eine Wegwerf-Datenbank im Entwicklungs- -# Cluster (Port 5433). Das Schema public dieser Datenbank wird verworfen. +# Voraussetzung: TEST_TENANT_DSN_B zeigt auf den Entwicklungs-Cluster (Port 5433) und +# darf Datenbanken anlegen. Das Skript legt eine eigene temporaere Datenbank an und +# entfernt sie wieder; andere Datenbanken bleiben unberuehrt. # Nur Loopback, Registry-Stub statt Core. set -euo pipefail @@ -16,16 +17,28 @@ fi tenant=acme-contract workdir=$(mktemp -d) pids=() +dbname="lh_contract_$$" +withdb() { python3 - "$TEST_TENANT_DSN_B" "$1" <<'PY' +import sys +from urllib.parse import urlsplit, urlunsplit +u = urlsplit(sys.argv[1]) +print(urlunsplit((u.scheme, u.netloc, "/" + sys.argv[2], u.query, u.fragment))) +PY +} +admin_dsn=$(withdb postgres) +test_dsn=$(withdb "$dbname") cleanup() { for p in "${pids[@]:-}"; do kill "$p" 2>/dev/null || true; done + psql "$admin_dsn" -q -c "DROP DATABASE IF EXISTS $dbname WITH (FORCE)" >/dev/null 2>&1 || true rm -rf "$workdir" } trap cleanup EXIT -sql() { psql "$TEST_TENANT_DSN_B" -v ON_ERROR_STOP=1 -q "$@"; } +psql "$admin_dsn" -v ON_ERROR_STOP=1 -q -c "CREATE DATABASE $dbname" >/dev/null +sql() { psql "$test_dsn" -v ON_ERROR_STOP=1 -q "$@"; } freeport() { python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1])'; } -sql -c "DROP SCHEMA public CASCADE" -c "CREATE SCHEMA public" -c "CREATE EXTENSION IF NOT EXISTS pgcrypto" +sql -c "CREATE EXTENSION IF NOT EXISTS pgcrypto" for f in $(ls "$root"/archive/migrations/*.up.sql | sort); do sql -f "$f" >/dev/null; done sql <<'SQL' >/dev/null INSERT INTO retention_objects (object_type, object_reference) VALUES @@ -51,7 +64,7 @@ python3 "$workdir/registry.py" & pids+=($!) (cd "$root/archive" && go build -o "$workdir/moduleadapter-api" ./cmd/moduleadapter-api) aport=$(freeport) -NEXARCH_MODULEADAPTER_TENANT_DSN="$TEST_TENANT_DSN_B" \ +NEXARCH_MODULEADAPTER_TENANT_DSN="$test_dsn" \ NEXARCH_MODULEADAPTER_API_LISTEN_ADDR="127.0.0.1:$aport" \ NEXARCH_TENANT_REGISTRY_URL="http://127.0.0.1:$rport" \ NEXARCH_MODULEADAPTER_TENANT_SLUG="$tenant" \ From 05f804705451e4b563c8461f8621e70cd16e717b Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 21 Sep 2026 22:39:43 +0200 Subject: [PATCH 4/4] RET-13: Pruefprotokoll Co-Authored-By: Claude Sonnet 5 --- docs/RET-13-PRUEFPROTOKOLL.md | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/RET-13-PRUEFPROTOKOLL.md diff --git a/docs/RET-13-PRUEFPROTOKOLL.md b/docs/RET-13-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..c2a1e9a3 --- /dev/null +++ b/docs/RET-13-PRUEFPROTOKOLL.md @@ -0,0 +1,41 @@ +# RET-13 – Prüfprotokoll: Legal-Hold-Abfrage-Endpunkt + +Ticket: `archive-kanban/tickets/RET-13.md`. Branch `feature/ret-13-legal-hold-abfrage-endpunkt` (Basis: Archive-Portierung, PR #105). + +## Umsetzung + +- `archive/internal/legalholdapi`: `GET /legal-hold?object_type=&object_reference=`, Antwort `200 {"on_hold","reason","registered"}`. Die ersten beiden Felder sind der vom DMS angenommene Vertrag (INT-11), `registered` ist eine Zusatzangabe (das DMS-JSON ignoriert sie). +- Eingehängt in `archive/cmd/moduleadapter-api` (derselbe Dienst und Port 8095 wie `/register`, dieselbe Basis-URL wie der DMS-`retentionclient`). Kein neuer Port, kein neuer Routenpräfix, `check-port-defaults.sh` und `check-route-prefixes.sh` sind grün. +- Reihenfolge der Prüfungen, jede fail-closed: Methode (405) → Instanz konfiguriert (503) → optionales Service-Token (401) → `X-Nexarch-Tenant` gültig (400) → gleich dem Instanz-Tenant (403) → Tenant im Core-Register bekannt (403, Register nicht erreichbar 503, gleiches Muster wie RET-12) → Parameter gültig (400) → Datenbank (Fehler 503). Fehlerantworten enthalten nie eine Sperrangabe. +- Mandanten-DB-Routing: Wie bei allen Archive-Diensten gehört die Datenbank der Instanz genau einem Mandanten (`NEXARCH_MODULEADAPTER_TENANT_DSN`). Neu ist `NEXARCH_MODULEADAPTER_TENANT_SLUG`: der Mandant, den diese Instanz bedient. Ein anderer Tenant-Header wird abgelehnt. Ohne die Variable antwortet die Route 503. +- `NEXARCH_MODULEADAPTER_SERVICE_TOKEN` (optional): Ist es gesetzt, verlangt Archive `X-Service-Token` (konstante Vergleichszeit). Das ist der Header, den Archive auch gegenüber der Core-Registry nutzt. +- Rein lesend: eine Abfrage `SELECT` mit `LEFT JOIN` auf `retention_objects` und die aktive Zeile in `legal_holds`. Ein unbekanntes Objekt liefert `200`, `on_hold:false`, `registered:false`. + +## Gefundener Fehler (behoben) + +Der DMS-`retentionclient.Register` sendete kein `tenant_slug` im Body. Der Archive-`RegisterHandler` verlangt es seit RET-12 als Pflichtfeld und antwortet sonst `400`. Die Registrierung des DMS gegen ein echtes Archive schlug damit im Gesamtprojekt fehl. Behoben in `dms/internal/retentionclient` (`tenant_slug` aus dem Client-Tenant), Test `TestRegisterBodyCarriesTenantSlug`. Der Fehler fiel im Vertragstest auf. + +## Prüfungen (Entwicklungshost 204, frischer Klon des gepushten Branches, Wegwerf-Cluster `17/coredev`) + +| Prüfung | Ergebnis | +|---|---| +| `gofmt -l`, `go vet ./...` (Modul `archive`) | sauber | +| `internal/legalholdapi` (echte Datenbank), 7 Tests | PASS: gesperrt/freigegeben/nie gesperrt/unbekannt; Objekttyp gehört zur Identität; 11 Fehlerfälle (kein/ungültiger/fremder Tenant, Tenant unbekannt im Register, Register nicht erreichbar, Datenbank geschlossen, Instanz ohne Tenant-Konfiguration, fehlender/zu langer Parameter, Service-Token fehlt/falsch) liefern 400/401/403/503 und nie eine Sperrangabe; nur GET; Datenbankzustand unverändert; zwei Instanzen mit zwei Datenbanken getrennt | +| `go test -p 1 ./...` (Modul `archive`) | alle Pakete ok | +| Vertragstest `scripts/legalhold-contract-test.sh` | bestanden (siehe unten) | +| `retentionclient`, `retentiondestroy`, `legalhold`, `trash`, `cmd/...` (Modul `dms`) | ok | +| Port- und Routenskripte | ok | + +### Vertragstest + +Das Skript legt eine eigene temporäre Datenbank im Wegwerf-Cluster an (und entfernt sie wieder), wendet alle 15 Archive-Migrationen an, startet den echten `moduleadapter-api` (Registry als Loopback-Stub statt Core) und lässt den echten DMS-`retentionclient` dagegen laufen: `Register` (200), `CheckLegalHold` für gesperrt (`true` samt Grund), freigegeben, nie gesperrt und unbekannt (jeweils `false`), fremder Tenant (Fehler mit Status 403). Dazu Prüfungen per `curl`: ohne Tenant-Header 400, fremder Tenant 403, POST 405, Register gestoppt 503 (ohne Sperrangabe). Ergebnis: `VERTRAGSTEST BESTANDEN`. + +Lösung für zwei getrennte Go-Module (`dms`, `archive`): `internal`-Pakete sind modulübergreifend nicht importierbar, deshalb läuft der Test als Skript mit zwei Prozessen über HTTP. Das entspricht dem Betrieb. Der DMS-Test (`contract_archive_test.go`) wird ohne `TEST_ARCHIVE_URL` übersprungen. + +## Offene Punkte + +- **Service-Token:** Der DMS-`retentionclient` sendet noch kein `X-Service-Token`. Ist es in Archive gesetzt, antwortet Archive 401 und das DMS löscht nie (fail closed). Bis Core die Dienst-zu-Dienst-Authentifizierung ausliefert, gilt Tenant-Bindung plus Registerprüfung wie bei RET-05/12. +- **Unbekanntes Objekt:** Archive antwortet `on_hold:false`, `registered:false`. Ob das DMS `registered:false` strenger behandeln soll (nicht löschen), ist eine DMS-Entscheidung. +- **Betrieb:** `NEXARCH_MODULEADAPTER_TENANT_SLUG` muss in `/etc/nexarch/archive-moduleadapter-api.env` gesetzt werden, sonst antwortet `/legal-hold` 503. Die Datei liegt nicht im Repository. +- **Nicht umgesetzt (Muster im Archive fehlt):** Ratenbegrenzung (liegt im Core-Gateway) und Audit-Eintrag für Lesezugriffe (nur Log bei Ablehnungen). +- Die Tests der neuen Pakete legen ihre Tabellen selbst an (Handschema) und laufen im gemeinsamen Wegwerf-Cluster seriell (`-p 1`).