Skip to content

Commit d32d8bf

Browse files
committed
docs: document full grant surface for non-superuser RLS sync
Previous guidance in jwt-claims.md listed an incomplete minimum set (schema + cloudsync_changes + user table + sequence). Missing grants on internal objects — shadow tables, cloudsync_settings, cloudsync_site_id, cloudsync_table_settings, cloudsync_schema_versions, app_schema_version, and the cloudsync_site_id_id_seq sequence — cause the per-PK savepoint to silently roll back writes while cloudsync_payload_apply still returns a non-zero column-change count, so callers see success while rows never land. jwt-claims.md: expand "PostgreSQL Role Requirement" with role creation (NOLOGIN + GRANT), a recommended default-privileges pattern, an explicit minimum allowlist for audited deployments, and a BYPASSRLS service-role recipe. rls.md: add a matching troubleshooting entry for the silent-skip failure mode, linking back to the grants reference.
1 parent 1a9e141 commit d32d8bf

2 files changed

Lines changed: 94 additions & 13 deletions

File tree

docs/postgresql/reference/jwt-claims.md

Lines changed: 86 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,36 +62,109 @@ For PostgreSQL JWT authentication, the `role` claim must name a real database ro
6262
That role should:
6363

6464
- already exist in PostgreSQL
65-
- have the schema, table, and sequence privileges your sync operations need
66-
- have access to the `cloudsync_changes` view used by PostgreSQL sync operations
65+
- have the schema, table, and sequence privileges your sync operations need (see [Required Grants](#required-grants))
6766
- be grantable by the connection-string user
6867

6968
If the JWT contains a `role` that does not exist, or the connection user cannot switch into it, PostgreSQL sync operations will fail even if the JWT itself is otherwise valid.
7069

71-
### Minimum Grants for a JWT Role
70+
### Creating the Role
7271

73-
In a standard PostgreSQL setup, functions created by `CREATE EXTENSION cloudsync;` are executable by `PUBLIC` unless your cluster has been hardened with explicit `REVOKE EXECUTE` statements. In the normal case, the JWT role needs grants on:
72+
A typical setup uses a `NOLOGIN` role that your connection user enters via `SET LOCAL ROLE` after JWT verification:
7473

75-
- the schema that contains your synced tables
76-
- the `cloudsync_changes` view
77-
- the synced user tables
78-
- any sequences used by those tables
74+
```sql
75+
CREATE ROLE rls_role NOLOGIN;
76+
77+
-- Allow the connection-string user (e.g. `postgres`) to switch into it
78+
GRANT rls_role TO postgres;
79+
```
80+
81+
### Required Grants
82+
83+
`cloudsync_payload_apply` running as a non-superuser touches several internal CloudSync objects during apply — not just your user table. If any grant is missing on an internal object, the per-PK savepoint silently rolls back the write and the caller sees a non-zero column-change count with no rows landing (see [RLS Troubleshooting](./rls.md#apply-reports-a-count-but-rows-are-missing)).
84+
85+
There are two equivalent ways to configure this: the **recommended default-privileges pattern** (future-proof) or the **explicit minimum grant set** (tighter, for audited deployments).
86+
87+
#### Recommended: default-privileges pattern
88+
89+
Run this **before** `CREATE EXTENSION cloudsync`, as the role that will install the extension (typically `postgres`). Objects created afterwards — including all CloudSync internal tables and future `cloudsync_init` shadows — inherit the grants automatically:
90+
91+
```sql
92+
GRANT USAGE ON SCHEMA public TO rls_role;
93+
GRANT USAGE ON SCHEMA auth TO rls_role;
94+
95+
ALTER DEFAULT PRIVILEGES IN SCHEMA public
96+
GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER
97+
ON TABLES TO rls_role;
98+
99+
ALTER DEFAULT PRIVILEGES IN SCHEMA public
100+
GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO rls_role;
101+
102+
ALTER DEFAULT PRIVILEGES IN SCHEMA public
103+
GRANT EXECUTE ON FUNCTIONS TO rls_role;
104+
105+
CREATE EXTENSION IF NOT EXISTS cloudsync;
106+
```
107+
108+
**If the extension is already installed**, `ALTER DEFAULT PRIVILEGES` doesn't apply retroactively — backfill existing objects with a one-time broad grant, then still set defaults for future creations:
109+
110+
```sql
111+
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO rls_role;
112+
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO rls_role;
113+
-- (plus the ALTER DEFAULT PRIVILEGES block above)
114+
```
115+
116+
#### Explicit minimum grant set
79117

80-
Example:
118+
For audited deployments that need an explicit allowlist, the tightest set that allows `cloudsync_payload_apply` to work under a non-superuser:
81119

82120
```sql
83121
GRANT USAGE ON SCHEMA public TO rls_role;
122+
GRANT USAGE ON SCHEMA auth TO rls_role;
123+
124+
-- User table (RLS policies filter rows within these grants)
125+
GRANT SELECT, INSERT, UPDATE, DELETE ON your_table TO rls_role;
126+
127+
-- Per-table CRDT shadow (created by cloudsync_init)
128+
GRANT SELECT, INSERT, UPDATE, DELETE ON your_table_cloudsync TO rls_role;
84129

130+
-- CloudSync metadata tables
131+
GRANT SELECT, INSERT, UPDATE, DELETE ON
132+
cloudsync_settings,
133+
cloudsync_table_settings,
134+
cloudsync_site_id,
135+
cloudsync_schema_versions,
136+
app_schema_version
137+
TO rls_role;
138+
139+
-- cloudsync_changes view: SELECT for apply-path readback, INSERT for the
140+
-- INSTEAD OF trigger that feeds column changes into the flush buffer
85141
GRANT SELECT, INSERT ON cloudsync_changes TO rls_role;
86142

87-
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE your_table TO rls_role;
143+
-- BIGSERIAL-backed sequence on cloudsync_site_id.id (nextval needs USAGE)
144+
GRANT USAGE ON SEQUENCE cloudsync_site_id_id_seq TO rls_role;
88145

89-
GRANT USAGE, SELECT ON SEQUENCE your_table_id_seq TO rls_role;
146+
-- Your user table's sequence, if it uses SERIAL / IDENTITY
147+
-- GRANT USAGE, SELECT ON SEQUENCE your_table_id_seq TO rls_role;
90148
```
91149

92-
Administrative functions such as `cloudsync_init`, `cloudsync_enable`, `cloudsync_set*`, `cloudsync_terminate`, `cloudsync_cleanup`, `cloudsync_begin_alter`, and `cloudsync_commit_alter` should be run by the database owner during setup, not by client JWT roles.
150+
Notes on the minimum set:
151+
152+
- **No `EXECUTE` grants on `cloudsync_*` functions or `auth.uid()` are required**, because PostgreSQL defaults `CREATE FUNCTION` to `EXECUTE TO PUBLIC`. If your cluster has revoked PUBLIC execute, grant `EXECUTE` explicitly on `cloudsync_payload_apply`, `cloudsync_payload_encode`, `cloudsync_changes_select`, `cloudsync_changes_insert_trigger`, `cloudsync_siteid`, `cloudsync_pk_encode`, and `cloudsync_encode_value`.
153+
- **`app_schema_version` is not `cloudsync_*`-prefixed** — easy to miss in `cloudsync_%`-pattern grants.
154+
- **Per-table shadows follow the `<table>_cloudsync` convention** — repeat the DML grant for every table passed to `cloudsync_init`.
155+
- **Administrative functions** such as `cloudsync_init`, `cloudsync_enable`, `cloudsync_set*`, `cloudsync_terminate`, `cloudsync_cleanup`, `cloudsync_begin_alter`, and `cloudsync_commit_alter` should be run by the database owner during setup, not by client JWT roles.
156+
- The minimum set will need widening if a future CloudSync version adds new internal objects. The default-privileges pattern above is future-proof.
157+
158+
### Service Role (RLS Bypass)
159+
160+
For server-side workers that need to apply payloads without RLS enforcement (admin restores, cross-user sync, maintenance jobs), create a dedicated role with `BYPASSRLS`:
161+
162+
```sql
163+
CREATE ROLE service_role NOLOGIN BYPASSRLS;
164+
GRANT service_role TO postgres;
165+
```
93166

94-
If your PostgreSQL setup has revoked the default `PUBLIC` execute privileges on functions, you must also explicitly grant execute permissions on the specific CloudSync functions needed by your sync path.
167+
Apply the same grants as for `rls_role`. Use this role only from trusted server code, never from JWT-gated request paths.
95168

96169
---
97170

docs/postgresql/reference/rls.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,14 @@ When using Supabase:
178178
- The `user_id` column in the synced data matches `auth.uid()`
179179
- RLS policies reference the correct ownership column
180180

181+
### Apply reports a count, but rows are missing
182+
183+
**Symptom**: `cloudsync_payload_apply` returns a non-zero column-change count, but `SELECT` on the target table shows no new rows. No error is raised to the caller.
184+
185+
**Cause**: The calling role is missing a grant on one of CloudSync's internal objects — the per-table shadow (`<table>_cloudsync`), a metadata table (`cloudsync_settings`, `cloudsync_site_id`, `cloudsync_table_settings`, `cloudsync_schema_versions`, `app_schema_version`), the `cloudsync_changes` view, or the `cloudsync_site_id_id_seq` sequence. The per-PK savepoint rolls the write back, but `cloudsync_payload_apply` still returns the number of column changes it processed.
186+
187+
**Solution**: Apply the full grant set from [JWT Claims → Required Grants](./jwt-claims.md#required-grants). To pinpoint which object is missing, re-run the apply as a superuser or raise log verbosity and inspect the server log for `permission denied` entries preceded by the `cloudsync_payload_apply` call.
188+
181189
### Debugging
182190

183191
```sql

0 commit comments

Comments
 (0)