feat(PROJ-53): Konfigurierbare Listenanzahl pro Seite

- users.list_page_size (Default 25), PATCH /api/auth/preferences,
  Whitelist 25/50/100/200, Wert in login/me-Response
- Settings-UI mit Select, /search nutzt gespeicherte Seitengröße
- /api/search page_size serverseitig auf max. 500 gecappt

fix(PROJ-46): login_attempts-Migration nutzte s.db statt s.pool
(Backend kompilierte nicht)

feat(PROJ-50): DSGVO-Löschersuchen Backend (dsgvo_requests, Handler,
cc_addr/bcc_addr Indexerweiterung) — noch nicht QA'd/deployed
This commit is contained in:
sysops
2026-06-14 22:25:02 +02:00
parent b73ef55a65
commit 472ba6a087
25 changed files with 1078 additions and 111 deletions
+49 -23
View File
@@ -24,17 +24,18 @@ const (
// User represents a user account in the system.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
Source string `json:"source"` // "local" or "ldap"
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
TenantID *int64 `json:"tenant_id,omitempty"`
TOTPEnabled bool `json:"totp_enabled"`
TOTPResetAt *time.Time `json:"totp_reset_at,omitempty"`
TOTPResetBy *string `json:"totp_reset_by,omitempty"`
ID int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
Source string `json:"source"` // "local" or "ldap"
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
TenantID *int64 `json:"tenant_id,omitempty"`
TOTPEnabled bool `json:"totp_enabled"`
TOTPResetAt *time.Time `json:"totp_reset_at,omitempty"`
TOTPResetBy *string `json:"totp_reset_by,omitempty"`
ListPageSize int `json:"list_page_size"`
}
// CreateUserRequest holds parameters for creating a new user.
@@ -106,6 +107,14 @@ func (s *Store) initSchema(ctx context.Context) error {
if err != nil {
return err
}
// PROJ-46: login_attempts.username auf VARCHAR(255) erweitern (passend zu users.email),
// damit lange E-Mail-Adressen als Login-Identifier nicht abgeschnitten werden.
_, err = s.pool.Exec(ctx, `
ALTER TABLE login_attempts ALTER COLUMN username TYPE VARCHAR(255);
`)
if err != nil {
return err
}
// PROJ-24: TOTP 2FA columns
_, err = s.pool.Exec(ctx, `
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret BYTEA;
@@ -113,6 +122,13 @@ func (s *Store) initSchema(ctx context.Context) error {
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_reset_at TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_reset_by TEXT;
`)
if err != nil {
return err
}
// PROJ-53: konfigurierbare Listenanzahl pro Seite (25/50/100/200, Default 25)
_, err = s.pool.Exec(ctx, `
ALTER TABLE users ADD COLUMN IF NOT EXISTS list_page_size INT NOT NULL DEFAULT 25;
`)
return err
}
@@ -180,7 +196,7 @@ func (s *Store) Activate(ctx context.Context, id int64) error {
// GetByEmail retrieves a user by email address.
func (s *Store) GetByEmail(ctx context.Context, email string) (*User, error) {
row := s.pool.QueryRow(ctx,
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE email = $1`, email,
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size FROM users WHERE email = $1`, email,
)
return scanUser(row)
}
@@ -199,7 +215,7 @@ func (s *Store) SetPassword(ctx context.Context, id int64, newPassword string) e
func (s *Store) GetByID(id int64) (*User, error) {
ctx := context.Background()
row := s.pool.QueryRow(ctx,
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE id = $1`, id,
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size FROM users WHERE id = $1`, id,
)
return scanUser(row)
}
@@ -208,7 +224,7 @@ func (s *Store) GetByID(id int64) (*User, error) {
func (s *Store) GetByUsername(username string) (*User, error) {
ctx := context.Background()
row := s.pool.QueryRow(ctx,
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE username = $1`, username,
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size FROM users WHERE username = $1`, username,
)
return scanUser(row)
}
@@ -218,13 +234,13 @@ func (s *Store) GetByUsername(username string) (*User, error) {
func (s *Store) VerifyPassword(username, password string) (*User, error) {
ctx := context.Background()
row := s.pool.QueryRow(ctx,
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, password_hash FROM users WHERE username = $1`,
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size, password_hash FROM users WHERE username = $1`,
username,
)
var u User
var hash string
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy, &hash)
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy, &u.ListPageSize, &hash)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errors.New("userstore: user not found")
}
@@ -296,10 +312,10 @@ func (s *Store) List(role string) ([]*User, error) {
if role == "" {
rows, err = s.pool.Query(ctx,
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users ORDER BY id`)
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size FROM users ORDER BY id`)
} else {
rows, err = s.pool.Query(ctx,
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE role = $1 ORDER BY id`, role)
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size FROM users WHERE role = $1 ORDER BY id`, role)
}
if err != nil {
return nil, fmt.Errorf("userstore: list: %w", err)
@@ -320,7 +336,7 @@ func (s *Store) List(role string) ([]*User, error) {
// ListByTenant returns all users belonging to a specific tenant.
func (s *Store) ListByTenant(ctx context.Context, tenantID int64) ([]*User, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE tenant_id = $1 ORDER BY id`,
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size FROM users WHERE tenant_id = $1 ORDER BY id`,
tenantID,
)
if err != nil {
@@ -438,10 +454,10 @@ func (s *Store) UpsertLDAPUser(username, email, role string, tenantID *int64) (*
active = true,
tenant_id = COALESCE($3, tenant_id)
WHERE email = $4
RETURNING id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by
RETURNING id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size
`, username, role, tenantID, email).Scan(
&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active,
&u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy,
&u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy, &u.ListPageSize,
)
if err == nil {
return &u, nil
@@ -466,7 +482,7 @@ func (s *Store) UpsertLDAPUser(username, email, role string, tenantID *int64) (*
func scanUser(row pgx.Row) (*User, error) {
var u User
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy)
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy, &u.ListPageSize)
if errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("userstore: not found")
}
@@ -478,7 +494,7 @@ func scanUser(row pgx.Row) (*User, error) {
func scanUserRow(rows pgx.Rows) (*User, error) {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy); err != nil {
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy, &u.ListPageSize); err != nil {
return nil, fmt.Errorf("userstore: scan row: %w", err)
}
return &u, nil
@@ -555,6 +571,16 @@ func (s *Store) UpdateEmail(ctx context.Context, userID int64, email string) err
return nil
}
// UpdateListPageSize sets the number of list entries per page for the given user.
// Validation of allowed values (25/50/100/200) happens in the API handler.
func (s *Store) UpdateListPageSize(ctx context.Context, userID int64, pageSize int) error {
_, err := s.pool.Exec(ctx, `UPDATE users SET list_page_size = $1 WHERE id = $2`, pageSize, userID)
if err != nil {
return fmt.Errorf("userstore: update list page size: %w", err)
}
return nil
}
// GetTOTPSecret returns the encrypted TOTP secret and enabled status for a user.
func (s *Store) GetTOTPSecret(ctx context.Context, userID int64) (secret []byte, enabled bool, err error) {
err = s.pool.QueryRow(ctx,