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..8a7e67d 100644 --- a/docs/postgresql/quickstarts/postgres.md +++ b/docs/postgresql/quickstarts/postgres.md @@ -82,6 +82,27 @@ 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; +``` + +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 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 +127,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 +139,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..fe4dc71 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,27 @@ 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; +``` + +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 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 +128,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 +140,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..09635fd 100644 --- a/docs/postgresql/reference/jwt-claims.md +++ b/docs/postgresql/reference/jwt-claims.md @@ -1,47 +1,176 @@ # 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 (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. + +### Creating the Role + +A typical setup uses a `NOLOGIN` role that your connection user enters via `SET LOCAL ROLE` after JWT verification: + +```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 + +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; + +-- BIGSERIAL-backed sequence on cloudsync_site_id.id (nextval needs USAGE) +GRANT USAGE ON SEQUENCE cloudsync_site_id_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; +``` + +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; +``` + +Apply the same grants as for `rls_role`. Use this role only from trusted server code, never from JWT-gated request paths. + --- ## 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 +179,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 +198,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..26e4bd5 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. See [PostgreSQL Role Requirement](./jwt-claims.md#postgresql-role-requirement). + ## Example: Two-User Sync with RLS This example shows the complete flow of syncing data between two databases where the target enforces RLS. @@ -176,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