@everystack/cli 0.4.53 → 0.4.56
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-reconcile.ts +146 -1
- package/src/cli/authz-render.ts +26 -4
- package/src/cli/commands/db-build.ts +153 -0
- package/src/cli/commands/db-check.ts +20 -3
- package/src/cli/commands/db-fingerprint.ts +16 -2
- package/src/cli/commands/db-generate.ts +3 -3
- package/src/cli/commands/db-plan.ts +7 -1
- package/src/cli/commands/db-pull.ts +6 -1
- package/src/cli/commands/db-reconcile.ts +41 -2
- package/src/cli/commands/db-sync.ts +5 -2
- package/src/cli/commands/security.ts +24 -3
- package/src/cli/db-build.ts +36 -1
- package/src/cli/declared-derived.ts +11 -0
- 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/edge-plan.ts +7 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/migration-generate.ts +24 -1
- package/src/cli/model-render.ts +28 -3
- package/src/cli/schema-compile.ts +75 -12
- package/src/cli/schema-diff.ts +133 -14
- package/src/cli/schema-fingerprint.ts +59 -6
- package/src/cli/schema-introspect.ts +53 -3
- package/src/cli/schema-source.ts +54 -8
- package/src/cli/security-audit.ts +131 -2
- package/src/cli/security-catalog.ts +18 -3
- package/src/cli/state-apply.ts +11 -2
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { IGNORED_SCHEMAS, coerceBool, type QueryRunner } from './authz-contract.js';
|
|
17
17
|
import { INTROSPECTION_SESSION, type SessionRunner } from './session.js';
|
|
18
|
+
import { normalizeDeparsedExpr } from './deparse-normal.js';
|
|
18
19
|
|
|
19
20
|
// ---------------------------------------------------------------------------
|
|
20
21
|
// The data-layer snapshot — the structured shape both producers target.
|
|
@@ -28,10 +29,23 @@ export interface ColumnSchema {
|
|
|
28
29
|
notNull: boolean;
|
|
29
30
|
/** The DEFAULT expression (deparsed), or null. e.g. `now()`, `gen_random_uuid()`, `'draft'::text`. */
|
|
30
31
|
default: string | null;
|
|
32
|
+
/**
|
|
33
|
+
* `GENERATED ALWAYS AS IDENTITY` / `GENERATED BY DEFAULT AS IDENTITY`, absent on a plain column.
|
|
34
|
+
*
|
|
35
|
+
* An identity column's auto-value lives in `pg_attribute.attidentity`, NOT in a default — so a
|
|
36
|
+
* read that only collects `pg_attrdef` sees a bare `bigint` and a rebuild produces a column with
|
|
37
|
+
* no auto-value at all. Every INSERT that omits the column then fails on NOT NULL. Measured on a
|
|
38
|
+
* consumer's `refresh_tokens.id`: token issuance died on a database built from the models.
|
|
39
|
+
*
|
|
40
|
+
* Identity implies NOT NULL in Postgres, so `notNull` is always true alongside it.
|
|
41
|
+
*/
|
|
42
|
+
identity?: 'always' | 'byDefault';
|
|
31
43
|
}
|
|
32
44
|
|
|
33
45
|
export interface ForeignKey {
|
|
34
46
|
name: string;
|
|
47
|
+
/** The name was DECLARED, not generated — see {@link IndexSchema.nameDeclared}, same rule. */
|
|
48
|
+
nameDeclared?: boolean;
|
|
35
49
|
columns: string[];
|
|
36
50
|
refTable: string;
|
|
37
51
|
refColumns: string[];
|
|
@@ -54,6 +68,16 @@ export interface CheckConstraint {
|
|
|
54
68
|
/** A standalone index (NOT one backing a PK or UNIQUE constraint — those are excluded). */
|
|
55
69
|
export interface IndexSchema {
|
|
56
70
|
name: string;
|
|
71
|
+
/**
|
|
72
|
+
* The name was DECLARED by the model, not generated from the columns.
|
|
73
|
+
*
|
|
74
|
+
* Declared-side only: introspection never sets it (the database has a name but no opinion
|
|
75
|
+
* about whether anyone chose it), and it is absent from `indexKey`, so content matching and
|
|
76
|
+
* the fingerprint are untouched. The diff reads it to decide whether a name difference is an
|
|
77
|
+
* intended `ALTER INDEX … RENAME TO` or just the generated fallback, which must never rename
|
|
78
|
+
* an adopted database to a name nobody chose.
|
|
79
|
+
*/
|
|
80
|
+
nameDeclared?: boolean;
|
|
57
81
|
/** Canonical key text per position: a plain column name, or anything richer verbatim —
|
|
58
82
|
* `lower(email)`, `created_at DESC`, `title text_pattern_ops` (Brick E: expression
|
|
59
83
|
* indexes, direction, and opclass are IDENTITY, not noise). Default ASC is stripped. */
|
|
@@ -126,6 +150,9 @@ SELECT
|
|
|
126
150
|
format_type(a.atttypid, a.atttypmod) AS type,
|
|
127
151
|
a.attnotnull AS not_null,
|
|
128
152
|
pg_get_expr(d.adbin, d.adrelid) AS "default",
|
|
153
|
+
-- 'a' = GENERATED ALWAYS AS IDENTITY, 'd' = GENERATED BY DEFAULT, '' = a plain column.
|
|
154
|
+
-- An identity column carries NO pg_attrdef row, so without this the auto-value is invisible.
|
|
155
|
+
a.attidentity AS identity,
|
|
129
156
|
a.attnum AS position
|
|
130
157
|
FROM pg_attribute a
|
|
131
158
|
JOIN pg_class c ON c.oid = a.attrelid
|
|
@@ -301,9 +328,19 @@ export interface ColumnRow {
|
|
|
301
328
|
type: unknown;
|
|
302
329
|
not_null: unknown;
|
|
303
330
|
default: unknown;
|
|
331
|
+
/** `pg_attribute.attidentity` — 'a', 'd', or '' / absent on a read that predates the column. */
|
|
332
|
+
identity?: unknown;
|
|
304
333
|
position: unknown;
|
|
305
334
|
}
|
|
306
335
|
|
|
336
|
+
/** `attidentity` → the IR's spelling. Anything else (including '') is a plain column. */
|
|
337
|
+
export function identityKind(attidentity: unknown): 'always' | 'byDefault' | undefined {
|
|
338
|
+
const c = attidentity == null ? '' : String(attidentity);
|
|
339
|
+
if (c === 'a') return 'always';
|
|
340
|
+
if (c === 'd') return 'byDefault';
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
307
344
|
/**
|
|
308
345
|
* Drop a precision qualifier that only restates the default.
|
|
309
346
|
*
|
|
@@ -333,6 +370,7 @@ export { canonicalColumnType };
|
|
|
333
370
|
|
|
334
371
|
export function columnRowToDescriptor(row: ColumnRow): { table: string; column: ColumnSchema; position: number } {
|
|
335
372
|
const def = row.default == null ? null : String(row.default);
|
|
373
|
+
const identity = identityKind(row.identity);
|
|
336
374
|
return {
|
|
337
375
|
table: `${row.schema}.${row.table}`,
|
|
338
376
|
position: Number(row.position),
|
|
@@ -341,6 +379,9 @@ export function columnRowToDescriptor(row: ColumnRow): { table: string; column:
|
|
|
341
379
|
type: canonicalColumnType(String(row.type)),
|
|
342
380
|
notNull: coerceBool(row.not_null),
|
|
343
381
|
default: def && def.length > 0 ? def : null,
|
|
382
|
+
// Omitted, never `undefined`-valued: the key is absent on a plain column so every
|
|
383
|
+
// structural comparison of a pre-identity snapshot against a fresh one stays equal.
|
|
384
|
+
...(identity ? { identity } : {}),
|
|
344
385
|
},
|
|
345
386
|
};
|
|
346
387
|
}
|
|
@@ -409,9 +450,12 @@ export function constraintRowToDescriptor(row: ConstraintRow): ConstraintDescrip
|
|
|
409
450
|
onUpdate: action('UPDATE'),
|
|
410
451
|
};
|
|
411
452
|
}
|
|
412
|
-
// check — keep the predicate text inside the outer CHECK ( ... )
|
|
453
|
+
// check — keep the predicate text inside the outer CHECK ( ... ), normalized.
|
|
454
|
+
// `pg_get_expr` output is NOT a fixed point under re-parse: dump a CHECK and restore it
|
|
455
|
+
// and the same server deparses it differently (measured 2026-08-10, PG13-18 alike). A
|
|
456
|
+
// restored clone would then fingerprint differently from the database it was cloned from.
|
|
413
457
|
const m = def.match(/^CHECK\s*\((.*)\)$/s);
|
|
414
|
-
return { table, name: row.name, kind: 'check', expr: m ? m[1].trim() : def };
|
|
458
|
+
return { table, name: row.name, kind: 'check', expr: normalizeDeparsedExpr(m ? m[1].trim() : def) };
|
|
415
459
|
}
|
|
416
460
|
|
|
417
461
|
// ---------------------------------------------------------------------------
|
|
@@ -491,6 +535,9 @@ export function parseIndexDefinition(definition: string): Omit<IndexSchema, 'nam
|
|
|
491
535
|
let where = whereMatch ? whereMatch[1].trim() : '';
|
|
492
536
|
// pg_get_indexdef wraps the predicate in one pair of parens — strip it.
|
|
493
537
|
if (where.startsWith('(') && parenGroup(where, 0) === where.slice(1, -1)) where = where.slice(1, -1).trim();
|
|
538
|
+
// Same fixed-point failure as CHECK predicates: a partial index's WHERE survives a
|
|
539
|
+
// dump/restore as different text for the same predicate. Normalize at the producer.
|
|
540
|
+
where = normalizeDeparsedExpr(where);
|
|
494
541
|
return {
|
|
495
542
|
columns,
|
|
496
543
|
unique: /^CREATE\s+UNIQUE\s+INDEX/i.test(definition),
|
|
@@ -504,7 +551,10 @@ export function parseIndexDefinition(definition: string): Omit<IndexSchema, 'nam
|
|
|
504
551
|
* a legacy row without one keeps the old plain-columns path, so fixtures stay valid.
|
|
505
552
|
* `is_unique`/`predicate`/`method` row fields take precedence over the parsed text. */
|
|
506
553
|
export function indexRowToDescriptor(row: IndexRow): { table: string; index: IndexSchema } {
|
|
507
|
-
|
|
554
|
+
// The row's own `predicate` column takes precedence over the parsed definition text, so
|
|
555
|
+
// it is the producer that must normalize — normalizing only the parse path left the
|
|
556
|
+
// predicate unstable across a dump/restore and the fingerprint with it.
|
|
557
|
+
const pred = row.predicate == null ? '' : normalizeDeparsedExpr(String(row.predicate));
|
|
508
558
|
const table = `${row.schema}.${row.table}`;
|
|
509
559
|
const base = { name: row.name, unique: coerceBool(row.is_unique), ...(pred ? { where: pred } : {}) };
|
|
510
560
|
|
package/src/cli/schema-source.ts
CHANGED
|
@@ -55,12 +55,23 @@ function strLiteral(value: string): string {
|
|
|
55
55
|
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
58
|
+
/**
|
|
59
|
+
* A `.default(<v>)` argument literal for a value default (the JSON forms drizzle accepts).
|
|
60
|
+
*
|
|
61
|
+
* `builder` is the drizzle column builder the value is a default FOR, because the accepted
|
|
62
|
+
* TYPE of the literal depends on it: drizzle's `numeric`/`decimal` take a STRING, since an
|
|
63
|
+
* arbitrary-precision default routed through a JS number is lossy by construction. A pulled
|
|
64
|
+
* model renders `field.numeric(4, 2).default(0.15)` — the model vocabulary accepting a number
|
|
65
|
+
* there is fine — and passing that straight through produced `.default(0.15)`, which fails
|
|
66
|
+
* `tsc --strict` with TS2345 and blocked a consumer from adopting the generated artifact.
|
|
67
|
+
*/
|
|
68
|
+
function defaultValueLiteral(value: unknown, builder?: string): string {
|
|
60
69
|
if (typeof value === 'string') return strLiteral(value);
|
|
61
70
|
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
62
71
|
if (value === null) return 'null';
|
|
63
|
-
if (typeof value === 'number')
|
|
72
|
+
if (typeof value === 'number') {
|
|
73
|
+
return builder === 'numeric' || builder === 'decimal' ? strLiteral(String(value)) : String(value);
|
|
74
|
+
}
|
|
64
75
|
// Arrays / objects: a JSON literal is valid TS for jsonb defaults.
|
|
65
76
|
return JSON.stringify(value);
|
|
66
77
|
}
|
|
@@ -130,6 +141,18 @@ function baseColumnSource(columnName: string, spec: FieldSpec): { call: string;
|
|
|
130
141
|
}
|
|
131
142
|
}
|
|
132
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Whether the drizzle builder for this column carries `generatedAlwaysAsIdentity`.
|
|
146
|
+
*
|
|
147
|
+
* Only the integer family does (`PgIntColumnBaseBuilder`). A `field.pgType('smallint')`
|
|
148
|
+
* identity column compiles to `customType`, which has no identity builder — the DDL still
|
|
149
|
+
* carries the clause (that comes from the compiler, not from here), so the DATABASE is right
|
|
150
|
+
* and only the generated TS type is imprecise: it will ask for a value the database supplies.
|
|
151
|
+
*/
|
|
152
|
+
function identitySupported(builder?: string): boolean {
|
|
153
|
+
return builder === 'integer' || builder === 'bigint' || builder === 'smallint';
|
|
154
|
+
}
|
|
155
|
+
|
|
133
156
|
/** The hoisted const name for an enum type. `status` → `statusEnum`. */
|
|
134
157
|
function enumConstName(enumName: string): string {
|
|
135
158
|
return `${toCamelCase(enumName)}Enum`;
|
|
@@ -159,15 +182,22 @@ function columnModifiers(
|
|
|
159
182
|
spec: FieldSpec,
|
|
160
183
|
isComposite: boolean,
|
|
161
184
|
modelsByDescriptor: Map<ModelDescriptor, { camel: string }>,
|
|
185
|
+
ctx: { builder?: string; self?: ModelDescriptor; usedTypes?: Set<string> } = {},
|
|
162
186
|
): string {
|
|
163
187
|
let s = '';
|
|
164
188
|
if (spec.isArray) s += '.array()';
|
|
165
|
-
|
|
189
|
+
// Identity comes FIRST and takes `.notNull()` with it: drizzle's builder sets notNull and
|
|
190
|
+
// hasDefault itself, and `hasDefault` is what makes the column OPTIONAL on insert. Without
|
|
191
|
+
// it the generated type demands an `id` the database supplies — which is how a correct
|
|
192
|
+
// database still fails to compile at the call site.
|
|
193
|
+
const identity = identitySupported(ctx.builder) ? spec.identity : undefined;
|
|
194
|
+
if (identity) s += identity === 'always' ? '.generatedAlwaysAsIdentity()' : '.generatedByDefaultAsIdentity()';
|
|
195
|
+
else if (spec.isNotNull) s += '.notNull()';
|
|
166
196
|
if (spec.isUnique) s += '.unique()';
|
|
167
197
|
|
|
168
198
|
if (spec.defaultKind === 'now') s += '.defaultNow()';
|
|
169
199
|
else if (spec.defaultKind === 'random') s += '.defaultRandom()';
|
|
170
|
-
else if (spec.defaultKind === 'value') s += `.default(${defaultValueLiteral(spec.default)})`;
|
|
200
|
+
else if (spec.defaultKind === 'value') s += `.default(${defaultValueLiteral(spec.default, ctx.builder)})`;
|
|
171
201
|
else if (spec.defaultKind === 'sql') s += `.default(sql.raw(${strLiteral(String(spec.default))}))`;
|
|
172
202
|
|
|
173
203
|
if (spec.isPrimaryKey && !isComposite) s += '.primaryKey()';
|
|
@@ -181,7 +211,17 @@ function columnModifiers(
|
|
|
181
211
|
if (spec.onDelete) opts.push(`onDelete: ${strLiteral(spec.onDelete)}`);
|
|
182
212
|
if (spec.onUpdate) opts.push(`onUpdate: ${strLiteral(spec.onUpdate)}`);
|
|
183
213
|
const optsText = opts.length ? `, { ${opts.join(', ')} }` : '';
|
|
184
|
-
s
|
|
214
|
+
// A SELF-referential FK needs drizzle's documented return-type annotation. Without it
|
|
215
|
+
// the table's own initializer references itself, TypeScript cannot infer it, and under
|
|
216
|
+
// `strict` it is TS7022 + TS7024 — which does not merely warn: the whole table
|
|
217
|
+
// degrades to `any`, so every query against it silently loses type safety in a project
|
|
218
|
+
// that otherwise builds. A reference to ANOTHER table infers fine and is left alone.
|
|
219
|
+
if (ctx.self && target === ctx.self) {
|
|
220
|
+
ctx.usedTypes?.add('AnyPgColumn');
|
|
221
|
+
s += `.references((): AnyPgColumn => ${entry.camel}.${parentPk}${optsText})`;
|
|
222
|
+
} else {
|
|
223
|
+
s += `.references(() => ${entry.camel}.${parentPk}${optsText})`;
|
|
224
|
+
}
|
|
185
225
|
}
|
|
186
226
|
// A reference to a model OUTSIDE the emitted set (a cross-schema FK) is omitted —
|
|
187
227
|
// the runtime + relations never needed it; the DB constraint stays unmanaged.
|
|
@@ -333,6 +373,9 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
333
373
|
|
|
334
374
|
// --- Track which pg-core builders + drizzle-orm symbols are used ----------
|
|
335
375
|
const pgCoreBuilders = new Set<string>();
|
|
376
|
+
// TYPE-only imports from pg-core (today: AnyPgColumn, for self-referential FKs). Kept apart
|
|
377
|
+
// from the value imports so the emitted `import type` line carries no runtime cost.
|
|
378
|
+
const pgCoreTypes = new Set<string>();
|
|
336
379
|
// Only when something actually lands in `public` — an all-non-public model set would
|
|
337
380
|
// otherwise import a builder it never calls.
|
|
338
381
|
if (models.some((m) => m.schema === 'public')) pgCoreBuilders.add('pgTable');
|
|
@@ -353,7 +396,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
353
396
|
const { call, builder: pgBuilder } = baseColumnSource(toSnakeCase(key), spec);
|
|
354
397
|
pgCoreBuilders.add(pgBuilder);
|
|
355
398
|
if (spec.defaultKind === 'sql') pgCoreBuilders.add('sql'); // imported from 'drizzle-orm', handled below
|
|
356
|
-
const mods = columnModifiers(spec, isComposite, modelsByDescriptor);
|
|
399
|
+
const mods = columnModifiers(spec, isComposite, modelsByDescriptor, { builder: pgBuilder, self: model, usedTypes: pgCoreTypes });
|
|
357
400
|
if (spec.isDeprecated) {
|
|
358
401
|
// The contract phase (decision 13): the column stays in the database and
|
|
359
402
|
// stays readable; the strikethrough warns new code away at author time.
|
|
@@ -457,7 +500,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
457
500
|
pgCoreBuilders.add(pgBuilder);
|
|
458
501
|
// Only .array()/.notNull() can appear — defineView/defineMaterializedView
|
|
459
502
|
// reject every table-structural modifier at define time.
|
|
460
|
-
const mods = columnModifiers(spec, true, modelsByDescriptor);
|
|
503
|
+
const mods = columnModifiers(spec, true, modelsByDescriptor, { builder: pgBuilder });
|
|
461
504
|
colLines.push(` ${key}: ${call}${mods},`);
|
|
462
505
|
}
|
|
463
506
|
const cols = `{\n${colLines.join('\n')}\n}`;
|
|
@@ -501,6 +544,9 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
501
544
|
|
|
502
545
|
const importLines: string[] = [];
|
|
503
546
|
importLines.push(`import { ${pgCoreNames.join(', ')} } from 'drizzle-orm/pg-core';`);
|
|
547
|
+
if (pgCoreTypes.size) {
|
|
548
|
+
importLines.push(`import type { ${[...pgCoreTypes].sort().join(', ')} } from 'drizzle-orm/pg-core';`);
|
|
549
|
+
}
|
|
504
550
|
|
|
505
551
|
const drizzleOrmNames: string[] = [];
|
|
506
552
|
if (anyRelations) drizzleOrmNames.push('relations');
|
|
@@ -43,6 +43,12 @@ export interface FunctionDescriptor {
|
|
|
43
43
|
* A SECURITY DEFINER function owned by such a role is the maximum blast radius.
|
|
44
44
|
*/
|
|
45
45
|
ownerBypassesRls?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* SUPERUSER (or rds_superuser member) specifically — the portable half of ownership.
|
|
48
|
+
* A scoped operator is often BYPASSRLS by design, so `ownerBypassesRls` alone cannot tell
|
|
49
|
+
* a correct design from a function left owned by the build identity. This can.
|
|
50
|
+
*/
|
|
51
|
+
ownerIsSuperuser?: boolean;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
export interface FunctionFinding {
|
|
@@ -86,12 +92,40 @@ export interface Waivers {
|
|
|
86
92
|
secdef?: Record<string, string>;
|
|
87
93
|
/** `schema.view` -> declared public column allowlist. */
|
|
88
94
|
publicColumns?: Record<string, string[]>;
|
|
95
|
+
/** schema name -> justification for leaving it broadly writable. Same rule: no bare opt-out. */
|
|
96
|
+
schema?: Record<string, string>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A schema and the two facts that decide whether it is a shadowing surface: who can CREATE in
|
|
101
|
+
* it, and what privileged code resolves through it.
|
|
102
|
+
*/
|
|
103
|
+
export interface SchemaDescriptor {
|
|
104
|
+
schema: string;
|
|
105
|
+
/** Grantees holding CREATE on the schema. `PUBLIC` is the pseudo-role entry. */
|
|
106
|
+
createGrantees: string[];
|
|
107
|
+
/** SECURITY DEFINER functions living in it. */
|
|
108
|
+
definerFunctions: number;
|
|
109
|
+
/** …of which carry no `SET search_path`. */
|
|
110
|
+
unpinnedDefinerFunctions: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface SchemaFinding {
|
|
114
|
+
/** The schema name — the waiver-match key. */
|
|
115
|
+
key: string;
|
|
116
|
+
schema: string;
|
|
117
|
+
severity: Severity;
|
|
118
|
+
reasons: string[];
|
|
119
|
+
waived: boolean;
|
|
120
|
+
waiverReason?: string;
|
|
89
121
|
}
|
|
90
122
|
|
|
91
123
|
export interface AuditReport {
|
|
92
124
|
functions: FunctionFinding[];
|
|
93
125
|
views: ViewFinding[];
|
|
94
|
-
/**
|
|
126
|
+
/** Writable-schema findings — absent when no schema ACLs were supplied (static path). */
|
|
127
|
+
schemas: SchemaFinding[];
|
|
128
|
+
/** Counts across every invariant, excluding waived. */
|
|
95
129
|
red: number;
|
|
96
130
|
warn: number;
|
|
97
131
|
}
|
|
@@ -181,6 +215,68 @@ export function classifyFunction(
|
|
|
181
215
|
return { key, schema: fn.schema, name: fn.name, returnType: fn.returnType, returnClass, severity, reasons, waived: false };
|
|
182
216
|
}
|
|
183
217
|
|
|
218
|
+
/**
|
|
219
|
+
* A10(ii) — the PRECONDITION leg: can an untrusted role CREATE in a schema that privileged code
|
|
220
|
+
* resolves through?
|
|
221
|
+
*
|
|
222
|
+
* The audit already flags an unpinned SECURITY DEFINER function and an owner that bypasses RLS.
|
|
223
|
+
* Both are about the privileged code. Neither says whether an attacker can actually plant
|
|
224
|
+
* anything for it to resolve to, and that is what separates latent debt from a live chain. A
|
|
225
|
+
* consumer asked for exactly this after finding `PUBLIC` holding CREATE on `public` across every
|
|
226
|
+
* dump-adopted database (the pre-PG15 default; PG15 removed it, so a database everystack BUILDS
|
|
227
|
+
* is hardened and one it ADOPTS is not).
|
|
228
|
+
*
|
|
229
|
+
* Tiering, and the reason it is not simply "red when SECDEF is present":
|
|
230
|
+
*
|
|
231
|
+
* - writable by a broad role, with an UNPINNED definer function in the same schema → RED.
|
|
232
|
+
* That is the whole chain: plant a shadow object, the unpinned function resolves through it
|
|
233
|
+
* and runs it with the owner's rights.
|
|
234
|
+
* - writable by a broad role, everything else → WARN. Pinning the definer functions defends
|
|
235
|
+
* that specific chain and does NOT make the schema safe: a PUBLIC-writable schema on a
|
|
236
|
+
* search path threatens EVERY privileged session resolving through it, including
|
|
237
|
+
* everystack's own build/reconcile credential executing DDL with `public` on its path. A
|
|
238
|
+
* shadow object planted there hijacks the builder, which outranks any SECDEF owner. This is
|
|
239
|
+
* why the finding does not require a definer function to fire.
|
|
240
|
+
*
|
|
241
|
+
* Only BROAD grantees count. A named operator role holding CREATE is how schemas get built.
|
|
242
|
+
*/
|
|
243
|
+
export function classifySchema(schema: SchemaDescriptor, waivers: Waivers = {}): SchemaFinding {
|
|
244
|
+
const key = schema.schema;
|
|
245
|
+
const reasons: string[] = [];
|
|
246
|
+
let severity: Severity = 'ok';
|
|
247
|
+
const broad = schema.createGrantees.filter((g) => BROAD_ROLES.has(g.toLowerCase())).sort();
|
|
248
|
+
|
|
249
|
+
if (broad.length > 0) {
|
|
250
|
+
const who = broad.join(', ');
|
|
251
|
+
if (schema.unpinnedDefinerFunctions > 0) {
|
|
252
|
+
reasons.push(
|
|
253
|
+
`${who} can CREATE in schema "${key}", which holds ${schema.unpinnedDefinerFunctions} unpinned SECURITY DEFINER function(s) — `
|
|
254
|
+
+ `that is a complete search_path hijack chain: plant a shadow object, the unpinned function resolves through it and runs it with the owner's rights. `
|
|
255
|
+
+ `REVOKE CREATE ON SCHEMA ${key} FROM ${who}, and pin those functions.`,
|
|
256
|
+
);
|
|
257
|
+
severity = 'red';
|
|
258
|
+
} else {
|
|
259
|
+
reasons.push(
|
|
260
|
+
`${who} can CREATE in schema "${key}" — the pre-PG15 default, which PG15 removed. No unpinned SECURITY DEFINER function is here, `
|
|
261
|
+
+ `but a writable schema on a search path threatens every privileged session that resolves through it, including the build credential `
|
|
262
|
+
+ `running DDL. REVOKE CREATE ON SCHEMA ${key} FROM ${who}.`,
|
|
263
|
+
);
|
|
264
|
+
severity = raise(severity, 'warn');
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const waiverReason = waivers.schema?.[key];
|
|
269
|
+
if (waiverReason !== undefined) {
|
|
270
|
+
if (typeof waiverReason === 'string' && waiverReason.trim().length > 0) {
|
|
271
|
+
return { key, schema: key, severity: 'ok', reasons, waived: true, waiverReason };
|
|
272
|
+
}
|
|
273
|
+
reasons.push('waiver present but missing a justification string — a waiver must explain why the schema is safe');
|
|
274
|
+
severity = raise(severity, 'red');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { key, schema: key, severity, reasons, waived: false };
|
|
278
|
+
}
|
|
279
|
+
|
|
184
280
|
/** Apply Invariant B to a single view/projection. */
|
|
185
281
|
export function classifyView(view: ViewDescriptor, waivers: Waivers = {}): ViewFinding {
|
|
186
282
|
const key = `${view.schema}.${view.name}`;
|
|
@@ -225,18 +321,51 @@ export function audit(
|
|
|
225
321
|
functions: FunctionDescriptor[],
|
|
226
322
|
views: ViewDescriptor[],
|
|
227
323
|
waivers: Waivers = {},
|
|
324
|
+
schemas: SchemaDescriptor[] = [],
|
|
228
325
|
): AuditReport {
|
|
229
326
|
const fnFindings = functions.map((f) => classifyFunction(f, waivers));
|
|
230
327
|
const viewFindings = views.map((v) => classifyView(v, waivers));
|
|
231
|
-
|
|
328
|
+
// Empty by default: the STATIC path parses migration SQL and cannot know a schema ACL, and a
|
|
329
|
+
// silent `ok` there would be a claim the parser is not entitled to make.
|
|
330
|
+
const schemaFindings = schemas.map((s) => classifySchema(s, waivers));
|
|
331
|
+
const all: { severity: Severity }[] = [...fnFindings, ...viewFindings, ...schemaFindings];
|
|
232
332
|
return {
|
|
233
333
|
functions: fnFindings,
|
|
234
334
|
views: viewFindings,
|
|
335
|
+
schemas: schemaFindings,
|
|
235
336
|
red: all.filter((f) => f.severity === 'red').length,
|
|
236
337
|
warn: all.filter((f) => f.severity === 'warn').length,
|
|
237
338
|
};
|
|
238
339
|
}
|
|
239
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Build the schema descriptors from a live authz contract — the catalog path's adapter.
|
|
343
|
+
*
|
|
344
|
+
* `schemaAcls` absent means the read never ran, and the audit must then say nothing about
|
|
345
|
+
* schemas rather than report every one of them as clean.
|
|
346
|
+
*/
|
|
347
|
+
export function schemaDescriptorsFromContract(
|
|
348
|
+
schemaAcls: Record<string, Record<string, string[]>> | undefined,
|
|
349
|
+
functions: readonly { name: string; securityDefiner: boolean; hasSearchPath: boolean }[],
|
|
350
|
+
): SchemaDescriptor[] {
|
|
351
|
+
if (!schemaAcls) return [];
|
|
352
|
+
const schemaOf = (qualified: string): string => qualified.split('.').slice(0, -1).join('.') || 'public';
|
|
353
|
+
return Object.entries(schemaAcls)
|
|
354
|
+
.map(([schema, grants]) => {
|
|
355
|
+
const here = functions.filter((f) => f.securityDefiner && schemaOf(f.name) === schema);
|
|
356
|
+
return {
|
|
357
|
+
schema,
|
|
358
|
+
createGrantees: Object.entries(grants)
|
|
359
|
+
.filter(([, privileges]) => privileges.includes('CREATE'))
|
|
360
|
+
.map(([grantee]) => grantee)
|
|
361
|
+
.sort(),
|
|
362
|
+
definerFunctions: here.length,
|
|
363
|
+
unpinnedDefinerFunctions: here.filter((f) => !f.hasSearchPath).length,
|
|
364
|
+
};
|
|
365
|
+
})
|
|
366
|
+
.sort((a, b) => a.schema.localeCompare(b.schema));
|
|
367
|
+
}
|
|
368
|
+
|
|
240
369
|
function raise(current: Severity, next: Severity): Severity {
|
|
241
370
|
const rank: Record<Severity, number> = { ok: 0, warn: 1, red: 2 };
|
|
242
371
|
return rank[next] > rank[current] ? next : current;
|
|
@@ -43,7 +43,20 @@ SELECT
|
|
|
43
43
|
WHERE m.member = p.proowner AND g.rolname = 'rds_superuser'
|
|
44
44
|
)
|
|
45
45
|
FROM pg_roles r WHERE r.oid = p.proowner
|
|
46
|
-
) AS owner_bypasses_rls
|
|
46
|
+
) AS owner_bypasses_rls,
|
|
47
|
+
-- SUPERUSER specifically, split out from owner_bypasses_rls. A deliberately-scoped
|
|
48
|
+
-- operator role is commonly BYPASSRLS by design, so the combined flag reads true for
|
|
49
|
+
-- the correct design AND for a function accidentally left owned by the build identity --
|
|
50
|
+
-- identical value, wildly different blast radius. This is the portable half: it separates
|
|
51
|
+
-- a scoped operator from a superuser without comparing a role NAME, which legitimately
|
|
52
|
+
-- differs between a dev machine, a deployed master and a normalized operator.
|
|
53
|
+
(
|
|
54
|
+
SELECT r.rolsuper OR EXISTS (
|
|
55
|
+
SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
|
|
56
|
+
WHERE m.member = p.proowner AND g.rolname = 'rds_superuser'
|
|
57
|
+
)
|
|
58
|
+
FROM pg_roles r WHERE r.oid = p.proowner
|
|
59
|
+
) AS owner_is_superuser
|
|
47
60
|
FROM pg_proc p
|
|
48
61
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
49
62
|
JOIN pg_type t ON t.oid = p.prorettype
|
|
@@ -124,6 +137,7 @@ interface FunctionRow {
|
|
|
124
137
|
has_search_path: unknown;
|
|
125
138
|
owner?: unknown;
|
|
126
139
|
owner_bypasses_rls?: unknown;
|
|
140
|
+
owner_is_superuser?: unknown;
|
|
127
141
|
}
|
|
128
142
|
|
|
129
143
|
/** Map a pg_proc row to a descriptor, computing a precise return class. */
|
|
@@ -147,6 +161,7 @@ export function catalogFunctionToDescriptor(row: FunctionRow): FunctionDescripto
|
|
|
147
161
|
hasSearchPath: truthy(row.has_search_path),
|
|
148
162
|
owner: row.owner != null ? String(row.owner) : undefined,
|
|
149
163
|
ownerBypassesRls: row.owner_bypasses_rls != null ? truthy(row.owner_bypasses_rls) : undefined,
|
|
164
|
+
ownerIsSuperuser: row.owner_is_superuser != null ? truthy(row.owner_is_superuser) : undefined,
|
|
150
165
|
};
|
|
151
166
|
}
|
|
152
167
|
|
|
@@ -155,9 +170,9 @@ export function catalogFunctionToDescriptor(row: FunctionRow): FunctionDescripto
|
|
|
155
170
|
* every `introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL)` call
|
|
156
171
|
* site uses this instead of keeping its own copy.
|
|
157
172
|
*/
|
|
158
|
-
export const contractFunctionRow = (row: any): { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean } => {
|
|
173
|
+
export const contractFunctionRow = (row: any): { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean; ownerIsSuperuser?: boolean } => {
|
|
159
174
|
const d = catalogFunctionToDescriptor(row);
|
|
160
|
-
return { schema: d.schema, name: d.name, securityDefiner: d.securityDefiner, hasSearchPath: d.hasSearchPath, owner: d.owner, ownerBypassesRls: d.ownerBypassesRls };
|
|
175
|
+
return { schema: d.schema, name: d.name, securityDefiner: d.securityDefiner, hasSearchPath: d.hasSearchPath, owner: d.owner, ownerBypassesRls: d.ownerBypassesRls, ownerIsSuperuser: d.ownerIsSuperuser };
|
|
161
176
|
};
|
|
162
177
|
|
|
163
178
|
interface RelationRow {
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -115,6 +115,10 @@ const ADDITIVE_MATCHERS: RegExp[] = [
|
|
|
115
115
|
// Relaxing nullability and setting a default add capability; they remove nothing.
|
|
116
116
|
/^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sDROP\s+NOT\s+NULL\b/,
|
|
117
117
|
/^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sSET\s+DEFAULT\b/,
|
|
118
|
+
// Giving a column an auto-value adds capability and removes nothing. Its sibling
|
|
119
|
+
// `SET GENERATED ALWAYS` is deliberately NOT here — it starts REFUSING client-supplied
|
|
120
|
+
// values, so a human reads it as `unclassified` rather than being told it is safe.
|
|
121
|
+
/^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sADD\s+GENERATED\b/,
|
|
118
122
|
];
|
|
119
123
|
|
|
120
124
|
/**
|
|
@@ -195,7 +199,9 @@ export function classifyDestructive(
|
|
|
195
199
|
const narrowings: string[] = [];
|
|
196
200
|
const strips: string[] = [];
|
|
197
201
|
for (const statement of executable) {
|
|
198
|
-
|
|
202
|
+
// DROP IDENTITY is a drop: it takes the backing sequence and its counter with it, and a
|
|
203
|
+
// counter is state no re-run reconstructs. The column's rows survive; its auto-value does not.
|
|
204
|
+
if (/\bDROP\s+(TABLE|COLUMN|TYPE|IDENTITY)\b/.test(statement)) drops.push(statement);
|
|
199
205
|
else if (/\bSET DATA TYPE\b/.test(statement) && /\bUSING\b/.test(statement)) narrowings.push(statement);
|
|
200
206
|
else if (opts.declaredGrantees) {
|
|
201
207
|
// Only classified when the caller supplied the declared side — without it there is
|
|
@@ -347,6 +353,9 @@ export interface StateSyncOptions extends StateApplyOptions {
|
|
|
347
353
|
allowDrops?: boolean;
|
|
348
354
|
/** Standalone sequences — the verify re-diff must see the same declared state as the plan. */
|
|
349
355
|
sequences?: SequenceDescriptor[];
|
|
356
|
+
/** Extensions the modules declare — the re-generated stream must carry them too, or a
|
|
357
|
+
* verify-after re-plan disagrees with the plan that was applied. */
|
|
358
|
+
extensions?: string[];
|
|
350
359
|
}
|
|
351
360
|
|
|
352
361
|
export interface StateSyncOutcome {
|
|
@@ -406,7 +415,7 @@ export async function applyStateAndVerify(
|
|
|
406
415
|
}
|
|
407
416
|
|
|
408
417
|
const remaining = classifyGeneratedStatements(
|
|
409
|
-
generateMigrationSql(models, snapshot, { allowDrops: options.allowDrops, liveAuthz: contract, sequences: options.sequences }),
|
|
418
|
+
generateMigrationSql(models, snapshot, { allowDrops: options.allowDrops, liveAuthz: contract, sequences: options.sequences, extensions: options.extensions }),
|
|
410
419
|
).executable;
|
|
411
420
|
|
|
412
421
|
return {
|