From 8433e5ccac33c393acfba253c5648772a32ef1b8 Mon Sep 17 00:00:00 2001 From: damlayildiz Date: Mon, 20 Apr 2026 17:51:54 +0200 Subject: [PATCH 1/4] docs: update documentation for postgres and self-hosted supabase authentication/rls --- docs/internal/postgres-flyio.md | 68 ++++++++++-- docs/postgresql/quickstarts/postgres.md | 40 ++++++- .../quickstarts/supabase-self-hosted.md | 50 +++++++-- docs/postgresql/reference/jwt-claims.md | 103 ++++++++++++++---- docs/postgresql/reference/rls.md | 2 + 5 files changed, 221 insertions(+), 42 deletions(-) diff --git a/docs/internal/postgres-flyio.md b/docs/internal/postgres-flyio.md index e14253c..3fd3399 100644 --- a/docs/internal/postgres-flyio.md +++ b/docs/internal/postgres-flyio.md @@ -289,17 +289,21 @@ const server = http.createServer(async (req, res) => { } // Generate token - // POST /token { "sub": "user-id", "role": "authenticated", "expiresIn": "24h" } + // POST /token { "sub": "user-id", "role": "rls_role", "expiresIn": "24h" } if (req.method === "POST" && req.url === "/token") { try { const body = await parseBody(req); const sub = body.sub || "anonymous"; - const role = body.role || "authenticated"; + const role = body.role; const expiresIn = body.expiresIn || "24h"; const claims = body.claims || {}; + if (!role) { + return respond(res, 400, { error: "role is required" }); + } + const token = jwt.sign( - { sub, role, aud: "authenticated", ...claims }, + { sub, role, ...claims }, JWT_SECRET, { expiresIn, algorithm: "HS256" } ); @@ -403,17 +407,21 @@ const server = http.createServer(async (req, res) => { return res.end(jwksResponse); } - // POST /token { "sub": "user-id", "role": "authenticated", "expiresIn": "24h" } + // POST /token { "sub": "user-id", "role": "rls_role", "expiresIn": "24h" } if (req.method === "POST" && req.url === "/token") { try { const body = await parseBody(req); const sub = body.sub || "anonymous"; - const role = body.role || "authenticated"; + const role = body.role; const expiresIn = body.expiresIn || "24h"; const claims = body.claims || {}; + if (!role) { + return respond(res, 400, { error: "role is required" }); + } + const token = jwt.sign( - { sub, role, aud: "authenticated", iss: ISSUER, ...claims }, + { sub, role, iss: ISSUER, ...claims }, privateKey, { expiresIn, algorithm: "RS256", keyid: KID } ); @@ -486,12 +494,56 @@ curl http://localhost:3002/.well-known/jwks.json ## Step 8: Generate a JWT token +Before generating JWTs for PostgreSQL, create the database role referenced by the token's `role` claim and grant it the permissions CloudSync needs. + +### 8a. Create and grant the JWT role + +Create the role: + +```bash +cd /data/cloudsync-postgres +docker compose exec db psql -U postgres -d postgres -c "CREATE ROLE rls_role NOLOGIN;" +``` + +Grant schema and table permissions on current and future tables: + +```bash +cd /data/cloudsync-postgres +docker compose exec db psql -U postgres -d test_database_1 -c "GRANT USAGE ON SCHEMA public TO rls_role;" +docker compose exec db psql -U postgres -d test_database_1 -c "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO rls_role;" +docker compose exec db psql -U postgres -d test_database_1 -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO rls_role;" +docker compose exec db psql -U postgres -d test_database_1 -c "GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO rls_role;" +docker compose exec db psql -U postgres -d test_database_1 -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO rls_role;" +``` + +Allow the connection-string user to switch into that role: + +```bash +cd /data/cloudsync-postgres +docker compose exec db psql -U postgres -d postgres -c "GRANT rls_role TO postgres;" +``` + +Verify: + +```bash +cd /data/cloudsync-postgres +docker compose exec db psql -U postgres -d postgres -c "SELECT rolname, rolsuper, rolcanlogin, rolbypassrls FROM pg_roles WHERE rolname = 'rls_role';" +docker compose exec db psql -U postgres -d test_database_1 -c "\\ddp" +``` + +If you want to test the exact session shape CloudSync uses: + +```bash +cd /data/cloudsync-postgres +docker compose exec db psql -U postgres -d test_database_1 -c "BEGIN; SELECT set_config('request.jwt.claims', '{\"sub\":\"test-user-1\",\"role\":\"rls_role\"}', true); SET LOCAL ROLE rls_role; SELECT current_role, current_setting('request.jwt.claims', true); ROLLBACK;" +``` + **HS256 (shared secret):** ```bash curl -X POST http://localhost:3001/token \ -H "Content-Type: application/json" \ - -d '{"sub": "user-1", "role": "authenticated"}' + -d '{"sub": "user-1", "role": "rls_role"}' ``` **RS256 (JWKS):** @@ -499,7 +551,7 @@ curl -X POST http://localhost:3001/token \ ```bash curl -X POST http://localhost:3002/token \ -H "Content-Type: application/json" \ - -d '{"sub": "user-1", "role": "authenticated"}' + -d '{"sub": "user-1", "role": "rls_role"}' ``` Response (both): diff --git a/docs/postgresql/quickstarts/postgres.md b/docs/postgresql/quickstarts/postgres.md index e7b5c3d..d34fc01 100644 --- a/docs/postgresql/quickstarts/postgres.md +++ b/docs/postgresql/quickstarts/postgres.md @@ -82,6 +82,25 @@ If the extension is installed correctly, PostgreSQL returns the CloudSync versio --- +## Upgrading CloudSync + +Updating the Docker image or replacing `cloudsync.so` on disk does not by itself upgrade the SQL objects already registered in an existing database. PostgreSQL keeps the extension objects that were installed when `CREATE EXTENSION cloudsync;` was first run for that database. + +When a release includes PostgreSQL extension upgrade scripts, the preferred upgrade path is: + +```sql +ALTER EXTENSION cloudsync UPDATE; +``` + +If no upgrade path is available for the version you are moving from, a drop-and-recreate may be required to refresh the extension SQL objects: + +```sql +DROP EXTENSION IF EXISTS cloudsync CASCADE; +CREATE EXTENSION cloudsync; +``` + +--- + ## Step 3: Register Your Database in the CloudSync Dashboard In the [CloudSync dashboard](https://dashboard.sqlitecloud.io/), create a new workspace with the **PostgreSQL** provider, then add a project with your PostgreSQL connection string: @@ -106,6 +125,8 @@ On the **Client Integration** tab you'll find your **Database ID** and authentic The fastest way to test CloudSync without per-user access control — no JWT setup needed. +With API key authentication, CloudSync uses the database role resolved from the API-key-authenticated connection when available; otherwise it falls back to the role from the connection string. + ```sql SELECT cloudsync_network_init(''); SELECT cloudsync_network_set_apikey(':'); @@ -116,9 +137,22 @@ SELECT cloudsync_network_sync(); 1. Set **Row Level Security** to **Yes, enforce RLS** 2. Under **Authentication (JWT)**, click **Configure authentication** and choose: - - **HMAC Secret (HS256):** Enter your JWT secret (or generate one: `openssl rand -base64 32`) - - **JWKS Issuer Validation:** Enter the issuer base URL from your token's `iss` claim (e.g. `https://your-auth-domain`). CloudSync automatically fetches the JWKS document from `/.well-known/jwks.json` -3. In your client code: + - **HMAC Secret (HS256):** + - Enter your JWT secret (or generate one: `openssl rand -base64 32`) + - Optionally add **Expected audiences**. When configured, a token's `aud` claim must contain at least one of the configured audience values. + - **JWKS Issuer Validation:** + - Enter the issuer base URL from your token's `iss` claim (for example `https://your-auth-domain`) + - By default, CloudSync uses OIDC discovery: it requests `/.well-known/openid-configuration` and reads the returned `jwks_uri` + - Optionally set an **Explicit JWKS URI** to bypass OIDC discovery and use a specific JWKS endpoint directly. This must be a full HTTPS URI. + - Optionally add **Expected audiences**. When configured, a token's `aud` claim must contain at least one of the configured audience values. +3. CloudSync validates JWTs as follows: + - **HS256:** uses the configured JWT secret + - **JWKS:** uses the explicit `jwksUri` when provided; otherwise CloudSync requests `/.well-known/openid-configuration` and reads `jwks_uri` + - CloudSync does not fall back directly to `/.well-known/jwks.json` when discovery is used +4. For claim details and RLS examples, see: + - [JWT Claims Reference](../reference/jwt-claims.md) + - [RLS Reference](../reference/rls.md) +5. In your client code: ```sql SELECT cloudsync_network_init(''); SELECT cloudsync_network_set_token(''); diff --git a/docs/postgresql/quickstarts/supabase-self-hosted.md b/docs/postgresql/quickstarts/supabase-self-hosted.md index a5dd047..d1aa9e5 100644 --- a/docs/postgresql/quickstarts/supabase-self-hosted.md +++ b/docs/postgresql/quickstarts/supabase-self-hosted.md @@ -13,15 +13,15 @@ Follow [Supabase's Installing Supabase](https://supabase.com/docs/guides/self-ho ```yaml db: # Supabase on PostgreSQL 15 - image: sqlitecloud/sqlite-sync-supabase:15.8.1.085 + image: sqlitecloud/sqlite-sync-supabase:15 # instead of: public.ecr.aws/supabase/postgres:15.8.1.085 # OR Supabase on PostgreSQL 17 - image: sqlitecloud/sqlite-sync-supabase:17.6.1.071 + image: sqlitecloud/sqlite-sync-supabase:17 # instead of: public.ecr.aws/supabase/postgres:17.6.1.071 ``` -Use the tag that matches your Supabase Postgres base image exactly. Convenience tags `sqlitecloud/sqlite-sync-supabase:15` and `sqlitecloud/sqlite-sync-supabase:17` are also published, but the exact Supabase tag is the safest choice. +Use the CloudSync image tag that matches your Supabase PostgreSQL major version. The published major tags `sqlitecloud/sqlite-sync-supabase:15` and `sqlitecloud/sqlite-sync-supabase:17` are the standard choice. Exact Supabase base-image tags may also be published for some releases, but they are optional and not required for normal setup. ### Add the CloudSync Init Script @@ -59,8 +59,8 @@ Follow [Supabase's Updating](https://supabase.com/docs/guides/self-hosting/docke ```bash # Update docker-compose.yml to use: -# sqlitecloud/sqlite-sync-supabase:15.8.1.085 -# or sqlitecloud/sqlite-sync-supabase:17.6.1.071 +# sqlitecloud/sqlite-sync-supabase:15 +# or sqlitecloud/sqlite-sync-supabase:17 docker compose pull docker compose down && docker compose up -d ``` @@ -83,6 +83,25 @@ If the extension is installed correctly, PostgreSQL returns the CloudSync versio --- +## Upgrading CloudSync + +Updating the `sqlitecloud/sqlite-sync-supabase` image does not by itself upgrade the SQL objects already registered in an existing database. PostgreSQL keeps the extension objects that were installed when `CREATE EXTENSION cloudsync;` was first run for that database. + +When a release includes PostgreSQL extension upgrade scripts, the preferred upgrade path is: + +```sql +ALTER EXTENSION cloudsync UPDATE; +``` + +If no upgrade path is available for the version you are moving from, a drop-and-recreate may be required to refresh the extension SQL objects: + +```sql +DROP EXTENSION IF EXISTS cloudsync CASCADE; +CREATE EXTENSION cloudsync; +``` + +--- + ## Step 3: Register Your Database in the CloudSync Dashboard In the [CloudSync dashboard](https://dashboard.sqlitecloud.io/), create a new workspace with the **Supabase (Self-hosted)** provider, then add a project with your PostgreSQL connection string: @@ -107,6 +126,8 @@ On the **Client Integration** tab you'll find your **Database ID** and authentic The fastest way to test CloudSync without per-user access control — no JWT setup needed. +With API key authentication, CloudSync uses the database role resolved from the API-key-authenticated connection when available; otherwise it falls back to the role from the connection string. + ```sql SELECT cloudsync_network_init(''); SELECT cloudsync_network_set_apikey(':'); @@ -117,9 +138,22 @@ SELECT cloudsync_network_sync(); 1. Set **Row Level Security** to **Yes, enforce RLS** 2. Under **Authentication (JWT)**, click **Configure authentication** and choose: - - **HMAC Secret (HS256):** Enter your `JWT_SECRET` from Supabase's `.env` - - **JWKS Issuer Validation:** Enter the issuer base URL from your token's `iss` claim (e.g. `https://your-auth-domain`). CloudSync automatically fetches the JWKS document from `/.well-known/jwks.json` -3. In your client code: + - **HMAC Secret (HS256):** + - Enter your `JWT_SECRET` from Supabase's `.env` + - Optionally add **Expected audiences**. When configured, a token's `aud` claim must contain at least one of the configured audience values. + - **JWKS Issuer Validation:** + - Enter the issuer base URL from your token's `iss` claim (for example `https://your-auth-domain`) + - By default, CloudSync uses OIDC discovery: it requests `/.well-known/openid-configuration` and reads the returned `jwks_uri` + - Optionally set an **Explicit JWKS URI** to bypass OIDC discovery and use a specific JWKS endpoint directly. This must be a full HTTPS URI. + - Optionally add **Expected audiences**. When configured, a token's `aud` claim must contain at least one of the configured audience values. +3. CloudSync validates JWTs as follows: + - **HS256:** uses the configured JWT secret + - **JWKS:** uses the explicit `jwksUri` when provided; otherwise CloudSync requests `/.well-known/openid-configuration` and reads `jwks_uri` + - CloudSync does not fall back directly to `/.well-known/jwks.json` when discovery is used +4. For claim details and RLS examples, see: + - [JWT Claims Reference](../reference/jwt-claims.md) + - [RLS Reference](../reference/rls.md) +5. In your client code: ```sql SELECT cloudsync_network_init(''); SELECT cloudsync_network_set_token(''); diff --git a/docs/postgresql/reference/jwt-claims.md b/docs/postgresql/reference/jwt-claims.md index d941e79..179e7d6 100644 --- a/docs/postgresql/reference/jwt-claims.md +++ b/docs/postgresql/reference/jwt-claims.md @@ -1,47 +1,103 @@ # JWT Claims Reference -## Standard JWT Claims - -| Claim | Needed? | Purpose | -|-------|---------|---------| -| `sub` | ✅ Yes | User ID | -| `email` | ✅ Yes | User email | -| `role` | ✅ Yes | Permission level | -| `iss` | ✅ Yes | Issuer (validated in app) | -| `aud` | ✅ Yes | Audience (validated in app) | -| `iat` | ⚠️ Maybe | Issued at timestamp | -| `exp` | ✅ Yes | Expiration (validated in app) | +## HS256 Claims + +Use this mode when CloudSync validates JWTs with `jwtSecret`. + +| Claim | Required? | Notes | +| ------- | ---------- | ------------------------------------------------------------------------------------------------- | +| `sub` | ⚠️ Depends | Not required by CloudSync itself, but commonly used by application-specific RLS policies | +| `email` | ❌ No | Optional app-specific claim; not validated by CloudSync | +| `role` | ✅ Yes | Required for PostgreSQL JWT-authenticated requests because CloudSync uses it for `SET LOCAL ROLE` | +| `iss` | ❌ No | Optional in HS256 mode | +| `aud` | ⚠️ Depends | Required only when `jwtExpectedAudiences` is configured | +| `iat` | ❌ No | Optional issued-at timestamp; not validated by CloudSync | +| `exp` | ✅ Yes | Required and validated by CloudSync | + +## JWKS Claims + +Use this mode when CloudSync validates JWTs with `jwtAllowedIssuers` and optional `jwksUri`. + +| Claim | Required? | Notes | +| -------- | ---------- | ------------------------------------------------------------------------------------------------- | +| `sub` | ⚠️ Depends | Not required by CloudSync itself, but commonly used by application-specific RLS policies | +| `email` | ❌ No | Optional app-specific claim; not validated by CloudSync | +| `role` | ✅ Yes | Required for PostgreSQL JWT-authenticated requests because CloudSync uses it for `SET LOCAL ROLE` | +| `iss` | ✅ Yes | Required for JWKS / issuer-based validation | +| `aud` | ⚠️ Depends | Required only when `jwtExpectedAudiences` is configured | +| `iat` | ❌ No | Optional issued-at timestamp; not validated by CloudSync | +| `exp` | ✅ Yes | Required and validated by CloudSync | +| Header `kid` | ✅ Yes | Required in the JWT header so CloudSync can select the verification key from the JWKS | ## Custom Claims Examples -| Claim | Use Case | -|-------|----------| -| `org_id` | Multi-tenant apps | -| `team_id` | Team-based access | -| `permissions` | Fine-grained access | -| `scope` | OAuth scopes | +| Claim | Use Case | +| --------------- | -------------------------- | +| `org_id` | Multi-tenant apps | +| `team_id` | Team-based access | +| `permissions` | Fine-grained access | +| `scope` | OAuth scopes | | `department_id` | Department-based filtering | -| `is_admin` | Admin flag | +| `is_admin` | Admin flag | --- ## How RLS Works with JWT Claims **Flow:** + ``` 1. Client sends JWT token to CloudSync 2. CloudSync validates JWT and extracts claims 3. CloudSync passes claims to PostgreSQL as session variables -4. RLS policies read session variables via current_setting() +4. PostgreSQL policies can read session variables via current_setting() 5. Policies filter data based on claims 6. Only authorized rows returned to client ``` +## PostgreSQL Role Requirement + +For PostgreSQL JWT authentication, the `role` claim must name a real database role that CloudSync can switch into with `SET LOCAL ROLE`. + +That role should: + +- already exist in PostgreSQL +- have the schema, table, and sequence privileges your sync operations need +- have access to the `cloudsync_changes` view used by PostgreSQL sync operations +- be grantable by the connection-string user + +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. + +### Minimum Grants for a JWT Role + +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: + +- the schema that contains your synced tables +- the `cloudsync_changes` view +- the synced user tables +- any sequences used by those tables + +Example: + +```sql +GRANT USAGE ON SCHEMA public TO rls_role; + +GRANT SELECT, INSERT ON cloudsync_changes TO rls_role; + +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE your_table TO rls_role; + +GRANT USAGE, SELECT ON SEQUENCE your_table_id_seq TO rls_role; +``` + +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. + +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. + --- ## How CloudSync Passes JWT Claims to PostgreSQL -**CloudSync validates the JWT and converts all claims to JSON, then passes as a PostgreSQL session variable:** +**For PostgreSQL JWT-authenticated requests, CloudSync validates the JWT and passes all claims to PostgreSQL as a session variable:** ```go // CloudSync (internal implementation) @@ -50,15 +106,15 @@ claimJSON, _ := json.Marshal(userData) // Pass all claims as JSON to PostgreSQL session db.Exec( - `SELECT set_config('role', 'authenticated', true), - set_config('request.jwt.claims', $1, true)`, + `SELECT set_config('request.jwt.claims', $1, true)`, string(claimJSON) ) ``` -**Result:** All JWT claims available in PostgreSQL as JSON in `request.jwt.claims` +**Result:** All JWT claims are available in PostgreSQL as JSON in `request.jwt.claims`, and CloudSync also sets `SET LOCAL ROLE` from the JWT `role` claim. **Example:** If JWT contains: + ```json { "sub": "550e8400-e29b-41d4-a716-446655440000", @@ -69,6 +125,7 @@ db.Exec( ``` Then in PostgreSQL: + ```sql -- Returns: {"sub":"550e8400...","email":"user@example.com","role":"authenticated","org_id":"aaaaaaaa..."} current_setting('request.jwt.claims') diff --git a/docs/postgresql/reference/rls.md b/docs/postgresql/reference/rls.md index cc686ad..78bc054 100644 --- a/docs/postgresql/reference/rls.md +++ b/docs/postgresql/reference/rls.md @@ -59,6 +59,8 @@ CREATE POLICY "delete_own" ON documents FOR DELETE USING (auth.uid() = user_id); ``` +When you authenticate PostgreSQL requests with JWTs, CloudSync also executes `SET LOCAL ROLE` using the JWT `role` claim. That means the role named in the token must already exist in PostgreSQL and must have the permissions needed to read and write the synced tables. + ## Example: Two-User Sync with RLS This example shows the complete flow of syncing data between two databases where the target enforces RLS. From 1a9e1414497233d912080a742ee8014f652cd7f2 Mon Sep 17 00:00:00 2001 From: damlayildiz Date: Mon, 20 Apr 2026 17:54:33 +0200 Subject: [PATCH 2/4] docs: update rls.md --- docs/postgresql/reference/rls.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/postgresql/reference/rls.md b/docs/postgresql/reference/rls.md index 78bc054..d94c63b 100644 --- a/docs/postgresql/reference/rls.md +++ b/docs/postgresql/reference/rls.md @@ -59,7 +59,7 @@ CREATE POLICY "delete_own" ON documents FOR DELETE USING (auth.uid() = user_id); ``` -When you authenticate PostgreSQL requests with JWTs, CloudSync also executes `SET LOCAL ROLE` using the JWT `role` claim. That means the role named in the token must already exist in PostgreSQL and must have the permissions needed to read and write the synced tables. +When you authenticate PostgreSQL requests with JWTs, CloudSync also executes `SET LOCAL ROLE` using the JWT `role` claim. That means the role named in the token must already exist in PostgreSQL and must have the permissions needed to read and write the synced tables. See [PostgreSQL Role Requirement](./jwt-claims.md#postgresql-role-requirement). ## Example: Two-User Sync with RLS From d32d8bf19abd89fbf964a4f29c120d82cf8922ca Mon Sep 17 00:00:00 2001 From: Andrea Donetti Date: Mon, 20 Apr 2026 13:00:14 -0600 Subject: [PATCH 3/4] docs: document full grant surface for non-superuser RLS sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/postgresql/reference/jwt-claims.md | 99 +++++++++++++++++++++---- docs/postgresql/reference/rls.md | 8 ++ 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/docs/postgresql/reference/jwt-claims.md b/docs/postgresql/reference/jwt-claims.md index 179e7d6..09635fd 100644 --- a/docs/postgresql/reference/jwt-claims.md +++ b/docs/postgresql/reference/jwt-claims.md @@ -62,36 +62,109 @@ For PostgreSQL JWT authentication, the `role` claim must name a real database ro That role should: - already exist in PostgreSQL -- have the schema, table, and sequence privileges your sync operations need -- have access to the `cloudsync_changes` view used by PostgreSQL sync operations +- have the schema, table, and sequence privileges your sync operations need (see [Required Grants](#required-grants)) - be grantable by the connection-string user 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. -### Minimum Grants for a JWT Role +### Creating the Role -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: +A typical setup uses a `NOLOGIN` role that your connection user enters via `SET LOCAL ROLE` after JWT verification: -- the schema that contains your synced tables -- the `cloudsync_changes` view -- the synced user tables -- any sequences used by those tables +```sql +CREATE ROLE rls_role NOLOGIN; + +-- Allow the connection-string user (e.g. `postgres`) to switch into it +GRANT rls_role TO postgres; +``` + +### Required Grants + +`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)). + +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). + +#### Recommended: default-privileges pattern + +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: + +```sql +GRANT USAGE ON SCHEMA public TO rls_role; +GRANT USAGE ON SCHEMA auth TO rls_role; + +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER + ON TABLES TO rls_role; + +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO rls_role; + +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT EXECUTE ON FUNCTIONS TO rls_role; + +CREATE EXTENSION IF NOT EXISTS cloudsync; +``` + +**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: + +```sql +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO rls_role; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO rls_role; +-- (plus the ALTER DEFAULT PRIVILEGES block above) +``` + +#### Explicit minimum grant set -Example: +For audited deployments that need an explicit allowlist, the tightest set that allows `cloudsync_payload_apply` to work under a non-superuser: ```sql GRANT USAGE ON SCHEMA public TO rls_role; +GRANT USAGE ON SCHEMA auth TO rls_role; + +-- User table (RLS policies filter rows within these grants) +GRANT SELECT, INSERT, UPDATE, DELETE ON your_table TO rls_role; + +-- Per-table CRDT shadow (created by cloudsync_init) +GRANT SELECT, INSERT, UPDATE, DELETE ON your_table_cloudsync TO rls_role; +-- CloudSync metadata tables +GRANT SELECT, INSERT, UPDATE, DELETE ON + cloudsync_settings, + cloudsync_table_settings, + cloudsync_site_id, + cloudsync_schema_versions, + app_schema_version +TO rls_role; + +-- cloudsync_changes view: SELECT for apply-path readback, INSERT for the +-- INSTEAD OF trigger that feeds column changes into the flush buffer GRANT SELECT, INSERT ON cloudsync_changes TO rls_role; -GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE your_table TO rls_role; +-- BIGSERIAL-backed sequence on cloudsync_site_id.id (nextval needs USAGE) +GRANT USAGE ON SEQUENCE cloudsync_site_id_id_seq TO rls_role; -GRANT USAGE, SELECT ON SEQUENCE your_table_id_seq TO rls_role; +-- Your user table's sequence, if it uses SERIAL / IDENTITY +-- GRANT USAGE, SELECT ON SEQUENCE your_table_id_seq TO rls_role; ``` -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. +Notes on the minimum set: + +- **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`. +- **`app_schema_version` is not `cloudsync_*`-prefixed** — easy to miss in `cloudsync_%`-pattern grants. +- **Per-table shadows follow the `_cloudsync` convention** — repeat the DML grant for every table passed to `cloudsync_init`. +- **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. +- The minimum set will need widening if a future CloudSync version adds new internal objects. The default-privileges pattern above is future-proof. + +### Service Role (RLS Bypass) + +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`: + +```sql +CREATE ROLE service_role NOLOGIN BYPASSRLS; +GRANT service_role TO postgres; +``` -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. +Apply the same grants as for `rls_role`. Use this role only from trusted server code, never from JWT-gated request paths. --- diff --git a/docs/postgresql/reference/rls.md b/docs/postgresql/reference/rls.md index d94c63b..26e4bd5 100644 --- a/docs/postgresql/reference/rls.md +++ b/docs/postgresql/reference/rls.md @@ -178,6 +178,14 @@ When using Supabase: - The `user_id` column in the synced data matches `auth.uid()` - RLS policies reference the correct ownership column +### Apply reports a count, but rows are missing + +**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. + +**Cause**: The calling role is missing a grant on one of CloudSync's internal objects — the per-table shadow (`
_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. + +**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. + ### Debugging ```sql From 4bd7a1a2e335d304fa43f6adb336b07106e7320a Mon Sep 17 00:00:00 2001 From: damlayildiz Date: Tue, 21 Apr 2026 11:08:41 +0200 Subject: [PATCH 4/4] docs: note JWT role grants after extension upgrades --- docs/postgresql/quickstarts/postgres.md | 2 ++ docs/postgresql/quickstarts/supabase-self-hosted.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/postgresql/quickstarts/postgres.md b/docs/postgresql/quickstarts/postgres.md index d34fc01..8a7e67d 100644 --- a/docs/postgresql/quickstarts/postgres.md +++ b/docs/postgresql/quickstarts/postgres.md @@ -99,6 +99,8 @@ DROP EXTENSION IF EXISTS cloudsync CASCADE; CREATE EXTENSION cloudsync; ``` +If you use JWT authentication with PostgreSQL RLS, recheck the database grants for the role named in your JWT `role` claim after upgrading. Recreating the extension also recreates extension-owned objects such as `cloudsync_changes`, so grants on those objects may need to be reapplied. See [PostgreSQL Role Requirement](../reference/jwt-claims.md#postgresql-role-requirement). + --- ## Step 3: Register Your Database in the CloudSync Dashboard diff --git a/docs/postgresql/quickstarts/supabase-self-hosted.md b/docs/postgresql/quickstarts/supabase-self-hosted.md index d1aa9e5..fe4dc71 100644 --- a/docs/postgresql/quickstarts/supabase-self-hosted.md +++ b/docs/postgresql/quickstarts/supabase-self-hosted.md @@ -100,6 +100,8 @@ DROP EXTENSION IF EXISTS cloudsync CASCADE; CREATE EXTENSION cloudsync; ``` +If you use JWT authentication with PostgreSQL RLS, recheck the database grants for the role named in your JWT `role` claim after upgrading. Recreating the extension also recreates extension-owned objects such as `cloudsync_changes`, so grants on those objects may need to be reapplied. See [PostgreSQL Role Requirement](../reference/jwt-claims.md#postgresql-role-requirement). + --- ## Step 3: Register Your Database in the CloudSync Dashboard