From e4793303fc91c80bde9275f7b79baf7f1593eb6d Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 17:57:49 +0200 Subject: [PATCH] IAM-01: benutzer-datenmodell-crud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- 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 +++ 13 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 go.sum create mode 100644 internal/user/handler.go create mode 100644 internal/user/store_test.go create mode 100644 internal/user/superadmin_store.go create mode 100644 internal/user/tenant_store.go create mode 100644 internal/user/user.go create mode 100644 internal/user/user_test.go create mode 100644 migrations/0002_superadmins.down.sql create mode 100644 migrations/0002_superadmins.up.sql create mode 100644 migrations/tenant/0001_users.down.sql create mode 100644 migrations/tenant/0001_users.up.sql diff --git a/cmd/core/main.go b/cmd/core/main.go index 961bbf2..e672cf3 100644 --- a/cmd/core/main.go +++ b/cmd/core/main.go @@ -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 { diff --git a/go.mod b/go.mod index 75a79b7..05c10d0 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,12 @@ module gitea.perlbach24.de/scripte/nexarch go 1.22 require github.com/jackc/pgx/v5 v5.6.0 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5c39671 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/user/handler.go b/internal/user/handler.go new file mode 100644 index 0000000..001fc10 --- /dev/null +++ b/internal/user/handler.go @@ -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) +} diff --git a/internal/user/store_test.go b/internal/user/store_test.go new file mode 100644 index 0000000..1efa258 --- /dev/null +++ b/internal/user/store_test.go @@ -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) + } +} diff --git a/internal/user/superadmin_store.go b/internal/user/superadmin_store.go new file mode 100644 index 0000000..c3a0041 --- /dev/null +++ b/internal/user/superadmin_store.go @@ -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)) +} diff --git a/internal/user/tenant_store.go b/internal/user/tenant_store.go new file mode 100644 index 0000000..4ab1f47 --- /dev/null +++ b/internal/user/tenant_store.go @@ -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) +} diff --git a/internal/user/user.go b/internal/user/user.go new file mode 100644 index 0000000..1cae86b --- /dev/null +++ b/internal/user/user.go @@ -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 +} diff --git a/internal/user/user_test.go b/internal/user/user_test.go new file mode 100644 index 0000000..a996b5e --- /dev/null +++ b/internal/user/user_test.go @@ -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) + } + } +} diff --git a/migrations/0002_superadmins.down.sql b/migrations/0002_superadmins.down.sql new file mode 100644 index 0000000..51b8721 --- /dev/null +++ b/migrations/0002_superadmins.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS superadmins; diff --git a/migrations/0002_superadmins.up.sql b/migrations/0002_superadmins.up.sql new file mode 100644 index 0000000..202f785 --- /dev/null +++ b/migrations/0002_superadmins.up.sql @@ -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() +); diff --git a/migrations/tenant/0001_users.down.sql b/migrations/tenant/0001_users.down.sql new file mode 100644 index 0000000..c99ddcd --- /dev/null +++ b/migrations/tenant/0001_users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/migrations/tenant/0001_users.up.sql b/migrations/tenant/0001_users.up.sql new file mode 100644 index 0000000..53df0c9 --- /dev/null +++ b/migrations/tenant/0001_users.up.sql @@ -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() +);