@rebasepro/server-postgres 0.14.1 → 0.14.2-canary.g991e9df

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -73,6 +73,33 @@ export declare function classifyConnectFailure(error: unknown): ConnectFailure;
73
73
  * error to the Studio SQL Editor user.
74
74
  */
75
75
  export declare function isRoleSwitchingPermissionError(error: unknown): boolean;
76
+ /**
77
+ * Was this `42501` the *caller* being refused by a policy, rather than the
78
+ * server lacking a privilege?
79
+ *
80
+ * Both arrive as `insufficient_privilege`, and they are opposite kinds of
81
+ * problem. A row-level-security refusal is a working access-control system
82
+ * doing its job: the caller asked for something their policies do not permit,
83
+ * which is a 403 and nobody's bug. A missing `GRANT` is the deployment being
84
+ * wrong — the connection role cannot touch the table at all, no policy is
85
+ * involved, and nothing the caller changes about the request will help.
86
+ *
87
+ * Postgres distinguishes them in the message, so this does too:
88
+ *
89
+ * new row violates row-level security policy for table "notes" → the caller
90
+ * permission denied for table notes → the server
91
+ *
92
+ * Only writes reach this. A read that RLS excludes is not an error — the rows
93
+ * are filtered and the caller gets an empty page — so the erroring case is
94
+ * specifically an `INSERT`/`UPDATE` whose row fails a policy's `WITH CHECK`.
95
+ *
96
+ * Matched on the message because that is the only thing carrying the
97
+ * distinction; the SQLSTATE is identical either way. Narrow by design: anything
98
+ * not naming row-level security stays the server's problem, since reporting a
99
+ * genuine privilege misconfiguration as "forbidden" would send an operator
100
+ * hunting for a policy bug that does not exist.
101
+ */
102
+ export declare function isRowLevelSecurityDenial(error: unknown): boolean;
76
103
  /**
77
104
  * Translate a raw PostgreSQL error into a user-friendly message.
78
105
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgres",
3
3
  "type": "module",
4
- "version": "0.14.1",
4
+ "version": "0.14.2-canary.g991e9df",
5
5
  "description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -47,11 +47,11 @@
47
47
  "execa": "^9.6.1",
48
48
  "pg": "^8.22.0",
49
49
  "ws": "^8.21.1",
50
- "@rebasepro/codegen": "0.14.1",
51
- "@rebasepro/common": "0.14.1",
52
- "@rebasepro/server": "0.14.1",
53
- "@rebasepro/utils": "0.14.1",
54
- "@rebasepro/types": "0.14.1"
50
+ "@rebasepro/codegen": "0.14.2-canary.g991e9df",
51
+ "@rebasepro/common": "0.14.2-canary.g991e9df",
52
+ "@rebasepro/server": "0.14.2-canary.g991e9df",
53
+ "@rebasepro/types": "0.14.2-canary.g991e9df",
54
+ "@rebasepro/utils": "0.14.2-canary.g991e9df"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@hono/node-server": "^2.0.12",
@@ -26,7 +26,7 @@ import {
26
26
  type NestedPathHop
27
27
  } from "./nested-path";
28
28
  import { ApiError, logger } from "@rebasepro/server";
29
- import { extractPgError, extractCauseMessage, pgErrorToFriendlyMessage } from "../utils/pg-error-utils";
29
+ import { extractPgError, extractCauseMessage, pgErrorToFriendlyMessage, isRowLevelSecurityDenial } from "../utils/pg-error-utils";
30
30
  import { explainZeroRowWrite } from "./write-denial";
31
31
 
32
32
  /**
@@ -511,12 +511,33 @@ export class PersistService {
511
511
  // reported to callers as a bad request. Classes 22 (data exception)
512
512
  // and 23 (integrity constraint violation) are the caller's data;
513
513
  // everything else — a dropped connection, a missing column, a
514
- // permission problem — is ours, and stays a 500.
514
+ // *privilege* problem — is ours, and stays a 500.
515
515
  if (/^2[23]/.test(code)) {
516
516
  return code === "23505"
517
517
  ? ApiError.conflict(message, `PG_${code}`)
518
518
  : ApiError.badRequest(message, `PG_${code}`);
519
519
  }
520
+ // With one exception inside class 42: a row-level-security policy
521
+ // refusing the caller is not a fault at all, it is access control
522
+ // working. It fell through to the 500 below, so the client could not
523
+ // tell "you may not do this" from "the server is broken" — and a
524
+ // 500's message is sanitized on the way out, so the reason was lost
525
+ // too. `expected`, because a caller attempting what their policies
526
+ // forbid is routine and should not page anyone; that is the same
527
+ // treatment `unauthenticated()` gets one status code down.
528
+ // `WRITE_DENIED`, the same code `explainZeroRowWrite` returns for an
529
+ // UPDATE or DELETE that RLS refused. Those already answered 403; only
530
+ // INSERT reached here, because a failed `WITH CHECK` raises 42501 while
531
+ // a refused UPDATE simply matches no rows. Two spellings of one denial
532
+ // should not be two status codes.
533
+ //
534
+ // Left at the default log level rather than `expected`: `ApiError`'s
535
+ // own documentation puts "a permission the database refused" in the
536
+ // stays-at-warn column, and the status code is the defect here. The
537
+ // log level is a separate decision that already has an answer.
538
+ if (isRowLevelSecurityDenial(error)) {
539
+ return ApiError.forbidden(message, "WRITE_DENIED");
540
+ }
520
541
  return new Error(message);
521
542
  }
522
543
 
@@ -178,6 +178,38 @@ export function isRoleSwitchingPermissionError(error: unknown): boolean {
178
178
  return msg.includes("set role") || msg.includes("member of role");
179
179
  }
180
180
 
181
+ /**
182
+ * Was this `42501` the *caller* being refused by a policy, rather than the
183
+ * server lacking a privilege?
184
+ *
185
+ * Both arrive as `insufficient_privilege`, and they are opposite kinds of
186
+ * problem. A row-level-security refusal is a working access-control system
187
+ * doing its job: the caller asked for something their policies do not permit,
188
+ * which is a 403 and nobody's bug. A missing `GRANT` is the deployment being
189
+ * wrong — the connection role cannot touch the table at all, no policy is
190
+ * involved, and nothing the caller changes about the request will help.
191
+ *
192
+ * Postgres distinguishes them in the message, so this does too:
193
+ *
194
+ * new row violates row-level security policy for table "notes" → the caller
195
+ * permission denied for table notes → the server
196
+ *
197
+ * Only writes reach this. A read that RLS excludes is not an error — the rows
198
+ * are filtered and the caller gets an empty page — so the erroring case is
199
+ * specifically an `INSERT`/`UPDATE` whose row fails a policy's `WITH CHECK`.
200
+ *
201
+ * Matched on the message because that is the only thing carrying the
202
+ * distinction; the SQLSTATE is identical either way. Narrow by design: anything
203
+ * not naming row-level security stays the server's problem, since reporting a
204
+ * genuine privilege misconfiguration as "forbidden" would send an operator
205
+ * hunting for a policy bug that does not exist.
206
+ */
207
+ export function isRowLevelSecurityDenial(error: unknown): boolean {
208
+ const pgError = extractPgError(error);
209
+ if (!pgError || pgError.code !== "42501") return false;
210
+ return pgError.message.toLowerCase().includes("row-level security policy");
211
+ }
212
+
181
213
  /**
182
214
  * Translate a raw PostgreSQL error into a user-friendly message.
183
215
  *
@@ -249,10 +281,23 @@ export function pgErrorToFriendlyMessage(pgError: PostgresError, context: string
249
281
  code
250
282
  };
251
283
  case "42501": // insufficient_privilege
252
- return {
253
- message: `Permission denied on "${tableRef}". Check your database credentials and RLS policies.${suffix}`,
254
- code
255
- };
284
+ // Two unrelated failures share this SQLSTATE, and the old message
285
+ // named both causes because it could not tell them apart — which
286
+ // meant it was half wrong whichever one had happened, and sent the
287
+ // reader to check the other. Postgres says which in its own message.
288
+ return pgMessage.toLowerCase().includes("row-level security policy")
289
+ ? {
290
+ // The caller. Their policies do not permit this row; the
291
+ // deployment is working exactly as configured.
292
+ message: `Not permitted to write this row in "${tableRef}": it does not satisfy the row-level security policy.${suffix}`,
293
+ code
294
+ }
295
+ : {
296
+ // The deployment. No policy is involved — the connecting
297
+ // role cannot touch the table at all.
298
+ message: `Permission denied on "${tableRef}": the database role this server connects as lacks privileges on it.${suffix}`,
299
+ code
300
+ };
256
301
  case "28000": // invalid_authorization_specification
257
302
  return {
258
303
  message: `Authorization failed for "${context}". Check your database credentials.${suffix}`,