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)) } // SetPasswordHash schreibt einen bereits berechneten bcrypt-Hash (siehe // internal/auth, IAM-02). Der Store selbst kennt kein Klartext-Passwort. func (s *TenantUserStore) SetPasswordHash(ctx context.Context, id, hash string) error { tag, err := s.pool.Exec(ctx, ` UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1 `, id, hash) if err != nil { return fmt.Errorf("passwort setzen: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // AuthCredentials wird ausschliesslich fuer den Login-Pfad (internal/auth) // verwendet und traegt bewusst den password_hash, damit er nicht ueber den // regulaeren User-Typ/JSON-Serialisierungspfad nach aussen dringen kann. type AuthCredentials struct { User User PasswordHash string } // GetByIDForAuth liefert Benutzer + Passwort-Hash zu einer User-ID — fuer // IAM-08 (Passwortaenderung: die bestehende Session liefert die ID, nicht // die E-Mail-Adresse, gleiches Prinzip wie GetByEmailForAuth). func (s *TenantUserStore) GetByIDForAuth(ctx context.Context, id string) (AuthCredentials, error) { var c AuthCredentials row := s.pool.QueryRow(ctx, ` SELECT id, email, name, status, created_at, updated_at, password_hash FROM users WHERE id = $1 `, id) if err := row.Scan(&c.User.ID, &c.User.Email, &c.User.Name, &c.User.Status, &c.User.CreatedAt, &c.User.UpdatedAt, &c.PasswordHash); err != nil { if errors.Is(err, pgx.ErrNoRows) { return AuthCredentials{}, ErrNotFound } return AuthCredentials{}, fmt.Errorf("anmeldedaten lesen: %w", err) } return c, nil } // GetByEmailForAuth liefert Benutzer + Passwort-Hash zu einer E-Mail-Adresse // aus GENAU DIESER Tenant-Datenbank — der Tenant-Scope ergibt sich damit // zwingend aus dem verwendeten Pool, es gibt keine Moeglichkeit, versehentlich // ueber Tenant-Grenzen hinweg zu suchen (bekannter archivmail-Fehler, siehe // IAM-02 "Bekannte Fehler vermeiden"). func (s *TenantUserStore) GetByEmailForAuth(ctx context.Context, email string) (AuthCredentials, error) { var c AuthCredentials row := s.pool.QueryRow(ctx, ` SELECT id, email, name, status, created_at, updated_at, password_hash FROM users WHERE email = $1 `, email) if err := row.Scan(&c.User.ID, &c.User.Email, &c.User.Name, &c.User.Status, &c.User.CreatedAt, &c.User.UpdatedAt, &c.PasswordHash); err != nil { if errors.Is(err, pgx.ErrNoRows) { return AuthCredentials{}, ErrNotFound } return AuthCredentials{}, fmt.Errorf("anmeldedaten lesen: %w", err) } return c, nil } 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) }