Files
member-console/internal/db/database.go
T
cgalo5758 ea4fee18b6 Fix five findings from security audit run 2
- Rotate the session token at the OIDC callback and restore the full
  lifetime; cap pre-auth sessions at 15 minutes and write no session
  for bare anonymous requests
- Treat db-dsn as a secret: accept db-dsn-file, log only host, port,
  database and user, and never echo a malformed DSN in an error
- Guard the logout callback with a state cookie so a forged visit
  cannot end a live session
- Collapse FedWiki site actions on a foreign tenant's domain to the
  not-found answer, as for a domain that does not exist
2026-09-09 20:53:31 -05:00

173 lines
5.8 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package db
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/url"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
// DBConfig holds database configuration.
type DBConfig struct {
DSN string // Data Source Name (PostgreSQL connection string)
MaxOpenConns int // Maximum number of open connections
MaxIdleConns int // Maximum number of idle connections
ConnMaxLifetime time.Duration // Maximum lifetime of connections
ConnMaxIdleTime time.Duration // Maximum idle time for connections
}
// DefaultDBConfig returns a DBConfig with sensible defaults for PostgreSQL.
func DefaultDBConfig(dsn string) *DBConfig {
return &DBConfig{
DSN: dsn,
MaxOpenConns: 25,
MaxIdleConns: 10,
ConnMaxLifetime: 30 * time.Minute,
ConnMaxIdleTime: 5 * time.Minute,
}
}
// ensureSearchPath appends search_path with the core schema to the DSN if
// not already present, so every connection in the pool resolves core-schema
// tables without qualification (Decision 113). Provider schemas (stripe,
// fedwiki) stay out of search_path and their queries stay schema-qualified.
func ensureSearchPath(dsn string) string {
const moduleSearchPath = "core,public"
u, err := url.Parse(dsn)
if err != nil || u.Scheme == "" {
// Key=value DSN format — append if not already set.
if !strings.Contains(dsn, "search_path") {
return dsn + " search_path=" + moduleSearchPath
}
return dsn
}
// URI format — append as query parameter.
q := u.Query()
if q.Get("search_path") == "" {
q.Set("search_path", moduleSearchPath)
u.RawQuery = q.Encode()
}
return u.String()
}
// dsnLogAttrs parses dsn as a PostgreSQL connection URL and returns only the
// parts an operator needs to recognise the connection target: host, port,
// database and user (design D1, security-audit-remediation-2 — the DSN is a
// secret, not a string to redact at each call site). It never returns the
// password or the query string. A DSN that fails to parse yields a single
// attribute naming the failure, never the input, since url.Error.Error()
// echoes the string it failed to parse.
func dsnLogAttrs(dsn string) []slog.Attr {
u, err := url.Parse(dsn)
if err != nil || u.Scheme == "" || u.Host == "" {
return []slog.Attr{slog.String("dsn_parse_error", "unparseable")}
}
database := strings.TrimPrefix(u.Path, "/")
var user string
if u.User != nil {
user = u.User.Username()
}
return []slog.Attr{
slog.String("db_host", u.Hostname()),
slog.String("db_port", u.Port()),
slog.String("db_name", database),
slog.String("db_user", user),
}
}
// openAndConfigureDB opens the database connection and configures the connection pool.
func openAndConfigureDB(config *DBConfig) (*sql.DB, error) {
db, err := sql.Open("pgx", ensureSearchPath(config.DSN))
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Configure connection pool
db.SetMaxOpenConns(config.MaxOpenConns)
db.SetMaxIdleConns(config.MaxIdleConns)
db.SetConnMaxLifetime(config.ConnMaxLifetime)
db.SetConnMaxIdleTime(config.ConnMaxIdleTime)
return db, nil
}
// ConnectPlain opens a database connection without the custom search_path.
// Use this for migrations — the custom search_path causes a pgx/PG18
// interaction that silently drops tables from multi-statement DDL.
func ConnectPlain(ctx context.Context, logger *slog.Logger, config *DBConfig) (*sql.DB, error) {
db, err := sql.Open("pgx", config.DSN)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
db.SetMaxOpenConns(config.MaxOpenConns)
db.SetMaxIdleConns(config.MaxIdleConns)
db.SetConnMaxLifetime(config.ConnMaxLifetime)
db.SetConnMaxIdleTime(config.ConnMaxIdleTime)
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
logger.LogAttrs(ctx, slog.LevelInfo, "database connection established (plain)",
dsnLogAttrs(config.DSN)...)
return db, nil
}
// Connect initializes and returns a new database connection pool.
// This is the basic connection function without any automatic operations.
func Connect(ctx context.Context, logger *slog.Logger, config *DBConfig) (*sql.DB, error) {
db, err := openAndConfigureDB(config)
if err != nil {
return nil, err
}
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
attrs := append(dsnLogAttrs(config.DSN),
slog.Int("max_open_conns", config.MaxOpenConns),
slog.Int("max_idle_conns", config.MaxIdleConns))
logger.LogAttrs(ctx, slog.LevelInfo, "database connection established", attrs...)
return db, nil
}
// ConnectAndMigrate initializes a database connection and automatically runs migrations.
// The sources parameter specifies migration sources in dependency order.
//
// Migrations run on a separate connection WITHOUT the custom search_path to
// avoid a pgx/PG18 interaction where setting the core schema in the search_path
// at connection time causes multi-statement DDL to silently lose tables. After
// migrations complete, the returned connection uses the full search_path for
// application queries.
func ConnectAndMigrate(ctx context.Context, logger *slog.Logger, config *DBConfig, sources []MigrationSource) (*sql.DB, error) {
// Run migrations on a plain connection (no custom search_path).
migDB, err := ConnectPlain(ctx, logger, config)
if err != nil {
return nil, err
}
if err := RunMigrations(migDB, sources); err != nil {
migDB.Close()
return nil, fmt.Errorf("failed to run migrations: %w", err)
}
migDB.Close()
// Now open the application connection with full search_path.
db, err := Connect(ctx, logger, config)
if err != nil {
return nil, err
}
logger.Info("database migrations applied")
return db, nil
}