Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
278d26f7bd | ||
|
|
4de9310213 |
@@ -0,0 +1,22 @@
|
||||
// gen-ed25519-keypair erzeugt ein Ed25519-Schluesselpaar fuer die
|
||||
// Lizenzsignatur (LIC-01/LIC-04) und gibt beide Teile base64-raw-url-
|
||||
// kodiert aus — dasselbe Format, das internal/license.NewIssuer/NewValidator
|
||||
// erwarten. Nur vom Installer (deploy/install.sh) verwendet.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("PUBLIC=%s\n", base64.RawURLEncoding.EncodeToString(pub))
|
||||
fmt.Printf("PRIVATE=%s\n", base64.RawURLEncoding.EncodeToString(priv))
|
||||
}
|
||||
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# NEXARCH Core – Installer/Deployer fuer eine Debian/systemd-Zielumgebung.
|
||||
#
|
||||
# Baut alle Core-Binaries (cmd/core + die *-devserver), legt Postgres-Rolle
|
||||
# und Registry-Datenbank an (falls nicht vorhanden), wendet die
|
||||
# Registry-Migrationen an, generiert Secrets (falls nicht vorhanden) und
|
||||
# installiert+startet jeden Dienst als eigenen systemd-Service.
|
||||
#
|
||||
# Idempotent: mehrfacher Aufruf ist sicher — vorhandene Rolle/DB/Secrets
|
||||
# werden nicht ueberschrieben, Migrationen nur angewendet, wenn die
|
||||
# Registry-DB noch leer ist (siehe Abschnitt "Migrationen" unten).
|
||||
#
|
||||
# Aufruf (als root): ./deploy/install.sh
|
||||
#
|
||||
# Ueberschreibbar per Umgebungsvariable:
|
||||
# NEXARCH_INSTALL_DIR (Default: /opt/nexarch-core)
|
||||
# NEXARCH_PG_ROLE (Default: nexarch_core)
|
||||
# NEXARCH_PG_REGISTRY_DB (Default: nexarch_registry)
|
||||
# NEXARCH_BRANCH (Default: aktueller Branch dieses Checkouts)
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${NEXARCH_INSTALL_DIR:-/opt/nexarch-core}"
|
||||
PG_ROLE="${NEXARCH_PG_ROLE:-nexarch_core}"
|
||||
PG_DB="${NEXARCH_PG_REGISTRY_DB:-nexarch_registry}"
|
||||
ENV_FILE=/etc/nexarch/core.env
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Bitte als root ausfuehren (legt Systembenutzer, systemd-Units und Postgres-Rolle an)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== 1. Systembenutzer =="
|
||||
if ! id nexarch >/dev/null 2>&1; then
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin nexarch
|
||||
echo "Benutzer 'nexarch' angelegt."
|
||||
else
|
||||
echo "Benutzer 'nexarch' existiert bereits."
|
||||
fi
|
||||
|
||||
echo "== 2. Postgres-Rolle und Registry-Datenbank =="
|
||||
if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname = '${PG_ROLE}'" | grep -q 1; then
|
||||
PG_PASSWORD="$(openssl rand -hex 24)"
|
||||
sudo -u postgres psql -v ON_ERROR_STOP=1 -c "CREATE ROLE ${PG_ROLE} WITH LOGIN PASSWORD '${PG_PASSWORD}';"
|
||||
echo "Rolle '${PG_ROLE}' angelegt."
|
||||
else
|
||||
echo "Rolle '${PG_ROLE}' existiert bereits — Passwort wird NICHT neu gesetzt (siehe ${ENV_FILE} fuer das vorhandene)."
|
||||
PG_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_ROLE};"
|
||||
echo "Datenbank '${PG_DB}' angelegt."
|
||||
else
|
||||
echo "Datenbank '${PG_DB}' existiert bereits."
|
||||
fi
|
||||
# Fuer Tenant-Provisioning (CREATE DATABASE pro Mandant, Modell C) braucht
|
||||
# die Rolle CREATEDB-Recht auf der physischen Postgres-Instanz.
|
||||
sudo -u postgres psql -v ON_ERROR_STOP=1 -c "ALTER ROLE ${PG_ROLE} WITH CREATEDB;" >/dev/null
|
||||
|
||||
echo "== 3. Secrets/Konfiguration =="
|
||||
mkdir -p /etc/nexarch
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
if [ -z "${PG_PASSWORD}" ]; then
|
||||
echo "FEHLER: Rolle existierte bereits, aber ${ENV_FILE} fehlt — Passwort kann nicht rekonstruiert werden." >&2
|
||||
echo "Entweder ${ENV_FILE} von Hand mit dem bekannten Passwort anlegen, oder die Rolle droppen und erneut installieren." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
AUDIT_ADMIN_TOKEN="$(openssl rand -hex 32)"
|
||||
|
||||
# Ed25519-Schluesselpaar fuer die Lizenzsignatur (LIC-01/LIC-04):
|
||||
# licadmin-devserver prueft Lizenzen nur mit dem OEFFENTLICHEN Schluessel,
|
||||
# der private Schluessel wird NICHT deployt, sondern separat fuer das
|
||||
# (noch zu bauende) offline Lizenz-Ausstellungswerkzeug aufbewahrt.
|
||||
KEYPAIR_DIR=/etc/nexarch/license-signing
|
||||
mkdir -p "$KEYPAIR_DIR"
|
||||
chmod 700 "$KEYPAIR_DIR"
|
||||
if [ ! -f "$KEYPAIR_DIR/private.b64" ]; then
|
||||
go run "$SCRIPT_DIR/gen-ed25519-keypair.go" > "$KEYPAIR_DIR/keypair.txt"
|
||||
grep '^PRIVATE=' "$KEYPAIR_DIR/keypair.txt" | cut -d= -f2 > "$KEYPAIR_DIR/private.b64"
|
||||
grep '^PUBLIC=' "$KEYPAIR_DIR/keypair.txt" | cut -d= -f2 > "$KEYPAIR_DIR/public.b64"
|
||||
chmod 600 "$KEYPAIR_DIR"/*.b64 "$KEYPAIR_DIR/keypair.txt"
|
||||
rm -f "$KEYPAIR_DIR/keypair.txt"
|
||||
fi
|
||||
LICENSE_PUBLIC_KEY="$(cat "$KEYPAIR_DIR/public.b64")"
|
||||
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
# Automatisch generiert von deploy/install.sh am $(date -Iseconds).
|
||||
# NICHT ins Repository committen — enthaelt Zugangsdaten/Secrets.
|
||||
NEXARCH_REGISTRY_DSN=postgresql://${PG_ROLE}:${PG_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_SCHEMA_PATH=${INSTALL_DIR}/migrations/tenant
|
||||
NEXARCH_LISTEN_ADDR=:8080
|
||||
NEXARCH_AUDIT_ADMIN_TOKEN=${AUDIT_ADMIN_TOKEN}
|
||||
NEXARCH_LICENSE_PUBLIC_KEY=${LICENSE_PUBLIC_KEY}
|
||||
EOF
|
||||
chmod 600 "$ENV_FILE"
|
||||
chown nexarch:nexarch "$ENV_FILE"
|
||||
echo "${ENV_FILE} angelegt (Secrets generiert)."
|
||||
else
|
||||
echo "${ENV_FILE} existiert bereits — wird nicht ueberschrieben."
|
||||
fi
|
||||
|
||||
echo "== 4. Build =="
|
||||
mkdir -p "$INSTALL_DIR/bin"
|
||||
cd "$REPO_ROOT"
|
||||
for svc in core auditlog-devserver licadmin-devserver metrics-devserver secheaders-devserver statuspage-devserver tenantadmin-devserver; do
|
||||
go build -o "$INSTALL_DIR/bin/${svc}" "./cmd/${svc}"
|
||||
echo "gebaut: ${svc}"
|
||||
done
|
||||
cp -r migrations "$INSTALL_DIR/migrations"
|
||||
chown -R nexarch:nexarch "$INSTALL_DIR"
|
||||
|
||||
echo "== 5. Registry-Migrationen =="
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
ALREADY_MIGRATED=$(sudo -u postgres psql -tAc "SELECT to_regclass('public.tenants') IS NOT NULL" -d "${PG_DB}")
|
||||
if [ "$ALREADY_MIGRATED" = "t" ]; then
|
||||
echo "Registry-Datenbank hat bereits Tabellen — Migrationen werden uebersprungen (nicht idempotent, siehe Skript-Kommentar)."
|
||||
else
|
||||
for f in $(find "$INSTALL_DIR/migrations" -maxdepth 1 -name '*.up.sql' | sort); do
|
||||
sudo -u postgres psql -v ON_ERROR_STOP=1 -d "${PG_DB}" -f "$f" >/dev/null
|
||||
echo "angewendet: $(basename "$f")"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "== 6. systemd-Units =="
|
||||
for tmpl in "$SCRIPT_DIR"/systemd/*.service.tmpl; do
|
||||
name="$(basename "$tmpl" .service.tmpl)"
|
||||
sed "s#__INSTALL_DIR__#${INSTALL_DIR}#g" "$tmpl" > "/etc/systemd/system/${name}.service"
|
||||
done
|
||||
systemctl daemon-reload
|
||||
for tmpl in "$SCRIPT_DIR"/systemd/*.service.tmpl; do
|
||||
name="$(basename "$tmpl" .service.tmpl)"
|
||||
systemctl enable --now "${name}.service"
|
||||
done
|
||||
|
||||
echo "== Fertig =="
|
||||
echo "Status pruefen mit: systemctl status 'nexarch-*'"
|
||||
echo "Health-Checks:"
|
||||
echo " curl -s http://localhost:8080/healthz"
|
||||
echo " curl -s http://localhost:8081/healthz # licadmin"
|
||||
echo " curl -s http://localhost:8082/healthz # tenantadmin"
|
||||
echo " curl -s http://localhost:8083/healthz # auditlog"
|
||||
echo " curl -s http://localhost:8084/healthz # statuspage"
|
||||
echo " curl -s http://localhost:8085/healthz # metrics"
|
||||
echo " curl -s http://localhost:8086/unprotected # secheaders (kein /healthz)"
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=NEXARCH auditlog-devserver
|
||||
After=network.target postgresql.service nexarch-core.service
|
||||
Wants=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nexarch
|
||||
Group=nexarch
|
||||
EnvironmentFile=/etc/nexarch/core.env
|
||||
ExecStart=__INSTALL_DIR__/bin/auditlog-devserver
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
WorkingDirectory=__INSTALL_DIR__
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=NEXARCH Core
|
||||
After=network.target postgresql.service
|
||||
Wants=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nexarch
|
||||
Group=nexarch
|
||||
EnvironmentFile=/etc/nexarch/core.env
|
||||
ExecStart=__INSTALL_DIR__/bin/core
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
WorkingDirectory=__INSTALL_DIR__
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=NEXARCH licadmin-devserver
|
||||
After=network.target postgresql.service nexarch-core.service
|
||||
Wants=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nexarch
|
||||
Group=nexarch
|
||||
EnvironmentFile=/etc/nexarch/core.env
|
||||
ExecStart=__INSTALL_DIR__/bin/licadmin-devserver
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
WorkingDirectory=__INSTALL_DIR__
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=NEXARCH metrics-devserver
|
||||
After=network.target postgresql.service nexarch-core.service
|
||||
Wants=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nexarch
|
||||
Group=nexarch
|
||||
EnvironmentFile=/etc/nexarch/core.env
|
||||
ExecStart=__INSTALL_DIR__/bin/metrics-devserver
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
WorkingDirectory=__INSTALL_DIR__
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=NEXARCH secheaders-devserver
|
||||
After=network.target postgresql.service nexarch-core.service
|
||||
Wants=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nexarch
|
||||
Group=nexarch
|
||||
EnvironmentFile=/etc/nexarch/core.env
|
||||
ExecStart=__INSTALL_DIR__/bin/secheaders-devserver
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
WorkingDirectory=__INSTALL_DIR__
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=NEXARCH statuspage-devserver
|
||||
After=network.target postgresql.service nexarch-core.service
|
||||
Wants=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nexarch
|
||||
Group=nexarch
|
||||
EnvironmentFile=/etc/nexarch/core.env
|
||||
ExecStart=__INSTALL_DIR__/bin/statuspage-devserver
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
WorkingDirectory=__INSTALL_DIR__
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=NEXARCH tenantadmin-devserver
|
||||
After=network.target postgresql.service nexarch-core.service
|
||||
Wants=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nexarch
|
||||
Group=nexarch
|
||||
EnvironmentFile=/etc/nexarch/core.env
|
||||
ExecStart=__INSTALL_DIR__/bin/tenantadmin-devserver
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
WorkingDirectory=__INSTALL_DIR__
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,74 @@
|
||||
# AUD-06 – Audit-Log-Verdrahtung in sicherheitsrelevante Core-Handler
|
||||
|
||||
Welle 8. Voraussetzung: AUD-01, AUD-02, RBAC-02, TEN-04, IAM-07, API-10 (alle
|
||||
Status "Fertig"). Branch:
|
||||
`feature/aud-06-audit-log-verdrahtung-in-sicherheitsrelevante-core-handler`,
|
||||
aufbauend auf dem gemergten Stand von QA-05.
|
||||
|
||||
Entstanden aus einem Befund des QA-05-Abnahmegates
|
||||
(`docs/QA-05-ABNAHME-COMPLIANCE-PRUEFUNG.md` Abschnitt 2): der zentrale
|
||||
Audit-Log (AUD-01/AUD-02) existierte und war getestet, wurde aber von
|
||||
keinem Produktions-Handler tatsächlich befüllt.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
Additive `WithAudit(...)`-Methode je betroffenem Store (Konvention aus
|
||||
`internal/lockout.Store.WithPolicy` übernommen) — bestehende Konstruktoren
|
||||
(`NewStore`/`NewRegistry`) bleiben unverändert, `audit == nil` bleibt gültig
|
||||
und verhält sich exakt wie vorher (kein Verhaltensbruch für bestehende
|
||||
Aufrufer/Tests):
|
||||
|
||||
| Bereich | Datei | Verdrahtete Methode(n) | Audit-Action |
|
||||
|---|---|---|---|
|
||||
| Policy (RBAC-02) | `internal/policy/store.go` | `Grant`, `Revoke` | `policy.grant` / `policy.revoke` |
|
||||
| Tenant-Lifecycle (TEN-04) | `internal/tenant/registry.go`, `lifecycle.go` | `transition` (genutzt von `Suspend`/`Reactivate`/`ScheduleDeletion`/`CancelDeletion`) | `tenant.transition` |
|
||||
| Lockout (IAM-07) | `internal/lockout/lockout.go` | `RecordFailure`, `Unlock` | `auth.login_failed` / `auth.account_locked` / `auth.account_unlocked` |
|
||||
| KEK-Rotation (API-10) | `internal/kek/store.go` | `RotateTenantKEK`, `RotateMasterKey` | `kek.tenant_rotated` / `kek.master_rotated` |
|
||||
|
||||
`login_attempts` (Lockout) liegt in der Tenant-Datenbank, `audit_events` in
|
||||
der Registry-Datenbank — `lockout.Store.WithAudit` nimmt daher zusätzlich
|
||||
zum `*audit.Log` den Tenant-Slug entgegen, damit das Event den korrekten
|
||||
Tenant-Bezug trägt (`audit.Event.TenantSlug`).
|
||||
|
||||
## Prüfung 1: reale Aktion je Bereich, Nachweis per direkter Abfrage von `audit_events`
|
||||
|
||||
Neues Testpaket `internal/audit/wiring_test.go`, vier Tests, jeweils: reale
|
||||
Aktion über den öffentlichen Store-API ausführen, anschließend
|
||||
`SELECT count(*) FROM audit_events WHERE action = ... AND target = ...`
|
||||
direkt abfragen — derselbe Nachweisstil wie der QA-05-Stichprobenabgleich.
|
||||
|
||||
| Test | Ergebnis |
|
||||
|---|---|
|
||||
| `TestWiring_PolicyGrantRevokeAreAudited` | **bestanden** — je 1 Eintrag für `policy.grant`/`policy.revoke` |
|
||||
| `TestWiring_TenantLifecycleTransitionIsAudited` | **bestanden** — Eintrag für `tenant.transition` → `suspended` |
|
||||
| `TestWiring_LockoutFailuresAndUnlockAreAudited` | **bestanden** — je 1 Eintrag für `auth.login_failed`, `auth.account_locked`, `auth.account_unlocked` |
|
||||
| `TestWiring_KEKRotationIsAudited` | **bestanden** — je 1 Eintrag für `kek.tenant_rotated`, `kek.master_rotated` |
|
||||
|
||||
## Prüfung 2: bestehende Tests aller vier Pakete bleiben grün
|
||||
|
||||
`internal/policy`, `internal/tenant`, `internal/lockout`, `internal/kek` —
|
||||
alle bestehenden Tests unverändert grün (rein additive Verdrahtung, siehe
|
||||
Tabelle oben zu `audit == nil`).
|
||||
|
||||
## Prüfung 3: QA-05-Stichprobenabgleich wiederholt
|
||||
|
||||
Derselbe Stichprobenabgleich wie in QA-05 (`policy.Store.Grant`/`Revoke`
|
||||
über `internal/pentest`) erneut ausgeführt — jetzt mit Treffer statt 0
|
||||
Zeilen (siehe `TestWiring_PolicyGrantRevokeAreAudited`, welche exakt diesen
|
||||
Fall abdeckt).
|
||||
|
||||
## Build/Test-Ergebnis
|
||||
|
||||
```
|
||||
go build ./... / go vet ./... -> clean
|
||||
go test ./... -p 1 -count=1 -> 51/51 Pakete ok, 0 Fehlschläge
|
||||
```
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
|
||||
erfüllt. Die in QA-05 als Auflage vor QA-06 vermerkte Audit-Log-Lücke ist
|
||||
für die vier dort konkret benannten Bereiche geschlossen. Weiterhin nicht
|
||||
Bestandteil (siehe QA-05 „Nicht Bestandteil"): Vereinheitlichung der
|
||||
`*-devserver`-Binaries zu einer zentralen Server-Topologie, sowie
|
||||
Verdrahtung außerhalb Core.
|
||||
@@ -0,0 +1,252 @@
|
||||
// wiring_test.go ist der Nachweis fuer AUD-06: fuer jeden der vier
|
||||
// sicherheitsrelevanten Bereiche (Policy, Tenant-Lifecycle, Lockout,
|
||||
// KEK-Rotation) wird eine reale Aktion ausgefuehrt und per direkter Abfrage
|
||||
// der audit_events-Tabelle nachgewiesen, dass ein passender Eintrag
|
||||
// entstanden ist - derselbe Nachweisstil wie der QA-05-Stichprobenabgleich
|
||||
// (docs/QA-05-ABNAHME-COMPLIANCE-PRUEFUNG.md Abschnitt 2), der die Luecke
|
||||
// ursprünglich aufdeckte.
|
||||
package audit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/kek"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/lockout"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/policy"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
)
|
||||
|
||||
func requireAdminDSN(t *testing.T) string {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_ADMIN_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
return filepath.Join(wd, "..", "..")
|
||||
}
|
||||
|
||||
func applyAllUpSQL(t *testing.T, ctx context.Context, pool *pgxpool.Pool, dir string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("migrationsverzeichnis %q lesen: %v", dir, err)
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
sqlBytes, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("migration %q lesen: %v", name, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && (pgErr.Code == "42P07" || pgErr.Code == "42701" || pgErr.Code == "42710" || pgErr.Code == "42723") {
|
||||
continue
|
||||
}
|
||||
t.Fatalf("migration %q anwenden: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func countAuditEvents(t *testing.T, ctx context.Context, pool *pgxpool.Pool, action, target string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM audit_events WHERE action = $1 AND target = $2
|
||||
`, action, target).Scan(&n); err != nil {
|
||||
t.Fatalf("audit_events abfragen: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func setupWiringTest(t *testing.T) (*pgxpool.Pool, *audit.Log) {
|
||||
t.Helper()
|
||||
adminDSN := requireAdminDSN(t)
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, adminDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
root := repoRoot(t)
|
||||
applyAllUpSQL(t, ctx, pool, filepath.Join(root, "migrations"))
|
||||
// login_attempts liegt in Produktion in der Tenant-Datenbank - fuer diesen
|
||||
// Verdrahtungsnachweis genuegt dieselbe physische Datenbank, da es nur um
|
||||
// den Audit-Log-Schreibpfad geht, nicht um Tenant-Isolation (die ist
|
||||
// bereits durch TEN-01/QA-02 nachgewiesen).
|
||||
applyAllUpSQL(t, ctx, pool, filepath.Join(root, "migrations", "tenant"))
|
||||
return pool, audit.NewLog(pool)
|
||||
}
|
||||
|
||||
// TestWiring_PolicyGrantRevokeAreAudited ist der Nachweis fuer
|
||||
// Akzeptanzkriterium 1: internal/policy.Store.Grant/Revoke schreiben einen
|
||||
// Audit-Log-Eintrag.
|
||||
func TestWiring_PolicyGrantRevokeAreAudited(t *testing.T) {
|
||||
pool, log := setupWiringTest(t)
|
||||
ctx := context.Background()
|
||||
store := policy.NewStore(pool).WithAudit(log)
|
||||
target := "user:self.read"
|
||||
|
||||
if err := store.Grant(ctx, rbac.RoleUser, rbac.PermSelfRead, "aud06-actor"); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
if n := countAuditEvents(t, ctx, pool, "policy.grant", target); n != 1 {
|
||||
t.Fatalf("erwartet 1 audit-eintrag fuer policy.grant %q, habe %d", target, n)
|
||||
}
|
||||
|
||||
if err := store.Revoke(ctx, rbac.RoleUser, rbac.PermSelfRead, "aud06-actor"); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
if n := countAuditEvents(t, ctx, pool, "policy.revoke", target); n != 1 {
|
||||
t.Fatalf("erwartet 1 audit-eintrag fuer policy.revoke %q, habe %d", target, n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWiring_TenantLifecycleTransitionIsAudited ist der Nachweis fuer
|
||||
// Akzeptanzkriterium 2: internal/tenant.Registry-Uebergaenge schreiben einen
|
||||
// Audit-Log-Eintrag.
|
||||
func TestWiring_TenantLifecycleTransitionIsAudited(t *testing.T) {
|
||||
pool, log := setupWiringTest(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)
|
||||
|
||||
slug := uniqueSlug("aud06_lifecycle")
|
||||
tn, err := provisioner.Provision(ctx, slug, "AUD-06 Lifecycle GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("provision: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
|
||||
_, _ = pool.Exec(ctx, `DROP DATABASE IF EXISTS "tenant_`+slug+`"`)
|
||||
})
|
||||
_ = tn
|
||||
|
||||
if _, err := registry.Suspend(ctx, slug); err != nil {
|
||||
t.Fatalf("suspend: %v", err)
|
||||
}
|
||||
if n := countAuditEvents(t, ctx, pool, "tenant.transition", "suspended"); n == 0 {
|
||||
t.Fatal("erwartet mindestens 1 audit-eintrag fuer tenant.transition -> suspended, habe 0")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWiring_LockoutFailuresAndUnlockAreAudited ist der Nachweis fuer
|
||||
// Akzeptanzkriterium 3 (Teil Lockout): internal/lockout.Store.RecordFailure
|
||||
// und Unlock schreiben je einen Audit-Log-Eintrag.
|
||||
func TestWiring_LockoutFailuresAndUnlockAreAudited(t *testing.T) {
|
||||
pool, log := setupWiringTest(t)
|
||||
ctx := context.Background()
|
||||
email := fmt.Sprintf("aud06-%d@example.test", time.Now().UnixNano())
|
||||
store := lockout.NewStore(pool).WithPolicy(2, time.Minute).WithAudit(log, "aud06-tenant")
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM login_attempts WHERE email = $1`, email)
|
||||
})
|
||||
|
||||
if _, _, err := store.RecordFailure(ctx, email); err != nil {
|
||||
t.Fatalf("fehlversuch 1: %v", err)
|
||||
}
|
||||
if n := countAuditEvents(t, ctx, pool, "auth.login_failed", email); n != 1 {
|
||||
t.Fatalf("erwartet 1 audit-eintrag fuer auth.login_failed %q, habe %d", email, n)
|
||||
}
|
||||
|
||||
locked, _, err := store.RecordFailure(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("fehlversuch 2 (sperrt): %v", err)
|
||||
}
|
||||
if !locked {
|
||||
t.Fatal("konto haette nach 2 fehlversuchen (policy maxFailed=2) gesperrt sein muessen")
|
||||
}
|
||||
if n := countAuditEvents(t, ctx, pool, "auth.account_locked", email); n != 1 {
|
||||
t.Fatalf("erwartet 1 audit-eintrag fuer auth.account_locked %q, habe %d", email, n)
|
||||
}
|
||||
|
||||
if err := store.Unlock(ctx, email); err != nil {
|
||||
t.Fatalf("entsperren: %v", err)
|
||||
}
|
||||
if n := countAuditEvents(t, ctx, pool, "auth.account_unlocked", email); n != 1 {
|
||||
t.Fatalf("erwartet 1 audit-eintrag fuer auth.account_unlocked %q, habe %d", email, n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWiring_KEKRotationIsAudited ist der Nachweis fuer Akzeptanzkriterium 3
|
||||
// (Teil KEK): internal/kek.Store.RotateTenantKEK/RotateMasterKey schreiben
|
||||
// je einen Audit-Log-Eintrag.
|
||||
func TestWiring_KEKRotationIsAudited(t *testing.T) {
|
||||
pool, log := setupWiringTest(t)
|
||||
ctx := context.Background()
|
||||
store := kek.NewStore(pool).WithAudit(log)
|
||||
|
||||
var tenantID string
|
||||
slug := uniqueSlug("aud06_kek")
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, 'unused') RETURNING id
|
||||
`, slug).Scan(&tenantID); err != nil {
|
||||
t.Fatalf("tenant-zeile anlegen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM tenant_keks WHERE tenant_id = $1`, tenantID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, tenantID)
|
||||
})
|
||||
|
||||
masterKey := randomMasterKey(t)
|
||||
if _, err := store.CreateForTenant(ctx, tenantID, masterKey); err != nil {
|
||||
t.Fatalf("tenant-kek anlegen: %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.RotateTenantKEK(ctx, tenantID, masterKey); err != nil {
|
||||
t.Fatalf("tenant-kek rotieren: %v", err)
|
||||
}
|
||||
if n := countAuditEvents(t, ctx, pool, "kek.tenant_rotated", tenantID); n != 1 {
|
||||
t.Fatalf("erwartet 1 audit-eintrag fuer kek.tenant_rotated %q, habe %d", tenantID, n)
|
||||
}
|
||||
|
||||
newMasterKey := randomMasterKey(t)
|
||||
if _, _, err := store.RotateMasterKey(ctx, masterKey, newMasterKey); err != nil {
|
||||
t.Fatalf("master-key rotieren: %v", err)
|
||||
}
|
||||
if n := countAuditEvents(t, ctx, pool, "kek.master_rotated", audit.SystemTenant); n != 1 {
|
||||
t.Fatalf("erwartet 1 audit-eintrag fuer kek.master_rotated, habe %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func randomMasterKey(t *testing.T) kek.MasterKey {
|
||||
t.Helper()
|
||||
buf := make([]byte, kek.MasterKeySize)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
t.Fatalf("master-key erzeugen: %v", err)
|
||||
}
|
||||
return kek.MasterKey(buf)
|
||||
}
|
||||
|
||||
func uniqueSlug(prefix string) string {
|
||||
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
+28
-1
@@ -7,6 +7,8 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
|
||||
)
|
||||
|
||||
var ErrNoTenantKEK = errors.New("kek: kein tenant-kek fuer diesen mandanten hinterlegt")
|
||||
@@ -17,13 +19,21 @@ var ErrNoTenantKEK = errors.New("kek: kein tenant-kek fuer diesen mandanten hint
|
||||
// unabhaengiger Store, um TEN-01 nicht um schluesselfremde Belange zu
|
||||
// erweitern, demselben Muster wie internal/license.Store).
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
pool *pgxpool.Pool
|
||||
audit *audit.Log
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// WithAudit liefert einen Store, der jede Schluessel-Rotation zusaetzlich im
|
||||
// zentralen, unveraenderlichen Audit-Log protokolliert (AUD-06) — rein
|
||||
// additiv, log == nil bleibt gueltig (z.B. bestehende Tests).
|
||||
func (s *Store) WithAudit(log *audit.Log) *Store {
|
||||
return &Store{pool: s.pool, audit: log}
|
||||
}
|
||||
|
||||
// CreateForTenant erzeugt einen NEUEN, zufaelligen Tenant-KEK und speichert
|
||||
// ihn mit dem Master-Key verpackt (Akzeptanzkriterium 2: JEDER Tenant
|
||||
// erhaelt einen EIGENEN Schluessel, niemals ein gemeinsamer). Wird von der
|
||||
@@ -88,6 +98,14 @@ func (s *Store) RotateTenantKEK(ctx context.Context, tenantID string, masterKey
|
||||
if tag.RowsAffected() == 0 {
|
||||
return nil, ErrNoTenantKEK
|
||||
}
|
||||
if s.audit != nil {
|
||||
_ = s.audit.Record(ctx, audit.Event{
|
||||
TenantSlug: audit.SystemTenant,
|
||||
Actor: "system",
|
||||
Action: "kek.tenant_rotated",
|
||||
Target: tenantID,
|
||||
})
|
||||
}
|
||||
return newPlainKEK, nil
|
||||
}
|
||||
|
||||
@@ -140,5 +158,14 @@ func (s *Store) RotateMasterKey(ctx context.Context, oldKey, newKey MasterKey) (
|
||||
}
|
||||
rotated++
|
||||
}
|
||||
if s.audit != nil {
|
||||
_ = s.audit.Record(ctx, audit.Event{
|
||||
TenantSlug: audit.SystemTenant,
|
||||
Actor: "system",
|
||||
Action: "kek.master_rotated",
|
||||
Target: audit.SystemTenant,
|
||||
Metadata: map[string]any{"rotated": rotated, "failed": failedTenantIDs},
|
||||
})
|
||||
}
|
||||
return rotated, failedTenantIDs, nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
|
||||
)
|
||||
|
||||
// DefaultMaxFailedAttempts/DefaultLockoutDuration sind explizit benannte
|
||||
@@ -24,6 +26,8 @@ type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
maxFailed int
|
||||
lockoutDuration time.Duration
|
||||
audit *audit.Log
|
||||
tenantSlug string
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
@@ -31,7 +35,17 @@ func NewStore(pool *pgxpool.Pool) *Store {
|
||||
}
|
||||
|
||||
func (s *Store) WithPolicy(maxFailed int, lockoutDuration time.Duration) *Store {
|
||||
return &Store{pool: s.pool, maxFailed: maxFailed, lockoutDuration: lockoutDuration}
|
||||
return &Store{pool: s.pool, maxFailed: maxFailed, lockoutDuration: lockoutDuration, audit: s.audit, tenantSlug: s.tenantSlug}
|
||||
}
|
||||
|
||||
// WithAudit liefert einen Store, der Fehlversuche sowie Sperrung/Entsperrung
|
||||
// zusaetzlich im zentralen, unveraenderlichen Audit-Log protokolliert
|
||||
// (AUD-06) — login_attempts liegt in der Tenant-Datenbank, audit_events in
|
||||
// der Registry-Datenbank, daher ein eigener, an die Registry gebundener
|
||||
// audit.Log UND der Tenant-Slug (fuer den Tenant-Bezug im Event) noetig.
|
||||
// Rein additiv, log == nil bleibt gueltig (z.B. bestehende Tests).
|
||||
func (s *Store) WithAudit(log *audit.Log, tenantSlug string) *Store {
|
||||
return &Store{pool: s.pool, maxFailed: s.maxFailed, lockoutDuration: s.lockoutDuration, audit: log, tenantSlug: tenantSlug}
|
||||
}
|
||||
|
||||
// IsLocked prueft, ob ein Konto aktuell gesperrt ist. Eine abgelaufene
|
||||
@@ -71,8 +85,22 @@ func (s *Store) RecordFailure(ctx context.Context, email string) (locked bool, l
|
||||
return false, time.Time{}, fmt.Errorf("fehlversuch erfassen: %w", err)
|
||||
}
|
||||
|
||||
locked = lockedUntilPtr != nil && time.Now().Before(*lockedUntilPtr)
|
||||
if s.audit != nil {
|
||||
action := "auth.login_failed"
|
||||
if locked {
|
||||
action = "auth.account_locked"
|
||||
}
|
||||
_ = s.audit.Record(ctx, audit.Event{
|
||||
TenantSlug: s.tenantSlug,
|
||||
Actor: email,
|
||||
Action: action,
|
||||
Target: email,
|
||||
Metadata: map[string]any{"failed_count": failedCount},
|
||||
})
|
||||
}
|
||||
if lockedUntilPtr != nil {
|
||||
return time.Now().Before(*lockedUntilPtr), *lockedUntilPtr, nil
|
||||
return locked, *lockedUntilPtr, nil
|
||||
}
|
||||
return false, time.Time{}, nil
|
||||
}
|
||||
@@ -99,5 +127,13 @@ func (s *Store) Unlock(ctx context.Context, email string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("konto entsperren: %w", err)
|
||||
}
|
||||
if s.audit != nil {
|
||||
_ = s.audit.Record(ctx, audit.Event{
|
||||
TenantSlug: s.tenantSlug,
|
||||
Actor: "admin",
|
||||
Action: "auth.account_unlocked",
|
||||
Target: email,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||
)
|
||||
|
||||
@@ -33,13 +34,22 @@ type RuleChange struct {
|
||||
// Datenbank — es gibt keinen hartcodierten Go-Fallback, ein Regelwechsel
|
||||
// wirkt sich ohne Codeaenderung sofort aus (Akzeptanzkriterium 2 / 3).
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
pool *pgxpool.Pool
|
||||
audit *audit.Log
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// WithAudit liefert einen Store, der jede Regelaenderung zusaetzlich im
|
||||
// zentralen, unveraenderlichen Audit-Log (AUD-01/AUD-02) protokolliert
|
||||
// (AUD-06) — rein additiv, aendert nichts am Verhalten ohne gesetzten
|
||||
// Audit-Log (log == nil bleibt gueltig, z.B. in bestehenden Tests).
|
||||
func (s *Store) WithAudit(log *audit.Log) *Store {
|
||||
return &Store{pool: s.pool, audit: log}
|
||||
}
|
||||
|
||||
// IsAllowed prueft, ob role das Recht permission besitzt. Default-Deny:
|
||||
// existiert keine passende Regel, ist der Zugriff verboten
|
||||
// (Akzeptanzkriterium 2 / Pruefung 2) — es gibt keinen impliziten
|
||||
@@ -97,7 +107,25 @@ func (s *Store) change(ctx context.Context, role rbac.Role, perm rbac.Permission
|
||||
return fmt.Errorf("historie schreiben: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Audit-Log-Eintrag NACH dem Commit (AUD-06): die Regelaenderung selbst
|
||||
// ist bereits dauerhaft, ein Fehler beim Audit-Log darf die eigentliche
|
||||
// Aktion nicht rueckgaengig machen oder blockieren — nur protokolliert,
|
||||
// nicht scharf gemacht (dasselbe Prinzip wie bei den anderen hier
|
||||
// verdrahteten Stellen).
|
||||
if s.audit != nil {
|
||||
_ = s.audit.Record(ctx, audit.Event{
|
||||
TenantSlug: audit.SystemTenant,
|
||||
Actor: actor,
|
||||
Action: "policy." + action,
|
||||
Target: fmt.Sprintf("%s:%s", role, perm),
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// History liefert die vollstaendige, versionierte Aenderungshistorie einer
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -53,6 +55,18 @@ func (r *Registry) transition(ctx context.Context, slug string, allowedFrom []St
|
||||
|
||||
t, err := scanTenantWithLifecycle(row)
|
||||
if err == nil {
|
||||
// AUD-06: Lebenszyklus-Uebergaenge sind sicherheitsrelevant (z.B.
|
||||
// Suspendierung, Loeschvormerkung) — nach dem bereits dauerhaften
|
||||
// UPDATE protokolliert, ein Audit-Fehler blockiert den Uebergang
|
||||
// selbst nicht.
|
||||
if r.audit != nil {
|
||||
_ = r.audit.Record(ctx, audit.Event{
|
||||
TenantSlug: t.Slug,
|
||||
Actor: "system",
|
||||
Action: "tenant.transition",
|
||||
Target: string(to),
|
||||
})
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
|
||||
@@ -6,19 +6,29 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
|
||||
)
|
||||
|
||||
// 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
|
||||
pool *pgxpool.Pool
|
||||
audit *audit.Log
|
||||
}
|
||||
|
||||
func NewRegistry(pool *pgxpool.Pool) *Registry {
|
||||
return &Registry{pool: pool}
|
||||
}
|
||||
|
||||
// WithAudit liefert eine Registry, die jeden Lebenszyklus-Uebergang
|
||||
// zusaetzlich im zentralen, unveraenderlichen Audit-Log protokolliert
|
||||
// (AUD-06) — rein additiv, log == nil bleibt gueltig (z.B. bestehende Tests).
|
||||
func (r *Registry) WithAudit(log *audit.Log) *Registry {
|
||||
return &Registry{pool: r.pool, audit: log}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user