GOV-03: keine training auf kundendaten – datenresidenz-kontrolle #222

Merged
patrick merged 1 commits from feature/gov-03-keine-training-auf-kundendaten-datenresidenz-kontrolle into main 2026-09-27 10:24:53 +02:00
8 changed files with 745 additions and 0 deletions

No files matched your search

+102
View File
@@ -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.
+80
View File
@@ -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 ""
}
+140
View File
@@ -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()
}
+131
View File
@@ -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)
}
}
+133
View File
@@ -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})
}
@@ -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)
}
}
@@ -0,0 +1 @@
DROP TABLE tenant_data_residency;
@@ -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'))
);