@everystack/cli 0.3.31 → 0.4.0

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.
@@ -41,6 +41,28 @@ export interface LiveObject {
41
41
  bytes?: number;
42
42
  /** Matview only: reltuples estimate. */
43
43
  rows?: number;
44
+ /** Trigger only: the (bare or schema-qualified) relation it rides — `DROP TRIGGER … ON` needs it. */
45
+ table?: string;
46
+ /** Live ACLs (grantee → sorted privileges), owner excluded, function defaults expanded.
47
+ * Absent on triggers (they take no grants) and when the introspection predates B4. */
48
+ grants?: Record<string, string[]>;
49
+ /** View only: the security_invoker reloption — db:pull renders the required property. */
50
+ securityInvoker?: boolean;
51
+ /** Function only (B5 pull rendering): the structured signature and body. */
52
+ fn?: {
53
+ /** pg_get_function_arguments — `'query text, max integer DEFAULT 20'`. */
54
+ args: string;
55
+ /** pg_get_function_result — `'integer'`, `'SETOF posts'`, `'trigger'`. */
56
+ returns: string;
57
+ language: string;
58
+ secdef: boolean;
59
+ /** provolatile: 'i' | 's' | 'v'. */
60
+ volatility: string;
61
+ /** The pinned search_path value, when set ('pg_catalog, public'). */
62
+ searchPath?: string;
63
+ /** prosrc — the raw body. */
64
+ src: string;
65
+ };
44
66
  }
45
67
 
46
68
  /** dependent identity → referenced identity (both derived objects). */
@@ -54,6 +76,10 @@ export interface ProvenanceRow {
54
76
  identity: string;
55
77
  srcHash: string;
56
78
  defHash: string;
79
+ /** The object kind at record time — how a `'sql'` object (invisible to the catalog) is known. */
80
+ kind?: string;
81
+ /** How to remove what was recorded — the C1 closure: removal never orphans. */
82
+ dropSql?: string;
57
83
  }
58
84
 
59
85
  export interface DerivedCatalog {
@@ -66,7 +92,8 @@ export interface DerivedCatalog {
66
92
  // Introspection SQL.
67
93
  // ---------------------------------------------------------------------------
68
94
 
69
- /** Views and matviews with canonical definitions, size, and comment. */
95
+ /** Views and matviews with canonical definitions, size, comment, and (views) the
96
+ * security_invoker reloption — db:pull renders it as the REQUIRED securityInvoker. */
70
97
  export const DERIVED_RELATIONS_SQL = `
71
98
  SELECT
72
99
  n.nspname AS schema,
@@ -76,7 +103,11 @@ SELECT
76
103
  obj_description(c.oid, 'pg_class') AS comment,
77
104
  c.relispopulated AS populated,
78
105
  pg_total_relation_size(c.oid) AS bytes,
79
- c.reltuples::bigint AS rows
106
+ c.reltuples::bigint AS rows,
107
+ EXISTS (
108
+ SELECT 1 FROM unnest(COALESCE(c.reloptions, '{}'::text[])) o
109
+ WHERE o IN ('security_invoker=true', 'security_invoker=on')
110
+ ) AS security_invoker
80
111
  FROM pg_class c
81
112
  JOIN pg_namespace n ON n.oid = c.relnamespace
82
113
  WHERE c.relkind IN ('v', 'm')
@@ -88,15 +119,28 @@ WHERE c.relkind IN ('v', 'm')
88
119
  ORDER BY n.nspname, c.relname;
89
120
  `.trim();
90
121
 
91
- /** Plain functions and procedures with canonical definitions and comment. */
122
+ /** Plain functions and procedures: the canonical definition (reconcile's def hash) plus
123
+ * the STRUCTURED fields db:pull renders into defineFunction — args/returns from the
124
+ * deparse helpers, language, SECDEF, volatility, the pinned search_path, and the raw
125
+ * body (prosrc). Anything the v1 signature vocabulary can't say falls back to defineSql
126
+ * with the whole pg_get_functiondef. */
92
127
  export const DERIVED_FUNCTIONS_SQL = `
93
128
  SELECT
94
129
  n.nspname AS schema,
95
130
  p.proname AS name,
96
131
  pg_get_functiondef(p.oid) AS definition,
97
- obj_description(p.oid, 'pg_proc') AS comment
132
+ obj_description(p.oid, 'pg_proc') AS comment,
133
+ pg_get_function_arguments(p.oid) AS args,
134
+ pg_get_function_result(p.oid) AS returns,
135
+ l.lanname AS language,
136
+ p.prosecdef AS secdef,
137
+ p.provolatile AS volatility,
138
+ (SELECT split_part(cfg, '=', 2) FROM unnest(COALESCE(p.proconfig, '{}'::text[])) cfg
139
+ WHERE cfg LIKE 'search_path=%' LIMIT 1) AS search_path,
140
+ p.prosrc AS src
98
141
  FROM pg_proc p
99
142
  JOIN pg_namespace n ON n.oid = p.pronamespace
143
+ JOIN pg_language l ON l.oid = p.prolang
100
144
  WHERE p.prokind IN ('f', 'p')
101
145
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
102
146
  AND n.nspname NOT LIKE 'pg_%'
@@ -122,10 +166,15 @@ ORDER BY n.nspname, t.relname, idx.indexrelid;
122
166
  `.trim();
123
167
 
124
168
  /**
125
- * Dependency edges between derived objects, through each view/matview's
126
- * rewrite rule: view → view/matview it selects from, and view → function it
127
- * calls. (Function function edges do not exist in pg_depend function
128
- * bodies are opaque to it; source order is the fallback there.)
169
+ * Dependency edges from each view/matview's rewrite rule: view → view/matview/TABLE it
170
+ * selects from, and view → function it calls. (Function → function edges do not exist in
171
+ * pg_depend function bodies are opaque to it; source order is the fallback there.)
172
+ *
173
+ * Table edges (relkind 'r') feed B4's declared-vs-actual dependency drift — the
174
+ * verification half of "dependsOn: declared, then verified". The cascade logic keys its
175
+ * lookups by MANAGED derived identities, so table-referenced edges never join it.
176
+ * System-schema referenced sides are excluded on both branches: a view reading
177
+ * pg_catalog is not an edge anyone declares.
129
178
  */
130
179
  export const DERIVED_DEPENDS_SQL = `
131
180
  SELECT DISTINCT
@@ -143,7 +192,12 @@ WHERE d.classid = 'pg_rewrite'::regclass
143
192
  AND d.refclassid = 'pg_class'::regclass
144
193
  AND dc.oid <> rc.oid
145
194
  AND dc.relkind IN ('v', 'm')
146
- AND rc.relkind IN ('v', 'm')
195
+ AND rc.relkind IN ('v', 'm', 'r')
196
+ AND rn.nspname NOT IN ('pg_catalog', 'information_schema')
197
+ AND rn.nspname NOT LIKE 'pg_%'
198
+ AND NOT EXISTS (
199
+ SELECT 1 FROM pg_depend dep WHERE dep.objid = rc.oid AND dep.deptype = 'e'
200
+ )
147
201
  UNION
148
202
  SELECT DISTINCT
149
203
  dn.nspname, dc.relname, rn.nspname, rp.proname
@@ -155,12 +209,90 @@ JOIN pg_proc rp ON rp.oid = d.refobjid
155
209
  JOIN pg_namespace rn ON rn.oid = rp.pronamespace
156
210
  WHERE d.classid = 'pg_rewrite'::regclass
157
211
  AND d.refclassid = 'pg_proc'::regclass
158
- AND dc.relkind IN ('v', 'm');
212
+ AND dc.relkind IN ('v', 'm')
213
+ AND rn.nspname NOT IN ('pg_catalog', 'information_schema')
214
+ AND rn.nspname NOT LIKE 'pg_%'
215
+ AND NOT EXISTS (
216
+ SELECT 1 FROM pg_depend dep WHERE dep.objid = rp.oid AND dep.deptype = 'e'
217
+ );
218
+ `.trim();
219
+
220
+ /**
221
+ * Live ACLs for the derived kinds — the grant half of B4's drift (a hand-run
222
+ * GRANT/REVOKE touches no content hash and was invisible without this read).
223
+ *
224
+ * Relations (`relacl`): a NULL acl means owner-only — aclexplode(NULL) yields nothing,
225
+ * which reads correctly as "no grants". Functions (`proacl`) are the opposite trap:
226
+ * NULL means the BUILT-IN DEFAULT, which includes EXECUTE to PUBLIC — so the function
227
+ * branch expands `acldefault('f', proowner)` and a never-revoked function honestly
228
+ * shows its PUBLIC EXECUTE. Owner rows are excluded (owner privileges are not grants);
229
+ * `LEFT JOIN pg_roles` + COALESCE('PUBLIC') keeps a dropped-role ACL from failing the
230
+ * read (the table path's offline-safety). `kind` disambiguates a relation and a
231
+ * function sharing a name.
232
+ */
233
+ export const DERIVED_GRANTS_SQL = `
234
+ SELECT
235
+ n.nspname AS schema,
236
+ c.relname AS name,
237
+ 'r' AS kind,
238
+ COALESCE(r.rolname, 'PUBLIC') AS grantee,
239
+ array_agg(DISTINCT a.privilege_type ORDER BY a.privilege_type) AS privileges
240
+ FROM pg_class c
241
+ JOIN pg_namespace n ON n.oid = c.relnamespace
242
+ CROSS JOIN LATERAL aclexplode(c.relacl) a
243
+ LEFT JOIN pg_roles r ON r.oid = a.grantee
244
+ WHERE c.relkind IN ('v', 'm')
245
+ AND a.grantee <> c.relowner
246
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
247
+ AND n.nspname NOT LIKE 'pg_%'
248
+ AND NOT EXISTS (
249
+ SELECT 1 FROM pg_depend dep WHERE dep.objid = c.oid AND dep.deptype = 'e'
250
+ )
251
+ GROUP BY n.nspname, c.relname, r.rolname
252
+ UNION ALL
253
+ SELECT
254
+ n.nspname,
255
+ p.proname,
256
+ 'f',
257
+ COALESCE(r.rolname, 'PUBLIC'),
258
+ array_agg(DISTINCT a.privilege_type ORDER BY a.privilege_type)
259
+ FROM pg_proc p
260
+ JOIN pg_namespace n ON n.oid = p.pronamespace
261
+ CROSS JOIN LATERAL aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) a
262
+ LEFT JOIN pg_roles r ON r.oid = a.grantee
263
+ WHERE p.prokind IN ('f', 'p')
264
+ AND a.grantee <> p.proowner
265
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
266
+ AND n.nspname NOT LIKE 'pg_%'
267
+ AND NOT EXISTS (
268
+ SELECT 1 FROM pg_depend dep WHERE dep.objid = p.oid AND dep.deptype = 'e'
269
+ )
270
+ GROUP BY n.nspname, p.proname, r.rolname
271
+ ORDER BY 1, 2, 4;
159
272
  `.trim();
160
273
 
161
- /** The reconciler's provenance claims. The table may not exist yet — callers tolerate that. */
274
+ /** The reconciler's provenance claims. The table may not exist yet — callers tolerate
275
+ * that. `to_jsonb(p)` carries the newer columns (kind, drop_sql) version-tolerantly:
276
+ * a legacy database without them still SELECTs clean, the keys are simply absent. */
162
277
  export const PROVENANCE_SQL = `
163
- SELECT identity, src_hash, def_hash FROM everystack.derived_provenance;
278
+ SELECT identity, src_hash, def_hash, to_jsonb(p) AS extra FROM everystack.derived_provenance p;
279
+ `.trim();
280
+
281
+ /** User triggers (never internal/constraint machinery) with their canonical definition —
282
+ * the live side of the trigger reconcile. Compound identity: schema.table.name. */
283
+ export const DERIVED_TRIGGERS_SQL = `
284
+ SELECT
285
+ n.nspname AS schema,
286
+ c.relname AS "table",
287
+ t.tgname AS name,
288
+ pg_get_triggerdef(t.oid) AS definition
289
+ FROM pg_trigger t
290
+ JOIN pg_class c ON c.oid = t.tgrelid
291
+ JOIN pg_namespace n ON n.oid = c.relnamespace
292
+ WHERE NOT t.tgisinternal
293
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
294
+ AND n.nspname NOT LIKE 'pg_%'
295
+ ORDER BY n.nspname, c.relname, t.tgname;
164
296
  `.trim();
165
297
 
166
298
  // ---------------------------------------------------------------------------
@@ -176,6 +308,8 @@ export interface RelationRow {
176
308
  populated: unknown;
177
309
  bytes: unknown;
178
310
  rows: unknown;
311
+ /** security_invoker reloption (views). Optional — pre-B5 fixtures omit it. */
312
+ security_invoker?: unknown;
179
313
  }
180
314
 
181
315
  export interface FunctionRow {
@@ -183,6 +317,14 @@ export interface FunctionRow {
183
317
  name: string;
184
318
  definition: unknown;
185
319
  comment: unknown;
320
+ /** Structured signature fields (B5 pull rendering). Optional — pre-B5 fixtures omit them. */
321
+ args?: unknown;
322
+ returns?: unknown;
323
+ language?: unknown;
324
+ secdef?: unknown;
325
+ volatility?: unknown;
326
+ search_path?: unknown;
327
+ src?: unknown;
186
328
  }
187
329
 
188
330
  export interface IndexDefRow {
@@ -202,6 +344,24 @@ export interface ProvenanceRawRow {
202
344
  identity: string;
203
345
  src_hash: unknown;
204
346
  def_hash: unknown;
347
+ /** `to_jsonb(p)` — the version-tolerant payload; carries kind/drop_sql when the columns exist. */
348
+ extra?: Record<string, unknown> | null;
349
+ }
350
+
351
+ export interface TriggerRow {
352
+ schema: string;
353
+ table: string;
354
+ name: string;
355
+ definition: unknown;
356
+ }
357
+
358
+ export interface GrantAclRow {
359
+ schema: string;
360
+ name: string;
361
+ /** 'r' = relation (view/matview), 'f' = function — disambiguates shared names. */
362
+ kind: unknown;
363
+ grantee: string;
364
+ privileges: unknown;
205
365
  }
206
366
 
207
367
  /** sha256 over an object's normalized definition + sorted indexes + comment. */
@@ -217,6 +377,19 @@ export interface DerivedRows {
217
377
  indexes: IndexDefRow[];
218
378
  depends: DependsRow[];
219
379
  provenance: ProvenanceRawRow[];
380
+ /** pg_trigger rows (user triggers only). Optional — older callers predate triggers. */
381
+ triggers?: TriggerRow[];
382
+ /** Live ACL rows (DERIVED_GRANTS_SQL). Optional — older callers predate B4's drift. */
383
+ grants?: GrantAclRow[];
384
+ }
385
+
386
+ /** Coerce a Postgres text[] (a JS array, or the `{a,b}` text form some drivers return) to string[]. */
387
+ function pgTextArray(v: unknown): string[] {
388
+ if (Array.isArray(v)) return v.map(String);
389
+ if (typeof v === 'string') {
390
+ return v.replace(/^\{|\}$/g, '').split(',').map((s) => s.replace(/^"|"$/g, '').trim()).filter(Boolean);
391
+ }
392
+ return [];
220
393
  }
221
394
 
222
395
  /**
@@ -225,6 +398,21 @@ export interface DerivedRows {
225
398
  * database is byte-stable.
226
399
  */
227
400
  export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
401
+ // Live ACLs keyed by kind-class + identity ('r' relations, 'f' functions) so a view and
402
+ // a function sharing a name never swap grants.
403
+ const grantsByKey = new Map<string, Record<string, string[]>>();
404
+ for (const row of rows.grants ?? []) {
405
+ if (IGNORED_SCHEMAS.has(row.schema)) continue;
406
+ const key = `${String(row.kind)}:${row.schema}.${row.name}`;
407
+ const acc = grantsByKey.get(key) ?? {};
408
+ acc[row.grantee] = [...new Set([...(acc[row.grantee] ?? []), ...pgTextArray(row.privileges)])].sort();
409
+ grantsByKey.set(key, acc);
410
+ }
411
+ const grantsFor = (kindClass: 'r' | 'f', identity: string): { grants?: Record<string, string[]> } => {
412
+ if (rows.grants === undefined) return {}; // pre-B4 caller — absent, not empty
413
+ return { grants: grantsByKey.get(`${kindClass}:${identity}`) ?? {} };
414
+ };
415
+
228
416
  const indexesByIdentity = new Map<string, string[]>();
229
417
  for (const row of rows.indexes) {
230
418
  if (IGNORED_SCHEMAS.has(row.schema)) continue;
@@ -248,12 +436,16 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
248
436
  definition, indexes,
249
437
  ...(comment !== undefined ? { comment } : {}),
250
438
  defHash: computeDefHash(definition, indexes, comment),
439
+ ...grantsFor('r', identity),
251
440
  };
252
441
  if (kind === 'materialized view') {
253
442
  obj.populated = row.populated == null ? undefined : String(row.populated) !== 'false';
254
443
  obj.bytes = row.bytes == null ? undefined : Number(row.bytes);
255
444
  obj.rows = row.rows == null ? undefined : Math.max(0, Number(row.rows));
256
445
  }
446
+ if (kind === 'view' && row.security_invoker !== undefined) {
447
+ obj.securityInvoker = String(row.security_invoker) === 'true' || row.security_invoker === true || row.security_invoker === 't';
448
+ }
257
449
  objects.push(obj);
258
450
  }
259
451
 
@@ -267,19 +459,61 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
267
459
  definition, indexes: [],
268
460
  ...(comment !== undefined ? { comment } : {}),
269
461
  defHash: computeDefHash(definition, [], comment),
462
+ ...grantsFor('f', identity),
463
+ ...(row.src !== undefined ? {
464
+ fn: {
465
+ args: String(row.args ?? ''),
466
+ returns: String(row.returns ?? ''),
467
+ language: String(row.language ?? 'sql'),
468
+ secdef: row.secdef === true || row.secdef === 't' || row.secdef === 'true',
469
+ volatility: String(row.volatility ?? 'v'),
470
+ ...(row.search_path != null && String(row.search_path) !== '' ? { searchPath: String(row.search_path) } : {}),
471
+ src: String(row.src),
472
+ },
473
+ } : {}),
270
474
  });
271
475
  }
272
476
 
273
- const edges: DependencyEdge[] = rows.depends
274
- .filter((r) => !IGNORED_SCHEMAS.has(r.dependent_schema) && !IGNORED_SCHEMAS.has(r.referenced_schema))
275
- .map((r) => ({
276
- dependent: `${r.dependent_schema}.${r.dependent_name}`,
277
- referenced: `${r.referenced_schema}.${r.referenced_name}`,
278
- }))
279
- .sort((a, b) => (a.dependent + a.referenced).localeCompare(b.dependent + b.referenced));
477
+ // Triggers: compound identity (schema.table.name); the table rides along for the
478
+ // structural drop. An edge to the OWNING relation is synthesized when that relation
479
+ // is itself derived (a view/matview) so a view rebuild cascades its INSTEAD OF
480
+ // triggers instead of CASCADE silently eating them. Tables are state — no edge.
481
+ const relationIdentities = new Set(objects.map((o) => o.identity));
482
+ const triggerEdges: DependencyEdge[] = [];
483
+ for (const row of rows.triggers ?? []) {
484
+ if (IGNORED_SCHEMAS.has(row.schema)) continue;
485
+ const ownerIdentity = `${row.schema}.${row.table}`;
486
+ const identity = `${ownerIdentity}.${row.name}`;
487
+ const definition = String(row.definition ?? '');
488
+ objects.push({
489
+ kind: 'trigger', schema: row.schema, name: row.name, identity,
490
+ definition, indexes: [],
491
+ defHash: computeDefHash(definition, []),
492
+ table: row.schema === 'public' ? row.table : ownerIdentity,
493
+ });
494
+ if (relationIdentities.has(ownerIdentity)) {
495
+ triggerEdges.push({ dependent: identity, referenced: ownerIdentity });
496
+ }
497
+ }
498
+
499
+ const edges: DependencyEdge[] = [
500
+ ...rows.depends
501
+ .filter((r) => !IGNORED_SCHEMAS.has(r.dependent_schema) && !IGNORED_SCHEMAS.has(r.referenced_schema))
502
+ .map((r) => ({
503
+ dependent: `${r.dependent_schema}.${r.dependent_name}`,
504
+ referenced: `${r.referenced_schema}.${r.referenced_name}`,
505
+ })),
506
+ ...triggerEdges,
507
+ ].sort((a, b) => (a.dependent + a.referenced).localeCompare(b.dependent + b.referenced));
280
508
 
281
509
  const provenance: ProvenanceRow[] = rows.provenance
282
- .map((r) => ({ identity: r.identity, srcHash: String(r.src_hash ?? ''), defHash: String(r.def_hash ?? '') }))
510
+ .map((r) => ({
511
+ identity: r.identity,
512
+ srcHash: String(r.src_hash ?? ''),
513
+ defHash: String(r.def_hash ?? ''),
514
+ ...(r.extra?.kind != null ? { kind: String(r.extra.kind) } : {}),
515
+ ...(r.extra?.drop_sql != null ? { dropSql: String(r.extra.drop_sql) } : {}),
516
+ }))
283
517
  .sort((a, b) => a.identity.localeCompare(b.identity));
284
518
 
285
519
  return {
@@ -295,8 +529,9 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
295
529
  * the reconciler has never touched) folds to an empty claims list.
296
530
  */
297
531
  export async function introspectDerived(run: QueryRunner): Promise<DerivedCatalog> {
298
- const [relations, functions, indexes, depends] = await Promise.all([
532
+ const [relations, functions, indexes, depends, triggers, grants] = await Promise.all([
299
533
  run(DERIVED_RELATIONS_SQL), run(DERIVED_FUNCTIONS_SQL), run(MATVIEW_INDEXES_SQL), run(DERIVED_DEPENDS_SQL),
534
+ run(DERIVED_TRIGGERS_SQL), run(DERIVED_GRANTS_SQL),
300
535
  ]);
301
536
  let provenance: ProvenanceRawRow[] = [];
302
537
  try {
@@ -309,6 +544,8 @@ export async function introspectDerived(run: QueryRunner): Promise<DerivedCatalo
309
544
  functions: functions as FunctionRow[],
310
545
  indexes: indexes as IndexDefRow[],
311
546
  depends: depends as DependsRow[],
547
+ triggers: triggers as TriggerRow[],
548
+ grants: grants as GrantAclRow[],
312
549
  provenance,
313
550
  });
314
551
  }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * derived-lint — the B2 gates over the declared derived layer
3
+ * (docs/plans/read-model-everywhere.md, "Rejection rules and gates").
4
+ *
5
+ * The table gate (authz-lint's findReadAuthzGaps) has a derived mirror: a declared
6
+ * relation nobody can read exists superuser-only from the moment it is created. Two more
7
+ * gates close the function surface (SECURITY DEFINER with undeclared callers) and the
8
+ * matview trap (a snapshot taken with the refresher's eyes over row-scoped sources), and
9
+ * one closes the invoker-view gap: `securityInvoker: true` means the CALLER's rights are
10
+ * checked against every relation the body touches — a view can be granted and still
11
+ * unreadable, one property over.
12
+ *
13
+ * Reachability walks DECLARED deps only — the declared graph paying for itself
14
+ * (`dependsOn`: declared, then verified; reconcile checks the declaration against the
15
+ * live catalog). An empty `dependsOn` is an empty closure, not a pass on a technicality:
16
+ * the drift report names the missing declaration.
17
+ *
18
+ * Audience semantics mirror the table grant compiler (authz-compile), and the mirroring
19
+ * is PINNED by a differential test against compileTableContract (derived-lint.test.ts) —
20
+ * asserted-in-parallel implementations drift exactly where the examples don't look. The
21
+ * shapes: bare read → anon + authenticated; owner/via-scoped read → authenticated only;
22
+ * column-scoped self read (`owner` + `columns`) → its role (or authenticated); `{ role }`
23
+ * narrows to that role; manage → its role (or authenticated); model-private does NOT
24
+ * zero the audience (private models still compile grants — the auth.users pattern). Row
25
+ * scoping (RLS) filters rows, not reach — a grant that returns only your rows is still a
26
+ * grant.
27
+ */
28
+
29
+ import type {
30
+ ModelDescriptor, DerivedDescriptor, ViewDescriptor, MaterializedViewDescriptor,
31
+ FunctionDescriptor, DependsOnRef, Ability,
32
+ } from '@everystack/model';
33
+ import { parseQualified } from './derived-source.js';
34
+
35
+ export interface DerivedAuthzGap {
36
+ /** `schema.name` of the descriptor that fails the gate. */
37
+ identity: string;
38
+ message: string;
39
+ }
40
+
41
+ function identityOf(d: DerivedDescriptor): string {
42
+ const { schema, name } = parseQualified(d.name);
43
+ return `${schema}.${name}`;
44
+ }
45
+
46
+ function isModelRef(ref: DependsOnRef): ref is ModelDescriptor {
47
+ return 'table' in ref;
48
+ }
49
+
50
+ /** The column-scoped self read — same predicate as the table grant compiler. */
51
+ function isColumnRead(a: Ability): boolean {
52
+ return a.action === 'read' && Boolean(a.condition.owner) && Boolean(a.condition.columns?.length);
53
+ }
54
+
55
+ /**
56
+ * Does the table's grant compile give `role` SELECT (full or column-scoped)?
57
+ *
58
+ * Exported for the differential pin against compileTableContract — this math must agree
59
+ * with the ACTUAL grant compiler for every ability shape, or the gate refuses views the
60
+ * database would serve (or worse, passes ones it won't). Note `private` on a MODEL is
61
+ * deliberately not consulted: private means "not exposed via the generic API", and its
62
+ * authz still compiles into real grants (the auth.users pattern — private + admin
63
+ * manage + column-scoped self read). The red team caught the gate treating model-private
64
+ * as zero-reach, which bricked every invoker view over a private table.
65
+ */
66
+ export function tableReaches(m: ModelDescriptor, role: string): boolean {
67
+ for (const a of m.abilities) {
68
+ if (isColumnRead(a)) {
69
+ if ((a.condition.role ?? 'authenticated') === role) return true;
70
+ continue;
71
+ }
72
+ if (a.action !== 'read' && a.action !== 'manage') continue;
73
+ if (a.condition.role) {
74
+ if (a.condition.role === role) return true;
75
+ continue;
76
+ }
77
+ if (role === 'authenticated') return true;
78
+ if (role === 'anon' && a.action === 'read' && !a.condition.owner && !a.condition.via) return true;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ /** The roles a relation's grants reach — bare read is anon + authenticated (the table precedent). */
84
+ function relationRoles(d: ViewDescriptor | MaterializedViewDescriptor): Set<string> {
85
+ const roles = new Set<string>();
86
+ for (const a of d.abilities) {
87
+ if (a.condition.role) roles.add(a.condition.role);
88
+ else {
89
+ roles.add('anon');
90
+ roles.add('authenticated');
91
+ }
92
+ }
93
+ return roles;
94
+ }
95
+
96
+ function functionRoles(fn: FunctionDescriptor): Set<string> {
97
+ // define-time already rejected bare can('execute') — every ability carries a role.
98
+ return new Set(fn.abilities.map((a) => a.condition.role!));
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Gate: derived read-gap (db:check fail)
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Every declared relation makes its read decision or fails: an exposed view/matview with
107
+ * no read ability and no `private: true` is superuser-only from birth — the derived
108
+ * mirror of the table landmine findReadAuthzGaps catches.
109
+ */
110
+ export function findDerivedReadGaps(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
111
+ const gaps: DerivedAuthzGap[] = [];
112
+ for (const d of derived) {
113
+ if (d.kind !== 'view' && d.kind !== 'materialized view') continue;
114
+ if (d.private || d.abilities.length > 0) continue;
115
+ const identity = identityOf(d);
116
+ gaps.push({
117
+ identity,
118
+ message:
119
+ `${identity} is a declared ${d.kind} with no read ability and no private: true — no app role can ` +
120
+ `read it, so it exists superuser-only. Declare can('read') for public data, can('read', { columns }) ` +
121
+ `to scope fields, or private: true if it is not part of the data API.`,
122
+ });
123
+ }
124
+ return gaps;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Gate: SECURITY DEFINER with no declared caller (db:check fail)
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /**
132
+ * A definer function runs with its owner's rights; compiling one with no `can('execute')`
133
+ * is privileged code with an undeclared audience — callable by nobody today (PUBLIC is
134
+ * revoked unconditionally) and by whoever gets a grant tomorrow, with no declaration to
135
+ * review. Invoker functions may stay dark: unprivileged, owner-callable, honest.
136
+ */
137
+ export function findSecdefExecuteGaps(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
138
+ const gaps: DerivedAuthzGap[] = [];
139
+ for (const d of derived) {
140
+ if (d.kind !== 'function' || d.security !== 'definer' || d.abilities.length > 0) continue;
141
+ const identity = identityOf(d);
142
+ gaps.push({
143
+ identity,
144
+ message:
145
+ `${identity} is SECURITY DEFINER with no declared caller — privileged code whose audience nobody ` +
146
+ `reviewed. Declare who calls it with can('execute', { role: '…' }), or make it security: 'invoker'.`,
147
+ });
148
+ }
149
+ return gaps;
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Gate: matview snapshot over row-scoped sources (db:check warn)
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /** Walk the declared closure through views (a matview's own snapshot is its own decision). */
157
+ function closureModels(refs: readonly DependsOnRef[], seen = new Set<DependsOnRef>()): ModelDescriptor[] {
158
+ const models: ModelDescriptor[] = [];
159
+ for (const ref of refs) {
160
+ if (seen.has(ref)) continue;
161
+ seen.add(ref);
162
+ if (isModelRef(ref)) models.push(ref);
163
+ else if (ref.kind === 'view') models.push(...closureModels(ref.dependsOn, seen));
164
+ }
165
+ return models;
166
+ }
167
+
168
+ function rowScoped(m: ModelDescriptor): boolean {
169
+ return m.abilities.some((a) => a.condition.owner || a.condition.via);
170
+ }
171
+
172
+ /**
173
+ * A matview's rows are whatever its refresher could see, served flat to every granted
174
+ * role: over a row-scoped source, a superuser refresher snapshots EVERYTHING (broad), and
175
+ * a FORCE-RLS-subject owner silently snapshots a SUBSET (filtered) — both wrong in a
176
+ * different direction, neither visible in the schema. Warn, don't fail: an aggregating
177
+ * body (counts, sums) is the legitimate case, and only the author knows.
178
+ */
179
+ export function findMatviewSnapshotWarnings(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
180
+ const warnings: DerivedAuthzGap[] = [];
181
+ for (const d of derived) {
182
+ if (d.kind !== 'materialized view' || d.private || d.abilities.length === 0) continue;
183
+ const scoped = closureModels(d.dependsOn).filter(rowScoped);
184
+ if (scoped.length === 0) continue;
185
+ const identity = identityOf(d);
186
+ const sources = scoped.map((m) => `${m.schema}.${m.table}`).join(', ');
187
+ warnings.push({
188
+ identity,
189
+ message:
190
+ `${identity} snapshots row-scoped source(s) ${sources} with the refresher's eyes — a superuser ` +
191
+ `refresh serves EVERY row flat to every granted role; a FORCE-RLS-subject owner silently snapshots ` +
192
+ `a subset. Confirm the body aggregates/filters away the per-row data, or declare private: true.`,
193
+ });
194
+ }
195
+ return warnings;
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Gate: invoker-view grant reachability (compile fail)
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /** The first ref in the closure `role` cannot reach, or null. */
203
+ function findUnreachable(role: string, refs: readonly DependsOnRef[], seen: Set<DependsOnRef>): string | null {
204
+ for (const ref of refs) {
205
+ if (seen.has(ref)) continue;
206
+ seen.add(ref);
207
+ if (isModelRef(ref)) {
208
+ if (!tableReaches(ref, role)) return `${ref.schema}.${ref.table}`;
209
+ continue;
210
+ }
211
+ switch (ref.kind) {
212
+ case 'view': {
213
+ if (ref.private || !relationRoles(ref).has(role)) return identityOf(ref);
214
+ // An invoker dep re-checks the caller one level down; a definer dep reads as its owner.
215
+ if (ref.securityInvoker) {
216
+ const deeper = findUnreachable(role, ref.dependsOn, seen);
217
+ if (deeper) return deeper;
218
+ }
219
+ break;
220
+ }
221
+ case 'materialized view':
222
+ // A matview is its own snapshot: SELECT on the matview is the whole requirement.
223
+ if (ref.private || !relationRoles(ref).has(role)) return identityOf(ref);
224
+ break;
225
+ case 'function':
226
+ // The body calls it with the caller's rights — EXECUTE is part of reach.
227
+ if (!functionRoles(ref).has(role)) return identityOf(ref);
228
+ break;
229
+ case 'sql':
230
+ break; // opaque — never a false positive
231
+ }
232
+ }
233
+ return null;
234
+ }
235
+
236
+ /**
237
+ * `securityInvoker: true` + a grant to role R is a PROMISE that R can read the body's
238
+ * relations — PostgreSQL checks R, not the owner, against every one of them. This gate
239
+ * makes the promise static: every granted role must have reach through the declared
240
+ * `dependsOn` closure, or the view is granted-but-unreadable and the compile refuses.
241
+ */
242
+ export function findInvokerReachabilityGaps(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
243
+ const gaps: DerivedAuthzGap[] = [];
244
+ for (const d of derived) {
245
+ if (d.kind !== 'view' || !d.securityInvoker || d.private || d.abilities.length === 0) continue;
246
+ const identity = identityOf(d);
247
+ for (const role of [...relationRoles(d)].sort()) {
248
+ const unreachable = findUnreachable(role, d.dependsOn, new Set());
249
+ if (unreachable) {
250
+ gaps.push({
251
+ identity,
252
+ message:
253
+ `view '${identity}' grants ${role} but its dependency '${unreachable}' gives ${role} no reach — ` +
254
+ `granted-but-unreadable (an invoker view reads with the caller's rights). Grant '${unreachable}' ` +
255
+ `to ${role}, seal the view with securityInvoker: false, or narrow the view's roles.`,
256
+ });
257
+ }
258
+ }
259
+ }
260
+ return gaps;
261
+ }