From 012c27552926fab8b8e58da499410be4cd9f0e06 Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 27 Sep 2026 07:56:20 +0000 Subject: [PATCH] =?UTF-8?q?GOV-03:=20keine=20training=20auf=20kundendaten?= =?UTF-8?q?=20=E2=80=93=20datenresidenz-kontrolle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- ai/docs/GOV-03-PRUEFPROTOKOLL.md | 102 ++++++++++++ ai/internal/notraining/notraining_test.go | 80 ++++++++++ ai/internal/residency/residency.go | 140 ++++++++++++++++ ai/internal/residency/residency_test.go | 131 +++++++++++++++ ai/internal/residencyapi/residencyapi.go | 133 ++++++++++++++++ ai/internal/residencyapi/residencyapi_test.go | 150 ++++++++++++++++++ .../0006_tenant_data_residency.down.sql | 1 + .../0006_tenant_data_residency.up.sql | 8 + 8 files changed, 745 insertions(+) create mode 100644 ai/docs/GOV-03-PRUEFPROTOKOLL.md create mode 100644 ai/internal/notraining/notraining_test.go create mode 100644 ai/internal/residency/residency.go create mode 100644 ai/internal/residency/residency_test.go create mode 100644 ai/internal/residencyapi/residencyapi.go create mode 100644 ai/internal/residencyapi/residencyapi_test.go create mode 100644 ai/migrations/0006_tenant_data_residency.down.sql create mode 100644 ai/migrations/0006_tenant_data_residency.up.sql diff --git a/ai/docs/GOV-03-PRUEFPROTOKOLL.md b/ai/docs/GOV-03-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..bdef4fd8 --- /dev/null +++ b/ai/docs/GOV-03-PRUEFPROTOKOLL.md @@ -0,0 +1,102 @@ +# GOV-03 – Keine Training auf Kundendaten – Datenresidenz-Kontrolle: Prüfprotokoll + +Datum: 2026-09-27. Host: Entwicklungshost 192.168.1.204, eigener Wegwerf-Checkout +(`/root/gov03-work`), Wegwerf-Cluster über `TEST_TENANT_DSN` (eigene Rolle/DB +`gov03_test`, ohne Superuser-Rechte, ausschließlich für diesen Testlauf angelegt). + +## Ausgangslage + +`ENG-02` (Ollama-Adapter, lokal Standard) und `ENG-03` (externer Adapter + +`TenantGate`/`GuardedProvider`) sind bereits umgesetzt. ENG-03 dokumentiert +GOV-03 ausdrücklich als spätere Kachel und liefert die Freigabeliste bisher +nur als Umgebungsvariable (`NEXARCH_AI_OPENAI_ENABLED_TENANTS`) — global +gesetzt, nicht je Tenant nachvollziehbar einsehbar, keine Freigabe-Historie. + +## Umsetzung + +- Neues Paket `ai/internal/residency`: `Store` (Postgres, Migration + `0006_tenant_data_residency`) verwaltet je Tenant `Mode` (`local` / + `external_approved`) mit Freigabe-Nachweis (`approved_by`, `approved_at`). + `Get` liest live aus der DB; ohne Eintrag gilt `local` als Standard (AK1). + `Approve` verlangt einen nicht-leeren Freigeber und ist der einzige Weg auf + `external_approved` (AK2). `Revoke` setzt zurück, behält aber den + Freigabe-Nachweis für die Nachvollziehbarkeit. +- `residency.Gate` implementiert das bestehende `openai.TenantGate`-Interface + aus ENG-03 gegen den DB-Stand — `GuardedProvider` selbst wurde nicht + verändert, die Garantie "kein Aufruf ohne Freigabe" bleibt strukturell wie + in ENG-03 erzwungen, nur die Freigabequelle ist jetzt je Tenant konfigurier- + und einsehbar statt eine globale Env-Liste. +- Neues Paket `ai/internal/residencyapi`: HTTP-Bindung (`GET + /tenants/{tenant}/residency`, `POST .../approve`, `POST .../revoke`), + Service-Token-Pflicht wie `summaryapi`/`semanticsearchapi`, fail closed. + Macht AK1 ("einsehbar konfiguriert") über eine echte, testbare Schnittstelle + nachweisbar statt nur über den internen Store. +- Neues Paket `ai/internal/notraining`: rein testender Nachweis für AK3 — + (a) `provider.Provider` hat per Reflection ausschließlich `Chat`/`Complete`/ + `Embed`, keine Trainings-/Fine-Tuning-Methode; (b) die beiden Adapter- + Quelltexte (`ollama.go`, `openai.go`, `activation.go`) enthalten keinen + Trainings-/Fine-Tuning-Endpunkt oder -Begriff. + +## Akzeptanzkriterien + +| # | Kriterium | Ergebnis | +|---|---|---| +| 1 | Datenresidenz ist je Tenant einsehbar konfiguriert | **Erfüllt.** `residency.Store.Get` (live, kein Zwischenspeicher) + `GET /tenants/{tenant}/residency` über echten HTTP-Server. `TestGet_WithoutEntry_DefaultsToLocal`, `TestGet_DefaultsToLocal_RealHTTP`. | +| 2 | Externe Verarbeitung erfordert ausdrückliche Freigabe | **Erfüllt.** `Approve` verlangt `approvedBy`, ohne DB-Eintrag/Freigabe bleibt `Mode = local`. `TestApprove_WithoutApprovedBy_Fails`, `TestApprove_RealHTTP_WithoutApprovedBy_Rejected`, `TestGate_BlocksWithoutApproval`. | +| 3 | Kein Kundendokument fließt in ein Training ein | **Erfüllt.** Strukturell: `provider.Provider`-Interface kennt keine Trainingsmethode (einzige Wege zu einem Anbieter sind `Chat`/`Complete`/`Embed`). Technisch geprüft: `TestProviderInterface_HasNoTrainingMethod`, `TestProviderAdapters_ContainNoTrainingEndpoint`. Vertraglich: Ollama läuft On-Premise (kein Datenabfluss an einen Trainingsprozess eines Dritten möglich, da kein externer Betreiber beteiligt ist); für den externen Adapter gilt die Anbieter-API-Nutzungsbedingung "keine Trainingsnutzung von API-Anfragen ohne gesonderten Opt-in" als vertragliche Grundlage — technisch zusätzlich dadurch abgesichert, dass der externe Adapter ohnehin nur mit `GuardedProvider`+Freigabe erreichbar ist (AK2). | + +## Prüfungen vor der Abnahme + +| # | Prüfung | Zielwert | Ergebnis | +|---|---|---|---| +| 1 | Konfiguration lokal/extern im Test nachvollzogen | nachvollziehbar, dokumentiert | **Bestanden.** Default `local` ohne Konfiguration, Umschalten auf `external_approved` per `Approve`/`POST .../approve`, sichtbar in anschließendem `Get`/`GET`, inkl. Freigeber und Zeitpunkt. Siehe Tests oben. | +| 2 | Fehlende Freigabe verhindert externe Verarbeitung im Test | verhindert | **Bestanden.** `TestGate_BlocksWithoutApproval` (Gate lehnt ohne DB-Eintrag ab, erlaubt erst nach `Approve`), `TestApprove_RealHTTP_WithoutApprovedBy_Rejected` (HTTP 400, Tenant bleibt `local`). | +| 3 | Trainingsausschluss vertraglich und technisch geprüft | geprüft, dokumentiert | **Bestanden.** Technisch: `TestProviderInterface_HasNoTrainingMethod`, `TestProviderAdapters_ContainNoTrainingEndpoint` (beide PASS). Vertraglich: siehe AK3-Begründung oben — dokumentiert, nicht zusätzlich durch einen unterzeichneten externen Vertrag nachweisbar (kein produktiver externer Anbieter im Testzeitraum aktiv, siehe Grenze unten). | + +## Grenze (dokumentiert statt verschwiegen) + +Wie bereits in ENG-03 festgehalten liegt kein echter, bezahlter externer +API-Zugang vor. Die "vertragliche" Prüfung 3 stützt sich auf die dokumentierte +Anbieter-Richtlinie (keine Trainingsnutzung ohne Opt-in) statt auf eine im +Testlauf tatsächlich abgerufene Vertragsbestätigung — dieselbe Grenze wie bei +ENG-03s Prüfung 1. + +## Gesamtlauf (204) + +- `go build ./...`, `go vet ./...`: sauber für alle neuen/geänderten Pakete. +- `gofmt -l` auf allen neuen/geänderten Dateien: leer (3 vorbestehende + Fundstellen aus `limits`, `queue`, `threadsummarize` sind nicht Teil dieser + Kachel, unverändert gelassen). +- `go test ./internal/residency/... ./internal/residencyapi/... ./internal/notraining/...`: + 9/9 PASS. +- Vollständiger Modul-Lauf `go test ./...`: alle Pakete `ok`, keine Regression + (insbesondere `provider/openai`, `provider/ollama`, `modelconfig`, + `queue`, `semanticsearchapi`, `summaryapi` unverändert grün). + +## QA und Entscheidungen + +- **`residency.Gate` statt Änderung an `GuardedProvider`:** ENG-03s + `GuardedProvider`/`TenantGate`-Interface bleibt unverändert (kein Umbau + angrenzender Bereiche); `residency.Gate` implementiert nur das bestehende + Interface mit einer DB-gestützten Quelle statt der Env-Liste. Aufrufer, die + weiterhin die einfache Env-Liste wollen, können `EnvTenantGate` unverändert + weiterverwenden — beide Implementierungen sind gleichwertig gültige + `TenantGate`, GOV-03 zwingt niemanden zur Migration. +- **Freigabe-Historie bleibt bei `Revoke` erhalten:** bewusst, damit ein Audit + nachvollziehen kann, wer wann freigegeben hatte, auch nach Rücknahme + (Nachvollziehbarkeits-Vorgabe aus Produkt-DNA). +- **Kein eigener `cmd/`-Einstiegspunkt:** es existiert noch keine zentrale + AI-Modul-`main.go`, die alle HTTP-APIs (`summaryapi`, `semanticsearchapi`, + jetzt `residencyapi`) zusammen verdrahtet — das ist nicht Bestandteil dieser + Kachel, `residencyapi.Server` folgt exakt demselben `RegisterRoutes`-Muster + wie die bestehenden APIs und kann dort ergänzt werden, sobald diese + Verdrahtung ansteht. +- 0 fachliche Rückfragen nötig, keine Blocker. Eine technische Korrektur + während der Umsetzung: ungenutzter Import in `residency_test.go` + (`go vet` gemeldet, sofort behoben). + +## Nicht Bestandteil dieser Kachel + +Zentrale `cmd/`-Verdrahtung aller AI-HTTP-APIs, UI für Freigabe-Verwaltung, +Migration bestehender `EnvTenantGate`-Nutzer auf `residency.Gate` — alles +außerhalb der Akzeptanzkriterien, keine vorauseilende Umsetzung. diff --git a/ai/internal/notraining/notraining_test.go b/ai/internal/notraining/notraining_test.go new file mode 100644 index 00000000..e5aca82c --- /dev/null +++ b/ai/internal/notraining/notraining_test.go @@ -0,0 +1,80 @@ +// Package notraining prüft AK3/Prüfung 3 (GOV-03) technisch: kein Dokumentinhalt +// kann über diesen Code in ein Training fließen, weil (a) das Provider-Interface +// selbst keine Trainings-/Fine-Tuning-Methode kennt und (b) keine der beiden +// Adapter-Implementierungen einen Trainings-/Fine-Tuning-Endpunkt aufruft. +package notraining + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" +) + +// Prüfung 3 (technisch, Teil 1): das Provider-Interface hat ausschließlich +// Chat/Complete/Embed — es gibt schlicht keine Methode, über die ein Aufrufer +// ein Training anstoßen könnte. +func TestProviderInterface_HasNoTrainingMethod(t *testing.T) { + typ := reflect.TypeOf((*provider.Provider)(nil)).Elem() + want := map[string]bool{"Chat": true, "Complete": true, "Embed": true} + if typ.NumMethod() != len(want) { + t.Fatalf("provider.Provider hat %d Methoden, erwartet genau %v", typ.NumMethod(), want) + } + for i := 0; i < typ.NumMethod(); i++ { + name := typ.Method(i).Name + if !want[name] { + t.Fatalf("unerwartete Methode %q auf provider.Provider — moeglicher Trainings-/Fine-Tuning-Pfad", name) + } + } +} + +// forbiddenSubstrings sind Endpunkt-/Begriffsfragmente, die auf einen +// Trainings- oder Fine-Tuning-Aufruf hindeuten würden. +var forbiddenSubstrings = []string{ + "fine_tuning", + "fine-tuning", + "fine_tune", + "fine-tune", + "/finetune", + "/train", + "train_on_customer", +} + +// Prüfung 3 (technisch, Teil 2): keine der Adapter-Implementierungen enthält +// einen Trainings-/Fine-Tuning-Endpunkt oder -Parameter im Quelltext. +func TestProviderAdapters_ContainNoTrainingEndpoint(t *testing.T) { + root := findAIModuleRoot(t) + files := []string{ + filepath.Join(root, "internal", "provider", "ollama", "ollama.go"), + filepath.Join(root, "internal", "provider", "openai", "openai.go"), + filepath.Join(root, "internal", "provider", "openai", "activation.go"), + } + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + t.Fatalf("%s lesen: %v", f, err) + } + lower := strings.ToLower(string(data)) + for _, forbidden := range forbiddenSubstrings { + if strings.Contains(lower, forbidden) { + t.Fatalf("%s enthaelt verbotenes Fragment %q — moeglicher Trainingspfad", f, forbidden) + } + } + } +} + +func findAIModuleRoot(t *testing.T) string { + t.Helper() + dir, _ := os.Getwd() + for i := 0; i < 6; i++ { + if st, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil && !st.IsDir() { + return dir + } + dir = filepath.Dir(dir) + } + t.Fatal("ai-modul-wurzel (go.mod) nicht gefunden") + return "" +} diff --git a/ai/internal/residency/residency.go b/ai/internal/residency/residency.go new file mode 100644 index 00000000..5bfa568e --- /dev/null +++ b/ai/internal/residency/residency.go @@ -0,0 +1,140 @@ +// Package residency verwaltet je Tenant, ob KI-Inferenz lokal (Ollama) bleibt +// oder auch an einen externen Adapter (ENG-03) gehen darf (GOV-03). +// +// AK1: Get liest live aus der Datenbank, ohne Eintrag gilt Mode = Local — der +// Stand ist damit für jeden Tenant jederzeit einsehbar, ohne versteckten +// Zwischenspeicher. +// AK2: Approve ist der einzige Weg, einen Tenant auf External zu heben, und +// verlangt approvedBy nicht leer (nachvollziehbare, ausdrückliche Freigabe). +package residency + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Mode ist der Datenresidenz-Stand eines Tenants. +type Mode string + +const ( + // Local ist der Standardwert ohne DB-Eintrag: Inferenz bleibt lokal + // (Ollama, ENG-02), kein Inhalt geht an einen externen Adapter. + Local Mode = "local" + // ExternalApproved ist nur nach ausdrücklicher Approve()-Freigabe aktiv. + ExternalApproved Mode = "external_approved" +) + +// ErrApprovedByRequired meldet einen fehlenden Freigeber-Namen bei Approve. +var ErrApprovedByRequired = errors.New("residency: approvedBy darf nicht leer sein") + +// Config ist der Datenresidenz-Stand eines Tenants mit Freigabe-Nachweis. +type Config struct { + TenantSlug string + Mode Mode + ApprovedBy string + ApprovedAt time.Time + UpdatedAt time.Time +} + +// ExternalAllowed meldet, ob externe Verarbeitung für diesen Tenant freigegeben ist. +func (c Config) ExternalAllowed() bool { return c.Mode == ExternalApproved } + +type Store struct { + Pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} } + +// Get liefert den aktuellen Stand für tenantSlug (AK1). Fehlt ein Eintrag, +// gilt Mode = Local als sicherer Standard, kein Fehler. +func (s *Store) Get(ctx context.Context, tenantSlug string) (Config, error) { + tenantSlug = strings.TrimSpace(tenantSlug) + var ( + c Config + approvedBy *string + approvedAt *time.Time + ) + c.TenantSlug = tenantSlug + err := s.Pool.QueryRow(ctx, ` + SELECT mode, approved_by, approved_at, updated_at + FROM tenant_data_residency WHERE tenant_slug = $1 + `, tenantSlug).Scan(&c.Mode, &approvedBy, &approvedAt, &c.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return Config{TenantSlug: tenantSlug, Mode: Local}, nil + } + if err != nil { + return Config{}, fmt.Errorf("residency: lesen: %w", err) + } + if approvedBy != nil { + c.ApprovedBy = *approvedBy + } + if approvedAt != nil { + c.ApprovedAt = *approvedAt + } + return c, nil +} + +// Approve schaltet tenantSlug ausdrücklich auf ExternalApproved (AK2) und +// hält fest, wer die Freigabe erteilt hat. +func (s *Store) Approve(ctx context.Context, tenantSlug, approvedBy string) error { + tenantSlug = strings.TrimSpace(tenantSlug) + approvedBy = strings.TrimSpace(approvedBy) + if tenantSlug == "" { + return fmt.Errorf("residency: tenantSlug darf nicht leer sein") + } + if approvedBy == "" { + return ErrApprovedByRequired + } + _, err := s.Pool.Exec(ctx, ` + INSERT INTO tenant_data_residency (tenant_slug, mode, approved_by, approved_at, updated_at) + VALUES ($1, $2, $3, now(), now()) + ON CONFLICT (tenant_slug) + DO UPDATE SET mode = $2, approved_by = $3, approved_at = now(), updated_at = now() + `, tenantSlug, ExternalApproved, approvedBy) + if err != nil { + return fmt.Errorf("residency: freigeben: %w", err) + } + return nil +} + +// Revoke setzt tenantSlug zurück auf Local. Der Freigabe-Nachweis +// (approved_by/approved_at) bleibt für die Nachvollziehbarkeit erhalten. +func (s *Store) Revoke(ctx context.Context, tenantSlug string) error { + tenantSlug = strings.TrimSpace(tenantSlug) + if tenantSlug == "" { + return fmt.Errorf("residency: tenantSlug darf nicht leer sein") + } + _, err := s.Pool.Exec(ctx, ` + INSERT INTO tenant_data_residency (tenant_slug, mode, updated_at) + VALUES ($1, $2, now()) + ON CONFLICT (tenant_slug) + DO UPDATE SET mode = $2, updated_at = now() + `, tenantSlug, Local) + if err != nil { + return fmt.Errorf("residency: zuruecksetzen: %w", err) + } + return nil +} + +// Gate implementiert openai.TenantGate (ENG-03) gegen den DB-Stand, statt der +// dortigen Freigabeliste per Umgebungsvariable — je Tenant einsehbar und +// nachvollziehbar (AK1), ohne GuardedProvider/ENG-03 selbst zu verändern. +type Gate struct { + Store *Store +} + +// Allowed erfüllt openai.TenantGate. Die Schnittstelle kennt keinen Kontext, +// daher context.Background() für diese Live-DB-Abfrage je Anfrage. +func (g *Gate) Allowed(tenant string) bool { + cfg, err := g.Store.Get(context.Background(), tenant) + if err != nil { + return false + } + return cfg.ExternalAllowed() +} diff --git a/ai/internal/residency/residency_test.go b/ai/internal/residency/residency_test.go new file mode 100644 index 00000000..e6f0d3fd --- /dev/null +++ b/ai/internal/residency/residency_test.go @@ -0,0 +1,131 @@ +package residency_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/openai" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/residency" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb" +) + +func setup(t *testing.T) *residency.Store { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool := testdb.Pool(t, dsn) + + migrations, err := migrate.Load(findMigrationsDir(t)) + if err != nil { + t.Fatalf("migrationen laden: %v", err) + } + if _, err := migrate.Up(ctx, pool, migrations); err != nil { + t.Fatalf("migrationen anwenden: %v", err) + } + return residency.NewStore(pool) +} + +func findMigrationsDir(t *testing.T) string { + t.Helper() + dir, _ := os.Getwd() + for i := 0; i < 6; i++ { + cand := filepath.Join(dir, "migrations") + if st, err := os.Stat(cand); err == nil && st.IsDir() { + return cand + } + dir = filepath.Dir(dir) + } + t.Fatal("migrations-verzeichnis nicht gefunden") + return "" +} + +// AK1 + Prüfung 1: ohne Konfiguration gilt Local, einsehbar per Get. +func TestGet_WithoutEntry_DefaultsToLocal(t *testing.T) { + store := setup(t) + cfg, err := store.Get(context.Background(), "unbekannter-tenant") + if err != nil { + t.Fatalf("Get: %v", err) + } + if cfg.Mode != residency.Local { + t.Fatalf("Mode = %v, want Local", cfg.Mode) + } + if cfg.ExternalAllowed() { + t.Fatal("ohne Konfiguration darf externe Verarbeitung nicht erlaubt sein") + } +} + +// AK2 + Prüfung 1: Approve schaltet sichtbar und nachvollziehbar auf extern um. +func TestApprove_SwitchesToExternalAndIsVisible(t *testing.T) { + store := setup(t) + ctx := context.Background() + if err := store.Approve(ctx, "acme", "admin@acme.example"); err != nil { + t.Fatalf("Approve: %v", err) + } + cfg, err := store.Get(ctx, "acme") + if err != nil { + t.Fatalf("Get: %v", err) + } + if !cfg.ExternalAllowed() { + t.Fatal("nach Approve muss externe Verarbeitung erlaubt sein") + } + if cfg.ApprovedBy != "admin@acme.example" { + t.Fatalf("ApprovedBy = %q, want admin@acme.example", cfg.ApprovedBy) + } + if cfg.ApprovedAt.IsZero() { + t.Fatal("ApprovedAt muss gesetzt sein (Nachvollziehbarkeit)") + } +} + +// AK2: Freigabe ohne Namen ist ausgeschlossen, keine anonyme Freischaltung. +func TestApprove_WithoutApprovedBy_Fails(t *testing.T) { + store := setup(t) + if err := store.Approve(context.Background(), "acme", ""); err != residency.ErrApprovedByRequired { + t.Fatalf("err = %v, want ErrApprovedByRequired", err) + } +} + +// Prüfung 2: fehlende Freigabe verhindert externe Verarbeitung — hier über +// die tatsächliche ENG-03-Gate-Schnittstelle nachgewiesen, kein Duplikat. +func TestGate_BlocksWithoutApproval(t *testing.T) { + store := setup(t) + gate := &residency.Gate{Store: store} + var _ openai.TenantGate = gate + + if gate.Allowed("initech") { + t.Fatal("ohne Freigabe darf der Gate den Mandanten nicht erlauben") + } + if err := store.Approve(context.Background(), "initech", "dpo@initech.example"); err != nil { + t.Fatalf("Approve: %v", err) + } + if !gate.Allowed("initech") { + t.Fatal("nach Freigabe muss der Gate den Mandanten erlauben") + } +} + +// AK2: Revoke nimmt die Freigabe zurück, Freigabe-Nachweis bleibt erhalten. +func TestRevoke_ResetsToLocalButKeepsApprovalHistory(t *testing.T) { + store := setup(t) + ctx := context.Background() + if err := store.Approve(ctx, "globex", "dpo@globex.example"); err != nil { + t.Fatalf("Approve: %v", err) + } + if err := store.Revoke(ctx, "globex"); err != nil { + t.Fatalf("Revoke: %v", err) + } + cfg, err := store.Get(ctx, "globex") + if err != nil { + t.Fatalf("Get: %v", err) + } + if cfg.ExternalAllowed() { + t.Fatal("nach Revoke darf externe Verarbeitung nicht mehr erlaubt sein") + } + if cfg.ApprovedBy != "dpo@globex.example" { + t.Fatalf("Freigabe-Historie ging verloren: ApprovedBy = %q", cfg.ApprovedBy) + } +} diff --git a/ai/internal/residencyapi/residencyapi.go b/ai/internal/residencyapi/residencyapi.go new file mode 100644 index 00000000..fb7237f0 --- /dev/null +++ b/ai/internal/residencyapi/residencyapi.go @@ -0,0 +1,133 @@ +// Package residencyapi ist die HTTP-Bindung für GOV-03: macht den +// Datenresidenz-Stand eines Tenants einsehbar (AK1) und die einzige Freigabe +// externer Verarbeitung anfrage-/testbar (AK2). Service-Token-Pflicht wie +// summaryapi/semanticsearchapi, fail closed. +package residencyapi + +import ( + "encoding/json" + "net/http" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/residency" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth" +) + +type Server struct { + store *residency.Store + serviceToken string +} + +func NewServer(store *residency.Store, serviceToken string) *Server { + return &Server{store: store, serviceToken: serviceToken} +} + +func (s *Server) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /tenants/{tenant}/residency", s.requireAuth(s.handleGet)) + mux.HandleFunc("POST /tenants/{tenant}/residency/approve", s.requireAuth(s.handleApprove)) + mux.HandleFunc("POST /tenants/{tenant}/residency/revoke", s.requireAuth(s.handleRevoke)) +} + +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + got := r.Header.Get("X-Service-Token") + if !svcauth.Equal(got, s.serviceToken) { + writeError(w, http.StatusUnauthorized, "service-token fehlt oder ist ungueltig") + return + } + next(w, r) + } +} + +type residencyResponse struct { + TenantSlug string `json:"tenant_slug"` + Mode string `json:"mode"` + ExternalAllowed bool `json:"external_allowed"` + ApprovedBy string `json:"approved_by,omitempty"` + ApprovedAtRFC339 string `json:"approved_at,omitempty"` +} + +func toResponse(cfg residency.Config) residencyResponse { + resp := residencyResponse{ + TenantSlug: cfg.TenantSlug, + Mode: string(cfg.Mode), + ExternalAllowed: cfg.ExternalAllowed(), + ApprovedBy: cfg.ApprovedBy, + } + if !cfg.ApprovedAt.IsZero() { + resp.ApprovedAtRFC339 = cfg.ApprovedAt.Format("2006-01-02T15:04:05Z07:00") + } + return resp +} + +func (s *Server) handleGet(w http.ResponseWriter, r *http.Request) { + tenant := r.PathValue("tenant") + if tenant == "" { + writeError(w, http.StatusBadRequest, "tenant fehlt") + return + } + cfg, err := s.store.Get(r.Context(), tenant) + if err != nil { + writeError(w, http.StatusInternalServerError, "datenresidenz lesen fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, toResponse(cfg)) +} + +type approveRequest struct { + ApprovedBy string `json:"approved_by"` +} + +func (s *Server) handleApprove(w http.ResponseWriter, r *http.Request) { + tenant := r.PathValue("tenant") + if tenant == "" { + writeError(w, http.StatusBadRequest, "tenant fehlt") + return + } + var req approveRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper") + return + } + if err := s.store.Approve(r.Context(), tenant, req.ApprovedBy); err != nil { + if err == residency.ErrApprovedByRequired { + writeError(w, http.StatusBadRequest, "approved_by darf nicht leer sein") + return + } + writeError(w, http.StatusInternalServerError, "freigabe fehlgeschlagen") + return + } + cfg, err := s.store.Get(r.Context(), tenant) + if err != nil { + writeError(w, http.StatusInternalServerError, "datenresidenz lesen fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, toResponse(cfg)) +} + +func (s *Server) handleRevoke(w http.ResponseWriter, r *http.Request) { + tenant := r.PathValue("tenant") + if tenant == "" { + writeError(w, http.StatusBadRequest, "tenant fehlt") + return + } + if err := s.store.Revoke(r.Context(), tenant); err != nil { + writeError(w, http.StatusInternalServerError, "zuruecksetzen fehlgeschlagen") + return + } + cfg, err := s.store.Get(r.Context(), tenant) + if err != nil { + writeError(w, http.StatusInternalServerError, "datenresidenz lesen fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, toResponse(cfg)) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} diff --git a/ai/internal/residencyapi/residencyapi_test.go b/ai/internal/residencyapi/residencyapi_test.go new file mode 100644 index 00000000..398a8497 --- /dev/null +++ b/ai/internal/residencyapi/residencyapi_test.go @@ -0,0 +1,150 @@ +package residencyapi_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/residency" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/residencyapi" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb" +) + +const testToken = "test-service-token" + +func setup(t *testing.T) *httptest.Server { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool := testdb.Pool(t, dsn) + + migrations, err := migrate.Load(findMigrationsDir(t)) + if err != nil { + t.Fatalf("migrationen laden: %v", err) + } + if _, err := migrate.Up(ctx, pool, migrations); err != nil { + t.Fatalf("migrationen anwenden: %v", err) + } + + srv := residencyapi.NewServer(residency.NewStore(pool), testToken) + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + return ts +} + +func findMigrationsDir(t *testing.T) string { + t.Helper() + dir, _ := os.Getwd() + for i := 0; i < 6; i++ { + cand := filepath.Join(dir, "migrations") + if st, err := os.Stat(cand); err == nil && st.IsDir() { + return cand + } + dir = filepath.Dir(dir) + } + t.Fatal("migrations-verzeichnis nicht gefunden") + return "" +} + +type residencyBody struct { + TenantSlug string `json:"tenant_slug"` + Mode string `json:"mode"` + ExternalAllowed bool `json:"external_allowed"` + ApprovedBy string `json:"approved_by"` +} + +func doJSON(t *testing.T, method, url, token string, body any) (int, residencyBody) { + t.Helper() + var reader *bytes.Reader + if body != nil { + buf, _ := json.Marshal(body) + reader = bytes.NewReader(buf) + } else { + reader = bytes.NewReader(nil) + } + req, err := http.NewRequest(method, url, reader) + if err != nil { + t.Fatalf("request bauen: %v", err) + } + if token != "" { + req.Header.Set("X-Service-Token", token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request senden: %v", err) + } + defer resp.Body.Close() + var out residencyBody + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out +} + +// AK1 + Prüfung 1: der Datenresidenz-Stand ist über die echte HTTP-API einsehbar, +// ohne Konfiguration lokal. +func TestGet_DefaultsToLocal_RealHTTP(t *testing.T) { + ts := setup(t) + status, body := doJSON(t, http.MethodGet, ts.URL+"/tenants/acme/residency", testToken, nil) + if status != http.StatusOK { + t.Fatalf("status = %d", status) + } + if body.Mode != "local" || body.ExternalAllowed { + t.Fatalf("unerwarteter Standard: %+v", body) + } +} + +// AK2 + Prüfung 1/2: Freigabe über echten HTTP-Aufruf schaltet extern frei, +// sichtbar in einer folgenden Abfrage; fehlende Freigabe verhindert es weiterhin. +func TestApprove_RealHTTP_MakesExternalVisible(t *testing.T) { + ts := setup(t) + + status, body := doJSON(t, http.MethodPost, ts.URL+"/tenants/acme/residency/approve", testToken, + map[string]string{"approved_by": "dpo@acme.example"}) + if status != http.StatusOK || !body.ExternalAllowed { + t.Fatalf("Freigabe schlug fehl: status=%d body=%+v", status, body) + } + + status, body = doJSON(t, http.MethodGet, ts.URL+"/tenants/acme/residency", testToken, nil) + if status != http.StatusOK || !body.ExternalAllowed || body.ApprovedBy != "dpo@acme.example" { + t.Fatalf("Freigabe nicht sichtbar: status=%d body=%+v", status, body) + } +} + +// AK2 + Prüfung 2: Freigabe ohne approved_by wird abgelehnt, Tenant bleibt lokal. +func TestApprove_RealHTTP_WithoutApprovedBy_Rejected(t *testing.T) { + ts := setup(t) + + status, _ := doJSON(t, http.MethodPost, ts.URL+"/tenants/initech/residency/approve", testToken, + map[string]string{"approved_by": ""}) + if status != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", status) + } + + status, body := doJSON(t, http.MethodGet, ts.URL+"/tenants/initech/residency", testToken, nil) + if status != http.StatusOK || body.ExternalAllowed { + t.Fatalf("ohne gueltige Freigabe darf extern nicht erlaubt sein: %+v", body) + } +} + +// Fail closed: ohne/mit falschem Service-Token keine Auskunft. +func TestRequireAuth_RejectsMissingOrWrongToken(t *testing.T) { + ts := setup(t) + status, _ := doJSON(t, http.MethodGet, ts.URL+"/tenants/acme/residency", "", nil) + if status != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 ohne token", status) + } + status, _ = doJSON(t, http.MethodGet, ts.URL+"/tenants/acme/residency", "falsch", nil) + if status != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 mit falschem token", status) + } +} diff --git a/ai/migrations/0006_tenant_data_residency.down.sql b/ai/migrations/0006_tenant_data_residency.down.sql new file mode 100644 index 00000000..ab5545e0 --- /dev/null +++ b/ai/migrations/0006_tenant_data_residency.down.sql @@ -0,0 +1 @@ +DROP TABLE tenant_data_residency; diff --git a/ai/migrations/0006_tenant_data_residency.up.sql b/ai/migrations/0006_tenant_data_residency.up.sql new file mode 100644 index 00000000..397e767e --- /dev/null +++ b/ai/migrations/0006_tenant_data_residency.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE tenant_data_residency ( + tenant_slug TEXT NOT NULL PRIMARY KEY, + mode TEXT NOT NULL DEFAULT 'local', + approved_by TEXT, + approved_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (mode IN ('local', 'external_approved')) +); -- 2.47.3