feat(store): add append-only login_events table with forced RLS

Stage-0 usage measurement (VISION/ADR-016): summary_actions captures acts
(watch/skip/save) but not reads. A reader who logs in weekly and clicks
nothing is invisible — for a reading product that return is the signal the
gate ("usage in >=2 distinct weeks") is defined on. login_events records
THAT a user was active, append-only, one row per user per active day.

Per-user isolation via the same GUC-keyed FORCE RLS policy as migration 003.
No FK to users (mirrors summary_actions) — the cascade footgun is handled by
DeleteUser in a later commit. Adds an up/down reversibility test and registers
the table in both truncate helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 23:46:03 +02:00
co-authored by Claude Opus 4.8
parent 99743af182
commit b070347597
5 changed files with 100 additions and 2 deletions
+61
View File
@@ -0,0 +1,61 @@
package store_test
import (
"context"
"database/sql"
"os"
"testing"
"github.com/golang-migrate/migrate/v4"
migratepgx "github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/stretchr/testify/require"
_ "github.com/jackc/pgx/v5/stdlib" // register the "pgx" database/sql driver
)
// fileMigrator builds a golang-migrate instance from the on-disk migration files
// (not the embedded FS the production Migrate uses), so a test can step the schema
// up and down. os.DirFS(".") is rooted at the package dir; the SQL lives under
// "migrations". Mirrors store.Migrate's construction otherwise.
func fileMigrator(t *testing.T) *migrate.Migrate {
t.Helper()
db, err := sql.Open("pgx", dsn)
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
drv, err := migratepgx.WithInstance(db, &migratepgx.Config{})
require.NoError(t, err)
src, err := iofs.New(os.DirFS("."), "migrations")
require.NoError(t, err)
m, err := migrate.NewWithInstance("iofs", src, "pgx", drv)
require.NoError(t, err)
t.Cleanup(func() { _, _ = m.Close() })
return m
}
// loginEventsExists reports whether the login_events relation is present.
func loginEventsExists(t *testing.T) bool {
t.Helper()
var reg *string
require.NoError(t, rawPool(t).QueryRow(context.Background(),
`SELECT to_regclass('public.login_events')::text`).Scan(&reg))
return reg != nil
}
// TestMigration010LoginEventsUpDown proves migration 010 is reversible: the down
// migration drops login_events cleanly and the up migration recreates it. A rotten
// down migration (forgotten DROP, dangling policy) would fail here rather than in
// production during a rollback. The test restores the schema to latest before
// returning so the shared embedded-postgres stays at HEAD for sibling tests.
func TestMigration010LoginEventsUpDown(t *testing.T) {
newStore(t) // ensure the schema is migrated to latest (010 applied)
require.True(t, loginEventsExists(t), "login_events must exist at latest migration")
m := fileMigrator(t)
require.NoError(t, m.Steps(-1), "down one migration must drop login_events")
require.False(t, loginEventsExists(t), "login_events must be gone after the down migration")
require.NoError(t, m.Steps(1), "up one migration must recreate login_events")
require.True(t, loginEventsExists(t), "login_events must be restored after the up migration")
}
@@ -0,0 +1,4 @@
DROP POLICY IF EXISTS login_events_isolation ON login_events;
ALTER TABLE login_events NO FORCE ROW LEVEL SECURITY;
ALTER TABLE login_events DISABLE ROW LEVEL SECURITY;
DROP TABLE IF EXISTS login_events;
@@ -0,0 +1,33 @@
-- Migration 010: login_events records THAT a user was active (returned and read)
-- on a given day — the Stage-0 signal summary_actions misses. summary_actions
-- captures *acts* (watch/skip/save); a reader who logs in weekly and clicks
-- nothing is otherwise invisible, yet for a reading product that return IS the
-- signal the gate ("usage in >=2 distinct weeks", VISION/ADR-016) is defined on.
--
-- Append-only: one row per user per active day (the request-path throttle in the
-- web layer enforces that cadence), never updated. Per-user isolation like every
-- user-owned table.
--
-- NO foreign key to users (mirrors summary_actions, migration 002): user_id is
-- carried for RLS/scoping but the table is decoupled so a stamp never blocks on a
-- users row. The cost of that decoupling: the users-row cascade does NOT reach
-- login_events, so DeleteUser must delete it explicitly (see account.go) — the
-- exact footgun the summary_actions delete work caught.
CREATE TABLE login_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- (user_id, seen_at) serves both the per-user-per-day throttle lookup
-- (seen_at >= start-of-today) and the gate query's per-user week bucketing.
CREATE INDEX idx_login_events_user_seen ON login_events(user_id, seen_at);
-- RLS: identical GUC-keyed policy/pattern to migration 003. FORCE so the table
-- owner (tapir, non-superuser in prod) is subject to it; an unset GUC yields NULL
-- → no rows match → deny-all.
ALTER TABLE login_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE login_events FORCE ROW LEVEL SECURITY;
CREATE POLICY login_events_isolation ON login_events
FOR ALL
USING (user_id = current_setting('tapir.current_user_id', true)::uuid);
+1 -1
View File
@@ -74,7 +74,7 @@ func rawPool(t *testing.T) *pgxpool.Pool {
func resetDB(t *testing.T, p *pgxpool.Pool) {
t.Helper()
_, err := p.Exec(context.Background(),
`TRUNCATE summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`)
`TRUNCATE login_events, summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`)
require.NoError(t, err)
}
+1 -1
View File
@@ -72,7 +72,7 @@ func rawPool(t *testing.T) *pgxpool.Pool {
func truncateAll(t *testing.T, p *pgxpool.Pool) {
t.Helper()
_, err := p.Exec(context.Background(),
`TRUNCATE summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`)
`TRUNCATE login_events, summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`)
require.NoError(t, err)
}