Files
tapir/internal/adapters/store/connections.go
T
mathiasandClaude Opus 4.8 0c9531a9b8 feat(store): video_connections table + RLS + connection store methods
Add the video_connections table (data-model VIDEO_CONNECTION) with FORCE
row-level security keyed off tapir.current_user_id, identical to migration
003's per-user isolation pattern — connections are user-owned data and must
be isolated at the DB layer, not only by application WHERE clauses.

Store methods (UpsertConnection / ConnectionsForUser / DeleteConnection) all
route through withUser so RLS scopes every access. UpsertConnection is
idempotent on (user_id, provider). The OAuth refresh token never lives here;
token_ref is the opaque SecretStore reference.

Extend the RLS isolation proof to cover video_connections: seeded per user,
included in the deny-all + scoped-read assertions, and added to the
cross-user write-invisibility and survivor checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:12:34 +02:00

101 lines
3.3 KiB
Go

package store
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Connection is one connected video account (data-model.md VIDEO_CONNECTION).
// TokenRef is the opaque SecretStore reference that resolves to the OAuth refresh
// token — never the token itself. ConnectedAt is set by the DB and is read-only
// on writes (UpsertConnection ignores it).
type Connection struct {
Provider string
ProviderAccount string
TokenRef string
Status string
ConnectedAt time.Time
}
// UpsertConnection records (or refreshes) the user's connection to a provider,
// keyed on (user_id, provider): re-connecting the same provider overwrites the
// token_ref/status/account and bumps connected_at, never duplicating. Like every
// access in this package it routes through withUser, so RLS scopes the write to
// the calling user — a connection can only be written for the current user.
func (s *Store) UpsertConnection(ctx context.Context, userID string, c Connection) error {
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
`INSERT INTO video_connections
(user_id, provider, provider_account, token_ref, status)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, provider) DO UPDATE SET
provider_account = EXCLUDED.provider_account,
token_ref = EXCLUDED.token_ref,
status = EXCLUDED.status,
connected_at = now()`,
userID, c.Provider, nullIfEmpty(c.ProviderAccount), c.TokenRef, c.Status,
); err != nil {
return fmt.Errorf("store: upsert connection: %w", err)
}
return nil
})
}
// ConnectionsForUser returns the user's connections, most-recently-connected
// first. Scoped by user_id via withUser: one user never sees another's.
func (s *Store) ConnectionsForUser(ctx context.Context, userID string) ([]Connection, error) {
var out []Connection
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
`SELECT provider, COALESCE(provider_account, ''), token_ref, status, connected_at
FROM video_connections
WHERE user_id = $1
ORDER BY connected_at DESC, provider`,
userID)
if err != nil {
return fmt.Errorf("store: connections for user: %w", err)
}
defer rows.Close()
for rows.Next() {
var c Connection
if err := rows.Scan(&c.Provider, &c.ProviderAccount, &c.TokenRef, &c.Status, &c.ConnectedAt); err != nil {
return fmt.Errorf("store: scan connection: %w", err)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("store: iterate connections: %w", err)
}
return nil
}); err != nil {
return nil, err
}
return out, nil
}
// DeleteConnection removes the user's connection to a provider. Deleting an
// absent connection is a no-op (no error).
func (s *Store) DeleteConnection(ctx context.Context, userID, provider string) error {
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
`DELETE FROM video_connections WHERE user_id = $1 AND provider = $2`,
userID, provider); err != nil {
return fmt.Errorf("store: delete connection: %w", err)
}
return nil
})
}
// nullIfEmpty maps "" to a SQL NULL so an unknown provider_account is stored as
// NULL (the column is nullable) rather than an empty string.
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}