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:
commit
d5b526db0e
26 files changed
+304
-29
No files matched your search
+1
-1
@@ -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
@@ -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
|
||||
@@ -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).
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')`)
|
||||
|
||||
@@ -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'`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user