feat(store): route all DB access through withUser for structural scoping

Add Store.withUser(ctx, userID, fn) — a single choke point that BEGINs a tx,
sets the transaction-local GUC tapir.current_user_id via
set_config(..., true), runs fn, and commits. set_config is used over SET LOCAL
because it is parameterizable; the local flag means the value auto-resets on
commit/rollback so a pooled connection never leaks one request's user into the
next.

Route all 9 DB-touching methods through it (Deliver, HasSummary, SeenVideoIDs,
ListSummaries, GetSummaryByVideo, SetAction, ClearAction, ActionsFor, and
UpsertVideo; attachActions flows via ActionsFor). Scoping is now structural —
not a per-query opt-in someone can forget — and arms the migration-003 RLS
policies. Method signatures and existing WHERE clauses are unchanged (defence in
depth; superuser DSNs in existing tests bypass RLS so behaviour is preserved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 15:15:52 +02:00
co-authored by Claude Opus 4.8
parent 6775e5f53d
commit 7b139c2cd7
4 changed files with 231 additions and 175 deletions
+16 -15
View File
@@ -3,6 +3,8 @@ package store
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/jackc/pgx/v5"
) )
// allowedActions is the closed set of action verbs persisted in summary_actions. // allowedActions is the closed set of action verbs persisted in summary_actions.
@@ -39,12 +41,7 @@ func (s *Store) SetAction(ctx context.Context, userID, videoID, action string) e
return err return err
} }
tx, err := s.pool.Begin(ctx) return s.withUser(ctx, userID, func(tx pgx.Tx) error {
if err != nil {
return fmt.Errorf("store: begin set action: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit
if opposite, ok := oppositeAction[action]; ok { if opposite, ok := oppositeAction[action]; ok {
if _, err := tx.Exec(ctx, if _, err := tx.Exec(ctx,
`DELETE FROM summary_actions `DELETE FROM summary_actions
@@ -61,11 +58,8 @@ func (s *Store) SetAction(ctx context.Context, userID, videoID, action string) e
userID, videoID, action); err != nil { userID, videoID, action); err != nil {
return fmt.Errorf("store: set action: %w", err) return fmt.Errorf("store: set action: %w", err)
} }
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("store: commit set action: %w", err)
}
return nil return nil
})
} }
// ClearAction removes an action for (user, video). Clearing an action that is // ClearAction removes an action for (user, video). Clearing an action that is
@@ -74,13 +68,15 @@ func (s *Store) ClearAction(ctx context.Context, userID, videoID, action string)
if err := validateAction(action); err != nil { if err := validateAction(action); err != nil {
return err return err
} }
if _, err := s.pool.Exec(ctx, return s.withUser(ctx, userID, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
`DELETE FROM summary_actions `DELETE FROM summary_actions
WHERE user_id = $1 AND video_id = $2 AND action = $3`, WHERE user_id = $1 AND video_id = $2 AND action = $3`,
userID, videoID, action); err != nil { userID, videoID, action); err != nil {
return fmt.Errorf("store: clear action: %w", err) return fmt.Errorf("store: clear action: %w", err)
} }
return nil return nil
})
} }
// ActionsFor returns the active actions per video for the given user, keyed by // ActionsFor returns the active actions per video for the given user, keyed by
@@ -91,25 +87,30 @@ func (s *Store) ActionsFor(ctx context.Context, userID string, videoIDs []string
if len(videoIDs) == 0 { if len(videoIDs) == 0 {
return out, nil return out, nil
} }
rows, err := s.pool.Query(ctx, if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
`SELECT video_id, action FROM summary_actions `SELECT video_id, action FROM summary_actions
WHERE user_id = $1 AND video_id = ANY($2) WHERE user_id = $1 AND video_id = ANY($2)
ORDER BY video_id, action`, ORDER BY video_id, action`,
userID, videoIDs) userID, videoIDs)
if err != nil { if err != nil {
return nil, fmt.Errorf("store: actions for: %w", err) return fmt.Errorf("store: actions for: %w", err)
} }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var videoID, action string var videoID, action string
if err := rows.Scan(&videoID, &action); err != nil { if err := rows.Scan(&videoID, &action); err != nil {
return nil, fmt.Errorf("store: scan action: %w", err) return fmt.Errorf("store: scan action: %w", err)
} }
out[videoID] = append(out[videoID], action) out[videoID] = append(out[videoID], action)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate actions: %w", err) return fmt.Errorf("store: iterate actions: %w", err)
}
return nil
}); err != nil {
return nil, err
} }
return out, nil return out, nil
} }
+28 -10
View File
@@ -66,27 +66,32 @@ func (s *Store) ListSummaries(ctx context.Context, userID string, limit int) ([]
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
} }
rows, err := s.pool.Query(ctx, var out []SummaryRow
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
selectSummary+` selectSummary+`
WHERE s.user_id = $1 WHERE s.user_id = $1
ORDER BY s.created_at DESC ORDER BY s.created_at DESC
LIMIT $2`, LIMIT $2`,
userID, limit) userID, limit)
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list summaries: %w", err) return fmt.Errorf("store: list summaries: %w", err)
} }
defer rows.Close() defer rows.Close()
var out []SummaryRow
for rows.Next() { for rows.Next() {
row, err := scanSummaryRow(rows) row, err := scanSummaryRow(rows)
if err != nil { if err != nil {
return nil, err return err
} }
out = append(out, row) out = append(out, row)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate summaries: %w", err) return fmt.Errorf("store: iterate summaries: %w", err)
}
return nil
}); err != nil {
return nil, err
} }
if err := s.attachActions(ctx, userID, out); err != nil { if err := s.attachActions(ctx, userID, out); err != nil {
return nil, err return nil, err
@@ -98,25 +103,38 @@ func (s *Store) ListSummaries(ctx context.Context, userID string, limit int) ([]
// highlights and takeaways. Returns ErrNotFound when the user has no such // highlights and takeaways. Returns ErrNotFound when the user has no such
// summary. Scoped by user_id. // summary. Scoped by user_id.
func (s *Store) GetSummaryByVideo(ctx context.Context, userID, videoID string) (*SummaryRow, error) { func (s *Store) GetSummaryByVideo(ctx context.Context, userID, videoID string) (*SummaryRow, error) {
rows, err := s.pool.Query(ctx, var (
row SummaryRow
found bool
)
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
selectSummary+` selectSummary+`
WHERE s.user_id = $1 AND s.video_id = $2`, WHERE s.user_id = $1 AND s.video_id = $2`,
userID, videoID) userID, videoID)
if err != nil { if err != nil {
return nil, fmt.Errorf("store: get summary: %w", err) return fmt.Errorf("store: get summary: %w", err)
} }
defer rows.Close() defer rows.Close()
if !rows.Next() { if !rows.Next() {
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: get summary: %w", err) return fmt.Errorf("store: get summary: %w", err)
} }
return nil, ErrNotFound return nil
} }
row, err := scanSummaryRow(rows) row, err = scanSummaryRow(rows)
if err != nil { if err != nil {
return err
}
found = true
return nil
}); err != nil {
return nil, err return nil, err
} }
if !found {
return nil, ErrNotFound
}
holder := []SummaryRow{row} holder := []SummaryRow{row}
if err := s.attachActions(ctx, userID, holder); err != nil { if err := s.attachActions(ctx, userID, holder); err != nil {
return nil, err return nil, err
+59 -19
View File
@@ -19,6 +19,7 @@ import (
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
migratepgx "github.com/golang-migrate/migrate/v4/database/pgx/v5" migratepgx "github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/golang-migrate/migrate/v4/source/iofs" "github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib" // register the "pgx" database/sql driver for migrate _ "github.com/jackc/pgx/v5/stdlib" // register the "pgx" database/sql driver for migrate
@@ -90,6 +91,46 @@ func (s *Store) Close() {
// Name identifies this sink in delivery records. // Name identifies this sink in delivery records.
func (s *Store) Name() string { return "store" } func (s *Store) Name() string { return "store" }
// withUser is the single choke point through which EVERY DB access in this
// package flows, so per-user isolation is structural — not a per-query opt-in
// someone can forget. It:
//
// - BEGINs a transaction,
// - sets the per-request GUC tapir.current_user_id via
// set_config('tapir.current_user_id', $1, true). The set_config form is used
// instead of `SET LOCAL` because it is parameterizable (SET cannot bind a
// value through the driver); the third arg true = local = transaction-scoped,
// so it auto-resets on commit/rollback and a pooled connection never leaks one
// request's user into the next,
// - runs fn against that transaction,
// - COMMITs (or ROLLBACKs on error).
//
// The migration-003 RLS policies key off this GUC: a row is visible/writable only
// when its owner = current_setting('tapir.current_user_id'). RLS enforces only
// when the app connects as a non-superuser, non-BYPASSRLS role (in production the
// table owner tapir, made subject via FORCE ROW LEVEL SECURITY). A superuser DSN
// bypasses RLS regardless — see rls_test.go, which connects as a dedicated
// non-superuser role to prove the enforcement is real.
func (s *Store) withUser(ctx context.Context, userID string, fn func(pgx.Tx) error) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("store: begin: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit
if _, err := tx.Exec(ctx,
`SELECT set_config('tapir.current_user_id', $1, true)`, userID); err != nil {
return fmt.Errorf("store: scope user: %w", err)
}
if err := fn(tx); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("store: commit: %w", err)
}
return nil
}
// Deliver upserts the summary idempotently on (user_id, video_id) and records the // Deliver upserts the summary idempotently on (user_id, video_id) and records the
// store delivery. Re-delivering the same summary updates in place — it never // store delivery. Re-delivering the same summary updates in place — it never
// errors or duplicates. The whole write is one transaction so a summary and its // errors or duplicates. The whole write is one transaction so a summary and its
@@ -104,14 +145,9 @@ func (s *Store) Deliver(ctx context.Context, sum domain.Summary) error {
return fmt.Errorf("store: marshal takeaways: %w", err) return fmt.Errorf("store: marshal takeaways: %w", err)
} }
tx, err := s.pool.Begin(ctx) return s.withUser(ctx, sum.UserID, func(tx pgx.Tx) error {
if err != nil { // Ensure the owning user exists (FK target). The store sink receives only
return fmt.Errorf("store: begin: %w", err) // a Summary, so a minimal user row is enough at Stage 0.
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit
// Ensure the owning user exists (FK target). The store sink receives only a
// Summary, so a minimal user row is enough at Stage 0.
if _, err := tx.Exec(ctx, if _, err := tx.Exec(ctx,
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, `INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
sum.UserID); err != nil { sum.UserID); err != nil {
@@ -147,20 +183,19 @@ func (s *Store) Deliver(ctx context.Context, sum domain.Summary) error {
summaryID); err != nil { summaryID); err != nil {
return fmt.Errorf("store: record delivery: %w", err) return fmt.Errorf("store: record delivery: %w", err)
} }
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("store: commit: %w", err)
}
return nil return nil
})
} }
// HasSummary reports whether a summary already exists for (userID, videoID). // HasSummary reports whether a summary already exists for (userID, videoID).
// This is the per-video durable dedup check. // This is the per-video durable dedup check.
func (s *Store) HasSummary(ctx context.Context, userID, videoID string) (bool, error) { func (s *Store) HasSummary(ctx context.Context, userID, videoID string) (bool, error) {
var exists bool var exists bool
if err := s.pool.QueryRow(ctx, if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM summaries WHERE user_id = $1 AND video_id = $2)`, `SELECT EXISTS(SELECT 1 FROM summaries WHERE user_id = $1 AND video_id = $2)`,
userID, videoID).Scan(&exists); err != nil { userID, videoID).Scan(&exists)
}); err != nil {
return false, fmt.Errorf("store: has summary: %w", err) return false, fmt.Errorf("store: has summary: %w", err)
} }
return exists, nil return exists, nil
@@ -170,23 +205,28 @@ func (s *Store) HasSummary(ctx context.Context, userID, videoID string) (bool, e
// user. The watcher uses it to skip re-summarizing across restarts. Scoped by // user. The watcher uses it to skip re-summarizing across restarts. Scoped by
// user_id, so one user never sees another's videos. // user_id, so one user never sees another's videos.
func (s *Store) SeenVideoIDs(ctx context.Context, userID string) (map[string]bool, error) { func (s *Store) SeenVideoIDs(ctx context.Context, userID string) (map[string]bool, error) {
rows, err := s.pool.Query(ctx, seen := make(map[string]bool)
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
`SELECT video_id FROM summaries WHERE user_id = $1`, userID) `SELECT video_id FROM summaries WHERE user_id = $1`, userID)
if err != nil { if err != nil {
return nil, fmt.Errorf("store: seen video ids: %w", err) return fmt.Errorf("store: seen video ids: %w", err)
} }
defer rows.Close() defer rows.Close()
seen := make(map[string]bool)
for rows.Next() { for rows.Next() {
var id string var id string
if err := rows.Scan(&id); err != nil { if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("store: scan video id: %w", err) return fmt.Errorf("store: scan video id: %w", err)
} }
seen[id] = true seen[id] = true
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate video ids: %w", err) return fmt.Errorf("store: iterate video ids: %w", err)
}
return nil
}); err != nil {
return nil, err
} }
return seen, nil return seen, nil
} }
+14 -17
View File
@@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/jackc/pgx/v5"
"gitea.d-ma.be/mathias/tapir/internal/domain" "gitea.d-ma.be/mathias/tapir/internal/domain"
) )
@@ -29,25 +31,20 @@ func (s *Store) UpsertVideo(ctx context.Context, v domain.Video) (string, error)
return "", fmt.Errorf("store: upsert video: empty provider video id") return "", fmt.Errorf("store: upsert video: empty provider video id")
} }
tx, err := s.pool.Begin(ctx)
if err != nil {
return "", fmt.Errorf("store: begin: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit
// Ensure the owning user exists (FK target) — same as the Deliver path.
if _, err := tx.Exec(ctx,
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
v.UserID); err != nil {
return "", fmt.Errorf("store: upsert user: %w", err)
}
provider := string(v.Provider) provider := string(v.Provider)
if provider == "" { if provider == "" {
provider = string(domain.ProviderYouTube) provider = string(domain.ProviderYouTube)
} }
var id string var id string
if err := s.withUser(ctx, v.UserID, func(tx pgx.Tx) error {
// Ensure the owning user exists (FK target) — same as the Deliver path.
if _, err := tx.Exec(ctx,
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
v.UserID); err != nil {
return fmt.Errorf("store: upsert user: %w", err)
}
if err := tx.QueryRow(ctx, if err := tx.QueryRow(ctx,
`INSERT INTO videos (user_id, provider, provider_video_id, title, url, published_at) `INSERT INTO videos (user_id, provider, provider_video_id, title, url, published_at)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
@@ -58,11 +55,11 @@ func (s *Store) UpsertVideo(ctx context.Context, v domain.Video) (string, error)
RETURNING id`, RETURNING id`,
v.UserID, provider, v.ProviderVideoID, v.Title, v.URL, nullTime(v.PublishedAt), v.UserID, provider, v.ProviderVideoID, v.Title, v.URL, nullTime(v.PublishedAt),
).Scan(&id); err != nil { ).Scan(&id); err != nil {
return "", fmt.Errorf("store: upsert video: %w", err) return fmt.Errorf("store: upsert video: %w", err)
} }
return nil
if err := tx.Commit(ctx); err != nil { }); err != nil {
return "", fmt.Errorf("store: commit: %w", err) return "", err
} }
return id, nil return id, nil
} }