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>
This commit is contained in:
sysops
2026-08-27 17:57:49 +02:00
co-authored by Claude Sonnet 5
parent 8da9c67d08
commit e4793303fc
13 changed files with 589 additions and 1 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 {
+9
View File
@@ -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
)
+28
View File
@@ -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=
+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()
);