feat(store): throttled per-day StampLogin + login_events delete cascade
StampLogin appends one login_events row per user per day via an atomic INSERT ... SELECT ... WHERE NOT EXISTS, run through withUser so the throttle probe is itself RLS-scoped to the caller. DeleteUser now deletes login_events explicitly (no FK = no cascade — the summary_actions footgun, repeated). Extends the two-user RLS isolation proof and the delete-account proof to cover login_events, and adds throttle / new-day / user-scoping tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,10 +13,11 @@ import (
|
|||||||
// Deleting the users row cascades (ON DELETE CASCADE) to videos, transcripts,
|
// Deleting the users row cascades (ON DELETE CASCADE) to videos, transcripts,
|
||||||
// summaries (→ sink_deliveries), video_connections, and the user_identities map
|
// summaries (→ sink_deliveries), video_connections, and the user_identities map
|
||||||
// — referential-integrity cascades bypass RLS, so a user's child rows are removed
|
// — 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:
|
// even though the deleting connection is scoped. summary_actions and login_events
|
||||||
// it carries a user_id but has NO foreign key to users (migration 002), so the
|
// are the exceptions: each carries a user_id but has NO foreign key to users
|
||||||
// cascade does not reach it; it is deleted explicitly in the same scoped
|
// (migrations 002 and 010), so the cascade does not reach them; they are deleted
|
||||||
// transaction. Deleting an absent user is a no-op (idempotent).
|
// 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
|
// 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
|
// 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 {
|
`DELETE FROM summary_actions WHERE user_id = $1`, userID); err != nil {
|
||||||
return fmt.Errorf("store: delete summary_actions: %w", err)
|
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,
|
if _, err := tx.Exec(ctx,
|
||||||
`DELETE FROM users WHERE id = $1`, userID); err != nil {
|
`DELETE FROM users WHERE id = $1`, userID); err != nil {
|
||||||
return fmt.Errorf("store: delete user: %w", err)
|
return fmt.Errorf("store: delete user: %w", err)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
// userIsolatedTables are the tables that carry a user_id and whose policy keys
|
// userIsolatedTables are the tables that carry a user_id and whose policy keys
|
||||||
// directly off the tapir.current_user_id GUC.
|
// directly off the tapir.current_user_id GUC.
|
||||||
var userIsolatedTables = []string{
|
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
|
// 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)
|
VALUES ($1, $2, 'watched')`, userID, videoID)
|
||||||
require.NoError(t, err)
|
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,
|
_, err = p.Exec(ctx,
|
||||||
`INSERT INTO sink_deliveries (summary_id, sink, status)
|
`INSERT INTO sink_deliveries (summary_id, sink, status)
|
||||||
VALUES ($1, 'store', 'delivered')`, summaryID)
|
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 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 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 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 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},
|
{"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 summaries", `DELETE FROM summaries WHERE user_id = $1`, b.userID},
|
||||||
{"delete summary_actions", `DELETE FROM summary_actions 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 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},
|
{"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))
|
`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")
|
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,
|
require.NoError(t, super.QueryRow(ctx,
|
||||||
`SELECT count(*) FROM summaries WHERE user_id = $1`, b.userID).Scan(&bSummaries))
|
`SELECT count(*) FROM summaries WHERE user_id = $1`, b.userID).Scan(&bSummaries))
|
||||||
require.NoError(t, super.QueryRow(ctx,
|
require.NoError(t, super.QueryRow(ctx,
|
||||||
`SELECT count(*) FROM summary_actions WHERE user_id = $1`, b.userID).Scan(&bActions))
|
`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,
|
require.NoError(t, super.QueryRow(ctx,
|
||||||
fmt.Sprintf(`SELECT count(*) FROM sink_deliveries WHERE summary_id = '%s'`, b.summaryID)).Scan(&bDeliveries))
|
fmt.Sprintf(`SELECT count(*) FROM sink_deliveries WHERE summary_id = '%s'`, b.summaryID)).Scan(&bDeliveries))
|
||||||
require.NoError(t, super.QueryRow(ctx,
|
require.NoError(t, super.QueryRow(ctx,
|
||||||
`SELECT count(*) FROM video_connections WHERE user_id = $1 AND token_ref <> 'hacked'`, b.userID).Scan(&bConnections))
|
`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, 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, 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, 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")
|
require.Equal(t, 1, bConnections, "A's writes must not have touched B's connection")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user