OPS-12: systemd haertung und db rollentrennung (F-15)
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s

10 bisher ungehaertete Archive-Backup/Reconcile/Scrub-Oneshot-Vorlagen
(deploy/systemd/) bekommen die etablierten Sandbox-Direktiven
(NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, ggf.
ReadWritePaths). Drei Vorlagen ohne Dateisystemzugriff (reconcile,
retention-notify, scrub) bekommen bewusst keine ReadWritePaths,
begruendet dokumentiert (S3-kompatible Storage-Abstraktion statt
lokaler Pfade).

Neue, getrennte Postgres-Laufzeit-Rolle (nexarch_tenant_runtime, OHNE
CREATEDB) statt der bisher gemeinsam genutzten CREATEDB-Rolle fuer
NEXARCH_TENANT_DSN_TEMPLATE. Provisioner.Provision() berechtigt die
neue Rolle je frisch angelegter Tenant-DB (GRANT CONNECT + Schema-
Rechte inkl. Default-Privileges), Tenant.DBDSN wird jetzt mit dieser
Rolle statt der CREATEDB-Rolle gebaut. Neue Umgebungsvariable
NEXARCH_TENANT_RUNTIME_DSN_TEMPLATE, deploy/install.sh legt die Rolle
automatisch an.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-26 22:34:51 +02:00
1 parent c3c9034650
commit 02038866a9
14 files changed
+172 -15

No files matched your search

+1 -1
View File
@@ -43,7 +43,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,12 @@ 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).
ReadWritePaths=__TENANTBACKUP_STORAGE_BASE__
+17 -6
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
@@ -40,6 +45,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")
@@ -51,10 +61,11 @@ func Load() (Config, error) {
}
return Config{
ListenAddr: addr,
RegistryDSN: dsn,
AdminDSN: adminDSN,
TenantDSNTemplate: dsnTemplate,
TenantSchemaPath: schemaPath,
ListenAddr: addr,
RegistryDSN: dsn,
AdminDSN: adminDSN,
TenantDSNTemplate: dsnTemplate,
TenantRuntimeDSNTemplate: runtimeDSNTemplate,
TenantSchemaPath: schemaPath,
}, nil
}
+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) {