GOV-03: keine training auf kundendaten – datenresidenz-kontrolle #222
No files matched your search
@@ -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.
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'))
|
||||
);
|
||||
Reference in new issue
Block a user