@everystack/cli 0.4.103 → 0.4.105

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.
@@ -0,0 +1,617 @@
1
+ /** Read-only prerequisite for the state and derived halves of a lifecycle apply. */
2
+ import { createHash } from 'node:crypto';
3
+ import { DESTROY_LOG_MARKER_BODIES, DESTROY_LOG_MARKER_LANGUAGES, DESTROY_LOG_MARKER_SEARCH_PATH, LIFECYCLE_OWNER_ROLE, type ModelDescriptor } from '@everystack/model';
4
+ import type { QueryRunner } from './authz-contract';
5
+ import { compileTableContract } from './authz-compile';
6
+ import { reconcileOwner, triggerFunctionOf } from './derived-apply';
7
+ import { isSessionError, type SessionRunner } from './session';
8
+ import { parseQualified, type SourceObject } from './derived-source';
9
+ import { lifecycleDerived, lifecycleManifestOf, lifecycleRole } from './lifecycle-derived';
10
+
11
+ export const LIFECYCLE_LOG_REFUSAL = 'everystack.destroy_log is missing; run db:provision before db:apply';
12
+ export const LIFECYCLE_LOG_OUTDATED_REFUSAL = 'everystack.destroy_log is out of date (it has no before column or no marker functions); run db:provision before db:apply';
13
+ /**
14
+ * Present, and current: it has the `before` column and the three marker functions the generated
15
+ * destroy calls (destroy_log_mark, destroy_log_unmark, destroy_cutoff). The reconcile creates
16
+ * functions with check_function_bodies off, so on an earlier log it would install them and every
17
+ * lifecycle DELETE would then fail with an unknown-column or unknown-function error.
18
+ */
19
+ export const LIFECYCLE_LOG_SQL = `SELECT pg_catalog.to_regclass('everystack.destroy_log') IS NOT NULL AS present,
20
+ EXISTS (SELECT 1 FROM pg_catalog.pg_attribute WHERE attrelid = pg_catalog.to_regclass('everystack.destroy_log')
21
+ AND attname = 'before' AND NOT attisdropped)
22
+ AND pg_catalog.to_regprocedure('everystack.destroy_log_mark(timestamptz)') IS NOT NULL
23
+ AND pg_catalog.to_regprocedure('everystack.destroy_log_unmark()') IS NOT NULL
24
+ AND pg_catalog.to_regprocedure('everystack.destroy_cutoff()') IS NOT NULL AS current`;
25
+
26
+ /** The refusal for one LIFECYCLE_LOG_SQL row, or null when the log is present and current. */
27
+ export function lifecycleLogRowRefusal(log: { present?: unknown; current?: unknown } | undefined): string | null {
28
+ if (log?.present !== true) return LIFECYCLE_LOG_REFUSAL;
29
+ return log.current === true ? null : LIFECYCLE_LOG_OUTDATED_REFUSAL;
30
+ }
31
+
32
+ /**
33
+ * The log's shape first (its refusals are unchanged), then the live catalog facts the soft delete
34
+ * rests on (lifecycleCatalogRefusal). Pure SELECTs: the --stage lane runs them over db:query.
35
+ */
36
+ export async function lifecycleApplyRefusal(runner: QueryRunner, models: readonly ModelDescriptor[]): Promise<string | null> {
37
+ if (!models.some(model => model.lifecycle === true)) return null;
38
+ const [log] = await runner(LIFECYCLE_LOG_SQL);
39
+ // Before the plan: a modeled table's keys are the plan's to converge (the models declare each
40
+ // one, or refuse to compile), so rule 6 reports only keys from tables the models do not have.
41
+ const modeledTables = [...new Set(models.map(model => `${model.schema}.${model.table}`))];
42
+ return lifecycleLogRowRefusal(log) ?? lifecycleCatalogRefusal(runner, { ...catalogInputsFromModels(models), modeledTables });
43
+ }
44
+
45
+ const literal = (s: string): string => `'${s.replace(/'/g, "''")}'`;
46
+ const ident = (s: string): string => (/^[a-z_][a-z0-9_]*$/.test(s) ? s : `"${s.replace(/"/g, '""')}"`);
47
+ /** A `schema.table` label, quoted for a fix statement. */
48
+ const quoteLabel = (label: string): string => {
49
+ const dot = label.indexOf('.');
50
+ return `${ident(label.slice(0, dot))}.${ident(label.slice(dot + 1))}`;
51
+ };
52
+ const textArray = (values: readonly string[]): string => `ARRAY[${values.map(literal).join(', ')}]::text[]`;
53
+ /** An aclexplode grantee by name, PUBLIC for 0. */
54
+ const granteeName = (column: string): string =>
55
+ `CASE WHEN ${column} = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(${column})::text END`;
56
+ const md5 = (text: string): string => createHash('md5').update(text, 'utf8').digest('hex');
57
+ const truthy = (v: unknown): boolean => v === true || v === 't' || v === 'true';
58
+ const textList = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : typeof v === 'string' && v.startsWith('{')
59
+ ? v.slice(1, -1).split(',').filter(Boolean).map(s => s.replace(/^"|"$/g, '')) : []);
60
+ const MARKERS = Object.keys(DESTROY_LOG_MARKER_BODIES) as Array<keyof typeof DESTROY_LOG_MARKER_BODIES>;
61
+ const MARKER_CONFIG = `search_path=${DESTROY_LOG_MARKER_SEARCH_PATH}`;
62
+
63
+ /**
64
+ * What the catalog check reads from the declared side. The check needs nothing else from the
65
+ * models, so db:reconcile's ops action can run it from its wire payload, which carries no models.
66
+ *
67
+ * lifecycleTables `schema.table` of every lifecycle table
68
+ * applicationRoles every role the models' compiled contracts grant to or name in a policy. A
69
+ * request runs as one of them. SQL cannot tell which role is the application's
70
+ * login; a login with no grant or policy of its own is application-side only
71
+ * when it can assume one of these. PUBLIC and the lifecycle role never count.
72
+ */
73
+ export interface LifecycleCatalogInputs {
74
+ lifecycleTables: readonly string[];
75
+ applicationRoles: readonly string[];
76
+ /** Every declared dependent key (dependentKeysFromDeclared). */
77
+ dependentKeys: readonly LifecycleDependentKey[];
78
+ /**
79
+ * `schema.table` of each lifecycle table whose manifest has no nullify record (an older CLI sent
80
+ * it): a key it declares 'nullify' cannot be seen, so its finding says to upgrade the CLI.
81
+ */
82
+ nullifyUnrecorded?: readonly string[];
83
+ /**
84
+ * Before the plan only (lifecycleApplyRefusal): `schema.table` of every modeled table, whose keys
85
+ * rule 6 leaves to the plan. Absent after the state apply: every key is checked.
86
+ */
87
+ modeledTables?: readonly string[];
88
+ }
89
+
90
+ /** A declared dependent: the child's `schema.table`, its foreign-key column (SQL name), the lifecycle parent's `schema.table`. */
91
+ export interface LifecycleDependentKey {
92
+ child: string;
93
+ column: string;
94
+ parent: string;
95
+ }
96
+
97
+ /**
98
+ * The declared dependents ('cascade', 'restrict' and 'nullify'), read from the generated lifecycle
99
+ * functions' manifests: the declared layer itself, so the models path and a db:reconcile payload
100
+ * (which carries no models) read one source.
101
+ */
102
+ export function dependentKeysFromDeclared(declared: readonly Parameters<typeof lifecycleManifestOf>[0][]): LifecycleDependentKey[] {
103
+ const label = (name: string): string => {
104
+ if (!name.startsWith('"')) return name;
105
+ const { schema, name: table } = parseQualified(name);
106
+ return `${schema}.${table}`;
107
+ };
108
+ const keys = new Map<string, LifecycleDependentKey>();
109
+ for (const object of declared) {
110
+ const manifest = lifecycleManifestOf(object);
111
+ if (!manifest) continue;
112
+ const edges = [...manifest.decl.edges, ...(manifest.decl.nullify ?? []).map(n => ({ ...n, parent: manifest.model }))];
113
+ for (const edge of edges) {
114
+ const key = { child: label(edge.child), column: edge.column, parent: edge.parent };
115
+ keys.set(JSON.stringify(key), key);
116
+ }
117
+ }
118
+ return [...keys.values()].sort((a, b) => (JSON.stringify(a) < JSON.stringify(b) ? -1 : 1));
119
+ }
120
+
121
+ /** The lifecycle tables whose transition manifest has no `decl.nullify` (a CLI that predates it sent the layer). */
122
+ export function nullifyUnrecordedFromDeclared(declared: readonly Parameters<typeof lifecycleManifestOf>[0][]): string[] {
123
+ return [...new Set(declared.map(object => lifecycleManifestOf(object))
124
+ .filter(manifest => manifest?.role === 'transition' && manifest.decl.nullify === undefined)
125
+ .map(manifest => manifest!.model))].sort();
126
+ }
127
+
128
+ const notApplicationRole = (lc: string) => (role: string): boolean => role !== 'PUBLIC' && role !== 'public' && role !== lc;
129
+
130
+ /** The catalog check's inputs, read from the models. */
131
+ export function catalogInputsFromModels(models: readonly ModelDescriptor[]): { lifecycleTables: string[]; applicationRoles: string[]; dependentKeys: LifecycleDependentKey[] } {
132
+ const roles = new Set<string>();
133
+ for (const model of models) {
134
+ const contract = compileTableContract(model);
135
+ for (const role of Object.keys(contract.grants)) roles.add(role);
136
+ for (const role of Object.keys(contract.columnGrants ?? {})) roles.add(role);
137
+ for (const policy of contract.policies) for (const role of policy.roles) roles.add(role);
138
+ }
139
+ return {
140
+ lifecycleTables: [...new Set(models.filter(model => model.lifecycle === true).map(model => `${model.schema}.${model.table}`))].sort(),
141
+ applicationRoles: [...roles].filter(notApplicationRole(LIFECYCLE_OWNER_ROLE)).sort(),
142
+ dependentKeys: models.some(model => model.lifecycle === true) ? dependentKeysFromDeclared(lifecycleDerived(models)) : [],
143
+ };
144
+ }
145
+
146
+ /**
147
+ * `x` can act as `y`: SET ROLE to it, or inherit its privileges. On PostgreSQL 16 and later
148
+ * pg_has_role's MEMBER also counts an ADMIN-only grant (no SET, no INHERIT), which a non-superuser
149
+ * CREATEROLE role gets on every role it creates; SET and USAGE do not. Before 16 every membership
150
+ * could SET ROLE, MEMBER means exactly that, and 'SET' is not a keyword, so the branch is on
151
+ * server_version_num.
152
+ */
153
+ const assumes = (x: string, y: string): string => `(pg_catalog.pg_has_role(${x}, ${y}, 'USAGE') OR CASE
154
+ WHEN pg_catalog.current_setting('server_version_num')::int >= 160000 THEN pg_catalog.pg_has_role(${x}, ${y}, 'SET')
155
+ ELSE pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER') END)`;
156
+ /** `x` can act as `y`, or hold ADMIN on it: an ADMIN holder can grant `y` to itself WITH SET. */
157
+ const reaches = (x: string, y: string): string =>
158
+ `(${assumes(x, y)} OR pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER WITH ADMIN OPTION'))`;
159
+
160
+ /**
161
+ * The application-side roles, as a CTE `side(oid, rolname)`: non-superusers that are, or can act
162
+ * as, an application role. A superuser passes every pg_has_role and is left out; so is the RDS
163
+ * master, whose ADMIN-only membership in the roles it created lets it act as none of them.
164
+ */
165
+ const sideCte = (apps: string[]): string => `side AS (SELECT r.oid, r.rolname::text AS rolname FROM pg_catalog.pg_roles r
166
+ WHERE NOT r.rolsuper AND EXISTS (SELECT 1 FROM pg_catalog.pg_roles a
167
+ WHERE a.rolname = ANY(${textArray(apps)}) AND (a.oid = r.oid OR ${assumes('r.oid', 'a.oid')})))`;
168
+
169
+ /** Rule 1, attributes. */
170
+ const roleSql = (lc: string): string => `SELECT lc.oid IS NOT NULL AS present,
171
+ COALESCE(lc.rolcanlogin, false) AS login, COALESCE(lc.rolsuper, false) AS super,
172
+ COALESCE(lc.rolcreaterole, false) AS createrole, COALESCE(lc.rolbypassrls, false) AS bypassrls
173
+ FROM (SELECT 1) AS one LEFT JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}`;
174
+
175
+ /**
176
+ * Rule 1, members: each application-side role that reaches the lifecycle role, with the
177
+ * application roles it acts as. The operator's membership (WITH INHERIT FALSE, SET TRUE, which a
178
+ * non-superuser builder needs to create the lifecycle functions) passes: it acts as no application role.
179
+ */
180
+ const memberSql = (apps: string[], lc: string): string => `WITH ${sideCte(apps)}
181
+ SELECT s.rolname AS role, ARRAY(SELECT a.rolname::text FROM pg_catalog.pg_roles a
182
+ WHERE a.rolname = ANY(${textArray(apps)}) AND (a.oid = s.oid OR ${assumes('s.oid', 'a.oid')}) ORDER BY 1) AS apps
183
+ FROM side s JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}
184
+ WHERE s.oid <> lc.oid AND ${reaches('s.oid', 'lc.oid')}
185
+ ORDER BY 1`;
186
+
187
+ /**
188
+ * Rule 2. The owner of a lifecycle table can disable its triggers; the owner of destroy_log can
189
+ * write the marker; the owner of a marker function can replace it. Each such object is a finding
190
+ * when its owner is the lifecycle role, or when an application-side role is that owner, can act
191
+ * as it, or holds ADMIN on it. The operator owning all of them is the documented setup and passes.
192
+ */
193
+ const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH ${sideCte(apps)},
194
+ owned AS (
195
+ SELECT 'table' AS kind, n.nspname::text || '.' || c.relname::text AS name, c.relowner AS owner
196
+ FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
197
+ WHERE c.relkind IN ('r', 'p') AND (n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)})
198
+ UNION ALL
199
+ SELECT 'table', 'everystack.destroy_log', c.relowner FROM pg_catalog.pg_class c
200
+ WHERE c.oid = pg_catalog.to_regclass('everystack.destroy_log')
201
+ UNION ALL
202
+ SELECT 'function', m.sig, p.proowner FROM pg_catalog.unnest(${textArray(MARKERS)}) AS m(sig)
203
+ JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(m.sig))
204
+ SELECT o.kind, o.name, pg_catalog.pg_get_userbyid(o.owner)::text AS owner, o.owner = lc.oid AS lifecycle_owned,
205
+ ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.owner OR ${reaches('s.oid', 'o.owner')} ORDER BY 1) AS via
206
+ FROM owned o LEFT JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}
207
+ WHERE o.owner = lc.oid OR EXISTS (SELECT 1 FROM side s WHERE s.oid = o.owner OR ${reaches('s.oid', 'o.owner')})
208
+ ORDER BY 2, 1`;
209
+
210
+ /**
211
+ * Rule 3. TRIGGER, REFERENCES or TRUNCATE on a lifecycle table, or REFERENCES on one of its
212
+ * columns, held by anyone but its owner. On everystack.destroy_log, any privilege but SELECT
213
+ * (table or column) held by anyone but its owner: a writer could forge or remove a marker.
214
+ */
215
+ const aclSql = (tables: string[]): string => `SELECT n.nspname::text || '.' || c.relname::text AS "table", ${granteeName('a.grantee')} AS grantee,
216
+ a.privilege_type AS privilege, NULL::text AS "column"
217
+ FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
218
+ CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) a
219
+ WHERE c.relkind IN ('r', 'p') AND a.grantee <> c.relowner AND (
220
+ ((n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)}) AND a.privilege_type IN ('TRIGGER', 'REFERENCES', 'TRUNCATE'))
221
+ OR (c.oid = pg_catalog.to_regclass('everystack.destroy_log') AND a.privilege_type <> 'SELECT'))
222
+ UNION ALL
223
+ SELECT n.nspname::text || '.' || c.relname::text, ${granteeName('a.grantee')}, a.privilege_type, t.attname::text
224
+ FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
225
+ JOIN pg_catalog.pg_attribute t ON t.attrelid = c.oid AND t.attnum > 0 AND NOT t.attisdropped
226
+ CROSS JOIN LATERAL pg_catalog.aclexplode(t.attacl) a
227
+ WHERE c.relkind IN ('r', 'p') AND a.grantee <> c.relowner AND (
228
+ ((n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)}) AND a.privilege_type = 'REFERENCES')
229
+ OR (c.oid = pg_catalog.to_regclass('everystack.destroy_log') AND a.privilege_type <> 'SELECT'))
230
+ ORDER BY 1, 4 NULLS FIRST, 3, 2`;
231
+
232
+ /**
233
+ * Rule 4. Each marker function: SECURITY DEFINER, owned by destroy_log's owner, EXECUTE for the
234
+ * lifecycle role and nobody else but the owner (PUBLIC included; a NULL ACL is PUBLIC's default),
235
+ * and the language, settings and body (md5 of prosrc) provisioning installs.
236
+ */
237
+ const markerSql = (lc: string): string => `SELECT s.sig, p.oid IS NOT NULL AS present, COALESCE(p.prosecdef, false) AS secdef,
238
+ pg_catalog.pg_get_userbyid(p.proowner)::text AS owner, pg_catalog.pg_get_userbyid(d.relowner)::text AS log_owner,
239
+ pg_catalog.md5(p.prosrc) AS body_md5, l.lanname::text AS language,
240
+ pg_catalog.array_to_string(p.proconfig, '; ') AS config,
241
+ ARRAY(SELECT DISTINCT ${granteeName('a.grantee')} FROM pg_catalog.aclexplode(COALESCE(p.proacl, pg_catalog.acldefault('f', p.proowner))) a
242
+ WHERE a.privilege_type = 'EXECUTE' AND a.grantee <> p.proowner AND a.grantee IS DISTINCT FROM lc.oid ORDER BY 1) AS others,
243
+ EXISTS (SELECT 1 FROM pg_catalog.aclexplode(COALESCE(p.proacl, pg_catalog.acldefault('f', p.proowner))) a
244
+ WHERE a.privilege_type = 'EXECUTE' AND a.grantee = lc.oid) AS lifecycle_execute
245
+ FROM (VALUES ${MARKERS.map((sig, i) => `(${i}, ${literal(sig)})`).join(', ')}) AS s(i, sig)
246
+ LEFT JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(s.sig)
247
+ LEFT JOIN pg_catalog.pg_language l ON l.oid = p.prolang
248
+ LEFT JOIN pg_catalog.pg_class d ON d.oid = pg_catalog.to_regclass('everystack.destroy_log')
249
+ LEFT JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}
250
+ ORDER BY s.i`;
251
+
252
+ /**
253
+ * Rule 5. A trigger on a lifecycle table whose function's owner is an application-side role, or
254
+ * one an application-side role reaches. The soft delete's stamp and destroy's DELETE run as the
255
+ * lifecycle role, so an INVOKER trigger function runs as it and a DEFINER one as its owner; either
256
+ * way whoever can replace the body acts inside every soft delete and destroy. So the security
257
+ * mode does not matter, and neither the table's owner nor the lifecycle role is exempt.
258
+ */
259
+ const triggerOwnerSql = (tables: string[], apps: string[]): string => `WITH ${sideCte(apps)}
260
+ SELECT n.nspname::text || '.' || c.relname::text AS "table", g.tgname::text AS trigger,
261
+ pg_catalog.quote_ident(fn.nspname) || '.' || pg_catalog.quote_ident(p.proname) || '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')' AS function,
262
+ pg_catalog.pg_get_userbyid(p.proowner)::text AS owner,
263
+ ARRAY(SELECT s.rolname FROM side s WHERE s.oid = p.proowner OR ${reaches('s.oid', 'p.proowner')} ORDER BY 1) AS via
264
+ FROM pg_catalog.pg_trigger g JOIN pg_catalog.pg_class c ON c.oid = g.tgrelid
265
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace JOIN pg_catalog.pg_proc p ON p.oid = g.tgfoid
266
+ JOIN pg_catalog.pg_namespace fn ON fn.oid = p.pronamespace
267
+ WHERE NOT g.tgisinternal AND (n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)})
268
+ AND EXISTS (SELECT 1 FROM side s WHERE s.oid = p.proowner OR ${reaches('s.oid', 'p.proowner')})
269
+ ORDER BY 1, 2`;
270
+
271
+ /**
272
+ * One rule-5 finding. `via` holds the application-side roles that are, or reach, `owner`. When the
273
+ * owner is one of them the fix is a new owner; otherwise it is to revoke the path to the owner.
274
+ * `would`: the pre-exposure gate, which judges a declared trigger before the reconcile creates it.
275
+ */
276
+ export function triggerOwnerFinding(row: { trigger: string; table: string; function: string; owner: string; via: readonly string[] }, would = false): string {
277
+ const { owner, via } = row;
278
+ const sideOwned = via.includes(owner);
279
+ const who = sideOwned ? `${owner}, an application-side role`
280
+ : `${owner}, which the application-side role${via.length === 1 ? '' : 's'} ${via.join(', ')} can assume`;
281
+ const fix = sideOwned
282
+ ? `declare ${row.function}'s owner in db/models as a role no application role can act as, and apply `
283
+ + `(the apply re-creates a declared function with its declared owner, so a live ALTER alone is undone); `
284
+ + `for a function not declared in db/models: ALTER FUNCTION ${row.function} OWNER TO <that role>;`
285
+ : `${via.map(r => `REVOKE ${ident(owner)} FROM ${ident(r)};`).join(' ')} (or from the role each reaches ${owner} through)`;
286
+ return `trigger ${row.trigger} on ${row.table} ${would ? 'would run' : 'runs'} ${row.function}, owned by ${who}; `
287
+ + `${via.join(', ')} can replace its body, and it runs inside every soft delete and destroy. Fix: ${fix}`;
288
+ }
289
+
290
+ /**
291
+ * Rule 6. Every foreign key into a lifecycle table (a partition's clone excluded), with its columns
292
+ * in key order and its ON DELETE action. Which ones a model declares is decided by the caller.
293
+ */
294
+ const inboundKeySql = (tables: string[]): string => `SELECT n.nspname::text || '.' || c.relname::text AS child, k.conname::text AS "constraint",
295
+ ARRAY(SELECT a.attname::text FROM pg_catalog.unnest(k.conkey) WITH ORDINALITY AS u(attnum, i)
296
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = k.conrelid AND a.attnum = u.attnum ORDER BY u.i) AS columns,
297
+ pn.nspname::text || '.' || p.relname::text AS parent, k.confdeltype::text AS action
298
+ FROM pg_catalog.pg_constraint k JOIN pg_catalog.pg_class c ON c.oid = k.conrelid
299
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
300
+ JOIN pg_catalog.pg_class p ON p.oid = k.confrelid JOIN pg_catalog.pg_namespace pn ON pn.oid = p.relnamespace
301
+ WHERE k.contype = 'f' AND k.conparentid = 0 AND (pn.nspname::text || '.' || p.relname::text) = ANY(${textArray(tables)})
302
+ ORDER BY 1, 2`;
303
+
304
+ const ON_DELETE: Record<string, string> = { a: 'NO ACTION', r: 'RESTRICT', c: 'CASCADE', n: 'SET NULL', d: 'SET DEFAULT' };
305
+
306
+ const OWNER_HAZARD = {
307
+ table: 'an owner can disable its triggers',
308
+ log: 'an owner can write the marker',
309
+ function: 'an owner can replace the function',
310
+ };
311
+
312
+ /**
313
+ * The live catalog facts a lifecycle soft delete rests on, which the generator cannot guarantee.
314
+ * Every finding is named on its own line with its fix, or null when there is none.
315
+ * `lifecycleRole` is a test seam (the role is cluster-level); callers leave it unset.
316
+ *
317
+ * THREAT MODEL. This defends an HONEST operator from installing or re-arming the lifecycle
318
+ * SECURITY DEFINER objects onto a database whose grants or ownership have drifted: an application
319
+ * role that owns a lifecycle table, holds TRIGGER on one, can write everystack.destroy_log, a
320
+ * replaced marker function, and the rest below. It does not defend against a hostile IAM deployer,
321
+ * who can already reach the ops Lambda's other actions; so the roles may come from the caller
322
+ * (db:reconcile's wire payload carries them).
323
+ *
324
+ * The one implementation: db:apply, db:sync and db:plan reach it through lifecycleApplyRefusal
325
+ * (inputs from the models); db:reconcile, local and in the ops Lambda, through
326
+ * lifecycleReconcileRefusal (tables from the declared transition triggers); db:apply and db:build
327
+ * again after the derived layer lands, through lifecyclePostApplyRefusal.
328
+ */
329
+ export async function lifecycleCatalogRefusal(
330
+ runner: QueryRunner,
331
+ inputs: LifecycleCatalogInputs,
332
+ options: { lifecycleRole?: string } = {},
333
+ ): Promise<string | null> {
334
+ const findings = await lifecycleCatalogFindings(runner, inputs, options);
335
+ if (findings.length === 0) return null;
336
+ return `the live catalog breaks the lifecycle soft delete (${findings.length} finding${findings.length === 1 ? '' : 's'}); `
337
+ + `fix each, then re-run:\n${findingLines(findings)}`;
338
+ }
339
+
340
+ const findingLines = (findings: readonly string[]): string => findings.map(finding => ` - ${finding}`).join('\n');
341
+ const countFindings = (n: number): string => `${n} lifecycle finding${n === 1 ? '' : 's'}`;
342
+
343
+ /** lifecycleCatalogRefusal's findings, one string each, without the heading. */
344
+ async function lifecycleCatalogFindings(
345
+ runner: QueryRunner,
346
+ inputs: LifecycleCatalogInputs,
347
+ options: { lifecycleRole?: string } = {},
348
+ ): Promise<string[]> {
349
+ const lc = options.lifecycleRole ?? LIFECYCLE_OWNER_ROLE;
350
+ const tables = [...new Set(inputs.lifecycleTables)].sort();
351
+ if (tables.length === 0) return [];
352
+ const apps = [...new Set(inputs.applicationRoles)].filter(notApplicationRole(lc)).sort();
353
+ const findings: string[] = [];
354
+
355
+ const [role] = await runner(roleSql(lc));
356
+ if (!truthy(role?.present)) {
357
+ findings.push(`role ${lc} is missing. Fix: run db:provision.`);
358
+ } else {
359
+ const attributes = (['login', 'super', 'createrole', 'bypassrls'] as const).filter(key => truthy(role[key]));
360
+ const word = { login: 'LOGIN', super: 'SUPERUSER', createrole: 'CREATEROLE', bypassrls: 'BYPASSRLS' };
361
+ if (attributes.length > 0) {
362
+ findings.push(`role ${lc} has ${attributes.map(key => word[key]).join(', ')}. `
363
+ + `Fix: ALTER ROLE ${ident(lc)} ${attributes.map(key => `NO${word[key]}`).join(' ')};`);
364
+ }
365
+ for (const member of await runner(memberSql(apps, lc))) {
366
+ findings.push(`role ${member.role} can assume ${lc} and is, or can assume, an application role (${textList(member.apps).join(', ')}). `
367
+ + `Fix: REVOKE ${ident(lc)} FROM ${ident(String(member.role))}; (or from the role it reaches ${lc} through).`);
368
+ }
369
+ }
370
+
371
+ for (const row of await runner(ownerSql(tables, apps, lc))) {
372
+ const kind = row.kind === 'function' ? 'function' : 'table';
373
+ const hazard = row.name === 'everystack.destroy_log' ? OWNER_HAZARD.log : OWNER_HAZARD[kind];
374
+ const via = textList(row.via);
375
+ const who = truthy(row.lifecycle_owned) ? `${row.owner}, the lifecycle role`
376
+ : via.includes(String(row.owner)) ? `${row.owner}, an application-side role`
377
+ : `${row.owner}, which the application-side role${via.length === 1 ? '' : 's'} ${via.join(', ')} can assume`;
378
+ const name = kind === 'function' ? String(row.name) : quoteLabel(String(row.name));
379
+ const fix = truthy(row.lifecycle_owned) || via.includes(String(row.owner))
380
+ ? `ALTER ${kind.toUpperCase()} ${name} OWNER TO <your operator role>;`
381
+ : `${via.map(r => `REVOKE ${ident(String(row.owner))} FROM ${ident(r)};`).join(' ')} (or from the role each reaches it through)`;
382
+ findings.push(`${kind} ${row.name} is owned by ${who}; ${hazard}. Fix: ${fix}`);
383
+ }
384
+
385
+ for (const row of await runner(aclSql(tables))) {
386
+ const on = row.column == null ? '' : ` (${ident(String(row.column))})`;
387
+ const who = row.grantee === 'PUBLIC' ? 'PUBLIC' : ident(String(row.grantee));
388
+ findings.push(`${row.grantee} holds ${row.privilege}${on} on ${row.table}. `
389
+ + `Fix: REVOKE ${row.privilege}${on} ON TABLE ${quoteLabel(String(row.table))} FROM ${who};`);
390
+ }
391
+
392
+ for (const row of await runner(markerSql(lc))) {
393
+ const sig = String(row.sig) as keyof typeof DESTROY_LOG_MARKER_BODIES;
394
+ const fn = `marker function ${sig}`;
395
+ if (!truthy(row.present)) { findings.push(`${fn} is missing. Fix: run db:provision.`); continue; }
396
+ if (!truthy(row.secdef)) findings.push(`${fn} is not SECURITY DEFINER. Fix: run db:provision.`);
397
+ if (row.owner !== row.log_owner) {
398
+ findings.push(`${fn} is owned by ${row.owner}, not ${row.log_owner} (the owner of everystack.destroy_log). `
399
+ + `Fix: ALTER FUNCTION ${sig} OWNER TO ${ident(String(row.log_owner))}; then run db:provision.`);
400
+ }
401
+ const others = textList(row.others);
402
+ if (others.length > 0) findings.push(`${fn} grants EXECUTE to ${others.join(', ')}. Fix: run db:provision.`);
403
+ if (!truthy(row.lifecycle_execute)) findings.push(`${fn} does not grant EXECUTE to ${lc}. Fix: run db:provision.`);
404
+ if (row.language !== DESTROY_LOG_MARKER_LANGUAGES[sig]) {
405
+ findings.push(`${fn} is in language ${row.language}, not ${DESTROY_LOG_MARKER_LANGUAGES[sig]}. Fix: run db:provision.`);
406
+ }
407
+ if ((row.config ?? '') !== MARKER_CONFIG) {
408
+ findings.push(`${fn} has settings ${row.config || '(none)'}, not ${MARKER_CONFIG}. Fix: run db:provision.`);
409
+ }
410
+ if (row.body_md5 !== md5(DESTROY_LOG_MARKER_BODIES[sig])) {
411
+ findings.push(`${fn} has a body other than the one db:provision installs. Fix: run db:provision.`);
412
+ }
413
+ }
414
+
415
+ for (const row of await runner(triggerOwnerSql(tables, apps))) {
416
+ findings.push(triggerOwnerFinding({ trigger: String(row.trigger), table: String(row.table), function: String(row.function),
417
+ owner: String(row.owner), via: textList(row.via) }));
418
+ }
419
+
420
+ const declared = new Set(inputs.dependentKeys.map(key => JSON.stringify([key.child, key.column, key.parent])));
421
+ const modeled = new Set(inputs.modeledTables ?? []);
422
+ // After the state apply a key the models no longer hold, or now declare, may not be converged yet.
423
+ const applyFirst = inputs.modeledTables ? '' : 'If the models already refer to it by value or declare it, mint a plan that drops or rewrites the key '
424
+ + '(db:plan, with --allow-drops when it is a drop) and db:apply it. Otherwise ';
425
+ const unrecorded = new Set(inputs.nullifyUnrecorded ?? []);
426
+ for (const row of await runner(inboundKeySql(tables))) {
427
+ // Before the plan, a modeled table's keys are the plan's to converge: the models hold only
428
+ // declared keys from lifecycle tables (or refuse to compile), and the plan drops the rest.
429
+ if (modeled.has(String(row.child))) continue;
430
+ const columns = textList(row.columns);
431
+ const key = `foreign key ${row.constraint} on ${row.child} (${columns.join(', ')}) references lifecycle table ${row.parent} `
432
+ + `with ON DELETE ${ON_DELETE[String(row.action)] ?? row.action}`;
433
+ const drop = `ALTER TABLE ${quoteLabel(String(row.child))} DROP CONSTRAINT ${ident(String(row.constraint))};`;
434
+ if (!tables.includes(String(row.child))) {
435
+ findings.push(`${key}, but ${row.child} is not a lifecycle table: only a lifecycle table may hold a foreign key into a lifecycle table. `
436
+ + `Fix: ${applyFirst}${applyFirst ? 'make' : 'Make'} ${row.child} a lifecycle model and declare the dependent on ${row.parent}, or refer to it by value and drop the key: ${drop}`);
437
+ continue;
438
+ }
439
+ if (columns.length === 1 && declared.has(JSON.stringify([row.child, columns[0], row.parent]))) continue;
440
+ findings.push(`${key}, and no model declares what deleting a ${row.parent} row does to it: soft delete never consults it, `
441
+ + `so destroy would defer or skip the row. Fix: ${applyFirst}${applyFirst ? 'declare' : 'Declare'} it as a hasMany dependent on ${row.parent}, or drop the key: ${drop}`
442
+ + (unrecorded.has(String(row.parent)) ? ` The declared layer for ${row.parent} carries no nullify record, so the sending CLI may predate it: `
443
+ + 'if the models declare this key nullify, upgrade the CLI (@everystack/cli) and re-run.' : ''));
444
+ }
445
+
446
+ return findings;
447
+ }
448
+
449
+ /**
450
+ * A lifecycle finding raised around the derived layer: by the pre-exposure gate (nothing derived
451
+ * was written) or by the post-check (the objects are live). Its message is the whole report, one
452
+ * line of head and one line per finding, so a caller prints it as is.
453
+ */
454
+ export class LifecycleFindingsError extends Error {}
455
+
456
+ /**
457
+ * The pre-exposure gate: rule 5 over the DECLARED owners, before the reconcile creates anything.
458
+ * Each declared trigger on a lifecycle table whose function is declared is judged by the owner the
459
+ * reconcile will give that function (reconcileOwner: the declared owner, else the role the
460
+ * reconcile runs as). A declared owner that does not exist live is a finding. A trigger whose
461
+ * function is not declared is left to the post-check, which reads the live owner.
462
+ *
463
+ * `session` reads the builder: it runs on the connection the reconcile writes on. On a stage that
464
+ * is db:session, the operator connection; db:query (`runner`) may run on a reader
465
+ * (QUERY_DATABASE_URL), whose CURRENT_USER is not the role that will own the function. `runner`
466
+ * reads only pg_roles, which is the same on any connection.
467
+ *
468
+ * `stateApplied`: the table and column changes have committed, so the refusal says so.
469
+ */
470
+ export async function lifecycleDeclaredOwnerRefusal(
471
+ runner: QueryRunner,
472
+ session: SessionRunner,
473
+ declared: readonly SourceObject[],
474
+ models: readonly ModelDescriptor[],
475
+ options: { stateApplied: boolean },
476
+ ): Promise<string | null> {
477
+ const { lifecycleTables, applicationRoles } = catalogInputsFromModels(models);
478
+ const tables = new Set(lifecycleTables);
479
+ const triggers = declared.flatMap((object) => {
480
+ if (object.kind !== 'trigger' || !object.table) return [];
481
+ const { schema, name } = parseQualified(object.table);
482
+ const fn = tables.has(`${schema}.${name}`) ? triggerFunctionOf(object, declared) : undefined;
483
+ return fn ? [{ trigger: object.name, table: `${schema}.${name}`, fn }] : [];
484
+ });
485
+ if (triggers.length === 0) return null;
486
+ const [slot] = await session(['SELECT CURRENT_USER::text AS builder']);
487
+ if (isSessionError(slot) || slot?.[0]?.builder == null) throw new Error('could not read the role the reconcile runs as (CURRENT_USER)');
488
+ const builder = String(slot[0].builder);
489
+ const judged = triggers.map(t => ({ ...t, owner: reconcileOwner(t.fn, builder) }));
490
+ const owners = [...new Set(judged.map(t => t.owner))].sort();
491
+ const rows = await runner(`WITH ${sideCte(applicationRoles)}
492
+ SELECT d.name AS owner, o.oid IS NOT NULL AS present,
493
+ ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.oid OR ${reaches('s.oid', 'o.oid')} ORDER BY 1) AS via
494
+ FROM pg_catalog.unnest(${textArray(owners)}) AS d(name) LEFT JOIN pg_catalog.pg_roles o ON o.rolname = d.name`);
495
+ const byOwner = new Map(rows.map(row => [String(row.owner), { present: truthy(row.present), via: textList(row.via) }]));
496
+ const findings = judged.flatMap((t) => {
497
+ const owner = byOwner.get(t.owner);
498
+ if (!owner?.present) {
499
+ return [`trigger ${t.trigger} on ${t.table} would run ${t.fn.identity}, owned by ${t.owner}, which does not exist. `
500
+ + `Fix: create ${t.owner} as a role no application role can act as, or declare an existing such role as ${t.fn.identity}'s owner in db/models.`];
501
+ }
502
+ if (owner.via.length === 0) return [];
503
+ return [triggerOwnerFinding({ trigger: t.trigger, table: t.table, function: t.fn.identity, owner: t.owner, via: owner.via }, true)];
504
+ });
505
+ if (findings.length === 0) return null;
506
+ return `${options.stateApplied ? 'tables and columns applied; ' : ''}derived layer NOT applied: ${countFindings(findings.length)}; `
507
+ + `fix each, then re-run:\n${findingLines(findings)}`;
508
+ }
509
+
510
+ /**
511
+ * The post-check: the live catalog check again, after the derived layer landed. The reconcile
512
+ * commits what it created, so the report never says refused or rolled back: the objects are live,
513
+ * and the next apply's preflight refuses until each finding is fixed.
514
+ *
515
+ * It reads on `session`, the reconcile's own connection: on a stage, db:session (the operator
516
+ * connection). db:query may read a replica the reconcile's commit has not reached yet, and pass.
517
+ */
518
+ export async function lifecyclePostApplyRefusal(session: SessionRunner, models: readonly ModelDescriptor[]): Promise<string | null> {
519
+ if (!models.some(model => model.lifecycle === true)) return null;
520
+ const read: QueryRunner = async (q) => {
521
+ const [slot] = await session([q]);
522
+ if (isSessionError(slot)) throw new Error(slot.message);
523
+ return slot ?? [];
524
+ };
525
+ const findings = await lifecycleCatalogFindings(read, catalogInputsFromModels(models));
526
+ if (findings.length === 0) return null;
527
+ return `${countFindings(findings.length)} after reconcile; the objects are live; the next apply refuses until fixed:\n${findingLines(findings)}`;
528
+ }
529
+
530
+ /** An older CLI sends no applicationRoles, so the catalog check cannot run: fail closed. */
531
+ export const LIFECYCLE_ROLES_ABSENT_REFUSAL = 'this db:reconcile payload carries lifecycle-owned objects but no application roles, '
532
+ + 'so the lifecycle catalog check cannot run. The sending CLI predates the check: upgrade the CLI (@everystack/cli) and re-run.';
533
+
534
+ /** The declared dependents are read from the transition functions' manifests: one without fails closed. */
535
+ export const LIFECYCLE_MANIFEST_ABSENT_REFUSAL = (functions: readonly string[]): string => `this db:reconcile payload's lifecycle transition `
536
+ + `function${functions.length === 1 ? '' : 's'} ${functions.join(', ')} carr${functions.length === 1 ? 'ies' : 'y'} no manifest, so the declared dependents `
537
+ + 'cannot be read and the lifecycle catalog check cannot run. Compile the declared layer from the models with this CLI and re-run.';
538
+
539
+ /** Generated by an older CLI (0.4.101's per-row restrict pair); a payload may still carry one it is replacing. */
540
+ const RETIRED_LIFECYCLE_ROLES = ['restrict'] as const;
541
+
542
+ /**
543
+ * The lifecycle tables, read from the declared derived layer: each `<t>_lifecycle_transition`
544
+ * trigger names its table. Every lifecycle-owned object must belong to one of those tables by its
545
+ * exact generated name in the table's schema (lifecycleRole, or a retired role). Never by prefix:
546
+ * every name of a table `t_lifecycle_x` starts with `t_lifecycle_`, so a prefix would hand them to
547
+ * `t`. One that belongs to none is a contradiction, refused: the check would otherwise run on fewer
548
+ * tables than the objects it arms. Never read from the live catalog, which would be a second
549
+ * derivation that drifts.
550
+ */
551
+ export function lifecycleTablesFromDeclared(declared: readonly SourceObject[]): { tables: string[] } | { refusal: string } {
552
+ const tables = [...new Set(declared.flatMap(object => {
553
+ if (object.kind !== 'trigger' || !object.table) return [];
554
+ const { schema, name } = parseQualified(object.table);
555
+ return object.name === `${name}_lifecycle_transition` ? [`${schema}.${name}`] : [];
556
+ }))].sort();
557
+ const orphans = declared.filter(object => object.owner === LIFECYCLE_OWNER_ROLE
558
+ && !tables.some(table => table.startsWith(`${object.schema}.`) && (lifecycleRole(`${object.schema}.${object.name}`, table) !== undefined
559
+ || RETIRED_LIFECYCLE_ROLES.some(role => `${object.schema}.${object.name}` === `${table}_lifecycle_${role}`))))
560
+ .map(object => object.identity).sort();
561
+ if (orphans.length === 0) return { tables };
562
+ return {
563
+ refusal: `the declared derived layer contradicts itself: ${orphans.join(', ')} ${orphans.length === 1 ? 'is' : 'are'} owned by `
564
+ + `${LIFECYCLE_OWNER_ROLE}, but no declared <table>_lifecycle_transition trigger names ${orphans.length === 1 ? 'its table' : 'their tables'}. `
565
+ + 'The lifecycle tables are read from those triggers, so the catalog check cannot cover these objects. '
566
+ + 'Compile the declared layer from the models with this CLI and re-run.',
567
+ };
568
+ }
569
+
570
+ /**
571
+ * db:reconcile's lifecycle gate, local and in the ops Lambda alike: the destroy_log shape, the
572
+ * declared layer's own consistency, the application roles' presence (fail closed), then the
573
+ * catalog check. Null when nothing declared is lifecycle-owned. `applicationRoles` is `unknown`
574
+ * because the ops Lambda reads it off the wire.
575
+ */
576
+ export async function lifecycleReconcileRefusal(
577
+ runner: QueryRunner,
578
+ declared: readonly SourceObject[],
579
+ options: { applicationRoles?: unknown; lifecycleRole?: string } = {},
580
+ ): Promise<string | null> {
581
+ if (!declared.some(object => object.owner === LIFECYCLE_OWNER_ROLE)) return null;
582
+ const [log] = await runner(LIFECYCLE_LOG_SQL);
583
+ const shape = lifecycleLogRowRefusal(log);
584
+ if (shape) return shape;
585
+ const derived = lifecycleTablesFromDeclared(declared);
586
+ if ('refusal' in derived) return derived.refusal;
587
+ const roles = options.applicationRoles;
588
+ if (!Array.isArray(roles) || roles.some(role => typeof role !== 'string')) return LIFECYCLE_ROLES_ABSENT_REFUSAL;
589
+ const unreadable = declared.filter(object => object.owner === LIFECYCLE_OWNER_ROLE && object.name.endsWith('_lifecycle_transition')
590
+ && !lifecycleManifestOf(object)).map(object => object.identity).sort();
591
+ if (unreadable.length > 0) return LIFECYCLE_MANIFEST_ABSENT_REFUSAL(unreadable);
592
+ return lifecycleCatalogRefusal(runner, { lifecycleTables: derived.tables, applicationRoles: roles, dependentKeys: dependentKeysFromDeclared(declared),
593
+ nullifyUnrecorded: nullifyUnrecordedFromDeclared(declared) },
594
+ { lifecycleRole: options.lifecycleRole });
595
+ }
596
+
597
+ // Live = fires in an ordinary session ('O' origin/local, 'A' always; not 'R' replica-only or 'D')
598
+ // and runs the table's own generated transition function, not merely a trigger with its name.
599
+ const LIVE_TRANSITIONS_SQL = `SELECT n.nspname || '.' || c.relname AS "table" FROM pg_catalog.pg_trigger g
600
+ JOIN pg_catalog.pg_class c ON c.oid = g.tgrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
601
+ JOIN pg_catalog.pg_proc p ON p.oid = g.tgfoid
602
+ WHERE NOT g.tgisinternal AND g.tgenabled IN ('O', 'A') AND g.tgname = c.relname || '_lifecycle_transition'
603
+ AND p.pronamespace = c.relnamespace AND p.proname = c.relname || '_lifecycle_transition'`;
604
+
605
+ /**
606
+ * --state-only skips the derived layer, so a lifecycle table whose transition trigger is not live
607
+ * would keep its DELETE policies with nothing turning a DELETE into a soft delete.
608
+ */
609
+ export async function lifecycleStateOnlyRefusal(runner: QueryRunner, models: readonly ModelDescriptor[]): Promise<string | null> {
610
+ const lifecycle = models.filter(model => model.lifecycle === true);
611
+ if (lifecycle.length === 0) return null;
612
+ const live = new Set((await runner(LIVE_TRANSITIONS_SQL)).map(row => row.table));
613
+ const missing = lifecycle.map(model => `${model.schema}.${model.table}`).filter(table => !live.has(table)).sort();
614
+ if (missing.length === 0) return null;
615
+ return `--state-only skips the lifecycle triggers, and ${missing.join(', ')} ${missing.length === 1 ? 'has' : 'have'} no live transition trigger, `
616
+ + 'so a DELETE there would remove rows for real. Run db:apply without --state-only.';
617
+ }