Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45bc10719a |
@@ -1,96 +0,0 @@
|
|||||||
// Package audit implementiert Core AUD-01: das zentrale, vom allgemeinen
|
|
||||||
// Anwendungs-Log getrennte Audit-Datenmodell fuer sicherheits- und
|
|
||||||
// compliancerelevante Ereignisse (wer, was, wann, an welchem Tenant).
|
|
||||||
// Unveraenderlichkeit (Append-only) ist AUD-02, Export/Filter-API ist AUD-03
|
|
||||||
// — dieses Paket liefert nur das Datenmodell und den EINEN zentralen
|
|
||||||
// Schreibpfad (Akzeptanzkriterium 3).
|
|
||||||
package audit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SystemTenant ist der reservierte Tenant-Bezug fuer mandantenuebergreifende
|
|
||||||
// Ereignisse (z.B. Superadmin-Aktionen) — es gibt bewusst KEINEN Weg, ein
|
|
||||||
// Ereignis ganz ohne Tenant-Bezug zu schreiben (Akzeptanzkriterium 2).
|
|
||||||
const SystemTenant = "system"
|
|
||||||
|
|
||||||
var ErrMissingTenant = errors.New("audit: tenant_slug darf nicht leer sein")
|
|
||||||
var ErrMissingActor = errors.New("audit: actor darf nicht leer sein")
|
|
||||||
var ErrMissingAction = errors.New("audit: action darf nicht leer sein")
|
|
||||||
|
|
||||||
// Event ist ein strukturiertes Audit-Ereignis (Akzeptanzkriterium 1: Akteur,
|
|
||||||
// Aktion, Zielobjekt, Zeitpunkt, Tenant).
|
|
||||||
type Event struct {
|
|
||||||
TenantSlug string
|
|
||||||
Actor string
|
|
||||||
Action string
|
|
||||||
Target string
|
|
||||||
Metadata map[string]any
|
|
||||||
OccurredAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log ist der EINE zentrale Schreibpfad fuer Audit-Ereignisse — es gibt
|
|
||||||
// bewusst keine zweite Schreibmoeglichkeit, damit kein Handler versehentlich
|
|
||||||
// direkt in audit_events schreibt und dabei die Validierung umgeht
|
|
||||||
// (Akzeptanzkriterium 3).
|
|
||||||
type Log struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewLog(pool *pgxpool.Pool) *Log {
|
|
||||||
return &Log{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record persistiert genau einen Audit-Eintrag. Fehlender Tenant-Bezug wird
|
|
||||||
// bereits hier abgewiesen (klarer Fehler statt Constraint-Verletzung im
|
|
||||||
// Normalfall) — die Datenbank-CHECK-Constraint aus der Migration ist die
|
|
||||||
// zweite, unumgehbare Verteidigungslinie (Akzeptanzkriterium 2 / Pruefung 2).
|
|
||||||
func (l *Log) Record(ctx context.Context, e Event) error {
|
|
||||||
if e.TenantSlug == "" {
|
|
||||||
return ErrMissingTenant
|
|
||||||
}
|
|
||||||
if e.Actor == "" {
|
|
||||||
return ErrMissingActor
|
|
||||||
}
|
|
||||||
if e.Action == "" {
|
|
||||||
return ErrMissingAction
|
|
||||||
}
|
|
||||||
if e.Metadata == nil {
|
|
||||||
e.Metadata = map[string]any{}
|
|
||||||
}
|
|
||||||
metadataJSON, err := json.Marshal(e.Metadata)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("metadaten serialisieren: %w", err)
|
|
||||||
}
|
|
||||||
if e.OccurredAt.IsZero() {
|
|
||||||
e.OccurredAt = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = l.pool.Exec(ctx, `
|
|
||||||
INSERT INTO audit_events (occurred_at, tenant_slug, actor, action, target, metadata)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
`, e.OccurredAt, e.TenantSlug, e.Actor, e.Action, e.Target, metadataJSON)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("audit-ereignis schreiben: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountByTenant ist eine schlanke Lesehilfe fuer Tests/Diagnose — die
|
|
||||||
// eigentliche Filter-/Export-API ist AUD-03, hier bewusst nicht vorgezogen.
|
|
||||||
func (l *Log) CountByTenant(ctx context.Context, tenantSlug string) (int, error) {
|
|
||||||
var n int
|
|
||||||
if err := l.pool.QueryRow(ctx, `
|
|
||||||
SELECT count(*) FROM audit_events WHERE tenant_slug = $1
|
|
||||||
`, tenantSlug).Scan(&n); err != nil {
|
|
||||||
return 0, fmt.Errorf("audit-ereignisse zaehlen: %w", err)
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
package audit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupAuditTest(t *testing.T) (*Log, *pgxpool.Pool, func()) {
|
|
||||||
t.Helper()
|
|
||||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
||||||
if adminDSN == "" {
|
|
||||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
pool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("pool: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := pool.Exec(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_events (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
|
|
||||||
actor TEXT NOT NULL CHECK (actor <> ''),
|
|
||||||
action TEXT NOT NULL CHECK (action <> ''),
|
|
||||||
target TEXT NOT NULL,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
|
||||||
)`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_events WHERE tenant_slug LIKE 'test\_%' ESCAPE '\' OR tenant_slug = $1`, SystemTenant)
|
|
||||||
pool.Close()
|
|
||||||
}
|
|
||||||
return NewLog(pool), pool, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 1: ein sicherheitsrelevanter Vorgang
|
|
||||||
// (hier: fehlgeschlagener Login) erzeugt zuverlaessig genau einen Eintrag.
|
|
||||||
func TestRecord_PersistsExactlyOneEventPerSecurityIncident(t *testing.T) {
|
|
||||||
log, pool, cleanup := setupAuditTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := log.Record(ctx, Event{
|
|
||||||
TenantSlug: "test_acme",
|
|
||||||
Actor: "alice@example.com",
|
|
||||||
Action: "iam.login_failed",
|
|
||||||
Target: "user:alice@example.com",
|
|
||||||
Metadata: map[string]any{"reason": "falsches passwort"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("record: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
count, err := log.CountByTenant(ctx, "test_acme")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("count: %v", err)
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
t.Fatalf("erwartet genau 1 audit-eintrag, habe %d", count)
|
|
||||||
}
|
|
||||||
|
|
||||||
var actor, action, target string
|
|
||||||
if err := pool.QueryRow(ctx, `
|
|
||||||
SELECT actor, action, target FROM audit_events WHERE tenant_slug = 'test_acme'
|
|
||||||
`).Scan(&actor, &action, &target); err != nil {
|
|
||||||
t.Fatalf("eintrag lesen: %v", err)
|
|
||||||
}
|
|
||||||
if actor != "alice@example.com" || action != "iam.login_failed" || target != "user:alice@example.com" {
|
|
||||||
t.Fatalf("eintrag unerwartet: actor=%q action=%q target=%q", actor, action, target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 2 (App-Ebene): fehlender Tenant-Bezug wird
|
|
||||||
// bereits vom zentralen Schreibpfad abgewiesen.
|
|
||||||
func TestRecord_RejectsMissingTenant(t *testing.T) {
|
|
||||||
log, _, cleanup := setupAuditTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := log.Record(ctx, Event{TenantSlug: "", Actor: "alice", Action: "irgendwas"})
|
|
||||||
if !errors.Is(err, ErrMissingTenant) {
|
|
||||||
t.Fatalf("erwartet ErrMissingTenant, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 2 (DB-Ebene): selbst ein direkter INSERT,
|
|
||||||
// der Log.Record umgeht, wird durch die CHECK-Constraint verhindert — der
|
|
||||||
// Schutz haengt nicht allein von der Go-Validierung ab.
|
|
||||||
func TestConstraint_RejectsMissingTenantAtDatabaseLevel(t *testing.T) {
|
|
||||||
_, pool, cleanup := setupAuditTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
_, err := pool.Exec(ctx, `
|
|
||||||
INSERT INTO audit_events (tenant_slug, actor, action, target)
|
|
||||||
VALUES ('', 'alice', 'irgendwas', 'ziel')
|
|
||||||
`)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("erwartet fehler durch CHECK-constraint bei leerem tenant_slug, habe nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecord_RejectsMissingActorAndAction(t *testing.T) {
|
|
||||||
log, _, cleanup := setupAuditTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "", Action: "x"}); !errors.Is(err, ErrMissingActor) {
|
|
||||||
t.Fatalf("erwartet ErrMissingActor, habe %v", err)
|
|
||||||
}
|
|
||||||
if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "alice", Action: ""}); !errors.Is(err, ErrMissingAction) {
|
|
||||||
t.Fatalf("erwartet ErrMissingAction, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecord_SystemTenantForCrossTenantEvents(t *testing.T) {
|
|
||||||
log, _, cleanup := setupAuditTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if err := log.Record(ctx, Event{TenantSlug: SystemTenant, Actor: "superadmin", Action: "tenant.provisioned", Target: "tenant:acme"}); err != nil {
|
|
||||||
t.Fatalf("record mit SystemTenant: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package audit
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
// AuditLogObjectType ist der Objekttyp-Bezeichner, unter dem Audit-Log-
|
|
||||||
// Eintraege bei der Archive-Retention-Engine registriert werden
|
|
||||||
// (Akzeptanzkriterium 1).
|
|
||||||
const AuditLogObjectType = "audit_log_entry"
|
|
||||||
|
|
||||||
// DefaultAuditRetentionYears ist die GoBD-Buchungsbeleg-Frist (Akzeptanz-
|
|
||||||
// kriterium 2) — Default, pro Tenant ueberschreibbar sofern rechtlich
|
|
||||||
// zulaessig. Die eigentliche Ueberschreibung/Durchsetzung liegt vollstaendig
|
|
||||||
// bei Archive, siehe AuditRetentionTenantOverridable.
|
|
||||||
const DefaultAuditRetentionYears = 10
|
|
||||||
|
|
||||||
// AuditRetentionTenantOverridable erlaubt Archive, die Default-Frist pro
|
|
||||||
// Tenant zu ueberschreiben — Core trifft dabei keine rechtliche Entscheidung,
|
|
||||||
// sondern erlaubt Archive lediglich, so eine Entscheidung zuzulassen.
|
|
||||||
const AuditRetentionTenantOverridable = true
|
|
||||||
|
|
||||||
// RetentionRegistrar ist der Modul-Adapter-Vertrag aus Archive RET-05, wie
|
|
||||||
// Core ihn konsumiert. Die tatsaechliche Implementierung lebt im
|
|
||||||
// Archive-Modul (RET-01/RET-02/RET-05) und existiert zum Zeitpunkt dieser
|
|
||||||
// Kachel noch nicht als Code — Core kennt nur diese Schnittstelle.
|
|
||||||
//
|
|
||||||
// WICHTIG: Core implementiert absichtlich KEINE eigene Loeschlogik fuer
|
|
||||||
// Audit-Eintraege (Akzeptanzkriterium 3). Dieses Paket enthaelt keinen
|
|
||||||
// Delete-Codepfad fuer audit_events ausser dem durch AUD-02 technisch
|
|
||||||
// unterbundenen — die tatsaechliche Loeschung/Aufbewahrungssperre erfolgt
|
|
||||||
// ausschliesslich innerhalb von Archive, ausserhalb dieses Prozesses.
|
|
||||||
type RetentionRegistrar interface {
|
|
||||||
RegisterObjectType(ctx context.Context, objectType string, defaultRetentionYears int, tenantOverridable bool) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterWithArchive meldet den Audit-Log-Objekttyp bei der Archive-
|
|
||||||
// Retention-Engine an. Dies ist die EINZIGE Beruehrung dieses Pakets mit
|
|
||||||
// Retention ueberhaupt — kein zweites, Core-eigenes Retention-System
|
|
||||||
// (siehe "Bekannte Fehler vermeiden" im AUD-05-Ticket).
|
|
||||||
func RegisterWithArchive(ctx context.Context, registrar RetentionRegistrar) error {
|
|
||||||
return registrar.RegisterObjectType(ctx, AuditLogObjectType, DefaultAuditRetentionYears, AuditRetentionTenantOverridable)
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package audit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeRegistrar simuliert den RET-05-Modul-Adapter-Vertrag, da Archive
|
|
||||||
// (RET-01/RET-02/RET-05) zum Zeitpunkt dieser Kachel noch nicht als Code
|
|
||||||
// existiert (nur geplant in archive-kanban). Belegt NUR, dass Core mit den
|
|
||||||
// richtigen Parametern registriert — ersetzt KEINE Integrationspruefung
|
|
||||||
// gegen die echte Archive-Engine, siehe Pruefungen-Abschnitt im Commit.
|
|
||||||
type fakeRegistrar struct {
|
|
||||||
objectType string
|
|
||||||
defaultRetentionYears int
|
|
||||||
tenantOverridable bool
|
|
||||||
called bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeRegistrar) RegisterObjectType(ctx context.Context, objectType string, defaultRetentionYears int, tenantOverridable bool) error {
|
|
||||||
f.called = true
|
|
||||||
f.objectType = objectType
|
|
||||||
f.defaultRetentionYears = defaultRetentionYears
|
|
||||||
f.tenantOverridable = tenantOverridable
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + 2: Registrierung mit korrektem Objekttyp und
|
|
||||||
// GoBD-Default-Frist von 10 Jahren, tenant-ueberschreibbar.
|
|
||||||
func TestRegisterWithArchive_UsesCorrectObjectTypeAndRetention(t *testing.T) {
|
|
||||||
fake := &fakeRegistrar{}
|
|
||||||
|
|
||||||
if err := RegisterWithArchive(context.Background(), fake); err != nil {
|
|
||||||
t.Fatalf("register: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !fake.called {
|
|
||||||
t.Fatal("erwartet aufruf von RegisterObjectType")
|
|
||||||
}
|
|
||||||
if fake.objectType != AuditLogObjectType {
|
|
||||||
t.Fatalf("objectType = %q, want %q", fake.objectType, AuditLogObjectType)
|
|
||||||
}
|
|
||||||
if fake.defaultRetentionYears != 10 {
|
|
||||||
t.Fatalf("defaultRetentionYears = %d, want 10 (GoBD-Frist)", fake.defaultRetentionYears)
|
|
||||||
}
|
|
||||||
if !fake.tenantOverridable {
|
|
||||||
t.Fatal("erwartet tenantOverridable = true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrTenantNotFound = errors.New("tenant: nicht gefunden")
|
||||||
|
ErrInvalidTransition = errors.New("tenant: ungueltiger zustandsuebergang")
|
||||||
|
// ErrTenantNotActive wird von Lifecycle.CheckActive verwendet — bewusst
|
||||||
|
// EIN Fehler fuer suspendiert/zur-Loeschung-vorgemerkt/geloescht, da der
|
||||||
|
// Aufrufer (z.B. Login) nur wissen muss "kein Zugriff", nicht welcher der
|
||||||
|
// Nicht-aktiv-Zustaende genau vorliegt.
|
||||||
|
ErrTenantNotActive = errors.New("tenant: nicht aktiv")
|
||||||
|
)
|
||||||
|
|
||||||
|
func scanTenantWithLifecycle(row pgx.Row) (Tenant, error) {
|
||||||
|
var t Tenant
|
||||||
|
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status,
|
||||||
|
&t.CreatedAt, &t.PreviousStatus, &t.DeletionScheduledAt); err != nil {
|
||||||
|
return Tenant{}, err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// transition fuehrt einen bewachten Zustandsuebergang aus: das UPDATE greift
|
||||||
|
// nur, wenn der aktuelle Status einer von allowedFrom ist (atomarer
|
||||||
|
// Check-and-Set, kein Race zwischen Lesen und Schreiben). Greift es nicht,
|
||||||
|
// wird zwischen "Tenant existiert nicht" und "Uebergang nicht erlaubt"
|
||||||
|
// unterschieden, damit AC1 ("ungueltige Uebergaenge werden abgewiesen") einen
|
||||||
|
// sprechenden Fehler liefert statt eines stillen No-Ops.
|
||||||
|
func (r *Registry) transition(ctx context.Context, slug string, allowedFrom []Status, to Status, previousStatus *string, deletionAt *time.Time) (Tenant, error) {
|
||||||
|
from := make([]string, len(allowedFrom))
|
||||||
|
for i, s := range allowedFrom {
|
||||||
|
from[i] = string(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := r.pool.QueryRow(ctx, `
|
||||||
|
UPDATE tenants
|
||||||
|
SET status = $2, previous_status = $3, deletion_scheduled_at = $4
|
||||||
|
WHERE slug = $1 AND status = ANY($5)
|
||||||
|
RETURNING id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
|
||||||
|
`, slug, string(to), previousStatus, deletionAt, from)
|
||||||
|
|
||||||
|
t, err := scanTenantWithLifecycle(row)
|
||||||
|
if err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Tenant{}, fmt.Errorf("zustandsuebergang: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, getErr := r.GetBySlug(ctx, slug)
|
||||||
|
if getErr != nil {
|
||||||
|
return Tenant{}, ErrTenantNotFound
|
||||||
|
}
|
||||||
|
return Tenant{}, fmt.Errorf("%w: von %q nach %q (aktuell: %q)", ErrInvalidTransition, allowedFrom, to, existing.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suspend haelt die Daten des Mandanten unveraendert, sperrt aber den Zugriff
|
||||||
|
// (Akzeptanzkriterium 1) — es findet keine Loeschung/Migration statt.
|
||||||
|
func (r *Registry) Suspend(ctx context.Context, slug string) (Tenant, error) {
|
||||||
|
return r.transition(ctx, slug, []Status{StatusActive}, StatusSuspended, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactivate stellt den Zustand vor der Suspendierung vollstaendig wieder her
|
||||||
|
// (Akzeptanzkriterium 2) — da Suspend keine weiteren Daten veraendert, genuegt
|
||||||
|
// die Rueckkehr nach StatusActive.
|
||||||
|
func (r *Registry) Reactivate(ctx context.Context, slug string) (Tenant, error) {
|
||||||
|
return r.transition(ctx, slug, []Status{StatusSuspended}, StatusActive, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduleDeletion merkt den Mandanten zur Loeschung vor und startet die
|
||||||
|
// Karenzzeit (Akzeptanzkriterium 3). previous_status wird festgehalten, damit
|
||||||
|
// CancelDeletion exakt dorthin zurueckkehren kann (aktiv ODER suspendiert).
|
||||||
|
func (r *Registry) ScheduleDeletion(ctx context.Context, slug string, grace time.Duration) (Tenant, error) {
|
||||||
|
existing, err := r.GetBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return Tenant{}, ErrTenantNotFound
|
||||||
|
}
|
||||||
|
prev := string(existing.Status)
|
||||||
|
deletionAt := time.Now().Add(grace)
|
||||||
|
return r.transition(ctx, slug, []Status{StatusActive, StatusSuspended}, StatusPendingDeletion, &prev, &deletionAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelDeletion widerruft eine Loeschvormerkung innerhalb der Karenzzeit und
|
||||||
|
// stellt exakt den zuvor gesicherten Zustand wieder her.
|
||||||
|
func (r *Registry) CancelDeletion(ctx context.Context, slug string) (Tenant, error) {
|
||||||
|
existing, err := r.GetBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return Tenant{}, ErrTenantNotFound
|
||||||
|
}
|
||||||
|
if existing.Status != StatusPendingDeletion || existing.PreviousStatus == nil {
|
||||||
|
return Tenant{}, fmt.Errorf("%w: von %q nach aktiv/suspendiert (aktuell: %q)", ErrInvalidTransition, StatusPendingDeletion, existing.Status)
|
||||||
|
}
|
||||||
|
restoreTo := Status(*existing.PreviousStatus)
|
||||||
|
return r.transition(ctx, slug, []Status{StatusPendingDeletion}, restoreTo, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle fuehrt die tatsaechliche, physische Loeschung nach Ablauf der
|
||||||
|
// Karenzzeit aus (Datenbank-Drop) und stellt die Zugriffsschutz-Pruefung
|
||||||
|
// bereit. Getrennt von Registry, weil hierfuer zusaetzlich der adminPool
|
||||||
|
// (fuer DROP DATABASE) noetig ist, siehe internal/tenant.Provisioner.
|
||||||
|
type Lifecycle struct {
|
||||||
|
registry *Registry
|
||||||
|
adminPool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLifecycle(registry *Registry, adminPool *pgxpool.Pool) *Lifecycle {
|
||||||
|
return &Lifecycle{registry: registry, adminPool: adminPool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckActive verweigert Zugriff fuer jeden Nicht-aktiv-Zustand und loggt den
|
||||||
|
// Vorgang strukturiert (Akzeptanzkriterium 1 / Pruefung 2).
|
||||||
|
func (l *Lifecycle) CheckActive(ctx context.Context, slug string) error {
|
||||||
|
t, err := l.registry.GetBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return ErrTenantNotFound
|
||||||
|
}
|
||||||
|
if t.Status != StatusActive {
|
||||||
|
slog.Warn("zugriff auf nicht-aktiven mandanten verweigert",
|
||||||
|
"tenant_slug", slug, "tenant_status", t.Status)
|
||||||
|
return ErrTenantNotActive
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessDueDeletions loescht alle Mandanten-Datenbanken, deren Karenzzeit
|
||||||
|
// abgelaufen ist (Akzeptanzkriterium 3 / Pruefung 3). FOR UPDATE SKIP LOCKED
|
||||||
|
// folgt der projektweiten Postgres-Jobqueue-Konvention (siehe
|
||||||
|
// SKALIERUNGSKONZEPT.md) und macht die Funktion sicher fuer mehrere parallel
|
||||||
|
// laufende Core-Instanzen.
|
||||||
|
func (l *Lifecycle) ProcessDueDeletions(ctx context.Context) (int, error) {
|
||||||
|
tx, err := l.registry.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("sweep-transaktion starten: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
rows, err := tx.Query(ctx, `
|
||||||
|
SELECT id, db_name FROM tenants
|
||||||
|
WHERE status = $1 AND deletion_scheduled_at <= now()
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
`, string(StatusPendingDeletion))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("faellige loeschungen abfragen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type due struct{ id, dbName string }
|
||||||
|
var candidates []due
|
||||||
|
for rows.Next() {
|
||||||
|
var d due
|
||||||
|
if err := rows.Scan(&d.id, &d.dbName); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, fmt.Errorf("faellige loeschung lesen: %w", err)
|
||||||
|
}
|
||||||
|
candidates = append(candidates, d)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
processed := 0
|
||||||
|
for _, c := range candidates {
|
||||||
|
if _, err := l.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, c.dbName)); err != nil {
|
||||||
|
return processed, fmt.Errorf("tenant-datenbank %q loeschen: %w", c.dbName, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE tenants SET status = $2, previous_status = NULL, deletion_scheduled_at = NULL
|
||||||
|
WHERE id = $1
|
||||||
|
`, c.id, string(StatusDeleted)); err != nil {
|
||||||
|
return processed, fmt.Errorf("tenant %q als geloescht markieren: %w", c.id, err)
|
||||||
|
}
|
||||||
|
processed++
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return 0, fmt.Errorf("sweep-transaktion committen: %w", err)
|
||||||
|
}
|
||||||
|
return processed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunSweeper triggert ProcessDueDeletions periodisch, bis ctx beendet wird —
|
||||||
|
// die "In-Prozess-Worker-Goroutine" aus der projektweiten Jobqueue-Konvention.
|
||||||
|
func (l *Lifecycle) RunSweeper(ctx context.Context, interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if _, err := l.ProcessDueDeletions(ctx); err != nil {
|
||||||
|
slog.Error("tenant-loeschung-sweep fehlgeschlagen", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newLifecycleTestSetup(t *testing.T) (*Registry, *Lifecycle, *pgxpool.Pool, func()) {
|
||||||
|
t.Helper()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
registryPool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("registry pool: %v", err)
|
||||||
|
}
|
||||||
|
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(),
|
||||||
|
previous_status TEXT,
|
||||||
|
deletion_scheduled_at TIMESTAMPTZ
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatalf("registry-schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := NewRegistry(registryPool)
|
||||||
|
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
||||||
|
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
||||||
|
lifecycle := NewLifecycle(registry, adminPool)
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
registryPool.Close()
|
||||||
|
adminPool.Close()
|
||||||
|
}
|
||||||
|
_ = provisioner
|
||||||
|
return registry, lifecycle, adminPool, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if _, err := provisioner.Provision(context.Background(), slug, slug); err != nil {
|
||||||
|
t.Fatalf("provision %s: %v", slug, err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug)))
|
||||||
|
_, _ = registry.pool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 (Suspend) + 2 (Reactivate) + Pruefung 1 (Uebergaenge).
|
||||||
|
func TestLifecycle_SuspendAndReactivate(t *testing.T) {
|
||||||
|
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||||
|
defer cleanup()
|
||||||
|
provisionTestTenant(t, registry, adminPool, "lc_suspend")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
suspended, err := registry.Suspend(ctx, "lc_suspend")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("suspend: %v", err)
|
||||||
|
}
|
||||||
|
if suspended.Status != StatusSuspended {
|
||||||
|
t.Fatalf("status = %q, want suspended", suspended.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
reactivated, err := registry.Reactivate(ctx, "lc_suspend")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reactivate: %v", err)
|
||||||
|
}
|
||||||
|
if reactivated.Status != StatusActive {
|
||||||
|
t.Fatalf("status = %q, want active", reactivated.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefung 1: ungueltige Uebergaenge werden abgewiesen.
|
||||||
|
func TestLifecycle_RejectsInvalidTransitions(t *testing.T) {
|
||||||
|
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||||
|
defer cleanup()
|
||||||
|
provisionTestTenant(t, registry, adminPool, "lc_invalid")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Reactivate auf einem bereits aktiven Tenant ist kein gueltiger Uebergang.
|
||||||
|
if _, err := registry.Reactivate(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := registry.Suspend(ctx, "lc_invalid"); err != nil {
|
||||||
|
t.Fatalf("suspend: %v", err)
|
||||||
|
}
|
||||||
|
// Suspend auf einem bereits suspendierten Tenant ist ebenfalls ungueltig.
|
||||||
|
if _, err := registry.Suspend(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelDeletion ohne vorherige Loeschvormerkung ist ungueltig.
|
||||||
|
if _, err := registry.CancelDeletion(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := registry.Suspend(ctx, "unbekannter-slug-xyz"); !errors.Is(err, ErrTenantNotFound) {
|
||||||
|
t.Fatalf("erwartet ErrTenantNotFound, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3: Loeschung zweistufig mit Karenzzeit, innerhalb der
|
||||||
|
// Frist widerrufbar — sowohl aus 'active' als auch aus 'suspended' heraus,
|
||||||
|
// mit exakter Wiederherstellung des jeweiligen Vorzustands.
|
||||||
|
func TestLifecycle_ScheduleAndCancelDeletion_RestoresExactPreviousState(t *testing.T) {
|
||||||
|
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||||
|
defer cleanup()
|
||||||
|
provisionTestTenant(t, registry, adminPool, "lc_cancel_active")
|
||||||
|
provisionTestTenant(t, registry, adminPool, "lc_cancel_suspended")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Fall 1: aus 'active' heraus vorgemerkt und widerrufen.
|
||||||
|
scheduled, err := registry.ScheduleDeletion(ctx, "lc_cancel_active", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("schedule deletion: %v", err)
|
||||||
|
}
|
||||||
|
if scheduled.Status != StatusPendingDeletion {
|
||||||
|
t.Fatalf("status = %q, want pending_deletion", scheduled.Status)
|
||||||
|
}
|
||||||
|
if scheduled.DeletionScheduledAt == nil {
|
||||||
|
t.Fatal("erwartet gesetzte deletion_scheduled_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := registry.CancelDeletion(ctx, "lc_cancel_active")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cancel deletion: %v", err)
|
||||||
|
}
|
||||||
|
if restored.Status != StatusActive {
|
||||||
|
t.Fatalf("status = %q, want active (vorheriger zustand)", restored.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall 2: aus 'suspended' heraus vorgemerkt und widerrufen — muss zu
|
||||||
|
// 'suspended' zurueckkehren, NICHT zu 'active'.
|
||||||
|
if _, err := registry.Suspend(ctx, "lc_cancel_suspended"); err != nil {
|
||||||
|
t.Fatalf("suspend: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := registry.ScheduleDeletion(ctx, "lc_cancel_suspended", time.Hour); err != nil {
|
||||||
|
t.Fatalf("schedule deletion: %v", err)
|
||||||
|
}
|
||||||
|
restoredSuspended, err := registry.CancelDeletion(ctx, "lc_cancel_suspended")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cancel deletion: %v", err)
|
||||||
|
}
|
||||||
|
if restoredSuspended.Status != StatusSuspended {
|
||||||
|
t.Fatalf("status = %q, want suspended (vorheriger zustand)", restoredSuspended.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 2: suspendierter Tenant erzeugt bei jedem
|
||||||
|
// Zugriffsversuch einen klaren Fehler.
|
||||||
|
func TestLifecycle_CheckActive_RejectsNonActive(t *testing.T) {
|
||||||
|
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||||
|
defer cleanup()
|
||||||
|
provisionTestTenant(t, registry, adminPool, "lc_checkactive")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := lifecycle.CheckActive(ctx, "lc_checkactive"); err != nil {
|
||||||
|
t.Fatalf("aktiver tenant sollte durchgehen, habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := registry.Suspend(ctx, "lc_checkactive"); err != nil {
|
||||||
|
t.Fatalf("suspend: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := lifecycle.CheckActive(ctx, "lc_checkactive"); !errors.Is(err, ErrTenantNotActive) {
|
||||||
|
t.Fatalf("versuch %d: erwartet ErrTenantNotActive, habe %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lifecycle.CheckActive(ctx, "nie-registriert"); !errors.Is(err, ErrTenantNotFound) {
|
||||||
|
t.Fatalf("erwartet ErrTenantNotFound, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3: Loeschvorgang nach Ablauf der Karenzzeit
|
||||||
|
// automatisch ausgeloest (hier durch direkten Aufruf von ProcessDueDeletions,
|
||||||
|
// das RunSweeper periodisch aufruft).
|
||||||
|
func TestLifecycle_ProcessDueDeletions(t *testing.T) {
|
||||||
|
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||||
|
defer cleanup()
|
||||||
|
provisionTestTenant(t, registry, adminPool, "lc_due")
|
||||||
|
provisionTestTenant(t, registry, adminPool, "lc_not_due")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// lc_due: Karenzzeit liegt bereits in der Vergangenheit -> faellig.
|
||||||
|
if _, err := registry.ScheduleDeletion(ctx, "lc_due", -time.Minute); err != nil {
|
||||||
|
t.Fatalf("schedule deletion (due): %v", err)
|
||||||
|
}
|
||||||
|
// lc_not_due: Karenzzeit liegt weit in der Zukunft -> nicht faellig.
|
||||||
|
if _, err := registry.ScheduleDeletion(ctx, "lc_not_due", time.Hour); err != nil {
|
||||||
|
t.Fatalf("schedule deletion (not due): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
processed, err := lifecycle.ProcessDueDeletions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("process due deletions: %v", err)
|
||||||
|
}
|
||||||
|
if processed != 1 {
|
||||||
|
t.Fatalf("erwartet genau 1 verarbeitete loeschung, habe %d", processed)
|
||||||
|
}
|
||||||
|
|
||||||
|
due, err := registry.GetBySlug(ctx, "lc_due")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get lc_due: %v", err)
|
||||||
|
}
|
||||||
|
if due.Status != StatusDeleted {
|
||||||
|
t.Fatalf("lc_due status = %q, want deleted", due.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
notDue, err := registry.GetBySlug(ctx, "lc_not_due")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get lc_not_due: %v", err)
|
||||||
|
}
|
||||||
|
if notDue.Status != StatusPendingDeletion {
|
||||||
|
t.Fatalf("lc_not_due status = %q, want pending_deletion (noch nicht faellig)", notDue.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datenbank von lc_due wurde tatsaechlich physisch entfernt.
|
||||||
|
var exists bool
|
||||||
|
if err := adminPool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`,
|
||||||
|
dbNameForSlug("lc_due")).Scan(&exists); err != nil {
|
||||||
|
t.Fatalf("pg_database pruefen: %v", err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
t.Fatal("erwartet, dass die tenant-datenbank von lc_due geloescht wurde")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,13 +35,17 @@ func (r *Registry) insertTx(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
|
func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
|
||||||
|
// previous_status/deletion_scheduled_at werden mitgelesen, damit TEN-04
|
||||||
|
// (internal/tenant/lifecycle.go) den vollstaendigen Lebenszyklus-Zustand
|
||||||
|
// ueber GetBySlug ansehen kann, statt eine eigene Abfrage zu duplizieren.
|
||||||
var t Tenant
|
var t Tenant
|
||||||
row := r.pool.QueryRow(ctx, `
|
row := r.pool.QueryRow(ctx, `
|
||||||
SELECT id, slug, name, db_name, db_dsn, status, created_at
|
SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
|
||||||
FROM tenants WHERE slug = $1
|
FROM tenants WHERE slug = $1
|
||||||
`, slug)
|
`, slug)
|
||||||
|
|
||||||
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
|
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt,
|
||||||
|
&t.PreviousStatus, &t.DeletionScheduledAt); err != nil {
|
||||||
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
|
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
|
||||||
}
|
}
|
||||||
return t, nil
|
return t, nil
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ type Status string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
StatusActive Status = "active"
|
StatusActive Status = "active"
|
||||||
|
// Lebenszyklus-Zustaende aus TEN-04 (siehe internal/tenant/lifecycle.go).
|
||||||
|
StatusSuspended Status = "suspended"
|
||||||
|
StatusPendingDeletion Status = "pending_deletion"
|
||||||
|
StatusDeleted Status = "deleted"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Tenant struct {
|
type Tenant struct {
|
||||||
@@ -22,6 +26,11 @@ type Tenant struct {
|
|||||||
DBDSN string
|
DBDSN string
|
||||||
Status Status
|
Status Status
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
// PreviousStatus und DeletionScheduledAt sind nur waehrend
|
||||||
|
// StatusPendingDeletion gesetzt (TEN-04) — sie halten fest, in welchen
|
||||||
|
// Zustand CancelDeletion zurueckkehrt und wann die Karenzzeit ablaeuft.
|
||||||
|
PreviousStatus *string
|
||||||
|
DeletionScheduledAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
|
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE tenants DROP COLUMN previous_status;
|
||||||
|
ALTER TABLE tenants DROP COLUMN deletion_scheduled_at;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Lebenszyklus-Zustaende fuer Mandanten (TEN-04, siehe core-kanban/tickets/TEN-04.md).
|
||||||
|
-- previous_status haelt den Zustand VOR einer Loeschvormerkung, damit
|
||||||
|
-- CancelDeletion "den vorherigen Zustand vollstaendig wiederherstellt"
|
||||||
|
-- (aktiv ODER suspendiert), statt hart auf 'active' zurueckzusetzen.
|
||||||
|
ALTER TABLE tenants ADD COLUMN previous_status TEXT;
|
||||||
|
ALTER TABLE tenants ADD COLUMN deletion_scheduled_at TIMESTAMPTZ;
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS audit_events;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
-- Zentrales Audit-Log-Modell (AUD-01, siehe core-kanban/tickets/AUD-01.md).
|
|
||||||
-- Getrennt vom allgemeinen Anwendungs-Log (Akzeptanzkriterium 2): eigene
|
|
||||||
-- Tabelle, eigenes Paket (internal/audit), kein Log-Framework.
|
|
||||||
-- tenant_slug ist NOT NULL + darf nicht leer sein (Akzeptanzkriterium 2 /
|
|
||||||
-- Pruefung 2) — mandantenuebergreifende Ereignisse nutzen den reservierten
|
|
||||||
-- Wert 'system', niemals NULL oder leeren String.
|
|
||||||
CREATE TABLE audit_events (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
|
|
||||||
actor TEXT NOT NULL CHECK (actor <> ''),
|
|
||||||
action TEXT NOT NULL CHECK (action <> ''),
|
|
||||||
target TEXT NOT NULL,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX audit_events_tenant_slug_idx ON audit_events (tenant_slug, occurred_at);
|
|
||||||
@@ -13,8 +13,7 @@ ROLE="nexarch_test"
|
|||||||
|
|
||||||
export PGPASSWORD="$PASS"
|
export PGPASSWORD="$PASS"
|
||||||
|
|
||||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
|
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants;"
|
||||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS audit_events CASCADE;"
|
|
||||||
|
|
||||||
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
||||||
for db in $dbs; do
|
for db in $dbs; do
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
|
|
||||||
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
|
|
||||||
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
|
|
||||||
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
|
|
||||||
#
|
|
||||||
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
|
|
||||||
|
|
||||||
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
|
|
||||||
|
|
||||||
echo "== go build =="
|
|
||||||
go build ./...
|
|
||||||
|
|
||||||
echo "== go vet =="
|
|
||||||
go vet ./...
|
|
||||||
|
|
||||||
echo "== go test (-p 1) =="
|
|
||||||
go test ./... -p 1 -count=1
|
|
||||||
Reference in New Issue
Block a user