@everystack/cli 0.4.44 → 0.4.46
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/alter-type-dependents.ts +96 -0
- package/src/cli/apply-execute.ts +22 -8
- package/src/cli/authz-adoption-class.ts +314 -0
- package/src/cli/authz-baseline.ts +25 -3
- package/src/cli/authz-canonical.ts +178 -0
- package/src/cli/authz-compile.ts +130 -40
- package/src/cli/authz-contract.ts +212 -44
- package/src/cli/authz-derive.ts +244 -34
- package/src/cli/authz-identity.ts +222 -0
- package/src/cli/authz-ownership.ts +193 -0
- package/src/cli/authz-reconcile.ts +61 -27
- package/src/cli/aws.ts +32 -0
- package/src/cli/commands/db-apply.ts +60 -14
- package/src/cli/commands/db-authz.ts +9 -14
- package/src/cli/commands/db-fingerprint.ts +54 -18
- package/src/cli/commands/db-generate.ts +59 -15
- package/src/cli/commands/db-plan.ts +89 -9
- package/src/cli/commands/db-pull.ts +36 -19
- package/src/cli/commands/db-reconcile.ts +18 -20
- package/src/cli/commands/db-swap.ts +5 -4
- package/src/cli/commands/db-sync.ts +8 -5
- package/src/cli/db-build.ts +2 -2
- package/src/cli/db-source.ts +56 -0
- package/src/cli/derived-introspect.ts +27 -26
- package/src/cli/derived-lint.ts +7 -8
- package/src/cli/edge-plan.ts +125 -17
- package/src/cli/git-descent.ts +16 -9
- package/src/cli/index.ts +2 -18
- package/src/cli/model-render.ts +75 -52
- package/src/cli/output.ts +25 -3
- package/src/cli/parse-flags.ts +39 -0
- package/src/cli/schema-compile.ts +6 -1
- package/src/cli/schema-diff.ts +1 -1
- package/src/cli/schema-fingerprint.ts +154 -42
- package/src/cli/schema-introspect.ts +44 -17
- package/src/cli/schema-source.ts +9 -0
- package/src/cli/session.ts +184 -0
- package/src/cli/stage-read-consistency.ts +128 -0
- package/src/cli/state-apply.ts +4 -2
- package/src/cli/swap-execute.ts +4 -3
- package/src/cli/search-path.ts +0 -51
|
@@ -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
|
+
}
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
|
|
19
|
-
import { effectivePolicyCheck, parenthesizeOnce } from './authz-contract.js';
|
|
19
|
+
import { effectivePolicyCheck, parenthesizeOnce, isPolicyDead, isPolicySubsumed } 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]));
|
|
@@ -179,7 +169,7 @@ export function emitReconcileSql(
|
|
|
179
169
|
// the EMITTED SQL must quote it, or a reserved table name (`public.user`) is a syntax error.
|
|
180
170
|
const table = quoteQualified(d.table);
|
|
181
171
|
reconcileRls(table, d, l, sql);
|
|
182
|
-
reconcilePolicies(table, d, l, sql);
|
|
172
|
+
reconcilePolicies(table, d, l, sql, governed);
|
|
183
173
|
reconcileGrants(table, d, l, sql, governed);
|
|
184
174
|
reconcileColumnGrants(table, d, l, sql, governed);
|
|
185
175
|
}
|
|
@@ -229,19 +219,63 @@ 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
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
+
*/
|
|
233
|
+
function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
|
|
234
|
+
const live = l?.policies ?? [];
|
|
235
|
+
const m = matchPolicies(d.policies, live);
|
|
236
|
+
const byName = new Map(live.map((p) => [p.name, p]));
|
|
235
237
|
|
|
236
|
-
// Drops first
|
|
237
|
-
for (const
|
|
238
|
-
|
|
239
|
-
|
|
238
|
+
// Drops first, so a replaced policy never briefly co-exists with its old form.
|
|
239
|
+
for (const name of m.toDrop) {
|
|
240
|
+
// A live policy scoped ENTIRELY to roles the models do not govern is left alone, exactly
|
|
241
|
+
// as reconcileGrants leaves that role's privileges alone. Without this the two lanes
|
|
242
|
+
// disagree about the same role: it keeps its GRANT and loses its POLICY, and on an
|
|
243
|
+
// RLS-enabled table that combination reads zero rows. A bounded read-only role added for
|
|
244
|
+
// one indexable query is the shape this bites — the model has no word for it, so "no
|
|
245
|
+
// declaration" and "no access" collapsed into the same thing. Same failure as the
|
|
246
|
+
// ungoverned-grantee bug, one lane over.
|
|
247
|
+
//
|
|
248
|
+
// Only when EVERY role is ungoverned. A policy naming anon alongside such a role still
|
|
249
|
+
// governs anon, and leaving it would be the reconciler declining to do its job.
|
|
250
|
+
const p = byName.get(name);
|
|
251
|
+
if (p && p.roles.length > 0 && p.roles.every((r) => !isGoverned(governed, r))) continue;
|
|
252
|
+
// A DEAD policy authorizes nothing — no role it names holds the privilege it polices, so
|
|
253
|
+
// Postgres refuses at the GRANT before ever consulting it. Dropping it changes no access,
|
|
254
|
+
// which makes it churn: a statement an adopter must read, approve and apply to reach zero,
|
|
255
|
+
// whose only effect is to tidy a catalog. Leave it, exactly as the canonical form leaves
|
|
256
|
+
// it out of the state.
|
|
257
|
+
//
|
|
258
|
+
// DEAD BEFORE **AND AFTER**, and the second half is the whole safety of this. Deadness is
|
|
259
|
+
// a property of the contract, not the policy: if THIS plan grants the privilege the policy
|
|
260
|
+
// polices, the policy wakes up the moment the plan lands — undeclared, ungoverned, and
|
|
261
|
+
// granting access the model never asked for. Leaving it would be a widening the plan
|
|
262
|
+
// performs on itself. So it may only be left alone when the DECLARED state keeps it just
|
|
263
|
+
// as inert as the live one does.
|
|
264
|
+
if (p && l && isPolicyDead(l, p) && isPolicyDead(d, p)) continue;
|
|
265
|
+
// Subsumed, before AND after, for exactly the reason deadness needs both: if this plan
|
|
266
|
+
// drops the PUBLIC policy that was covering it, the role-scoped one stops being redundant
|
|
267
|
+
// the moment the plan lands — and leaving it would keep access the model never declared.
|
|
268
|
+
if (p && l && isPolicySubsumed(l, p) && isPolicySubsumed(d, p)) continue;
|
|
269
|
+
out.push(policyDropSql(table, name));
|
|
240
270
|
}
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
271
|
+
// Symmetric with the drop rule above, and required by the same identity: a DECLARED policy
|
|
272
|
+
// the declared grants do not back is inert — it would authorize nothing the moment it
|
|
273
|
+
// landed. The canonical form already leaves it out of the declared state, so creating it
|
|
274
|
+
// would emit a statement for a difference the hash says does not exist. It appears the
|
|
275
|
+
// moment the model grants the privilege it polices.
|
|
276
|
+
for (const p of m.toCreate) {
|
|
277
|
+
if (isPolicyDead(d, p) || isPolicySubsumed(d, p)) continue;
|
|
278
|
+
out.push(policyCreateSql(table, p));
|
|
245
279
|
}
|
|
246
280
|
}
|
|
247
281
|
|
package/src/cli/aws.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { isOpsHandlerCrash, formatOpsCrashReport, fetchDiagnosisWithRetry } from './ops-diagnostics.js';
|
|
9
9
|
import { tagInvokeTransport } from './ops-advice.js';
|
|
10
|
+
import type { SessionRunner } from './session.js';
|
|
10
11
|
|
|
11
12
|
let s3Client: InstanceType<typeof import('@aws-sdk/client-s3').S3Client> | null = null;
|
|
12
13
|
let lambdaClient: InstanceType<typeof import('@aws-sdk/client-lambda').LambdaClient> | null = null;
|
|
@@ -302,6 +303,37 @@ export function lambdaQueryRunner(
|
|
|
302
303
|
};
|
|
303
304
|
}
|
|
304
305
|
|
|
306
|
+
/**
|
|
307
|
+
* A `SessionRunner` backed by the ops Lambda's `db:session` action — the stage lane's
|
|
308
|
+
* only consistent read.
|
|
309
|
+
*
|
|
310
|
+
* `lambdaQueryRunner` above sends one invoke per statement, so a multi-statement read is
|
|
311
|
+
* answered by however many containers happen to be warm, each on its own connection.
|
|
312
|
+
* This sends the statements TOGETHER: one invoke, one container, one connection, one
|
|
313
|
+
* transaction. The response arrives gzipped (catalog JSON compresses 10-20x, which is
|
|
314
|
+
* what keeps a real schema inside the 6 MB invoke-response limit).
|
|
315
|
+
*/
|
|
316
|
+
export function lambdaSessionRunner(
|
|
317
|
+
region: string,
|
|
318
|
+
functionName: string,
|
|
319
|
+
invoke: typeof invokeAction = invokeAction,
|
|
320
|
+
): SessionRunner {
|
|
321
|
+
return async (statements, opts) => {
|
|
322
|
+
const result: any = await invoke(region, functionName, 'db:session', {
|
|
323
|
+
statements,
|
|
324
|
+
...(opts ?? {}),
|
|
325
|
+
});
|
|
326
|
+
if (result?.error) throw new Error(`Session failed: ${result.error}`);
|
|
327
|
+
if (typeof result?.gzip !== 'string') {
|
|
328
|
+
throw new Error(
|
|
329
|
+
'the ops Lambda did not answer db:session. Deploy a server build that ships the db:session action — the stage lane cannot read consistently without it.',
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const { gunzipSync } = await import('node:zlib');
|
|
333
|
+
return JSON.parse(gunzipSync(Buffer.from(result.gzip, 'base64')).toString('utf8'));
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
305
337
|
export async function invokeAction(
|
|
306
338
|
region: string,
|
|
307
339
|
functionName: string,
|
|
@@ -18,14 +18,19 @@
|
|
|
18
18
|
* is exact even on brownfield targets (mint predicted it with the
|
|
19
19
|
* unmodeled tables merged in). A mismatch here is a real fault, loud.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
21
|
+
* Two lanes. DIRECT (--database-url / --stage --direct) executes CLI-side over
|
|
22
|
+
* one session. STAGE (--stage) executes credential-free in the ops Lambda —
|
|
23
|
+
* and reads over `lambdaQueryRunner`, one Lambda invoke per catalog query, so a
|
|
24
|
+
* single introspection can be assembled from several containers holding
|
|
25
|
+
* connections to different databases. That lane therefore reads the stage TWICE
|
|
26
|
+
* and refuses a disagreement, reports agreement as a non-detection (it cannot
|
|
27
|
+
* verify read consistency), and refuses a DESTRUCTIVE plan outright — losing
|
|
28
|
+
* data on a read that cannot be verified is not a trade the ceremony can buy.
|
|
29
|
+
* See stage-read-consistency.ts.
|
|
22
30
|
*/
|
|
23
31
|
|
|
24
32
|
import fs from 'node:fs/promises';
|
|
25
33
|
import { type QueryRunner } from '../authz-contract.js';
|
|
26
|
-
import { introspectSchema } from '../schema-introspect.js';
|
|
27
|
-
import { introspectContract } from '../authz-contract.js';
|
|
28
|
-
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
29
34
|
import { currentGitRef } from '../state-apply.js';
|
|
30
35
|
import { planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan.js';
|
|
31
36
|
import { verifyDescent, type DescentVerdict, type LiveState } from '../git-descent.js';
|
|
@@ -37,12 +42,14 @@ import { compileTableContract } from '../authz-compile.js';
|
|
|
37
42
|
import { governedRoleSet, ungovernedGrants } from '../authz-reconcile.js';
|
|
38
43
|
import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline.js';
|
|
39
44
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
45
|
+
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
40
46
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
41
|
-
import { invokeAction, lambdaQueryRunner } from '../aws.js';
|
|
47
|
+
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
42
48
|
import { executeApplyPlan, type ApplyPlanResult } from '../apply-execute.js';
|
|
43
49
|
import { estimateOpsRuntimeFit } from '../ops-fit.js';
|
|
44
50
|
import { resolveOperatorUrlViaStage } from '../direct-venue.js';
|
|
45
51
|
import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
|
|
52
|
+
import { readStageStateTwice, stageDestructiveRefusal } from '../stage-read-consistency.js';
|
|
46
53
|
import { step, success, fail, info, warn } from '../output.js';
|
|
47
54
|
|
|
48
55
|
// The apply core lives in apply-execute.ts (shared with the ops Lambda's
|
|
@@ -128,20 +135,34 @@ async function applyPlanViaStage(
|
|
|
128
135
|
|
|
129
136
|
info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
|
|
130
137
|
|
|
138
|
+
// THE STAGE LANE'S READ GUARD, before anything is dispatched. Each introspection is
|
|
139
|
+
// one session, but a full read is two of them (state, then authz) and they can be
|
|
140
|
+
// answered by two containers at two moments. Read the stage twice and refuse a
|
|
141
|
+
// disagreement — the apply never starts. Deliberately OUTSIDE the try below: this
|
|
142
|
+
// refusal must exit as itself, not as "Apply failed".
|
|
143
|
+
step('Reading the stage TWICE (state and authz are separate ops-Lambda invokes)...');
|
|
144
|
+
const readSession = lambdaSessionRunner(region, fn);
|
|
145
|
+
const readPair = await readStageStateTwice(readSession).catch((err: any) => {
|
|
146
|
+
fail(`Apply failed: could not read the stage — ${err.message}`);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
});
|
|
149
|
+
if (!readPair.ok) {
|
|
150
|
+
fail(readPair.reason);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
warn(readPair.warning);
|
|
154
|
+
|
|
131
155
|
try {
|
|
132
156
|
// Descent — the fast-forward rule needs the git checkout, so the CLI
|
|
133
|
-
// decides it (the Lambda has no checkout).
|
|
134
|
-
//
|
|
135
|
-
//
|
|
157
|
+
// decides it (the Lambda has no checkout). It runs against the state the
|
|
158
|
+
// double read already produced (no third trip), and the verdict goes to the
|
|
159
|
+
// write action, which enforces + records it in order.
|
|
136
160
|
let descentVerdict: { ok: true } | { ok: false; reason: string };
|
|
137
161
|
if (forceDescent !== undefined) {
|
|
138
162
|
warn(`DESCENT FORCED — the fast-forward rule is bypassed for this apply. Snapshot on record: ${forceDescent}.`);
|
|
139
163
|
descentVerdict = { ok: true };
|
|
140
164
|
} else {
|
|
141
|
-
|
|
142
|
-
const readRunner = lambdaQueryRunner(region, fn);
|
|
143
|
-
const snapshot = await introspectSchema(readRunner);
|
|
144
|
-
const contract = await introspectContract(readRunner, contractFunctionRow, FUNCTIONS_SQL);
|
|
165
|
+
const { snapshot, contract } = readPair.state;
|
|
145
166
|
step("Descent: searching git for the commit that declares the stage's state...");
|
|
146
167
|
const verdict = await verifyDescent(resolveModelsPath(flags.models), { snapshot, contract }, {});
|
|
147
168
|
switch (verdict.status) {
|
|
@@ -166,6 +187,11 @@ async function applyPlanViaStage(
|
|
|
166
187
|
// and checks it against the stage's declared approver set (SSM). The safety
|
|
167
188
|
// net is a VERIFIED backup: the Lambda auto-resolves the stage's newest (or
|
|
168
189
|
// the id in --snapshot-ref) and refuses unless it covers this plan. --confirm always.
|
|
190
|
+
//
|
|
191
|
+
// CURRENTLY UNREACHABLE: dbApplyCommand refuses every destructive plan on the
|
|
192
|
+
// stage lane before it gets here (read consistency cannot be guaranteed on this
|
|
193
|
+
// lane). Kept intact and second in line, so lifting that refusal restores the
|
|
194
|
+
// full ceremony rather than silently shipping without one.
|
|
169
195
|
let authorityVerdict: AuthorityVerdict | undefined;
|
|
170
196
|
if (plan.destructive > 0) {
|
|
171
197
|
const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
|
|
@@ -272,6 +298,16 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
272
298
|
// Deployed stage, no direct URL: the write runs in the ops Lambda — the
|
|
273
299
|
// operator never holds a database URL. --database-url stays local-only.
|
|
274
300
|
if (dbSource.kind === 'stage') {
|
|
301
|
+
// A DESTRUCTIVE plan does not run on this lane at all — no ceremony lifts it.
|
|
302
|
+
// The stage lane's fingerprint (the concurrency lock) can be assembled from
|
|
303
|
+
// several ops-Lambda containers, and the double read below only DETECTS that;
|
|
304
|
+
// it cannot rule it out. Dropping data on a read that may be a collage is the
|
|
305
|
+
// one trade never worth making, so the destructive path is the direct lane's.
|
|
306
|
+
if (plan.destructive > 0) {
|
|
307
|
+
const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
|
|
308
|
+
fail(stageDestructiveRefusal(shape, planPath, flags.stage));
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
275
311
|
// Will this edge fit the ops-Lambda's 900-second clock? If not, refuse up front and
|
|
276
312
|
// name --direct (credential-free, unbounded) instead of burning 15 minutes to learn it.
|
|
277
313
|
const fit = estimateOpsRuntimeFit(plan);
|
|
@@ -328,7 +364,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
328
364
|
}
|
|
329
365
|
|
|
330
366
|
step(connectingVia(dbSource));
|
|
331
|
-
const { runner, end } = await createUrlRunner(dbSource.url);
|
|
367
|
+
const { runner, session, end } = await createUrlRunner(dbSource.url);
|
|
332
368
|
|
|
333
369
|
try {
|
|
334
370
|
info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
|
|
@@ -342,7 +378,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
342
378
|
const result = await withMutationLease(
|
|
343
379
|
runner,
|
|
344
380
|
{ verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
|
|
345
|
-
() => executeApplyPlan(runner, plan, {
|
|
381
|
+
() => executeApplyPlan(runner, session, plan, {
|
|
346
382
|
actor: process.env.USER ?? null,
|
|
347
383
|
gitRef: currentGitRef() ?? plan.gitRef,
|
|
348
384
|
...(verifyAuthority ? { verifyAuthority } : {}),
|
|
@@ -371,6 +407,16 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
371
407
|
info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
|
|
372
408
|
return { ok: true };
|
|
373
409
|
case 'no-git':
|
|
410
|
+
// A DESTRUCTIVE plan may not ride an accidental waiver: the fast-forward
|
|
411
|
+
// rule's verdict must never depend on which directory the operator ran
|
|
412
|
+
// from. Unverifiable + destructive ⇒ the waiver must be the deliberate
|
|
413
|
+
// ceremony (--force-descent <snapshot-ref> --confirm), not a warning.
|
|
414
|
+
if (plan.destructive > 0) {
|
|
415
|
+
return {
|
|
416
|
+
ok: false,
|
|
417
|
+
reason: 'descent: not a git checkout, and this plan is DESTRUCTIVE — the fast-forward rule cannot be verified here, so waive it deliberately: re-run with --force-descent <snapshot-ref> --confirm.',
|
|
418
|
+
};
|
|
419
|
+
}
|
|
374
420
|
warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
|
|
375
421
|
return { ok: true };
|
|
376
422
|
case 'diverged':
|
|
@@ -57,22 +57,14 @@ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
|
57
57
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
58
58
|
import { resolveModelsPath } from '../models-path.js';
|
|
59
59
|
import { resolveDbSource, connectingVia, createUrlProbeRunner } from '../db-source.js';
|
|
60
|
-
import {
|
|
60
|
+
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
61
|
+
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
61
62
|
import { step, success, fail, info, warn } from '../output.js';
|
|
62
63
|
import { opsAdviceLines, IAM_ADVICE } from '../ops-advice.js';
|
|
63
64
|
import { loadModels } from './db-generate.js';
|
|
64
65
|
|
|
65
66
|
const DEFAULT_DIR = 'authz';
|
|
66
67
|
|
|
67
|
-
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
68
|
-
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
69
|
-
return async (sql: string) => {
|
|
70
|
-
const result: any = await invokeAction(region, fn, 'db:query', { sql });
|
|
71
|
-
if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
|
|
72
|
-
return result?.rows ?? [];
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
68
|
/**
|
|
77
69
|
* Where an authz command runs. Both venues expose the same two capabilities, so the
|
|
78
70
|
* commands never branch on venue after this point — that is what keeps the local
|
|
@@ -81,6 +73,8 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
|
81
73
|
interface AuthzVenue {
|
|
82
74
|
/** Read-only introspection. */
|
|
83
75
|
runner: QueryRunner;
|
|
76
|
+
/** The consistent multi-statement read — what every introspection takes. */
|
|
77
|
+
session: SessionRunner;
|
|
84
78
|
/** Self-reverting red-team probe (writes, always rolled back). */
|
|
85
79
|
probe: (setup: string, read: string) => Promise<any[]>;
|
|
86
80
|
/** Human-readable venue, printed with every verdict. */
|
|
@@ -97,15 +91,16 @@ async function resolveVenue(flags: Record<string, string>): Promise<AuthzVenue>
|
|
|
97
91
|
const source = resolveDbSource(flags);
|
|
98
92
|
if (source.kind === 'url') {
|
|
99
93
|
step(connectingVia(source));
|
|
100
|
-
const { runner, probe, end } = await createUrlProbeRunner(source.url);
|
|
101
|
-
return { runner, probe, end, label: 'direct connection' };
|
|
94
|
+
const { runner, session, probe, end } = await createUrlProbeRunner(source.url);
|
|
95
|
+
return { runner, session, probe, end, label: 'direct connection' };
|
|
102
96
|
}
|
|
103
97
|
step('Resolving deployed config...');
|
|
104
98
|
const config = await resolveConfig(flags.stage);
|
|
105
99
|
const fn = opsFunction(config);
|
|
106
100
|
info(`Region: ${config.region}, Function: ${fn}`);
|
|
107
101
|
return {
|
|
108
|
-
runner:
|
|
102
|
+
runner: lambdaQueryRunner(config.region, fn),
|
|
103
|
+
session: lambdaSessionRunner(config.region, fn),
|
|
109
104
|
probe: async (setup: string, read: string) => {
|
|
110
105
|
const result: any = await invokeAction(config.region, fn, 'db:authz:probe', { setup, read });
|
|
111
106
|
if (result?.error) throw new Error(result.error);
|
|
@@ -120,7 +115,7 @@ async function introspectVenue(flags: Record<string, string>): Promise<{ contrac
|
|
|
120
115
|
const venue = await resolveVenue(flags);
|
|
121
116
|
try {
|
|
122
117
|
step('Introspecting authorization (rls + grants + policies + secdef)...');
|
|
123
|
-
return { contract: await introspectContract(venue.
|
|
118
|
+
return { contract: await introspectContract(venue.session, contractFunctionRow, FUNCTIONS_SQL), label: venue.label };
|
|
124
119
|
} finally {
|
|
125
120
|
await venue.end();
|
|
126
121
|
}
|