diff --git a/DEVLOG.md b/DEVLOG.md index 7178fe5..6a9fd56 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -44,3 +44,88 @@ Keine Commits in dieser Session. Keine Änderungen ermittelbar. --- +## 2026-08-27 17:26 – 17:28 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** code + +### Commits +- c895a67 core: initial Go module skeleton (config, db pool, tenant registry migration) + +### Geänderte Dateien +- .gitignore | 2 ++ +- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ +- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++ +- go.mod | 5 +++++ +- internal/config/config.go | 29 +++++++++++++++++++++++++++++ +- internal/db/db.go | 11 +++++++++++ +- migrations/0001_tenant_registry.sql | 10 ++++++++++ + +--- +## 2026-08-27 17:28 – 17:29 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** code + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- .gitignore | 2 ++ +- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ +- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++ +- go.mod | 5 +++++ +- internal/config/config.go | 29 +++++++++++++++++++++++++++++ +- internal/db/db.go | 11 +++++++++++ +- migrations/0001_tenant_registry.sql | 10 ++++++++++ + +--- +## 2026-08-27 17:31 – 17:31 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** code + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- .gitignore | 2 ++ +- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ +- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++ +- go.mod | 5 +++++ +- internal/config/config.go | 29 +++++++++++++++++++++++++++++ +- internal/db/db.go | 11 +++++++++++ +- migrations/0001_tenant_registry.sql | 10 ++++++++++ + +--- +## 2026-08-27 17:36 – 17:36 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** code + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- .gitignore | 2 ++ +- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ +- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++ +- go.mod | 5 +++++ +- internal/config/config.go | 29 +++++++++++++++++++++++++++++ +- internal/db/db.go | 11 +++++++++++ +- migrations/0001_tenant_registry.sql | 10 ++++++++++ + +--- +## 2026-08-27 17:36 – 17:37 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** code + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- .gitignore | 2 ++ +- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ +- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++ +- go.mod | 5 +++++ +- internal/config/config.go | 29 +++++++++++++++++++++++++++++ +- internal/db/db.go | 11 +++++++++++ +- migrations/0001_tenant_registry.sql | 10 ++++++++++ + +--- diff --git a/cmd/core/main.go b/cmd/core/main.go index f8e362e..961bbf2 100644 --- a/cmd/core/main.go +++ b/cmd/core/main.go @@ -7,6 +7,7 @@ import ( "gitea.perlbach24.de/scripte/nexarch/internal/config" "gitea.perlbach24.de/scripte/nexarch/internal/db" + "gitea.perlbach24.de/scripte/nexarch/internal/tenant" ) func main() { @@ -15,16 +16,30 @@ func main() { log.Fatalf("config: %v", err) } - pool, err := db.Connect(context.Background(), cfg.RegistryDSN) + ctx := context.Background() + + registryPool, err := db.Connect(ctx, cfg.RegistryDSN) if err != nil { - log.Fatalf("db: %v", err) + log.Fatalf("registry db: %v", err) } - defer pool.Close() + defer registryPool.Close() + + adminPool, err := db.Connect(ctx, cfg.AdminDSN) + if err != nil { + log.Fatalf("admin db: %v", err) + } + defer adminPool.Close() + + registry := tenant.NewRegistry(registryPool) + provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate) + tenantHandler := tenant.NewHandler(provisioner) mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + // Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 abgeloest. + mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant) log.Printf("nexarch-core listening on %s", cfg.ListenAddr) if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index d51dc5f..86ac457 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,8 +10,15 @@ import ( // connection info, superadmin accounts) — see nexarch-state.json // multi_tenancy: Modell C (physisch getrennte DB pro Mandant). type Config struct { - ListenAddr string + ListenAddr string + // RegistryDSN verbindet zur Control-Plane-Registry-Datenbank. RegistryDSN string + // AdminDSN verbindet zur Wartungsdatenbank (z.B. "postgres") und wird nur + // fuer CREATE/DROP DATABASE beim Tenant-Provisioning verwendet. + AdminDSN string + // TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den + // Datenbanknamen einer neu provisionierten Tenant-Datenbank. + TenantDSNTemplate string } func Load() (Config, error) { @@ -20,10 +27,25 @@ func Load() (Config, error) { return Config{}, fmt.Errorf("NEXARCH_REGISTRY_DSN not set") } + adminDSN := os.Getenv("NEXARCH_ADMIN_DSN") + if adminDSN == "" { + return Config{}, fmt.Errorf("NEXARCH_ADMIN_DSN not set") + } + + dsnTemplate := os.Getenv("NEXARCH_TENANT_DSN_TEMPLATE") + if dsnTemplate == "" { + return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set") + } + addr := os.Getenv("NEXARCH_LISTEN_ADDR") if addr == "" { addr = ":8080" } - return Config{ListenAddr: addr, RegistryDSN: dsn}, nil + return Config{ + ListenAddr: addr, + RegistryDSN: dsn, + AdminDSN: adminDSN, + TenantDSNTemplate: dsnTemplate, + }, nil } diff --git a/internal/tenant/handler.go b/internal/tenant/handler.go new file mode 100644 index 0000000..54eac5d --- /dev/null +++ b/internal/tenant/handler.go @@ -0,0 +1,45 @@ +package tenant + +import ( + "encoding/json" + "net/http" +) + +// Handler ist eine schlanke Vorbereitung der Schnittstelle fuer API-01 +// (REST-API-Grundgerüst & Versionierung) und TEN-02 (Self-Service-Onboarding). +// Auth/Rate-Limiting/Versionierung selbst sind ausdruecklich nicht Teil von +// TEN-01 und werden dort nachgezogen. +type Handler struct { + provisioner *Provisioner +} + +func NewHandler(p *Provisioner) *Handler { + return &Handler{provisioner: p} +} + +type createTenantRequest struct { + Slug string `json:"slug"` + Name string `json:"name"` +} + +func (h *Handler) CreateTenant(w http.ResponseWriter, r *http.Request) { + var req createTenantRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + + t, err := h.provisioner.Provision(r.Context(), req.Slug, req.Name) + if err != nil { + if err == ErrInvalidSlug { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + http.Error(w, "tenant konnte nicht angelegt werden", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(t) +} diff --git a/internal/tenant/provisioner.go b/internal/tenant/provisioner.go new file mode 100644 index 0000000..5b60a8c --- /dev/null +++ b/internal/tenant/provisioner.go @@ -0,0 +1,78 @@ +package tenant + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Provisioner legt fuer jeden neuen Mandanten eine vollstaendig isolierte +// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry +// (Akzeptanzkriterium 2). Zwei Mandanten-Datenbanken sind danach auf +// Infrastrukturebene komplett getrennt (Akzeptanzkriterium 3). +type Provisioner struct { + // adminPool ist mit der Wartungsdatenbank (z. B. "postgres") verbunden + // und wird ausschliesslich fuer CREATE/DROP DATABASE verwendet, da diese + // Befehle in PostgreSQL nicht in einer Transaktion laufen koennen. + adminPool *pgxpool.Pool + registry *Registry + // dsnTemplate enthaelt genau ein "%s" als Platzhalter fuer den + // Datenbanknamen, z. B. "postgresql://user:pass@host:5432/%s?sslmode=disable". + dsnTemplate string +} + +func NewProvisioner(adminPool *pgxpool.Pool, registry *Registry, dsnTemplate string) *Provisioner { + return &Provisioner{adminPool: adminPool, registry: registry, dsnTemplate: dsnTemplate} +} + +// Provision legt die Tenant-Datenbank an und registriert sie. Schlaegt die +// Registrierung fehl, wird die bereits angelegte Datenbank wieder entfernt, +// damit kein verwaister, unregistrierter Tenant zurueckbleibt. +func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant, error) { + if err := ValidateSlug(slug); err != nil { + return Tenant{}, err + } + + dbName := dbNameForSlug(slug) + + // CREATE DATABASE erlaubt keine Parameter-Platzhalter; slug ist durch + // ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen. + if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil { + return Tenant{}, fmt.Errorf("tenant-datenbank anlegen: %w", err) + } + + t := Tenant{ + Slug: slug, + Name: name, + DBName: dbName, + DBDSN: fmt.Sprintf(p.dsnTemplate, dbName), + Status: StatusActive, + } + + tx, err := p.registry.pool.Begin(ctx) + if err != nil { + p.rollbackDatabase(ctx, dbName) + return Tenant{}, fmt.Errorf("registry-transaktion starten: %w", err) + } + + created, err := p.registry.insertTx(ctx, tx, t) + if err != nil { + _ = tx.Rollback(ctx) + p.rollbackDatabase(ctx, dbName) + return Tenant{}, err + } + + if err := tx.Commit(ctx); err != nil { + p.rollbackDatabase(ctx, dbName) + return Tenant{}, fmt.Errorf("registry-transaktion committen: %w", err) + } + + return created, nil +} + +// rollbackDatabase entfernt eine bereits angelegte Tenant-Datenbank, wenn die +// Registrierung fehlschlug, damit Provisioning insgesamt atomar wirkt. +func (p *Provisioner) rollbackDatabase(ctx context.Context, dbName string) { + _, _ = p.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName)) +} diff --git a/internal/tenant/provisioner_test.go b/internal/tenant/provisioner_test.go new file mode 100644 index 0000000..84712ae --- /dev/null +++ b/internal/tenant/provisioner_test.go @@ -0,0 +1,105 @@ +package tenant + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Integrationstest fuer Akzeptanzkriterien 2 und 3. Benoetigt eine echte +// Postgres-Instanz und wird ohne TEST_ADMIN_DSN uebersprungen, nicht als +// fehlgeschlagen gewertet — siehe Pruefungen-Ergebnis im PR. +// +// TEST_ADMIN_DSN muss auf die Wartungsdatenbank zeigen, z. B.: +// +// postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable +func TestProvision_CreatesIsolatedDatabases(t *testing.T) { + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + adminPool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("admin pool: %v", err) + } + defer adminPool.Close() + + registryPool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("registry pool: %v", err) + } + defer registryPool.Close() + + if _, err := registryPool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + db_name TEXT NOT NULL UNIQUE, + db_dsn TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`); err != nil { + t.Fatalf("registry-schema: %v", err) + } + + dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1) + registry := NewRegistry(registryPool) + provisioner := NewProvisioner(adminPool, registry, dsnTemplate) + + t.Cleanup(func() { + _, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug IN ('acme','globex')`) + _, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_acme`) + _, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_globex`) + }) + + tenantA, err := provisioner.Provision(ctx, "acme", "Acme GmbH") + if err != nil { + t.Fatalf("provision acme: %v", err) + } + tenantB, err := provisioner.Provision(ctx, "globex", "Globex AG") + if err != nil { + t.Fatalf("provision globex: %v", err) + } + + if tenantA.DBName == tenantB.DBName { + t.Fatalf("erwartet unterschiedliche db_name, beide sind %q", tenantA.DBName) + } + + // Akzeptanzkriterium 3 / Pruefung 3: In der Datenbank von Tenant A existiert + // keine Verbindungsmoeglichkeit zu Tenant B, weil beide physisch getrennte + // Datenbanken sind, statt sich auf einen Query-Filter zu verlassen. + poolA, err := pgxpool.New(ctx, tenantA.DBDSN) + if err != nil { + t.Fatalf("connect tenant a: %v", err) + } + defer poolA.Close() + + var globexVisible bool + err = poolA.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1 + ) + `, tenantB.DBName).Scan(&globexVisible) + if err != nil { + t.Fatalf("pruefung tenant-trennung: %v", err) + } + // pg_database ist clusterweit sichtbar (Existenz der DB), aber die + // eigentliche Pruefung ist: aus poolA (verbunden mit tenant_acme) ist keine + // Tabelle/Zeile aus tenant_globex erreichbar, da current_database() getrennt ist. + var currentDB string + if err := poolA.QueryRow(ctx, `SELECT current_database()`).Scan(¤tDB); err != nil { + t.Fatalf("current_database: %v", err) + } + if currentDB != tenantA.DBName { + t.Fatalf("current_database() = %q, want %q — keine physische Trennung", currentDB, tenantA.DBName) + } + if currentDB == tenantB.DBName { + t.Fatalf("tenant a verbindung zeigt auf tenant b datenbank") + } +} diff --git a/internal/tenant/registry.go b/internal/tenant/registry.go new file mode 100644 index 0000000..401f623 --- /dev/null +++ b/internal/tenant/registry.go @@ -0,0 +1,69 @@ +package tenant + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Registry kapselt den Zugriff auf die Control-Plane-Registry-Datenbank. +// Sie enthaelt ausschliesslich Tenant-Metadaten (Akzeptanzkriterium 1) — +// niemals Geschaeftsdaten eines Mandanten. +type Registry struct { + pool *pgxpool.Pool +} + +func NewRegistry(pool *pgxpool.Pool) *Registry { + return &Registry{pool: pool} +} + +// insertTx schreibt den Tenant-Datensatz innerhalb einer laufenden Transaktion, +// damit Provisioner.Provision DB-Anlage und Registrierung atomar behandeln kann. +func (r *Registry) insertTx(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, error) { + row := tx.QueryRow(ctx, ` + INSERT INTO tenants (slug, name, db_name, db_dsn, status) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, created_at + `, t.Slug, t.Name, t.DBName, t.DBDSN, t.Status) + + if err := row.Scan(&t.ID, &t.CreatedAt); err != nil { + return Tenant{}, fmt.Errorf("tenant registrieren: %w", err) + } + return t, nil +} + +func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) { + var t Tenant + row := r.pool.QueryRow(ctx, ` + SELECT id, slug, name, db_name, db_dsn, status, created_at + FROM tenants WHERE slug = $1 + `, slug) + + if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil { + return Tenant{}, fmt.Errorf("tenant laden: %w", err) + } + return t, nil +} + +func (r *Registry) List(ctx context.Context) ([]Tenant, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, slug, name, db_name, db_dsn, status, created_at + FROM tenants ORDER BY created_at + `) + if err != nil { + return nil, fmt.Errorf("tenants auflisten: %w", err) + } + defer rows.Close() + + var out []Tenant + for rows.Next() { + var t Tenant + if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil { + return nil, fmt.Errorf("tenant lesen: %w", err) + } + out = append(out, t) + } + return out, rows.Err() +} diff --git a/internal/tenant/tenant.go b/internal/tenant/tenant.go new file mode 100644 index 0000000..698566e --- /dev/null +++ b/internal/tenant/tenant.go @@ -0,0 +1,42 @@ +// Package tenant implements Core TEN-01: die Control-Plane-Registry und die +// Provisioning-Routine fuer physisch getrennte Mandanten-Datenbanken (Modell C). +package tenant + +import ( + "errors" + "regexp" + "time" +) + +type Status string + +const ( + StatusActive Status = "active" +) + +type Tenant struct { + ID string + Slug string + Name string + DBName string + DBDSN string + Status Status + CreatedAt time.Time +} + +// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit +// der Datenbankname niemals aus unkontrolliertem Nutzereingabe-Text gebaut wird. +var slugPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{1,48}$`) + +var ErrInvalidSlug = errors.New("tenant: slug muss mit Kleinbuchstaben beginnen und darf nur [a-z0-9_] enthalten (2-49 Zeichen)") + +func ValidateSlug(slug string) error { + if !slugPattern.MatchString(slug) { + return ErrInvalidSlug + } + return nil +} + +func dbNameForSlug(slug string) string { + return "tenant_" + slug +} diff --git a/internal/tenant/tenant_test.go b/internal/tenant/tenant_test.go new file mode 100644 index 0000000..1452517 --- /dev/null +++ b/internal/tenant/tenant_test.go @@ -0,0 +1,35 @@ +package tenant + +import "testing" + +func TestValidateSlug(t *testing.T) { + cases := []struct { + slug string + wantErr bool + }{ + {"acme", false}, + {"acme_gmbh", false}, + {"a1", false}, + {"", true}, + {"a", true}, + {"1acme", true}, + {"Acme", true}, + {"acme-gmbh", true}, + {"acme;drop table tenants", true}, + } + + for _, c := range cases { + err := ValidateSlug(c.slug) + if (err != nil) != c.wantErr { + t.Errorf("ValidateSlug(%q) error = %v, wantErr %v", c.slug, err, c.wantErr) + } + } +} + +func TestDBNameForSlug(t *testing.T) { + got := dbNameForSlug("acme") + want := "tenant_acme" + if got != want { + t.Errorf("dbNameForSlug() = %q, want %q", got, want) + } +} diff --git a/migrations/0001_tenant_registry.down.sql b/migrations/0001_tenant_registry.down.sql new file mode 100644 index 0000000..ad1bfb2 --- /dev/null +++ b/migrations/0001_tenant_registry.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tenants; diff --git a/migrations/0001_tenant_registry.sql b/migrations/0001_tenant_registry.sql deleted file mode 100644 index 3b01739..0000000 --- a/migrations/0001_tenant_registry.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Control-plane registry: tenant list + connection info (Modell C). --- Core TEN-01 (siehe core-kanban). -CREATE TABLE tenants ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - slug TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - db_dsn TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); diff --git a/migrations/0001_tenant_registry.up.sql b/migrations/0001_tenant_registry.up.sql new file mode 100644 index 0000000..5aa255a --- /dev/null +++ b/migrations/0001_tenant_registry.up.sql @@ -0,0 +1,14 @@ +-- Control-plane registry: Tenant-Liste + Verbindungsinformationen (Modell C). +-- Enthaelt AUSSCHLIESSLICH Tenant-Metadaten, keine Geschaeftsdaten eines Mandanten. +-- Core TEN-01 (siehe core-kanban/tickets/TEN-01.md). +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + db_name TEXT NOT NULL UNIQUE, + db_dsn TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +);