Compare commits

..
Author SHA1 Message Date
sysopsandClaude Sonnet 5 e4793303fc IAM-01: benutzer-datenmodell-crud
Benutzer-Datenmodell + CRUD fuer Tenant-User (tenant-scoped, keine
tenant_id-Spalte noetig, Tenant ergibt sich aus der DB-Verbindung, Modell C)
und getrennt dafuer SuperadminStore fuer mandantenuebergreifende Konten in
der Registry-DB — First-Class-Typ statt tenant_id-NULL-Sonderfall im
Tenant-User-Code (bekannter archivdms-Fehler vermieden).

E-Mail-Eindeutigkeit: tenant-scoped fuer normale Benutzer (UNIQUE-Constraint
gilt nur innerhalb der jeweiligen Tenant-DB), global fuer Superadmins
(eine Registry-DB, ein UNIQUE-Constraint).

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. CRUD automatisiert getestet inkl. Negativfaellen — TestTenantUserStore_CRUD
   deckt doppelte E-Mail (ErrEmailTaken) und unbekannte ID ab. PASS.
2. Superadmin-Anlage ohne Tenant-Kontext — TestSuperadminStore_CreateWithoutTenantContext:
   SuperadminStore.Create hat syntaktisch keinen Tenant-Parameter, kein
   if-Zweig fuer "kein Tenant" im Code. PASS.
3. Datenmodell von zweiter Person gegen Dokumentation geprueft — NICHT
   durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen.

Tenant-User-Handler ist im Code vorhanden, aber in cmd/core/main.go noch
nicht geroutet — braucht Connection-Routing pro Mandant (TEN-06), das nicht
Teil dieser Kachel ist. Nur der Superadmin-Endpunkt ist verdrahtet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:57:49 +02:00
13 changed files with 552 additions and 297 deletions
+10 -1
View File
@@ -8,6 +8,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/config"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func main() {
@@ -34,12 +35,20 @@ func main() {
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
tenantHandler := tenant.NewHandler(provisioner)
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
// Mandant (TEN-06, noch nicht gebaut) und wird hier bewusst noch nicht
// verdrahtet — Package ist bereits eigenstaendig nutzbar/testbar.
superadmins := user.NewSuperadminStore(registryPool)
userHandler := user.NewHandler(nil, superadmins)
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 abgeloest.
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
-136
View File
@@ -1,136 +0,0 @@
package tenant
import (
"container/list"
"context"
"errors"
"fmt"
"sync"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrMissingTenantContext wird geliefert, wenn keine Tenant-Kennung
// uebergeben wurde — es gibt bewusst keinen impliziten Default-Tenant
// (TEN-06 Akzeptanzkriterium 3).
var ErrMissingTenantContext = errors.New("tenant: kein tenant-kontext angegeben")
// ErrUnknownTenant wird geliefert, wenn die Tenant-Kennung in der Registry
// nicht existiert.
var ErrUnknownTenant = errors.New("tenant: unbekannter tenant")
// Router loest den Tenant-Kontext (Slug, aus dem JWT-Claim von API-05) in
// eine wiederverwendbare Verbindung zur richtigen Tenant-Datenbank auf.
// Ein LRU-verwalteter Cache begrenzt die Zahl gleichzeitig offener
// pgxpool.Pool-Instanzen, damit die Zahl offener Postgres-Verbindungen NICHT
// linear mit der Mandantenzahl waechst (Akzeptanzkriterium 2).
type Router struct {
registry *Registry
maxOpen int
mu sync.Mutex
order *list.List // vorne = zuletzt benutzt
items map[string]*list.Element // slug -> element mit *routerEntry
}
type routerEntry struct {
slug string
pool *pgxpool.Pool
}
func NewRouter(registry *Registry, maxOpen int) *Router {
if maxOpen < 1 {
maxOpen = 1
}
return &Router{
registry: registry,
maxOpen: maxOpen,
order: list.New(),
items: make(map[string]*list.Element),
}
}
// Resolve liefert einen wiederverwendeten Pool fuer den angegebenen Tenant.
// Ist der Tenant bereits im Cache, wird KEINE neue Verbindung aufgebaut
// (Akzeptanzkriterium 2 / Pruefung 3).
func (r *Router) Resolve(ctx context.Context, tenantSlug string) (*pgxpool.Pool, error) {
if tenantSlug == "" {
return nil, ErrMissingTenantContext
}
r.mu.Lock()
if el, ok := r.items[tenantSlug]; ok {
r.order.MoveToFront(el)
pool := el.Value.(*routerEntry).pool
r.mu.Unlock()
return pool, nil
}
r.mu.Unlock()
// Registry-Lookup und Verbindungsaufbau bewusst ausserhalb des Locks,
// damit ein langsamer Verbindungsaufbau nicht alle anderen Tenants blockiert.
t, err := r.registry.GetBySlug(ctx, tenantSlug)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrUnknownTenant, tenantSlug)
}
pool, err := pgxpool.New(ctx, t.DBDSN)
if err != nil {
return nil, fmt.Errorf("verbindung zu tenant %q aufbauen: %w", tenantSlug, err)
}
r.mu.Lock()
defer r.mu.Unlock()
// Zwischen Unlock oben und hier koennte ein paralleler Aufruf denselben
// Tenant bereits eingefuegt haben — dann die eigene, ueberzaehlige
// Verbindung wieder schliessen und die vorhandene verwenden.
if el, ok := r.items[tenantSlug]; ok {
r.order.MoveToFront(el)
existing := el.Value.(*routerEntry).pool
pool.Close()
return existing, nil
}
el := r.order.PushFront(&routerEntry{slug: tenantSlug, pool: pool})
r.items[tenantSlug] = el
if r.order.Len() > r.maxOpen {
r.evictOldest()
}
return pool, nil
}
// evictOldest schliesst den am laengsten nicht genutzten Pool. Muss mit
// gehaltenem r.mu aufgerufen werden.
func (r *Router) evictOldest() {
oldest := r.order.Back()
if oldest == nil {
return
}
entry := oldest.Value.(*routerEntry)
r.order.Remove(oldest)
delete(r.items, entry.slug)
entry.pool.Close()
}
// OpenCount liefert die aktuelle Zahl offen gehaltener Tenant-Pools —
// dient Tests/Monitoring, um AC2 nachzuweisen.
func (r *Router) OpenCount() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.order.Len()
}
// Close schliesst alle offen gehaltenen Tenant-Pools, z.B. beim
// Herunterfahren des Core-Prozesses.
func (r *Router) Close() {
r.mu.Lock()
defer r.mu.Unlock()
for el := r.order.Front(); el != nil; el = el.Next() {
el.Value.(*routerEntry).pool.Close()
}
r.order.Init()
r.items = make(map[string]*list.Element)
}
-160
View File
@@ -1,160 +0,0 @@
package tenant
import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func newTestRouterSetup(t *testing.T, tenantCount int) (*Router, []Tenant, 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)
var tenants []Tenant
var slugs []string
for i := 0; i < tenantCount; i++ {
slug := fmt.Sprintf("router_t%d", i)
slugs = append(slugs, slug)
tn, err := provisioner.Provision(ctx, slug, slug)
if err != nil {
t.Fatalf("provision %s: %v", slug, err)
}
tenants = append(tenants, tn)
}
router := NewRouter(registry, 2) // klein gewaehlt, um Eviction im Test zu erzwingen
cleanup := func() {
router.Close()
for _, slug := range slugs {
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug)))
}
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs)
registryPool.Close()
adminPool.Close()
}
return router, tenants, cleanup
}
// Akzeptanzkriterium 1: Verbindung wird zuverlaessig anhand des Tenant-Kontexts aufgeloest.
func TestRouter_ResolvesCorrectTenantDatabase(t *testing.T) {
router, tenants, cleanup := newTestRouterSetup(t, 2)
defer cleanup()
ctx := context.Background()
pool, err := router.Resolve(ctx, tenants[0].Slug)
if err != nil {
t.Fatalf("resolve: %v", err)
}
var dbName string
if err := pool.QueryRow(ctx, `SELECT current_database()`).Scan(&dbName); err != nil {
t.Fatalf("current_database: %v", err)
}
if dbName != tenants[0].DBName {
t.Fatalf("current_database() = %q, want %q", dbName, tenants[0].DBName)
}
}
// Akzeptanzkriterium 3 + Pruefung 2: fehlender/unbekannter Tenant-Kontext
// wird explizit abgewiesen statt irgendeine Verbindung zu liefern.
func TestRouter_RejectsMissingOrUnknownTenant(t *testing.T) {
router, _, cleanup := newTestRouterSetup(t, 1)
defer cleanup()
ctx := context.Background()
if _, err := router.Resolve(ctx, ""); !errors.Is(err, ErrMissingTenantContext) {
t.Fatalf("erwartet ErrMissingTenantContext, habe %v", err)
}
if _, err := router.Resolve(ctx, "nie-registrierter-slug"); !errors.Is(err, ErrUnknownTenant) {
t.Fatalf("erwartet ErrUnknownTenant, habe %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 3: Verbindungswiederverwendung nachweislich
// gemessen — zweiter Resolve-Aufruf liefert exakt denselben Pool, kein
// erneuter Verbindungsaufbau.
func TestRouter_ReusesConnectionForSameTenant(t *testing.T) {
router, tenants, cleanup := newTestRouterSetup(t, 1)
defer cleanup()
ctx := context.Background()
first, err := router.Resolve(ctx, tenants[0].Slug)
if err != nil {
t.Fatalf("resolve 1: %v", err)
}
second, err := router.Resolve(ctx, tenants[0].Slug)
if err != nil {
t.Fatalf("resolve 2: %v", err)
}
if first != second {
t.Fatal("erwartet identische pool-instanz bei wiederholtem resolve, habe unterschiedliche")
}
}
// Akzeptanzkriterium 2 + Pruefung 1: Lasttest mit mehr simulierten Mandanten
// als maxOpen — die Zahl gleichzeitig offener Tenant-Pools bleibt begrenzt
// (LRU-Eviction), waechst also NICHT linear mit der Mandantenzahl.
func TestRouter_BoundsOpenConnectionsUnderLoad(t *testing.T) {
const tenantCount = 6
router, tenants, cleanup := newTestRouterSetup(t, tenantCount)
defer cleanup()
ctx := context.Background()
for _, tn := range tenants {
if _, err := router.Resolve(ctx, tn.Slug); err != nil {
t.Fatalf("resolve %s: %v", tn.Slug, err)
}
if router.OpenCount() > 2 {
t.Fatalf("OpenCount() = %d, erwartet <= maxOpen (2) nach jedem Resolve", router.OpenCount())
}
}
if router.OpenCount() != 2 {
t.Fatalf("erwartet genau maxOpen=2 offene pools nach %d tenants, habe %d", tenantCount, router.OpenCount())
}
// Evictete Tenants sind wieder ganz normal ueber die Registry aufloesbar
// (Cache-Miss fuehrt zu neuem, funktionierendem Pool, kein Fehlerzustand).
pool, err := router.Resolve(ctx, tenants[0].Slug)
if err != nil {
t.Fatalf("resolve nach eviction: %v", err)
}
var one int
if err := pool.QueryRow(ctx, `SELECT 1`).Scan(&one); err != nil {
t.Fatalf("query nach re-resolve: %v", err)
}
}
+66
View File
@@ -0,0 +1,66 @@
package user
import (
"encoding/json"
"errors"
"net/http"
)
// Handler stellt die CRUD-API fuer Benutzerkonten bereit (IAM-01-Auftrag).
// Auth/Sessions (IAM-02) und Rollen (RBAC-01) sind ausdruecklich nicht Teil
// dieser Kachel und daher hier noch nicht angebunden.
type Handler struct {
users *TenantUserStore
superadmins *SuperadminStore
}
func NewHandler(users *TenantUserStore, superadmins *SuperadminStore) *Handler {
return &Handler{users: users, superadmins: superadmins}
}
type createUserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
}
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
u, err := h.users.Create(r.Context(), req.Email, req.Name)
writeUserResult(w, u, err)
}
// CreateSuperadmin legt ein mandantenuebergreifendes Superadmin-Konto an —
// bewusst ein eigener Endpunkt statt eines Tenant-Parameters mit Null-Wert.
func (h *Handler) CreateSuperadmin(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
u, err := h.superadmins.Create(r.Context(), req.Email, req.Name)
writeUserResult(w, u, err)
}
func writeUserResult(w http.ResponseWriter, u User, err error) {
if err != nil {
switch {
case errors.Is(err, ErrInvalidEmail), errors.Is(err, ErrEmailTaken):
http.Error(w, err.Error(), http.StatusBadRequest)
case errors.Is(err, ErrNotFound):
http.Error(w, err.Error(), http.StatusNotFound)
default:
http.Error(w, "benutzer konnte nicht verarbeitet werden", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(u)
}
+173
View File
@@ -0,0 +1,173 @@
package user
import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// setupTestDB legt eine frische, isolierte Testdatenbank an, wendet die
// uebergebene Migration an und liefert einen verbundenen Pool. Wird ohne
// TEST_ADMIN_DSN uebersprungen — siehe internal/tenant/provisioner_test.go
// fuer dasselbe Muster.
func setupTestDB(t *testing.T, dbName, schemaSQL string) *pgxpool.Pool {
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)
}
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
t.Fatalf("testdatenbank anlegen: %v", err)
}
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect testdatenbank: %v", err)
}
if _, err := pool.Exec(ctx, schemaSQL); err != nil {
t.Fatalf("schema anwenden: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
adminPool.Close()
})
return pool
}
const usersSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE 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()
);`
const superadminsSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE superadmins (
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()
);`
// Akzeptanzkriterium 1 + 3, Pruefung 1 (inkl. Negativfall doppelte E-Mail).
func TestTenantUserStore_CRUD(t *testing.T) {
pool := setupTestDB(t, "test_iam01_users", usersSchema)
store := NewTenantUserStore(pool)
ctx := context.Background()
created, err := store.Create(ctx, "alice@example.com", "Alice")
if err != nil {
t.Fatalf("create: %v", err)
}
if created.Status != StatusActive {
t.Fatalf("erwartet status active, hat %q", created.Status)
}
got, err := store.Get(ctx, created.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Email != "alice@example.com" {
t.Fatalf("get email = %q", got.Email)
}
updated, err := store.Update(ctx, created.ID, "", "Alice A.")
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Name != "Alice A." || updated.Email != "alice@example.com" {
t.Fatalf("update ergebnis unerwartet: %+v", updated)
}
list, err := store.List(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list) != 1 {
t.Fatalf("erwartet 1 benutzer, habe %d", len(list))
}
deactivated, err := store.Deactivate(ctx, created.ID)
if err != nil {
t.Fatalf("deactivate: %v", err)
}
if deactivated.Status != StatusInactive {
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
}
// Negativfall: doppelte E-Mail-Adresse.
if _, err := store.Create(ctx, "second@example.com", "Bob"); err != nil {
t.Fatalf("create second: %v", err)
}
if _, err := store.Create(ctx, "second@example.com", "Bob Zwei"); !errors.Is(err, ErrEmailTaken) {
t.Fatalf("erwartet ErrEmailTaken, habe %v", err)
}
// Negativfall: fehlender Benutzer.
if _, err := store.Get(ctx, created.ID+"-nicht-vorhanden"); err == nil {
t.Fatalf("erwartet fehler bei unbekannter/ungueltiger id")
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Superadmin-Anlage ohne Tenant-Kontext.
// SuperadminStore.Create hat keinen Tenant-Parameter — es gibt syntaktisch
// keine Moeglichkeit, hier versehentlich einen Tenant-Sonderfall zu vergessen.
func TestSuperadminStore_CreateWithoutTenantContext(t *testing.T) {
pool := setupTestDB(t, "test_iam01_superadmins", superadminsSchema)
store := NewSuperadminStore(pool)
ctx := context.Background()
created, err := store.Create(ctx, "root@nexarch.internal", "Root")
if err != nil {
t.Fatalf("create superadmin: %v", err)
}
if created.Status != StatusActive {
t.Fatalf("erwartet status active, hat %q", created.Status)
}
got, err := store.Get(ctx, created.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Email != "root@nexarch.internal" {
t.Fatalf("get email = %q", got.Email)
}
if _, err := store.Create(ctx, "root@nexarch.internal", "Root Zwei"); !errors.Is(err, ErrEmailTaken) {
t.Fatalf("erwartet ErrEmailTaken (globale eindeutigkeit), habe %v", err)
}
deactivated, err := store.Deactivate(ctx, created.ID)
if err != nil {
t.Fatalf("deactivate: %v", err)
}
if deactivated.Status != StatusInactive {
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
}
}
+77
View File
@@ -0,0 +1,77 @@
package user
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// SuperadminStore verwaltet mandantenuebergreifende Superadmin-Konten in der
// Control-Plane-Registry (siehe internal/tenant.Registry). Superadmin-ohne-
// Tenant ist dadurch ein eigener Typ statt eines Sonderfalls von User/
// TenantUserStore — es gibt keinen Tenant-Parameter, den man weglassen
// koennte (IAM-01, "ohne Sonderbehandlung im Code").
type SuperadminStore struct {
pool *pgxpool.Pool
}
func NewSuperadminStore(pool *pgxpool.Pool) *SuperadminStore {
return &SuperadminStore{pool: pool}
}
func (s *SuperadminStore) Create(ctx context.Context, email, name string) (User, error) {
if err := ValidateEmail(email); err != nil {
return User{}, err
}
var u User
u.Email, u.Name, u.Status = email, name, StatusActive
row := s.pool.QueryRow(ctx, `
INSERT INTO superadmins (email, name, status)
VALUES ($1, $2, $3)
RETURNING id, created_at, updated_at
`, u.Email, u.Name, u.Status)
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
return User{}, mapWriteErr(err)
}
return u, nil
}
func (s *SuperadminStore) Get(ctx context.Context, id string) (User, error) {
return scanUser(s.pool.QueryRow(ctx, `
SELECT id, email, name, status, created_at, updated_at
FROM superadmins WHERE id = $1
`, id))
}
func (s *SuperadminStore) List(ctx context.Context) ([]User, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, email, name, status, created_at, updated_at
FROM superadmins ORDER BY created_at
`)
if err != nil {
return nil, fmt.Errorf("superadmins auflisten: %w", err)
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("superadmin lesen: %w", err)
}
out = append(out, u)
}
return out, rows.Err()
}
func (s *SuperadminStore) Deactivate(ctx context.Context, id string) (User, error) {
return scanUser(s.pool.QueryRow(ctx, `
UPDATE superadmins SET status = $2, updated_at = now()
WHERE id = $1
RETURNING id, email, name, status, created_at, updated_at
`, id, StatusInactive))
}
+128
View File
@@ -0,0 +1,128 @@
package user
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// TenantUserStore verwaltet Benutzer innerhalb GENAU EINER Tenant-Datenbank.
// Welcher Mandant gemeint ist, ergibt sich ausschliesslich aus dem
// uebergebenen Pool — es gibt keine tenant_id-Spalte (siehe migrations/tenant/0001_users.up.sql).
type TenantUserStore struct {
pool *pgxpool.Pool
}
func NewTenantUserStore(pool *pgxpool.Pool) *TenantUserStore {
return &TenantUserStore{pool: pool}
}
func (s *TenantUserStore) Create(ctx context.Context, email, name string) (User, error) {
if err := ValidateEmail(email); err != nil {
return User{}, err
}
var u User
u.Email, u.Name, u.Status = email, name, StatusActive
row := s.pool.QueryRow(ctx, `
INSERT INTO users (email, name, status)
VALUES ($1, $2, $3)
RETURNING id, created_at, updated_at
`, u.Email, u.Name, u.Status)
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
return User{}, mapWriteErr(err)
}
return u, nil
}
func (s *TenantUserStore) Get(ctx context.Context, id string) (User, error) {
return scanUser(s.pool.QueryRow(ctx, `
SELECT id, email, name, status, created_at, updated_at
FROM users WHERE id = $1
`, id))
}
func (s *TenantUserStore) List(ctx context.Context) ([]User, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, email, name, status, created_at, updated_at
FROM users ORDER BY created_at
`)
if err != nil {
return nil, fmt.Errorf("benutzer auflisten: %w", err)
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("benutzer lesen: %w", err)
}
out = append(out, u)
}
return out, rows.Err()
}
// Update aendert Name und E-Mail. Eine leere email/name laesst das jeweilige
// Feld unveraendert.
func (s *TenantUserStore) Update(ctx context.Context, id, email, name string) (User, error) {
if email != "" {
if err := ValidateEmail(email); err != nil {
return User{}, err
}
}
row := s.pool.QueryRow(ctx, `
UPDATE users
SET email = COALESCE(NULLIF($2, ''), email),
name = COALESCE(NULLIF($3, ''), name),
updated_at = now()
WHERE id = $1
RETURNING id, email, name, status, created_at, updated_at
`, id, email, name)
u, err := scanUser(row)
if err != nil {
return User{}, mapWriteErr(err)
}
return u, nil
}
// Deactivate setzt den Benutzer auf inaktiv statt ihn zu loeschen.
func (s *TenantUserStore) Deactivate(ctx context.Context, id string) (User, error) {
return scanUser(s.pool.QueryRow(ctx, `
UPDATE users SET status = $2, updated_at = now()
WHERE id = $1
RETURNING id, email, name, status, created_at, updated_at
`, id, StatusInactive))
}
func scanUser(row pgx.Row) (User, error) {
var u User
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
}
return User{}, fmt.Errorf("benutzer lesen: %w", err)
}
return u, nil
}
// mapWriteErr uebersetzt den Unique-Constraint-Verstoss der E-Mail-Spalte in
// einen sprechenden Fehler statt der rohen Postgres-Fehlermeldung.
func mapWriteErr(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return ErrEmailTaken
}
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("benutzer schreiben: %w", err)
}
+42
View File
@@ -0,0 +1,42 @@
// Package user implementiert Core IAM-01: das Benutzer-Datenmodell und die
// CRUD-Operationen. Tenant-Zugehoerigkeit ist ueber die Zieldatenbank
// gegeben (Modell C, siehe internal/tenant) — Superadmin-Konten leben
// dagegen mandantenuebergreifend in der Registry und sind ueber
// SuperadminStore als eigener, First-Class-Typ modelliert, nicht als
// tenant_id-NULL-Sonderfall in User.
package user
import (
"errors"
"regexp"
"time"
)
type Status string
const (
StatusActive Status = "active"
StatusInactive Status = "inactive"
)
type User struct {
ID string
Email string
Name string
Status Status
CreatedAt time.Time
UpdatedAt time.Time
}
var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
var ErrInvalidEmail = errors.New("user: ungueltige E-Mail-Adresse")
var ErrEmailTaken = errors.New("user: E-Mail-Adresse bereits vergeben")
var ErrNotFound = errors.New("user: nicht gefunden")
func ValidateEmail(email string) error {
if !emailPattern.MatchString(email) {
return ErrInvalidEmail
}
return nil
}
+24
View File
@@ -0,0 +1,24 @@
package user
import "testing"
func TestValidateEmail(t *testing.T) {
cases := []struct {
email string
wantErr bool
}{
{"a@b.de", false},
{"a.b+c@sub.example.com", false},
{"", true},
{"keine-email", true},
{"a@b", true},
{"@b.de", true},
}
for _, c := range cases {
err := ValidateEmail(c.email)
if (err != nil) != c.wantErr {
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", c.email, err, c.wantErr)
}
}
}
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS superadmins;
+14
View File
@@ -0,0 +1,14 @@
-- Superadmin-Konten arbeiten mandantenuebergreifend und leben deshalb in der
-- Control-Plane-Registry (siehe TEN-01), nicht in einer Tenant-Datenbank.
-- Das bildet "Superadmin ohne Tenant" strukturell als First-Class-Zustand ab,
-- statt ihn als Sonderfall in der Tenant-users-Tabelle zu behandeln
-- (IAM-01, siehe core-kanban/tickets/IAM-01.md — bekannte Fehler vermeiden).
-- E-Mail-Eindeutigkeit ist hier global, da die Registry-DB einmalig existiert.
CREATE TABLE superadmins (
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()
);
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS users;
+16
View File
@@ -0,0 +1,16 @@
-- Benutzer-Datenmodell (IAM-01, siehe core-kanban/tickets/IAM-01.md).
-- Diese Migration laeuft in der DB EINES Mandanten (Modell C, siehe TEN-01) —
-- die Tenant-Zugehoerigkeit ist implizit durch die Datenbankverbindung
-- gegeben, es gibt daher bewusst KEINE tenant_id-Spalte.
-- E-Mail-Eindeutigkeit ist hier tenant-scoped: der UNIQUE-Constraint gilt
-- nur innerhalb dieser einen Tenant-Datenbank.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE 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()
);