@everystack/cli 0.4.48 → 0.4.50
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 +110 -9
- package/src/cli/authz-lint.ts +61 -0
- package/src/cli/commands/db-check.ts +12 -3
- package/src/cli/commands/db-pull.ts +16 -2
- 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 +95 -5
- package/src/cli/derived-render.ts +203 -33
- package/src/cli/index.ts +1 -1
- package/src/cli/parse-flags.ts +21 -1
- package/src/plugin.ts +23 -5
|
@@ -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
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
FunctionDescriptor, DependsOnRef,
|
|
33
33
|
} from '@everystack/model';
|
|
34
34
|
import { parseQualified } from './derived-source.js';
|
|
35
|
+
import { GOVERNED_VOCABULARY } from './authz-derive.js';
|
|
35
36
|
|
|
36
37
|
export interface DerivedAuthzGap {
|
|
37
38
|
/** `schema.name` of the descriptor that fails the gate. */
|
|
@@ -60,6 +61,10 @@ function isModelRef(ref: DependsOnRef): ref is ModelDescriptor {
|
|
|
60
61
|
* as zero-reach, which bricked every invoker view over a private table.
|
|
61
62
|
*/
|
|
62
63
|
export function tableReaches(m: ModelDescriptor, role: string): boolean {
|
|
64
|
+
// A recorded grant is reach too. PUBLIC SELECT is held by every role; a named grantee
|
|
65
|
+
// reaches itself. See grantsPublic — this is the half the gate kept forgetting.
|
|
66
|
+
if (grantsPublic(m.privileges, 'SELECT')) return true;
|
|
67
|
+
if (privilegeRoles(m.privileges, 'SELECT').includes(role)) return true;
|
|
63
68
|
for (const a of m.abilities) {
|
|
64
69
|
if (isColumnAbility(a)) {
|
|
65
70
|
// Only the READ half reaches: a column-scoped update compiles to `UPDATE (cols)`
|
|
@@ -79,6 +84,30 @@ export function tableReaches(m: ModelDescriptor, role: string): boolean {
|
|
|
79
84
|
return false;
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
/**
|
|
88
|
+
* REACH comes from abilities AND from recorded privileges. Forgetting the second half is a
|
|
89
|
+
* bug this repo has now made three times — the 63-byte ACL truncation, the SECDEF caller
|
|
90
|
+
* gate, and this one — always the same shape: a fact recorded as `privileges` falls out of
|
|
91
|
+
* a downstream read and lands in neither set.
|
|
92
|
+
*
|
|
93
|
+
* `PUBLIC` is the case that matters. `GRANT … TO PUBLIC` is held by EVERY role, so a
|
|
94
|
+
* dependency carrying one is reachable by all of them, and a live PUBLIC grant is exactly
|
|
95
|
+
* what `db:pull` RECORDS rather than inventing an audience for. Reading abilities alone
|
|
96
|
+
* made a faithful pull fail to COMPILE, with no hand-edit available to fix it.
|
|
97
|
+
*/
|
|
98
|
+
function grantsPublic(privileges: Record<string, string[]> | undefined, privilege: string): boolean {
|
|
99
|
+
return Object.entries(privileges ?? {}).some(
|
|
100
|
+
([grantee, privs]) => grantee.toUpperCase() === 'PUBLIC' && privs.some((p) => p.toUpperCase() === privilege),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Grantees a recorded privilege names directly — reach for that role, nobody else. */
|
|
105
|
+
function privilegeRoles(privileges: Record<string, string[]> | undefined, privilege: string): string[] {
|
|
106
|
+
return Object.entries(privileges ?? {})
|
|
107
|
+
.filter(([, privs]) => privs.some((p) => p.toUpperCase() === privilege))
|
|
108
|
+
.map(([grantee]) => grantee);
|
|
109
|
+
}
|
|
110
|
+
|
|
82
111
|
/** The roles a relation's grants reach — bare read is anon + authenticated (the table precedent). */
|
|
83
112
|
function relationRoles(d: ViewDescriptor | MaterializedViewDescriptor): Set<string> {
|
|
84
113
|
const roles = new Set<string>();
|
|
@@ -89,12 +118,15 @@ function relationRoles(d: ViewDescriptor | MaterializedViewDescriptor): Set<stri
|
|
|
89
118
|
roles.add('authenticated');
|
|
90
119
|
}
|
|
91
120
|
}
|
|
121
|
+
for (const r of privilegeRoles(d.privileges, 'SELECT')) roles.add(r);
|
|
92
122
|
return roles;
|
|
93
123
|
}
|
|
94
124
|
|
|
95
125
|
function functionRoles(fn: FunctionDescriptor): Set<string> {
|
|
96
126
|
// define-time already rejected bare can('execute') — every ability carries a role.
|
|
97
|
-
|
|
127
|
+
const roles = new Set(fn.abilities.map((a) => a.condition.role!));
|
|
128
|
+
for (const r of privilegeRoles(fn.privileges, 'EXECUTE')) roles.add(r);
|
|
129
|
+
return roles;
|
|
98
130
|
}
|
|
99
131
|
|
|
100
132
|
// ---------------------------------------------------------------------------
|
|
@@ -132,11 +164,32 @@ export function findDerivedReadGaps(derived: readonly DerivedDescriptor[]): Deri
|
|
|
132
164
|
* is privileged code with an undeclared audience — callable by nobody today (PUBLIC is
|
|
133
165
|
* revoked unconditionally) and by whoever gets a grant tomorrow, with no declaration to
|
|
134
166
|
* review. Invoker functions may stay dark: unprivileged, owner-callable, honest.
|
|
167
|
+
*
|
|
168
|
+
* TRIGGER FUNCTIONS ARE EXEMPT, and the exemption is structural rather than a courtesy.
|
|
169
|
+
* A trigger function has no caller role to declare: PostgreSQL performs no EXECUTE check
|
|
170
|
+
* on the invoking role when a trigger fires, so its audience is the table event, not a
|
|
171
|
+
* grantee. Live ones therefore carry no EXECUTE grant, and the gate could only be
|
|
172
|
+
* satisfied by declaring one — which compiles to a GRANT the database never had. That is
|
|
173
|
+
* the same widening this module's read/manage siblings exist to prevent, arriving on the
|
|
174
|
+
* function branch, and it made a faithful pull of a SECDEF trigger function unshippable:
|
|
175
|
+
* the adopter's only way past db:check was to widen their own database.
|
|
176
|
+
*
|
|
177
|
+
* The discriminator is `returns === 'trigger'` — the return type, never the name. It is
|
|
178
|
+
* the same fact `trigger()` already relies on (model/src/derived.ts, which refuses an
|
|
179
|
+
* `execute:` whose function does not return `trigger`), so the two agree by construction.
|
|
180
|
+
*
|
|
181
|
+
* A RECORDED grant counts as a declared caller too. `privileges: { PUBLIC: ['EXECUTE'] }`
|
|
182
|
+
* is `db:pull` saying "the database grants this" — the audience IS declared and reviewable,
|
|
183
|
+
* it simply was not chosen by the author. The gap gate is about undeclared audience, so it
|
|
184
|
+
* stops firing; the objection moves to `findPublicExecutableSecdef` below, which is a
|
|
185
|
+
* warning. Abilities are opinion, privileges are fact, and the linter holds the opinions.
|
|
135
186
|
*/
|
|
136
187
|
export function findSecdefExecuteGaps(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
|
|
137
188
|
const gaps: DerivedAuthzGap[] = [];
|
|
138
189
|
for (const d of derived) {
|
|
139
190
|
if (d.kind !== 'function' || d.security !== 'definer' || d.abilities.length > 0) continue;
|
|
191
|
+
if (d.returns === 'trigger') continue;
|
|
192
|
+
if (Object.keys(d.privileges ?? {}).length > 0) continue;
|
|
140
193
|
const identity = identityOf(d);
|
|
141
194
|
gaps.push({
|
|
142
195
|
identity,
|
|
@@ -148,6 +201,38 @@ export function findSecdefExecuteGaps(derived: readonly DerivedDescriptor[]): De
|
|
|
148
201
|
return gaps;
|
|
149
202
|
}
|
|
150
203
|
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Gate: SECURITY DEFINER executable by PUBLIC (db:check warn)
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Privileged code any role can call. This is where the objection to a PUBLIC EXECUTE grant
|
|
210
|
+
* lives now that the grant is spellable — a WARN, not a failure, because on an adopted
|
|
211
|
+
* database it is a faithful report of what is already true, and failing CI on a faithful
|
|
212
|
+
* pull would make `db:pull` unusable on exactly the schemas it exists for. (Same call, and
|
|
213
|
+
* the same reasoning, as `findNakedGrants` on the table side.)
|
|
214
|
+
*
|
|
215
|
+
* Only DEFINER functions: an invoker function executes with the caller's own rights, so
|
|
216
|
+
* PUBLIC EXECUTE on one grants no authority the caller did not already have.
|
|
217
|
+
*/
|
|
218
|
+
export function findPublicExecutableSecdef(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
|
|
219
|
+
const warnings: DerivedAuthzGap[] = [];
|
|
220
|
+
for (const d of derived) {
|
|
221
|
+
if (d.kind !== 'function' || d.security !== 'definer') continue;
|
|
222
|
+
if (d.returns === 'trigger') continue;
|
|
223
|
+
if (!Object.keys(d.privileges ?? {}).some((g) => g.toUpperCase() === 'PUBLIC')) continue;
|
|
224
|
+
const identity = identityOf(d);
|
|
225
|
+
warnings.push({
|
|
226
|
+
identity,
|
|
227
|
+
message:
|
|
228
|
+
`${identity} is SECURITY DEFINER and executable by PUBLIC — it runs with its owner's rights and every ` +
|
|
229
|
+
`role can call it, including anon. This is recorded as live fact, not a choice: confirm the body is safe ` +
|
|
230
|
+
`for an untrusted caller, then narrow it to can('execute', { role: '…' }) and drop the PUBLIC privilege.`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return warnings;
|
|
234
|
+
}
|
|
235
|
+
|
|
151
236
|
// ---------------------------------------------------------------------------
|
|
152
237
|
// Gate: matview snapshot over row-scoped sources (db:check warn)
|
|
153
238
|
// ---------------------------------------------------------------------------
|
|
@@ -209,7 +294,7 @@ function findUnreachable(role: string, refs: readonly DependsOnRef[], seen: Set<
|
|
|
209
294
|
}
|
|
210
295
|
switch (ref.kind) {
|
|
211
296
|
case 'view': {
|
|
212
|
-
if (ref.private || !relationRoles(ref).has(role)) return identityOf(ref);
|
|
297
|
+
if (ref.private || (!relationRoles(ref).has(role) && !grantsPublic(ref.privileges, 'SELECT'))) return identityOf(ref);
|
|
213
298
|
// An invoker dep re-checks the caller one level down; a definer dep reads as its owner.
|
|
214
299
|
if (ref.securityInvoker) {
|
|
215
300
|
const deeper = findUnreachable(role, ref.dependsOn, seen);
|
|
@@ -219,11 +304,11 @@ function findUnreachable(role: string, refs: readonly DependsOnRef[], seen: Set<
|
|
|
219
304
|
}
|
|
220
305
|
case 'materialized view':
|
|
221
306
|
// A matview is its own snapshot: SELECT on the matview is the whole requirement.
|
|
222
|
-
if (ref.private || !relationRoles(ref).has(role)) return identityOf(ref);
|
|
307
|
+
if (ref.private || (!relationRoles(ref).has(role) && !grantsPublic(ref.privileges, 'SELECT'))) return identityOf(ref);
|
|
223
308
|
break;
|
|
224
309
|
case 'function':
|
|
225
310
|
// The body calls it with the caller's rights — EXECUTE is part of reach.
|
|
226
|
-
if (!functionRoles(ref).has(role)) return identityOf(ref);
|
|
311
|
+
if (!functionRoles(ref).has(role) && !grantsPublic(ref.privileges, 'EXECUTE')) return identityOf(ref);
|
|
227
312
|
break;
|
|
228
313
|
case 'sql':
|
|
229
314
|
break; // opaque — never a false positive
|
|
@@ -243,7 +328,12 @@ export function findInvokerReachabilityGaps(derived: readonly DerivedDescriptor[
|
|
|
243
328
|
for (const d of derived) {
|
|
244
329
|
if (d.kind !== 'view' || !d.securityInvoker || d.private || d.abilities.length === 0) continue;
|
|
245
330
|
const identity = identityOf(d);
|
|
246
|
-
|
|
331
|
+
// Only roles the declared world can SATISFY. A grantee outside the compiler's
|
|
332
|
+
// vocabulary is recorded fact on the view AND on the table (the tables path never
|
|
333
|
+
// renders one as an ability), so table-side reach for it is not expressible — checking
|
|
334
|
+
// it is a demand no model can ever meet. Their reach is adopted via authz-baseline,
|
|
335
|
+
// outside this gate. PUBLIC is governed and stays checked.
|
|
336
|
+
for (const role of [...relationRoles(d)].filter((r) => GOVERNED_VOCABULARY.has(r)).sort()) {
|
|
247
337
|
const unreachable = findUnreachable(role, d.dependsOn, new Set());
|
|
248
338
|
if (unreachable) {
|
|
249
339
|
gaps.push({
|