Registry-DB (nur Tenant-Metadaten), Provisioning-Routine legt pro Mandant
eine physisch isolierte Postgres-DB an und registriert sie transaktional
(Rollback der DB bei fehlgeschlagener Registrierung). Schlanker HTTP-Handler
als Schnittstellen-Vorbereitung fuer API-01/TEN-02, kein eigenes REST-Grundgerüst.
Pruefungen:
1. Migration up/down geschrieben (0001_tenant_registry.{up,down}.sql) — nicht
gegen echte DB ausgefuehrt, da auf dieser Maschine kein Go/Postgres-Test-
Setup verfuegbar ist. Offen zur Ausfuehrung.
2. Integrationstest TestProvision_CreatesIsolatedDatabases geschrieben (zwei
Mandanten, prueft unterschiedliche db_name und current_database()) —
ebenfalls nicht ausgefuehrt, guarded per TEST_ADMIN_DSN env var. Offen.
3. Slug-Validierung (unit test TestValidateSlug) deckt SQL-Injection-Versuch
im Datenbanknamen ab — ebenfalls nicht lokal ausgefuehrt, da kein Go
Compiler auf dieser Maschine vorhanden ist. Offen.
Alle drei Pruefungen sind vorbereitet, aber NICHT durchgefuehrt worden —
zaehlen laut Vorgabe als offen bis auf einer Maschine mit Go+Postgres verifiziert.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
79 lines
2.6 KiB
Go
79 lines
2.6 KiB
Go
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))
|
|
}
|