@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.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/cli/authz-compile.ts +5 -2
- package/src/cli/backfill.ts +1 -1
- package/src/cli/commands/db-branch.ts +28 -4
- package/src/cli/commands/db-check.ts +84 -33
- package/src/cli/commands/db-fingerprint.ts +7 -2
- package/src/cli/commands/db-generate.ts +71 -20
- package/src/cli/commands/db-pull.ts +37 -5
- package/src/cli/commands/db-reconcile.ts +132 -41
- package/src/cli/commands/db-sync.ts +54 -19
- package/src/cli/db-build.ts +9 -3
- package/src/cli/db-source.ts +5 -1
- package/src/cli/declared-derived.ts +136 -0
- package/src/cli/derived-apply.ts +40 -6
- package/src/cli/derived-compile.ts +327 -0
- package/src/cli/derived-grants.ts +96 -0
- package/src/cli/derived-introspect.ts +258 -21
- package/src/cli/derived-lint.ts +261 -0
- package/src/cli/derived-plan.ts +176 -10
- package/src/cli/derived-render.ts +320 -0
- package/src/cli/derived-source.ts +53 -125
- package/src/cli/git-descent.ts +15 -2
- package/src/cli/index.ts +6 -6
- package/src/cli/migration-compile.ts +38 -7
- package/src/cli/migration-generate.ts +43 -4
- package/src/cli/model-render.ts +106 -13
- package/src/cli/models-path.ts +1 -1
- package/src/cli/pipeline-path.ts +1 -1
- package/src/cli/schema-compile.ts +41 -10
- package/src/cli/schema-diff.ts +123 -17
- package/src/cli/schema-fingerprint.ts +28 -18
- package/src/cli/schema-introspect.ts +175 -8
- package/src/cli/schema-source.ts +75 -6
- package/src/cli/state-apply.ts +4 -1
|
@@ -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
|
|
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
|
|
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
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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) => ({
|
|
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
|
+
}
|