@everystack/server 0.4.16 → 0.4.17

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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. package/src/db-doctor.ts +66 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.4.16",
3
+ "version": "0.4.17",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -163,8 +163,8 @@
163
163
  "sst": "4.13.1",
164
164
  "ts-jest": "29.4.9",
165
165
  "typescript": "5.9.3",
166
- "@everystack/auth": "0.4.0",
167
- "@everystack/cli": "0.4.46"
166
+ "@everystack/cli": "0.4.47",
167
+ "@everystack/auth": "0.4.1"
168
168
  },
169
169
  "scripts": {
170
170
  "test": "jest",
package/src/db-doctor.ts CHANGED
@@ -94,6 +94,14 @@ export interface PublicExecutableFn {
94
94
  schema: string;
95
95
  name: string;
96
96
  secdef: boolean;
97
+ /**
98
+ * True when some RLS policy expression names this function. Such a function is evaluated as
99
+ * the QUERYING role, so a bare `REVOKE ... FROM PUBLIC` with no grant-back breaks every policy
100
+ * that calls it — `auth.uid()` being the canonical case. Measured from pg_policy rather than
101
+ * guessed from a name list, so it stays right for an app whose policies call its own helpers.
102
+ * Absent on an older probe (treated as unknown, never as false).
103
+ */
104
+ policyReferenced?: boolean;
97
105
  }
98
106
 
99
107
  /** A view ('v') or matview ('m') with no grants to any non-owner role. */
@@ -187,7 +195,14 @@ export const PUBLIC_EXECUTABLE_FUNCTIONS_SQL = `
187
195
  SELECT
188
196
  n.nspname AS schema,
189
197
  p.proname AS name,
190
- p.prosecdef AS secdef
198
+ p.prosecdef AS secdef,
199
+ EXISTS (
200
+ SELECT 1
201
+ FROM pg_policy pol
202
+ WHERE COALESCE(pg_get_expr(pol.polqual, pol.polrelid), '')
203
+ || ' ' || COALESCE(pg_get_expr(pol.polwithcheck, pol.polrelid), '')
204
+ ~ ('\\m' || n.nspname || '\\.' || p.proname || '\\M')
205
+ ) AS policy_referenced
191
206
  FROM pg_proc p
192
207
  JOIN pg_namespace n ON n.oid = p.pronamespace
193
208
  CROSS JOIN LATERAL aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) a
@@ -411,6 +426,17 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
411
426
  // (deriveForceRlsCarveouts): auth.users and the six other auth.* tables (functions),
412
427
  // ops.runs and dist.blobs (worker), the notification tables. No hand-list, no framework
413
428
  // default that could drift from — or fall short of — the modeled set.
429
+ //
430
+ // ABSENT IS NOT EMPTY. `forceRlsCarveouts` comes from the app's models via its dbPlugin. A
431
+ // deploy that never passes it is not a deploy with no owner-written tables — it is a deploy
432
+ // whose declarations we cannot see. Collapsing the two with `?? []` made db:doctor red-flag
433
+ // seven owner-written auth tables and print `FORCE ROW LEVEL SECURITY` for each, unqualified.
434
+ // Running that paste breaks the auth paths: FORCE applies RLS to the OWNER, which is exactly
435
+ // the SECURITY DEFINER function that has to write those tables. The check has to say what it
436
+ // could not see rather than assert a fix it cannot justify. (Consumer report 2026-08-04: they
437
+ // read the remediation, recognised the trap themselves, and refused to run it. The next
438
+ // operator will not.)
439
+ const carveoutsDeclared = options.forceRlsCarveouts !== undefined;
414
440
  const FORCE_RLS_CARVEOUTS = new Set(options.forceRlsCarveouts ?? []);
415
441
  const allUnforced = probe.forceRls.filter((r) => r.enabled && !r.forced);
416
442
  const carved = allUnforced.filter((r) => FORCE_RLS_CARVEOUTS.has(`${r.schema}.${r.table}`));
@@ -425,11 +451,23 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
425
451
  detail: 'no tables have RLS enabled yet (nothing to force)',
426
452
  });
427
453
  } else if (unforced.length > 0) {
454
+ const names = unforced.map((r) => `${r.schema}.${r.table}`).join(', ');
455
+ const fix = unforced.map((r) => `ALTER TABLE ${r.schema}.${r.table} FORCE ROW LEVEL SECURITY;`).join(' ');
456
+ // The STATUS stays 'fail' whether or not the carveouts were declared. An unforced RLS table
457
+ // is a real finding, and an app with genuinely nothing to carve out passes no carveouts —
458
+ // indistinguishable from one that never wired them, so downgrading on absence would weaken
459
+ // the check for everyone to spare the misconfigured. What changes is the REMEDIATION, which
460
+ // is where the actual harm was: a flat list of FORCE statements that breaks owner-mediated
461
+ // writes if any of these tables is one.
428
462
  checks.push({
429
463
  name: 'FORCE ROW LEVEL SECURITY on RLS tables',
430
464
  status: 'fail',
431
- detail: `RLS enabled but not FORCEd (owner bypasses policies): ${unforced.map((r) => `${r.schema}.${r.table}`).join(', ')}`,
432
- remediation: unforced.map((r) => `ALTER TABLE ${r.schema}.${r.table} FORCE ROW LEVEL SECURITY;`).join(' '),
465
+ detail: carveoutsDeclared
466
+ ? `RLS enabled but not FORCEd (owner bypasses policies): ${names}`
467
+ : `RLS enabled but not FORCEd: ${names}. NOTE: this deploy's dbPlugin passes no forceRlsCarveouts, so db:doctor cannot tell an unprotected table from one deliberately ENABLE-not-FORCE because SECURITY DEFINER functions or a worker own its writes.`,
468
+ remediation: carveoutsDeclared
469
+ ? fix
470
+ : `Do NOT paste this blind — FORCE applies RLS to the table OWNER, so forcing a table whose writes are owner-mediated breaks those writes, both before and after the mediating functions exist. First pass forceRlsCarveouts (and functionSchemas) from your models into dbPlugin and re-run: the answer may be that nothing is wrong. Then, for the tables that remain and are NOT owner-written: ${fix}`,
433
471
  });
434
472
  } else {
435
473
  checks.push({
@@ -486,12 +524,34 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
486
524
  const offenders = probe.publicExecutableFunctions;
487
525
  if (offenders.length > 0) {
488
526
  const label = (f: PublicExecutableFn): string => `${f.schema}.${f.name}${f.secdef ? ' (SECURITY DEFINER)' : ''}`;
527
+ // SPLIT BY WHO CALLS IT, because one remediation is not safe for both groups.
528
+ //
529
+ // A function named in an RLS policy expression is evaluated as the QUERYING role. Revoking
530
+ // PUBLIC without granting the request roles back breaks every policy that calls it — the
531
+ // canonical case being `auth.uid()`. This used to print one flat REVOKE list covering
532
+ // SECDEF internals and policy helpers together, and a consumer who pasted it would have
533
+ // taken their own policies down. They spotted the trap and refused to run it; the next
534
+ // operator will not. `policyReferenced` is MEASURED from pg_policy, not guessed from a
535
+ // name list, so it stays correct for an app whose policies call its own helpers.
536
+ const policyFns = offenders.filter((f) => f.policyReferenced === true);
537
+ const plainFns = offenders.filter((f) => f.policyReferenced !== true);
538
+ const revoke = (f: PublicExecutableFn): string => `REVOKE ALL ON FUNCTION ${f.schema}.${f.name} FROM PUBLIC;`;
539
+ const parts: string[] = [];
540
+ if (plainFns.length > 0) {
541
+ parts.push(
542
+ `${plainFns.map(revoke).join(' ')} Then grant the intended callers — or declare the function with defineFunction + can('execute', { role }) so the compiler emits both. (A SECURITY DEFINER internal called only from other SECDEF functions needs NO grant back: those run as the owner.)`,
543
+ );
544
+ }
545
+ if (policyFns.length > 0) {
546
+ parts.push(
547
+ `CAUTION — ${policyFns.map((f) => `${f.schema}.${f.name}`).join(', ')} ${policyFns.length === 1 ? 'is' : 'are'} referenced by an RLS POLICY and evaluated as the querying role. Revoking alone will break every policy that calls ${policyFns.length === 1 ? 'it' : 'them'}. Revoke AND grant the request roles back in the same change: ${policyFns.map((f) => `${revoke(f)} GRANT EXECUTE ON FUNCTION ${f.schema}.${f.name} TO anon, authenticated, admin;`).join(' ')}`,
548
+ );
549
+ }
489
550
  checks.push({
490
551
  name: 'Functions are not PUBLIC-executable',
491
552
  status: offenders.some((f) => f.secdef) ? 'fail' : 'warn',
492
553
  detail: `PUBLIC can EXECUTE (PostgreSQL's silent-open default — a NULL proacl grants it): ${offenders.map(label).join(', ')}`,
493
- remediation: offenders.map((f) => `REVOKE ALL ON FUNCTION ${f.schema}.${f.name} FROM PUBLIC;`).join(' ')
494
- + ` Then grant the intended callers — or declare the function with defineFunction + can('execute', { role }) so the compiler emits both.`,
554
+ remediation: parts.join(' '),
495
555
  });
496
556
  } else {
497
557
  checks.push({
@@ -638,6 +698,7 @@ export function rowToPublicExecutableFn(row: any): PublicExecutableFn {
638
698
  schema: String(row.schema),
639
699
  name: String(row.name),
640
700
  secdef: pgBool(row.secdef),
701
+ ...(row.policy_referenced === undefined ? {} : { policyReferenced: pgBool(row.policy_referenced) }),
641
702
  };
642
703
  }
643
704