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>
101 lines
3.3 KiB
Go
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
|
|
}
|