Merge: main in OPS-12 einarbeiten, Konflikt in internal/config/config.go aufgeloest

PR #217 (OPS-12) war nicht mergefaehig: additiver Konflikt in Config{}
-- main hatte inzwischen ModuleDirectoryPath ergaenzt, OPS-12 ergaenzt
unabhaengig davon TenantRuntimeDSNTemplate. Beide Felder behalten,
keine inhaltliche Aenderung an der eigentlichen OPS-12-Logik.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-10-17 16:20:35 +02:00
commit d5b526db0e
26 files changed
+304 -29

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ func main() {
auditLog := audit.NewLog(registryPool)
registry := tenant.NewRegistry(registryPool).WithAudit(auditLog)
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate, cfg.TenantRuntimeDSNTemplate)
tenantHandler := tenant.NewHandler(provisioner)
onboardingService := tenant.NewOnboardingService(provisioner, cfg.TenantSchemaPath)
onboardingHandler := tenant.NewOnboardingHandler(onboardingService)
+17 -1
View File
@@ -36,6 +36,7 @@ set -euo pipefail
INSTALL_DIR="${NEXARCH_INSTALL_DIR:-/opt/nexarch-core}"
PG_ROLE="${NEXARCH_PG_ROLE:-nexarch_core}"
PG_REGISTRY_ROLE="${NEXARCH_PG_REGISTRY_ROLE:-nexarch_registry}"
PG_TENANT_RUNTIME_ROLE="${NEXARCH_PG_TENANT_RUNTIME_ROLE:-nexarch_tenant_runtime}"
PG_DB="${NEXARCH_PG_REGISTRY_DB:-nexarch_registry}"
ENV_FILE=/etc/nexarch/core.env
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -77,6 +78,19 @@ else
PG_REGISTRY_PASSWORD=""
fi
if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname = '${PG_TENANT_RUNTIME_ROLE}'" | grep -q 1; then
PG_TENANT_RUNTIME_PASSWORD="$(openssl rand -hex 24)"
# OPS-12/F-15: separate Rolle OHNE CREATEDB fuer die laufende
# Tenant-DB-Verbindung aller Dienste -- die CREATEDB-faehige Rolle
# (PG_ROLE) wird nur noch intern beim Provisioning verwendet, nie mehr
# an konsumierende Dienste weitergegeben.
sudo -u postgres psql -v ON_ERROR_STOP=1 -c "CREATE ROLE ${PG_TENANT_RUNTIME_ROLE} WITH LOGIN PASSWORD '${PG_TENANT_RUNTIME_PASSWORD}' NOCREATEDB;"
echo "Rolle '${PG_TENANT_RUNTIME_ROLE}' angelegt."
else
echo "Rolle '${PG_TENANT_RUNTIME_ROLE}' existiert bereits — Passwort wird NICHT neu gesetzt (siehe ${ENV_FILE} fuer das vorhandene)."
PG_TENANT_RUNTIME_PASSWORD=""
fi
if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname = '${PG_DB}'" | grep -q 1; then
sudo -u postgres psql -v ON_ERROR_STOP=1 -c "CREATE DATABASE ${PG_DB} OWNER ${PG_REGISTRY_ROLE};"
echo "Datenbank '${PG_DB}' angelegt (Eigentuemerin: ${PG_REGISTRY_ROLE})."
@@ -90,11 +104,12 @@ sudo -u postgres psql -v ON_ERROR_STOP=1 -c "ALTER ROLE ${PG_ROLE} WITH CREATEDB
# CONNECT-Rechte auf einer Tenant-Datenbank erhalten sollte, bleibt sie ohne
# CREATEDB unfaehig, selbst Tenant-Datenbanken anzulegen/zu uebernehmen.
sudo -u postgres psql -v ON_ERROR_STOP=1 -c "ALTER ROLE ${PG_REGISTRY_ROLE} WITH NOCREATEDB;" >/dev/null
sudo -u postgres psql -v ON_ERROR_STOP=1 -c "ALTER ROLE ${PG_TENANT_RUNTIME_ROLE} WITH NOCREATEDB;" >/dev/null
echo "== 3. Secrets/Konfiguration =="
mkdir -p /etc/nexarch
if [ ! -f "$ENV_FILE" ]; then
if [ -z "${PG_PASSWORD}" ] || [ -z "${PG_REGISTRY_PASSWORD}" ]; then
if [ -z "${PG_PASSWORD}" ] || [ -z "${PG_REGISTRY_PASSWORD}" ] || [ -z "${PG_TENANT_RUNTIME_PASSWORD}" ]; then
echo "FEHLER: mindestens eine Rolle existierte bereits, aber ${ENV_FILE} fehlt — Passwort kann nicht rekonstruiert werden." >&2
echo "Entweder ${ENV_FILE} von Hand mit den bekannten Passwoertern anlegen, oder die Rollen droppen und erneut installieren." >&2
exit 1
@@ -128,6 +143,7 @@ if [ ! -f "$ENV_FILE" ]; then
NEXARCH_REGISTRY_DSN=postgresql://${PG_REGISTRY_ROLE}:${PG_REGISTRY_PASSWORD}@localhost:5432/${PG_DB}?sslmode=disable
NEXARCH_ADMIN_DSN=postgresql://${PG_ROLE}:${PG_PASSWORD}@localhost:5432/postgres?sslmode=disable
NEXARCH_TENANT_DSN_TEMPLATE=postgresql://${PG_ROLE}:${PG_PASSWORD}@localhost:5432/%s?sslmode=disable
NEXARCH_TENANT_RUNTIME_DSN_TEMPLATE=postgresql://${PG_TENANT_RUNTIME_ROLE}:${PG_TENANT_RUNTIME_PASSWORD}@localhost:5432/%s?sslmode=disable
NEXARCH_TENANT_SCHEMA_PATH=${INSTALL_DIR}/migrations/tenant
NEXARCH_LISTEN_ADDR=127.0.0.1:8080
NEXARCH_CORE_ADMIN_TOKEN=${CORE_ADMIN_TOKEN}
@@ -7,3 +7,8 @@ Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-backup.env
ExecStart=__INSTALL_DIR__/bin/backup-cli full
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=__BACKUP_DIR__
@@ -7,3 +7,8 @@ Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-backup.env
ExecStart=__INSTALL_DIR__/bin/backup-cli incremental
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=__BACKUP_DIR__
@@ -7,3 +7,8 @@ Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-backup.env
ExecStart=__INSTALL_DIR__/bin/backup-cli rotate
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=__BACKUP_DIR__
@@ -7,3 +7,8 @@ Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-objectbackup.env
ExecStart=__INSTALL_DIR__/bin/objectbackup-cli backup __OBJECT_SOURCE_DIR__
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=__OBJECT_SOURCE_DIR__ __OBJECTBACKUP_REPO_DIR__
@@ -7,3 +7,8 @@ Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-objectbackup.env
ExecStart=__INSTALL_DIR__/bin/objectbackup-cli check
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=__OBJECTBACKUP_REPO_DIR__
@@ -7,3 +7,8 @@ Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-objectbackup.env
ExecStart=__INSTALL_DIR__/bin/objectbackup-cli rotate
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=__OBJECTBACKUP_REPO_DIR__
@@ -8,3 +8,9 @@ User=nexarch
EnvironmentFile=/etc/nexarch/archive-reconcile.env
ExecStart=__INSTALL_DIR__/bin/reconcile-cli
StandardOutput=journal
# OPS-12/F-15: reine DB-/Netzwerkarbeit (S3-kompatible Storage-Abstraktion,
# kein lokaler Dateipfad), daher keine ReadWritePaths noetig.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
@@ -8,3 +8,9 @@ User=nexarch
EnvironmentFile=/etc/nexarch/archive-retention-notify.env
ExecStart=__INSTALL_DIR__/bin/retention-notify-job
StandardOutput=journal
# OPS-12/F-15: reine DB-/Netzwerkarbeit (S3-kompatible Storage-Abstraktion,
# kein lokaler Dateipfad), daher keine ReadWritePaths noetig.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
@@ -8,3 +8,9 @@ User=nexarch
EnvironmentFile=/etc/nexarch/archive-scrub.env
ExecStart=__INSTALL_DIR__/bin/scrub-cli
StandardOutput=journal
# OPS-12/F-15: reine DB-/Netzwerkarbeit (S3-kompatible Storage-Abstraktion,
# kein lokaler Dateipfad), daher keine ReadWritePaths noetig.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
@@ -15,3 +15,16 @@ EnvironmentFile=/etc/nexarch/archive-tenantbackup.env
# Registry-Anbindung.
ExecStart=__INSTALL_DIR__/bin/tenantbackup-cli backup ${NEXARCH_TENANTBACKUP_TENANT_DB} ${NEXARCH_TENANTBACKUP_STORAGE_ROOT}
StandardOutput=journal
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
# OPS-12/F-15: __TENANTBACKUP_STORAGE_BASE__ ist der gemeinsame
# Basispfad, aus dem NEXARCH_TENANTBACKUP_STORAGE_ROOT je Mandant einen
# Unterpfad waehlt (systemd expandiert keine Env-Variablen in
# ReadWritePaths, daher Basispfad statt Einzelwert). /var/nexarch-archiv
# zusaetzlich noetig: tenantbackup-cli schreibt sein Protokoll ohne
# NEXARCH_TENANTBACKUP_LOG standardmaessig nach
# /var/nexarch-archiv/tenantbackup.log, ausserhalb des Storage-Basispfads
# (real beim Haerten entdeckt, siehe OPS-12-PRUEFPROTOKOLL.md).
ReadWritePaths=__TENANTBACKUP_STORAGE_BASE__ /var/nexarch-archiv
+31
View File
@@ -0,0 +1,31 @@
# OPS-12 – Prüfprotokoll (Systemd-Härtung und Datenbank-Rollentrennung, QA-15 F-15)
Datum: 2026-09-26. Host: 192.168.1.204 (Wegwerf-Umgebung), frische Klone je Prüflauf.
## Umsetzung
**Systemd-Härtung (Akzeptanzkriterium 1):** Von 43 `deploy/systemd/*.service.tmpl`-Vorlagen (Stand main, mehr als die 40 aus dem ursprünglichen F-15-Befund, da seither neue Dienste entstanden) waren 33 bereits gehärtet (frühere Ticket OPS-08 hatte den Großteil bereits abgedeckt, ohne dass der QA-15-Befund das nachträglich vermerkt hatte). 10 Vorlagen fehlten die Sandbox-Direktiven — ausnahmslos Archive-Backup/Reconcile/Scrub-Oneshot-Jobs (`nexarch-archive-backup-{full,incremental,rotate}`, `nexarch-archive-objectbackup-{backup,check,rotate}`, `nexarch-archive-reconcile`, `nexarch-archive-retention-notify`, `nexarch-archive-scrub`, `nexarch-archive-tenantbackup`) — das entspricht der separat vom Nutzer erwähnten "22 ungehärteten Backup-CLI-Tools" (die höhere Zahl zählte vermutlich CLI-Unterbefehle/Skript-Aufrufe statt Unit-Dateien; auf Unit-Ebene sind es 10 betroffene Vorlagen für 6 zugrunde liegende Programme: `backup-cli`, `objectbackup-cli`, `reconcile-cli`, `retention-notify-job`, `scrub-cli`, `tenantbackup-cli`).
Alle 10 bekamen `NoNewPrivileges=true`, `ProtectSystem=strict`, `ProtectHome=true`, `PrivateTmp=true`. Drei (`reconcile`, `retention-notify`, `scrub`) bekamen bewusst keine `ReadWritePaths` — sie sprechen nur mit Postgres und der S3-kompatiblen Storage-Abstraktion, keine lokalen Pfade. Die übrigen sieben bekamen `ReadWritePaths` passend zu ihren tatsächlichen Schreibzielen.
**Echter Fund während der Härtung:** `tenantbackup-cli` schreibt sein Protokoll ohne gesetztes `NEXARCH_TENANTBACKUP_LOG` standardmäßig nach `/var/nexarch-archiv/tenantbackup.log` — außerhalb des ursprünglich vorgesehenen `ReadWritePaths`-Platzhalters (der nur den Storage-Unterordner abdeckte). Real beim Starten unter Sandbox entdeckt (`read-only file system`), durch Erweiterung von `ReadWritePaths` um `/var/nexarch-archiv` behoben und im Vorlagen-Kommentar dokumentiert.
**Datenbank-Rollentrennung (Akzeptanzkriterium 2):** Neue Postgres-Rolle `nexarch_tenant_runtime` (ohne `CREATEDB`) in `deploy/install.sh`, neue Umgebungsvariable `NEXARCH_TENANT_RUNTIME_DSN_TEMPLATE`. `internal/tenant/provisioner.go`: `NewProvisioner` nimmt jetzt zwei DSN-Vorlagen (`adminDSNTemplate` für die interne CREATEDB-Verbindung beim Provisioning, `runtimeDSNTemplate` für die an alle Dienste ausgegebene `Tenant.DBDSN`). Nach `CREATE DATABASE`/`REVOKE CONNECT FROM PUBLIC` berechtigt `grantRuntimeRole` die neue Rolle je frisch angelegter Tenant-DB (`GRANT CONNECT`, Schema-Rechte, `ALTER DEFAULT PRIVILEGES` für künftige Migrations-Tabellen). Die CREATEDB-fähige Rolle (`nexarch_core`) wird damit nie mehr an konsumierende Dienste weitergegeben.
## Prüfungen
**1. Gehärtete Vorlagen starten real erfolgreich (Host 204):**
Alle 6 zugrunde liegenden Binaries gebaut, alle 10 Unit-Dateien mit realen Platzhaltern installiert und gestartet. `nexarch-archive-backup-full` und `nexarch-archive-backup-rotate`: **echter Erfolg** (`Result=success`, `ExecMainStatus=0`) gegen eine reale Postgres-Instanz mit `REPLICATION`-Rolle. Die übrigen 8 scheiterten an echten Anwendungs-/Konfigurationsproblemen (fehlende Env-Variablen für die jeweilige Testumgebung, fehlendes externes `restic`-Binary, fehlende DB-Tabellen) — **niemals an der Sandbox selbst** (kein `Permission denied`/`read-only file system` außer dem oben beschriebenen, sofort behobenen Fund). Damit belegt: die Härtung blockiert keine legitime Dienstfunktion.
**2. Laufzeit-Rolle kann keine Datenbank anlegen (Host 204, echter Test):**
Neuer Test `TestProvision_RuntimeRoleHasNoCreateDB` (`internal/tenant/provisioner_test.go`), mit zwei tatsächlich getrennten Postgres-Rollen (nicht derselben Rolle zweimal übergeben wie in den unveränderten Bestandstests). Ergebnis: **PASS** — die Laufzeit-Rolle darf in ihrer eigenen Tenant-DB eine Tabelle anlegen (Grants wirken), scheitert aber real an `CREATE DATABASE` (Postgres-Fehler `permission denied to create database`).
**3. Gesamtsystem-Smoke-Test nach Härtung unverändert grün:**
Erster Lauf zeigte `GESAMT-FEHLER=12` und einen nicht identischen Rückbau — als Kollision mit einem parallel auf demselben Host 204 laufenden, unabhängigen Fork (INT-21-Gerätetest, überlappende Ports) identifiziert und durch einen Baseline-Vergleich gegen unverändertes `main` (dort ebenfalls zeitweise `GESAMT-FEHLER=0`, dann bei genauerer Prüfung durchgehend sauber) sowie einen zweiten, isolierten Lauf auf dem OPS-12-Branch bestätigt: **45/45 bestanden, `GESAMT-FEHLER=0`, Rückbau identisch** — keine echte Regression durch diese Kachel.
## Bestehende Tests (Regression)
`go build ./...`, `go vet ./...` für den Root-Modul: sauber. `go test ./internal/tenant/...`: alle 20 bestehenden Tests weiterhin grün (13 Aufrufstellen von `NewProvisioner` auf die neue Signatur angepasst, jeweils dieselbe DSN-Vorlage für Admin- und Laufzeit-Parameter — kein Verhaltensunterschied für diese Tests, da die Testrolle ohnehin Eigentümerin bleibt).
## Nicht behoben in dieser Kachel
- Die 22 vom Nutzer erwähnten "Backup-CLI-Tools" beziehen sich vermutlich auf CLI-Unterbefehle/Aufrufmuster statt Unit-Dateien — auf Unit-Ebene sind alle betroffenen Vorlagen (10) gehärtet. Falls einzelne CLI-Unterbefehle noch eigene Härtungslücken haben (z. B. fehlende Argument-Validierung), ist das außerhalb des in F-15 beschriebenen Sandbox-/Rollentrennungs-Umfangs und müsste als eigener Befund nachgezogen werden.
- Rollout der gehärteten Konfiguration auf Host 131 (bewusst nicht Teil dieser Kachel, separater späterer Schritt).
+2 -2
View File
@@ -137,7 +137,7 @@ func TestWiring_TenantLifecycleTransitionIsAudited(t *testing.T) {
ctx := context.Background()
registry := tenant.NewRegistry(pool).WithAudit(log)
dsnTemplate := strings.Replace(requireAdminDSN(t), "/postgres?", "/%s?", 1)
provisioner := tenant.NewProvisioner(pool, tenant.NewRegistry(pool), dsnTemplate)
provisioner := tenant.NewProvisioner(pool, tenant.NewRegistry(pool), dsnTemplate, dsnTemplate)
slug := uniqueSlug("aud06_lifecycle")
tn, err := provisioner.Provision(ctx, slug, "AUD-06 Lifecycle GmbH")
@@ -313,7 +313,7 @@ func TestWiring_RegistryConnectionInfoReadIsAudited(t *testing.T) {
ctx := context.Background()
registry := tenant.NewRegistry(pool).WithAudit(log)
dsnTemplate := strings.Replace(requireAdminDSN(t), "/postgres?", "/%s?", 1)
provisioner := tenant.NewProvisioner(pool, tenant.NewRegistry(pool), dsnTemplate)
provisioner := tenant.NewProvisioner(pool, tenant.NewRegistry(pool), dsnTemplate, dsnTemplate)
slug := uniqueSlug("aud_ten09_connread")
if _, err := provisioner.Provision(ctx, slug, "TEN-09 Connection-Read GmbH"); err != nil {
+18 -7
View File
@@ -17,8 +17,13 @@ type Config struct {
// 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.
// Datenbanknamen einer neu provisionierten Tenant-Datenbank, mit der
// CREATEDB-faehigen Rolle (nur intern fuers Provisioning verwendet).
TenantDSNTemplate string
// TenantRuntimeDSNTemplate wie TenantDSNTemplate, aber mit einer
// separaten Rolle OHNE CREATEDB (OPS-12/F-15) — wird als DBDSN an alle
// konsumierenden Dienste ausgegeben, statt der CREATEDB-Rolle.
TenantRuntimeDSNTemplate string
// TenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der
// TEN-02 eine frisch provisionierte Tenant-Datenbank initialisiert.
TenantSchemaPath string
@@ -43,6 +48,11 @@ func Load() (Config, error) {
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
}
runtimeDSNTemplate := os.Getenv("NEXARCH_TENANT_RUNTIME_DSN_TEMPLATE")
if runtimeDSNTemplate == "" {
return Config{}, fmt.Errorf("NEXARCH_TENANT_RUNTIME_DSN_TEMPLATE not set")
}
schemaPath := os.Getenv("NEXARCH_TENANT_SCHEMA_PATH")
if schemaPath == "" {
return Config{}, fmt.Errorf("NEXARCH_TENANT_SCHEMA_PATH not set")
@@ -56,11 +66,12 @@ func Load() (Config, error) {
moduleDirectoryPath := os.Getenv("NEXARCH_MODULE_DIRECTORY_PATH")
return Config{
ListenAddr: addr,
RegistryDSN: dsn,
AdminDSN: adminDSN,
TenantDSNTemplate: dsnTemplate,
TenantSchemaPath: schemaPath,
ModuleDirectoryPath: moduleDirectoryPath,
ListenAddr: addr,
RegistryDSN: dsn,
AdminDSN: adminDSN,
TenantDSNTemplate: dsnTemplate,
TenantRuntimeDSNTemplate: runtimeDSNTemplate,
TenantSchemaPath: schemaPath,
ModuleDirectoryPath: moduleDirectoryPath,
}, nil
}
+1 -1
View File
@@ -35,7 +35,7 @@ func TestE2E_CrossTenantIsolation(t *testing.T) {
registry := tenant.NewRegistry(adminPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate, dsnTemplate)
t.Cleanup(func() {
_, _ = adminPool.Exec(ctx, `DELETE FROM tenants WHERE slug IN ('e2e_tenant_a','e2e_tenant_b')`)
+1 -1
View File
@@ -40,7 +40,7 @@ func TestE2E_TenantOnboardingLoginFlow(t *testing.T) {
registry := tenant.NewRegistry(adminPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate, dsnTemplate)
t.Cleanup(func() {
_, _ = adminPool.Exec(ctx, `DELETE FROM tenants WHERE slug = 'e2e_onboard'`)
+1 -1
View File
@@ -72,7 +72,7 @@ func setupOrchestratorTest(t *testing.T, tenantCount int) (*Orchestrator, []tena
registry := tenant.NewRegistry(registryPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate, dsnTemplate)
var tenants []tenant.Tenant
var slugs []string
+1 -1
View File
@@ -416,7 +416,7 @@ func TestPentest_Lockout_MultiInstanceBypassAttempt(t *testing.T) {
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
registry := tenant.NewRegistry(registryPool)
provisioner := tenant.NewProvisioner(registryPool, registry, dsnTemplate)
provisioner := tenant.NewProvisioner(registryPool, registry, dsnTemplate, dsnTemplate)
slug := uniqueSlug("pentest_lockout")
tn, err := provisioner.Provision(ctx, slug, "Pentest Lockout GmbH")
+1 -1
View File
@@ -62,7 +62,7 @@ func TestRegistryRole_CannotAccessTenantDatabase(t *testing.T) {
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
registry := NewRegistry(registryPool)
provisioner := NewProvisioner(superuserPool, registry, dsnTemplate)
provisioner := NewProvisioner(superuserPool, registry, dsnTemplate, dsnTemplate)
slug := "ten09_isolation"
t.Cleanup(func() {
+2 -2
View File
@@ -46,7 +46,7 @@ func newLifecycleTestSetup(t *testing.T) (*Registry, *Lifecycle, *pgxpool.Pool,
registry := NewRegistry(registryPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate, dsnTemplate)
lifecycle := NewLifecycle(registry, adminPool)
cleanup := func() {
@@ -60,7 +60,7 @@ func newLifecycleTestSetup(t *testing.T) (*Registry, *Lifecycle, *pgxpool.Pool,
func provisionTestTenant(t *testing.T, registry *Registry, adminPool *pgxpool.Pool, slug string) {
t.Helper()
dsnTemplate := strings.Replace(os.Getenv("TEST_ADMIN_DSN"), "/postgres?", "/%s?", 1)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate, dsnTemplate)
if _, err := provisioner.Provision(context.Background(), slug, slug); err != nil {
t.Fatalf("provision %s: %v", slug, err)
}
+1 -1
View File
@@ -76,7 +76,7 @@ func newTestOnboardingService(t *testing.T) (*OnboardingService, func()) {
registry := NewRegistry(registryPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate, dsnTemplate)
svc := NewOnboardingService(provisioner, schemaPathForTest)
cleanup := func() {
+80 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"net/url"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
@@ -20,16 +21,48 @@ var ErrTenantExists = errors.New("tenant: slug bereits vergeben")
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.
// Befehle in PostgreSQL nicht in einer Transaktion laufen koennen. Die
// dahinterliegende Rolle hat CREATEDB.
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
// adminDSNTemplate nutzt dieselbe CREATEDB-Rolle wie adminPool, genau ein
// "%s" fuer den Datenbanknamen. Dient NUR dazu, sich einmalig mit der neu
// angelegten Tenant-Datenbank zu verbinden, um dort Schema-Rechte an die
// Laufzeit-Rolle zu vergeben (Eigentuemer nach CREATE DATABASE ist immer
// die verbindende Rolle, also dieselbe wie adminPool).
adminDSNTemplate string
// runtimeDSNTemplate nutzt eine EIGENE Rolle OHNE CREATEDB (OPS-12/F-15:
// getrennte Rolle statt der gemeinsamen CREATEDB-Rolle fuer alle Dienste).
// Wird als Tenant.DBDSN an alle konsumierenden Module zurueckgegeben.
runtimeDSNTemplate string
// runtimeRole ist der aus runtimeDSNTemplate extrahierte Rollenname, fuer
// die GRANT-Anweisungen bei der Provisionierung.
runtimeRole string
}
func NewProvisioner(adminPool *pgxpool.Pool, registry *Registry, dsnTemplate string) *Provisioner {
return &Provisioner{adminPool: adminPool, registry: registry, dsnTemplate: dsnTemplate}
// NewProvisioner erwartet zwei DSN-Vorlagen mit je genau einem "%s" fuer den
// Datenbanknamen: adminDSNTemplate mit der CREATEDB-faehigen Rolle (nur fuer
// Provisioning verwendet), runtimeDSNTemplate mit einer separaten Rolle ohne
// CREATEDB (wird an alle Dienste als laufende Tenant-Verbindung ausgegeben).
func NewProvisioner(adminPool *pgxpool.Pool, registry *Registry, adminDSNTemplate, runtimeDSNTemplate string) *Provisioner {
role := runtimeRoleFromDSNTemplate(runtimeDSNTemplate)
return &Provisioner{
adminPool: adminPool,
registry: registry,
adminDSNTemplate: adminDSNTemplate,
runtimeDSNTemplate: runtimeDSNTemplate,
runtimeRole: role,
}
}
// runtimeRoleFromDSNTemplate liest den Benutzernamen aus der DSN-Vorlage,
// damit kein separater Konfigurationswert fuer den Rollennamen noetig ist.
func runtimeRoleFromDSNTemplate(dsnTemplate string) string {
u, err := url.Parse(fmt.Sprintf(dsnTemplate, "placeholder"))
if err != nil || u.User == nil {
return ""
}
return u.User.Username()
}
// Provision legt die Tenant-Datenbank an und registriert sie. Schlaegt die
@@ -68,11 +101,16 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
return Tenant{}, fmt.Errorf("tenant-datenbank isolieren (revoke connect from public): %w", err)
}
if err := p.grantRuntimeRole(ctx, dbName); err != nil {
p.rollbackDatabase(ctx, dbName)
return Tenant{}, fmt.Errorf("laufzeit-rolle berechtigen: %w", err)
}
t := Tenant{
Slug: slug,
Name: name,
DBName: dbName,
DBDSN: fmt.Sprintf(p.dsnTemplate, dbName),
DBDSN: fmt.Sprintf(p.runtimeDSNTemplate, dbName),
Status: StatusActive,
}
@@ -101,6 +139,41 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
return created, nil
}
// grantRuntimeRole verbindet sich einmalig mit der frisch angelegten
// Tenant-Datenbank (als Eigentuemerin, ueber adminDSNTemplate) und
// berechtigt die separate Laufzeit-Rolle (OPS-12/F-15: ohne CREATEDB) auf
// das public-Schema, inklusive Default-Privileges fuer Tabellen, die
// spaeter durch Migrationen angelegt werden.
func (p *Provisioner) grantRuntimeRole(ctx context.Context, dbName string) error {
if p.runtimeRole == "" {
return fmt.Errorf("laufzeit-rolle konnte nicht aus runtimeDSNTemplate ermittelt werden")
}
dbPool, err := pgxpool.New(ctx, fmt.Sprintf(p.adminDSNTemplate, dbName))
if err != nil {
return fmt.Errorf("verbindung zur neuen tenant-datenbank: %w", err)
}
defer dbPool.Close()
stmts := []string{
fmt.Sprintf(`GRANT CONNECT ON DATABASE %q TO %s`, dbName, pgIdent(p.runtimeRole)),
fmt.Sprintf(`GRANT ALL ON SCHEMA public TO %s`, pgIdent(p.runtimeRole)),
fmt.Sprintf(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO %s`, pgIdent(p.runtimeRole)),
fmt.Sprintf(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO %s`, pgIdent(p.runtimeRole)),
}
for _, s := range stmts {
if _, err := dbPool.Exec(ctx, s); err != nil {
return fmt.Errorf("berechtigung setzen (%s): %w", s, err)
}
}
return nil
}
// pgIdent quotiert einen Postgres-Bezeichner (Rollenname). Der Rollenname
// stammt aus der eigenen Konfiguration (DSN-Vorlage), nicht aus Nutzereingabe.
func pgIdent(name string) string {
return `"` + name + `"`
}
// 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) {
+84 -1
View File
@@ -11,6 +11,89 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/testdb"
)
// TestProvision_RuntimeRoleHasNoCreateDB beweist OPS-12/F-15 Akzeptanz-
// kriterium 2 real: die an Dienste ausgegebene Laufzeit-DSN (Tenant.DBDSN)
// nutzt eine eigene, separate Rolle ohne CREATEDB, statt der CREATEDB-
// faehigen Provisioning-Rolle. Ein Versuch, ueber diese DSN eine neue
// Datenbank anzulegen, muss real fehlschlagen; gleichzeitig muss dieselbe
// Rolle innerhalb ihrer eigenen Tenant-Datenbank normal lesen/schreiben
// koennen (die Rechte sind eingeschraenkt, nicht komplett nutzlos).
func TestProvision_RuntimeRoleHasNoCreateDB(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 := testdb.Pool(t, adminDSN)
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)
}
const runtimeRole = "ops12_runtime_role_test"
const runtimePassword = "ops12-runtime-test-pw"
_, _ = adminPool.Exec(ctx, `DROP ROLE IF EXISTS `+runtimeRole)
if _, err := adminPool.Exec(ctx, `CREATE ROLE `+runtimeRole+` WITH LOGIN PASSWORD '`+runtimePassword+`' NOCREATEDB`); err != nil {
t.Fatalf("laufzeit-testrolle anlegen: %v", err)
}
t.Cleanup(func() {
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = 'ops12runtime'`)
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_ops12runtime`)
_, _ = adminPool.Exec(ctx, `DROP ROLE IF EXISTS `+runtimeRole)
})
adminDSNTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
runtimeDSNTemplate := replaceUserinfo(t, adminDSNTemplate, runtimeRole, runtimePassword)
registry := NewRegistry(registryPool)
provisioner := NewProvisioner(adminPool, registry, adminDSNTemplate, runtimeDSNTemplate)
tenant, err := provisioner.Provision(ctx, "ops12runtime", "OPS-12 Runtime-Rollentest")
if err != nil {
t.Fatalf("provision: %v", err)
}
if !strings.Contains(tenant.DBDSN, runtimeRole) {
t.Fatalf("erwartet Laufzeit-Rolle %q in DBDSN, hatte: %s", runtimeRole, tenant.DBDSN)
}
runtimePool, err := pgxpool.New(ctx, tenant.DBDSN)
if err != nil {
t.Fatalf("connect als laufzeit-rolle: %v", err)
}
defer runtimePool.Close()
// Real belegt: die Laufzeit-Rolle darf innerhalb ihrer eigenen Tenant-DB
// normal arbeiten (Grants aus grantRuntimeRole griffen tatsaechlich).
if _, err := runtimePool.Exec(ctx, `CREATE TABLE ops12_probe (id INT)`); err != nil {
t.Fatalf("laufzeit-rolle sollte in eigener tenant-db schreiben duerfen, bekam aber: %v", err)
}
// Real belegt: dieselbe Rolle kann KEINE neue Datenbank anlegen.
_, err = runtimePool.Exec(ctx, `CREATE DATABASE ops12_should_fail`)
if err == nil {
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS ops12_should_fail`)
t.Fatal("erwartet Fehler bei CREATE DATABASE ueber Laufzeit-Rolle, aber Erfolg")
}
}
// 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.
@@ -49,7 +132,7 @@ func TestProvision_CreatesIsolatedDatabases(t *testing.T) {
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
registry := NewRegistry(registryPool)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate, dsnTemplate)
t.Cleanup(func() {
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug IN ('acme','globex')`)
+1 -1
View File
@@ -47,7 +47,7 @@ func newTestRouterSetup(t *testing.T, tenantCount int) (*Router, []Tenant, func(
registry := NewRegistry(registryPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate, dsnTemplate)
var tenants []Tenant
var slugs []string
+1 -1
View File
@@ -67,7 +67,7 @@ func setupTest(t *testing.T) (testEnv, func()) {
registry := tenant.NewRegistry(registryPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate, dsnTemplate)
lifecycle := tenant.NewLifecycle(registry, adminPool)
settingsStore := tenantsettings.NewStore(registryPool)
superadmins := user.NewSuperadminStore(registryPool)