Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,5 @@ next-env.d.ts
# Sentry Config File
.env.sentry-build-plugin

docs/
# Claude Code local settings
.claude/settings.local.json
111 changes: 111 additions & 0 deletions docs/realtime-rls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Realtime + RLS (OUT-3846)

How the frontend gets live status updates without ever reading our tables directly.

## The problem

The browser uses the Supabase **anon key** (`NEXT_PUBLIC_SUPABASE_ANON_KEY`) only for
realtime updates — two of them:

- `useRealtimeSync` → sync progress for a channel
- `useRealtimeDropboxConnections` → Dropbox connection status

We can't just let anon read the `channel_sync` / `dropbox_connections` tables, because:

- Those tables hold **secrets** (`refresh_token`, `account_id`, `dbx_cursor`, etc.).
- The old `postgres_changes` realtime mode broadcasts the **whole row**, secrets included.
- There's no logged-in Supabase user, so RLS can't scope rows per-portal. Any anon
policy is effectively "world readable".

## The approach: broadcast a curated payload from the database

Instead of exposing tables, the database **pushes a hand-picked, secret-free payload**
to the client over a private realtime channel.

```
table UPDATE ─▶ trigger ─▶ realtime.send(curated payload, ... , private=true)
realtime.messages ──(private channel)──▶ browser (anon)
```

## What we changed

### 1. Lock down the tables — RLS on, anon revoked

Row Level Security is enabled on every app table, and **all access is revoked from anon**.
The browser can never read or write our tables directly. (Server-side sync code connects
as the `postgres` role, which has `BYPASSRLS`, so Trigger.dev jobs are unaffected.)

### 2. Trigger functions that broadcast curated payloads

Two `AFTER UPDATE` triggers build a small JSON payload with **only the safe columns the UI
needs** — no secrets — and call `realtime.send(payload, event, topic, private => true)`.

| Trigger | Topic | Event | Fires when |
|---|---|---|---|
| `broadcast_channel_sync_status` | `channel_sync:<portal_id>` | `sync_update` | a UI-visible column changes (cursor-only writes are ignored) |
| `broadcast_dropbox_connection_status` | `dropbox_connection:<portal_id>` | `connection_update` | `status` changes |

> The 4th arg to `realtime.send` is `private`. `true` = private channel.

### 3. Policy so anon can receive the broadcast

Private channels only deliver messages the role is allowed to read, enforced by RLS on
`realtime.messages`. We add a `SELECT` policy for `anon`, scoped to just our two topics:

```sql
create policy "anon_receive_curated_broadcasts"
on realtime.messages
for select
to anon
using (
realtime.topic() like 'channel_sync:%'
or realtime.topic() like 'dropbox_connection:%'
);
```

Manually run file `supabase/snippets/2026-06-12-rls_and_anon_policy_to_realtime.sql` to
enable RLS on all tables and create the policy.

**Accepted trade-off — broadcasts are not scoped per portal.** The policy matches
`channel_sync:%` / `dropbox_connection:%`, not the subscriber's own portal. Per-portal
scoping is impossible here: the anon key carries no JWT, so there's no identity to match a
topic against (`auth.uid()` is null). So any anon client that knows or guesses another
portal's ID can subscribe to `channel_sync:<that_portal_id>` and receive its
`dbx_root_path`, sync counts, and `assembly_channel_id`. These are **not credentials**, but
they are business-sensitive — that visibility is the accepted cost of an anon-only realtime
path.

> ⚠️ Because topics are world-readable within their prefix, the payloads MUST stay
> non-sensitive. Do not add credentials, tokens, cursors, or account/namespace IDs to the
> `realtime.send()` calls in the triggers. Anything broadcast is readable cross-portal.

### 4. Client subscribes to the private channel

The channel name must equal the trigger's topic, and it must be marked private:

```ts
supabase.channel(topic, { config: { private: true } })
```

## Files

- `src/db/migrations/20260611081157_realtime_broadcast_triggers.sql` — triggers + trigger functions (Drizzle migration)
- `supabase/snippets/2026-06-12-rls_and_anon_policy_to_realtime.sql` — RLS enable, anon revoke, `realtime.messages` policy (run manually in the SQL editor)
- `src/lib/supabase/hooks/useRealtime.tsx` — generic private-channel subscriber
- `src/features/sync/hooks/useRealtimeSync.ts`
- `src/features/auth/hooks/useRealtimeDropboxConnetions.ts`

## Why it's safe

| Layer | Guarantee |
|---|---|
| RLS on + anon revoked | browser can't touch app tables |
| Curated payload | only non-secret columns ever leave the DB |
| Private channel + policy | anon receives only our two topics |
| `postgres` role `BYPASSRLS` | server sync jobs unaffected |

Not protected: cross-portal isolation. Broadcasts are readable by any anon client that knows
the portal ID — accepted because payloads carry only business-sensitive (not secret) fields.
See the trade-off note above before widening any payload.
67 changes: 67 additions & 0 deletions src/db/migrations/20260611081157_realtime_broadcast_triggers.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
-- channel_sync: broadcast a curated, secret-free payload (no dbx_cursor/account/root ids).
create or replace function public.broadcast_channel_sync_status()
returns trigger
language plpgsql
as $$
begin
-- Skip no-op broadcasts (hot path writes dbx_cursor every page); emit only on UI-visible change.
if (
new.status, new.synced_files_count, new.total_files_count,
new.last_synced_at, new.dbx_root_path, new.assembly_channel_id
) is distinct from (
old.status, old.synced_files_count, old.total_files_count,
old.last_synced_at, old.dbx_root_path, old.assembly_channel_id
) then
perform realtime.send(
jsonb_build_object(
'id', new.id,
'portal_id', new.portal_id,
'assembly_channel_id', new.assembly_channel_id,
'dbx_root_path', new.dbx_root_path,
'status', new.status,
'total_files_count', new.total_files_count,
'synced_files_count', new.synced_files_count,
'last_synced_at', new.last_synced_at
),
'sync_update', -- event
'channel_sync:' || new.portal_id, -- topic (per-portal)
true -- private channel (anon RLS in supabase/snippets)
);
end if;
return new;
end;
$$;

drop trigger if exists trg_broadcast_channel_sync_status on public.channel_sync;
create trigger trg_broadcast_channel_sync_status
after update on public.channel_sync
for each row
execute function public.broadcast_channel_sync_status();

-- dropbox_connections: broadcast status only (no refresh_token/account/namespace ids).
create or replace function public.broadcast_dropbox_connection_status()
returns trigger
language plpgsql
as $$
begin
if new.status is distinct from old.status then
perform realtime.send(
jsonb_build_object(
'id', new.id,
'portal_id', new.portal_id,
'status', new.status
),
'connection_update',
'dropbox_connection:' || new.portal_id,
true
);
end if;
return new;
end;
$$;

drop trigger if exists trg_broadcast_dropbox_connection_status on public.dropbox_connections;
create trigger trg_broadcast_dropbox_connection_status
after update on public.dropbox_connections
for each row
execute function public.broadcast_dropbox_connection_status();
Loading
Loading