@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
|
@@ -37,7 +37,8 @@ import { compileDrizzleSource } from '../schema-source.js';
|
|
|
37
37
|
import { currentGitRef } from '../state-apply.js';
|
|
38
38
|
import { createDatabase, dropDatabase, buildIntoDatabase, withDatabase } from '../db-build.js';
|
|
39
39
|
import { resolveModelsPath } from '../models-path.js';
|
|
40
|
-
import { loadModels, loadModules } from './db-generate.js';
|
|
40
|
+
import { loadModels, loadModules, readJournal, DEFAULT_MIGRATIONS } from './db-generate.js';
|
|
41
|
+
import { resolveSchemaOut } from '../migration-generate.js';
|
|
41
42
|
import { findUnexportedModels, scanModelDirectory, type ModelFileScan } from '../model-barrel-lint.js';
|
|
42
43
|
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
|
|
43
44
|
import type { SequenceDescriptor } from '@everystack/model';
|
|
@@ -46,6 +47,7 @@ import { findReadAuthzGaps, findNakedGrants } from '../authz-lint.js';
|
|
|
46
47
|
import { parseBaseline, renderBaseline, BASELINE_FILE } from '../authz-baseline.js';
|
|
47
48
|
import {
|
|
48
49
|
findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings, findPublicExecutableSecdef,
|
|
50
|
+
findUnpinnedDefiners,
|
|
49
51
|
} from '../derived-lint.js';
|
|
50
52
|
import { step, success, fail, info, warn } from '../output.js';
|
|
51
53
|
|
|
@@ -234,7 +236,9 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
234
236
|
for (const gap of derivedGaps) {
|
|
235
237
|
findings.push({ level: 'fail', area: 'authz', message: gap.message });
|
|
236
238
|
}
|
|
237
|
-
|
|
239
|
+
// findUnpinnedDefiners is the advisory half of A3: db:pull stopped inventing a pin, so the
|
|
240
|
+
// opinion moved here. It SUGGESTS one and never applies it.
|
|
241
|
+
for (const warning of [...findMatviewSnapshotWarnings(input.derived), ...findPublicExecutableSecdef(input.derived), ...findUnpinnedDefiners(input.derived)]) {
|
|
238
242
|
findings.push({ level: 'warn', area: 'authz', message: warning.message });
|
|
239
243
|
}
|
|
240
244
|
if (derivedGaps.length === 0) {
|
|
@@ -345,7 +349,17 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
345
349
|
} catch { /* a models-only barrel has no modules export — nothing to bootstrap */ }
|
|
346
350
|
}
|
|
347
351
|
|
|
348
|
-
|
|
352
|
+
// The SAME resolution db:generate uses — flag > journal-recorded > default. db:check read
|
|
353
|
+
// only the flag, so a project that had RELOCATED its artifact (recorded in the journal by
|
|
354
|
+
// db:generate, per its own help) had the two verbs disagree about which file is the
|
|
355
|
+
// artifact: generate wrote one path, check guarded another. A consumer worked around it by
|
|
356
|
+
// passing the flag explicitly in their CI script.
|
|
357
|
+
const journal = await readJournal(path.resolve(flags.dir || DEFAULT_MIGRATIONS));
|
|
358
|
+
const artifactRes = resolveSchemaOut(flags['schema-out'], journal, DEFAULT_SCHEMA_OUT);
|
|
359
|
+
const artifactPath = artifactRes.path;
|
|
360
|
+
if (artifactRes.source === 'recorded') {
|
|
361
|
+
info(`schema-out: ${artifactPath} (recorded in the journal; override with --schema-out)`);
|
|
362
|
+
}
|
|
349
363
|
let artifactSource: string | null = null;
|
|
350
364
|
try {
|
|
351
365
|
artifactSource = await fs.readFile(artifactPath, 'utf8');
|
|
@@ -438,6 +452,9 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
438
452
|
// ring proves the checkout against ITSELF, on an empty database. Whether an existing
|
|
439
453
|
// database IS this state is a different question with a different verb.
|
|
440
454
|
info('This proves the checkout is self-consistent and buildable — not that any existing database matches it. For that claim, run db:fingerprint against the database.');
|
|
455
|
+
// The ephemeral database is DROPPED. Saying so here is what stops this line being read
|
|
456
|
+
// as "you now have a database" — a consumer read exactly that into it, from our own docs.
|
|
457
|
+
info('The database this composed on was ephemeral and has been dropped. To build one you KEEP: everystack db:build --database-url <url>.');
|
|
441
458
|
} else {
|
|
442
459
|
success('db:check passed (static ring only — no database provided for the ephemeral compose).');
|
|
443
460
|
}
|
|
@@ -65,6 +65,8 @@ export async function computeFingerprintStatus(
|
|
|
65
65
|
models: ModelDescriptor[] | null,
|
|
66
66
|
sequences?: SequenceDescriptor[],
|
|
67
67
|
governedExtras?: readonly string[],
|
|
68
|
+
/** Declared derived objects — used to drop the `function owner` rows that ARE fingerprinted. */
|
|
69
|
+
declaredDerived?: readonly { kind: string; identity: string; owner?: string }[],
|
|
68
70
|
): Promise<FingerprintStatus> {
|
|
69
71
|
const snapshot = await introspectSchema(session);
|
|
70
72
|
const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
@@ -77,7 +79,16 @@ export async function computeFingerprintStatus(
|
|
|
77
79
|
const governedRoles = models ? governedRolesForModels(models, governedExtras) : undefined;
|
|
78
80
|
const governedLive = governedRoles ? governedLiveFingerprint(snapshot, contract, governedRoles) : undefined;
|
|
79
81
|
const predicted = models ? predictLiveFingerprint(models, snapshot, contract, { governedRoles }) : undefined;
|
|
80
|
-
|
|
82
|
+
// B5 — a declared owner IS hashed (derived-source prefixes it), so reporting it as
|
|
83
|
+
// unfingerprinted would be a false claim in the one report whose whole job is honesty about
|
|
84
|
+
// coverage. SQL cannot know what the models declare; the filter belongs here.
|
|
85
|
+
const ownerDeclared = new Set(
|
|
86
|
+
(declaredDerived ?? [])
|
|
87
|
+
.filter((o) => o.kind === 'function' && o.owner !== undefined)
|
|
88
|
+
.map((o) => o.identity.replace(/\(.*\)$/, '')),
|
|
89
|
+
);
|
|
90
|
+
const unfingerprinted = mapUnfingerprintedRows(unfingerprintedRows as any[])
|
|
91
|
+
.filter((u) => u.kind !== 'function owner' || !ownerDeclared.has(u.identity.split(' → ')[0]));
|
|
81
92
|
return {
|
|
82
93
|
live,
|
|
83
94
|
...(governedLive !== undefined ? { governedLive } : {}),
|
|
@@ -101,11 +112,14 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
101
112
|
let models: ModelDescriptor[] | null = null;
|
|
102
113
|
let sequences: SequenceDescriptor[] | undefined;
|
|
103
114
|
let governedExtras: string[] | undefined;
|
|
115
|
+
let declaredDerived: readonly { kind: string; identity: string; owner?: string }[] | undefined;
|
|
104
116
|
try {
|
|
105
117
|
models = await loadModels(modelsPath);
|
|
106
118
|
const declared = await loadDeclaredDerived(flags.models);
|
|
107
119
|
sequences = declared?.sequences;
|
|
108
120
|
governedExtras = declared?.governedRoles;
|
|
121
|
+
// B5 — needed to tell a function whose owner IS hashed (declared) from one whose is not.
|
|
122
|
+
declaredDerived = declared?.objects;
|
|
109
123
|
} catch (err: any) {
|
|
110
124
|
// Two very different situations used to land here identically.
|
|
111
125
|
//
|
|
@@ -141,7 +155,7 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
141
155
|
}
|
|
142
156
|
|
|
143
157
|
try {
|
|
144
|
-
const status = await computeFingerprintStatus(session, models, sequences, governedExtras);
|
|
158
|
+
const status = await computeFingerprintStatus(session, models, sequences, governedExtras, declaredDerived);
|
|
145
159
|
|
|
146
160
|
if (flags.json === 'true') {
|
|
147
161
|
console.log(JSON.stringify(status, null, 2));
|
|
@@ -43,7 +43,7 @@ import { resolveConfig, opsFunction } from '../config.js';
|
|
|
43
43
|
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
44
44
|
import { step, success, fail, info, warn } from '../output.js';
|
|
45
45
|
|
|
46
|
-
const DEFAULT_MIGRATIONS = 'drizzle';
|
|
46
|
+
export const DEFAULT_MIGRATIONS = 'drizzle';
|
|
47
47
|
const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
|
|
48
48
|
|
|
49
49
|
/** Import the app's Model barrel and return its `models` array (runs under tsx, so TS imports work).
|
|
@@ -187,7 +187,7 @@ function reportOwnership(
|
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
async function readJournal(migrationsDir: string): Promise<Journal | null> {
|
|
190
|
+
export async function readJournal(migrationsDir: string): Promise<Journal | null> {
|
|
191
191
|
try {
|
|
192
192
|
return JSON.parse(await fs.readFile(path.join(migrationsDir, 'meta', '_journal.json'), 'utf8'));
|
|
193
193
|
} catch {
|
|
@@ -290,7 +290,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
290
290
|
|
|
291
291
|
// One ordered migration carries both layers: data DDL first, then the authz reconcile
|
|
292
292
|
// (RLS/policies/grants) the Models' abilities declare, diffed against the live contract.
|
|
293
|
-
const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz, sequences: declaredDb?.sequences, governedRoles: declaredDb?.governedRoles });
|
|
293
|
+
const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz, sequences: declaredDb?.sequences, governedRoles: declaredDb?.governedRoles, extensions: declaredDb?.extensions });
|
|
294
294
|
const unmodeled = unmodeledTables(models, current);
|
|
295
295
|
if (unmodeled.length) {
|
|
296
296
|
info(`${unmodeled.length} table(s) in the database are not declared by any model — left untouched (db:generate manages only declared tables).`);
|
|
@@ -172,8 +172,13 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
172
172
|
// The modules' widened governed-role set. A barrel that exports only `models` has none,
|
|
173
173
|
// which is the greenfield default: govern exactly what the models name.
|
|
174
174
|
let declaredGovernedRoles: string[] = [];
|
|
175
|
+
// The modules' extensions ride the same read. Without them the plan omits every
|
|
176
|
+
// CREATE EXTENSION the target lacks, and the apply fails on a type the plan promised.
|
|
177
|
+
let declaredExtensions: string[] = [];
|
|
175
178
|
try {
|
|
176
|
-
|
|
179
|
+
const declared = await loadDeclaredDerived(flags.models);
|
|
180
|
+
declaredGovernedRoles = declared?.governedRoles ?? [];
|
|
181
|
+
declaredExtensions = declared?.extensions ?? [];
|
|
177
182
|
} catch {
|
|
178
183
|
// The barrel's own compose errors surface on the paths that need the derived layer;
|
|
179
184
|
// a plan must not fail to mint because a module could not be read for this one field.
|
|
@@ -202,6 +207,7 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
202
207
|
// Without this the MINTED PLAN carries a REVOKE for every live grantee the models
|
|
203
208
|
// do not name — which is the artifact an operator actually applies.
|
|
204
209
|
governedRoles: declaredGovernedRoles,
|
|
210
|
+
extensions: declaredExtensions,
|
|
205
211
|
});
|
|
206
212
|
} catch (err: any) {
|
|
207
213
|
fail(`Mint refused: ${err.message}`);
|
|
@@ -35,7 +35,7 @@ import fs from 'node:fs/promises';
|
|
|
35
35
|
import path from 'node:path';
|
|
36
36
|
import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, type ColumnRow, type ColumnSchema } from '../schema-introspect.js';
|
|
37
37
|
import { fingerprintLive } from '../schema-fingerprint.js';
|
|
38
|
-
import { ungovernedGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
|
|
38
|
+
import { ungovernedGrants, vestigialSequenceGrants, renderVestigialSequenceGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
|
|
39
39
|
import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline.js';
|
|
40
40
|
import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
|
|
41
41
|
import { introspectContract, type TableContract, type AuthzContract } from '../authz-contract.js';
|
|
@@ -235,6 +235,11 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
235
235
|
// governed role's grants are DECLARED (transcribed as privileges), so recording them
|
|
236
236
|
// as exemptions too would double-book them — declared and exempted at once.
|
|
237
237
|
pulledExemptions = ungovernedGrants(contract, new Set([...ALWAYS_GOVERNED, ...governRoles]));
|
|
238
|
+
// A6's advisory. A blanket `GRANT USAGE ON ALL SEQUENCES` beside a blanket SELECT is a
|
|
239
|
+
// common brownfield shape, and the inheritance rule never touches it — the rule only ADDS
|
|
240
|
+
// what an INSERT needs — so it would sit unnoticed forever. Named, never revoked: revoking
|
|
241
|
+
// it would be the tool deciding a security property from an inference.
|
|
242
|
+
for (const line of renderVestigialSequenceGrants(vestigialSequenceGrants(contract))) caution(line);
|
|
238
243
|
pulledFingerprint = fingerprintLive(current, contract).hash;
|
|
239
244
|
}
|
|
240
245
|
// --matviews-as-tables: the flip needs real fields — one extra catalog read for the
|
|
@@ -43,6 +43,11 @@ import {
|
|
|
43
43
|
derivedSearchPath,
|
|
44
44
|
renderSetSearchPath,
|
|
45
45
|
renderEnsureObjectSchemas,
|
|
46
|
+
ownerRequirements,
|
|
47
|
+
ownerPreflightSql,
|
|
48
|
+
ownerPreflightRefusal,
|
|
49
|
+
builderOwnerMode,
|
|
50
|
+
type OwnerApplyMode,
|
|
46
51
|
ENSURE_RECONCILER_SQL,
|
|
47
52
|
} from '../derived-apply.js';
|
|
48
53
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
@@ -78,6 +83,13 @@ export interface ReconcileRun {
|
|
|
78
83
|
statements: string[];
|
|
79
84
|
/** Why apply was refused, when it was. */
|
|
80
85
|
refusal?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Which mechanism enacted the declared owners, when any were enacted. Reported because the two
|
|
88
|
+
* make different demands of the operator: `set-role` needs membership plus CREATE-on-schema for
|
|
89
|
+
* the owner, `alter-owner` needs a superuser builder and nothing of the owner at all. An
|
|
90
|
+
* operator debugging a permission error should not have to guess which one ran.
|
|
91
|
+
*/
|
|
92
|
+
ownerMode?: OwnerApplyMode;
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
/**
|
|
@@ -118,7 +130,10 @@ export async function executeReconcile(
|
|
|
118
130
|
const live = await introspectDerived(session);
|
|
119
131
|
const parsed = { objects: options.declared ?? [], warnings: [] as string[] };
|
|
120
132
|
const plan = planReconcile(parsed, live, options);
|
|
121
|
-
|
|
133
|
+
// Rendered with the mechanism that works for EVERY builder. The preflight below reads the
|
|
134
|
+
// catalog and re-renders if this builder can use the cheaper one; a plan that is never applied
|
|
135
|
+
// (or is refused) shows the conservative form, which is the honest default.
|
|
136
|
+
let rendered = renderReconcileSql(plan, parsed.objects);
|
|
122
137
|
|
|
123
138
|
if (!options.apply) return { plan, applied: false, statements: rendered.statements };
|
|
124
139
|
|
|
@@ -138,6 +153,23 @@ export async function executeReconcile(
|
|
|
138
153
|
return { plan, applied: false, statements: [] };
|
|
139
154
|
}
|
|
140
155
|
|
|
156
|
+
// DECLARED-OWNER PREFLIGHT — the last thing before any DDL, bookkeeping included.
|
|
157
|
+
//
|
|
158
|
+
// Two jobs, one read. It refuses what this builder cannot enact, naming every owner at once
|
|
159
|
+
// rather than one per re-run, so the operator's first news is not a raw Postgres error from the
|
|
160
|
+
// middle of a batch. And it CHOOSES the mechanism: a superuser builder hands the object over
|
|
161
|
+
// with ALTER … OWNER TO, which asks nothing of the owner role; everyone else creates AS the
|
|
162
|
+
// owner, which needs membership and CREATE on the schema.
|
|
163
|
+
const requirements = ownerRequirements(plan, parsed.objects);
|
|
164
|
+
let ownerMode: OwnerApplyMode | undefined;
|
|
165
|
+
if (requirements.length > 0) {
|
|
166
|
+
const rows = (await runner(ownerPreflightSql(requirements))) as any[];
|
|
167
|
+
const refusal = ownerPreflightRefusal(rows);
|
|
168
|
+
if (refusal) return { plan, applied: false, statements: rendered.statements, refusal };
|
|
169
|
+
ownerMode = builderOwnerMode(rows);
|
|
170
|
+
if (ownerMode !== 'set-role') rendered = renderReconcileSql(plan, parsed.objects, ownerMode);
|
|
171
|
+
}
|
|
172
|
+
|
|
141
173
|
const now = options.now ?? Date.now;
|
|
142
174
|
await runner(ENSURE_RECONCILER_SQL.join(';\n'));
|
|
143
175
|
|
|
@@ -263,7 +295,7 @@ export async function executeReconcile(
|
|
|
263
295
|
throw explainReconcileError(err);
|
|
264
296
|
}
|
|
265
297
|
|
|
266
|
-
return { plan, applied: true, statements: rendered.statements };
|
|
298
|
+
return { plan, applied: true, statements: rendered.statements, ...(ownerMode ? { ownerMode } : {}) };
|
|
267
299
|
}
|
|
268
300
|
|
|
269
301
|
/**
|
|
@@ -520,6 +552,13 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
520
552
|
fail(`not applied: ${run.refusal}`);
|
|
521
553
|
} else if (run.applied) {
|
|
522
554
|
success(`Applied ${run.statements.length} statement(s); provenance and schema_log recorded.`);
|
|
555
|
+
// Which ownership mechanism ran. The two make different demands, so an operator
|
|
556
|
+
// debugging a permission error should not have to guess which one they hit.
|
|
557
|
+
if (run.ownerMode === 'alter-owner') {
|
|
558
|
+
info('Declared owners applied with ALTER … OWNER TO (this builder is a superuser) — the owner roles needed no membership grant and no CREATE on their schemas.');
|
|
559
|
+
} else if (run.ownerMode === 'set-role') {
|
|
560
|
+
info('Declared owners applied under SET ROLE (this builder is not a superuser) — each owner must be assumable by the builder and hold CREATE on its schema.');
|
|
561
|
+
}
|
|
523
562
|
if (run.plan.actions.some((a) => a.action === 'baseline')) {
|
|
524
563
|
warn('baseline recorded trust WITHOUT verifying live matches source — on first contact it CANNOT compare a source hash to a live deparse, so it trusts your assertion, it does not check. If you need a guarantee that live == the declared source, drop the object and let reconcile recreate it (or --overwrite-drift when the plan reports drift).');
|
|
525
564
|
}
|
|
@@ -76,6 +76,8 @@ export interface SyncOptions {
|
|
|
76
76
|
declared?: SourceObject[];
|
|
77
77
|
/** Standalone sequences the modules declare (state — created before tables, fingerprinted). */
|
|
78
78
|
sequences?: SequenceDescriptor[];
|
|
79
|
+
/** Extensions the modules declare — emitted before anything that uses their types. */
|
|
80
|
+
extensions?: string[];
|
|
79
81
|
/** Roles the modules govern beyond the ones the models name — a grantee outside the set
|
|
80
82
|
* is exempted from revocation and enumerated instead (authz-reconcile's governedRoleSet). */
|
|
81
83
|
governedRoles?: string[];
|
|
@@ -123,12 +125,12 @@ export async function executeSync(
|
|
|
123
125
|
const liveAuthz = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
124
126
|
const statements = generateMigrationSql(models, current, {
|
|
125
127
|
allowDrops: options.allowDrops, liveAuthz, sequences: options.sequences,
|
|
126
|
-
governedRoles: options.governedRoles,
|
|
128
|
+
governedRoles: options.governedRoles, extensions: options.extensions,
|
|
127
129
|
});
|
|
128
130
|
hooks.onStatePlan?.(statements, classifyGeneratedStatements(statements));
|
|
129
131
|
|
|
130
132
|
const state = await applyStateAndVerify(runner, session, models, statements, current, liveAuthz, {
|
|
131
|
-
allowDrops: options.allowDrops, sequences: options.sequences,
|
|
133
|
+
allowDrops: options.allowDrops, sequences: options.sequences, extensions: options.extensions,
|
|
132
134
|
actor: options.actor, gitRef: options.gitRef, now: options.now,
|
|
133
135
|
});
|
|
134
136
|
hooks.onStateDone?.(state);
|
|
@@ -315,6 +317,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
|
|
|
315
317
|
{
|
|
316
318
|
declared: declaredDb?.objects,
|
|
317
319
|
sequences: declaredDb?.sequences,
|
|
320
|
+
extensions: declaredDb?.extensions,
|
|
318
321
|
renamedTables: declaredDb?.renamedTables,
|
|
319
322
|
allowDrops: flags['allow-drops'] === 'true',
|
|
320
323
|
overwriteDrift: flags['overwrite-drift'] === 'true',
|
|
@@ -15,6 +15,7 @@ import fs from 'node:fs/promises';
|
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import {
|
|
17
17
|
audit,
|
|
18
|
+
schemaDescriptorsFromContract,
|
|
18
19
|
parseFunctionsFromSql,
|
|
19
20
|
parseViewsAndGrantsFromSql,
|
|
20
21
|
type Waivers,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
catalogFunctionToDescriptor,
|
|
29
30
|
catalogRelationToDescriptor,
|
|
30
31
|
} from '../security-catalog.js';
|
|
32
|
+
import { SCHEMA_ACL_SQL, parseSchemaAcl } from '../authz-contract.js';
|
|
31
33
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
32
34
|
import { invokeAction } from '../aws.js';
|
|
33
35
|
import { step, success, fail, info, warn } from '../output.js';
|
|
@@ -116,11 +118,28 @@ export async function auditDeployedSql(
|
|
|
116
118
|
step('Introspecting database catalog (functions + relations)...');
|
|
117
119
|
const fnRows = await catalogQuery(region, opsFn, FUNCTIONS_SQL);
|
|
118
120
|
const relRows = await catalogQuery(region, opsFn, RELATIONS_SQL);
|
|
121
|
+
// A10(ii) — the PRECONDITION leg. Unpinned SECDEF and owner-bypasses-RLS both describe the
|
|
122
|
+
// privileged CODE; neither says whether an attacker can plant something for it to resolve to.
|
|
123
|
+
// Only the catalog path can answer that, so the static path leaves `schemas` empty rather
|
|
124
|
+
// than report every schema as clean.
|
|
125
|
+
const aclRows = await catalogQuery(region, opsFn, SCHEMA_ACL_SQL);
|
|
119
126
|
|
|
120
127
|
const functions: FunctionDescriptor[] = fnRows.map(catalogFunctionToDescriptor);
|
|
121
128
|
const views: ViewDescriptor[] = relRows.map(catalogRelationToDescriptor);
|
|
122
|
-
|
|
123
|
-
|
|
129
|
+
const schemaAcls: Record<string, Record<string, string[]>> = {};
|
|
130
|
+
for (const row of aclRows) {
|
|
131
|
+
schemaAcls[String(row.schema)] = parseSchemaAcl(row.acl == null ? '{}' : String(row.acl)) ?? {};
|
|
132
|
+
}
|
|
133
|
+
const schemas = schemaDescriptorsFromContract(
|
|
134
|
+
schemaAcls,
|
|
135
|
+
fnRows.map((r: any) => ({
|
|
136
|
+
name: `${r.schema}.${r.name}`,
|
|
137
|
+
securityDefiner: r.security_definer === true || r.security_definer === 't',
|
|
138
|
+
hasSearchPath: r.has_search_path === true || r.has_search_path === 't',
|
|
139
|
+
})),
|
|
140
|
+
);
|
|
141
|
+
info(`Catalog: ${functions.length} function(s), ${views.length} relation(s), ${schemas.length} schema(s).`);
|
|
142
|
+
return audit(functions, views, waivers, schemas);
|
|
124
143
|
}
|
|
125
144
|
|
|
126
145
|
async function catalogQuery(region: string, fn: string, sql: string): Promise<any[]> {
|
|
@@ -153,12 +172,14 @@ export function printReport(report: AuditReport, instrument: 'static' | 'catalog
|
|
|
153
172
|
const reds = [
|
|
154
173
|
...report.functions.filter((f) => f.severity === 'red'),
|
|
155
174
|
...report.views.filter((v) => v.severity === 'red'),
|
|
175
|
+
...report.schemas.filter((s) => s.severity === 'red'),
|
|
156
176
|
];
|
|
157
177
|
const warns = [
|
|
158
178
|
...report.functions.filter((f) => f.severity === 'warn'),
|
|
159
179
|
...report.views.filter((v) => v.severity === 'warn'),
|
|
180
|
+
...report.schemas.filter((s) => s.severity === 'warn'),
|
|
160
181
|
];
|
|
161
|
-
const waived = report.functions.filter((f) => f.waived);
|
|
182
|
+
const waived = [...report.functions.filter((f) => f.waived), ...report.schemas.filter((s) => s.waived)];
|
|
162
183
|
|
|
163
184
|
if (reds.length > 0) {
|
|
164
185
|
console.log('');
|
package/src/cli/db-build.ts
CHANGED
|
@@ -129,6 +129,19 @@ export async function buildIntoDatabase(
|
|
|
129
129
|
for (const schema of [...new Set([...modelSchemas, ...derivedSchemas])].filter((s) => s && s !== 'public').sort()) {
|
|
130
130
|
await runner(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
|
|
131
131
|
}
|
|
132
|
+
// A10(i) — ASSERT the schema posture, do not inherit it.
|
|
133
|
+
//
|
|
134
|
+
// PG15 removed PUBLIC's CREATE on schema `public`; before that it was the default, and it
|
|
135
|
+
// still rides in with any pre-PG15 dump. So a database everystack BUILDS was hardened only
|
|
136
|
+
// by accident of the server version, while one it ADOPTS was not — and db:fingerprint
|
|
137
|
+
// reported MATCH across both. A security posture that depends on which major created the
|
|
138
|
+
// database is exactly the environmental dependence this class of defect is about.
|
|
139
|
+
//
|
|
140
|
+
// Safe here and ONLY here: db:build refuses a database that already holds objects, so there
|
|
141
|
+
// is nothing in `public` to strand. Bringing an ADOPTED database to this posture is a
|
|
142
|
+
// reviewable REVOKE the operator sees before it runs, never a silent side effect — some
|
|
143
|
+
// legacy apps do create objects in `public` at runtime.
|
|
144
|
+
await runner('REVOKE CREATE ON SCHEMA public FROM PUBLIC');
|
|
132
145
|
const createdRoles = await ensureContractRoles(runner, models);
|
|
133
146
|
|
|
134
147
|
// FUNCTIONS BEFORE STATE. An RLS policy's predicate is resolved when the policy is
|
|
@@ -170,12 +183,34 @@ export async function buildIntoDatabase(
|
|
|
170
183
|
await runner('RESET check_function_bodies');
|
|
171
184
|
}
|
|
172
185
|
}
|
|
173
|
-
|
|
186
|
+
let run = await executeSync(runner, session, models, {
|
|
174
187
|
declared: options.declared,
|
|
175
188
|
sequences: options.sequences,
|
|
176
189
|
actor: options.actor ?? 'db-build',
|
|
177
190
|
gitRef: options.gitRef ?? null,
|
|
178
191
|
});
|
|
192
|
+
// A12 — THE SECOND PASS, and it is inherent to building from nothing rather than a patch
|
|
193
|
+
// over one bug. `executeSync` is a single diff-and-verify: it reads the live authz contract,
|
|
194
|
+
// computes the delta, applies it. On a FROM-SCRATCH build some objects the authz layer must
|
|
195
|
+
// grant on do not exist at read time — most sharply the sequence behind a serial column,
|
|
196
|
+
// which our own CREATE TABLE makes moments later. So the first pass cannot see it, the
|
|
197
|
+
// sequence inheritance rule (A6) has nothing to grant on, and the build lands with a role
|
|
198
|
+
// that may INSERT into a table but cannot draw its sequence value.
|
|
199
|
+
//
|
|
200
|
+
// Caught by the round-trip oracle (B7), not by A6's own tests: the rule converges perfectly
|
|
201
|
+
// against an EXISTING database, which is what those tests exercise.
|
|
202
|
+
//
|
|
203
|
+
// Bounded at exactly one extra pass. The second read sees everything the first one created,
|
|
204
|
+
// so a third could only differ if the apply were non-convergent — and that is a real failure
|
|
205
|
+
// the `converged` bar must report, never something to loop away.
|
|
206
|
+
if (!run.converged) {
|
|
207
|
+
run = await executeSync(runner, session, models, {
|
|
208
|
+
declared: options.declared,
|
|
209
|
+
sequences: options.sequences,
|
|
210
|
+
actor: options.actor ?? 'db-build',
|
|
211
|
+
gitRef: options.gitRef ?? null,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
179
214
|
return {
|
|
180
215
|
converged: run.converged,
|
|
181
216
|
fingerprintMatch: run.fingerprintMatch,
|
|
@@ -34,6 +34,16 @@ export interface DeclaredDerived {
|
|
|
34
34
|
/** Roles the modules declare as governed beyond the ones the models name. A live grantee
|
|
35
35
|
* outside the governed set is exempted from reconciliation and ENUMERATED instead. */
|
|
36
36
|
governedRoles: string[];
|
|
37
|
+
/**
|
|
38
|
+
* Postgres extensions the modules declare — the bootstrap the state layer needs before
|
|
39
|
+
* anything that uses their types. Only the from-scratch compiler consumed these; the DIFF
|
|
40
|
+
* path never saw them, so `db:generate --apply`, `db:sync` and the whole `db:plan`/
|
|
41
|
+
* `db:apply` stage lane emitted zero `CREATE EXTENSION`. A consumer measured the gap as
|
|
42
|
+
* exactly their nine extensions (471 statements vs db:check's 480 on one checkout), and
|
|
43
|
+
* it is not only a fresh-build problem: a deployed stage evolving onto a model that newly
|
|
44
|
+
* uses `hstore` or `postgis` fails on apply.
|
|
45
|
+
*/
|
|
46
|
+
extensions: string[];
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
/**
|
|
@@ -178,6 +188,7 @@ export function composeDeclaredDerived(modules: Module[], modelsPath: string): D
|
|
|
178
188
|
renamedTables: { ...compileTableRenames(models, {}), ...compileTableMoves(models, {}) },
|
|
179
189
|
models,
|
|
180
190
|
governedRoles: moduleGovernedRoles(modules),
|
|
191
|
+
extensions: [...new Set(modules.flatMap((m) => m.extensions ?? []))].sort(),
|
|
181
192
|
};
|
|
182
193
|
} catch (err) {
|
|
183
194
|
throw asModelComposeError(modelsPath, err);
|