@everystack/cli 0.4.40 → 0.4.43
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/apply-execute.ts +20 -1
- package/src/cli/authz-baseline.ts +227 -0
- package/src/cli/authz-compile.ts +46 -4
- package/src/cli/authz-contract.ts +48 -1
- package/src/cli/authz-reconcile.ts +131 -22
- package/src/cli/authz-redteam.ts +32 -10
- package/src/cli/commands/db-apply.ts +18 -0
- package/src/cli/commands/db-authz.ts +17 -1
- package/src/cli/commands/db-check.ts +45 -0
- package/src/cli/commands/db-generate.ts +29 -3
- package/src/cli/commands/db-plan.ts +54 -0
- package/src/cli/commands/db-pull.ts +40 -0
- package/src/cli/commands/db-sync.ts +4 -0
- package/src/cli/declared-derived.ts +6 -1
- package/src/cli/edge-plan.ts +84 -6
- package/src/cli/migration-generate.ts +12 -2
- package/src/cli/model-render.ts +55 -20
- package/src/cli/schema-compile.ts +25 -2
- package/src/cli/schema-diff.ts +26 -1
- package/src/cli/state-apply.ts +127 -2
package/src/cli/authz-redteam.ts
CHANGED
|
@@ -40,7 +40,7 @@ export interface ProbeResult {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export interface RedTeamFinding {
|
|
43
|
-
severity: 'hole' | 'broken' | 'unprobed' | 'inconclusive' | 'inherited';
|
|
43
|
+
severity: 'hole' | 'broken' | 'unprobed' | 'inconclusive' | 'inherited' | 'elevated';
|
|
44
44
|
role: string;
|
|
45
45
|
table: string;
|
|
46
46
|
command: ProbeCommand;
|
|
@@ -244,6 +244,9 @@ export interface InheritedGrant {
|
|
|
244
244
|
table: string; // schema-qualified
|
|
245
245
|
command: ProbeCommand;
|
|
246
246
|
via: string; // the ancestor role(s) supplying it
|
|
247
|
+
/** True when an ancestor is SUPERUSER or BYPASSRLS — the role bypasses RLS entirely,
|
|
248
|
+
* which makes every probe result for it vacuous. Drives the severity split. */
|
|
249
|
+
elevated: boolean;
|
|
247
250
|
}
|
|
248
251
|
|
|
249
252
|
/**
|
|
@@ -273,7 +276,8 @@ WITH RECURSIVE anc AS (
|
|
|
273
276
|
SELECT r.rolname AS role,
|
|
274
277
|
(c.relnamespace::regnamespace::text || '.' || c.relname) AS "table",
|
|
275
278
|
p.priv AS command,
|
|
276
|
-
string_agg(DISTINCT ar.rolname, ', ' ORDER BY ar.rolname) AS via
|
|
279
|
+
string_agg(DISTINCT ar.rolname, ', ' ORDER BY ar.rolname) AS via,
|
|
280
|
+
bool_or(ar.rolsuper OR ar.rolbypassrls) AS elevated
|
|
277
281
|
FROM pg_class c
|
|
278
282
|
CROSS JOIN (VALUES ('SELECT'),('INSERT'),('UPDATE'),('DELETE')) AS p(priv)
|
|
279
283
|
JOIN pg_roles r ON r.rolname = ANY(${pgArrayLiteral(roles)})
|
|
@@ -296,6 +300,10 @@ export function toInheritedGrant(row: any): InheritedGrant {
|
|
|
296
300
|
table: String(row.table),
|
|
297
301
|
command: String(row.command).toUpperCase() as ProbeCommand,
|
|
298
302
|
via: String(row.via),
|
|
303
|
+
// An ancestor that is SUPERUSER or BYPASSRLS makes every probe result for this role
|
|
304
|
+
// vacuous — default-deny cannot be falsified and RLS assertions are void. That is a
|
|
305
|
+
// different finding from inheriting an ordinary role's grants, and it is graded so.
|
|
306
|
+
elevated: row.elevated === true || row.elevated === 't' || row.elevated === 'true',
|
|
299
307
|
};
|
|
300
308
|
}
|
|
301
309
|
|
|
@@ -329,8 +337,8 @@ export function evaluateRedTeam(
|
|
|
329
337
|
const findings: RedTeamFinding[] = [];
|
|
330
338
|
// Privileges explained by role membership rather than a direct grant. Keyed so the
|
|
331
339
|
// per-result lookup is exact; reported once per role, not once per table × command.
|
|
332
|
-
const inheritedBy = new Map(inherited.map((g) => [`${g.role}
|
|
333
|
-
const inheritedRoles = new Map<string, { via: string; count: number }>();
|
|
340
|
+
const inheritedBy = new Map(inherited.map((g) => [`${g.role}\u0000${g.table}\u0000${g.command}`, g]));
|
|
341
|
+
const inheritedRoles = new Map<string, { via: string; count: number; elevated: boolean }>();
|
|
334
342
|
|
|
335
343
|
for (const r of results) {
|
|
336
344
|
const table = tablesByName.get(r.table);
|
|
@@ -347,12 +355,12 @@ export function evaluateRedTeam(
|
|
|
347
355
|
findings.push({ severity: 'inconclusive', role: r.role, table: r.table, command: r.command,
|
|
348
356
|
detail: `${r.command} on ${r.table} failed before the privilege check — this probe proves nothing about ${r.role}` });
|
|
349
357
|
} else if (allowed && !granted) {
|
|
350
|
-
const via = inheritedBy.get(`${r.role}
|
|
358
|
+
const via = inheritedBy.get(`${r.role}\u0000${r.table}\u0000${r.command}`);
|
|
351
359
|
if (via) {
|
|
352
360
|
// Real, but it is one fact about a role, not N facts about N tables.
|
|
353
361
|
const seen = inheritedRoles.get(r.role);
|
|
354
|
-
if (seen) seen.count += 1;
|
|
355
|
-
else inheritedRoles.set(r.role, { via: via.via, count: 1 });
|
|
362
|
+
if (seen) { seen.count += 1; seen.elevated ||= via.elevated; }
|
|
363
|
+
else inheritedRoles.set(r.role, { via: via.via, count: 1, elevated: via.elevated });
|
|
356
364
|
} else {
|
|
357
365
|
findings.push({ severity: 'hole', role: r.role, table: r.table, command: r.command,
|
|
358
366
|
detail: `${r.role} can ${r.command} ${r.table} but the contract grants no such privilege (enforcement exceeds declaration)` });
|
|
@@ -366,9 +374,23 @@ export function evaluateRedTeam(
|
|
|
366
374
|
// One line per inheriting role. The membership IS the finding — a role that reaches
|
|
367
375
|
// tables through `GRANT parent TO child` is worth stating out loud once, and worth not
|
|
368
376
|
// stating N times.
|
|
369
|
-
for (const [role, { via, count }] of inheritedRoles) {
|
|
370
|
-
|
|
371
|
-
|
|
377
|
+
for (const [role, { via, count, elevated }] of inheritedRoles) {
|
|
378
|
+
// The severity axis is WHAT the ancestor is, not that inheritance happened.
|
|
379
|
+
//
|
|
380
|
+
// An ordinary ancestor's grants are finite, enumerable, and still RLS-subject: the
|
|
381
|
+
// database enforces declared ∪ inherited, which is a completeness note.
|
|
382
|
+
//
|
|
383
|
+
// A SUPERUSER/BYPASSRLS ancestor is categorically different. The role bypasses every
|
|
384
|
+
// policy, its effective access is unbounded, and every probe result for it is vacuous —
|
|
385
|
+
// this tool cannot vouch for the role at all. Reporting that at the same level as
|
|
386
|
+
// "could not SET ROLE into this role" is what let an adopter read the run as clean.
|
|
387
|
+
if (elevated) {
|
|
388
|
+
findings.push({ severity: 'elevated', role, table: '', command: 'SELECT',
|
|
389
|
+
detail: `${role} inherits ${via}, which is SUPERUSER or BYPASSRLS — it bypasses every RLS policy and holds privileges no ACL lists (${count} seen here). Every probe result for ${role} is vacuous: this run cannot vouch for it` });
|
|
390
|
+
} else {
|
|
391
|
+
findings.push({ severity: 'inherited', role, table: '', command: 'SELECT',
|
|
392
|
+
detail: `${role} holds ${count} undeclared privilege(s) INHERITED via membership in ${via} — not a direct grant, so the contract cannot see them. Intentional for a migrator/owner role; a finding if it is an application role` });
|
|
393
|
+
}
|
|
372
394
|
}
|
|
373
395
|
return findings;
|
|
374
396
|
}
|
|
@@ -31,6 +31,11 @@ import { planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan.js';
|
|
|
31
31
|
import { verifyDescent, type DescentVerdict, type LiveState } from '../git-descent.js';
|
|
32
32
|
import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity, type AuthorityVerdict } from '../apply-authority.js';
|
|
33
33
|
import { resolveModelsPath } from '../models-path.js';
|
|
34
|
+
import { loadModels } from './db-generate.js';
|
|
35
|
+
import { loadDeclaredDerived } from '../declared-derived.js';
|
|
36
|
+
import { compileTableContract } from '../authz-compile.js';
|
|
37
|
+
import { governedRoleSet, ungovernedGrants } from '../authz-reconcile.js';
|
|
38
|
+
import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline.js';
|
|
34
39
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
35
40
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
36
41
|
import { invokeAction, lambdaQueryRunner } from '../aws.js';
|
|
@@ -341,6 +346,19 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
341
346
|
actor: process.env.USER ?? null,
|
|
342
347
|
gitRef: currentGitRef() ?? plan.gitRef,
|
|
343
348
|
...(verifyAuthority ? { verifyAuthority } : {}),
|
|
349
|
+
// Re-verified here, not just at mint: a plan is an artifact with a lifetime, and a
|
|
350
|
+
// foreign role granted between minting and applying would otherwise ride through.
|
|
351
|
+
verifyAuthzBaseline: async ({ contract }) => {
|
|
352
|
+
const stageName = flags.stage || 'local';
|
|
353
|
+
const models = await loadModels(resolveModelsPath(flags.models));
|
|
354
|
+
const governedRoles = (await loadDeclaredDerived(flags.models))?.governedRoles ?? [];
|
|
355
|
+
const declaredAuthz = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
|
|
356
|
+
const exemptions = ungovernedGrants(contract, governedRoleSet(declaredAuthz, governedRoles));
|
|
357
|
+
const violations = checkAgainstBaseline(await readBaselineFile(), stageName, exemptions);
|
|
358
|
+
return violations.length === 0
|
|
359
|
+
? { ok: true as const }
|
|
360
|
+
: { ok: false as const, reason: baselineRefusal(stageName, violations) };
|
|
361
|
+
},
|
|
344
362
|
...(forceDescent === undefined ? {
|
|
345
363
|
verifyDescent: async (live: LiveState & { fingerprint: string }) => {
|
|
346
364
|
step('Descent: searching git for the commit that declares the target\'s state...');
|
|
@@ -252,12 +252,20 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
|
|
|
252
252
|
const unprobed = findings.filter((f) => f.severity === 'unprobed');
|
|
253
253
|
const inconclusive = findings.filter((f) => f.severity === 'inconclusive');
|
|
254
254
|
const inheritedFindings = findings.filter((f) => f.severity === 'inherited');
|
|
255
|
+
// An ancestor that is SUPERUSER/BYPASSRLS makes every probe for that role vacuous. It is
|
|
256
|
+
// NOT an enforcement hole — the database enforces exactly what the grants say — so it does
|
|
257
|
+
// not fail the gate, which would go red on the many legitimate elevated migration roles
|
|
258
|
+
// and teach adopters to stop running it. But it is warn-grade, and it must qualify the
|
|
259
|
+
// success line: people read the checkmark and stop, which is exactly how the first adopter
|
|
260
|
+
// came away thinking a real finding had been dismissed.
|
|
261
|
+
const elevatedFindings = findings.filter((f) => f.severity === 'elevated');
|
|
255
262
|
const gaps = gapRows.map(toGrantGap);
|
|
256
263
|
|
|
257
264
|
console.log('');
|
|
258
265
|
for (const f of holes) fail(`[HOLE] ${f.detail}`);
|
|
259
266
|
for (const f of broken) warn(`[BROKEN] ${f.detail}`);
|
|
260
267
|
for (const g of gaps) fail(`[GRANT-GAP] ${g.secdef} calls ${g.helper} but its owner cannot EXECUTE it (42501 in prod once ownership is normalized)`);
|
|
268
|
+
for (const f of elevatedFindings) warn(`[ELEVATED] ${f.detail}`);
|
|
261
269
|
for (const f of inheritedFindings) info(`[inherited] ${f.detail}`);
|
|
262
270
|
if (inconclusive.length) {
|
|
263
271
|
// Never a pass and never a failure — a probe that proved nothing, said out loud so it
|
|
@@ -273,7 +281,15 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
|
|
|
273
281
|
console.log('');
|
|
274
282
|
|
|
275
283
|
if (holes.length === 0 && broken.length === 0 && gaps.length === 0) {
|
|
276
|
-
|
|
284
|
+
// "default-deny holds" is a FALSE UNIVERSAL when a role bypasses RLS. Any claim this run
|
|
285
|
+
// could not verify for a role is excluded from the claim in the sentence that makes it,
|
|
286
|
+
// so a reader who sees only the green line still learns one role is outside it.
|
|
287
|
+
const exempt = elevatedFindings.map((f) => f.role).sort();
|
|
288
|
+
const scope = exempt.length
|
|
289
|
+
? `${contract.tables.length} tables probed; default-deny holds EXCEPT for ${exempt.join(', ')} — `
|
|
290
|
+
+ `${exempt.length === 1 ? 'that role bypasses RLS by inheritance and this run cannot vouch for it' : 'those roles bypass RLS by inheritance and this run cannot vouch for them'}`
|
|
291
|
+
: `${contract.tables.length} tables probed, default-deny holds, SECDEF grants complete`;
|
|
292
|
+
success(`db:authz:test — ${venueLabel} enforces the contract (${scope})`);
|
|
277
293
|
process.exit(0);
|
|
278
294
|
}
|
|
279
295
|
fail(`db:authz:test — ${holes.length} enforcement hole(s), ${broken.length} broken grant(s), ${gaps.length} SECDEF grant gap(s). ${venueLabel} does not enforce the contract.`);
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import fs from 'node:fs/promises';
|
|
32
|
+
import path from 'node:path';
|
|
32
33
|
import type { ModelDescriptor, DerivedDescriptor } from '@everystack/model';
|
|
33
34
|
import { compileDeclaredState } from '../declared-diff.js';
|
|
34
35
|
import { compileMigration } from '../migration-compile.js';
|
|
@@ -41,6 +42,7 @@ import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, t
|
|
|
41
42
|
import type { SequenceDescriptor } from '@everystack/model';
|
|
42
43
|
import type { SourceObject } from '../derived-source.js';
|
|
43
44
|
import { findReadAuthzGaps } from '../authz-lint.js';
|
|
45
|
+
import { parseBaseline, renderBaseline, BASELINE_FILE } from '../authz-baseline.js';
|
|
44
46
|
import { findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings } from '../derived-lint.js';
|
|
45
47
|
import { step, success, fail, info, warn } from '../output.js';
|
|
46
48
|
|
|
@@ -82,6 +84,9 @@ export interface StaticCheckInput {
|
|
|
82
84
|
* stream reads `export const models`; when the two disagree, the state layer builds a
|
|
83
85
|
* different database than the modules declare — the split-brain gate below names it. */
|
|
84
86
|
moduleModels?: ModelDescriptor[] | null;
|
|
87
|
+
/** Raw `db/authz-baseline.json`, or null when there is none. The ARTIFACT half of the
|
|
88
|
+
* ungoverned-grantee gate — see the check below for what it deliberately does not do. */
|
|
89
|
+
baselineSource?: string | null;
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
@@ -155,6 +160,37 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
155
160
|
}
|
|
156
161
|
}
|
|
157
162
|
|
|
163
|
+
// The adoption baseline, ARTIFACT ONLY.
|
|
164
|
+
//
|
|
165
|
+
// This check cannot see the threat and must not pretend to. The foreign grantees it
|
|
166
|
+
// exists to catch live in a real database; db:check's ephemeral compose builds a scratch
|
|
167
|
+
// database that by construction has none of them. So what runs offline is: does the
|
|
168
|
+
// artifact parse, and does it regenerate byte-identically (a hand-edit that does not
|
|
169
|
+
// match the recorded observation shape is a fail). The gate that actually defends the
|
|
170
|
+
// database is on the LIVE path — db:plan refuses to mint and db:apply re-verifies.
|
|
171
|
+
if (input.baselineSource != null) {
|
|
172
|
+
try {
|
|
173
|
+
const parsed = parseBaseline(input.baselineSource);
|
|
174
|
+
if (renderBaseline(parsed) !== input.baselineSource) {
|
|
175
|
+
findings.push({
|
|
176
|
+
level: 'fail',
|
|
177
|
+
area: 'authz',
|
|
178
|
+
message: `${BASELINE_FILE} is not in generated form — re-run \`db:pull --abilities live --stage <name>\` and commit the result. (A hand-edited baseline is how a foreign role gets admitted without the observation that justifies it.)`,
|
|
179
|
+
});
|
|
180
|
+
} else {
|
|
181
|
+
const stages = Object.keys(parsed.stages).sort();
|
|
182
|
+
const total = stages.reduce((n, st) => n + Object.keys(parsed.stages[st].grantees).length, 0);
|
|
183
|
+
findings.push({
|
|
184
|
+
level: 'ok',
|
|
185
|
+
area: 'authz',
|
|
186
|
+
message: `${BASELINE_FILE} is in generated form — ${total} adopted foreign grantee(s) across ${stages.length} stage(s): ${stages.join(', ')} (the live gate runs at db:plan/db:apply, not here)`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
} catch (err: any) {
|
|
190
|
+
findings.push({ level: 'fail', area: 'authz', message: err.message });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
158
194
|
// The derived layer failing to COMPILE (reachability, cycles, undeclared trigger
|
|
159
195
|
// functions) is a hard fail — CI cannot shrug at a compile error.
|
|
160
196
|
if (input.derivedError) {
|
|
@@ -295,10 +331,19 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
295
331
|
derivedError = err.message;
|
|
296
332
|
}
|
|
297
333
|
|
|
334
|
+
// The baseline artifact, read raw so the check can compare it against its own
|
|
335
|
+
// regeneration. Absent is not a failure here — an app with no foreign grantees has
|
|
336
|
+
// nothing to adopt; the LIVE gate is what refuses an unadopted one.
|
|
337
|
+
let baselineSource: string | null = null;
|
|
338
|
+
try {
|
|
339
|
+
baselineSource = await fs.readFile(path.resolve(process.cwd(), BASELINE_FILE), 'utf8');
|
|
340
|
+
} catch { /* no baseline — see above */ }
|
|
341
|
+
|
|
298
342
|
const findings = runStaticChecks({
|
|
299
343
|
modelsPath, models, modelsError, artifactPath, artifactSource, sqlDirRetired,
|
|
300
344
|
sequences: declaredDb?.sequences, derived: declaredDb?.derived, derivedError,
|
|
301
345
|
moduleModels: declaredDb?.models ?? null,
|
|
346
|
+
baselineSource,
|
|
302
347
|
});
|
|
303
348
|
for (const f of findings) {
|
|
304
349
|
(f.level === 'fail' || f.level === 'warn' ? warn : info)(`${MARK[f.level]} ${f.area}: ${f.message}`);
|
|
@@ -27,7 +27,9 @@ import { introspectSchema } from '../schema-introspect.js';
|
|
|
27
27
|
import { compileDrizzleSource } from '../schema-source.js';
|
|
28
28
|
import { compileModuleMigration } from '../migration-compile.js';
|
|
29
29
|
import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, resolveSchemaOut, HELD_DROP_PREFIX, type Journal } from '../migration-generate.js';
|
|
30
|
-
import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
30
|
+
import { introspectContract, type QueryRunner, type AuthzContract } from '../authz-contract.js';
|
|
31
|
+
import { compileTableContract } from '../authz-compile.js';
|
|
32
|
+
import { governedRoleSet, ungovernedGrants, renderGrantExemptions } from '../authz-reconcile.js';
|
|
31
33
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
32
34
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
33
35
|
import { resolveModelsPath } from '../models-path.js';
|
|
@@ -86,7 +88,7 @@ async function loadModules(modelsPath: string): Promise<Module[]> {
|
|
|
86
88
|
}
|
|
87
89
|
if (Array.isArray(mod.modules)) return mod.modules;
|
|
88
90
|
const models = mod.models ?? mod.default;
|
|
89
|
-
if (Array.isArray(models)) return [{ models, extensions: [], sequences: [], derived: [], functions: [] }];
|
|
91
|
+
if (Array.isArray(models)) return [{ models, extensions: [], sequences: [], derived: [], governedRoles: [], functions: [] }];
|
|
90
92
|
throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
|
|
91
93
|
}
|
|
92
94
|
|
|
@@ -129,6 +131,26 @@ async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaO
|
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
/** Read the journal if present (null = no history yet). */
|
|
134
|
+
/**
|
|
135
|
+
* Name every live grantee the declared authz does not govern, with its exact privileges.
|
|
136
|
+
*
|
|
137
|
+
* The reconciler EXEMPTS these from revocation — a migrator, ETL account or BI reader the
|
|
138
|
+
* model vocabulary cannot express would otherwise have its access destroyed by a plan
|
|
139
|
+
* nobody read (32 such revokes on the first brownfield schema this was measured against).
|
|
140
|
+
* The exemption is only defensible if it is never silent, so this prints on every generate,
|
|
141
|
+
* dry run or not, and names roles and privileges exactly rather than counting them.
|
|
142
|
+
*/
|
|
143
|
+
function reportGrantExemptions(liveAuthz: AuthzContract | undefined, models: ModelDescriptor[], governedRoles: string[]): void {
|
|
144
|
+
if (!liveAuthz) return; // no live side — nothing can be ungoverned
|
|
145
|
+
const declared: AuthzContract = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
|
|
146
|
+
const exemptions = ungovernedGrants(liveAuthz, governedRoleSet(declared, governedRoles));
|
|
147
|
+
if (exemptions.length === 0) return;
|
|
148
|
+
const roles = new Set(exemptions.map((e) => e.grantee));
|
|
149
|
+
warn(`${roles.size} role(s) hold privileges this schema does not govern — left UNTOUCHED, never revoked:`);
|
|
150
|
+
for (const line of renderGrantExemptions(exemptions)) info(` ${line}`);
|
|
151
|
+
info(`Bring them under the reconciler with defineModule({ governedRoles: [...] }), or leave them exempt — either way they are listed here every run.`);
|
|
152
|
+
}
|
|
153
|
+
|
|
132
154
|
async function readJournal(migrationsDir: string): Promise<Journal | null> {
|
|
133
155
|
try {
|
|
134
156
|
return JSON.parse(await fs.readFile(path.join(migrationsDir, 'meta', '_journal.json'), 'utf8'));
|
|
@@ -226,11 +248,15 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
226
248
|
|
|
227
249
|
// One ordered migration carries both layers: data DDL first, then the authz reconcile
|
|
228
250
|
// (RLS/policies/grants) the Models' abilities declare, diffed against the live contract.
|
|
229
|
-
const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz, sequences: declaredDb?.sequences });
|
|
251
|
+
const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz, sequences: declaredDb?.sequences, governedRoles: declaredDb?.governedRoles });
|
|
230
252
|
const unmodeled = unmodeledTables(models, current);
|
|
231
253
|
if (unmodeled.length) {
|
|
232
254
|
info(`${unmodeled.length} table(s) in the database are not declared by any model — left untouched (db:generate manages only declared tables).`);
|
|
233
255
|
}
|
|
256
|
+
// Exemptions are enumerated on EVERY generate, dry run or not. The reconciler leaves an
|
|
257
|
+
// ungoverned grantee's privileges alone rather than revoking them; that is only
|
|
258
|
+
// defensible because the artifact says exactly whose access it chose not to govern.
|
|
259
|
+
reportGrantExemptions(liveAuthz, models, declaredDb?.governedRoles ?? []);
|
|
234
260
|
console.log('');
|
|
235
261
|
if (statements.length === 0) {
|
|
236
262
|
success(`db:generate — the live database already matches the models. ${dryRun ? 'Nothing to preview.' : 'No migration written.'}`);
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import fs from 'node:fs/promises';
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
22
23
|
import type { ModelDescriptor } from '@everystack/model';
|
|
23
24
|
import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
24
25
|
import { introspectSchema } from '../schema-introspect.js';
|
|
@@ -33,11 +34,32 @@ import { resolveModelsPath } from '../models-path.js';
|
|
|
33
34
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
34
35
|
import { lambdaQueryRunner } from '../aws.js';
|
|
35
36
|
import { loadModels } from './db-generate.js';
|
|
37
|
+
import { loadDeclaredDerived } from '../declared-derived.js';
|
|
38
|
+
import { governedRoleSet, ungovernedGrants } from '../authz-reconcile.js';
|
|
39
|
+
import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline.js';
|
|
40
|
+
import { compileTableContract } from '../authz-compile.js';
|
|
36
41
|
import { reportPipelineLastRun } from './pipeline-run.js';
|
|
37
42
|
import { step, success, fail, info, warn } from '../output.js';
|
|
38
43
|
|
|
39
44
|
const DEFAULT_OUT = 'db.plan.json';
|
|
40
45
|
|
|
46
|
+
/**
|
|
47
|
+
* True when git would ignore `file` — false when it would happily commit it, and ALSO false
|
|
48
|
+
* outside a repo or when git is unavailable, because the warning is only ever advice and a
|
|
49
|
+
* missing git must not turn a plan mint into a failure.
|
|
50
|
+
*
|
|
51
|
+
* `check-ignore` exits 0 for ignored, 1 for not-ignored, 128 for "not a repo".
|
|
52
|
+
*/
|
|
53
|
+
function isGitIgnored(file: string): boolean {
|
|
54
|
+
try {
|
|
55
|
+
const r = spawnSync('git', ['check-ignore', '-q', file], { stdio: 'ignore' });
|
|
56
|
+
if (r.error || r.status === 128) return true; // no repo / no git — nothing to warn about
|
|
57
|
+
return r.status === 0;
|
|
58
|
+
} catch {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
41
63
|
export async function dbPlanCommand(flags: Record<string, string>): Promise<void> {
|
|
42
64
|
let dbSource: DbSource;
|
|
43
65
|
try {
|
|
@@ -73,6 +95,29 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
73
95
|
step('Asking the target its fingerprint (introspecting state + authz)...');
|
|
74
96
|
const snapshot = await introspectSchema(runner);
|
|
75
97
|
const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
|
|
98
|
+
// The modules' widened governed-role set. A barrel that exports only `models` has none,
|
|
99
|
+
// which is the greenfield default: govern exactly what the models name.
|
|
100
|
+
let declaredGovernedRoles: string[] = [];
|
|
101
|
+
try {
|
|
102
|
+
declaredGovernedRoles = (await loadDeclaredDerived(flags.models))?.governedRoles ?? [];
|
|
103
|
+
} catch {
|
|
104
|
+
// The barrel's own compose errors surface on the paths that need the derived layer;
|
|
105
|
+
// a plan must not fail to mint because a module could not be read for this one field.
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// THE GATE. The reconciler leaves an ungoverned grantee alone rather than revoking it,
|
|
109
|
+
// so the only thing standing between a brownfield exemption and a permanent silent hole
|
|
110
|
+
// is this: a foreign grantee must be in the stage's adoption baseline, and must not have
|
|
111
|
+
// grown since. An absent baseline is an EMPTY one, so greenfield gets the strong
|
|
112
|
+
// property with no flag to remember — a new foreign role refuses on day one.
|
|
113
|
+
const stageName = flags.stage || 'local';
|
|
114
|
+
const declaredAuthz = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
|
|
115
|
+
const exemptions = ungovernedGrants(contract, governedRoleSet(declaredAuthz, declaredGovernedRoles));
|
|
116
|
+
const violations = checkAgainstBaseline(await readBaselineFile(), stageName, exemptions);
|
|
117
|
+
if (violations.length > 0) {
|
|
118
|
+
fail(baselineRefusal(stageName, violations));
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
76
121
|
|
|
77
122
|
let plan;
|
|
78
123
|
try {
|
|
@@ -80,6 +125,9 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
80
125
|
allowDrops: flags['allow-drops'] === 'true',
|
|
81
126
|
gitRef: currentGitRef(),
|
|
82
127
|
actor: process.env.USER ?? null,
|
|
128
|
+
// Without this the MINTED PLAN carries a REVOKE for every live grantee the models
|
|
129
|
+
// do not name — which is the artifact an operator actually applies.
|
|
130
|
+
governedRoles: declaredGovernedRoles,
|
|
83
131
|
});
|
|
84
132
|
} catch (err: any) {
|
|
85
133
|
fail(`Mint refused: ${err.message}`);
|
|
@@ -131,6 +179,12 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
131
179
|
if (out !== '-') {
|
|
132
180
|
success(`Wrote ${out} — review it, then \`everystack db:apply --plan ${out}\`.`);
|
|
133
181
|
warn('Plans are ephemeral release artifacts — attach to the run, do NOT commit.');
|
|
182
|
+
// "Do NOT commit" is advice; git is what enforces it. The default lands in the app
|
|
183
|
+
// root, so an adopter following the happy path gets an untracked, unignored artifact
|
|
184
|
+
// sitting next to their source with only a log line between it and a `git add .`.
|
|
185
|
+
if (!isGitIgnored(out)) {
|
|
186
|
+
warn(`${out} is NOT gitignored — add it, or the next \`git add .\` commits the plan: echo '${out}' >> .gitignore`);
|
|
187
|
+
}
|
|
134
188
|
}
|
|
135
189
|
} finally {
|
|
136
190
|
await end?.();
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
import fs from 'node:fs/promises';
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, type ColumnRow, type ColumnSchema } from '../schema-introspect.js';
|
|
33
|
+
import { fingerprintLive } from '../schema-fingerprint.js';
|
|
34
|
+
import { ungovernedGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
|
|
35
|
+
import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline.js';
|
|
33
36
|
import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
|
|
34
37
|
import { introspectContract, type TableContract } from '../authz-contract.js';
|
|
35
38
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
@@ -126,6 +129,9 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
126
129
|
|
|
127
130
|
let current;
|
|
128
131
|
let liveAuthz: Map<string, TableContract> | undefined;
|
|
132
|
+
/** Adoption observation — the foreign grantees present, and what the claim is true OF. */
|
|
133
|
+
let pulledExemptions: GrantExemption[] = [];
|
|
134
|
+
let pulledFingerprint = '';
|
|
129
135
|
let derivedCatalog: DerivedCatalog | undefined;
|
|
130
136
|
let matviewColumns: Map<string, ColumnSchema[]> | undefined;
|
|
131
137
|
let candidatesByIdentity: Map<string, string[]> | undefined;
|
|
@@ -167,6 +173,17 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
167
173
|
note(`Grants exist for ${[...unmapped].sort().join(', ')} — not rendered: abilities name the roles the model knows (anon/authenticated/admin).`);
|
|
168
174
|
detail(`These are left exactly as they are in the database. Add can(..., { role }) only if you want the models to own them.`);
|
|
169
175
|
}
|
|
176
|
+
// ADOPTION: record the foreign grantees that were already here, per stage. The
|
|
177
|
+
// reconciler exempts them from revocation, so this artifact is what stops that
|
|
178
|
+
// exemption becoming permanent and silent — anything NEW, or anything that has since
|
|
179
|
+
// grown, refuses at db:plan. It lands as a reviewable diff, with writes flagged,
|
|
180
|
+
// because nothing mechanical can tell a legitimate BI role from an attacker's on day
|
|
181
|
+
// one; the defence is forcing the look and making it recur.
|
|
182
|
+
// The governed set at ADOPTION is the fixed vocabulary alone: the models being
|
|
183
|
+
// rendered here can only name anon/authenticated/admin, so every other grantee is
|
|
184
|
+
// foreign by construction — the same set `unmapped` just reported, with privileges.
|
|
185
|
+
pulledExemptions = ungovernedGrants(contract, new Set(ALWAYS_GOVERNED));
|
|
186
|
+
pulledFingerprint = fingerprintLive(current, contract).hash;
|
|
170
187
|
}
|
|
171
188
|
// --matviews-as-tables: the flip needs real fields — one extra catalog read for the
|
|
172
189
|
// matview columns the derived layer (definition-only) doesn't carry.
|
|
@@ -299,5 +316,28 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
299
316
|
} else {
|
|
300
317
|
note(`Stamped '${abilities}' abilities into every model — review the generated stanzas; they are code, not defaults.`);
|
|
301
318
|
}
|
|
319
|
+
|
|
320
|
+
// The adoption baseline. Only under --abilities live (it is an OBSERVATION of the live
|
|
321
|
+
// grants, so there is nothing to record without having read them) and only for a named
|
|
322
|
+
// stage, because the gate is per-stage: a production entry must not excuse the same role
|
|
323
|
+
// in dev. Written even when EMPTY — an empty baseline for a stage is a real, reviewed
|
|
324
|
+
// claim ("this stage had no foreign grantees"), and it is what makes a later arrival fail.
|
|
325
|
+
if (abilities === 'live') {
|
|
326
|
+
const stageName = flags.stage;
|
|
327
|
+
if (!stageName) {
|
|
328
|
+
note(`No --stage, so ${BASELINE_FILE} was not written. The foreign grantees above are recorded per stage; re-run with --stage <name> to adopt them, or db:plan will refuse until you do.`);
|
|
329
|
+
} else {
|
|
330
|
+
const entry = buildStageBaseline(pulledExemptions, { observedAt: new Date().toISOString(), fingerprint: pulledFingerprint });
|
|
331
|
+
const merged = mergeBaseline(await readBaselineFile(), stageName, entry);
|
|
332
|
+
const written = await writeBaselineFile(merged);
|
|
333
|
+
const names = Object.keys(entry.grantees);
|
|
334
|
+
const writers = names.filter((n) => entry.grantees[n].write);
|
|
335
|
+
ok(`Wrote ${path.relative(process.cwd(), written)} — ${names.length} foreign grantee(s) recorded for stage '${stageName}'.`);
|
|
336
|
+
if (writers.length) {
|
|
337
|
+
caution(`${writers.length} of them hold WRITE privileges: ${writers.join(', ')}. Review the diff before committing — this is the moment those roles get looked at.`);
|
|
338
|
+
}
|
|
339
|
+
note(`Committing this file ADOPTS those roles. Anything not in it — or anything that grows beyond it — refuses at db:plan.`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
302
342
|
process.exit(0);
|
|
303
343
|
}
|
|
@@ -75,6 +75,9 @@ export interface SyncOptions {
|
|
|
75
75
|
declared?: SourceObject[];
|
|
76
76
|
/** Standalone sequences the modules declare (state — created before tables, fingerprinted). */
|
|
77
77
|
sequences?: SequenceDescriptor[];
|
|
78
|
+
/** Roles the modules govern beyond the ones the models name — a grantee outside the set
|
|
79
|
+
* is exempted from revocation and enumerated instead (authz-reconcile's governedRoleSet). */
|
|
80
|
+
governedRoles?: string[];
|
|
78
81
|
/** Pending table renames — trigger provenance migrates instead of drop+create. */
|
|
79
82
|
renamedTables?: Record<string, string>;
|
|
80
83
|
/** Injectable clock for tests. */
|
|
@@ -118,6 +121,7 @@ export async function executeSync(
|
|
|
118
121
|
const liveAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
|
|
119
122
|
const statements = generateMigrationSql(models, current, {
|
|
120
123
|
allowDrops: options.allowDrops, liveAuthz, sequences: options.sequences,
|
|
124
|
+
governedRoles: options.governedRoles,
|
|
121
125
|
});
|
|
122
126
|
hooks.onStatePlan?.(statements, classifyGeneratedStatements(statements));
|
|
123
127
|
|
|
@@ -12,6 +12,7 @@ import path from 'node:path';
|
|
|
12
12
|
import fs from 'node:fs/promises';
|
|
13
13
|
import { pathToFileURL } from 'node:url';
|
|
14
14
|
import type { Module, ModelDescriptor, SequenceDescriptor, DerivedDescriptor } from '@everystack/model';
|
|
15
|
+
import { moduleGovernedRoles } from '@everystack/model';
|
|
15
16
|
import { compileDerived } from './derived-compile.js';
|
|
16
17
|
import { compileTableRenames, compileTableMoves } from './schema-compile.js';
|
|
17
18
|
import type { SourceObject } from './derived-source.js';
|
|
@@ -30,6 +31,9 @@ export interface DeclaredDerived {
|
|
|
30
31
|
/** The models the modules compose — db:check's split-brain gate compares these against
|
|
31
32
|
* the barrel's `export const models` (the array every verb's state stream reads). */
|
|
32
33
|
models: ModelDescriptor[];
|
|
34
|
+
/** Roles the modules declare as governed beyond the ones the models name. A live grantee
|
|
35
|
+
* outside the governed set is exempted from reconciliation and ENUMERATED instead. */
|
|
36
|
+
governedRoles: string[];
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
/**
|
|
@@ -64,7 +68,7 @@ export async function loadModulesFrom(modelsPath: string): Promise<Module[]> {
|
|
|
64
68
|
const models = mod.models ?? mod.default;
|
|
65
69
|
if (Array.isArray(models)) {
|
|
66
70
|
assertNoHoles(modelsPath, 'models', models);
|
|
67
|
-
return [{ models, extensions: [], sequences: [], derived: [], functions: [] }];
|
|
71
|
+
return [{ models, extensions: [], sequences: [], derived: [], governedRoles: [], functions: [] }];
|
|
68
72
|
}
|
|
69
73
|
throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
|
|
70
74
|
} catch (err) {
|
|
@@ -173,6 +177,7 @@ export function composeDeclaredDerived(modules: Module[], modelsPath: string): D
|
|
|
173
177
|
// ... SET SCHEMA alike, so the trigger-provenance migration is identical — feed both.
|
|
174
178
|
renamedTables: { ...compileTableRenames(models, {}), ...compileTableMoves(models, {}) },
|
|
175
179
|
models,
|
|
180
|
+
governedRoles: moduleGovernedRoles(modules),
|
|
176
181
|
};
|
|
177
182
|
} catch (err) {
|
|
178
183
|
throw asModelComposeError(modelsPath, err);
|