Postgres-RLS-Policies auf allen Tabellen mit echten Mandanten- Geschäftsdaten (antrag und alles darüber verkettete, abteilung, werkzeug/werkzeug_sperre, genehmiger_rolle/freigabe_regel, loeschfrist_einstellung). Kritischer Fund vor der Umsetzung: die Anwendung verbindet als postgres-Superuser, der RLS immer umgeht - Migration 0021 legt deshalb zusätzlich eine eingeschränkte Rolle "deklarix_app" an, nur für die greifen die Policies tatsächlich. internal/store/tenant_scope.go: WithTenantScope öffnet eine Transaktion und setzt Sitzungsvariablen (app.account_id/app.is_betreiber) per set_config mit Parameterbindung; alle Store-Methoden laufen jetzt über s.db(ctx) statt direkt s.Pool. Jede require*-Middleware umschließt die komplette Handler-Ausführung damit - jeder Request läuft dadurch auch atomar in einer Transaktion (positiver Nebeneffekt). Live end-to-end verifiziert (echter HTTP-Server + DATABASE_URL_APP auf die eingeschränkte Rolle gesetzt): zwei Firmen registriert, Isolation über Abteilung/Antrag/Bewertung bestätigt, zentraler NULL-Katalog für beide sichtbar. Produktivbetrieb braucht noch einen manuellen Schritt (Passwort für deklarix_app setzen + DATABASE_URL_APP konfigurieren, siehe CLAUDE.md) - die Migration allein aktiviert noch nichts, solange die App weiter als Superuser verbindet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
139 lines
4.4 KiB
Go
139 lines
4.4 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// ErrNotFound signalisiert, dass eine Abfrage keine Zeile ergeben hat —
|
|
// abgrenzbar von echten Fehlern (z. B. Verbindungsabbruch), damit
|
|
// Aufrufer "nicht gefunden" gezielt anders behandeln können (z. B. bei
|
|
// Login: falsche E-Mail vs. Datenbankfehler).
|
|
var ErrNotFound = errors.New("store: nicht gefunden")
|
|
|
|
// User ist ein Login innerhalb eines Account (Mandanten). Nutzer werden
|
|
// nicht gelöscht (app_user wird von antrag/session/entscheidung/
|
|
// audit_log per Foreign Key referenziert — ein Hard-Delete würde die
|
|
// Historie zerstören), sondern über Active deaktiviert.
|
|
type User struct {
|
|
ID string
|
|
AccountID string
|
|
Email string
|
|
PasswordHash string
|
|
Role string
|
|
Active bool
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
const userColumns = `id, account_id, email, password_hash, role, active, created_at`
|
|
|
|
func scanUser(row interface {
|
|
Scan(dest ...any) error
|
|
}) (User, error) {
|
|
var u User
|
|
err := row.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.Active, &u.CreatedAt)
|
|
return u, err
|
|
}
|
|
|
|
// CreateUser legt einen neuen Nutzer innerhalb eines Accounts an.
|
|
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
|
|
// speichert nur, es hasht nicht selbst.
|
|
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
|
|
row := s.db(ctx).QueryRow(ctx, `
|
|
INSERT INTO app_user (account_id, email, password_hash, role)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING `+userColumns,
|
|
accountID, email, passwordHash, role,
|
|
)
|
|
u, err := scanUser(row)
|
|
if err != nil {
|
|
return User{}, fmt.Errorf("store: create user: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
|
|
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
|
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email)
|
|
u, err := scanUser(row)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return User{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return User{}, fmt.Errorf("store: get user by email: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// GetUser liest einen Nutzer anhand seiner ID.
|
|
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id)
|
|
u, err := scanUser(row)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return User{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return User{}, fmt.Errorf("store: get user: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// ListUsersForAccount liefert alle Logins eines Mandanten — für den
|
|
// Admin-Bereich (Account-Detailansicht).
|
|
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
|
|
rows, err := s.db(ctx).Query(ctx, `
|
|
SELECT `+userColumns+` FROM app_user WHERE account_id = $1 ORDER BY created_at
|
|
`, accountID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: list users for account: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []User
|
|
for rows.Next() {
|
|
u, err := scanUser(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: scan user: %w", err)
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("store: list users for account: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// SetUserPassword ersetzt den Passwort-Hash eines Nutzers (Passwort-
|
|
// Zurücksetzen). passwordHash muss bereits gehasht sein, wie bei
|
|
// CreateUser.
|
|
func (s *Store) SetUserPassword(ctx context.Context, id, passwordHash string) error {
|
|
tag, err := s.db(ctx).Exec(ctx, `UPDATE app_user SET password_hash = $2 WHERE id = $1`, id, passwordHash)
|
|
if err != nil {
|
|
return fmt.Errorf("store: set user password: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetUserActive (de-)aktiviert einen Login. Ein deaktivierter Nutzer
|
|
// kann sich nicht mehr anmelden (siehe web.handleLogin), bleibt aber
|
|
// als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen
|
|
// nachvollziehbar — deshalb (de-)aktivieren statt löschen.
|
|
func (s *Store) SetUserActive(ctx context.Context, id string, active bool) error {
|
|
tag, err := s.db(ctx).Exec(ctx, `UPDATE app_user SET active = $2 WHERE id = $1`, id, active)
|
|
if err != nil {
|
|
return fmt.Errorf("store: set user active: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|