@everystack/cli 0.4.55 → 0.4.57
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 +4 -3
- package/src/cli/authz-contract.ts +155 -3
- package/src/cli/authz-derive.ts +46 -5
- package/src/cli/authz-reconcile.ts +146 -1
- package/src/cli/authz-render.ts +26 -4
- package/src/cli/commands/db-apply.ts +11 -0
- package/src/cli/commands/db-check.ts +4 -1
- package/src/cli/commands/db-fingerprint.ts +16 -2
- package/src/cli/commands/db-plan.ts +20 -3
- package/src/cli/commands/db-pull.ts +6 -1
- package/src/cli/commands/db-reconcile.ts +41 -2
- package/src/cli/commands/security.ts +24 -3
- package/src/cli/db-build.ts +36 -1
- package/src/cli/derived-apply.ts +249 -18
- package/src/cli/derived-compile.ts +25 -7
- package/src/cli/derived-grants.ts +96 -3
- package/src/cli/derived-introspect.ts +98 -1
- package/src/cli/derived-lint.ts +56 -0
- package/src/cli/derived-plan.ts +48 -1
- package/src/cli/derived-render.ts +153 -3
- package/src/cli/derived-source.ts +32 -2
- package/src/cli/git-descent.ts +91 -19
- package/src/cli/migration-generate.ts +3 -0
- package/src/cli/model-api.ts +24 -0
- package/src/cli/model-render.ts +100 -11
- package/src/cli/schema-compile.ts +66 -9
- package/src/cli/schema-diff.ts +133 -14
- package/src/cli/schema-fingerprint.ts +58 -5
- package/src/cli/schema-introspect.ts +40 -0
- package/src/cli/schema-source.ts +19 -1
- package/src/cli/security-audit.ts +131 -2
- package/src/cli/security-catalog.ts +18 -3
- package/src/cli/state-apply.ts +7 -1
|
@@ -194,9 +194,20 @@ function renderFunction(fn: FunctionDescriptor): { sql: string; attachments: Att
|
|
|
194
194
|
.join(', ');
|
|
195
195
|
const returns = typeof fn.returns === 'string' ? fn.returns : `SETOF ${refName(fn.returns.setof)}`;
|
|
196
196
|
const volatility = fn.volatility === 'volatile' ? '' : ` ${fn.volatility.toUpperCase()}`;
|
|
197
|
-
const
|
|
198
|
-
|
|
197
|
+
const definer = fn.security === 'definer' ? ' SECURITY DEFINER' : '';
|
|
198
|
+
// The pin is INDEPENDENT of the security mode, in both directions.
|
|
199
|
+
//
|
|
200
|
+
// `'unpinned'` emits no `SET search_path` at all — that is the whole point of the sentinel:
|
|
201
|
+
// a brownfield definer function that genuinely has none is now declarable, instead of the
|
|
202
|
+
// pull inventing `pg_catalog` and the build applying the invention for real.
|
|
203
|
+
//
|
|
204
|
+
// And a pin on an INVOKER function is emitted too. It used to be silently dropped, so a model
|
|
205
|
+
// could declare one, the build would not apply it, and the differ (which never read proconfig)
|
|
206
|
+
// would not notice — a declared property with no effect and no complaint.
|
|
207
|
+
const pin = Array.isArray(fn.searchPath) && fn.searchPath.length > 0
|
|
208
|
+
? ` SET search_path = ${fn.searchPath.join(', ')}`
|
|
199
209
|
: '';
|
|
210
|
+
const security = `${definer}${pin}`;
|
|
200
211
|
const tag = dollarTag(fn.body);
|
|
201
212
|
const signature = functionSignature(target, fn);
|
|
202
213
|
return {
|
|
@@ -321,10 +332,12 @@ function make(kind: SourceObject['kind'], rawName: string, sql: string, attachme
|
|
|
321
332
|
kind, schema, name,
|
|
322
333
|
identity: extra.identity ?? `${schema}.${name}`,
|
|
323
334
|
sql, attachments,
|
|
324
|
-
hash: hashSourceContent(sql, attachments),
|
|
335
|
+
hash: hashSourceContent(sql, attachments, extra.owner),
|
|
325
336
|
// bodyHash excludes PLAIN grants (column-scoped grants stay in — attacl isn't drift-checked, so
|
|
326
337
|
// a column-grant change must still rebuild). Equal across an authz-only plain-grant change.
|
|
327
|
-
|
|
338
|
+
// The OWNER stays in: changing who a SECURITY DEFINER function (or a non-invoker view)
|
|
339
|
+
// executes as is a rebuild, not an authz delta, so it must not be routed to a bare GRANT diff.
|
|
340
|
+
bodyHash: hashSourceContent(sql, attachments.filter((a) => !isPlainGrantAttachment(a)), extra.owner),
|
|
328
341
|
file: DECLARED, seq,
|
|
329
342
|
...extra,
|
|
330
343
|
});
|
|
@@ -369,18 +382,23 @@ export function compileDerived(models: readonly ModelDescriptor[], derived: read
|
|
|
369
382
|
switch (d.kind) {
|
|
370
383
|
case 'view': {
|
|
371
384
|
const { sql, attachments } = renderView(d);
|
|
372
|
-
|
|
385
|
+
// A15: the owner rides on the object, so it enters the content hash (a view that
|
|
386
|
+
// changes owner IS a different object) and the applier can create it under SET ROLE.
|
|
387
|
+
nodes.push({ identity, deps: depIdentities(d.dependsOn), build: make('view', d.name, sql, attachments, { declaredDeps: declaredIdentities(d.dependsOn), ...(d.owner ? { owner: d.owner } : {}) }) });
|
|
373
388
|
break;
|
|
374
389
|
}
|
|
375
390
|
case 'materialized view': {
|
|
376
391
|
const { sql, attachments } = renderMaterializedView(d);
|
|
377
|
-
nodes.push({ identity, deps: depIdentities(d.dependsOn), build: make('materialized view', d.name, sql, attachments, { declaredDeps: declaredIdentities(d.dependsOn) }) });
|
|
392
|
+
nodes.push({ identity, deps: depIdentities(d.dependsOn), build: make('materialized view', d.name, sql, attachments, { declaredDeps: declaredIdentities(d.dependsOn), ...(d.owner ? { owner: d.owner } : {}) }) });
|
|
378
393
|
break;
|
|
379
394
|
}
|
|
380
395
|
case 'function': {
|
|
381
396
|
const { sql, attachments } = renderFunction(d);
|
|
382
397
|
const setofDep = typeof d.returns === 'string' ? [] : depIdentities([d.returns.setof]);
|
|
383
|
-
|
|
398
|
+
// The pin rides STRUCTURALLY as well as inside `sql`, so the differ can compare it
|
|
399
|
+
// against the live catalog directly instead of only against a recorded hash. It is not
|
|
400
|
+
// in the hash (it is already inside `sql`), so carrying it moves nobody's fingerprint.
|
|
401
|
+
nodes.push({ identity, deps: [...depIdentities(d.dependsOn), ...setofDep], build: make('function', d.name, sql, attachments, { identity, ...(d.owner ? { owner: d.owner } : {}), ...(d.searchPath !== undefined ? { searchPath: d.searchPath } : {}) }) });
|
|
384
402
|
break;
|
|
385
403
|
}
|
|
386
404
|
case 'sql': {
|
|
@@ -99,20 +99,113 @@ export function parseGrantAttachments(attachments: readonly Attachment[]): Parse
|
|
|
99
99
|
* The idempotent REVOKE/GRANT delta between the declared contract and the live ACLs —
|
|
100
100
|
* the table path's reconcileGrants, per derived object. Empty when they match.
|
|
101
101
|
*/
|
|
102
|
+
export interface ObjectGrantDiff {
|
|
103
|
+
statements: string[];
|
|
104
|
+
/** REVOKEs withheld because ownership made the two sides incomparable, with the reason. */
|
|
105
|
+
suppressed: string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Ownership context for a FUNCTION's grant diff. Absent for relations.
|
|
110
|
+
*
|
|
111
|
+
* Owner-implicit EXECUTE is excluded from both grant reads (`grantee <> proowner`), which is
|
|
112
|
+
* correct in isolation — an owner's privileges are not grants. But it means the SET of
|
|
113
|
+
* non-owner grants is computed RELATIVE TO THE OWNER, so two sides with different owners
|
|
114
|
+
* produce two different sets for the same function, and the differ reconciles a difference
|
|
115
|
+
* that is purely ownership.
|
|
116
|
+
*
|
|
117
|
+
* Measured by a consumer, 2026-08-10: their local `resolve_user_slug` is owned by
|
|
118
|
+
* `outbound_migrator` (owner-implicit, so the pull rendered NO privilege), while on stage the
|
|
119
|
+
* same function is owned by `slug_resolver` and `outbound_migrator` holds an EXPLICIT grant.
|
|
120
|
+
* The differ planned `REVOKE EXECUTE ON api.resolve_user_slug FROM outbound_migrator` — a real
|
|
121
|
+
* access removal minted from an ownership difference. They did not apply it.
|
|
122
|
+
*/
|
|
123
|
+
export interface GrantOwnerContext {
|
|
124
|
+
/** The owner the DECLARED grant set was computed against, when the models declare one. */
|
|
125
|
+
declared?: string;
|
|
126
|
+
/** The owner the LIVE grant set was computed against (`pg_get_userbyid(proowner)`). */
|
|
127
|
+
live?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The idempotent REVOKE/GRANT delta between the declared contract and the live ACLs —
|
|
132
|
+
* the table path's reconcileGrants, per derived object. Empty when they match.
|
|
133
|
+
*
|
|
134
|
+
* When `owners` is supplied (functions) and the two sides cannot be shown to have been
|
|
135
|
+
* computed against the SAME owner, REVOKEs are WITHHELD and reported. GRANTs still flow: a
|
|
136
|
+
* redundant GRANT is noise, a wrong REVOKE removes access that was really there. Failing to
|
|
137
|
+
* revoke is the recoverable error and it is never silent.
|
|
138
|
+
*
|
|
139
|
+
* This withholds some legitimate revokes on a project that has not declared `owner` yet —
|
|
140
|
+
* deliberately. Declaring the owner restores full capability, and until then the tooling says
|
|
141
|
+
* plainly what it is not doing rather than guessing.
|
|
142
|
+
*/
|
|
102
143
|
export function diffObjectGrants(
|
|
103
144
|
target: string,
|
|
104
145
|
declared: Record<string, string[]>,
|
|
105
146
|
live: Record<string, string[]>,
|
|
106
|
-
|
|
147
|
+
owners?: GrantOwnerContext,
|
|
148
|
+
): ObjectGrantDiff {
|
|
107
149
|
const out: string[] = [];
|
|
150
|
+
const suppressed: string[] = [];
|
|
151
|
+
// WHICH GRANTEE, not which object. An owner-implicit privilege can only make a grantee
|
|
152
|
+
// appear or disappear if THAT GRANTEE is an owner on one of the two sides — the owner's own
|
|
153
|
+
// rows are what each read excludes. So suppression is per-grantee, and every other revoke
|
|
154
|
+
// still flows.
|
|
155
|
+
//
|
|
156
|
+
// This matters concretely: PUBLIC is a pseudo-role that can never own an object, so a
|
|
157
|
+
// `REVOKE ... FROM PUBLIC` is NEVER explained by ownership. An earlier, object-scoped
|
|
158
|
+
// version of this guard swallowed exactly that revoke — a hand-run `GRANT EXECUTE TO PUBLIC`
|
|
159
|
+
// on a managed function stopped being cleaned up, turning a privilege-escalation cleanup
|
|
160
|
+
// into a silent no-op. Caught by __tests__/integration/derived-drift.test.ts against a real
|
|
161
|
+
// database.
|
|
162
|
+
//
|
|
163
|
+
// The declared owner is only known once the models declare one (`defineFunction owner`).
|
|
164
|
+
// Until then the pull-time owner is unrecorded, so a grantee that WAS the pull-time owner
|
|
165
|
+
// cannot be identified — that case is closed by db:pull emitting `owner`, not here. What we
|
|
166
|
+
// can always identify is the LIVE owner, and that is the half that produces the asymmetry
|
|
167
|
+
// in the direction that removes access.
|
|
168
|
+
const ownerish = new Set([owners?.declared, owners?.live].filter((o): o is string => !!o));
|
|
169
|
+
const suppressibleGrantee = (grantee: string): boolean =>
|
|
170
|
+
owners?.live !== undefined && grantee !== 'PUBLIC' && ownerish.has(grantee);
|
|
171
|
+
// NORMALIZATION, and it is a different move from the suppression above.
|
|
172
|
+
//
|
|
173
|
+
// The LIVE owner holds every privilege implicitly, and the live read excludes exactly those
|
|
174
|
+
// rows. So when the two sides share a basis — the models declare the live owner, or declare no
|
|
175
|
+
// owner at all — a declared grant TO that owner is already true and unreadable. Minting it
|
|
176
|
+
// produces a GRANT the next read still cannot see: a statement that runs on every reconcile,
|
|
177
|
+
// forever, and never converges. Measured as 31 of them on one consumer's function set.
|
|
178
|
+
//
|
|
179
|
+
// Both directions drop, because both are vacuous: an owner's implicit privileges cannot be
|
|
180
|
+
// granted (they are already held) and cannot be revoked (they follow ownership, not the ACL).
|
|
181
|
+
// This is not a withheld statement, so it is not reported as one.
|
|
182
|
+
//
|
|
183
|
+
// It applies ONLY when the bases agree. A declared owner that differs from the live one means
|
|
184
|
+
// ownership itself is converging, the two grant sets were computed against different owners,
|
|
185
|
+
// and that case stays with the suppression rule above.
|
|
186
|
+
const sameBasis = owners?.declared === undefined || owners.declared === owners.live;
|
|
187
|
+
const ownerImplicit = (grantee: string): boolean =>
|
|
188
|
+
owners?.live !== undefined && grantee !== 'PUBLIC' && grantee === owners.live && sameBasis;
|
|
108
189
|
const grantees = new Set([...Object.keys(declared), ...Object.keys(live)]);
|
|
109
190
|
for (const grantee of [...grantees].sort()) {
|
|
191
|
+
if (ownerImplicit(grantee)) continue;
|
|
110
192
|
const want = new Set(declared[grantee] ?? []);
|
|
111
193
|
const have = new Set(live[grantee] ?? []);
|
|
112
194
|
const toRevoke = [...have].filter((p) => !want.has(p)).sort();
|
|
113
195
|
const toGrant = [...want].filter((p) => !have.has(p)).sort();
|
|
114
|
-
if (toRevoke.length)
|
|
196
|
+
if (toRevoke.length) {
|
|
197
|
+
if (!suppressibleGrantee(grantee)) {
|
|
198
|
+
out.push(`REVOKE ${toRevoke.join(', ')} ON ${target} FROM ${grantee}`);
|
|
199
|
+
} else {
|
|
200
|
+
const why = grantee === owners!.live
|
|
201
|
+
? `'${grantee}' is the function's live OWNER, whose implicit privileges are excluded from both grant reads — so this difference is ownership, not a grant`
|
|
202
|
+
: `'${grantee}' is the function's declared owner (live owner: ${owners!.live}), and an owner's implicit privileges are excluded from both grant reads`;
|
|
203
|
+
suppressed.push(
|
|
204
|
+
`${target}: withheld REVOKE ${toRevoke.join(', ')} FROM ${grantee} — ${why}. An owner's implicit privileges are excluded from both reads, so the two grant sets are not comparable and this revoke may remove access that is really there. Declare the function's owner to restore revokes.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
115
208
|
if (toGrant.length) out.push(`GRANT ${toGrant.join(', ')} ON ${target} TO ${grantee}`);
|
|
116
209
|
}
|
|
117
|
-
return out;
|
|
210
|
+
return { statements: out, suppressed };
|
|
118
211
|
}
|
|
@@ -37,6 +37,33 @@ export interface LiveObject {
|
|
|
37
37
|
comment?: string;
|
|
38
38
|
/** sha256 over normalized definition + indexes + comment. */
|
|
39
39
|
defHash: string;
|
|
40
|
+
/**
|
|
41
|
+
* Functions, views and matviews: the owning role.
|
|
42
|
+
*
|
|
43
|
+
* Read because the grant differ CANNOT be correct without it. Owner-implicit privileges are
|
|
44
|
+
* excluded from the ACL read (`a.grantee <> p.proowner` / `<> c.relowner`), which is right —
|
|
45
|
+
* but it means the SET of non-owner grants shifts when the owner changes, so the same object
|
|
46
|
+
* under two owners yields two different privilege sets and the differ mints statements to
|
|
47
|
+
* reconcile a difference that is purely ownership.
|
|
48
|
+
*
|
|
49
|
+
* A15 — and for views it is a privilege boundary in its own right. A `security_invoker =
|
|
50
|
+
* false` view executes with its OWNER's rights, exactly as a SECURITY DEFINER function does.
|
|
51
|
+
* Undeclared, the owner is whoever ran the build: an `admin`-owned sealed-slice view rebuilds
|
|
52
|
+
* as builder-owned, `admin` can no longer read through it, and every gate stays green.
|
|
53
|
+
*/
|
|
54
|
+
owner?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Is `owner` a SUPERUSER (or an rds_superuser member — RDS has no true superuser)? Half of
|
|
57
|
+
* the discriminator db:pull uses to decide whether the observed owner is a CHOSEN principal
|
|
58
|
+
* or the ambient build credential. Absent means not measured.
|
|
59
|
+
*/
|
|
60
|
+
ownerIsSuperuser?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Is `owner` the role this introspection is CONNECTED AS? The other half of the
|
|
63
|
+
* discriminator. A normalized non-superuser migrator owns everything it creates by default,
|
|
64
|
+
* so "superuser" alone would miss it. Absent means not measured.
|
|
65
|
+
*/
|
|
66
|
+
ownerIsConnectedRole?: boolean;
|
|
40
67
|
/** Matview only: whether it holds data. */
|
|
41
68
|
populated?: boolean;
|
|
42
69
|
/** Matview only: pg_total_relation_size — the rebuild-cost signal. */
|
|
@@ -128,7 +155,22 @@ SELECT
|
|
|
128
155
|
EXISTS (
|
|
129
156
|
SELECT 1 FROM unnest(COALESCE(c.reloptions, '{}'::text[])) o
|
|
130
157
|
WHERE o IN ('security_invoker=true', 'security_invoker=on')
|
|
131
|
-
) AS security_invoker
|
|
158
|
+
) AS security_invoker,
|
|
159
|
+
-- A15. The same three fields the function read carries, for the same reason: a
|
|
160
|
+
-- security_invoker = false view executes with its OWNER's rights, so its owner is a
|
|
161
|
+
-- privilege boundary exactly as a SECURITY DEFINER function's is. The two discriminators
|
|
162
|
+
-- let db:pull tell a CHOSEN owner from the ambient build credential.
|
|
163
|
+
pg_get_userbyid(c.relowner) AS owner,
|
|
164
|
+
COALESCE(
|
|
165
|
+
(SELECT r.rolsuper
|
|
166
|
+
OR EXISTS (
|
|
167
|
+
SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
|
|
168
|
+
WHERE m.member = c.relowner AND g.rolname = 'rds_superuser'
|
|
169
|
+
)
|
|
170
|
+
FROM pg_roles r WHERE r.oid = c.relowner),
|
|
171
|
+
false
|
|
172
|
+
) AS owner_is_superuser,
|
|
173
|
+
(pg_get_userbyid(c.relowner) IN (CURRENT_USER, SESSION_USER)) AS owner_is_connected_role
|
|
132
174
|
FROM pg_class c
|
|
133
175
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
134
176
|
WHERE c.relkind IN ('v', 'm')
|
|
@@ -171,6 +213,25 @@ SELECT
|
|
|
171
213
|
l.lanname AS language,
|
|
172
214
|
p.prosecdef AS secdef,
|
|
173
215
|
p.provolatile AS volatility,
|
|
216
|
+
pg_get_userbyid(p.proowner) AS owner,
|
|
217
|
+
-- The AMBIENT-BUILD-CREDENTIAL discriminator, two clauses and no more. db:pull renders
|
|
218
|
+
-- the owner only when the observed owner is neither of these — rendering the ambient
|
|
219
|
+
-- credential would BLESS an accident, turning "whoever happened to run the migration" into
|
|
220
|
+
-- fingerprinted, reproducible design. When it IS ambient the renderer emits a FIXME instead:
|
|
221
|
+
-- the defect stays visible without being ratified.
|
|
222
|
+
--
|
|
223
|
+
-- Superuser, matching security-catalog's rule (RDS has no true superuser, so rds_superuser
|
|
224
|
+
-- membership counts).
|
|
225
|
+
(
|
|
226
|
+
SELECT r.rolsuper OR EXISTS (
|
|
227
|
+
SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
|
|
228
|
+
WHERE m.member = p.proowner AND g.rolname = 'rds_superuser'
|
|
229
|
+
)
|
|
230
|
+
FROM pg_roles r WHERE r.oid = p.proowner
|
|
231
|
+
) AS owner_is_superuser,
|
|
232
|
+
-- ...or the role we are connected as. A normalized, deliberately non-superuser migrator owns
|
|
233
|
+
-- every object it creates, so the superuser clause alone would miss the commonest case.
|
|
234
|
+
(pg_get_userbyid(p.proowner) IN (CURRENT_USER, SESSION_USER)) AS owner_is_connected_role,
|
|
174
235
|
(SELECT split_part(cfg, '=', 2) FROM unnest(COALESCE(p.proconfig, '{}'::text[])) cfg
|
|
175
236
|
WHERE cfg LIKE 'search_path=%' LIMIT 1) AS search_path,
|
|
176
237
|
p.prosrc AS src
|
|
@@ -458,6 +519,12 @@ export interface RelationRow {
|
|
|
458
519
|
rows: unknown;
|
|
459
520
|
/** security_invoker reloption (views). Optional — pre-B5 fixtures omit it. */
|
|
460
521
|
security_invoker?: unknown;
|
|
522
|
+
/** `pg_get_userbyid(relowner)` — see LiveObject.owner. Optional: pre-A15 fixtures omit it. */
|
|
523
|
+
owner?: unknown;
|
|
524
|
+
/** See LiveObject.ownerIsSuperuser. Optional: absent means not measured. */
|
|
525
|
+
owner_is_superuser?: unknown;
|
|
526
|
+
/** See LiveObject.ownerIsConnectedRole. Optional: absent means not measured. */
|
|
527
|
+
owner_is_connected_role?: unknown;
|
|
461
528
|
}
|
|
462
529
|
|
|
463
530
|
export interface FunctionRow {
|
|
@@ -475,6 +542,12 @@ export interface FunctionRow {
|
|
|
475
542
|
secdef?: unknown;
|
|
476
543
|
volatility?: unknown;
|
|
477
544
|
search_path?: unknown;
|
|
545
|
+
/** `pg_get_userbyid(proowner)` — see LiveObject.owner. Optional: pre-owner fixtures omit it. */
|
|
546
|
+
owner?: unknown;
|
|
547
|
+
/** See LiveObject.ownerIsSuperuser. Optional: absent means not measured. */
|
|
548
|
+
owner_is_superuser?: unknown;
|
|
549
|
+
/** See LiveObject.ownerIsConnectedRole. Optional: absent means not measured. */
|
|
550
|
+
owner_is_connected_role?: unknown;
|
|
478
551
|
src?: unknown;
|
|
479
552
|
}
|
|
480
553
|
|
|
@@ -544,6 +617,19 @@ export interface DerivedRows {
|
|
|
544
617
|
grants?: GrantAclRow[];
|
|
545
618
|
}
|
|
546
619
|
|
|
620
|
+
/** Coerce a Postgres boolean as any driver spells it. `undefined` means NOT MEASURED —
|
|
621
|
+
* never conflated with a measured `false`. NULL is not-measured too: the only way these
|
|
622
|
+
* sub-selects go NULL is the role vanishing between reads, and guessing there would be
|
|
623
|
+
* exactly the fabrication this feature exists to stop. */
|
|
624
|
+
function pgBool(v: unknown): boolean | undefined {
|
|
625
|
+
if (v === undefined || v === null) return undefined;
|
|
626
|
+
if (typeof v === 'boolean') return v;
|
|
627
|
+
const s = String(v).toLowerCase();
|
|
628
|
+
if (s === 't' || s === 'true') return true;
|
|
629
|
+
if (s === 'f' || s === 'false') return false;
|
|
630
|
+
return undefined;
|
|
631
|
+
}
|
|
632
|
+
|
|
547
633
|
/** Coerce a Postgres text[] (a JS array, or the `{a,b}` text form some drivers return) to string[]. */
|
|
548
634
|
function pgTextArray(v: unknown): string[] {
|
|
549
635
|
if (Array.isArray(v)) return v.map(String);
|
|
@@ -597,6 +683,12 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
|
597
683
|
definition, indexes,
|
|
598
684
|
...(comment !== undefined ? { comment } : {}),
|
|
599
685
|
defHash: computeDefHash(definition, indexes, comment),
|
|
686
|
+
// A15 — same three fields, same meaning, as the function branch below. Absent (not
|
|
687
|
+
// measured) and false (measured, not ambient) stay DIFFERENT, so a pre-A15 fixture
|
|
688
|
+
// keeps behaving exactly as it did.
|
|
689
|
+
...(row.owner != null ? { owner: String(row.owner) } : {}),
|
|
690
|
+
...(pgBool(row.owner_is_superuser) !== undefined ? { ownerIsSuperuser: pgBool(row.owner_is_superuser) } : {}),
|
|
691
|
+
...(pgBool(row.owner_is_connected_role) !== undefined ? { ownerIsConnectedRole: pgBool(row.owner_is_connected_role) } : {}),
|
|
600
692
|
...grantsFor('r', identity),
|
|
601
693
|
};
|
|
602
694
|
if (kind === 'materialized view') {
|
|
@@ -625,6 +717,11 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
|
625
717
|
definition, indexes: [],
|
|
626
718
|
...(comment !== undefined ? { comment } : {}),
|
|
627
719
|
defHash: computeDefHash(definition, [], comment),
|
|
720
|
+
...(row.owner != null ? { owner: String(row.owner) } : {}),
|
|
721
|
+
// Absent (not measured) and false (measured, not ambient) are DIFFERENT — a pre-owner
|
|
722
|
+
// fixture must keep behaving exactly as it did, so undefined never becomes false here.
|
|
723
|
+
...(pgBool(row.owner_is_superuser) !== undefined ? { ownerIsSuperuser: pgBool(row.owner_is_superuser) } : {}),
|
|
724
|
+
...(pgBool(row.owner_is_connected_role) !== undefined ? { ownerIsConnectedRole: pgBool(row.owner_is_connected_role) } : {}),
|
|
628
725
|
...grantsFor('f', identity),
|
|
629
726
|
...(row.src !== undefined ? {
|
|
630
727
|
fn: {
|
package/src/cli/derived-lint.ts
CHANGED
|
@@ -233,6 +233,62 @@ export function findPublicExecutableSecdef(derived: readonly DerivedDescriptor[]
|
|
|
233
233
|
return warnings;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Advisory: SECURITY DEFINER with no pin (db:check warn) — A3's linter half
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
export interface UnpinnedDefinerFinding extends DerivedAuthzGap {
|
|
241
|
+
/** The schemas the DECLARATION can justify, `pg_catalog` first. A floor to review — never applied. */
|
|
242
|
+
suggested: string[];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Every SECURITY DEFINER function that declares `searchPath: 'unpinned'`, with a SUGGESTED pin.
|
|
247
|
+
*
|
|
248
|
+
* This is the half of A3 that keeps the honesty from becoming permission. `db:pull` no longer
|
|
249
|
+
* invents `['pg_catalog']` for a function whose live `proconfig` is empty — inventing it was an
|
|
250
|
+
* outage that passed every gate, and fabrication corrodes fingerprint, plan and apply, all of
|
|
251
|
+
* which rest on the model telling the truth. So the parser records what is there and the
|
|
252
|
+
* OPINION lives here: an unpinned definer function is a real latent vulnerability, and it gets
|
|
253
|
+
* named on every run until someone decides about it.
|
|
254
|
+
*
|
|
255
|
+
* SUGGEST, never apply. The suggestion is derived from the DECLARED structure — the function's
|
|
256
|
+
* own schema plus the schemas of its `dependsOn` — and not from reading the body. Body-derived
|
|
257
|
+
* pins are inventing with extra steps: dynamic SQL, operators and casts defeat the parse, and a
|
|
258
|
+
* pin that is wrong in the tool's favour is the same failure mode with better intentions. The
|
|
259
|
+
* suggestion is a floor. The author reviews it and lands it themselves.
|
|
260
|
+
*
|
|
261
|
+
* WARN, not fail. On an adopted database this is a faithful report of what is already true, and
|
|
262
|
+
* failing CI on a faithful pull would make `db:pull` unusable on exactly the schemas it exists
|
|
263
|
+
* for. Same call, and the same reasoning, as findPublicExecutableSecdef above.
|
|
264
|
+
*/
|
|
265
|
+
export function findUnpinnedDefiners(derived: readonly DerivedDescriptor[]): UnpinnedDefinerFinding[] {
|
|
266
|
+
const findings: UnpinnedDefinerFinding[] = [];
|
|
267
|
+
for (const d of derived) {
|
|
268
|
+
if (d.kind !== 'function' || d.security !== 'definer' || d.searchPath !== 'unpinned') continue;
|
|
269
|
+
const identity = identityOf(d);
|
|
270
|
+
const schemas = new Set<string>([parseQualified(d.name).schema]);
|
|
271
|
+
for (const ref of d.dependsOn ?? []) {
|
|
272
|
+
schemas.add(isModelRef(ref) ? (ref.schema ?? 'public') : parseQualified(ref.name).schema);
|
|
273
|
+
}
|
|
274
|
+
// pg_catalog FIRST and always: it is what stops a caller shadowing a built-in the body
|
|
275
|
+
// calls unqualified, which is the whole attack this pin defends against.
|
|
276
|
+
const suggested = ['pg_catalog', ...[...schemas].filter((s) => s !== 'pg_catalog').sort()];
|
|
277
|
+
findings.push({
|
|
278
|
+
identity,
|
|
279
|
+
suggested,
|
|
280
|
+
message:
|
|
281
|
+
`${identity} is SECURITY DEFINER with NO search_path — declared 'unpinned', which is the truth about the `
|
|
282
|
+
+ `database, not an approval. It runs with its owner's rights, and a caller controls the search_path, so an `
|
|
283
|
+
+ `unqualified name in the body can be resolved to an object the caller planted. SUGGESTED pin from what the `
|
|
284
|
+
+ `declaration references: searchPath: [${suggested.map((s) => `'${s}'`).join(', ')}]. That is a FLOOR derived `
|
|
285
|
+
+ `from dependsOn, not from reading the body — dynamic SQL, operators and casts are invisible to it, so review `
|
|
286
|
+
+ `it, extend it, and land it yourself. Nothing here is ever applied for you.`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return findings;
|
|
290
|
+
}
|
|
291
|
+
|
|
236
292
|
// ---------------------------------------------------------------------------
|
|
237
293
|
// Gate: matview snapshot over row-scoped sources (db:check warn)
|
|
238
294
|
// ---------------------------------------------------------------------------
|
package/src/cli/derived-plan.ts
CHANGED
|
@@ -233,6 +233,30 @@ export function planReconcile(
|
|
|
233
233
|
const sqlDrops = new Map<string, { dropSql: string; reason: string }>();
|
|
234
234
|
const extraWarnings: string[] = [];
|
|
235
235
|
|
|
236
|
+
/**
|
|
237
|
+
* B2 — the declared `SET search_path` compared against the LIVE one, structurally.
|
|
238
|
+
*
|
|
239
|
+
* Every other comparison in this table is a hash against a RECORDED hash, which is why the
|
|
240
|
+
* pin could differ in silence: `--baseline` records the declared source hash beside the live
|
|
241
|
+
* def hash without ever comparing the two, so a model claiming `search_path=pg_catalog` about
|
|
242
|
+
* a database with no pin reads as up to date forever. Measured — `db:reconcile --check`
|
|
243
|
+
* returned zero actions and zero drift on exactly that state.
|
|
244
|
+
*
|
|
245
|
+
* This is the one comparison that reads both sides at once. Null when they agree, or when
|
|
246
|
+
* either side was not measured (a parser-era object with no declared pin, a pre-B5 catalog
|
|
247
|
+
* read with no structured fields) — absent evidence is not evidence.
|
|
248
|
+
*/
|
|
249
|
+
const pinMismatch = (src: SourceObject, liveObj: LiveObject): string | null => {
|
|
250
|
+
if (src.kind !== 'function' || src.searchPath === undefined || !liveObj.fn) return null;
|
|
251
|
+
const tokens = (v: string | undefined): string[] =>
|
|
252
|
+
(v ?? '').split(',').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
|
|
253
|
+
const declared = src.searchPath === 'unpinned' ? [] : src.searchPath.map((s) => s.trim());
|
|
254
|
+
const livePin = tokens(liveObj.fn.searchPath);
|
|
255
|
+
if (declared.length === livePin.length && declared.every((s, i) => s === livePin[i])) return null;
|
|
256
|
+
const say = (p: string[]): string => (p.length ? `search_path = ${p.join(', ')}` : 'no search_path');
|
|
257
|
+
return `declared ${say(declared)}, live has ${say(livePin)} — replaced from source`;
|
|
258
|
+
};
|
|
259
|
+
|
|
236
260
|
for (const src of source.objects) {
|
|
237
261
|
// --only restricts the primary decisions to the named identities; the rest are left
|
|
238
262
|
// untouched (they are not skips — they were never in scope, so they get no plan entry).
|
|
@@ -271,9 +295,16 @@ export function planReconcile(
|
|
|
271
295
|
// (live deparse), so the reconciler can't verify live == source. --baseline trusts it,
|
|
272
296
|
// --rebuild guarantees it by rebuilding from source (relations through the dependency graph
|
|
273
297
|
// below; functions replace in place), and the default flags it for a decision.
|
|
298
|
+
const pin = pinMismatch(src, liveObj);
|
|
274
299
|
if (options.rebuild) {
|
|
275
300
|
if (isRelation(src.kind)) rebuild.set(src.identity, 'rebuilt from source (first contact, --rebuild)');
|
|
276
301
|
else fnReplace.set(src.identity, 'replaced from source (first contact, --rebuild)');
|
|
302
|
+
} else if (pin) {
|
|
303
|
+
// --baseline asserts "live IS the source, trust me". Here we can PROVE it is not, so
|
|
304
|
+
// recording the assertion would bless the difference permanently — nothing downstream
|
|
305
|
+
// ever compares these two again. Converge instead, on every path: an object whose pin
|
|
306
|
+
// is measurably wrong is not unverifiable, it is verifiably different.
|
|
307
|
+
fnReplace.set(src.identity, `search_path differs from live: ${pin}`);
|
|
277
308
|
} else if (options.baseline) {
|
|
278
309
|
baseline.push(src.identity);
|
|
279
310
|
} else {
|
|
@@ -341,6 +372,15 @@ export function planReconcile(
|
|
|
341
372
|
else fnReplace.set(src.identity, 'source changed');
|
|
342
373
|
continue;
|
|
343
374
|
}
|
|
375
|
+
// Self-consistent provenance is not proof. A --baseline taken before B2, or a mistaken
|
|
376
|
+
// --rebaseline, records the declared source hash beside the live def hash without ever
|
|
377
|
+
// comparing them — so a declared pin the database does not have reads as up to date on
|
|
378
|
+
// every run. The structural read is what breaks that loop; it self-heals in one apply.
|
|
379
|
+
const pin = pinMismatch(src, liveObj);
|
|
380
|
+
if (pin) {
|
|
381
|
+
fnReplace.set(src.identity, `search_path differs from live: ${pin}`);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
344
384
|
// Up-to-date (src + live both match provenance). If provenance predates body_hash, record it
|
|
345
385
|
// once — a record-only backfill that ARMS the authz-only fast path for a future grant change.
|
|
346
386
|
// Converges: after this run the row has body_hash, so it skips cleanly next time.
|
|
@@ -430,8 +470,15 @@ export function planReconcile(
|
|
|
430
470
|
);
|
|
431
471
|
} else {
|
|
432
472
|
const target = parsed.target ?? (src.kind === 'function' ? `FUNCTION ${src.identity}` : src.identity);
|
|
433
|
-
|
|
473
|
+
// Functions carry ownership context; relations do not (relacl's NULL means owner-only
|
|
474
|
+
// and aclexplode(NULL) is empty, so a relation's grant set does not shift with its owner).
|
|
475
|
+
const owners = src.kind === 'function'
|
|
476
|
+
? { ...(src.owner !== undefined ? { declared: src.owner } : {}), ...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}) }
|
|
477
|
+
: undefined;
|
|
478
|
+
const { statements, suppressed } = diffObjectGrants(target, parsed.grants, liveObj.grants, owners);
|
|
434
479
|
if (statements.length > 0) regrants.push({ identity: src.identity, statements });
|
|
480
|
+
// Never silent: a withheld revoke is reported every run until the owner is declared.
|
|
481
|
+
for (const note of suppressed) extraWarnings.push(note);
|
|
435
482
|
}
|
|
436
483
|
}
|
|
437
484
|
|