@everystack/server 0.3.0 → 0.3.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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>",
@@ -139,8 +139,8 @@
139
139
  "sst": "4.13.1",
140
140
  "ts-jest": "29.4.9",
141
141
  "typescript": "5.9.3",
142
- "@everystack/auth": "0.2.6",
143
- "@everystack/cli": "0.3.0"
142
+ "@everystack/auth": "0.3.0",
143
+ "@everystack/cli": "0.3.3"
144
144
  },
145
145
  "scripts": {
146
146
  "test": "jest",
package/src/db-doctor.ts CHANGED
@@ -62,6 +62,20 @@ export interface DoctorProbe {
62
62
  * `USING(true)` role like the master). Absent when there is no RLS table to probe.
63
63
  */
64
64
  apiAmbientRead?: { table: string; accessible: boolean };
65
+ /**
66
+ * Every SECURITY DEFINER function (schema, name, whether search_path is pinned). The
67
+ * `functions` teeth read this: for each schema that owns a `writtenBy: 'functions'` table
68
+ * (passed as `functionSchemas`), the package's SECDEF functions must exist and each must
69
+ * pin its search_path. Absent on an older probe (the check is then skipped).
70
+ */
71
+ secdefFunctions?: SecdefFn[];
72
+ }
73
+
74
+ /** A SECURITY DEFINER function and whether its `search_path` is pinned. */
75
+ export interface SecdefFn {
76
+ schema: string;
77
+ name: string;
78
+ hasSearchPath: boolean;
65
79
  }
66
80
 
67
81
  export type CheckStatus = 'pass' | 'fail' | 'warn';
@@ -106,6 +120,29 @@ WHERE c.relkind = 'r'
106
120
  ORDER BY n.nspname, c.relname
107
121
  `.trim();
108
122
 
123
+ /**
124
+ * Every SECURITY DEFINER function with whether its `search_path` is pinned. A SECDEF
125
+ * function runs as its owner (above RLS), so an unpinned `search_path` is a hijack vector:
126
+ * a caller can shadow an unqualified name the body calls and run code as the owner. This is
127
+ * the exact bug class that bit dev (migration 0022 went unapplied via a journal gap, leaving
128
+ * the auth SECDEF functions mutable) — the `functions` teeth read this to catch it on the
129
+ * deployed database, not just in the migration SQL (which `security:audit` already covers).
130
+ */
131
+ export const SECDEF_FUNCTIONS_SQL = `
132
+ SELECT
133
+ n.nspname AS schema,
134
+ p.proname AS name,
135
+ EXISTS (
136
+ SELECT 1 FROM unnest(COALESCE(p.proconfig, '{}'::text[]) ) cfg WHERE cfg LIKE 'search_path=%'
137
+ ) AS has_search_path
138
+ FROM pg_proc p
139
+ JOIN pg_namespace n ON n.oid = p.pronamespace
140
+ WHERE p.prosecdef
141
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
142
+ AND n.nspname NOT LIKE 'pg_%'
143
+ ORDER BY n.nspname, p.proname
144
+ `.trim();
145
+
109
146
  /** Coerce a Postgres boolean (true | 't' | 'true' | 1) to a JS boolean. */
110
147
  export function pgBool(v: unknown): boolean {
111
148
  return v === true || v === 't' || v === 'true' || v === 1 || v === '1';
@@ -170,12 +207,20 @@ ORDER BY n.nspname, c.relname;
170
207
  /** Options for the report builder. */
171
208
  export interface DoctorReportOptions {
172
209
  /**
173
- * App-owned tables that are intentionally ENABLE-not-FORCE: an operational table whose
174
- * owner (e.g. the worker, or a SECURITY DEFINER function) must read/write it, where FORCE
175
- * would block the owner. These are added to the framework default (`auth.users`) and not
176
- * flagged by the FORCE check. Example: `['ops.runs']` (the jobs queue the worker writes).
210
+ * Tables that are intentionally ENABLE-not-FORCE: a table whose owner (the worker, or a
211
+ * SECURITY DEFINER function) must read/write it, where FORCE would block the owner. These
212
+ * are not flagged by the FORCE check. Read from each model's `writtenBy` declaration via
213
+ * `deriveForceRlsCarveouts(models)` including `auth.users` and the six other `auth.*`
214
+ * tables (all `writtenBy: 'functions'`), `ops.runs`, `dist.blobs`, and the notification
215
+ * tables — so the list can't drift from the contract.
177
216
  */
178
217
  forceRlsCarveouts?: string[];
218
+ /**
219
+ * Schemas that own a `writtenBy: 'functions'` table (e.g. `['auth']`), via
220
+ * `deriveFunctionSchemas(models)`. The `functions` teeth assert each such schema has
221
+ * SECDEF functions and that every one pins its `search_path`. Omitted/empty skips the check.
222
+ */
223
+ functionSchemas?: string[];
179
224
  }
180
225
 
181
226
  /** Build the green/red report from a gathered probe. Pure. */
@@ -272,9 +317,11 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
272
317
  // ENABLE-but-not-FORCE: the API/SSR roles are non-owners and are still gated by its
273
318
  // policies regardless of FORCE. `auth.users` (the @everystack/auth credential table) is
274
319
  // the framework's one such table.
275
- // auth.users is the framework's own credential carve-out (always); apps add operational
276
- // tables whose owner must write them (e.g. ops.runs, the worker-written jobs queue).
277
- const FORCE_RLS_CARVEOUTS = new Set(['auth.users', ...(options.forceRlsCarveouts ?? [])]);
320
+ // The carveouts come entirely from the models' `writtenBy` declarations
321
+ // (deriveForceRlsCarveouts): auth.users and the six other auth.* tables (functions),
322
+ // ops.runs and dist.blobs (worker), the notification tables. No hand-list, no framework
323
+ // default that could drift from — or fall short of — the modeled set.
324
+ const FORCE_RLS_CARVEOUTS = new Set(options.forceRlsCarveouts ?? []);
278
325
  const allUnforced = probe.forceRls.filter((r) => r.enabled && !r.forced);
279
326
  const carved = allUnforced.filter((r) => FORCE_RLS_CARVEOUTS.has(`${r.schema}.${r.table}`));
280
327
  const unforced = allUnforced.filter((r) => !FORCE_RLS_CARVEOUTS.has(`${r.schema}.${r.table}`));
@@ -302,6 +349,42 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
302
349
  });
303
350
  }
304
351
 
352
+ // The `functions` teeth — the other half of `writtenBy: 'functions'`. A table written only
353
+ // by SECURITY DEFINER functions is ENABLE-not-FORCE (carved out above) precisely BECAUSE the
354
+ // package's functions run as the owner. That bargain is only safe if those functions exist
355
+ // and each pins its search_path; an unpinned one is an owner-privilege hijack vector. This
356
+ // asserts it on the deployed database — the gate that was missing when 0022 silently went
357
+ // unapplied and left the auth SECDEF functions mutable.
358
+ if (probe.secdefFunctions && (options.functionSchemas?.length ?? 0) > 0) {
359
+ const schemas = new Set(options.functionSchemas);
360
+ const relevant = probe.secdefFunctions.filter((f) => schemas.has(f.schema));
361
+ const unpinned = relevant.filter((f) => !f.hasSearchPath);
362
+ const present = new Set(relevant.map((f) => f.schema));
363
+ const missing = [...schemas].filter((s) => !present.has(s)).sort();
364
+ if (unpinned.length > 0) {
365
+ checks.push({
366
+ name: 'SECURITY DEFINER functions pin search_path',
367
+ status: 'fail',
368
+ detail: `SECDEF function(s) in a functions-written schema have a mutable search_path (owner-privilege hijack vector): ${unpinned.map((f) => `${f.schema}.${f.name}`).join(', ')}`,
369
+ remediation: unpinned.map((f) => `ALTER FUNCTION ${f.schema}.${f.name} SET search_path = ${f.schema}, public, pg_temp;`).join(' ')
370
+ + ' (Then redeploy so the migration that defines them carries the pin.)',
371
+ });
372
+ } else if (missing.length > 0) {
373
+ checks.push({
374
+ name: 'SECURITY DEFINER functions exist for functions-written tables',
375
+ status: 'fail',
376
+ detail: `no SECDEF functions found in schema(s) that own a writtenBy:'functions' table: ${missing.join(', ')} — the package SQL that writes those tables was not applied`,
377
+ remediation: `Apply the package's functions migration (e.g. everystack db:migrate). A functions-written table with no SECDEF functions cannot be written at all.`,
378
+ });
379
+ } else {
380
+ checks.push({
381
+ name: 'SECURITY DEFINER functions pin search_path',
382
+ status: 'pass',
383
+ detail: `all ${relevant.length} SECDEF function(s) in ${[...present].sort().join(', ')} pin their search_path`,
384
+ });
385
+ }
386
+ }
387
+
305
388
  return {
306
389
  api,
307
390
  admin,
@@ -310,6 +393,15 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
310
393
  };
311
394
  }
312
395
 
396
+ /** Map a SECDEF_FUNCTIONS_SQL row to SecdefFn. */
397
+ export function rowToSecdefFn(row: any): SecdefFn {
398
+ return {
399
+ schema: String(row.schema),
400
+ name: String(row.name),
401
+ hasSearchPath: pgBool(row.has_search_path),
402
+ };
403
+ }
404
+
313
405
  /** Map a CONN_SQL row to ConnInfo (tolerant of pg driver boolean encodings). */
314
406
  export function rowToConnInfo(row: any): ConnInfo {
315
407
  return {
package/src/plugin.ts CHANGED
@@ -463,11 +463,18 @@ export interface DbPluginOptions {
463
463
  /** pgSettings callback for console auth context — applied via SET LOCAL when user is present */
464
464
  pgSettings?: (user: Record<string, unknown> | null) => Record<string, string>;
465
465
  /**
466
- * App-owned tables that are intentionally ENABLE-not-FORCE row-level security, so db:doctor
467
- * does not flag them. The framework always carves out `auth.users`; add operational tables
468
- * whose owner must write them, e.g. `['ops.runs']` (the worker-written jobs queue).
466
+ * Tables that are intentionally ENABLE-not-FORCE row-level security, so db:doctor does not
467
+ * flag them. Pass `deriveForceRlsCarveouts(models)` it reads each model's `writtenBy`
468
+ * declaration (auth.users + the six other auth.* tables, ops.runs, dist.blobs, the
469
+ * notification tables), so the list can't drift from the contract.
469
470
  */
470
471
  forceRlsCarveouts?: string[];
472
+ /**
473
+ * Schemas that own a `writtenBy: 'functions'` table (e.g. `['auth']`). Pass
474
+ * `deriveFunctionSchemas(models)`. db:doctor's `functions` teeth assert each such schema has
475
+ * SECDEF functions and that every one pins its search_path (the unpinned-search_path gate).
476
+ */
477
+ functionSchemas?: string[];
471
478
  /** Console auth helper config — column names for login/asUser (defaults to everystack conventions) */
472
479
  consoleAuth?: {
473
480
  usersTable?: string;
@@ -628,7 +635,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
628
635
  actions['db:doctor'] = async (_payload, ctx) => {
629
636
  const { sql } = await import('drizzle-orm');
630
637
  const {
631
- buildDoctorReport, rowToConnInfo, rowToForceRls, rowToAppGranted, CONN_SQL, FORCE_RLS_SQL, appGrantsRlsSql,
638
+ buildDoctorReport, rowToConnInfo, rowToForceRls, rowToAppGranted, rowToSecdefFn,
639
+ CONN_SQL, FORCE_RLS_SQL, appGrantsRlsSql, SECDEF_FUNCTIONS_SQL,
632
640
  } = await import('./db-doctor');
633
641
  const rowsOf = (result: any): any[] => (Array.isArray(result) ? result : result?.rows || []);
634
642
 
@@ -649,6 +657,9 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
649
657
  // The "we require RLS" set — every app-reachable table + its RLS posture, so a table
650
658
  // granted to a request role with RLS off (the PostgREST fail-open shape) is caught.
651
659
  const appGrantedRls = rowsOf(await opsDb.execute(sql.raw(appGrantsRlsSql()))).map(rowToAppGranted);
660
+ // SECDEF functions (schema, name, search_path pinned?) — the `functions` teeth read these
661
+ // to assert every SECDEF function in a functions-written schema pins its search_path.
662
+ const secdefFunctions = rowsOf(await opsDb.execute(sql.raw(SECDEF_FUNCTIONS_SQL))).map(rowToSecdefFn);
652
663
 
653
664
  // Ambient-access probe: can the api connection read an RLS table WITHOUT SET ROLE?
654
665
  let apiAmbientRead: { table: string; accessible: boolean } | undefined;
@@ -664,8 +675,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
664
675
  }
665
676
 
666
677
  return buildDoctorReport(
667
- { api, admin, forceRls, appGrantedRls, apiAmbientRead },
668
- { forceRlsCarveouts: options.forceRlsCarveouts },
678
+ { api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions },
679
+ { forceRlsCarveouts: options.forceRlsCarveouts, functionSchemas: options.functionSchemas },
669
680
  );
670
681
  };
671
682