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>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user