Skip to content

Commit b063154

Browse files
committed
docs(postgresql/RLS): PostgreSQL RLS with CloudSync
1 parent 9a5b3fc commit b063154

File tree

1 file changed

+192
-0
lines changed

1 file changed

+192
-0
lines changed

docs/postgresql/RLS.md

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# Row Level Security (RLS) with CloudSync
2+
3+
CloudSync is fully compatible with PostgreSQL Row Level Security. Standard RLS policies work out of the box.
4+
5+
## How It Works
6+
7+
### Column-batch merge
8+
9+
CloudSync resolves CRDT conflicts at the column level — a sync payload may contain individual column changes arriving one at a time. Before writing to the target table, CloudSync buffers all winning column values for the same primary key and flushes them as a single SQL statement. This ensures the database sees a complete row with all columns present.
10+
11+
### UPDATE vs INSERT selection
12+
13+
When flushing a batch, CloudSync chooses the statement type based on whether the row already exists locally:
14+
15+
- **New row**: `INSERT ... ON CONFLICT DO UPDATE` — all columns are present (including the ownership column), so the INSERT `WITH CHECK` policy can evaluate correctly.
16+
- **Existing row**: `UPDATE ... SET ... WHERE pk = ...` — only the changed columns are set. The UPDATE `USING` policy checks the existing row, which already has the correct ownership column value.
17+
18+
### Per-PK savepoint isolation
19+
20+
Each primary key's flush is wrapped in its own savepoint. When RLS denies a write:
21+
22+
1. The database raises an error inside the savepoint
23+
2. CloudSync rolls back that savepoint, releasing all resources acquired during the failed statement
24+
3. Processing continues with the next primary key
25+
26+
This means a single payload can contain a mix of allowed and denied rows — allowed rows commit normally, denied rows are silently skipped. The caller receives the total number of column changes processed (including denied ones) rather than an error.
27+
28+
## Quick Setup
29+
30+
Given a table with an ownership column (`user_id`):
31+
32+
```sql
33+
CREATE TABLE documents (
34+
id TEXT PRIMARY KEY NOT NULL,
35+
user_id UUID,
36+
title TEXT,
37+
content TEXT
38+
);
39+
40+
SELECT cloudsync_init('documents');
41+
```
42+
43+
Enable RLS and create standard policies:
44+
45+
```sql
46+
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
47+
48+
CREATE POLICY "select_own" ON documents FOR SELECT
49+
USING (auth.uid() = user_id);
50+
51+
CREATE POLICY "insert_own" ON documents FOR INSERT
52+
WITH CHECK (auth.uid() = user_id);
53+
54+
CREATE POLICY "update_own" ON documents FOR UPDATE
55+
USING (auth.uid() = user_id)
56+
WITH CHECK (auth.uid() = user_id);
57+
58+
CREATE POLICY "delete_own" ON documents FOR DELETE
59+
USING (auth.uid() = user_id);
60+
```
61+
62+
## Example: Two-User Sync with RLS
63+
64+
This example shows the complete flow of syncing data between two databases where the target enforces RLS.
65+
66+
### Setup
67+
68+
```sql
69+
-- Source database (DB A) — no RLS, represents the sync server
70+
CREATE TABLE documents (
71+
id TEXT PRIMARY KEY NOT NULL, user_id UUID, title TEXT, content TEXT
72+
);
73+
SELECT cloudsync_init('documents');
74+
75+
-- Target database (DB B) — RLS enforced
76+
CREATE TABLE documents (
77+
id TEXT PRIMARY KEY NOT NULL, user_id UUID, title TEXT, content TEXT
78+
);
79+
SELECT cloudsync_init('documents');
80+
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
81+
-- (policies as above)
82+
```
83+
84+
### Insert sync
85+
86+
User 1 creates a document on DB A:
87+
88+
```sql
89+
-- On DB A
90+
INSERT INTO documents VALUES ('doc1', 'user1-uuid', 'Hello', 'World');
91+
```
92+
93+
Apply the payload on DB B as the authenticated user:
94+
95+
```sql
96+
-- On DB B (running as user1)
97+
SET app.current_user_id = 'user1-uuid';
98+
SET ROLE authenticated;
99+
SELECT cloudsync_payload_apply(decode(:payload_hex, 'hex'));
100+
```
101+
102+
The insert succeeds because `user_id` matches `auth.uid()`.
103+
104+
### Insert denial
105+
106+
User 1 tries to sync a document owned by user 2:
107+
108+
```sql
109+
-- On DB A
110+
INSERT INTO documents VALUES ('doc2', 'user2-uuid', 'Secret', 'Data');
111+
```
112+
113+
```sql
114+
-- On DB B (running as user1)
115+
SET app.current_user_id = 'user1-uuid';
116+
SET ROLE authenticated;
117+
SELECT cloudsync_payload_apply(decode(:payload_hex, 'hex'));
118+
```
119+
120+
The insert is denied by RLS. The row does not appear in DB B. No error is raised to the caller — CloudSync isolates the failure via a per-PK savepoint and continues processing the remaining payload.
121+
122+
### Partial update sync
123+
124+
User 1 updates only the title of their own document:
125+
126+
```sql
127+
-- On DB A
128+
UPDATE documents SET title = 'Hello Updated' WHERE id = 'doc1';
129+
```
130+
131+
The sync payload contains only the changed column (`title`). CloudSync detects that the row already exists on DB B and uses a plain `UPDATE` statement:
132+
133+
```sql
134+
UPDATE documents SET title = $2 WHERE id = $1;
135+
```
136+
137+
The UPDATE policy checks the existing row (which has the correct `user_id`), so it succeeds.
138+
139+
### Mixed payload
140+
141+
When a single payload contains rows for multiple users, CloudSync handles each primary key independently:
142+
143+
```sql
144+
-- On DB A
145+
INSERT INTO documents VALUES ('doc3', 'user1-uuid', 'Mine', '...');
146+
INSERT INTO documents VALUES ('doc4', 'user2-uuid', 'Theirs', '...');
147+
```
148+
149+
```sql
150+
-- On DB B (running as user1)
151+
SELECT cloudsync_payload_apply(decode(:payload_hex, 'hex'));
152+
-- doc3 is inserted (allowed), doc4 is silently skipped (denied)
153+
```
154+
155+
## Supabase Notes
156+
157+
When using Supabase:
158+
159+
1. **auth.uid()**: Returns the authenticated user's UUID from the JWT claims.
160+
2. **JWT propagation**: Ensure the JWT token is set before sync operations:
161+
```sql
162+
SELECT set_config('request.jwt.claims', '{"sub": "user-uuid", ...}', true);
163+
```
164+
3. **Service role bypass**: The Supabase service role bypasses RLS entirely. Use the `authenticated` role for user-context operations where RLS enforcement is desired.
165+
166+
## Troubleshooting
167+
168+
### "new row violates row-level security policy"
169+
170+
**Symptom**: Insert operations fail during sync.
171+
172+
**Cause**: The ownership column value doesn't match the authenticated user.
173+
174+
**Solution**: Verify that:
175+
- The JWT / session variable is set correctly before calling `cloudsync_payload_apply`
176+
- The `user_id` column in the synced data matches `auth.uid()`
177+
- RLS policies reference the correct ownership column
178+
179+
### Debugging
180+
181+
```sql
182+
-- Check current auth context
183+
SELECT auth.uid();
184+
185+
-- Inspect a specific row's ownership
186+
SELECT id, user_id FROM documents WHERE id = 'problematic-pk';
187+
188+
-- Temporarily disable RLS to inspect all data
189+
ALTER TABLE documents DISABLE ROW LEVEL SECURITY;
190+
-- ... inspect ...
191+
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
192+
```

0 commit comments

Comments
 (0)