@everystack/cli 0.4.47 → 0.4.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/cli/authz-derive.ts +105 -6
- package/src/cli/authz-lint.ts +61 -0
- package/src/cli/commands/db-check.ts +12 -3
- package/src/cli/derived-compile.ts +81 -12
- package/src/cli/derived-grants.ts +10 -3
- package/src/cli/derived-introspect.ts +126 -11
- package/src/cli/derived-lint.ts +53 -0
- package/src/cli/derived-render.ts +178 -33
- package/src/cli/derived-source.ts +6 -19
- package/src/cli/parse-flags.ts +21 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.49",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"structured-headers": "1.0.1",
|
|
110
110
|
"tsx": "4.21.0",
|
|
111
111
|
"typescript": "5.9.3",
|
|
112
|
-
"@everystack/model": "0.4.
|
|
112
|
+
"@everystack/model": "0.4.13"
|
|
113
113
|
},
|
|
114
114
|
"peerDependencies": {
|
|
115
115
|
"@everystack/server": ">=0.4.0",
|
package/src/cli/authz-derive.ts
CHANGED
|
@@ -232,6 +232,32 @@ function policiesFor(contract: TableContract, role: string, command: string): Po
|
|
|
232
232
|
);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Does this grant actually give the role rows?
|
|
237
|
+
*
|
|
238
|
+
* **The rule, in one line: an ability needs BOTH a grant and a covering permissive policy.**
|
|
239
|
+
*
|
|
240
|
+
* Postgres checks the grant and THEN the policy. Either half alone gives nothing, and the two
|
|
241
|
+
* failures look identical in a diff:
|
|
242
|
+
*
|
|
243
|
+
* - policy, no grant → dead code. Declaring it ADDs a grant. (handled in deriveAbilities)
|
|
244
|
+
* - grant, no policy → dead rows. Declaring it ADDs a policy. (this predicate)
|
|
245
|
+
*
|
|
246
|
+
* The second half was fixed for `admin` first, because that is where a consumer hit it. The
|
|
247
|
+
* round-trip gate (integration/authz-roundtrip) then found the SAME defect on the read branch —
|
|
248
|
+
* `authenticated` holding SELECT with no policy rendered `can('read')`, compiling to
|
|
249
|
+
* `CREATE POLICY … USING (true)` against a database that has no such policy. Hence one predicate,
|
|
250
|
+
* used at every site that turns a grant into an ability, rather than a fix per role.
|
|
251
|
+
*
|
|
252
|
+
* A restrictive-only policy does not count: RESTRICTIVE policies subtract, so with no permissive
|
|
253
|
+
* policy to pass first the role still sees nothing.
|
|
254
|
+
*/
|
|
255
|
+
function effective(contract: TableContract, role: string, priv: string): boolean {
|
|
256
|
+
if (!granted(contract, role, priv)) return false;
|
|
257
|
+
if (!contract.rls.enabled) return true;
|
|
258
|
+
return policiesFor(contract, role, priv).some((p) => p.permissive);
|
|
259
|
+
}
|
|
260
|
+
|
|
235
261
|
/** A sql`…` fragment literal, or null when the predicate is vacuous. */
|
|
236
262
|
function sqlFragment(pred: string | null): string | null {
|
|
237
263
|
if (!pred) return null;
|
|
@@ -303,10 +329,72 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
303
329
|
const abilities: string[] = [];
|
|
304
330
|
const notes: string[] = [];
|
|
305
331
|
const table = contract.table;
|
|
332
|
+
/** Live CRUD grants no policy covers — declared as grants so the plan neither invents a
|
|
333
|
+
* policy (a widening) nor revokes a real privilege (a narrowing). */
|
|
334
|
+
const deadGrants: Record<string, string[]> = {};
|
|
306
335
|
|
|
307
336
|
// --- the admin bypass: one role holding every privilege ------------------------------
|
|
308
|
-
|
|
309
|
-
|
|
337
|
+
//
|
|
338
|
+
// The grant is only HALF the question. Postgres checks the grant and THEN the policy, so a
|
|
339
|
+
// role holding all four privileges with no policy covering it reads zero rows. Rendering
|
|
340
|
+
// `can('manage')` there compiles to a GRANT **plus** `CREATE POLICY … TO admin USING (true)`,
|
|
341
|
+
// handing admin a table the database currently withholds — a WIDENING emitted by the adoption
|
|
342
|
+
// path itself. That is the same trap the policy-without-grant branch below prevents, reached
|
|
343
|
+
// from the other side, and it shipped: three tables at one adopter, one of the three
|
|
344
|
+
// statements in their plan, refused by them.
|
|
345
|
+
//
|
|
346
|
+
// The grants are still declared — via `privileges` below — so the plan neither invents a
|
|
347
|
+
// policy nor revokes a real grant. Do NOT "simplify" this to dropping the grants: a grantee
|
|
348
|
+
// with rolbypassrls holds them live (our introspection checks ownership, not rolbypassrls),
|
|
349
|
+
// and they re-arm the day anyone runs ALTER TABLE … DISABLE ROW LEVEL SECURITY.
|
|
350
|
+
//
|
|
351
|
+
// No owner ambiguity: GRANTS_SQL excludes the table owner, so a role present in `grants` is a
|
|
352
|
+
// non-owner, and RLS binds non-owners whenever `enabled` is true. `forced` is irrelevant here.
|
|
353
|
+
const CRUD = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'];
|
|
354
|
+
const adminGrants = CRUD.filter((p) => granted(contract, 'admin', p));
|
|
355
|
+
const adminLive = adminGrants.filter((p) => effective(contract, 'admin', p));
|
|
356
|
+
const adminDead = adminGrants.filter((p) => !adminLive.includes(p));
|
|
357
|
+
|
|
358
|
+
if (adminLive.length === CRUD.length) {
|
|
359
|
+
abilities.push(`can('manage', { role: 'admin' })`);
|
|
360
|
+
} else {
|
|
361
|
+
// PARTIAL. `manage` means all four, so it would be a lie — but the privileges that ARE
|
|
362
|
+
// policied are live authorization, and rendering nothing for them makes the plan DROP the
|
|
363
|
+
// policy and REVOKE the grant. That narrowing is how the round-trip gate caught this case:
|
|
364
|
+
// an admin table policied for SELECT only lost both halves on the way through.
|
|
365
|
+
for (const priv of adminLive) {
|
|
366
|
+
const pol = policiesFor(contract, 'admin', priv).find((p) => p.permissive);
|
|
367
|
+
const frag = sqlFragment(pol?.using ?? null);
|
|
368
|
+
const verb = DML[priv];
|
|
369
|
+
abilities.push(
|
|
370
|
+
frag ? `can('${verb}', { role: 'admin', sql: ${frag} })` : `can('${verb}', { role: 'admin' })`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (adminDead.length) {
|
|
376
|
+
deadGrants.admin = adminDead;
|
|
377
|
+
notes.push(
|
|
378
|
+
`admin holds ${adminDead.join(', ')} but no policy covers admin —`,
|
|
379
|
+
);
|
|
380
|
+
notes.push(
|
|
381
|
+
` dead while RLS is on, unrestricted if RLS is ever disabled. Declared as a grant, not`,
|
|
382
|
+
);
|
|
383
|
+
notes.push(
|
|
384
|
+
` an ability: an ability would ADD the policy the database does not have.`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// The same accounting for the app roles. A grant they hold with no policy behind it is
|
|
389
|
+
// declared (so the plan does not REVOKE a live privilege) and never rendered as an ability
|
|
390
|
+
// (so the plan does not CREATE the policy the database is missing).
|
|
391
|
+
for (const role of ['authenticated', 'anon']) {
|
|
392
|
+
const dead = CRUD.filter((p) => granted(contract, role, p) && !effective(contract, role, p));
|
|
393
|
+
if (!dead.length) continue;
|
|
394
|
+
deadGrants[role] = dead;
|
|
395
|
+
notes.push(`${role} holds ${dead.join(', ')} but no policy covers ${role} —`);
|
|
396
|
+
notes.push(` the grant is live in the catalog and gives no rows; declared as a grant, not an ability.`);
|
|
397
|
+
}
|
|
310
398
|
|
|
311
399
|
// --- policed but not granted at the table level ---------------------------------------
|
|
312
400
|
//
|
|
@@ -340,8 +428,11 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
340
428
|
}
|
|
341
429
|
|
|
342
430
|
// --- reads ---------------------------------------------------------------------------
|
|
343
|
-
|
|
344
|
-
|
|
431
|
+
// `effective`, not `granted`: a SELECT grant with no policy behind it renders can('read'),
|
|
432
|
+
// which compiles to CREATE POLICY … USING (true) — manufacturing a policy the database does
|
|
433
|
+
// not have. Same defect as the admin branch above; the round-trip gate found it here.
|
|
434
|
+
const anonRead = effective(contract, 'anon', 'SELECT');
|
|
435
|
+
const authedRead = effective(contract, 'authenticated', 'SELECT');
|
|
345
436
|
|
|
346
437
|
if (anonRead) {
|
|
347
438
|
// Public read. If the live policy narrows it (a soft-delete guard, a published flag),
|
|
@@ -465,7 +556,7 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
465
556
|
// --- writes --------------------------------------------------------------------------
|
|
466
557
|
for (const [command, action] of Object.entries(DML)) {
|
|
467
558
|
if (action === 'read') continue;
|
|
468
|
-
if (!
|
|
559
|
+
if (!effective(contract, 'authenticated', command)) continue;
|
|
469
560
|
|
|
470
561
|
const pol = policiesFor(contract, 'authenticated', command).find((p) => p.command !== 'ALL')
|
|
471
562
|
?? policiesFor(contract, 'authenticated', command)[0];
|
|
@@ -535,7 +626,15 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
535
626
|
notes.push(`no grants found — this table is internal (nothing reaches it through the data API).`);
|
|
536
627
|
}
|
|
537
628
|
|
|
538
|
-
|
|
629
|
+
// The beyond-CRUD privileges plus any live CRUD grant no policy covers. Both are "grants
|
|
630
|
+
// can() cannot say", for the same reason: `can()` decides a grant AND a policy together, and
|
|
631
|
+
// these have no policy to decide.
|
|
632
|
+
const privileges = deriveExtraPrivileges(contract);
|
|
633
|
+
for (const [role, privs] of Object.entries(deadGrants)) {
|
|
634
|
+
privileges[role] = [...new Set([...(privileges[role] ?? []), ...privs])].sort();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return { abilities, notes, unmappedRoles, privileges };
|
|
539
638
|
}
|
|
540
639
|
|
|
541
640
|
/** An object key, quoted only when the role name is not a bare JS identifier. */
|
package/src/cli/authz-lint.ts
CHANGED
|
@@ -46,3 +46,64 @@ export function findReadAuthzGaps(models: readonly ModelDescriptor[]): ReadAuthz
|
|
|
46
46
|
}
|
|
47
47
|
return gaps;
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Naked DML grants — the safety that used to live in the parser.
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A CRUD grant declared without the policy that gates it.
|
|
56
|
+
*
|
|
57
|
+
* `privileges` used to REFUSE the DML four at model-definition time, so that a grant could never
|
|
58
|
+
* be declared without its policy. That throw was removed because the invariant was vacuous for
|
|
59
|
+
* adoption: a live table can hold `GRANT SELECT TO admin` with no policy covering admin, and a
|
|
60
|
+
* language that cannot spell it makes such a database a permanent MISMATCH against
|
|
61
|
+
* `db:fingerprint` — so adopting one required mutating it first. Expressibility is total over the
|
|
62
|
+
* fingerprint's domain; the opinion belongs here instead.
|
|
63
|
+
*
|
|
64
|
+
* Severity is a WARNING, never fatal, and that is a considered narrowing of the original ruling.
|
|
65
|
+
* The ruling asked for an ERROR on a table without RLS, where a naked grant is an unrestricted
|
|
66
|
+
* table-wide privilege. That case cannot arise from models: `compileTableContract` emits
|
|
67
|
+
* `rls: { enabled: true }` UNCONDITIONALLY for every modeled table (authz-compile.ts:558). So a
|
|
68
|
+
* declared naked grant is always dead-on-arrival and only becomes live if someone later disables
|
|
69
|
+
* RLS on that table — a real risk, but a future one, and failing CI over a faithful rendering of
|
|
70
|
+
* a database the adopter is trying to adopt would make `db:pull` unusable on exactly the schemas
|
|
71
|
+
* it exists for.
|
|
72
|
+
*/
|
|
73
|
+
export interface NakedGrant {
|
|
74
|
+
schema: string;
|
|
75
|
+
table: string;
|
|
76
|
+
role: string;
|
|
77
|
+
privileges: string[];
|
|
78
|
+
message: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DML = new Set(['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Grants declared in `privileges` that carry no policy — reported for hand-authored and pulled
|
|
85
|
+
* models alike, because the shape is equally wrong to author and equally true to find.
|
|
86
|
+
*/
|
|
87
|
+
export function findNakedGrants(models: readonly ModelDescriptor[]): NakedGrant[] {
|
|
88
|
+
const out: NakedGrant[] = [];
|
|
89
|
+
for (const m of models) {
|
|
90
|
+
for (const role of Object.keys(m.privileges ?? {}).sort()) {
|
|
91
|
+
const dml = (m.privileges[role] ?? []).filter((p) => DML.has(p)).sort();
|
|
92
|
+
if (!dml.length) continue;
|
|
93
|
+
out.push({
|
|
94
|
+
schema: m.schema,
|
|
95
|
+
table: m.table,
|
|
96
|
+
role,
|
|
97
|
+
privileges: dml,
|
|
98
|
+
message:
|
|
99
|
+
`${m.schema}.${m.table}: '${role}' holds ${dml.join(', ')} as a grant with no policy beside it. ` +
|
|
100
|
+
`It is dead while RLS is on — the role reads zero rows — and becomes an unrestricted ` +
|
|
101
|
+
`table-wide privilege the day RLS is disabled or a broad policy is added. If this came from ` +
|
|
102
|
+
`db:pull it is a faithful reading of the database; decide whether to give it a policy ` +
|
|
103
|
+
`(can(...)) or revoke it. If you wrote it by hand, you almost certainly want can() instead, ` +
|
|
104
|
+
`which decides the grant and the policy together.`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
@@ -41,9 +41,11 @@ import { loadModels } from './db-generate.js';
|
|
|
41
41
|
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
|
|
42
42
|
import type { SequenceDescriptor } from '@everystack/model';
|
|
43
43
|
import type { SourceObject } from '../derived-source.js';
|
|
44
|
-
import { findReadAuthzGaps } from '../authz-lint.js';
|
|
44
|
+
import { findReadAuthzGaps, findNakedGrants } from '../authz-lint.js';
|
|
45
45
|
import { parseBaseline, renderBaseline, BASELINE_FILE } from '../authz-baseline.js';
|
|
46
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings, findPublicExecutableSecdef,
|
|
48
|
+
} from '../derived-lint.js';
|
|
47
49
|
import { step, success, fail, info, warn } from '../output.js';
|
|
48
50
|
|
|
49
51
|
// The role derivation moved to the shared builder (db-build) with brick
|
|
@@ -160,6 +162,13 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
160
162
|
}
|
|
161
163
|
}
|
|
162
164
|
|
|
165
|
+
// A CRUD grant declared with no policy beside it. WARN, never fatal: this is what a faithful
|
|
166
|
+
// db:pull of a brownfield database looks like, and failing CI over it would make the pull
|
|
167
|
+
// unusable on the schemas it exists for. See findNakedGrants for why it is not an error.
|
|
168
|
+
for (const naked of findNakedGrants(input.models)) {
|
|
169
|
+
findings.push({ level: 'warn', area: 'authz', message: naked.message });
|
|
170
|
+
}
|
|
171
|
+
|
|
163
172
|
// The adoption baseline, ARTIFACT ONLY.
|
|
164
173
|
//
|
|
165
174
|
// This check cannot see the threat and must not pretend to. The foreign grantees it
|
|
@@ -209,7 +218,7 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
209
218
|
for (const gap of derivedGaps) {
|
|
210
219
|
findings.push({ level: 'fail', area: 'authz', message: gap.message });
|
|
211
220
|
}
|
|
212
|
-
for (const warning of findMatviewSnapshotWarnings(input.derived)) {
|
|
221
|
+
for (const warning of [...findMatviewSnapshotWarnings(input.derived), ...findPublicExecutableSecdef(input.derived)]) {
|
|
213
222
|
findings.push({ level: 'warn', area: 'authz', message: warning.message });
|
|
214
223
|
}
|
|
215
224
|
if (derivedGaps.length === 0) {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import type {
|
|
21
21
|
ModelDescriptor, DerivedDescriptor, ViewDescriptor, MaterializedViewDescriptor,
|
|
22
|
-
FunctionDescriptor, SqlDescriptor, TriggerSpec, Ability, SetofReturn, DependsOnRef,
|
|
22
|
+
FunctionDescriptor, SqlDescriptor, TriggerSpec, TriggerDescriptor, Ability, SetofReturn, DependsOnRef,
|
|
23
23
|
} from '@everystack/model';
|
|
24
24
|
import { hashSourceContent, parseQualified, DECLARED_SOURCE_FILE, type Attachment, type SourceObject } from './derived-source.js';
|
|
25
25
|
import { isPlainGrantAttachment } from './derived-grants.js';
|
|
@@ -51,9 +51,21 @@ function refName(ref: SetofReturn['setof']): string {
|
|
|
51
51
|
// Grants — the same audience semantics as tables
|
|
52
52
|
// ---------------------------------------------------------------------------
|
|
53
53
|
|
|
54
|
-
/**
|
|
55
|
-
*
|
|
56
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Relation read grants: bare read → anon + authenticated (the table precedent);
|
|
56
|
+
* `{ role }` narrows; `{ columns }` scopes the SELECT to a column list.
|
|
57
|
+
*
|
|
58
|
+
* `privileges` is the second source — grants that EXIST, recorded by `db:pull` rather than
|
|
59
|
+
* chosen. It is what lets a brownfield view be rendered at all: the write grants a blanket
|
|
60
|
+
* `GRANT ALL ON ALL TABLES IN SCHEMA` left behind, and a `SELECT` to `PUBLIC`, are not
|
|
61
|
+
* expressible as read abilities, and emitting the object WITHOUT them would make the next
|
|
62
|
+
* generate plan a REVOKE of each one.
|
|
63
|
+
*/
|
|
64
|
+
function relationGrants(
|
|
65
|
+
target: string,
|
|
66
|
+
abilities: readonly Ability[],
|
|
67
|
+
privileges: Readonly<Record<string, string[]>> = {},
|
|
68
|
+
): Attachment[] {
|
|
57
69
|
const out: Attachment[] = [];
|
|
58
70
|
for (const a of abilities) {
|
|
59
71
|
const roles = a.condition.role ? a.condition.role : 'anon, authenticated';
|
|
@@ -62,17 +74,46 @@ function relationGrants(target: string, abilities: readonly Ability[]): Attachme
|
|
|
62
74
|
: '';
|
|
63
75
|
out.push({ kind: 'grant', sql: `GRANT SELECT${cols} ON ${target} TO ${roles}` });
|
|
64
76
|
}
|
|
77
|
+
// Grantee-sorted, privileges in the declared order the model already normalized, so a
|
|
78
|
+
// re-render of an unchanged database is byte-stable.
|
|
79
|
+
for (const grantee of Object.keys(privileges).sort()) {
|
|
80
|
+
out.push({ kind: 'grant', sql: `GRANT ${privileges[grantee].join(', ')} ON ${target} TO ${grantee}` });
|
|
81
|
+
}
|
|
65
82
|
return out;
|
|
66
83
|
}
|
|
67
84
|
|
|
68
|
-
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
|
|
72
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Function authz: `REVOKE ALL … FROM PUBLIC` (PostgreSQL grants EXECUTE to PUBLIC by
|
|
87
|
+
* default — the silent-open inverse of the RLS trap), then the declared grants.
|
|
88
|
+
*
|
|
89
|
+
* Two sources, and the distinction is the whole design: `abilities` are the INTENDED
|
|
90
|
+
* audience (`can('execute', { role })`), `privileges` are grants that EXIST, recorded by
|
|
91
|
+
* `db:pull` without claiming intent.
|
|
92
|
+
*
|
|
93
|
+
* **A declared `PUBLIC` privilege suppresses the revoke**, and that is not a loophole. The
|
|
94
|
+
* revoke is unconditional precisely because nobody could SAY "PUBLIC holds EXECUTE here";
|
|
95
|
+
* once a descriptor says it, revoking anyway would make a faithful pull of a live function
|
|
96
|
+
* plan a REVOKE the adopter never asked for — turning adoption into mutation. Declaring it
|
|
97
|
+
* is still a finding, raised where findings belong: db:check WARNs on a PUBLIC-executable
|
|
98
|
+
* SECURITY DEFINER function.
|
|
99
|
+
*/
|
|
100
|
+
function functionGrants(
|
|
101
|
+
signature: string,
|
|
102
|
+
abilities: readonly Ability[],
|
|
103
|
+
privileges: Readonly<Record<string, string[]>> = {},
|
|
104
|
+
): Attachment[] {
|
|
105
|
+
const declaresPublic = Object.keys(privileges).some((g) => g.toUpperCase() === 'PUBLIC');
|
|
106
|
+
const out: Attachment[] = declaresPublic
|
|
107
|
+
? []
|
|
108
|
+
: [{ kind: 'grant', sql: `REVOKE ALL ON FUNCTION ${signature} FROM PUBLIC` }];
|
|
73
109
|
for (const a of abilities) {
|
|
74
110
|
out.push({ kind: 'grant', sql: `GRANT EXECUTE ON FUNCTION ${signature} TO ${a.condition.role}` });
|
|
75
111
|
}
|
|
112
|
+
// Sorted so a re-render of an unchanged database is byte-stable — object key order is
|
|
113
|
+
// insertion order, and the introspection's row order is not a contract.
|
|
114
|
+
for (const grantee of Object.keys(privileges).sort()) {
|
|
115
|
+
out.push({ kind: 'grant', sql: `GRANT EXECUTE ON FUNCTION ${signature} TO ${grantee}` });
|
|
116
|
+
}
|
|
76
117
|
return out;
|
|
77
118
|
}
|
|
78
119
|
|
|
@@ -98,7 +139,7 @@ function renderView(v: ViewDescriptor): { sql: string; attachments: Attachment[]
|
|
|
98
139
|
sql: `CREATE VIEW ${target}${invoker} AS\n${v.as}`,
|
|
99
140
|
attachments: [
|
|
100
141
|
...commentAttachment('VIEW', target, v.comment),
|
|
101
|
-
...relationGrants(target, v.abilities),
|
|
142
|
+
...relationGrants(target, v.abilities, v.privileges ?? {}),
|
|
102
143
|
],
|
|
103
144
|
};
|
|
104
145
|
}
|
|
@@ -128,7 +169,7 @@ function renderMaterializedView(mv: MaterializedViewDescriptor): { sql: string;
|
|
|
128
169
|
attachments: [
|
|
129
170
|
...indexes,
|
|
130
171
|
...commentAttachment('MATERIALIZED VIEW', target, mv.comment),
|
|
131
|
-
...relationGrants(target, mv.abilities),
|
|
172
|
+
...relationGrants(target, mv.abilities, mv.privileges ?? {}),
|
|
132
173
|
],
|
|
133
174
|
};
|
|
134
175
|
}
|
|
@@ -166,7 +207,7 @@ function renderFunction(fn: FunctionDescriptor): { sql: string; attachments: Att
|
|
|
166
207
|
`AS ${tag}\n${fn.body}\n${tag}`,
|
|
167
208
|
attachments: [
|
|
168
209
|
...commentAttachment('FUNCTION', signature, fn.comment),
|
|
169
|
-
...functionGrants(signature, fn.abilities),
|
|
210
|
+
...functionGrants(signature, fn.abilities, fn.privileges ?? {}),
|
|
170
211
|
],
|
|
171
212
|
};
|
|
172
213
|
}
|
|
@@ -207,9 +248,22 @@ interface Node {
|
|
|
207
248
|
function derivedIdentity(d: DerivedDescriptor): string {
|
|
208
249
|
const { schema, name } = parseQualified(d.name);
|
|
209
250
|
if (d.kind === 'function') return functionIdentity(schema, name, d.args.map((a) => a.type));
|
|
251
|
+
// A trigger's name is unique only WITHIN its table — two tables may both carry
|
|
252
|
+
// `touch_updated_at` — so its identity is the owner's, plus the name.
|
|
253
|
+
if (d.kind === 'trigger') {
|
|
254
|
+
const o = triggerOwnerOf(d);
|
|
255
|
+
return `${o.schema}.${o.name}.${d.name}`;
|
|
256
|
+
}
|
|
210
257
|
return `${schema}.${name}`;
|
|
211
258
|
}
|
|
212
259
|
|
|
260
|
+
/** Where a standalone binding rides: a model is state (no derived identity), a view is not. */
|
|
261
|
+
function triggerOwnerOf(d: TriggerDescriptor): { schema: string; name: string; ownerIdentity: string | null } {
|
|
262
|
+
if ('table' in d.on) return { schema: d.on.schema, name: d.on.table, ownerIdentity: null };
|
|
263
|
+
const q = parseQualified(d.on.name);
|
|
264
|
+
return { schema: q.schema, name: q.name, ownerIdentity: `${q.schema}.${q.name}` };
|
|
265
|
+
}
|
|
266
|
+
|
|
213
267
|
/** What a bad ref IS, for the gate's message — null if the value is a usable object. */
|
|
214
268
|
function badRef(r: unknown): string | null {
|
|
215
269
|
if (r === undefined) return 'undefined';
|
|
@@ -233,6 +287,13 @@ function rejectBadRefs(models: readonly ModelDescriptor[], derived: readonly Der
|
|
|
233
287
|
hole('compileDerived', 'models', models);
|
|
234
288
|
hole('compileDerived', 'derived', derived);
|
|
235
289
|
for (const d of derived) {
|
|
290
|
+
// A trigger has no dependsOn — its two deps come free (the target, the execute
|
|
291
|
+
// function) — but its `on` is a ref like any other and a stale import must be loud.
|
|
292
|
+
if (d.kind === 'trigger') {
|
|
293
|
+
hole(`trigger '${d.name}'`, 'on', [d.on], false);
|
|
294
|
+
hole(`trigger '${d.name}'`, 'execute', [d.execute], false);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
236
297
|
hole(`${d.kind} '${d.name}'`, 'dependsOn', d.dependsOn ?? []);
|
|
237
298
|
if (d.kind === 'function' && typeof d.returns !== 'string') {
|
|
238
299
|
hole(`function '${d.name}'`, 'returns.setof', [d.returns.setof], false);
|
|
@@ -348,6 +409,14 @@ export function compileDerived(models: readonly ModelDescriptor[], derived: read
|
|
|
348
409
|
const q = parseQualified(v.name);
|
|
349
410
|
return { schema: q.schema, name: q.name, ownerIdentity: `${q.schema}.${q.name}`, triggers: v.triggers ?? [] };
|
|
350
411
|
}),
|
|
412
|
+
// Standalone bindings (defineTrigger) — the same unit, reached from the descriptor
|
|
413
|
+
// rather than through its target. Both spellings compile to an identical node, which
|
|
414
|
+
// is the point: the deprecated attached form keeps working while `db:pull` renders the
|
|
415
|
+
// standalone one (a binding on a model would make the generated module import the
|
|
416
|
+
// compute layer, which is a fatal cycle rather than a style preference).
|
|
417
|
+
...derived
|
|
418
|
+
.filter((d): d is TriggerDescriptor => d.kind === 'trigger')
|
|
419
|
+
.map((d) => ({ ...triggerOwnerOf(d), triggers: [d as TriggerSpec] })),
|
|
351
420
|
];
|
|
352
421
|
for (const owner of triggerOwners) {
|
|
353
422
|
for (const t of owner.triggers) {
|
|
@@ -38,7 +38,13 @@ export interface ParsedGrants {
|
|
|
38
38
|
// GRANT SELECT[ ("c1", "c2")] ON <target> TO r1[, r2…]
|
|
39
39
|
// GRANT EXECUTE ON FUNCTION <sig> TO r
|
|
40
40
|
// REVOKE ALL ON FUNCTION <sig> FROM PUBLIC
|
|
41
|
-
|
|
41
|
+
// The privilege slot is a LIST — `GRANT SELECT, INSERT ON x TO y` is one statement, and
|
|
42
|
+
// PostgreSQL's own grammar says so. It used to match a single word, so a multi-privilege
|
|
43
|
+
// grant parsed as no grant at all: the declared contract came back empty and the very next
|
|
44
|
+
// check planned a REVOKE of every privilege the object had just been given. That stayed
|
|
45
|
+
// latent only because the compiler happened to emit one privilege per statement; recording
|
|
46
|
+
// a relation's live ACL (which is naturally multi-privilege) walked straight into it.
|
|
47
|
+
const GRANT_RE = /^GRANT\s+([A-Z]+(?:\s*,\s*[A-Z]+)*)\s*(\([^)]*\))?\s+ON\s+(.+?)\s+TO\s+(.+)$/i;
|
|
42
48
|
const REVOKE_PUBLIC_RE = /^REVOKE\s+ALL\s+ON\s+(.+?)\s+FROM\s+PUBLIC$/i;
|
|
43
49
|
|
|
44
50
|
/**
|
|
@@ -72,14 +78,15 @@ export function parseGrantAttachments(attachments: readonly Attachment[]): Parse
|
|
|
72
78
|
}
|
|
73
79
|
const grant = a.sql.match(GRANT_RE);
|
|
74
80
|
if (!grant) continue;
|
|
75
|
-
const [,
|
|
81
|
+
const [, privilegeList, columns, onTarget, roleList] = grant;
|
|
76
82
|
if (columns) {
|
|
77
83
|
hasColumnGrants = true;
|
|
78
84
|
continue; // attacl territory — declared, applied at create, not drift-checked yet
|
|
79
85
|
}
|
|
80
86
|
target ??= onTarget.trim();
|
|
87
|
+
const privileges = privilegeList.split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
|
|
81
88
|
for (const role of roleList.split(',').map((r) => r.trim()).filter(Boolean)) {
|
|
82
|
-
(grants[role] ??= new Set()).add(privilege
|
|
89
|
+
for (const privilege of privileges) (grants[role] ??= new Set()).add(privilege);
|
|
83
90
|
}
|
|
84
91
|
}
|
|
85
92
|
|
|
@@ -45,6 +45,19 @@ export interface LiveObject {
|
|
|
45
45
|
rows?: number;
|
|
46
46
|
/** Trigger only: the (bare or schema-qualified) relation it rides — `DROP TRIGGER … ON` needs it. */
|
|
47
47
|
table?: string;
|
|
48
|
+
/** Trigger only: the owner's full `schema.table`, for joining to a model or a derived view. */
|
|
49
|
+
ownerIdentity?: string;
|
|
50
|
+
/** Trigger only (R2 pull rendering): the structured binding `defineTrigger` is rendered from.
|
|
51
|
+
* Absent when the introspection predates R2 — absent means "not measured", never "no WHEN". */
|
|
52
|
+
trg?: {
|
|
53
|
+
shape: TriggerShape;
|
|
54
|
+
/** `schema.name` of the trigger function. Trigger functions take no arguments. */
|
|
55
|
+
functionIdentity: string;
|
|
56
|
+
/** `UPDATE OF (cols)` in declaration order. */
|
|
57
|
+
updateOf?: string[];
|
|
58
|
+
/** The WHEN predicate as the catalog deparses it. */
|
|
59
|
+
when?: string;
|
|
60
|
+
};
|
|
48
61
|
/** Live ACLs (grantee → sorted privileges), owner excluded, function defaults expanded.
|
|
49
62
|
* Absent on triggers (they take no grants) and when the introspection predates B4. */
|
|
50
63
|
grants?: Record<string, string[]>;
|
|
@@ -198,13 +211,20 @@ ORDER BY n.nspname, t.relname, idx.indexrelid;
|
|
|
198
211
|
* lookups by MANAGED derived identities, so table-referenced edges never join it.
|
|
199
212
|
* System-schema referenced sides are excluded on both branches: a view reading
|
|
200
213
|
* pg_catalog is not an edge anyone declares.
|
|
214
|
+
*
|
|
215
|
+
* `::text` on the first branch's name columns is the same load-bearing cast as
|
|
216
|
+
* DERIVED_GRANTS_SQL: later branches put `proname(argtypes)` on both the dependent and
|
|
217
|
+
* the referenced side, and a UNION types each column from the FIRST branch, so a bare
|
|
218
|
+
* `relname` (`name`, 63 bytes) would truncate a long function signature. The edge would
|
|
219
|
+
* then name an identity that joins nothing, and the edge is silently dropped — a wrong
|
|
220
|
+
* rebuild order, and a `dependsOn` the pull never writes.
|
|
201
221
|
*/
|
|
202
222
|
export const DERIVED_DEPENDS_SQL = `
|
|
203
223
|
SELECT DISTINCT
|
|
204
|
-
dn.nspname AS dependent_schema,
|
|
205
|
-
dc.relname AS dependent_name,
|
|
206
|
-
rn.nspname AS referenced_schema,
|
|
207
|
-
rc.relname AS referenced_name
|
|
224
|
+
dn.nspname::text AS dependent_schema,
|
|
225
|
+
dc.relname::text AS dependent_name,
|
|
226
|
+
rn.nspname::text AS referenced_schema,
|
|
227
|
+
rc.relname::text AS referenced_name
|
|
208
228
|
FROM pg_depend d
|
|
209
229
|
JOIN pg_rewrite rw ON rw.oid = d.objid
|
|
210
230
|
JOIN pg_class dc ON dc.oid = rw.ev_class
|
|
@@ -282,13 +302,22 @@ WHERE d.classid = 'pg_proc'::regclass
|
|
|
282
302
|
* `LEFT JOIN pg_roles` + COALESCE('PUBLIC') keeps a dropped-role ACL from failing the
|
|
283
303
|
* read (the table path's offline-safety). `kind` disambiguates a relation and a
|
|
284
304
|
* function sharing a name.
|
|
305
|
+
*
|
|
306
|
+
* `::text` on the name-typed columns is load-bearing, not tidiness. `relname` is
|
|
307
|
+
* PostgreSQL's `name` type (63 bytes) and a UNION takes each column's type from the
|
|
308
|
+
* FIRST branch, so without the cast the function branch's `proname(argtypes)` text is
|
|
309
|
+
* coerced DOWN to `name` and silently truncated at 63. Every consumer keys on that
|
|
310
|
+
* identity, so a function with ~5 ordinary parameters lost its ACL outright: db:pull
|
|
311
|
+
* rendered no `can('execute')`, and diffObjectGrants — which unions the DECLARED and LIVE
|
|
312
|
+
* grantees — never saw the live grantee, so no REVOKE was planned and reconcile reported
|
|
313
|
+
* converged while the grant was still live.
|
|
285
314
|
*/
|
|
286
315
|
export const DERIVED_GRANTS_SQL = `
|
|
287
316
|
SELECT
|
|
288
|
-
n.nspname AS schema,
|
|
289
|
-
c.relname AS name,
|
|
290
|
-
'r' AS kind,
|
|
291
|
-
COALESCE(r.rolname, 'PUBLIC') AS grantee,
|
|
317
|
+
n.nspname::text AS schema,
|
|
318
|
+
c.relname::text AS name,
|
|
319
|
+
'r'::text AS kind,
|
|
320
|
+
COALESCE(r.rolname, 'PUBLIC')::text AS grantee,
|
|
292
321
|
array_agg(DISTINCT a.privilege_type ORDER BY a.privilege_type) AS privileges
|
|
293
322
|
FROM pg_class c
|
|
294
323
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
@@ -331,17 +360,83 @@ export const PROVENANCE_SQL = `
|
|
|
331
360
|
SELECT identity, src_hash, def_hash, to_jsonb(p) AS extra FROM everystack.derived_provenance p;
|
|
332
361
|
`.trim();
|
|
333
362
|
|
|
334
|
-
/**
|
|
335
|
-
*
|
|
363
|
+
/**
|
|
364
|
+
* The bits PostgreSQL packs into `pg_trigger.tgtype`
|
|
365
|
+
* (src/include/catalog/pg_trigger.h). Timing, event set and level all share one mask.
|
|
366
|
+
*/
|
|
367
|
+
export const TRIGGER_TYPE_BITS = {
|
|
368
|
+
row: 1, before: 2, insert: 4, delete: 8, update: 16, truncate: 32, instead: 64,
|
|
369
|
+
} as const;
|
|
370
|
+
|
|
371
|
+
/** The canonical event order — the model's own (`EVENT_ORDER` in model/src/derived.ts).
|
|
372
|
+
* Ordering by the MASK instead would render `update, insert` for one trigger and
|
|
373
|
+
* `insert, update` for another, and the two would hash differently while being equal. */
|
|
374
|
+
const TRIGGER_EVENT_ORDER = ['insert', 'update', 'delete', 'truncate'] as const;
|
|
375
|
+
|
|
376
|
+
export interface TriggerShape {
|
|
377
|
+
timing: 'before' | 'after' | 'insteadOf';
|
|
378
|
+
events: ('insert' | 'update' | 'delete' | 'truncate')[];
|
|
379
|
+
forEach: 'row' | 'statement';
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* `tgtype` → the shape `trigger()` declares. The bitmask is the ONLY structured source
|
|
384
|
+
* for a trigger's timing/events/level; `pg_get_triggerdef` is prose, and parsing prose
|
|
385
|
+
* back into structure is how a renderer starts disagreeing with the catalog.
|
|
386
|
+
*
|
|
387
|
+
* `instead` is checked before `before` because they are different fields sharing a mask:
|
|
388
|
+
* an INSTEAD OF trigger is never BEFORE, and reading the bits in the other order would
|
|
389
|
+
* silently render a view's INSTEAD OF trigger as a table-shaped BEFORE one.
|
|
390
|
+
*/
|
|
391
|
+
export function parseTriggerType(tgtype: number): TriggerShape {
|
|
392
|
+
const has = (bit: number): boolean => (tgtype & bit) !== 0;
|
|
393
|
+
const events = TRIGGER_EVENT_ORDER.filter((e) => has(TRIGGER_TYPE_BITS[e]));
|
|
394
|
+
if (events.length === 0) {
|
|
395
|
+
throw new Error(`trigger tgtype ${tgtype} has no event bits — PostgreSQL never writes that, so the read is wrong, not the database.`);
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
timing: has(TRIGGER_TYPE_BITS.instead) ? 'insteadOf' : has(TRIGGER_TYPE_BITS.before) ? 'before' : 'after',
|
|
399
|
+
events: [...events],
|
|
400
|
+
forEach: has(TRIGGER_TYPE_BITS.row) ? 'row' : 'statement',
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* User triggers (never internal/constraint machinery) with their canonical definition —
|
|
406
|
+
* the live side of the trigger reconcile. Compound identity: schema.table.name.
|
|
407
|
+
*
|
|
408
|
+
* The STRUCTURED half (`tgtype`, `update_of`, `when_expr`, the function identity) is what
|
|
409
|
+
* a `trigger()` binding is rendered from; the `definition` text stays for the def hash.
|
|
410
|
+
*
|
|
411
|
+
* **`when_expr` is scraped out of `pg_get_triggerdef`, and that is not laziness.**
|
|
412
|
+
* `pg_get_expr(t.tgqual, t.tgrelid)` — the obvious call — FAILS OUTRIGHT on any trigger
|
|
413
|
+
* carrying a WHEN clause that mentions both OLD and NEW:
|
|
414
|
+
*
|
|
415
|
+
* ERROR: expression contains variables of more than one relation
|
|
416
|
+
*
|
|
417
|
+
* A WHEN over two pseudo-relations is the common case (`WHEN (OLD.* IS DISTINCT FROM
|
|
418
|
+
* NEW.*)`), so the direct read is not merely lossy — it errors, and it errors on the
|
|
419
|
+
* whole query, taking every other trigger's row with it. `pg_get_triggerdef` renders the
|
|
420
|
+
* same expression correctly because it knows the OLD/NEW aliases. Verified against a live
|
|
421
|
+
* brownfield database, 2026-08-06.
|
|
422
|
+
*/
|
|
336
423
|
export const DERIVED_TRIGGERS_SQL = `
|
|
337
424
|
SELECT
|
|
338
425
|
n.nspname AS schema,
|
|
339
426
|
c.relname AS "table",
|
|
340
427
|
t.tgname AS name,
|
|
341
|
-
pg_get_triggerdef(t.oid) AS definition
|
|
428
|
+
pg_get_triggerdef(t.oid) AS definition,
|
|
429
|
+
t.tgtype::int AS tgtype,
|
|
430
|
+
fn.nspname::text || '.' || p.proname::text AS function_identity,
|
|
431
|
+
(SELECT array_agg(a.attname::text ORDER BY x.ord)
|
|
432
|
+
FROM unnest(t.tgattr::int2[]) WITH ORDINALITY AS x(attnum, ord)
|
|
433
|
+
JOIN pg_attribute a ON a.attrelid = t.tgrelid AND a.attnum = x.attnum) AS update_of,
|
|
434
|
+
substring(pg_get_triggerdef(t.oid) from ' WHEN [(](.*)[)] EXECUTE ') AS when_expr
|
|
342
435
|
FROM pg_trigger t
|
|
343
436
|
JOIN pg_class c ON c.oid = t.tgrelid
|
|
344
437
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
438
|
+
JOIN pg_proc p ON p.oid = t.tgfoid
|
|
439
|
+
JOIN pg_namespace fn ON fn.oid = p.pronamespace
|
|
345
440
|
WHERE NOT t.tgisinternal
|
|
346
441
|
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
347
442
|
AND n.nspname NOT LIKE 'pg_%'
|
|
@@ -409,6 +504,16 @@ export interface TriggerRow {
|
|
|
409
504
|
table: string;
|
|
410
505
|
name: string;
|
|
411
506
|
definition: unknown;
|
|
507
|
+
/** The structured half — all optional, because a pre-R2 introspection has none of it
|
|
508
|
+
* and an absent field must read as "not measured", never as "no WHEN clause". */
|
|
509
|
+
tgtype?: unknown;
|
|
510
|
+
/** `schema.name` of the trigger function. Trigger functions take no arguments, so the
|
|
511
|
+
* bare name identifies one — no signature needed, unlike every other function read. */
|
|
512
|
+
function_identity?: unknown;
|
|
513
|
+
/** `UPDATE OF (cols)`, column names in declaration order. NULL when unqualified. */
|
|
514
|
+
update_of?: unknown;
|
|
515
|
+
/** The WHEN predicate, scraped from pg_get_triggerdef — see DERIVED_TRIGGERS_SQL. */
|
|
516
|
+
when_expr?: unknown;
|
|
412
517
|
}
|
|
413
518
|
|
|
414
519
|
export interface GrantAclRow {
|
|
@@ -547,11 +652,21 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
|
547
652
|
const ownerIdentity = `${row.schema}.${row.table}`;
|
|
548
653
|
const identity = `${ownerIdentity}.${row.name}`;
|
|
549
654
|
const definition = String(row.definition ?? '');
|
|
655
|
+
const updateOf = pgTextArray(row.update_of);
|
|
550
656
|
objects.push({
|
|
551
657
|
kind: 'trigger', schema: row.schema, name: row.name, identity,
|
|
552
658
|
definition, indexes: [],
|
|
553
659
|
defHash: computeDefHash(definition, []),
|
|
554
660
|
table: row.schema === 'public' ? row.table : ownerIdentity,
|
|
661
|
+
ownerIdentity,
|
|
662
|
+
...(row.tgtype != null && row.function_identity != null ? {
|
|
663
|
+
trg: {
|
|
664
|
+
shape: parseTriggerType(Number(row.tgtype)),
|
|
665
|
+
functionIdentity: String(row.function_identity),
|
|
666
|
+
...(updateOf.length ? { updateOf } : {}),
|
|
667
|
+
...(row.when_expr != null ? { when: String(row.when_expr) } : {}),
|
|
668
|
+
},
|
|
669
|
+
} : {}),
|
|
555
670
|
});
|
|
556
671
|
if (relationIdentities.has(ownerIdentity)) {
|
|
557
672
|
triggerEdges.push({ dependent: identity, referenced: ownerIdentity });
|
package/src/cli/derived-lint.ts
CHANGED
|
@@ -132,11 +132,32 @@ export function findDerivedReadGaps(derived: readonly DerivedDescriptor[]): Deri
|
|
|
132
132
|
* is privileged code with an undeclared audience — callable by nobody today (PUBLIC is
|
|
133
133
|
* revoked unconditionally) and by whoever gets a grant tomorrow, with no declaration to
|
|
134
134
|
* review. Invoker functions may stay dark: unprivileged, owner-callable, honest.
|
|
135
|
+
*
|
|
136
|
+
* TRIGGER FUNCTIONS ARE EXEMPT, and the exemption is structural rather than a courtesy.
|
|
137
|
+
* A trigger function has no caller role to declare: PostgreSQL performs no EXECUTE check
|
|
138
|
+
* on the invoking role when a trigger fires, so its audience is the table event, not a
|
|
139
|
+
* grantee. Live ones therefore carry no EXECUTE grant, and the gate could only be
|
|
140
|
+
* satisfied by declaring one — which compiles to a GRANT the database never had. That is
|
|
141
|
+
* the same widening this module's read/manage siblings exist to prevent, arriving on the
|
|
142
|
+
* function branch, and it made a faithful pull of a SECDEF trigger function unshippable:
|
|
143
|
+
* the adopter's only way past db:check was to widen their own database.
|
|
144
|
+
*
|
|
145
|
+
* The discriminator is `returns === 'trigger'` — the return type, never the name. It is
|
|
146
|
+
* the same fact `trigger()` already relies on (model/src/derived.ts, which refuses an
|
|
147
|
+
* `execute:` whose function does not return `trigger`), so the two agree by construction.
|
|
148
|
+
*
|
|
149
|
+
* A RECORDED grant counts as a declared caller too. `privileges: { PUBLIC: ['EXECUTE'] }`
|
|
150
|
+
* is `db:pull` saying "the database grants this" — the audience IS declared and reviewable,
|
|
151
|
+
* it simply was not chosen by the author. The gap gate is about undeclared audience, so it
|
|
152
|
+
* stops firing; the objection moves to `findPublicExecutableSecdef` below, which is a
|
|
153
|
+
* warning. Abilities are opinion, privileges are fact, and the linter holds the opinions.
|
|
135
154
|
*/
|
|
136
155
|
export function findSecdefExecuteGaps(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
|
|
137
156
|
const gaps: DerivedAuthzGap[] = [];
|
|
138
157
|
for (const d of derived) {
|
|
139
158
|
if (d.kind !== 'function' || d.security !== 'definer' || d.abilities.length > 0) continue;
|
|
159
|
+
if (d.returns === 'trigger') continue;
|
|
160
|
+
if (Object.keys(d.privileges ?? {}).length > 0) continue;
|
|
140
161
|
const identity = identityOf(d);
|
|
141
162
|
gaps.push({
|
|
142
163
|
identity,
|
|
@@ -148,6 +169,38 @@ export function findSecdefExecuteGaps(derived: readonly DerivedDescriptor[]): De
|
|
|
148
169
|
return gaps;
|
|
149
170
|
}
|
|
150
171
|
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Gate: SECURITY DEFINER executable by PUBLIC (db:check warn)
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Privileged code any role can call. This is where the objection to a PUBLIC EXECUTE grant
|
|
178
|
+
* lives now that the grant is spellable — a WARN, not a failure, because on an adopted
|
|
179
|
+
* database it is a faithful report of what is already true, and failing CI on a faithful
|
|
180
|
+
* pull would make `db:pull` unusable on exactly the schemas it exists for. (Same call, and
|
|
181
|
+
* the same reasoning, as `findNakedGrants` on the table side.)
|
|
182
|
+
*
|
|
183
|
+
* Only DEFINER functions: an invoker function executes with the caller's own rights, so
|
|
184
|
+
* PUBLIC EXECUTE on one grants no authority the caller did not already have.
|
|
185
|
+
*/
|
|
186
|
+
export function findPublicExecutableSecdef(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
|
|
187
|
+
const warnings: DerivedAuthzGap[] = [];
|
|
188
|
+
for (const d of derived) {
|
|
189
|
+
if (d.kind !== 'function' || d.security !== 'definer') continue;
|
|
190
|
+
if (d.returns === 'trigger') continue;
|
|
191
|
+
if (!Object.keys(d.privileges ?? {}).some((g) => g.toUpperCase() === 'PUBLIC')) continue;
|
|
192
|
+
const identity = identityOf(d);
|
|
193
|
+
warnings.push({
|
|
194
|
+
identity,
|
|
195
|
+
message:
|
|
196
|
+
`${identity} is SECURITY DEFINER and executable by PUBLIC — it runs with its owner's rights and every ` +
|
|
197
|
+
`role can call it, including anon. This is recorded as live fact, not a choice: confirm the body is safe ` +
|
|
198
|
+
`for an untrusted caller, then narrow it to can('execute', { role: '…' }) and drop the PUBLIC privilege.`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return warnings;
|
|
202
|
+
}
|
|
203
|
+
|
|
151
204
|
// ---------------------------------------------------------------------------
|
|
152
205
|
// Gate: matview snapshot over row-scoped sources (db:check warn)
|
|
153
206
|
// ---------------------------------------------------------------------------
|
|
@@ -77,26 +77,45 @@ function tsTemplate(body: string): string {
|
|
|
77
77
|
return body.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/** An object key, bare when it is a valid identifier and quoted when it is not — a role
|
|
81
|
+
* name is a PostgreSQL identifier and may hold characters JavaScript will not take bare. */
|
|
82
|
+
function tsKey(s: string): string {
|
|
83
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s) ? s : tsString(s);
|
|
84
|
+
}
|
|
85
|
+
|
|
80
86
|
function tsString(s: string): string {
|
|
81
87
|
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
82
88
|
}
|
|
83
89
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Split a relation's live ACL into what `abilities` can SAY and what must be RECORDED.
|
|
92
|
+
*
|
|
93
|
+
* A grantee whose privileges are exactly `SELECT`, and which is a named app role, is an
|
|
94
|
+
* intended read audience — `can('read')`. Everything else is fact without intent:
|
|
95
|
+
* `PUBLIC` (not a role the ability vocabulary addresses) and any privilege beyond SELECT
|
|
96
|
+
* (write grants, REFERENCES/TRIGGER/TRUNCATE). Those become `privileges`.
|
|
97
|
+
*
|
|
98
|
+
* Splitting rather than rejecting is the whole of R1: previously ANY inexpressible grant
|
|
99
|
+
* discarded the entire object, body included.
|
|
100
|
+
*/
|
|
101
|
+
function splitRelationGrants(
|
|
102
|
+
grants: Record<string, string[]>,
|
|
103
|
+
): { abilities: string[]; privileges: Record<string, string[]> } {
|
|
104
|
+
const readRoles = new Set<string>();
|
|
105
|
+
const privileges: Record<string, string[]> = {};
|
|
106
|
+
for (const [grantee, privs] of Object.entries(grants)) {
|
|
107
|
+
const selectOnly = privs.length === 1 && privs[0] === 'SELECT';
|
|
108
|
+
if (selectOnly && grantee.toUpperCase() !== 'PUBLIC') readRoles.add(grantee);
|
|
109
|
+
else privileges[grantee] = [...privs].sort();
|
|
90
110
|
}
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
set.delete('authenticated');
|
|
111
|
+
const abilities: string[] = [];
|
|
112
|
+
if (readRoles.has('anon') && readRoles.has('authenticated')) {
|
|
113
|
+
abilities.push(`can('read')`);
|
|
114
|
+
readRoles.delete('anon');
|
|
115
|
+
readRoles.delete('authenticated');
|
|
97
116
|
}
|
|
98
|
-
for (const role of [...
|
|
99
|
-
return
|
|
117
|
+
for (const role of [...readRoles].sort()) abilities.push(`can('read', { role: ${tsString(role)} })`);
|
|
118
|
+
return { abilities, privileges };
|
|
100
119
|
}
|
|
101
120
|
|
|
102
121
|
/**
|
|
@@ -202,6 +221,32 @@ function renderTableIndexBuilder(indexdef: string): string | null {
|
|
|
202
221
|
return s;
|
|
203
222
|
}
|
|
204
223
|
|
|
224
|
+
/**
|
|
225
|
+
* A collision-free variable for a trigger binding, chosen deterministically so a re-pull of
|
|
226
|
+
* an unchanged database is byte-stable. In order: the bare name, then `<name>Trigger`, then
|
|
227
|
+
* owner-qualified, then owner-qualified + `Trigger`. The last step cannot collide —
|
|
228
|
+
* a trigger name is unique within its table, so table + name is unique in the schema.
|
|
229
|
+
*/
|
|
230
|
+
function uniqueVar(base: string, o: LiveObject, used: Set<string>): string {
|
|
231
|
+
const ownerName = (o.table ?? '').split('.').pop() ?? '';
|
|
232
|
+
for (const candidate of [
|
|
233
|
+
base,
|
|
234
|
+
`${base}Trigger`,
|
|
235
|
+
toCamelCase(`${ownerName}_${o.name}`),
|
|
236
|
+
toCamelCase(`${ownerName}_${o.name}_trigger`),
|
|
237
|
+
]) {
|
|
238
|
+
if (!used.has(candidate)) {
|
|
239
|
+
used.add(candidate);
|
|
240
|
+
return candidate;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Unreachable on a real catalog; a counter beats throwing on a name.
|
|
244
|
+
let i = 2;
|
|
245
|
+
while (used.has(`${base}Trigger${i}`)) i += 1;
|
|
246
|
+
used.add(`${base}Trigger${i}`);
|
|
247
|
+
return `${base}Trigger${i}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
205
250
|
export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRenderOptions): DerivedRenderResult {
|
|
206
251
|
const warnings: string[] = [];
|
|
207
252
|
const lines: string[] = [];
|
|
@@ -227,10 +272,12 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
227
272
|
|
|
228
273
|
// -- emission order: dependencies first (Kahn over derived→derived edges) ---
|
|
229
274
|
|
|
275
|
+
// Triggers are held out of the topological pass and emitted LAST (see the trigger block
|
|
276
|
+
// at the end). A binding needs BOTH its target and its execute function to already
|
|
277
|
+
// exist, and the catalog records no trigger→function edge, so file order — not the
|
|
278
|
+
// dependency sort — is what guarantees it.
|
|
230
279
|
const renderable = catalog.objects.filter((o) => o.kind !== 'trigger');
|
|
231
|
-
|
|
232
|
-
warnings.push(`${t.identity}: live trigger not rendered — declare it on its model with trigger() (v1 renders relations, functions, and sequences).`);
|
|
233
|
-
}
|
|
280
|
+
const triggers = catalog.objects.filter((o) => o.kind === 'trigger');
|
|
234
281
|
const byIdentity = new Map(renderable.map((o) => [o.identity, o]));
|
|
235
282
|
// Var names carry the schema for non-public objects — two schemas can share a bare name.
|
|
236
283
|
// OVERLOADS share everything but their argument types, so a bare name would emit two
|
|
@@ -302,22 +349,34 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
302
349
|
const { refs, fixmes } = depsFor(o.identity);
|
|
303
350
|
|
|
304
351
|
if (o.kind === 'view' || o.kind === 'materialized view') {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
352
|
+
// The live ACL splits in two: the part `can('read')` can SAY (plain SELECT to named
|
|
353
|
+
// app roles) becomes abilities, and everything else is RECORDED as `privileges`.
|
|
354
|
+
//
|
|
355
|
+
// This used to skip the object outright — definition and all — whenever any grant
|
|
356
|
+
// was inexpressible, which on a real brownfield schema is every relation it has:
|
|
357
|
+
// three roles hold the blanket DELETE/INSERT/REFERENCES/SELECT/TRIGGER/TRUNCATE/
|
|
358
|
+
// UPDATE that `GRANT ALL ON ALL TABLES IN SCHEMA` leaves behind, and PUBLIC holds
|
|
359
|
+
// SELECT on a few. The adopter got a FIXME instead of their view.
|
|
360
|
+
//
|
|
361
|
+
// Recording is also what makes rendering SAFE, and it is why the silent-REVOKE
|
|
362
|
+
// question that blocked this no longer needs an answer: emitting the view with only
|
|
363
|
+
// the abilities we can express would leave the write grants undeclared, so the next
|
|
364
|
+
// generate would plan a REVOKE for each — a silent skip traded for a silent revoke.
|
|
365
|
+
// Declared == live means nothing is planned at all.
|
|
366
|
+
const { abilities, privileges } = splitRelationGrants(o.grants ?? {});
|
|
367
|
+
const writeGrants = Object.values(privileges).some((ps) =>
|
|
368
|
+
ps.some((p) => p === 'INSERT' || p === 'UPDATE' || p === 'DELETE'),
|
|
369
|
+
);
|
|
370
|
+
if (writeGrants) {
|
|
308
371
|
// A view carrying INSERT/UPDATE/DELETE almost never means someone intended a
|
|
309
372
|
// writable view — it usually traces to a blanket `GRANT ALL ON ALL TABLES IN
|
|
310
373
|
// SCHEMA public` in an old migration, which sweeps views up with the tables.
|
|
311
374
|
// Naming that here saves the diagnosis; an adopter paid for it once already.
|
|
312
|
-
const
|
|
313
|
-
|
|
375
|
+
const grantText = Object.entries(privileges).map(([r, p]) => `${r}: ${p.join('/')}`).join(', ');
|
|
376
|
+
warnings.push(
|
|
377
|
+
`${o.identity}: write privileges (${grantText}) recorded as \`privileges\` — the object is declared and nothing will be revoked. ` +
|
|
378
|
+
'Write privileges on a view usually come from a blanket `GRANT ALL ON ALL TABLES IN SCHEMA …` rather than a deliberate writable view — check that first, then revoke and re-pull.',
|
|
314
379
|
);
|
|
315
|
-
const hint = writeGrants
|
|
316
|
-
? ' Write privileges on a view usually come from a blanket `GRANT ALL ON ALL TABLES IN SCHEMA …` rather than a deliberate writable view — check that first.'
|
|
317
|
-
: '';
|
|
318
|
-
warnings.push(`${o.identity}: live grants (${grantText}) are not expressible as relation abilities (read-only, role-shaped) — object skipped; migrate it by hand.${hint}`);
|
|
319
|
-
lines.push(`// FIXME: ${o.identity} skipped — live grants (${grantText}) are not expressible as abilities (views are read surfaces).`, '');
|
|
320
|
-
continue;
|
|
321
380
|
}
|
|
322
381
|
|
|
323
382
|
// --matviews-as-tables (the gap-B consumer flip): the matview renders as a
|
|
@@ -359,7 +418,13 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
359
418
|
lines.push(
|
|
360
419
|
...fixmes,
|
|
361
420
|
`export const ${varName} = defineMaterializedTable(${tsString(o.name)}, {`,
|
|
362
|
-
|
|
421
|
+
// Same split as the relation path: a model carries `privileges` too, so the
|
|
422
|
+
// inexpressible grants are recorded rather than dropped on this branch either.
|
|
423
|
+
...(abilities.length ? [` abilities: [${abilities.join(', ')}],`]
|
|
424
|
+
: Object.keys(privileges).length ? [] : [' private: true,']),
|
|
425
|
+
...(Object.keys(privileges).length
|
|
426
|
+
? [` privileges: { ${Object.keys(privileges).sort().map((g) => `${tsKey(g)}: [${privileges[g].map(tsString).join(', ')}]`).join(', ')} },`]
|
|
427
|
+
: []),
|
|
363
428
|
...(o.schema !== 'public' ? [` schema: ${tsString(o.schema)},`] : []),
|
|
364
429
|
' fields: {',
|
|
365
430
|
...fieldLines,
|
|
@@ -377,12 +442,21 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
377
442
|
}
|
|
378
443
|
|
|
379
444
|
const props: string[] = [];
|
|
445
|
+
const hasPrivileges = Object.keys(privileges).length > 0;
|
|
380
446
|
if (o.kind === 'view') props.push(`securityInvoker: ${o.securityInvoker === true},`);
|
|
381
|
-
|
|
382
|
-
|
|
447
|
+
// `private: true` means "declared dark, no grants" — it contradicts a recorded
|
|
448
|
+
// grant, and the model refuses the pair. A relation whose only reach is recorded
|
|
449
|
+
// (PUBLIC SELECT, or a write grant) is not dark; it just has no CHOSEN audience.
|
|
450
|
+
if (abilities.length === 0 && !hasPrivileges) props.push('private: true,');
|
|
451
|
+
else if (abilities.length) {
|
|
383
452
|
props.push(`abilities: [${abilities.join(', ')}],`);
|
|
384
453
|
need('can');
|
|
385
454
|
}
|
|
455
|
+
if (hasPrivileges) {
|
|
456
|
+
const entries = Object.keys(privileges).sort()
|
|
457
|
+
.map((g) => `${tsKey(g)}: [${privileges[g].map(tsString).join(', ')}]`);
|
|
458
|
+
props.push(`privileges: { ${entries.join(', ')} },`);
|
|
459
|
+
}
|
|
386
460
|
if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
|
|
387
461
|
if (o.kind === 'materialized view') {
|
|
388
462
|
const builders = o.indexes.map((def) => {
|
|
@@ -458,6 +532,11 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
458
532
|
props.push(`abilities: [${roleGrants.map((r) => `can('execute', { role: ${tsString(r)} })`).join(', ')}],`);
|
|
459
533
|
need('can');
|
|
460
534
|
}
|
|
535
|
+
// A live PUBLIC EXECUTE is RECORDED, not converted into an ability. Rendering it as
|
|
536
|
+
// can('execute', { role: … }) would invent an audience the database never named, and
|
|
537
|
+
// dropping it silently would make the descriptor plan a REVOKE on adoption. Both are
|
|
538
|
+
// the adopter's database being changed to suit our vocabulary.
|
|
539
|
+
if (publicExec) props.push(`privileges: { PUBLIC: ['EXECUTE'] },`);
|
|
461
540
|
if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
|
|
462
541
|
if (o.comment) props.push(`comment: ${tsString(o.comment)},`);
|
|
463
542
|
props.push(`body: sql\`${tsTemplate(f.src.trim())}\`,`);
|
|
@@ -465,8 +544,11 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
465
544
|
need('sql');
|
|
466
545
|
lines.push(
|
|
467
546
|
...fixmes,
|
|
468
|
-
|
|
469
|
-
|
|
547
|
+
// Not a FIXME any more — `privileges: { PUBLIC: ['EXECUTE'] }` above says it, and
|
|
548
|
+
// adopting the descriptor no longer applies a revoke. It is still worth a look on a
|
|
549
|
+
// SECURITY DEFINER function, which is what db:check now WARNs about.
|
|
550
|
+
...(publicExec && f.secdef
|
|
551
|
+
? [`// NOTE: ${o.identity} is SECURITY DEFINER and live-granted EXECUTE to PUBLIC — recorded, not chosen. Narrow it to can('execute', { role }) once you know the intended callers.`]
|
|
470
552
|
: []),
|
|
471
553
|
`export const ${varName} = defineFunction(${tsString(declaredName(o))}, {`,
|
|
472
554
|
...props.map((p) => ` ${p}`),
|
|
@@ -476,6 +558,69 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
476
558
|
names.push(varName);
|
|
477
559
|
}
|
|
478
560
|
|
|
561
|
+
// -- trigger bindings, last -------------------------------------------------
|
|
562
|
+
//
|
|
563
|
+
// Every identifier the file has already exported. A trigger's variable must dodge all of
|
|
564
|
+
// them, not just other triggers.
|
|
565
|
+
const usedVars = new Set<string>([...names, ...sequenceNames, ...materializedTableNames]);
|
|
566
|
+
//
|
|
567
|
+
// One descriptor per BINDING, never per function. The two are many-to-one — on the
|
|
568
|
+
// reference schema 18 bindings share 11 functions, `_touch_updated_at()` alone serving
|
|
569
|
+
// five tables — so a renderer keyed on the function would silently emit 11 and lose the
|
|
570
|
+
// rest. The target may be a MODEL (imported) or a derived VIEW (declared above).
|
|
571
|
+
for (const o of [...triggers].sort((a, b) => a.identity.localeCompare(b.identity))) {
|
|
572
|
+
const owner = o.ownerIdentity ?? (o.table?.includes('.') ? o.table : `${o.schema}.${o.table ?? ''}`);
|
|
573
|
+
const modelVar = opts.knownTables.get(owner);
|
|
574
|
+
const targetVar = modelVar ?? varOf.get(owner);
|
|
575
|
+
if (!o.trg || !targetVar) {
|
|
576
|
+
warnings.push(
|
|
577
|
+
`${o.identity}: trigger not rendered — ${!o.trg
|
|
578
|
+
? 'the introspection carried no structured binding (pre-R2 catalog read)'
|
|
579
|
+
: `its target '${owner}' is neither a known model nor a rendered view`}.`,
|
|
580
|
+
);
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
// The function is matched by identity. Trigger functions take no arguments, so the
|
|
584
|
+
// catalog's signature is always `()` — no overload can exist to disambiguate.
|
|
585
|
+
const fnVar = varOf.get(`${o.trg.functionIdentity}()`);
|
|
586
|
+
if (!fnVar) {
|
|
587
|
+
warnings.push(`${o.identity}: trigger not rendered — its function '${o.trg.functionIdentity}' is not in the rendered set.`);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
// Registered only once the binding actually renders — an import emitted for a skipped
|
|
591
|
+
// trigger would be an unused name in generated code.
|
|
592
|
+
if (modelVar) modelRefs.add(owner);
|
|
593
|
+
// A trigger's name collides freely: with its OWN function (`update_users_tsvector` is
|
|
594
|
+
// both, on the reference schema) and with a same-named trigger on another table
|
|
595
|
+
// (trigger names are unique per table, not per schema). Either collision emits two
|
|
596
|
+
// `export const X` — the file does not compile, and `execute: X` would bind the
|
|
597
|
+
// trigger to itself. Suffix only on collision, so an uncontended schema renders
|
|
598
|
+
// byte-identically to a name-per-object scheme.
|
|
599
|
+
const varName = uniqueVar(
|
|
600
|
+
toCamelCase(o.schema === 'public' ? o.name : `${o.schema}_${o.name}`),
|
|
601
|
+
o,
|
|
602
|
+
usedVars,
|
|
603
|
+
);
|
|
604
|
+
const props = [
|
|
605
|
+
`on: ${targetVar},`,
|
|
606
|
+
`timing: ${tsString(o.trg.shape.timing)},`,
|
|
607
|
+
`events: [${o.trg.shape.events.map(tsString).join(', ')}],`,
|
|
608
|
+
`forEach: ${tsString(o.trg.shape.forEach)},`,
|
|
609
|
+
...(o.trg.updateOf?.length ? [`of: [${o.trg.updateOf.map(tsString).join(', ')}],`] : []),
|
|
610
|
+
...(o.trg.when ? [`condition: sql\`${tsTemplate(o.trg.when)}\`,`] : []),
|
|
611
|
+
`execute: ${fnVar},`,
|
|
612
|
+
];
|
|
613
|
+
if (o.trg.when) need('sql');
|
|
614
|
+
need('defineTrigger');
|
|
615
|
+
lines.push(
|
|
616
|
+
`export const ${varName} = defineTrigger(${tsString(o.name)}, {`,
|
|
617
|
+
...props.map((p) => ` ${p}`),
|
|
618
|
+
'});',
|
|
619
|
+
'',
|
|
620
|
+
);
|
|
621
|
+
names.push(varName);
|
|
622
|
+
}
|
|
623
|
+
|
|
479
624
|
return {
|
|
480
625
|
block: lines.join('\n').trimEnd(),
|
|
481
626
|
names,
|
|
@@ -285,25 +285,12 @@ export function normalizeSql(sql: string): string {
|
|
|
285
285
|
// Classification.
|
|
286
286
|
// ---------------------------------------------------------------------------
|
|
287
287
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const ch = raw[i];
|
|
295
|
-
if (ch === '"') {
|
|
296
|
-
if (quoted && raw[i + 1] === '"') { current += '"'; i++; continue; }
|
|
297
|
-
quoted = !quoted;
|
|
298
|
-
continue;
|
|
299
|
-
}
|
|
300
|
-
if (ch === '.' && !quoted) { parts.push(current); current = ''; continue; }
|
|
301
|
-
current += ch;
|
|
302
|
-
}
|
|
303
|
-
parts.push(current);
|
|
304
|
-
if (parts.length >= 2) return { schema: parts[0], name: parts.slice(1).join('.') };
|
|
305
|
-
return { schema: 'public', name: parts[0] };
|
|
306
|
-
}
|
|
288
|
+
// `parseQualified` moved to `@everystack/model` (src/qualified.ts) when the composition
|
|
289
|
+
// guard needed it: `defineModule` has to line a model's `schema` up against the schema
|
|
290
|
+
// inside a descriptor's declared name, and model cannot import cli. Re-exported here so
|
|
291
|
+
// every existing caller keeps its `./derived-source.js` import and there stays exactly one
|
|
292
|
+
// definition — two copies of this splitter is how the emitters and the guard drift apart.
|
|
293
|
+
export { parseQualified } from '@everystack/model';
|
|
307
294
|
|
|
308
295
|
// (The db/sql statement classifier and parseSqlSources lived here until B7 —
|
|
309
296
|
// descriptors are the single home; declared-derived.ts's retiredSqlDir() is the
|
package/src/cli/parse-flags.ts
CHANGED
|
@@ -25,12 +25,32 @@ function isFlag(arg: string): boolean {
|
|
|
25
25
|
return arg.startsWith('-') && arg.length > 1;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/** Parse `--key value
|
|
28
|
+
/** Parse `--key value`, `--key=value`, `-k value`; a flag with no value becomes `'true'`. */
|
|
29
29
|
export function parseFlags(args: string[]): Record<string, string> {
|
|
30
30
|
const flags: Record<string, string> = {};
|
|
31
31
|
for (let i = 0; i < args.length; i++) {
|
|
32
32
|
const arg = args[i];
|
|
33
33
|
if (!isFlag(arg)) continue;
|
|
34
|
+
|
|
35
|
+
// `--key=value`. Split on the FIRST `=` only: the value is routinely a connection string
|
|
36
|
+
// whose own query carries more (`postgresql://h/db?sslmode=require`), and splitting on
|
|
37
|
+
// every `=` would truncate it to the host.
|
|
38
|
+
//
|
|
39
|
+
// This form used to be dropped ENTIRELY — the whole `--database-url=…` token parsed as a
|
|
40
|
+
// key with no value, so the flag was simply absent. For a venue flag that is not a parse
|
|
41
|
+
// error, it is a silent CHANGE OF TARGET: `db:plan --database-url=postgres://localhost/x`
|
|
42
|
+
// found no url source, fell through to the stage lane, and went to AWS. A consumer lost an
|
|
43
|
+
// afternoon to it against a local database with no AWS at all. On a destructive verb the
|
|
44
|
+
// same slip aims at the deployed stage instead of the local database.
|
|
45
|
+
const eq = arg.indexOf('=');
|
|
46
|
+
if (eq > 1) {
|
|
47
|
+
const rawKey = arg.slice(0, eq);
|
|
48
|
+
const key = rawKey.startsWith('--') ? rawKey.slice(2) : rawKey.slice(1);
|
|
49
|
+
// The empty string is a VALUE, not an absent one — same rule as `--set ''` below.
|
|
50
|
+
flags[key] = arg.slice(eq + 1);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
const key = arg.startsWith('--') ? arg.slice(2) : arg.slice(1);
|
|
35
55
|
const next = args[i + 1];
|
|
36
56
|
flags[key] = next !== undefined && !isFlag(next) ? args[++i] : 'true';
|