@everystack/cli 0.4.44 → 0.4.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/cli/authz-adoption-class.ts +265 -0
- package/src/cli/authz-baseline.ts +25 -3
- package/src/cli/authz-canonical.ts +146 -0
- package/src/cli/authz-compile.ts +46 -6
- package/src/cli/authz-contract.ts +120 -25
- package/src/cli/authz-derive.ts +99 -14
- package/src/cli/authz-identity.ts +222 -0
- package/src/cli/authz-ownership.ts +193 -0
- package/src/cli/authz-reconcile.ts +19 -27
- package/src/cli/commands/db-generate.ts +50 -0
- package/src/cli/commands/db-plan.ts +34 -2
- package/src/cli/commands/db-pull.ts +20 -1
- package/src/cli/edge-plan.ts +14 -2
- package/src/cli/index.ts +1 -17
- package/src/cli/model-render.ts +19 -2
- package/src/cli/output.ts +25 -3
- package/src/cli/parse-flags.ts +39 -0
- package/src/cli/schema-fingerprint.ts +88 -36
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table OWNERSHIP — reported, never compared.
|
|
3
|
+
*
|
|
4
|
+
* A table's owner bypasses its own RLS policies unless the table is FORCEd. So the
|
|
5
|
+
* pair (owner, FORCE) decides whether the policies this repo argues about are actually
|
|
6
|
+
* in force for the principal that writes. Neither half is visible in the contract:
|
|
7
|
+
* `GRANTS_SQL` deliberately EXCLUDES the owner's self-grant because the owner is
|
|
8
|
+
* environment-specific (the dev user locally, the master/operator role when deployed),
|
|
9
|
+
* and committing it would make the contract non-portable and drift on every stage.
|
|
10
|
+
*
|
|
11
|
+
* That exclusion is right, and it is exactly why this module exists. The owner is a
|
|
12
|
+
* fact about the DEPLOYED database that no committed artifact can carry, so the only
|
|
13
|
+
* honest treatment is to NAME it on every surface that can see a live database, and
|
|
14
|
+
* let the human read it. Locally it is the dev user and nothing fires; on the stage it
|
|
15
|
+
* is somebody else, and that difference is the whole point.
|
|
16
|
+
*
|
|
17
|
+
* **The owner is NOT part of the contract.** It does not enter `TableContract`, it does
|
|
18
|
+
* not enter `canonicalAuthz`, and it therefore does not touch the fingerprint. Putting
|
|
19
|
+
* an environment-specific field into the content address would make the same declared
|
|
20
|
+
* state hash differently per stage — and adding any field to the canonical form is a
|
|
21
|
+
* FORMAT BUMP, which this does not need: it needs to be reported, not equated.
|
|
22
|
+
*
|
|
23
|
+
* Search_path: this query deparses nothing (`pg_get_expr` is not involved) — it reads
|
|
24
|
+
* catalog names directly — so it needs no canonical-path pinning to be stable.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { WrittenBy, ModelDescriptor } from '@everystack/model';
|
|
28
|
+
import { IGNORED_SCHEMAS, type QueryRunner, type AuthzContract } from './authz-contract.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every base table with its owner. The exclusions are RLS_SQL's, verbatim — base
|
|
32
|
+
* relations only, no `pg_catalog` / `information_schema` / `pg_%`, no extension-owned
|
|
33
|
+
* objects — so the two queries agree about which tables exist and the report can be
|
|
34
|
+
* joined onto the contract without either side inventing a table the other cannot see.
|
|
35
|
+
*/
|
|
36
|
+
export const TABLE_OWNERS_SQL = `
|
|
37
|
+
SELECT
|
|
38
|
+
n.nspname AS schema,
|
|
39
|
+
c.relname AS "table",
|
|
40
|
+
r.rolname AS owner
|
|
41
|
+
FROM pg_class c
|
|
42
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
43
|
+
JOIN pg_roles r ON r.oid = c.relowner
|
|
44
|
+
WHERE c.relkind = 'r'
|
|
45
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
46
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
47
|
+
AND NOT EXISTS (
|
|
48
|
+
SELECT 1 FROM pg_depend d
|
|
49
|
+
WHERE d.objid = c.oid AND d.deptype = 'e'
|
|
50
|
+
)
|
|
51
|
+
ORDER BY n.nspname, c.relname;
|
|
52
|
+
`.trim();
|
|
53
|
+
|
|
54
|
+
export interface TableOwnerRow {
|
|
55
|
+
schema: string;
|
|
56
|
+
table: string;
|
|
57
|
+
owner: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One live table's owner, keyed by the same schema-qualified identity the contract uses. */
|
|
61
|
+
export interface TableOwner {
|
|
62
|
+
/** Schema-qualified table, e.g. `public.posts`. */
|
|
63
|
+
table: string;
|
|
64
|
+
/** The role that owns it. */
|
|
65
|
+
owner: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Fold owner rows into the reportable set. Tooling schemas are dropped here rather than
|
|
70
|
+
* in SQL, matching `assembleContract` — one definition of "not the app's", applied the
|
|
71
|
+
* same way on both sides.
|
|
72
|
+
*/
|
|
73
|
+
export function assembleOwners(rows: TableOwnerRow[]): TableOwner[] {
|
|
74
|
+
return rows
|
|
75
|
+
.filter((row) => !IGNORED_SCHEMAS.has(row.schema))
|
|
76
|
+
.map((row) => ({ table: `${row.schema}.${row.table}`, owner: String(row.owner ?? '') }))
|
|
77
|
+
.sort((a, b) => a.table.localeCompare(b.table));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Read the live table owners. Read-only, one query, no canonical-path pinning needed. */
|
|
81
|
+
export async function introspectTableOwners(run: QueryRunner): Promise<TableOwner[]> {
|
|
82
|
+
return assembleOwners((await run(TABLE_OWNERS_SQL)) as TableOwnerRow[]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One governed table's ownership posture, with the model's own intent beside it. */
|
|
86
|
+
export interface OwnershipRow {
|
|
87
|
+
/** Schema-qualified table. */
|
|
88
|
+
table: string;
|
|
89
|
+
/** The live owner, or null when the ownership query did not return this table. */
|
|
90
|
+
owner: string | null;
|
|
91
|
+
/** Live `relforcerowsecurity` — whether the owner is subject to its own policies. */
|
|
92
|
+
forced: boolean;
|
|
93
|
+
/** The model's declared write principal, or null when there is no model (db:pull). */
|
|
94
|
+
writtenBy: WrittenBy | null;
|
|
95
|
+
/** The owner writes past every policy on this table, and the model did not ask for that. */
|
|
96
|
+
flagged: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build the ownership report for the tables the models govern.
|
|
101
|
+
*
|
|
102
|
+
* The flag rule follows the FORCE axiom the compiler already encodes: FORCE iff the
|
|
103
|
+
* write principal is subject to its own policies.
|
|
104
|
+
*
|
|
105
|
+
* - `writtenBy: 'app'` + FORCE off -> FLAGGED. The app writes through the owner
|
|
106
|
+
* connection past policies the model believes are enforcing. A silent bypass.
|
|
107
|
+
* - `writtenBy: 'worker' | 'functions'` + FORCE off -> reported, NOT flagged. These
|
|
108
|
+
* write on the owner connection on purpose; a FORCEd table would block them (on RDS
|
|
109
|
+
* the owner is not a superuser). Correct, and still named.
|
|
110
|
+
* - FORCE on -> reported, not flagged, whatever the principal.
|
|
111
|
+
*
|
|
112
|
+
* Without models (`db:pull`, where the models do not exist yet) `writtenBy` is null and
|
|
113
|
+
* NOTHING is flagged: the intent that would make a bypass wrong has not been declared.
|
|
114
|
+
* The owner is still named — that half is a live fact the pull genuinely saw.
|
|
115
|
+
*/
|
|
116
|
+
export function buildOwnershipReport(
|
|
117
|
+
owners: readonly TableOwner[],
|
|
118
|
+
live: AuthzContract,
|
|
119
|
+
opts: { models?: readonly ModelDescriptor[]; tables?: readonly string[] } = {},
|
|
120
|
+
): OwnershipRow[] {
|
|
121
|
+
const ownerByTable = new Map(owners.map((o) => [o.table, o.owner]));
|
|
122
|
+
const liveByTable = new Map(live.tables.map((t) => [t.table, t]));
|
|
123
|
+
|
|
124
|
+
// Governed = the tables the models declare when there are models; else the caller's
|
|
125
|
+
// explicit subject list (the pull's schema-scoped tables); else every live table.
|
|
126
|
+
const subjects: { table: string; writtenBy: WrittenBy | null }[] = opts.models
|
|
127
|
+
? opts.models.map((m) => ({ table: `${m.schema}.${m.table}`, writtenBy: m.writtenBy }))
|
|
128
|
+
: (opts.tables ?? live.tables.map((t) => t.table)).map((table) => ({ table, writtenBy: null }));
|
|
129
|
+
|
|
130
|
+
const rows: OwnershipRow[] = [];
|
|
131
|
+
for (const s of subjects) {
|
|
132
|
+
const l = liveByTable.get(s.table);
|
|
133
|
+
// A model with no live table is a CREATE — it has no owner yet, and nothing to report.
|
|
134
|
+
if (!l) continue;
|
|
135
|
+
const forced = l.rls.forced;
|
|
136
|
+
rows.push({
|
|
137
|
+
table: s.table,
|
|
138
|
+
owner: ownerByTable.get(s.table) ?? null,
|
|
139
|
+
forced,
|
|
140
|
+
writtenBy: s.writtenBy,
|
|
141
|
+
flagged: s.writtenBy === 'app' && !forced,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return rows.sort((a, b) => a.table.localeCompare(b.table));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Render the report. Grouped by owner, because one operator role usually owns the whole
|
|
149
|
+
* schema and thirty-three identical lines would bury the findings — the same reason
|
|
150
|
+
* `db:pull` says "grants exist for X" once instead of repeating it in every model.
|
|
151
|
+
*
|
|
152
|
+
* When ONE owner owns everything, that is a single line naming it. When there is more
|
|
153
|
+
* than one, every owner is named WITH its tables: a split ownership is itself the
|
|
154
|
+
* anomaly, and a count alone would not say which table changed hands.
|
|
155
|
+
*/
|
|
156
|
+
export function renderOwnershipReport(rows: readonly OwnershipRow[]): string[] {
|
|
157
|
+
if (rows.length === 0) return [];
|
|
158
|
+
|
|
159
|
+
const byOwner = new Map<string, OwnershipRow[]>();
|
|
160
|
+
for (const r of rows) {
|
|
161
|
+
const key = r.owner ?? '(not returned by the ownership query)';
|
|
162
|
+
let group = byOwner.get(key);
|
|
163
|
+
if (!group) byOwner.set(key, (group = []));
|
|
164
|
+
group.push(r);
|
|
165
|
+
}
|
|
166
|
+
const owners = [...byOwner.keys()].sort();
|
|
167
|
+
|
|
168
|
+
const lines = [
|
|
169
|
+
`Ownership of ${rows.length} governed table(s) — the owner bypasses RLS unless the table is FORCEd:`,
|
|
170
|
+
];
|
|
171
|
+
for (const owner of owners) {
|
|
172
|
+
const group = byOwner.get(owner)!;
|
|
173
|
+
const forced = group.filter((r) => r.forced).length;
|
|
174
|
+
const notForced = group.length - forced;
|
|
175
|
+
const tail = notForced > 0 ? `, ${notForced} NOT forced` : '';
|
|
176
|
+
lines.push(` ${owner} — ${group.length} table(s): ${forced} FORCE on${tail}`);
|
|
177
|
+
if (owners.length > 1) {
|
|
178
|
+
for (const r of group) lines.push(` ${r.table}${r.forced ? '' : ' (not forced)'}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const flagged = rows.filter((r) => r.flagged);
|
|
183
|
+
for (const r of flagged) {
|
|
184
|
+
lines.push(
|
|
185
|
+
` ! ${r.table} — written by the app but NOT FORCEd: owner ${r.owner ?? '(unknown)'} writes past every policy on this table.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const unknown = rows.filter((r) => r.owner === null);
|
|
189
|
+
for (const r of unknown) {
|
|
190
|
+
lines.push(` ? ${r.table} — the ownership query did not return this table; its owner is unknown.`);
|
|
191
|
+
}
|
|
192
|
+
return lines;
|
|
193
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
|
|
19
19
|
import { effectivePolicyCheck, parenthesizeOnce } from './authz-contract.js';
|
|
20
|
+
import { matchPolicies } from './authz-identity.js';
|
|
20
21
|
import { quoteQualified } from './pg-ident.js';
|
|
21
22
|
|
|
22
23
|
/**
|
|
@@ -135,20 +136,9 @@ function policyDropSql(table: string, name: string): string {
|
|
|
135
136
|
return `DROP POLICY IF EXISTS ${name} ON ${table};`;
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
* a live `FOR ALL USING (true)` and a compiled `FOR ALL USING (true) WITH CHECK (true)`
|
|
142
|
-
* authorize identically, and reconciling them would emit a DROP + CREATE that changes
|
|
143
|
-
* nothing.
|
|
144
|
-
*/
|
|
145
|
-
function policiesEqual(a: PolicyContract, b: PolicyContract): boolean {
|
|
146
|
-
return a.command === b.command
|
|
147
|
-
&& a.permissive === b.permissive
|
|
148
|
-
&& a.roles.join(',') === b.roles.join(',')
|
|
149
|
-
&& (a.using ?? '') === (b.using ?? '')
|
|
150
|
-
&& (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
|
|
151
|
-
}
|
|
139
|
+
// Policy equality lives in authz-identity.ts and nowhere else. The copy that used to sit here
|
|
140
|
+
// compared roles as `roles.join(',')`, which made `['a,b']` equal `['a','b']` — a false match
|
|
141
|
+
// on a legal role name. Two implementations of one predicate is how these surfaces drift.
|
|
152
142
|
|
|
153
143
|
function tableMap(c: AuthzContract): Map<string, TableContract> {
|
|
154
144
|
return new Map(c.tables.map((t) => [t.table, t]));
|
|
@@ -229,20 +219,22 @@ function reconcileRls(table: string, d: TableContract, l: TableContract | undefi
|
|
|
229
219
|
if (!d.rls.forced && l?.rls.forced) out.push(`ALTER TABLE ${table} NO FORCE ROW LEVEL SECURITY;`);
|
|
230
220
|
}
|
|
231
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Reconcile policies through the SHARED matcher — the same equivalence the differ and the
|
|
224
|
+
* fingerprint use, so the three can never contradict each other about one database.
|
|
225
|
+
*
|
|
226
|
+
* A live policy carrying the declared authorization under a different name, or covering in one
|
|
227
|
+
* policy the roles the compiler splits apart, is ADOPTED: it stays exactly as it is and
|
|
228
|
+
* nothing is emitted. Renaming it would be DDL that buys spelling.
|
|
229
|
+
*
|
|
230
|
+
* The matcher requires full field equality, so this can only turn a DROP + CREATE pair into a
|
|
231
|
+
* no-op — never emit DDL the name-keyed version would not have emitted.
|
|
232
|
+
*/
|
|
232
233
|
function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
for (const [name, lp] of lMap) {
|
|
238
|
-
const dp = dMap.get(name);
|
|
239
|
-
if (!dp || !policiesEqual(dp, lp)) out.push(policyDropSql(table, name));
|
|
240
|
-
}
|
|
241
|
-
// Creates: policies added, or changed (recreated to the declared form).
|
|
242
|
-
for (const [name, dp] of dMap) {
|
|
243
|
-
const lp = lMap.get(name);
|
|
244
|
-
if (!lp || !policiesEqual(dp, lp)) out.push(policyCreateSql(table, dp));
|
|
245
|
-
}
|
|
234
|
+
const m = matchPolicies(d.policies, l?.policies ?? []);
|
|
235
|
+
// Drops first, so a replaced policy never briefly co-exists with its old form.
|
|
236
|
+
for (const name of m.toDrop) out.push(policyDropSql(table, name));
|
|
237
|
+
for (const p of m.toCreate) out.push(policyCreateSql(table, p));
|
|
246
238
|
}
|
|
247
239
|
|
|
248
240
|
function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
|
|
@@ -30,6 +30,8 @@ import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrati
|
|
|
30
30
|
import { introspectContract, type QueryRunner, type AuthzContract } from '../authz-contract.js';
|
|
31
31
|
import { compileTableContract } from '../authz-compile.js';
|
|
32
32
|
import { governedRoleSet, ungovernedGrants, renderGrantExemptions } from '../authz-reconcile.js';
|
|
33
|
+
import { classifyAdoption, renderAdoptionReport } from '../authz-adoption-class.js';
|
|
34
|
+
import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership.js';
|
|
33
35
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
34
36
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
35
37
|
import { resolveModelsPath } from '../models-path.js';
|
|
@@ -151,6 +153,48 @@ function reportGrantExemptions(liveAuthz: AuthzContract | undefined, models: Mod
|
|
|
151
153
|
info(`Bring them under the reconciler with defineModule({ governedRoles: [...] }), or leave them exempt — either way they are listed here every run.`);
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
/**
|
|
157
|
+
* WHY does each authorization statement exist? — printed on every brownfield generate.
|
|
158
|
+
*
|
|
159
|
+
* A first plan against an existing database is long, and the length is not the problem: the
|
|
160
|
+
* problem is that nobody can tell which statements are everystack imposing its own spelling
|
|
161
|
+
* and which are real differences. `convention` is the count that indicts US, and driving it to
|
|
162
|
+
* zero is the whole point — so it is named first and never hidden behind a flag.
|
|
163
|
+
*/
|
|
164
|
+
function reportAdoptionClasses(liveAuthz: AuthzContract | undefined, models: ModelDescriptor[]): void {
|
|
165
|
+
if (!liveAuthz) return; // greenfield — every statement is additive, nothing to adjudicate
|
|
166
|
+
const declared: AuthzContract = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
|
|
167
|
+
const { counts } = classifyAdoption(declared, liveAuthz);
|
|
168
|
+
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
169
|
+
if (total === 0) return;
|
|
170
|
+
info(`${total} authorization statement(s), by why they exist:`);
|
|
171
|
+
for (const line of renderAdoptionReport(counts)) info(line);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* WHO owns each governed table, and is that owner subject to its own RLS? — printed
|
|
176
|
+
* on every generate that has a live database.
|
|
177
|
+
*
|
|
178
|
+
* The owner never appears in the contract or the fingerprint: it is the dev user locally
|
|
179
|
+
* and the operator role on a stage, so committing it would drift on every environment.
|
|
180
|
+
* That is precisely why it must be SAID. A deployed owner that differs from the one the
|
|
181
|
+
* author has in mind is invisible in every artifact, and an unforced table lets that owner
|
|
182
|
+
* write past every policy in this repo. The report names the owner even when nothing is
|
|
183
|
+
* wrong, because silence here is what makes the mismatch impossible to notice.
|
|
184
|
+
*/
|
|
185
|
+
function reportOwnership(
|
|
186
|
+
liveAuthz: AuthzContract | undefined,
|
|
187
|
+
owners: readonly TableOwner[],
|
|
188
|
+
models: ModelDescriptor[],
|
|
189
|
+
): void {
|
|
190
|
+
if (!liveAuthz) return; // greenfield — nothing owns anything yet
|
|
191
|
+
const rows = buildOwnershipReport(owners, liveAuthz, { models });
|
|
192
|
+
for (const line of renderOwnershipReport(rows)) {
|
|
193
|
+
if (line.trimStart().startsWith('!')) warn(line.trim());
|
|
194
|
+
else info(line);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
154
198
|
async function readJournal(migrationsDir: string): Promise<Journal | null> {
|
|
155
199
|
try {
|
|
156
200
|
return JSON.parse(await fs.readFile(path.join(migrationsDir, 'meta', '_journal.json'), 'utf8'));
|
|
@@ -203,6 +247,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
203
247
|
let declaredDb: DeclaredDerived | null = null;
|
|
204
248
|
let current;
|
|
205
249
|
let liveAuthz;
|
|
250
|
+
let liveOwners: TableOwner[] = [];
|
|
206
251
|
let runner!: QueryRunner;
|
|
207
252
|
let end: (() => Promise<void>) | undefined;
|
|
208
253
|
try {
|
|
@@ -236,6 +281,9 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
236
281
|
current = await introspectSchema(runner);
|
|
237
282
|
step('Introspecting authorization (rls + grants + policies)...');
|
|
238
283
|
liveAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
|
|
284
|
+
// Ownership rides the same connection: it is not part of the contract (it never
|
|
285
|
+
// enters the fingerprint), but it decides whether the contract is actually in force.
|
|
286
|
+
liveOwners = await introspectTableOwners(runner);
|
|
239
287
|
if (!apply) await end?.();
|
|
240
288
|
} catch (err: any) {
|
|
241
289
|
fail(err.message);
|
|
@@ -257,6 +305,8 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
257
305
|
// ungoverned grantee's privileges alone rather than revoking them; that is only
|
|
258
306
|
// defensible because the artifact says exactly whose access it chose not to govern.
|
|
259
307
|
reportGrantExemptions(liveAuthz, models, declaredDb?.governedRoles ?? []);
|
|
308
|
+
reportAdoptionClasses(liveAuthz, models);
|
|
309
|
+
reportOwnership(liveAuthz, liveOwners, models);
|
|
260
310
|
console.log('');
|
|
261
311
|
if (statements.length === 0) {
|
|
262
312
|
success(`db:generate — the live database already matches the models. ${dryRun ? 'Nothing to preview.' : 'No migration written.'}`);
|
|
@@ -22,6 +22,8 @@ import fs from 'node:fs/promises';
|
|
|
22
22
|
import { spawnSync } from 'node:child_process';
|
|
23
23
|
import type { ModelDescriptor } from '@everystack/model';
|
|
24
24
|
import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
25
|
+
import { classifyAdoption, renderAdoptionReport } from '../authz-adoption-class.js';
|
|
26
|
+
import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport } from '../authz-ownership.js';
|
|
25
27
|
import { introspectSchema } from '../schema-introspect.js';
|
|
26
28
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
27
29
|
import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan.js';
|
|
@@ -39,7 +41,7 @@ import { governedRoleSet, ungovernedGrants } from '../authz-reconcile.js';
|
|
|
39
41
|
import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline.js';
|
|
40
42
|
import { compileTableContract } from '../authz-compile.js';
|
|
41
43
|
import { reportPipelineLastRun } from './pipeline-run.js';
|
|
42
|
-
import { step, success, fail, info, warn } from '../output.js';
|
|
44
|
+
import { step, success, fail, info, warn, reserveStdoutForData } from '../output.js';
|
|
43
45
|
|
|
44
46
|
const DEFAULT_OUT = 'db.plan.json';
|
|
45
47
|
|
|
@@ -61,6 +63,12 @@ function isGitIgnored(file: string): boolean {
|
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
export async function dbPlanCommand(flags: Record<string, string>): Promise<void> {
|
|
66
|
+
const out = flags.out || DEFAULT_OUT;
|
|
67
|
+
// `--out -` makes stdout the artifact. Declared before anything prints, so the summary,
|
|
68
|
+
// the adoption report and the ownership report land on stderr and the plan JSON is the
|
|
69
|
+
// only thing on stdout — otherwise `db:plan --out - | jq` reads a report as its input.
|
|
70
|
+
if (out === '-') reserveStdoutForData();
|
|
71
|
+
|
|
64
72
|
let dbSource: DbSource;
|
|
65
73
|
try {
|
|
66
74
|
dbSource = resolveDbSource(flags);
|
|
@@ -95,6 +103,9 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
95
103
|
step('Asking the target its fingerprint (introspecting state + authz)...');
|
|
96
104
|
const snapshot = await introspectSchema(runner);
|
|
97
105
|
const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
|
|
106
|
+
// Not part of the plan and not part of either fingerprint — the owner is
|
|
107
|
+
// environment-specific. It is read here so the REVIEW surface can name it.
|
|
108
|
+
const owners = await introspectTableOwners(runner);
|
|
98
109
|
// The modules' widened governed-role set. A barrel that exports only `models` has none,
|
|
99
110
|
// which is the greenfield default: govern exactly what the models name.
|
|
100
111
|
let declaredGovernedRoles: string[] = [];
|
|
@@ -134,7 +145,6 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
134
145
|
process.exit(1);
|
|
135
146
|
}
|
|
136
147
|
|
|
137
|
-
const out = flags.out || DEFAULT_OUT;
|
|
138
148
|
const body = JSON.stringify(plan, null, 2) + '\n';
|
|
139
149
|
if (out === '-') {
|
|
140
150
|
console.log(body);
|
|
@@ -145,6 +155,28 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
145
155
|
for (const line of buildPlanSummary(plan)) info(line);
|
|
146
156
|
info(`plan_ref: ${planHash(plan).slice(0, 12)}`);
|
|
147
157
|
|
|
158
|
+
// WHY does each authorization statement exist? — the same report db:generate prints,
|
|
159
|
+
// on the artifact a human actually reviews before applying.
|
|
160
|
+
//
|
|
161
|
+
// Re-DERIVED from live every mint, never stored: a recorded claim about what the models
|
|
162
|
+
// fail to capture goes stale the moment someone closes the gap, and a stale note is worse
|
|
163
|
+
// than none. `capability` is the count that says "this plan removes something the model
|
|
164
|
+
// cannot express" — the operator reading this is the last one who can catch it.
|
|
165
|
+
const adoption = classifyAdoption(declaredAuthz, contract, { governedRoles: governedRoleSet(declaredAuthz, declaredGovernedRoles) });
|
|
166
|
+
const adoptionTotal = Object.values(adoption.counts).reduce((a, b) => a + b, 0);
|
|
167
|
+
if (adoptionTotal > 0) {
|
|
168
|
+
info(`${adoptionTotal} authorization statement(s), by why they exist:`);
|
|
169
|
+
for (const line of renderAdoptionReport(adoption.counts)) info(line);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// WHO owns the tables this plan authorizes, and does that owner obey the policies it
|
|
173
|
+
// is about to write? Re-derived live every mint, never stored — the owner is a fact
|
|
174
|
+
// about THIS target, and the target is the one thing a committed artifact cannot carry.
|
|
175
|
+
for (const line of renderOwnershipReport(buildOwnershipReport(owners, contract, { models }))) {
|
|
176
|
+
if (line.trimStart().startsWith('!')) warn(line.trim());
|
|
177
|
+
else info(line);
|
|
178
|
+
}
|
|
179
|
+
|
|
148
180
|
// The fast-forward rule, warn-only at mint time (db:apply enforces it):
|
|
149
181
|
// a stale checkout mints a legal-looking plan whose edge REVERTS merged
|
|
150
182
|
// work — say so on the review surface, where a human still reads it.
|
|
@@ -34,7 +34,8 @@ import { fingerprintLive } from '../schema-fingerprint.js';
|
|
|
34
34
|
import { ungovernedGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
|
|
35
35
|
import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline.js';
|
|
36
36
|
import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
|
|
37
|
-
import { introspectContract, type TableContract } from '../authz-contract.js';
|
|
37
|
+
import { introspectContract, type TableContract, type AuthzContract } from '../authz-contract.js';
|
|
38
|
+
import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership.js';
|
|
38
39
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
39
40
|
import { renderDerivedSource, renderDerivedFile, type DerivedRenderResult } from '../derived-render.js';
|
|
40
41
|
import { renderModelSource, renderModelFiles, pullableTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
|
|
@@ -150,6 +151,9 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
150
151
|
/** Adoption observation — the foreign grantees present, and what the claim is true OF. */
|
|
151
152
|
let pulledExemptions: GrantExemption[] = [];
|
|
152
153
|
let pulledFingerprint = '';
|
|
154
|
+
/** Live ownership — reported, never rendered into a model (it is environment-specific). */
|
|
155
|
+
let liveContract: AuthzContract | undefined;
|
|
156
|
+
let liveOwners: TableOwner[] = [];
|
|
153
157
|
let derivedCatalog: DerivedCatalog | undefined;
|
|
154
158
|
let matviewColumns: Map<string, ColumnSchema[]> | undefined;
|
|
155
159
|
let candidatesByIdentity: Map<string, string[]> | undefined;
|
|
@@ -176,6 +180,11 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
176
180
|
if (abilities === 'live') {
|
|
177
181
|
note('Introspecting live authorization (grants + policies)...');
|
|
178
182
|
const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
|
|
183
|
+
liveContract = contract;
|
|
184
|
+
// The owner is NOT rendered into the models — it is the dev user here and the
|
|
185
|
+
// operator role on a stage, so a model that declared it would drift everywhere.
|
|
186
|
+
// It is read to be SAID, on the one surface that is looking at the database.
|
|
187
|
+
liveOwners = await introspectTableOwners(runner);
|
|
179
188
|
liveAuthz = new Map(contract.tables.map((t) => [t.table, t]));
|
|
180
189
|
detail(`${liveAuthz.size} table(s) carry authorization.`);
|
|
181
190
|
// Roles outside the model vocabulary (anon/authenticated/admin) are usually ONE
|
|
@@ -243,6 +252,16 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
243
252
|
process.exit(1);
|
|
244
253
|
}
|
|
245
254
|
|
|
255
|
+
// WHO owns the tables being pulled. Nothing is FLAGGED here: flagging needs a declared
|
|
256
|
+
// write principal to contradict, and the models this pull is about to write do not exist
|
|
257
|
+
// yet. Naming the owner is the half the pull genuinely saw — and the half that vanishes
|
|
258
|
+
// from every artifact afterwards, because the contract excludes the owner on purpose.
|
|
259
|
+
if (liveContract) {
|
|
260
|
+
for (const line of renderOwnershipReport(
|
|
261
|
+
buildOwnershipReport(liveOwners, liveContract, { tables: pulled.map((t) => t.table) }),
|
|
262
|
+
)) note(line);
|
|
263
|
+
}
|
|
264
|
+
|
|
246
265
|
// The derived layer (B5): rendered from the live catalog with dependsOn from the real
|
|
247
266
|
// edge graph. Everything inexpressible is a stderr warning AND an inline FIXME.
|
|
248
267
|
const knownTables = new Map(pulled.map((t) => [t.table, modelVarName(t.table)]));
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -31,7 +31,7 @@ import type { AuthzContract } from './authz-contract.js';
|
|
|
31
31
|
import { generateMigrationSql, unmodeledTables } from './migration-generate.js';
|
|
32
32
|
import { compileTableContract } from './authz-compile.js';
|
|
33
33
|
import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply.js';
|
|
34
|
-
import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
|
|
34
|
+
import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify, compareStoredFingerprint, formatChangedMessage, FINGERPRINT_VERSION } from './schema-fingerprint.js';
|
|
35
35
|
import { compileDeclaredState } from './declared-diff.js';
|
|
36
36
|
import { compileTableRenames, compileTableMoves } from './schema-compile.js';
|
|
37
37
|
|
|
@@ -61,6 +61,12 @@ export interface EdgePlan {
|
|
|
61
61
|
v: number;
|
|
62
62
|
/** The target's exact live fingerprint at mint time — the lock. */
|
|
63
63
|
fromFingerprint: string;
|
|
64
|
+
/**
|
|
65
|
+
* The FINGERPRINT_VERSION the endpoints were computed under. Stored beside the hashes so a
|
|
66
|
+
* plan minted under an older canonical form REFUSES as a format change rather than reporting
|
|
67
|
+
* drift that no edit could explain. Absent on plans minted before v4.
|
|
68
|
+
*/
|
|
69
|
+
fpVersion?: number;
|
|
64
70
|
/** The predicted live fingerprint after apply — exact, unmodeled-aware. */
|
|
65
71
|
toFingerprint: string;
|
|
66
72
|
/** The models-only fingerprint — context for the strong claim. Equals `to` when nothing is unmodeled. */
|
|
@@ -190,6 +196,7 @@ export function mintEdgePlan(
|
|
|
190
196
|
return {
|
|
191
197
|
v: PLAN_VERSION,
|
|
192
198
|
fromFingerprint: fingerprintLive(snapshot, contract).hash,
|
|
199
|
+
fpVersion: FINGERPRINT_VERSION,
|
|
193
200
|
toFingerprint: predictLiveFingerprint(models, snapshot, contract, { schema: opts.schema }),
|
|
194
201
|
declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
|
|
195
202
|
statements,
|
|
@@ -250,7 +257,12 @@ export type PlanPrecondition = { ok: true } | { ok: false; reason: string };
|
|
|
250
257
|
|
|
251
258
|
/** The lock: apply only when the target is exactly where the plan started. */
|
|
252
259
|
export function checkPlanPrecondition(plan: EdgePlan, liveFingerprint: string): PlanPrecondition {
|
|
253
|
-
|
|
260
|
+
const cmp = compareStoredFingerprint({ hash: plan.fromFingerprint, v: plan.fpVersion }, liveFingerprint);
|
|
261
|
+
if (cmp.kind === 'match') return { ok: true };
|
|
262
|
+
// A plan minted under an older canonical form cannot be compared at all. Saying "the state
|
|
263
|
+
// moved (a concurrent apply or a hand edit)" would send the operator hunting a change that
|
|
264
|
+
// never happened — plans are ephemeral by design, so the honest answer is re-mint.
|
|
265
|
+
if (cmp.kind === 'format-changed') return { ok: false, reason: formatChangedMessage(cmp) };
|
|
254
266
|
return {
|
|
255
267
|
ok: false,
|
|
256
268
|
reason:
|
package/src/cli/index.ts
CHANGED
|
@@ -45,27 +45,11 @@ import { auditCommand } from './commands/audit.js';
|
|
|
45
45
|
import { uiAuditCommand } from './commands/ui-audit.js';
|
|
46
46
|
import { runbookCommand } from './commands/runbook.js';
|
|
47
47
|
import { fail } from './output.js';
|
|
48
|
+
import { parseFlags } from './parse-flags.js';
|
|
48
49
|
|
|
49
50
|
const args = process.argv.slice(2);
|
|
50
51
|
const command = args[0];
|
|
51
52
|
|
|
52
|
-
function parseFlags(args: string[]): Record<string, string> {
|
|
53
|
-
const flags: Record<string, string> = {};
|
|
54
|
-
for (let i = 0; i < args.length; i++) {
|
|
55
|
-
// Handle both --flag and -flag
|
|
56
|
-
if (args[i].startsWith('--')) {
|
|
57
|
-
const key = args[i].slice(2);
|
|
58
|
-
const value = args[i + 1] && !args[i + 1].startsWith('-') ? args[++i] : 'true';
|
|
59
|
-
flags[key] = value;
|
|
60
|
-
} else if (args[i].startsWith('-') && args[i].length > 1) {
|
|
61
|
-
const key = args[i].slice(1);
|
|
62
|
-
const value = args[i + 1] && !args[i + 1].startsWith('-') ? args[++i] : 'true';
|
|
63
|
-
flags[key] = value;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return flags;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
53
|
/**
|
|
70
54
|
* Auto-detect HOST_URL from SST outputs.
|
|
71
55
|
* SST writes .sst/outputs.json after every deploy with { routerUrl, apiUrl, ... }.
|
package/src/cli/model-render.ts
CHANGED
|
@@ -213,8 +213,25 @@ function bareName(table: string): string {
|
|
|
213
213
|
return table.replace(/^[^.]+\./, '');
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
/**
|
|
217
|
-
|
|
216
|
+
/**
|
|
217
|
+
* Migration infrastructure a pull must never render as a model, whatever schema it landed in.
|
|
218
|
+
*
|
|
219
|
+
* A brownfield pull adopts a database another migration tool still owns, and that tool's
|
|
220
|
+
* bookkeeping is not the app's data. Rendering it as a model makes everystack GOVERN it: the
|
|
221
|
+
* compiled contract declares an admin policy, and the adopter's first plan carries a
|
|
222
|
+
* `CREATE POLICY` on a table nobody asked us to manage. The journal belongs to whoever writes
|
|
223
|
+
* it — drizzle-kit's `__drizzle_migrations`, Rails' `schema_migrations` +
|
|
224
|
+
* `ar_internal_metadata`.
|
|
225
|
+
*
|
|
226
|
+
* Deliberately a SHORT list of names that are unambiguously a migration tool's own state. It
|
|
227
|
+
* is not a heuristic and must not become one: a real app table wrongly matched here silently
|
|
228
|
+
* loses its declared authorization.
|
|
229
|
+
*/
|
|
230
|
+
const INFRASTRUCTURE_TABLES = new Set([
|
|
231
|
+
'__drizzle_migrations', // drizzle-kit
|
|
232
|
+
'schema_migrations', // Rails / ActiveRecord
|
|
233
|
+
'ar_internal_metadata', // Rails / ActiveRecord
|
|
234
|
+
]);
|
|
218
235
|
|
|
219
236
|
/**
|
|
220
237
|
* The tables a pull renders: the requested schema, minus migration infrastructure.
|
package/src/cli/output.ts
CHANGED
|
@@ -11,11 +11,11 @@ export function step(msg: string): void {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function success(msg: string): void {
|
|
14
|
-
|
|
14
|
+
say(` \u2713 ${msg}`);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function warn(msg: string): void {
|
|
18
|
-
|
|
18
|
+
say(` ! ${msg}`);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function fail(msg: string): void {
|
|
@@ -23,5 +23,27 @@ export function fail(msg: string): void {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function info(msg: string): void {
|
|
26
|
-
|
|
26
|
+
say(` ${msg}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* When a command makes stdout the DATA channel (`db:plan --out -`), every human-readable
|
|
31
|
+
* line has to move to stderr \u2014 otherwise the report is interleaved with the artifact and
|
|
32
|
+
* the stdout form cannot be piped to anything. `step` and `fail` already write to stderr
|
|
33
|
+
* on that principle; this extends it to the rest, on demand.
|
|
34
|
+
*
|
|
35
|
+
* Off by default, so no command's output moves unless it asks. One-way and process-wide:
|
|
36
|
+
* a CLI process runs exactly one command, and the choice is made once, before it prints.
|
|
37
|
+
*/
|
|
38
|
+
let stdoutReserved = false;
|
|
39
|
+
|
|
40
|
+
/** Declare stdout the data channel: human-readable output moves to stderr from here on. */
|
|
41
|
+
export function reserveStdoutForData(): void {
|
|
42
|
+
stdoutReserved = true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Human-readable output \u2014 stdout normally, stderr once stdout is reserved for data. */
|
|
46
|
+
function say(line: string): void {
|
|
47
|
+
if (stdoutReserved) console.error(line);
|
|
48
|
+
else console.log(line);
|
|
27
49
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's flag parser.
|
|
3
|
+
*
|
|
4
|
+
* `--key value` / `-k value`, with a value-less flag defaulting to the string `'true'`.
|
|
5
|
+
* Extracted from the entry point so it can be tested without executing the CLI.
|
|
6
|
+
*
|
|
7
|
+
* The subtlety is what counts as "the next argument is a VALUE, not the next flag".
|
|
8
|
+
* Two legitimate values look like flags, or like nothing, to a naive check:
|
|
9
|
+
*
|
|
10
|
+
* - A bare `-` is the conventional stdout sentinel. `db:plan --out -` documents itself
|
|
11
|
+
* as "print to stdout"; read as a value-less flag it became `out=true` and the
|
|
12
|
+
* command wrote a file literally named `true`.
|
|
13
|
+
* - The EMPTY string is a value. `db:approvers --set ''` is the documented way to
|
|
14
|
+
* declare an empty approver set and DISABLE destructive applies on a stage; read as
|
|
15
|
+
* value-less it became `set=true`, and the command refused with "--set needs a
|
|
16
|
+
* value" — so the documented way to turn the gate off could not be typed.
|
|
17
|
+
*
|
|
18
|
+
* Both are the same mistake: treating an unusual-looking value as an absent one. A flag
|
|
19
|
+
* is an argument that starts with `-` AND is longer than one character; everything else,
|
|
20
|
+
* including `''`, is a value.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Is this argument a flag (as opposed to a value)? A bare `-` is a value. */
|
|
24
|
+
function isFlag(arg: string): boolean {
|
|
25
|
+
return arg.startsWith('-') && arg.length > 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Parse `--key value` / `-k value` pairs; a flag with no value becomes `'true'`. */
|
|
29
|
+
export function parseFlags(args: string[]): Record<string, string> {
|
|
30
|
+
const flags: Record<string, string> = {};
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
const arg = args[i];
|
|
33
|
+
if (!isFlag(arg)) continue;
|
|
34
|
+
const key = arg.startsWith('--') ? arg.slice(2) : arg.slice(1);
|
|
35
|
+
const next = args[i + 1];
|
|
36
|
+
flags[key] = next !== undefined && !isFlag(next) ? args[++i] : 'true';
|
|
37
|
+
}
|
|
38
|
+
return flags;
|
|
39
|
+
}
|