diff --git a/internal/adapters/store/account.go b/internal/adapters/store/account.go index d269e2e..780c3f1 100644 --- a/internal/adapters/store/account.go +++ b/internal/adapters/store/account.go @@ -13,10 +13,11 @@ import ( // Deleting the users row cascades (ON DELETE CASCADE) to videos, transcripts, // summaries (→ sink_deliveries), video_connections, and the user_identities map // — referential-integrity cascades bypass RLS, so a user's child rows are removed -// even though the deleting connection is scoped. summary_actions is the exception: -// it carries a user_id but has NO foreign key to users (migration 002), so the -// cascade does not reach it; it is deleted explicitly in the same scoped -// transaction. Deleting an absent user is a no-op (idempotent). +// even though the deleting connection is scoped. summary_actions and login_events +// are the exceptions: each carries a user_id but has NO foreign key to users +// (migrations 002 and 010), so the cascade does not reach them; they are deleted +// explicitly in the same scoped transaction. Deleting an absent user is a no-op +// (idempotent). // // This is tapir-side only (decision 2026-06-03): it removes all tapir data; the // Dex login identity is left untouched — a later login simply re-enters @@ -28,6 +29,10 @@ func (s *Store) DeleteUser(ctx context.Context, userID string) error { `DELETE FROM summary_actions WHERE user_id = $1`, userID); err != nil { return fmt.Errorf("store: delete summary_actions: %w", err) } + if _, err := tx.Exec(ctx, + `DELETE FROM login_events WHERE user_id = $1`, userID); err != nil { + return fmt.Errorf("store: delete login_events: %w", err) + } if _, err := tx.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID); err != nil { return fmt.Errorf("store: delete user: %w", err) diff --git a/internal/adapters/store/login.go b/internal/adapters/store/login.go new file mode 100644 index 0000000..5763428 --- /dev/null +++ b/internal/adapters/store/login.go @@ -0,0 +1,41 @@ +package store + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// StampLogin records that the user was active today, throttled to one row per +// user per day. It is the read-side counterpart to SetAction: the middleware +// calls it on every authenticated request, but the append happens at most once a +// day so login_events stays small and the signal clean (one row = one active +// day, not one request). +// +// The check-and-insert is a single atomic statement: the INSERT ... SELECT ... +// WHERE NOT EXISTS only writes when no row for this user has seen_at in today +// (date_trunc('day', NOW()), server timezone). It runs through withUser, so the +// NOT EXISTS probe is itself RLS-scoped to the calling user via the +// tapir.current_user_id GUC — one user's stamp can never be suppressed or +// triggered by another user's rows. The explicit user_id predicate also keeps the +// probe on the (user_id, seen_at) index. +// +// A unique constraint is deliberately not used: under concurrent same-day +// requests the worst case is two rows for one day, which the gate query collapses +// to a single week bucket anyway — not worth a write-blocking constraint. +func (s *Store) StampLogin(ctx context.Context, userID string) error { + if err := s.withUser(ctx, userID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, + `INSERT INTO login_events (user_id) + SELECT $1 + WHERE NOT EXISTS ( + SELECT 1 FROM login_events + WHERE user_id = $1 AND seen_at >= date_trunc('day', NOW()) + )`, userID) + return err + }); err != nil { + return fmt.Errorf("store: stamp login: %w", err) + } + return nil +} diff --git a/internal/adapters/store/login_test.go b/internal/adapters/store/login_test.go new file mode 100644 index 0000000..edf37ee --- /dev/null +++ b/internal/adapters/store/login_test.go @@ -0,0 +1,83 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" +) + +// countLoginEvents counts a user's login_events via the superuser pool, which +// bypasses RLS — so the assertion sees the true row count regardless of scope. +func countLoginEvents(t *testing.T, p *pgxpool.Pool, userID string) int { + t.Helper() + var n int + require.NoError(t, p.QueryRow(context.Background(), + `SELECT count(*) FROM login_events WHERE user_id = $1`, userID).Scan(&n)) + return n +} + +// TestStampLoginThrottlesToOnePerDay: repeated stamps within the same day insert +// exactly one row — the throttle that keeps login_events one-row-per-active-day. +func TestStampLoginThrottlesToOnePerDay(t *testing.T) { + ctx := context.Background() + s := newStore(t) + super := rawPool(t) + resetDB(t, super) + _, err := super.Exec(ctx, `INSERT INTO users (id) VALUES ($1)`, userA) + require.NoError(t, err) + + for i := 0; i < 3; i++ { + require.NoError(t, s.StampLogin(ctx, userA)) + } + require.Equal(t, 1, countLoginEvents(t, super, userA), + "three same-day stamps must collapse to one row") +} + +// TestStampLoginRecordsOncePerNewDay: with yesterday's row already present, a +// stamp today is NOT throttled — it appends the day's row, so distinct active days +// accumulate (the substrate the gate's distinct-week count reads). +func TestStampLoginRecordsOncePerNewDay(t *testing.T) { + ctx := context.Background() + s := newStore(t) + super := rawPool(t) + resetDB(t, super) + _, err := super.Exec(ctx, `INSERT INTO users (id) VALUES ($1)`, userA) + require.NoError(t, err) + + // Seed an event dated yesterday (before today's start), so the throttle's + // "row exists with seen_at >= start-of-today" probe finds nothing for today. + _, err = super.Exec(ctx, + `INSERT INTO login_events (user_id, seen_at) VALUES ($1, NOW() - INTERVAL '1 day')`, userA) + require.NoError(t, err) + + require.NoError(t, s.StampLogin(ctx, userA)) + require.Equal(t, 2, countLoginEvents(t, super, userA), + "a stamp on a new day must append a second row") + + // A second stamp the same day is throttled again. + require.NoError(t, s.StampLogin(ctx, userA)) + require.Equal(t, 2, countLoginEvents(t, super, userA), + "the same-day repeat must not add a third row") +} + +// TestStampLoginIsUserScoped: one user's stamp lands only on that user's rows — +// the throttle probe is RLS-scoped, so user B's existing same-day row neither +// suppresses nor is touched by user A's stamp. +func TestStampLoginIsUserScoped(t *testing.T) { + ctx := context.Background() + s := newStore(t) + super := rawPool(t) + resetDB(t, super) + _, err := super.Exec(ctx, `INSERT INTO users (id) VALUES ($1), ($2)`, userA, userB) + require.NoError(t, err) + + // B already has a same-day row; it must not throttle A's first stamp. + _, err = super.Exec(ctx, `INSERT INTO login_events (user_id) VALUES ($1)`, userB) + require.NoError(t, err) + + require.NoError(t, s.StampLogin(ctx, userA)) + require.Equal(t, 1, countLoginEvents(t, super, userA), "A's stamp must record despite B's same-day row") + require.Equal(t, 1, countLoginEvents(t, super, userB), "A's stamp must not touch B's rows") +} diff --git a/internal/adapters/store/rls_test.go b/internal/adapters/store/rls_test.go index b4b7385..4f07018 100644 --- a/internal/adapters/store/rls_test.go +++ b/internal/adapters/store/rls_test.go @@ -24,7 +24,7 @@ import ( // userIsolatedTables are the tables that carry a user_id and whose policy keys // directly off the tapir.current_user_id GUC. var userIsolatedTables = []string{ - "users", "videos", "transcripts", "summaries", "summary_actions", "video_connections", + "users", "videos", "transcripts", "summaries", "summary_actions", "login_events", "video_connections", } // allIsolatedTables adds sink_deliveries, whose ownership is derived from its @@ -69,6 +69,10 @@ func seedUser(t *testing.T, p *pgxpool.Pool, userID string) seeded { VALUES ($1, $2, 'watched')`, userID, videoID) require.NoError(t, err) + _, err = p.Exec(ctx, + `INSERT INTO login_events (user_id) VALUES ($1)`, userID) + require.NoError(t, err) + _, err = p.Exec(ctx, `INSERT INTO sink_deliveries (summary_id, sink, status) VALUES ($1, 'store', 'delivered')`, summaryID) @@ -185,10 +189,12 @@ func TestRLSEnforcesPerUserIsolation(t *testing.T) { {"update transcripts", `UPDATE transcripts SET content = 'hacked' WHERE user_id = $1`, b.userID}, {"update summaries", `UPDATE summaries SET summary = 'hacked' WHERE user_id = $1`, b.userID}, {"update summary_actions", `UPDATE summary_actions SET action = 'skipped' WHERE user_id = $1`, b.userID}, + {"update login_events", `UPDATE login_events SET seen_at = NOW() WHERE user_id = $1`, b.userID}, {"update sink_deliveries", `UPDATE sink_deliveries SET status = 'hacked' WHERE summary_id = $1`, b.summaryID}, {"update video_connections", `UPDATE video_connections SET token_ref = 'hacked' WHERE user_id = $1`, b.userID}, {"delete summaries", `DELETE FROM summaries WHERE user_id = $1`, b.userID}, {"delete summary_actions", `DELETE FROM summary_actions WHERE user_id = $1`, b.userID}, + {"delete login_events", `DELETE FROM login_events WHERE user_id = $1`, b.userID}, {"delete sink_deliveries", `DELETE FROM sink_deliveries WHERE summary_id = $1`, b.summaryID}, {"delete video_connections", `DELETE FROM video_connections WHERE user_id = $1`, b.userID}, } @@ -205,17 +211,20 @@ func TestRLSEnforcesPerUserIsolation(t *testing.T) { `SELECT summary FROM summaries WHERE user_id = $1`, b.userID).Scan(&bSummary)) require.Equal(t, "sum", bSummary, "B's summary must be untouched by A's writes") - var bSummaries, bActions, bDeliveries, bConnections int + var bSummaries, bActions, bLogins, bDeliveries, bConnections int require.NoError(t, super.QueryRow(ctx, `SELECT count(*) FROM summaries WHERE user_id = $1`, b.userID).Scan(&bSummaries)) require.NoError(t, super.QueryRow(ctx, `SELECT count(*) FROM summary_actions WHERE user_id = $1`, b.userID).Scan(&bActions)) + require.NoError(t, super.QueryRow(ctx, + `SELECT count(*) FROM login_events WHERE user_id = $1`, b.userID).Scan(&bLogins)) require.NoError(t, super.QueryRow(ctx, fmt.Sprintf(`SELECT count(*) FROM sink_deliveries WHERE summary_id = '%s'`, b.summaryID)).Scan(&bDeliveries)) require.NoError(t, super.QueryRow(ctx, `SELECT count(*) FROM video_connections WHERE user_id = $1 AND token_ref <> 'hacked'`, b.userID).Scan(&bConnections)) require.Equal(t, 1, bSummaries, "A's DELETE must not have removed B's summary") require.Equal(t, 1, bActions, "A's DELETE must not have removed B's action") + require.Equal(t, 1, bLogins, "A's DELETE must not have removed B's login event") require.Equal(t, 1, bDeliveries, "A's DELETE must not have removed B's delivery") require.Equal(t, 1, bConnections, "A's writes must not have touched B's connection")