Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d63fcbb49e | ||
|
|
ed67887385 |
@@ -129,26 +129,3 @@ Keine Commits in dieser Session.
|
|||||||
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
||||||
|
|
||||||
---
|
---
|
||||||
## 2026-08-27 17:53 – 17:57 (4m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** code
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
- e479330 IAM-01: benutzer-datenmodell-crud
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- cmd/core/main.go | 11 ++++++++++-
|
|
||||||
- go.mod | 9 +++++++++
|
|
||||||
- go.sum | 28 ++++++++++++++++++++++++++++
|
|
||||||
- internal/user/handler.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/user/store_test.go | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/user/superadmin_store.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/user/tenant_store.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/user/user.go | 42 ++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/user/user_test.go | 24 ++++++++++++++++++++++++
|
|
||||||
- migrations/0002_superadmins.down.sql | 1 +
|
|
||||||
- migrations/0002_superadmins.up.sql | 14 ++++++++++++++
|
|
||||||
- migrations/tenant/0001_users.down.sql | 1 +
|
|
||||||
- migrations/tenant/0001_users.up.sql | 16 ++++++++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ func main() {
|
|||||||
registry := tenant.NewRegistry(registryPool)
|
registry := tenant.NewRegistry(registryPool)
|
||||||
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
||||||
tenantHandler := tenant.NewHandler(provisioner)
|
tenantHandler := tenant.NewHandler(provisioner)
|
||||||
onboardingService := tenant.NewOnboardingService(provisioner, cfg.TenantSchemaPath)
|
|
||||||
onboardingHandler := tenant.NewOnboardingHandler(onboardingService)
|
|
||||||
|
|
||||||
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
|
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
|
||||||
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
|
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
|
||||||
@@ -50,7 +48,6 @@ func main() {
|
|||||||
})
|
})
|
||||||
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
|
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
|
||||||
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
||||||
mux.HandleFunc("/internal/tenants/onboard", onboardingHandler.Onboard)
|
|
||||||
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
|
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
|
||||||
|
|
||||||
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ type Config struct {
|
|||||||
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
|
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
|
||||||
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
|
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
|
||||||
TenantDSNTemplate string
|
TenantDSNTemplate string
|
||||||
// TenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der
|
|
||||||
// TEN-02 eine frisch provisionierte Tenant-Datenbank initialisiert.
|
|
||||||
TenantSchemaPath string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -40,11 +37,6 @@ func Load() (Config, error) {
|
|||||||
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
|
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
schemaPath := os.Getenv("NEXARCH_TENANT_SCHEMA_PATH")
|
|
||||||
if schemaPath == "" {
|
|
||||||
return Config{}, fmt.Errorf("NEXARCH_TENANT_SCHEMA_PATH not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
|
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
addr = ":8080"
|
addr = ":8080"
|
||||||
@@ -55,6 +47,5 @@ func Load() (Config, error) {
|
|||||||
RegistryDSN: dsn,
|
RegistryDSN: dsn,
|
||||||
AdminDSN: adminDSN,
|
AdminDSN: adminDSN,
|
||||||
TenantDSNTemplate: dsnTemplate,
|
TenantDSNTemplate: dsnTemplate,
|
||||||
TenantSchemaPath: schemaPath,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrDenied wird geliefert, wenn keine Regel role+permission erlaubt —
|
||||||
|
// Default-Deny (Akzeptanzkriterium 2).
|
||||||
|
var ErrDenied = errors.New("policy: zugriff verweigert")
|
||||||
|
|
||||||
|
// Enforcer ist die EINE zentrale Entscheidungs- und Durchsetzungsschicht
|
||||||
|
// (Akzeptanzkriterium 1). Repository-/Query-Code ruft ausschliesslich Guard
|
||||||
|
// bzw. GuardTenantScoped auf, nie eine Rohabfrage direkt.
|
||||||
|
type Enforcer struct {
|
||||||
|
store *Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEnforcer(store *Store) *Enforcer {
|
||||||
|
return &Enforcer{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize entscheidet erlaubt/verboten — unabhaengig von jeder konkreten
|
||||||
|
// Query, rein anhand der deklarativen Regeln (Akzeptanzkriterium 2: fuer
|
||||||
|
// sich genommen testbar, ohne Anwendungslogik).
|
||||||
|
func (e *Enforcer) Authorize(ctx context.Context, role rbac.Role, perm rbac.Permission) error {
|
||||||
|
allowed, err := e.store.IsAllowed(ctx, role, perm)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return fmt.Errorf("%w: rolle %q hat kein recht %q", ErrDenied, role, perm)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard ist die zentrale Enforcement-Funktion (Akzeptanzkriterium 1): query
|
||||||
|
// wird NUR aufgerufen, wenn Authorize zustimmt. Es gibt keinen Weg, query
|
||||||
|
// ausserhalb von Guard aufzurufen und trotzdem den Aufrufer als autorisiert
|
||||||
|
// zu behandeln — die Autorisierungsentscheidung steht immer VOR dem
|
||||||
|
// Datenzugriff, nie danach.
|
||||||
|
func Guard[T any](ctx context.Context, e *Enforcer, role rbac.Role, perm rbac.Permission, query func(ctx context.Context) (T, error)) (T, error) {
|
||||||
|
var zero T
|
||||||
|
if err := e.Authorize(ctx, role, perm); err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
return query(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuardTenantScoped erzwingt zusaetzlich, dass tenantSlug TEIL der Query-
|
||||||
|
// Funktion selbst ist (Akzeptanzkriterium 3): der Funktionstyp verlangt,
|
||||||
|
// dass die Repository-Implementierung tenantSlug in ihre eigene WHERE-
|
||||||
|
// Klausel einbaut — ein nachgelagerter Filter auf dem Ergebnis (der
|
||||||
|
// archivmail-Fehler aus "Bekannte Fehler vermeiden") ist mit dieser
|
||||||
|
// Signatur nicht moeglich, da die Query-Funktion tenantSlug selbst
|
||||||
|
// entgegennimmt und dafuer verantwortlich ist, statt ihn hinterher
|
||||||
|
// anzuwenden.
|
||||||
|
func GuardTenantScoped[T any](ctx context.Context, e *Enforcer, role rbac.Role, perm rbac.Permission, tenantSlug string, query func(ctx context.Context, tenantSlug string) (T, error)) (T, error) {
|
||||||
|
var zero T
|
||||||
|
if err := e.Authorize(ctx, role, perm); err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
return query(ctx, tenantSlug)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTest(t *testing.T) (*Store, *Enforcer, 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 policy_rules (
|
||||||
|
role TEXT NOT NULL, permission TEXT NOT NULL,
|
||||||
|
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (role, permission)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS policy_rule_changes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), role TEXT NOT NULL, permission TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL CHECK (action IN ('grant','revoke')), actor TEXT NOT NULL,
|
||||||
|
version INT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := NewStore(pool)
|
||||||
|
cleanup := func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM policy_rule_changes WHERE role LIKE 'test\_%' ESCAPE '\'`)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM policy_rules WHERE role LIKE 'test\_%' ESCAPE '\'`)
|
||||||
|
pool.Close()
|
||||||
|
}
|
||||||
|
return store, NewEnforcer(store), cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2: Anfrage ohne passende Policy wird
|
||||||
|
// zuverlaessig abgewiesen (Default-Deny), nicht standardmaessig erlaubt.
|
||||||
|
func TestAuthorize_DefaultDeny(t *testing.T) {
|
||||||
|
_, enforcer, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := enforcer.Authorize(ctx, rbac.Role("test_niemand"), rbac.Permission("test_irgendwas"))
|
||||||
|
if !errors.Is(err, ErrDenied) {
|
||||||
|
t.Fatalf("erwartet ErrDenied ohne konfigurierte regel, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 1: Guard ruft query NUR bei Autorisierung
|
||||||
|
// auf — kein Datenzugriffs-Pfad umgeht die Enforcement-Schicht.
|
||||||
|
func TestGuard_NeverCallsQueryWithoutAuthorization(t *testing.T) {
|
||||||
|
store, enforcer, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
role := rbac.Role("test_rolle_guard")
|
||||||
|
perm := rbac.Permission("test_lesen")
|
||||||
|
|
||||||
|
queryCalled := false
|
||||||
|
_, err := Guard(ctx, enforcer, role, perm, func(ctx context.Context) (string, error) {
|
||||||
|
queryCalled = true
|
||||||
|
return "geheime-daten", nil
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrDenied) {
|
||||||
|
t.Fatalf("erwartet ErrDenied, habe %v", err)
|
||||||
|
}
|
||||||
|
if queryCalled {
|
||||||
|
t.Fatal("query haette bei fehlender autorisierung NICHT aufgerufen werden duerfen")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Grant(ctx, role, perm, "admin@example.com"); err != nil {
|
||||||
|
t.Fatalf("grant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Guard(ctx, enforcer, role, perm, func(ctx context.Context) (string, error) {
|
||||||
|
queryCalled = true
|
||||||
|
return "geheime-daten", nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("guard nach grant: %v", err)
|
||||||
|
}
|
||||||
|
if !queryCalled || result != "geheime-daten" {
|
||||||
|
t.Fatalf("erwartet query-aufruf mit ergebnis nach autorisierung, habe queryCalled=%v result=%q", queryCalled, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + 3: Policy-Entscheidungen deklarativ und unabhaengig
|
||||||
|
// von Anwendungslogik testbar — Regelwechsel wirkt ohne Codeaenderung,
|
||||||
|
// Historie ist versioniert nachvollziehbar.
|
||||||
|
func TestGrantRevoke_ChangesBehaviorWithoutCodeChange(t *testing.T) {
|
||||||
|
store, enforcer, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
role := rbac.Role("test_rolle_history")
|
||||||
|
perm := rbac.Permission("test_schreiben")
|
||||||
|
|
||||||
|
allowed, err := store.IsAllowed(ctx, role, perm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("is allowed (vorher): %v", err)
|
||||||
|
}
|
||||||
|
if allowed {
|
||||||
|
t.Fatal("erwartet nicht erlaubt vor grant")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Grant(ctx, role, perm, "admin@example.com"); err != nil {
|
||||||
|
t.Fatalf("grant: %v", err)
|
||||||
|
}
|
||||||
|
if err := enforcer.Authorize(ctx, role, perm); err != nil {
|
||||||
|
t.Fatalf("authorize nach grant sollte erlauben: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Revoke(ctx, role, perm, "admin@example.com"); err != nil {
|
||||||
|
t.Fatalf("revoke: %v", err)
|
||||||
|
}
|
||||||
|
if err := enforcer.Authorize(ctx, role, perm); !errors.Is(err, ErrDenied) {
|
||||||
|
t.Fatalf("authorize nach revoke sollte verweigern, habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.History(ctx, role, perm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("history: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("erwartet 2 historieneintraege (grant, revoke), habe %d", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Action != "grant" || history[0].Version != 1 {
|
||||||
|
t.Fatalf("history[0] unerwartet: %+v", history[0])
|
||||||
|
}
|
||||||
|
if history[1].Action != "revoke" || history[1].Version != 2 {
|
||||||
|
t.Fatalf("history[1] unerwartet: %+v", history[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3 (Tenant-Scoping): GuardTenantScoped
|
||||||
|
// zwingt tenantSlug in die Query-Funktion selbst, kein Post-Filter moeglich.
|
||||||
|
func TestGuardTenantScoped_IsolatesDataBetweenTenants(t *testing.T) {
|
||||||
|
_, enforcer, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Simuliertes Repository: die WHERE-Klausel (hier: Map-Lookup) liegt
|
||||||
|
// INNERHALB der Query-Funktion, nicht als nachgelagerter Filter.
|
||||||
|
data := map[string][]string{
|
||||||
|
"acme": {"dokument-a1", "dokument-a2"},
|
||||||
|
"globex": {"dokument-b1"},
|
||||||
|
}
|
||||||
|
repoQuery := func(ctx context.Context, tenantSlug string) ([]string, error) {
|
||||||
|
return data[tenantSlug], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
role := rbac.Role("test_tenant_rolle")
|
||||||
|
perm := rbac.Permission("test_dokumente_lesen")
|
||||||
|
if err := enforcer.store.Grant(ctx, role, perm, "admin@example.com"); err != nil {
|
||||||
|
t.Fatalf("grant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotAcme, err := GuardTenantScoped(ctx, enforcer, role, perm, "acme", repoQuery)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("guard tenant scoped (acme): %v", err)
|
||||||
|
}
|
||||||
|
if len(gotAcme) != 2 {
|
||||||
|
t.Fatalf("erwartet 2 dokumente fuer acme, habe %d", len(gotAcme))
|
||||||
|
}
|
||||||
|
|
||||||
|
gotGlobex, err := GuardTenantScoped(ctx, enforcer, role, perm, "globex", repoQuery)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("guard tenant scoped (globex): %v", err)
|
||||||
|
}
|
||||||
|
if len(gotGlobex) != 1 {
|
||||||
|
t.Fatalf("erwartet 1 dokument fuer globex, habe %d", len(gotGlobex))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Package policy implementiert Core RBAC-02: die zentrale Policy-
|
||||||
|
// Entscheidungs- und Durchsetzungsschicht. Regeln liegen deklarativ in der
|
||||||
|
// Datenbank (Casbin-Prinzip: Policy als Modell+Regeln getrennt vom Code),
|
||||||
|
// versioniert und auditierbar — anders als Casbin-Dateien im Dateisystem
|
||||||
|
// (siehe "bewusst vermeiden" im Ticket).
|
||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rule ist eine gewaehrte Regel: role darf permission.
|
||||||
|
type Rule struct {
|
||||||
|
Role rbac.Role
|
||||||
|
Permission rbac.Permission
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleChange ist ein Eintrag der Aenderungshistorie (Akzeptanzkriterium 3).
|
||||||
|
type RuleChange struct {
|
||||||
|
Role rbac.Role
|
||||||
|
Permission rbac.Permission
|
||||||
|
Action string // "grant" oder "revoke"
|
||||||
|
Actor string
|
||||||
|
Version int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store verwaltet den deklarativen Regelsatz. IsAllowed schaut NUR in die
|
||||||
|
// Datenbank — es gibt keinen hartcodierten Go-Fallback, ein Regelwechsel
|
||||||
|
// wirkt sich ohne Codeaenderung sofort aus (Akzeptanzkriterium 2 / 3).
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// "erlaubt, wenn nichts anderes gesagt"-Pfad.
|
||||||
|
func (s *Store) IsAllowed(ctx context.Context, role rbac.Role, perm rbac.Permission) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS(SELECT 1 FROM policy_rules WHERE role = $1 AND permission = $2)
|
||||||
|
`, string(role), string(perm)).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("policy pruefen: %w", err)
|
||||||
|
}
|
||||||
|
return exists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant gewaehrt role das Recht permission — deklarativ, ohne Codeaenderung
|
||||||
|
// wirksam. actor wird fuer die Aenderungshistorie festgehalten.
|
||||||
|
func (s *Store) Grant(ctx context.Context, role rbac.Role, perm rbac.Permission, actor string) error {
|
||||||
|
return s.change(ctx, role, perm, "grant", actor, `
|
||||||
|
INSERT INTO policy_rules (role, permission, granted_by, granted_at)
|
||||||
|
VALUES ($1, $2, $3, now())
|
||||||
|
ON CONFLICT (role, permission) DO UPDATE SET granted_by = $3, granted_at = now()
|
||||||
|
`, string(role), string(perm), actor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke entzieht role das Recht permission.
|
||||||
|
func (s *Store) Revoke(ctx context.Context, role rbac.Role, perm rbac.Permission, actor string) error {
|
||||||
|
return s.change(ctx, role, perm, "revoke", actor, `
|
||||||
|
DELETE FROM policy_rules WHERE role = $1 AND permission = $2
|
||||||
|
`, string(role), string(perm))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) change(ctx context.Context, role rbac.Role, perm rbac.Permission, action, actor, sql string, args ...any) error {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("transaktion starten: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, sql, args...); err != nil {
|
||||||
|
return fmt.Errorf("regel aendern: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var version int
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT COALESCE(MAX(version), 0) + 1 FROM policy_rule_changes WHERE role = $1 AND permission = $2
|
||||||
|
`, string(role), string(perm)).Scan(&version); err != nil {
|
||||||
|
return fmt.Errorf("naechste version ermitteln: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO policy_rule_changes (role, permission, action, actor, version, changed_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, now())
|
||||||
|
`, string(role), string(perm), action, actor, version); err != nil {
|
||||||
|
return fmt.Errorf("historie schreiben: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// History liefert die vollstaendige, versionierte Aenderungshistorie einer
|
||||||
|
// Regel (Akzeptanzkriterium 3).
|
||||||
|
func (s *Store) History(ctx context.Context, role rbac.Role, perm rbac.Permission) ([]RuleChange, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT action, actor, version FROM policy_rule_changes
|
||||||
|
WHERE role = $1 AND permission = $2 ORDER BY version
|
||||||
|
`, string(role), string(perm))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("historie abfragen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []RuleChange
|
||||||
|
for rows.Next() {
|
||||||
|
c := RuleChange{Role: role, Permission: perm}
|
||||||
|
if err := rows.Scan(&c.Action, &c.Actor, &c.Version); err != nil {
|
||||||
|
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Package rbac implementiert Core RBAC-01: das Rollenmodell mit
|
||||||
|
// Grundrollen/-rechten, Hierarchie und Zuweisung. Die eigentliche
|
||||||
|
// Durchsetzung (erlaubt/verboten je Request) ist RBAC-02, dieses Paket
|
||||||
|
// liefert nur Modell + Zuweisung (Casbin-Prinzip: Policy-Modell getrennt
|
||||||
|
// von der Entscheidungsfunktion).
|
||||||
|
package rbac
|
||||||
|
|
||||||
|
// Role ist eine der drei Grundrollen. Superadmin lebt mandantenuebergreifend
|
||||||
|
// (siehe internal/user.SuperadminStore aus IAM-01) und wird deshalb NICHT
|
||||||
|
// ueber Store (tenant-gescoped) zugewiesen — die Existenz eines Superadmin-
|
||||||
|
// Kontos IST die Rollenzuweisung.
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleSuperadmin Role = "superadmin"
|
||||||
|
RoleTenantAdmin Role = "tenant_admin"
|
||||||
|
RoleUser Role = "user"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Permission string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PermSelfRead Permission = "self.read"
|
||||||
|
PermSelfUpdate Permission = "self.update"
|
||||||
|
PermManageUsers Permission = "tenant.manage_users"
|
||||||
|
PermManageSettings Permission = "tenant.manage_settings"
|
||||||
|
PermManageTenants Permission = "platform.manage_tenants"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parent bildet die Rollenhierarchie ab (Akzeptanzkriterium 2): eine Rolle
|
||||||
|
// erbt alle Rechte ihrer Elternrolle. tenant_admin erbt von user,
|
||||||
|
// superadmin erbt von tenant_admin.
|
||||||
|
var parent = map[Role]Role{
|
||||||
|
RoleTenantAdmin: RoleUser,
|
||||||
|
RoleSuperadmin: RoleTenantAdmin,
|
||||||
|
}
|
||||||
|
|
||||||
|
// direct sind die einer Rolle direkt (ohne Vererbung) zugeordneten Rechte.
|
||||||
|
var direct = map[Role][]Permission{
|
||||||
|
RoleUser: {PermSelfRead, PermSelfUpdate},
|
||||||
|
RoleTenantAdmin: {PermManageUsers, PermManageSettings},
|
||||||
|
RoleSuperadmin: {PermManageTenants},
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectivePermissions liefert die vollstaendige, ueber die Hierarchie
|
||||||
|
// aufgeloeste Rechtemenge einer Rolle (Akzeptanzkriterium 2 / Pruefung 2).
|
||||||
|
func EffectivePermissions(role Role) []Permission {
|
||||||
|
seen := make(map[Permission]bool)
|
||||||
|
var out []Permission
|
||||||
|
|
||||||
|
for r, ok := role, true; ok; r, ok = parent[r] {
|
||||||
|
for _, p := range direct[r] {
|
||||||
|
if !seen[p] {
|
||||||
|
seen[p] = true
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPermission prueft, ob eine Rolle (inklusive geerbter Rechte) ein
|
||||||
|
// bestimmtes Recht besitzt.
|
||||||
|
func HasPermission(role Role, perm Permission) bool {
|
||||||
|
for _, p := range EffectivePermissions(role) {
|
||||||
|
if p == perm {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package rbac
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func contains(perms []Permission, p Permission) bool {
|
||||||
|
for _, x := range perms {
|
||||||
|
if x == p {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2: Rollenhierarchie liefert die erwartete
|
||||||
|
// effektive Rechtemenge.
|
||||||
|
func TestEffectivePermissions_Inheritance(t *testing.T) {
|
||||||
|
userPerms := EffectivePermissions(RoleUser)
|
||||||
|
if !contains(userPerms, PermSelfRead) || !contains(userPerms, PermSelfUpdate) {
|
||||||
|
t.Fatalf("user sollte self.read/self.update haben, habe %v", userPerms)
|
||||||
|
}
|
||||||
|
if contains(userPerms, PermManageUsers) {
|
||||||
|
t.Fatal("user sollte KEIN tenant.manage_users haben")
|
||||||
|
}
|
||||||
|
|
||||||
|
adminPerms := EffectivePermissions(RoleTenantAdmin)
|
||||||
|
if !contains(adminPerms, PermSelfRead) || !contains(adminPerms, PermManageUsers) || !contains(adminPerms, PermManageSettings) {
|
||||||
|
t.Fatalf("tenant_admin sollte geerbte user-rechte + eigene rechte haben, habe %v", adminPerms)
|
||||||
|
}
|
||||||
|
if contains(adminPerms, PermManageTenants) {
|
||||||
|
t.Fatal("tenant_admin sollte KEIN platform.manage_tenants haben")
|
||||||
|
}
|
||||||
|
|
||||||
|
superPerms := EffectivePermissions(RoleSuperadmin)
|
||||||
|
for _, want := range []Permission{PermSelfRead, PermSelfUpdate, PermManageUsers, PermManageSettings, PermManageTenants} {
|
||||||
|
if !contains(superPerms, want) {
|
||||||
|
t.Fatalf("superadmin sollte %q haben (volle vererbte kette), habe %v", want, superPerms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasPermission(t *testing.T) {
|
||||||
|
if !HasPermission(RoleTenantAdmin, PermSelfRead) {
|
||||||
|
t.Fatal("tenant_admin sollte geerbtes self.read haben")
|
||||||
|
}
|
||||||
|
if HasPermission(RoleUser, PermManageTenants) {
|
||||||
|
t.Fatal("user sollte platform.manage_tenants nicht haben")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package rbac
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrRoleNotAssignableInTenantScope wird geliefert, wenn versucht wird,
|
||||||
|
// eine mandantenuebergreifende Rolle (superadmin) ueber den tenant-
|
||||||
|
// gescopten Store zu vergeben — die erlaubte Matrix laesst hier nur
|
||||||
|
// user/tenant_admin zu (Akzeptanzkriterium 1 / Pruefung 1).
|
||||||
|
ErrRoleNotAssignableInTenantScope = errors.New("rbac: rolle ist in diesem geltungsbereich nicht zuweisbar")
|
||||||
|
ErrNotFound = errors.New("rbac: keine rollenzuweisung gefunden")
|
||||||
|
)
|
||||||
|
|
||||||
|
// assignableRoles ist die erlaubte Matrix fuer Store (tenant-gescoped).
|
||||||
|
var assignableRoles = map[Role]bool{
|
||||||
|
RoleUser: true,
|
||||||
|
RoleTenantAdmin: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Assignment struct {
|
||||||
|
UserID string
|
||||||
|
Role Role
|
||||||
|
GrantedBy string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store verwaltet Rollenzuweisungen innerhalb GENAU EINER Tenant-Datenbank —
|
||||||
|
// analog zu internal/user.TenantUserStore (Modell C: der Pool bestimmt den
|
||||||
|
// Tenant, keine tenant_id-Spalte noetig).
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign vergibt eine Rolle an einen Benutzer. grantedBy identifiziert den
|
||||||
|
// Akteur, der die Zuweisung vorgenommen hat (Akzeptanzkriterium 3). Jede
|
||||||
|
// Zuweisung wird zusaetzlich in role_assignment_history festgehalten, auch
|
||||||
|
// wenn sie eine vorherige Rolle ersetzt.
|
||||||
|
func (s *Store) Assign(ctx context.Context, userID string, role Role, grantedBy string) (Assignment, error) {
|
||||||
|
if !assignableRoles[role] {
|
||||||
|
return Assignment{}, ErrRoleNotAssignableInTenantScope
|
||||||
|
}
|
||||||
|
if grantedBy == "" {
|
||||||
|
return Assignment{}, errors.New("rbac: grantedBy darf nicht leer sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Assignment{}, fmt.Errorf("transaktion starten: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO role_assignments (user_id, role, granted_by, granted_at)
|
||||||
|
VALUES ($1, $2, $3, now())
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET role = $2, granted_by = $3, granted_at = now()
|
||||||
|
`, userID, string(role), grantedBy); err != nil {
|
||||||
|
return Assignment{}, fmt.Errorf("rolle zuweisen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO role_assignment_history (user_id, role, granted_by, granted_at)
|
||||||
|
VALUES ($1, $2, $3, now())
|
||||||
|
`, userID, string(role), grantedBy); err != nil {
|
||||||
|
return Assignment{}, fmt.Errorf("historie schreiben: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return Assignment{}, fmt.Errorf("transaktion committen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Assignment{UserID: userID, Role: role, GrantedBy: grantedBy}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Get(ctx context.Context, userID string) (Assignment, error) {
|
||||||
|
var a Assignment
|
||||||
|
var role string
|
||||||
|
a.UserID = userID
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT role, granted_by FROM role_assignments WHERE user_id = $1
|
||||||
|
`, userID).Scan(&role, &a.GrantedBy); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Assignment{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Assignment{}, fmt.Errorf("rollenzuweisung lesen: %w", err)
|
||||||
|
}
|
||||||
|
a.Role = Role(role)
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// History liefert jede Rollenzuweisung eines Benutzers in chronologischer
|
||||||
|
// Reihenfolge — die Grundlage fuer "wer hat wann welche Rolle vergeben"
|
||||||
|
// (Akzeptanzkriterium 3).
|
||||||
|
func (s *Store) History(ctx context.Context, userID string) ([]Assignment, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT role, granted_by FROM role_assignment_history
|
||||||
|
WHERE user_id = $1 ORDER BY granted_at
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("historie abfragen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Assignment
|
||||||
|
for rows.Next() {
|
||||||
|
var role string
|
||||||
|
a := Assignment{UserID: userID}
|
||||||
|
if err := rows.Scan(&role, &a.GrantedBy); err != nil {
|
||||||
|
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
|
||||||
|
}
|
||||||
|
a.Role = Role(role)
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package rbac
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupStoreTest(t *testing.T) (*Store, string, 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 users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS role_assignments (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES users(id),
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('user', 'tenant_admin')),
|
||||||
|
granted_by TEXT NOT NULL,
|
||||||
|
granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS role_assignment_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
granted_by TEXT NOT NULL,
|
||||||
|
granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userStore := user.NewTenantUserStore(pool)
|
||||||
|
u, err := userStore.Create(ctx, "rbac_test@example.com", "RBAC Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("testuser anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM role_assignment_history WHERE user_id = $1`, u.ID)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM role_assignments WHERE user_id = $1`, u.ID)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, u.ID)
|
||||||
|
pool.Close()
|
||||||
|
}
|
||||||
|
return NewStore(pool), u.ID, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1: Grundrollen sind einem Benutzer je Tenant zuweisbar.
|
||||||
|
func TestStore_AssignAndGet(t *testing.T) {
|
||||||
|
store, userID, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
assigned, err := store.Assign(ctx, userID, RoleTenantAdmin, "admin@acme.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("assign: %v", err)
|
||||||
|
}
|
||||||
|
if assigned.Role != RoleTenantAdmin {
|
||||||
|
t.Fatalf("erwartet tenant_admin, habe %q", assigned.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := store.Get(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Role != RoleTenantAdmin || got.GrantedBy != "admin@acme.example" {
|
||||||
|
t.Fatalf("zuweisung unerwartet: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 1: Zuweisung ausserhalb der erlaubten
|
||||||
|
// Matrix (superadmin im tenant-gescopten Store) wird abgewiesen.
|
||||||
|
func TestStore_RejectsSuperadminOutsideAllowedMatrix(t *testing.T) {
|
||||||
|
store, userID, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := store.Assign(ctx, userID, RoleSuperadmin, "admin@acme.example")
|
||||||
|
if !errors.Is(err, ErrRoleNotAssignableInTenantScope) {
|
||||||
|
t.Fatalf("erwartet ErrRoleNotAssignableInTenantScope, habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sicherstellen, dass der abgewiesene Versuch keine Zeile hinterlassen hat.
|
||||||
|
if _, err := store.Get(ctx, userID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("erwartet ErrNotFound nach abgewiesener zuweisung, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_RejectsUnknownRole(t *testing.T) {
|
||||||
|
store, userID, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := store.Assign(ctx, userID, Role("erfunden"), "admin@acme.example"); !errors.Is(err, ErrRoleNotAssignableInTenantScope) {
|
||||||
|
t.Fatalf("erwartet ErrRoleNotAssignableInTenantScope fuer unbekannte rolle, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3: Rollenzuweisung vollstaendig auditierbar.
|
||||||
|
func TestStore_HistoryTracksWhoAndWhen(t *testing.T) {
|
||||||
|
store, userID, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := store.Assign(ctx, userID, RoleUser, "system"); err != nil {
|
||||||
|
t.Fatalf("assign 1: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.Assign(ctx, userID, RoleTenantAdmin, "admin@acme.example"); err != nil {
|
||||||
|
t.Fatalf("assign 2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.History(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("history: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("erwartet 2 historieneintraege, habe %d", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Role != RoleUser || history[0].GrantedBy != "system" {
|
||||||
|
t.Fatalf("history[0] unerwartet: %+v", history[0])
|
||||||
|
}
|
||||||
|
if history[1].Role != RoleTenantAdmin || history[1].GrantedBy != "admin@acme.example" {
|
||||||
|
t.Fatalf("history[1] unerwartet: %+v", history[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,7 @@ package tenant
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler ist eine schlanke Vorbereitung der Schnittstelle fuer API-01
|
// Handler ist eine schlanke Vorbereitung der Schnittstelle fuer API-01
|
||||||
@@ -46,49 +43,3 @@ func (h *Handler) CreateTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
_ = json.NewEncoder(w).Encode(t)
|
_ = json.NewEncoder(w).Encode(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnboardingHandler stellt den Self-Service-Endpunkt aus TEN-02 bereit —
|
|
||||||
// eigener Handler statt Erweiterung von Handler.CreateTenant, damit die
|
|
||||||
// TEN-01-Schnittstelle (reines Provisioning ohne Admin-Konto) unveraendert
|
|
||||||
// bleibt.
|
|
||||||
type OnboardingHandler struct {
|
|
||||||
service *OnboardingService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewOnboardingHandler(s *OnboardingService) *OnboardingHandler {
|
|
||||||
return &OnboardingHandler{service: s}
|
|
||||||
}
|
|
||||||
|
|
||||||
type onboardRequest struct {
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
AdminEmail string `json:"admin_email"`
|
|
||||||
AdminName string `json:"admin_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *OnboardingHandler) Onboard(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req onboardRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.service.Onboard(r.Context(), req.Slug, req.Name, req.AdminEmail, req.AdminName)
|
|
||||||
if err != nil {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, ErrInvalidSlug),
|
|
||||||
errors.Is(err, ErrMissingField),
|
|
||||||
errors.Is(err, user.ErrInvalidEmail):
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
case errors.Is(err, ErrTenantExists):
|
|
||||||
http.Error(w, err.Error(), http.StatusConflict)
|
|
||||||
default:
|
|
||||||
http.Error(w, "mandant konnte nicht angelegt werden", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_ = json.NewEncoder(w).Encode(result)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrMissingField = errors.New("tenant: pflichtfeld fehlt")
|
|
||||||
|
|
||||||
// OnboardingService implementiert TEN-02: Self-Service-Anlage eines neuen
|
|
||||||
// Mandanten inklusive Ersteinrichtung des Administrator-Kontos in einem
|
|
||||||
// Vorgang. Schlaegt die Admin-Anlage fehl, wird der bereits provisionierte
|
|
||||||
// Tenant per Deprovision wieder vollstaendig entfernt (Saga-Kompensation,
|
|
||||||
// da CREATE DATABASE keine echte cross-database Transaktion erlaubt).
|
|
||||||
type OnboardingService struct {
|
|
||||||
provisioner *Provisioner
|
|
||||||
// tenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der
|
|
||||||
// die frisch angelegte Tenant-Datenbank initialisiert wird, bevor der
|
|
||||||
// erste Administrator angelegt werden kann.
|
|
||||||
tenantSchemaPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewOnboardingService(provisioner *Provisioner, tenantSchemaPath string) *OnboardingService {
|
|
||||||
return &OnboardingService{provisioner: provisioner, tenantSchemaPath: tenantSchemaPath}
|
|
||||||
}
|
|
||||||
|
|
||||||
type OnboardResult struct {
|
|
||||||
Tenant Tenant
|
|
||||||
Admin user.User
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OnboardingService) Onboard(ctx context.Context, slug, tenantName, adminEmail, adminName string) (OnboardResult, error) {
|
|
||||||
if tenantName == "" {
|
|
||||||
return OnboardResult{}, fmt.Errorf("%w: name", ErrMissingField)
|
|
||||||
}
|
|
||||||
if adminName == "" {
|
|
||||||
return OnboardResult{}, fmt.Errorf("%w: admin-name", ErrMissingField)
|
|
||||||
}
|
|
||||||
if err := ValidateSlug(slug); err != nil {
|
|
||||||
return OnboardResult{}, err
|
|
||||||
}
|
|
||||||
if err := user.ValidateEmail(adminEmail); err != nil {
|
|
||||||
return OnboardResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
t, err := s.provisioner.Provision(ctx, slug, tenantName)
|
|
||||||
if err != nil {
|
|
||||||
return OnboardResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
admin, err := s.setupAdmin(ctx, t, adminEmail, adminName)
|
|
||||||
if err != nil {
|
|
||||||
_ = s.provisioner.Deprovision(ctx, t)
|
|
||||||
return OnboardResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return OnboardResult{Tenant: t, Admin: admin}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OnboardingService) setupAdmin(ctx context.Context, t Tenant, adminEmail, adminName string) (user.User, error) {
|
|
||||||
pool, err := pgxpool.New(ctx, t.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
return user.User{}, fmt.Errorf("verbindung zur neuen tenant-datenbank: %w", err)
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
|
|
||||||
schema, err := os.ReadFile(s.tenantSchemaPath)
|
|
||||||
if err != nil {
|
|
||||||
return user.User{}, fmt.Errorf("tenant-schema lesen: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := pool.Exec(ctx, string(schema)); err != nil {
|
|
||||||
return user.User{}, fmt.Errorf("tenant-schema anwenden: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
store := user.NewTenantUserStore(pool)
|
|
||||||
return store.Create(ctx, adminEmail, adminName)
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
// schemaPathForTest liefert den Pfad zur echten Migration, damit Tests exakt
|
|
||||||
// dasselbe Schema anwenden wie die Produktion (keine Zweitkopie des SQL).
|
|
||||||
const schemaPathForTest = "../../migrations/tenant/0001_users.up.sql"
|
|
||||||
|
|
||||||
func TestOnboarding_ValidationErrors(t *testing.T) {
|
|
||||||
// Reine Eingabevalidierung, braucht keine Datenbank.
|
|
||||||
svc := NewOnboardingService(nil, schemaPathForTest)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
slug string
|
|
||||||
tenantName string
|
|
||||||
email string
|
|
||||||
adminName string
|
|
||||||
wantErr error
|
|
||||||
}{
|
|
||||||
{"leerer name", "acme", "", "a@b.de", "Admin", ErrMissingField},
|
|
||||||
{"leerer admin-name", "acme", "Acme", "a@b.de", "", ErrMissingField},
|
|
||||||
{"ungueltiger slug", "Acme!", "Acme", "a@b.de", "Admin", ErrInvalidSlug},
|
|
||||||
{"ungueltige email", "acme", "Acme", "keine-email", "Admin", user.ErrInvalidEmail},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
_, err := svc.Onboard(ctx, c.slug, c.tenantName, c.email, c.adminName)
|
|
||||||
if !errors.Is(err, c.wantErr) {
|
|
||||||
t.Fatalf("erwartet %v, habe %v", c.wantErr, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestOnboardingService(t *testing.T) (*OnboardingService, 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()
|
|
||||||
)`); err != nil {
|
|
||||||
t.Fatalf("registry-schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
registry := NewRegistry(registryPool)
|
|
||||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
||||||
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
|
||||||
svc := NewOnboardingService(provisioner, schemaPathForTest)
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
registryPool.Close()
|
|
||||||
adminPool.Close()
|
|
||||||
}
|
|
||||||
return svc, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterien 1+2: Mandant + Erst-Admin in einem Vorgang.
|
|
||||||
func TestOnboarding_CreatesTenantAndAdmin(t *testing.T) {
|
|
||||||
svc, cleanup := newTestOnboardingService(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
||||||
p, _ := pgxpool.New(ctx, adminDSN)
|
|
||||||
defer p.Close()
|
|
||||||
_, _ = p.Exec(ctx, `DELETE FROM tenants WHERE slug = 'onboardtest'`)
|
|
||||||
_, _ = p.Exec(ctx, `DROP DATABASE IF EXISTS tenant_onboardtest`)
|
|
||||||
})
|
|
||||||
|
|
||||||
result, err := svc.Onboard(ctx, "onboardtest", "Onboard Test GmbH", "admin@onboardtest.example", "Erste Admin")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("onboard: %v", err)
|
|
||||||
}
|
|
||||||
if result.Tenant.Slug != "onboardtest" {
|
|
||||||
t.Fatalf("tenant slug = %q", result.Tenant.Slug)
|
|
||||||
}
|
|
||||||
if result.Admin.Email != "admin@onboardtest.example" {
|
|
||||||
t.Fatalf("admin email = %q", result.Admin.Email)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := pgxpool.New(ctx, result.Tenant.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("connect tenant db: %v", err)
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
|
|
||||||
var count int
|
|
||||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "admin@onboardtest.example").Scan(&count); err != nil {
|
|
||||||
t.Fatalf("admin in tenant-db pruefen: %v", err)
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
t.Fatalf("erwartet genau einen admin in der tenant-db, habe %d", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 1: doppelte Registrierung wird abgewiesen,
|
|
||||||
// auch bei zwei parallelen Anfragen entsteht kein Doppel-Tenant.
|
|
||||||
func TestOnboarding_RejectsDuplicateSlugConcurrently(t *testing.T) {
|
|
||||||
svc, cleanup := newTestOnboardingService(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
||||||
p, _ := pgxpool.New(ctx, adminDSN)
|
|
||||||
defer p.Close()
|
|
||||||
_, _ = p.Exec(ctx, `DELETE FROM tenants WHERE slug = 'dupetest'`)
|
|
||||||
_, _ = p.Exec(ctx, `DROP DATABASE IF EXISTS tenant_dupetest`)
|
|
||||||
})
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
results := make([]error, 2)
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(i int) {
|
|
||||||
defer wg.Done()
|
|
||||||
_, err := svc.Onboard(ctx, "dupetest", "Dupe Test", "admin@dupetest.example", "Admin")
|
|
||||||
results[i] = err
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
successCount := 0
|
|
||||||
for _, err := range results {
|
|
||||||
if err == nil {
|
|
||||||
successCount++
|
|
||||||
} else if !errors.Is(err, ErrTenantExists) {
|
|
||||||
t.Fatalf("unerwarteter fehler: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if successCount != 1 {
|
|
||||||
t.Fatalf("erwartet genau einen erfolgreichen onboarding-vorgang, habe %d", successCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,17 +2,11 @@ package tenant
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrTenantExists wird zurueckgegeben, wenn Slug oder Datenbankname eines
|
|
||||||
// Mandanten bereits vergeben sind (TEN-02 Akzeptanzkriterium 3).
|
|
||||||
var ErrTenantExists = errors.New("tenant: slug bereits vergeben")
|
|
||||||
|
|
||||||
// Provisioner legt fuer jeden neuen Mandanten eine vollstaendig isolierte
|
// Provisioner legt fuer jeden neuen Mandanten eine vollstaendig isolierte
|
||||||
// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry
|
// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry
|
||||||
// (Akzeptanzkriterium 2). Zwei Mandanten-Datenbanken sind danach auf
|
// (Akzeptanzkriterium 2). Zwei Mandanten-Datenbanken sind danach auf
|
||||||
@@ -45,14 +39,6 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
|
|||||||
// CREATE DATABASE erlaubt keine Parameter-Platzhalter; slug ist durch
|
// CREATE DATABASE erlaubt keine Parameter-Platzhalter; slug ist durch
|
||||||
// ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen.
|
// ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen.
|
||||||
if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
|
if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
// 42P04 (duplicate_database) im Normalfall; bei zwei echt parallelen
|
|
||||||
// CREATE DATABASE auf denselben Namen liefert Postgres stattdessen den
|
|
||||||
// internen Unique-Index-Konflikt 23505 auf pg_database — beide Faelle
|
|
||||||
// bedeuten: der Tenant existiert bereits bzw. wird gerade angelegt.
|
|
||||||
if errors.As(err, &pgErr) && (pgErr.Code == "42P04" || pgErr.Code == "23505") {
|
|
||||||
return Tenant{}, ErrTenantExists
|
|
||||||
}
|
|
||||||
return Tenant{}, fmt.Errorf("tenant-datenbank anlegen: %w", err)
|
return Tenant{}, fmt.Errorf("tenant-datenbank anlegen: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +60,6 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tx.Rollback(ctx)
|
_ = tx.Rollback(ctx)
|
||||||
p.rollbackDatabase(ctx, dbName)
|
p.rollbackDatabase(ctx, dbName)
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique_violation auf slug
|
|
||||||
return Tenant{}, ErrTenantExists
|
|
||||||
}
|
|
||||||
return Tenant{}, err
|
return Tenant{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,16 +76,3 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
|
|||||||
func (p *Provisioner) rollbackDatabase(ctx context.Context, dbName string) {
|
func (p *Provisioner) rollbackDatabase(ctx context.Context, dbName string) {
|
||||||
_, _ = p.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
_, _ = p.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deprovision entfernt einen bereits erfolgreich registrierten Tenant wieder
|
|
||||||
// vollstaendig (Registry-Eintrag + Datenbank). Wird als Kompensation
|
|
||||||
// verwendet, wenn ein Schritt NACH Provision fehlschlaegt (z.B. die
|
|
||||||
// Admin-Account-Anlage in TEN-02) — Provision selbst bleibt dadurch die
|
|
||||||
// einzige Stelle, die "erfolgreich provisioniert" garantieren muss.
|
|
||||||
func (p *Provisioner) Deprovision(ctx context.Context, t Tenant) error {
|
|
||||||
if err := p.registry.Delete(ctx, t.ID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
p.rollbackDatabase(ctx, t.DBName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -47,16 +47,6 @@ func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
|
|||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete entfernt einen Registry-Eintrag. Wird von Provisioner.Deprovision
|
|
||||||
// als Kompensation verwendet, wenn nach erfolgreicher Registrierung ein
|
|
||||||
// nachgelagerter Schritt (z.B. Admin-Account-Anlage in TEN-02) fehlschlaegt.
|
|
||||||
func (r *Registry) Delete(ctx context.Context, id string) error {
|
|
||||||
if _, err := r.pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, id); err != nil {
|
|
||||||
return fmt.Errorf("tenant loeschen: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
|
func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
|
||||||
rows, err := r.pool.Query(ctx, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
SELECT id, slug, name, db_name, db_dsn, status, created_at
|
SELECT id, slug, name, db_name, db_dsn, status, created_at
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
package tenantsettings
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handler stellt Lesen/Schreiben der Tenant-Einstellungen als schlanke
|
|
||||||
// Vorbereitung der Schnittstelle bereit (API-01/TEN-05 sind nicht Teil
|
|
||||||
// dieser Kachel). tenantID wird hier als Pfad-/Query-Parameter erwartet,
|
|
||||||
// eine echte Auth-/Berechtigungspruefung kommt erst mit IAM-02/RBAC-01.
|
|
||||||
type Handler struct {
|
|
||||||
store *Store
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHandler(store *Store) *Handler {
|
|
||||||
return &Handler{store: store}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
|
||||||
tenantID := r.URL.Query().Get("tenant_id")
|
|
||||||
if tenantID == "" {
|
|
||||||
http.Error(w, "tenant_id fehlt", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
settings, err := h.store.Get(r.Context(), tenantID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "einstellungen konnten nicht geladen werden", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
type updateRequest struct {
|
|
||||||
DisplayName *string `json:"display_name"`
|
|
||||||
LogoURL *string `json:"logo_url"`
|
|
||||||
ColorScheme *string `json:"color_scheme"`
|
|
||||||
Timezone *string `json:"timezone"`
|
|
||||||
Language *string `json:"language"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|
||||||
tenantID := r.URL.Query().Get("tenant_id")
|
|
||||||
if tenantID == "" {
|
|
||||||
http.Error(w, "tenant_id fehlt", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req updateRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
settings, err := h.store.Update(r.Context(), tenantID, Patch{
|
|
||||||
DisplayName: req.DisplayName,
|
|
||||||
LogoURL: req.LogoURL,
|
|
||||||
ColorScheme: req.ColorScheme,
|
|
||||||
Timezone: req.Timezone,
|
|
||||||
Language: req.Language,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "einstellungen konnten nicht gespeichert werden", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
package tenantsettings
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupTest(t *testing.T) (*Store, string, string, 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 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()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS tenant_settings (
|
|
||||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
|
|
||||||
display_name TEXT, logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT,
|
|
||||||
version INT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS tenant_settings_history (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL,
|
|
||||||
display_name TEXT, logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT,
|
|
||||||
version INT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var tenantA, tenantB string
|
|
||||||
if err := pool.QueryRow(ctx, `INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ('ts_a', 'A', 'tenant_ts_a', 'unused') RETURNING id`).Scan(&tenantA); err != nil {
|
|
||||||
t.Fatalf("tenant a anlegen: %v", err)
|
|
||||||
}
|
|
||||||
if err := pool.QueryRow(ctx, `INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ('ts_b', 'B', 'tenant_ts_b', 'unused') RETURNING id`).Scan(&tenantB); err != nil {
|
|
||||||
t.Fatalf("tenant b anlegen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM tenant_settings_history WHERE tenant_id IN ($1, $2)`, tenantA, tenantB)
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM tenant_settings WHERE tenant_id IN ($1, $2)`, tenantA, tenantB)
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id IN ($1, $2)`, tenantA, tenantB)
|
|
||||||
pool.Close()
|
|
||||||
}
|
|
||||||
return NewStore(pool), tenantA, tenantB, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
func strp(s string) *string { return &s }
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 2: fehlende Werte liefern Defaults, kein Fehler.
|
|
||||||
func TestGet_UnsetTenantReturnsDefaults(t *testing.T) {
|
|
||||||
store, tenantA, _, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
got, err := store.Get(ctx, tenantA)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get: %v", err)
|
|
||||||
}
|
|
||||||
want := Defaults()
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("erwartet defaults %+v, habe %+v", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + 2: Teil-Update laesst nicht gesetzte Felder auf Default.
|
|
||||||
func TestUpdate_PartialPatchKeepsOtherFieldsAtDefault(t *testing.T) {
|
|
||||||
store, tenantA, _, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
got, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("Acme GmbH")})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("update: %v", err)
|
|
||||||
}
|
|
||||||
if got.DisplayName != "Acme GmbH" {
|
|
||||||
t.Fatalf("erwartet 'Acme GmbH', habe %q", got.DisplayName)
|
|
||||||
}
|
|
||||||
d := Defaults()
|
|
||||||
if got.ColorScheme != d.ColorScheme || got.Timezone != d.Timezone || got.Language != d.Language {
|
|
||||||
t.Fatalf("nicht gesetzte felder haetten auf default bleiben sollen, habe %+v", got)
|
|
||||||
}
|
|
||||||
if got.Version != 1 {
|
|
||||||
t.Fatalf("erwartet version 1, habe %d", got.Version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 1: Aenderung eines Tenants wirkt sich
|
|
||||||
// nicht auf einen anderen aus.
|
|
||||||
func TestUpdate_IsolatedBetweenTenants(t *testing.T) {
|
|
||||||
store, tenantA, tenantB, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("Tenant A")}); err != nil {
|
|
||||||
t.Fatalf("update a: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gotB, err := store.Get(ctx, tenantB)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get b: %v", err)
|
|
||||||
}
|
|
||||||
if gotB != Defaults() {
|
|
||||||
t.Fatalf("tenant b sollte unberuehrt bei defaults bleiben, habe %+v", gotB)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3: Aenderungen versioniert nachvollziehbar.
|
|
||||||
func TestUpdate_HistoryTracksVersions(t *testing.T) {
|
|
||||||
store, tenantA, _, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("v1")}); err != nil {
|
|
||||||
t.Fatalf("update 1: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("v2")}); err != nil {
|
|
||||||
t.Fatalf("update 2: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.Update(ctx, tenantA, Patch{ColorScheme: strp("dark")}); err != nil {
|
|
||||||
t.Fatalf("update 3: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
history, err := store.History(ctx, tenantA)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("history: %v", err)
|
|
||||||
}
|
|
||||||
if len(history) != 3 {
|
|
||||||
t.Fatalf("erwartet 3 historieneintraege, habe %d", len(history))
|
|
||||||
}
|
|
||||||
if history[0].DisplayName != "v1" || history[0].Version != 1 {
|
|
||||||
t.Fatalf("history[0] unerwartet: %+v", history[0])
|
|
||||||
}
|
|
||||||
if history[1].DisplayName != "v2" || history[1].Version != 2 {
|
|
||||||
t.Fatalf("history[1] unerwartet: %+v", history[1])
|
|
||||||
}
|
|
||||||
if history[2].ColorScheme != "dark" || history[2].DisplayName != "v2" || history[2].Version != 3 {
|
|
||||||
t.Fatalf("history[2] unerwartet (display_name haette aus vorherigem update erhalten bleiben sollen): %+v", history[2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
// Package tenantsettings implementiert Core TEN-03: pro-Tenant-Einstellungen
|
|
||||||
// (Anzeigename, Logo, Farbschema, Zeitzone, Sprache) mit versionierter
|
|
||||||
// Aenderungshistorie und dokumentierten Systemvoreinstellungen.
|
|
||||||
package tenantsettings
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Settings ist der vollstaendig AUFGELOESTE Stand (jede leere Einstellung ist
|
|
||||||
// bereits durch ihren Default ersetzt) — Aufrufer muessen sich nie selbst um
|
|
||||||
// Systemvoreinstellungen kuemmern (Akzeptanzkriterium 2).
|
|
||||||
type Settings struct {
|
|
||||||
DisplayName string
|
|
||||||
LogoURL string
|
|
||||||
ColorScheme string
|
|
||||||
Timezone string
|
|
||||||
Language string
|
|
||||||
Version int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defaults liefert die dokumentierten Systemvoreinstellungen.
|
|
||||||
func Defaults() Settings {
|
|
||||||
return Settings{
|
|
||||||
DisplayName: "",
|
|
||||||
LogoURL: "",
|
|
||||||
ColorScheme: "system",
|
|
||||||
Timezone: "UTC",
|
|
||||||
Language: "de",
|
|
||||||
Version: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patch enthaelt nur die Felder, die tatsaechlich geaendert werden sollen —
|
|
||||||
// nil bedeutet "unveraendert lassen", nicht "auf Default zuruecksetzen".
|
|
||||||
type Patch struct {
|
|
||||||
DisplayName *string
|
|
||||||
LogoURL *string
|
|
||||||
ColorScheme *string
|
|
||||||
Timezone *string
|
|
||||||
Language *string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Store struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStore(pool *pgxpool.Pool) *Store {
|
|
||||||
return &Store{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get liefert die aufgeloesten Einstellungen eines Tenants. Existiert noch
|
|
||||||
// kein Datensatz oder sind einzelne Spalten NULL, werden die jeweiligen
|
|
||||||
// Systemvoreinstellungen eingesetzt (Akzeptanzkriterium 2 / Pruefung 2) —
|
|
||||||
// niemals ein Fehler wegen fehlender Werte.
|
|
||||||
func (s *Store) Get(ctx context.Context, tenantID string) (Settings, error) {
|
|
||||||
var displayName, logoURL, colorScheme, timezone, language *string
|
|
||||||
var version int
|
|
||||||
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT display_name, logo_url, color_scheme, timezone, language, version
|
|
||||||
FROM tenant_settings WHERE tenant_id = $1
|
|
||||||
`, tenantID)
|
|
||||||
err := row.Scan(&displayName, &logoURL, &colorScheme, &timezone, &language, &version)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Defaults(), nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Settings{}, fmt.Errorf("einstellungen lesen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
d := Defaults()
|
|
||||||
return Settings{
|
|
||||||
DisplayName: orDefault(displayName, d.DisplayName),
|
|
||||||
LogoURL: orDefault(logoURL, d.LogoURL),
|
|
||||||
ColorScheme: orDefault(colorScheme, d.ColorScheme),
|
|
||||||
Timezone: orDefault(timezone, d.Timezone),
|
|
||||||
Language: orDefault(language, d.Language),
|
|
||||||
Version: version,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func orDefault(v *string, def string) string {
|
|
||||||
if v == nil {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
return *v
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update wendet patch auf den aktuellen Stand an, erhoeht die Version und
|
|
||||||
// schreibt Stand + Historieneintrag atomar (Akzeptanzkriterium 3). Nicht in
|
|
||||||
// patch gesetzte Felder bleiben unveraendert (bzw. weiterhin auf Default,
|
|
||||||
// wenn sie es vorher schon waren).
|
|
||||||
func (s *Store) Update(ctx context.Context, tenantID string, patch Patch) (Settings, error) {
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return Settings{}, fmt.Errorf("transaktion starten: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = tx.Rollback(ctx) }()
|
|
||||||
|
|
||||||
var rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage *string
|
|
||||||
var version int
|
|
||||||
row := tx.QueryRow(ctx, `
|
|
||||||
SELECT display_name, logo_url, color_scheme, timezone, language, version
|
|
||||||
FROM tenant_settings WHERE tenant_id = $1 FOR UPDATE
|
|
||||||
`, tenantID)
|
|
||||||
err = row.Scan(&rawDisplayName, &rawLogoURL, &rawColorScheme, &rawTimezone, &rawLanguage, &version)
|
|
||||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Settings{}, fmt.Errorf("aktuellen stand lesen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if patch.DisplayName != nil {
|
|
||||||
rawDisplayName = patch.DisplayName
|
|
||||||
}
|
|
||||||
if patch.LogoURL != nil {
|
|
||||||
rawLogoURL = patch.LogoURL
|
|
||||||
}
|
|
||||||
if patch.ColorScheme != nil {
|
|
||||||
rawColorScheme = patch.ColorScheme
|
|
||||||
}
|
|
||||||
if patch.Timezone != nil {
|
|
||||||
rawTimezone = patch.Timezone
|
|
||||||
}
|
|
||||||
if patch.Language != nil {
|
|
||||||
rawLanguage = patch.Language
|
|
||||||
}
|
|
||||||
newVersion := version + 1
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
INSERT INTO tenant_settings (tenant_id, display_name, logo_url, color_scheme, timezone, language, version, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
|
||||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
|
||||||
display_name = $2, logo_url = $3, color_scheme = $4, timezone = $5, language = $6, version = $7, updated_at = now()
|
|
||||||
`, tenantID, rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage, newVersion); err != nil {
|
|
||||||
return Settings{}, fmt.Errorf("einstellungen speichern: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
INSERT INTO tenant_settings_history (tenant_id, display_name, logo_url, color_scheme, timezone, language, version, changed_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
|
||||||
`, tenantID, rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage, newVersion); err != nil {
|
|
||||||
return Settings{}, fmt.Errorf("historie schreiben: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return Settings{}, fmt.Errorf("transaktion committen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.Get(ctx, tenantID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// History liefert die vollstaendige Versionshistorie eines Tenants
|
|
||||||
// (Akzeptanzkriterium 3 / Pruefung 3-Vorbereitung).
|
|
||||||
func (s *Store) History(ctx context.Context, tenantID string) ([]Settings, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
|
||||||
SELECT display_name, logo_url, color_scheme, timezone, language, version
|
|
||||||
FROM tenant_settings_history WHERE tenant_id = $1 ORDER BY version
|
|
||||||
`, tenantID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("historie abfragen: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
d := Defaults()
|
|
||||||
var out []Settings
|
|
||||||
for rows.Next() {
|
|
||||||
var displayName, logoURL, colorScheme, timezone, language *string
|
|
||||||
var version int
|
|
||||||
if err := rows.Scan(&displayName, &logoURL, &colorScheme, &timezone, &language, &version); err != nil {
|
|
||||||
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, Settings{
|
|
||||||
DisplayName: orDefault(displayName, d.DisplayName),
|
|
||||||
LogoURL: orDefault(logoURL, d.LogoURL),
|
|
||||||
ColorScheme: orDefault(colorScheme, d.ColorScheme),
|
|
||||||
Timezone: orDefault(timezone, d.Timezone),
|
|
||||||
Language: orDefault(language, d.Language),
|
|
||||||
Version: version,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS policy_rule_changes;
|
||||||
|
DROP TABLE IF EXISTS policy_rules;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Zentrale, deklarative Policy-Regeln (RBAC-02, siehe core-kanban/tickets/RBAC-02.md).
|
||||||
|
-- policy_rules haelt den AKTUELLEN, gewaehrten Regelsatz (Existenz = erlaubt,
|
||||||
|
-- Default-Deny fuer alles ohne Zeile). policy_rule_changes ist die
|
||||||
|
-- versionierte Aenderungshistorie (Akzeptanzkriterium 3: Regelwechsel ohne
|
||||||
|
-- Codeaenderung nachvollziehbar).
|
||||||
|
CREATE TABLE policy_rules (
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
granted_by TEXT NOT NULL,
|
||||||
|
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (role, permission)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE policy_rule_changes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL CHECK (action IN ('grant', 'revoke')),
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
version INT NOT NULL,
|
||||||
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX policy_rule_changes_idx ON policy_rule_changes (role, permission, version);
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS tenant_settings_history;
|
|
||||||
DROP TABLE IF EXISTS tenant_settings;
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
-- Pro-Tenant-Einstellungen & Branding (TEN-03, siehe core-kanban/tickets/TEN-03.md).
|
|
||||||
-- Alle Spalten NULLABLE: ein fehlender Wert bedeutet "System-Default
|
|
||||||
-- verwenden" (Akzeptanzkriterium 2), niemals ein Fehler.
|
|
||||||
CREATE TABLE tenant_settings (
|
|
||||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
|
|
||||||
display_name TEXT,
|
|
||||||
logo_url TEXT,
|
|
||||||
color_scheme TEXT,
|
|
||||||
timezone TEXT,
|
|
||||||
language TEXT,
|
|
||||||
version INT NOT NULL DEFAULT 0,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE tenant_settings_history (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL,
|
|
||||||
display_name TEXT,
|
|
||||||
logo_url TEXT,
|
|
||||||
color_scheme TEXT,
|
|
||||||
timezone TEXT,
|
|
||||||
language TEXT,
|
|
||||||
version INT NOT NULL,
|
|
||||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX tenant_settings_history_tenant_idx ON tenant_settings_history (tenant_id, version);
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS role_assignment_history;
|
||||||
|
DROP TABLE IF EXISTS role_assignments;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Rollenzuweisung pro Benutzer (RBAC-01, siehe core-kanban/tickets/RBAC-01.md).
|
||||||
|
-- Nur 'user' und 'tenant_admin' sind hier zuweisbar — 'superadmin' lebt
|
||||||
|
-- mandantenuebergreifend in der Registry (IAM-01 SuperadminStore) und hat
|
||||||
|
-- daher bewusst KEINE Zeile in dieser tenant-lokalen Tabelle (Akzeptanz-
|
||||||
|
-- kriterium 1: nur Zuweisungen innerhalb der erlaubten Matrix).
|
||||||
|
CREATE TABLE role_assignments (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES users(id),
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('user', 'tenant_admin')),
|
||||||
|
granted_by TEXT NOT NULL,
|
||||||
|
granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Vollstaendige Historie jeder Rollenaenderung (Akzeptanzkriterium 3: wer
|
||||||
|
-- hat wann welche Rolle vergeben).
|
||||||
|
CREATE TABLE role_assignment_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
granted_by TEXT NOT NULL,
|
||||||
|
granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX role_assignment_history_user_idx ON role_assignment_history (user_id, granted_at);
|
||||||
@@ -13,8 +13,6 @@ 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 tenant_settings_history CASCADE;"
|
|
||||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings CASCADE;"
|
|
||||||
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 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 '\'")
|
||||||
|
|||||||
Reference in New Issue
Block a user