@everystack/cli 0.4.45 → 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 +75 -26
- package/src/cli/authz-canonical.ts +37 -5
- package/src/cli/authz-compile.ts +87 -37
- package/src/cli/authz-contract.ts +92 -19
- package/src/cli/authz-derive.ts +158 -33
- package/src/cli/authz-reconcile.ts +48 -6
- 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 +11 -17
- package/src/cli/commands/db-plan.ts +56 -8
- package/src/cli/commands/db-pull.ts +16 -18
- 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 +112 -16
- package/src/cli/git-descent.ts +16 -9
- package/src/cli/index.ts +1 -1
- package/src/cli/model-render.ts +56 -50
- package/src/cli/schema-compile.ts +6 -1
- package/src/cli/schema-diff.ts +1 -1
- package/src/cli/schema-fingerprint.ts +67 -7
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.46",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"structured-headers": "1.0.1",
|
|
110
110
|
"tsx": "4.21.0",
|
|
111
111
|
"typescript": "5.9.3",
|
|
112
|
-
"@everystack/model": "0.4.
|
|
112
|
+
"@everystack/model": "0.4.11"
|
|
113
113
|
},
|
|
114
114
|
"peerDependencies": {
|
|
115
115
|
"@everystack/server": ">=0.4.0",
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* alter-type-dependents — the mint-time gate for a hard PostgreSQL rule:
|
|
3
|
+
* `ALTER COLUMN … SET DATA TYPE` fails when a view, materialized view, or
|
|
4
|
+
* rewrite rule references the column ("cannot alter type of a column used by
|
|
5
|
+
* a view or rule"), and it fails MID-TRANSACTION, at apply time.
|
|
6
|
+
*
|
|
7
|
+
* The plan lane's contract is "what it mints, it can apply" — so the mint
|
|
8
|
+
* refuses the plan up front, with the dependents NAMED, instead of letting the
|
|
9
|
+
* operator discover them from a rolled-back apply. (Proven live: the first-ever
|
|
10
|
+
* Z1a apply of a brownfield adoption plan failed exactly this way.) The route
|
|
11
|
+
* for a real type migration under dependents is deliberate: rebuild through
|
|
12
|
+
* `db:swap`, or drop and re-create the derived objects around the change.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { QueryRunner } from './authz-contract.js';
|
|
16
|
+
|
|
17
|
+
export interface AlterTypeTarget {
|
|
18
|
+
table: string;
|
|
19
|
+
column: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BlockedAlterType extends AlterTypeTarget {
|
|
23
|
+
/** Qualified names of the views / matviews / rules that bind the column. */
|
|
24
|
+
dependents: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The (table, column) pairs a plan's `SET DATA TYPE` statements touch — read
|
|
29
|
+
* from the emitted artifact itself, the same way the destructive classifier
|
|
30
|
+
* reads it: the statement is what gets applied, so it is what gets checked.
|
|
31
|
+
*/
|
|
32
|
+
export function alterTypeStatementTargets(statements: readonly string[]): AlterTypeTarget[] {
|
|
33
|
+
const targets: AlterTypeTarget[] = [];
|
|
34
|
+
for (const s of statements) {
|
|
35
|
+
const m = s.match(/ALTER TABLE\s+(\S+)\s+ALTER COLUMN\s+"([^"]+)"\s+SET DATA TYPE\s/);
|
|
36
|
+
if (m) targets.push({ table: m[1], column: m[2] });
|
|
37
|
+
}
|
|
38
|
+
return targets;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const quoteLiteral = (s: string): string => `'${s.replace(/'/g, "''")}'`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The dependents PostgreSQL itself would refuse over: rewrite-rule entries
|
|
45
|
+
* (views, matviews, rules) that depend on the specific column. Whole-table
|
|
46
|
+
* dependencies (refobjsubid 0 — every view's FROM clause) are deliberately
|
|
47
|
+
* excluded: a view that never touches the column does not block the change.
|
|
48
|
+
*/
|
|
49
|
+
export function dependentsSql(target: AlterTypeTarget): string {
|
|
50
|
+
const rel = quoteLiteral(target.table);
|
|
51
|
+
return `
|
|
52
|
+
SELECT DISTINCT
|
|
53
|
+
CASE cl.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'rule' END AS kind,
|
|
54
|
+
n.nspname || '.' || cl.relname AS name
|
|
55
|
+
FROM pg_depend d
|
|
56
|
+
JOIN pg_rewrite r ON r.oid = d.objid
|
|
57
|
+
JOIN pg_class cl ON cl.oid = r.ev_class
|
|
58
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
59
|
+
WHERE d.classid = 'pg_rewrite'::regclass
|
|
60
|
+
AND d.refclassid = 'pg_class'::regclass
|
|
61
|
+
AND d.refobjid = ${rel}::regclass
|
|
62
|
+
AND d.refobjsubid = (
|
|
63
|
+
SELECT attnum FROM pg_attribute
|
|
64
|
+
WHERE attrelid = ${rel}::regclass AND attname = ${quoteLiteral(target.column)}
|
|
65
|
+
)
|
|
66
|
+
AND cl.oid <> ${rel}::regclass
|
|
67
|
+
ORDER BY name;
|
|
68
|
+
`.trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Query the live catalog for each target; return only the blocked ones. */
|
|
72
|
+
export async function findAlterTypeDependents(
|
|
73
|
+
runner: QueryRunner,
|
|
74
|
+
targets: readonly AlterTypeTarget[],
|
|
75
|
+
): Promise<BlockedAlterType[]> {
|
|
76
|
+
const blocked: BlockedAlterType[] = [];
|
|
77
|
+
for (const t of targets) {
|
|
78
|
+
const rows = await runner(dependentsSql(t));
|
|
79
|
+
if (rows.length > 0) {
|
|
80
|
+
blocked.push({ ...t, dependents: rows.map((r: any) => `${r.kind} ${r.name}`) });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return blocked;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The refusal, with every dependent named — actionable, not archaeological. */
|
|
87
|
+
export function renderAlterTypeRefusal(blocked: readonly BlockedAlterType[]): string {
|
|
88
|
+
const lines = blocked.map(
|
|
89
|
+
(b) => ` ${b.table}."${b.column}" is bound by: ${b.dependents.join(', ')}`,
|
|
90
|
+
);
|
|
91
|
+
return [
|
|
92
|
+
`This plan changes the type of ${blocked.length} column(s) that a view or rule depends on — PostgreSQL would refuse the ALTER mid-transaction ("cannot alter type of a column used by a view or rule"), so the mint refuses it now:`,
|
|
93
|
+
...lines,
|
|
94
|
+
`Route the type change through db:swap (rebuild + atomic rename), or drop and re-create the dependent derived objects around it (db:reconcile) — deliberately, not as an apply-time surprise.`,
|
|
95
|
+
].join('\n');
|
|
96
|
+
}
|
package/src/cli/apply-execute.ts
CHANGED
|
@@ -30,9 +30,11 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { introspectContract, type QueryRunner, type AuthzContract } from './authz-contract.js';
|
|
33
|
+
import type { SessionRunner } from './session.js';
|
|
33
34
|
import { introspectSchema, type SchemaSnapshot } from './schema-introspect.js';
|
|
34
35
|
import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog.js';
|
|
35
36
|
import { fingerprintLive } from './schema-fingerprint.js';
|
|
37
|
+
import { governedLiveFingerprint } from './edge-plan.js';
|
|
36
38
|
import { applyGeneratedStatements } from './state-apply.js';
|
|
37
39
|
import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply.js';
|
|
38
40
|
import { checkPlanPrecondition, planHash, type EdgePlan } from './edge-plan.js';
|
|
@@ -134,21 +136,30 @@ export interface LiveBaseFingerprint {
|
|
|
134
136
|
* destructive-apply safety gate (backup.fp == plan.from). A second copy would silently drift the
|
|
135
137
|
* fingerprint chain — the whole point of the gate.
|
|
136
138
|
*/
|
|
137
|
-
export async function liveBaseFingerprint(
|
|
138
|
-
const snapshot = await introspectSchema(
|
|
139
|
-
const contract = await introspectContract(
|
|
139
|
+
export async function liveBaseFingerprint(session: SessionRunner): Promise<LiveBaseFingerprint> {
|
|
140
|
+
const snapshot = await introspectSchema(session);
|
|
141
|
+
const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
140
142
|
return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract };
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
/** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
|
|
144
146
|
export async function executeApplyPlan(
|
|
145
147
|
runner: QueryRunner,
|
|
148
|
+
session: SessionRunner,
|
|
146
149
|
plan: EdgePlan,
|
|
147
150
|
options: ApplyPlanOptions = {},
|
|
148
151
|
): Promise<ApplyPlanResult> {
|
|
149
|
-
const { hash: live, snapshot: before, contract: beforeAuthz } = await liveBaseFingerprint(
|
|
152
|
+
const { hash: live, snapshot: before, contract: beforeAuthz } = await liveBaseFingerprint(session);
|
|
150
153
|
|
|
151
|
-
|
|
154
|
+
// Two flavors, matched to what each check compares (see EdgePlan): the RAW
|
|
155
|
+
// hash for live-vs-live (the lock, the backup chain), the GOVERNED hash for
|
|
156
|
+
// declared-vs-live (already-applied here, verify-after below) — computed with
|
|
157
|
+
// the exact set the mint stamped on the plan. A plan minted before the field
|
|
158
|
+
// existed verifies raw, as it was minted.
|
|
159
|
+
const governed = plan.governedRoles ? new Set(plan.governedRoles) : undefined;
|
|
160
|
+
const governedLive = governed ? governedLiveFingerprint(before, beforeAuthz, governed) : live;
|
|
161
|
+
|
|
162
|
+
if (governedLive === plan.toFingerprint) {
|
|
152
163
|
return { status: 'already-applied', liveFingerprint: live };
|
|
153
164
|
}
|
|
154
165
|
const pre = checkPlanPrecondition(plan, live);
|
|
@@ -200,16 +211,19 @@ export async function executeApplyPlan(
|
|
|
200
211
|
now: options.now,
|
|
201
212
|
});
|
|
202
213
|
|
|
203
|
-
const { hash: landed } = await liveBaseFingerprint(
|
|
214
|
+
const { hash: landed, snapshot: after, contract: afterAuthz } = await liveBaseFingerprint(session);
|
|
204
215
|
if (result.logId !== undefined) {
|
|
216
|
+
// The log stamps the RAW landed hash — the same flavor as `from`, so the
|
|
217
|
+
// schema_log chain reads as one consistent series of live states.
|
|
205
218
|
await runner(renderSchemaLogFingerprintUpdate(result.logId, landed));
|
|
206
219
|
}
|
|
207
220
|
|
|
208
|
-
|
|
221
|
+
const governedLanded = governed ? governedLiveFingerprint(after, afterAuthz, governed) : landed;
|
|
222
|
+
if (governedLanded !== plan.toFingerprint) {
|
|
209
223
|
return {
|
|
210
224
|
status: 'verify-failed',
|
|
211
225
|
liveFingerprint: landed,
|
|
212
|
-
reason: `applied, but the target landed on ${
|
|
226
|
+
reason: `applied, but the target landed on ${governedLanded.slice(0, 12)} — the plan predicted ${plan.toFingerprint.slice(0, 12)}. Investigate before touching this database again (db:fingerprint, db:generate).`,
|
|
213
227
|
...(result.logId !== undefined ? { logId: result.logId } : {}),
|
|
214
228
|
};
|
|
215
229
|
}
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
import { matchPolicies, roleSetEqual } from './authz-identity.js';
|
|
32
32
|
import { governedRoleSet } from './authz-reconcile.js';
|
|
33
|
-
import { effectivePolicyCheck, holdsPrivilege } from './authz-contract.js';
|
|
33
|
+
import { effectivePolicyCheck, holdsPrivilege, isPolicyDead, policyCommands } from './authz-contract.js';
|
|
34
34
|
import type { AuthzContract, PolicyContract, TableContract } from './authz-contract.js';
|
|
35
35
|
|
|
36
36
|
export type AdoptionClass = 'convention' | 'capability' | 'narrowing' | 'dead' | 'unclassified';
|
|
@@ -86,30 +86,28 @@ function sameRule(a: PolicyContract, b: PolicyContract): boolean {
|
|
|
86
86
|
&& (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
/** Every command a policy governs — `ALL` fans out, so a live `FOR ALL` is compared per command. */
|
|
90
|
-
function commandsOf(p: PolicyContract): string[] {
|
|
91
|
-
return p.command === 'ALL' ? [...CRUD] : [p.command];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
89
|
/**
|
|
95
|
-
* A policy
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
90
|
+
* A policy's predicate, collapsed to one line for the report.
|
|
91
|
+
*
|
|
92
|
+
* Live predicates arrive from `pg_get_expr` with real newlines and indentation (an EXISTS
|
|
93
|
+
* subquery spans several lines), and the adoption report is one line per statement. Collapsing
|
|
94
|
+
* keeps the rule READABLE in place rather than referring the operator elsewhere to find it.
|
|
99
95
|
*/
|
|
100
|
-
function
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
// `holdsPrivilege`, not the table-level one: a column-scoped grant is real access, and
|
|
104
|
-
// "dead" tells an operator the drop takes away nothing. Shared with db:pull so the two can
|
|
105
|
-
// never answer this differently about one database.
|
|
106
|
-
return !commandsOf(p).some((cmd) => roles.some((r) => holdsPrivilege(t, r, cmd)));
|
|
96
|
+
function pred(p: PolicyContract): string {
|
|
97
|
+
const using = (p.using ?? 'true').replace(/\s+/g, ' ').trim();
|
|
98
|
+
return using === 'true' ? 'true (unfiltered)' : using;
|
|
107
99
|
}
|
|
108
100
|
|
|
101
|
+
// `commandsOf` and the dead-policy test live in authz-contract — ONE definition, shared with
|
|
102
|
+
// the reconciler, the canonical form, and db:pull.
|
|
103
|
+
const commandsOf = policyCommands;
|
|
104
|
+
const isDead = isPolicyDead;
|
|
105
|
+
|
|
109
106
|
/** Classify the policy statements one table would emit. */
|
|
110
|
-
function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedStatement[]): void {
|
|
107
|
+
function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedStatement[], governed: ReadonlySet<string>): void {
|
|
111
108
|
const table = d.table;
|
|
112
109
|
const m = matchPolicies(d.policies, l.policies);
|
|
110
|
+
const liveByName = new Map(l.policies.map((p) => [p.name, p]));
|
|
113
111
|
|
|
114
112
|
// Adopted shapes — a rule-identical policy under another name, or a live multi-role policy
|
|
115
113
|
// covering the declared per-role group — emit NOTHING, so there is nothing here to classify.
|
|
@@ -122,10 +120,16 @@ function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedSta
|
|
|
122
120
|
for (const name of m.toDrop) {
|
|
123
121
|
const live = l.policies.find((p) => p.name === name)!;
|
|
124
122
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
123
|
+
// The reconciler LEAVES a policy scoped entirely to ungoverned roles alone, so no statement
|
|
124
|
+
// is emitted for it and there is nothing here to classify. Counting one would describe a
|
|
125
|
+
// plan nobody is going to apply — the same reason classifyGrants skips those grantees.
|
|
126
|
+
if (live.roles.length > 0 && live.roles.every((r) => !isGovernedGrantee(governed, r))) continue;
|
|
127
|
+
|
|
128
|
+
// A DEAD policy is left alone by the reconciler now (it authorizes nothing, so dropping it
|
|
129
|
+
// changes no access), which means no statement exists to classify — same as the ungoverned
|
|
130
|
+
// case above, and counting one would describe a plan nobody is going to apply. The adopter
|
|
131
|
+
// still hears about it: db:pull writes the finding into the model file, where it persists.
|
|
132
|
+
if (isDead(l, live)) continue;
|
|
129
133
|
|
|
130
134
|
// A replacement exists when some declared policy governs the same command. If those
|
|
131
135
|
// replacements together cover FEWER roles than the live policy did, the plan is reducing
|
|
@@ -165,6 +169,37 @@ function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedSta
|
|
|
165
169
|
continue;
|
|
166
170
|
}
|
|
167
171
|
|
|
172
|
+
// The PREDICATE axis. Everything above this line decides on ROLE SETS alone, and that was
|
|
173
|
+
// the blind spot: a replacement holding the SAME roles with a different USING is a real
|
|
174
|
+
// access change that no role comparison can see. It fell to `unclassified` — a bucket with
|
|
175
|
+
// a count and, until E3, no detail — so the most dangerous shape this tool can emit was
|
|
176
|
+
// also its quietest. A live `(guard) AND (mine OR published)` read replaced by a
|
|
177
|
+
// `published`-only one is not silence; it is a replacement that READS as equivalent.
|
|
178
|
+
//
|
|
179
|
+
// Narrower vs. wider is not decidable here without a predicate lattice, so this takes the
|
|
180
|
+
// most severe reading, exactly as the destructive-statement partition does: an access change
|
|
181
|
+
// we cannot prove is identical is reported as a reduction, and BOTH predicates are carried
|
|
182
|
+
// so the operator reads the rule rather than the label.
|
|
183
|
+
const sameRoleReplacements = replacements.filter((r) => r.roles.some((role) => live.roles.includes(role)));
|
|
184
|
+
if (sameRoleReplacements.length && !sameRoleReplacements.some((r) => sameRule(r, live))) {
|
|
185
|
+
const shared = [...new Set(sameRoleReplacements.flatMap((r) => r.roles.filter((role) => live.roles.includes(role))))];
|
|
186
|
+
out.push({
|
|
187
|
+
table,
|
|
188
|
+
subject: name,
|
|
189
|
+
cls: 'narrowing',
|
|
190
|
+
why:
|
|
191
|
+
`live applies to ${shared.join(', ')} USING ${pred(live)}; the declared replacement `
|
|
192
|
+
+ `${sameRoleReplacements.map((r) => `"${r.name}"`).join(', ')} keeps the same role(s) but states `
|
|
193
|
+
+ `${sameRoleReplacements.map((r) => pred(r)).join(' / ')} — a different rule, not a different spelling`,
|
|
194
|
+
});
|
|
195
|
+
for (const r of sameRoleReplacements) {
|
|
196
|
+
if (consumed.has(r.name)) continue;
|
|
197
|
+
consumed.add(r.name);
|
|
198
|
+
out.push({ table, subject: r.name, cls: 'narrowing', why: `the differently-predicated replacement for "${name}"` });
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
168
203
|
out.push({ table, subject: name, cls: 'unclassified', why: `dropped with a replacement that is neither the same rule nor narrower` });
|
|
169
204
|
}
|
|
170
205
|
|
|
@@ -241,7 +276,7 @@ export function classifyAdoption(
|
|
|
241
276
|
for (const d of declared.tables) {
|
|
242
277
|
const l = liveByTable.get(d.table);
|
|
243
278
|
if (!l) continue; // a brand-new table adds; nothing here is an adoption decision
|
|
244
|
-
classifyPolicies(d, l, statements);
|
|
279
|
+
classifyPolicies(d, l, statements, governed);
|
|
245
280
|
classifyGrants(d, l, governed, statements);
|
|
246
281
|
}
|
|
247
282
|
|
|
@@ -250,8 +285,19 @@ export function classifyAdoption(
|
|
|
250
285
|
return { statements, counts };
|
|
251
286
|
}
|
|
252
287
|
|
|
253
|
-
/**
|
|
254
|
-
|
|
288
|
+
/**
|
|
289
|
+
* The report block for the plan surface. Terse: one line per class, then the offenders.
|
|
290
|
+
*
|
|
291
|
+
* `unclassified` is the one class whose count cannot be acted on by itself — its whole meaning
|
|
292
|
+
* is "read these before applying", and a bare number gives the reader nothing to read. The
|
|
293
|
+
* adopter who hit this said it best: a count with no way to act on it is the same shape as the
|
|
294
|
+
* problem the bucket was created to solve. So every unclassified statement is NAMED here, and
|
|
295
|
+
* the per-statement detail rides on the artifact too (`adoption` on the plan).
|
|
296
|
+
*
|
|
297
|
+
* Only `unclassified` is expanded. The others are counts on purpose — 19 narrowings listed
|
|
298
|
+
* inline is a wall nobody reads, and they are each described by their class.
|
|
299
|
+
*/
|
|
300
|
+
export function renderAdoptionReport(counts: AdoptionCounts, statements: readonly ClassifiedStatement[] = []): string[] {
|
|
255
301
|
const lines = [
|
|
256
302
|
` convention ${counts.convention} — our spelling, not their schema (this must reach 0)`,
|
|
257
303
|
` capability ${counts.capability} — the model cannot express what is live`,
|
|
@@ -259,7 +305,10 @@ export function renderAdoptionReport(counts: AdoptionCounts): string[] {
|
|
|
259
305
|
` dead ${counts.dead} — policed but never granted`,
|
|
260
306
|
];
|
|
261
307
|
if (counts.unclassified) {
|
|
262
|
-
lines.push(` unclassified ${counts.unclassified} — NOT described; read these before applying
|
|
308
|
+
lines.push(` unclassified ${counts.unclassified} — NOT described; read these before applying:`);
|
|
309
|
+
for (const s of statements.filter((s) => s.cls === 'unclassified')) {
|
|
310
|
+
lines.push(` ${s.table} "${s.subject}" — ${s.why}`);
|
|
311
|
+
}
|
|
263
312
|
}
|
|
264
313
|
return lines;
|
|
265
314
|
}
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import type { PolicyContract, TableContract } from './authz-contract.js';
|
|
35
|
-
import { effectivePolicyCheck } from './authz-contract.js';
|
|
35
|
+
import { effectivePolicyCheck, isPolicyDead, isPolicySubsumed } from './authz-contract.js';
|
|
36
36
|
|
|
37
37
|
/** PostgreSQL's open role set, kept whole. */
|
|
38
38
|
const PUBLIC_ROLE = 'public';
|
|
@@ -76,9 +76,28 @@ function multiset(entries: string[]): Array<[string, number]> {
|
|
|
76
76
|
return [...counts.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The canonical policy form: every policy expanded across its roles, as a sorted multiset.
|
|
81
|
+
*
|
|
82
|
+
* When a governed set is supplied (hashing a LIVE contract), per-role entries for roles the
|
|
83
|
+
* models do not govern are dropped — the SAME choice the reconciler makes when it leaves an
|
|
84
|
+
* ungoverned role's policy alone (the `reconcilePolicies` ungoverned escape). Hashing them
|
|
85
|
+
* would keep MATCH unreachable on any brownfield database whose previous stack left a policy
|
|
86
|
+
* TO a migrator/resolver role: zero statements, mismatched hash, forever. PUBLIC is a
|
|
87
|
+
* governed sentinel and always kept.
|
|
88
|
+
*/
|
|
89
|
+
export function canonicalPolicies(
|
|
90
|
+
policies: readonly PolicyContract[],
|
|
91
|
+
governed?: ReadonlySet<string>,
|
|
92
|
+
): Array<[string, number]> {
|
|
93
|
+
const entries = policies.flatMap(expandPolicy);
|
|
94
|
+
const kept = governed
|
|
95
|
+
? entries.filter((e) => {
|
|
96
|
+
const role = e.slice(0, e.indexOf('|'));
|
|
97
|
+
return governed.has(role) || role === PUBLIC_ROLE || (role.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
|
|
98
|
+
})
|
|
99
|
+
: entries;
|
|
100
|
+
return multiset(kept);
|
|
82
101
|
}
|
|
83
102
|
|
|
84
103
|
/**
|
|
@@ -141,6 +160,19 @@ export function canonicalAuthz(
|
|
|
141
160
|
),
|
|
142
161
|
}
|
|
143
162
|
: {}),
|
|
144
|
-
|
|
163
|
+
// A DEAD policy is not part of the state. It authorizes nothing (Postgres refuses at the
|
|
164
|
+
// GRANT before consulting it), and the reconciler now leaves it alone — so counting it
|
|
165
|
+
// here would keep MATCH unreachable on exactly the databases that have them: brownfield
|
|
166
|
+
// ones, where a previous stack left policies behind after its grants were revoked. Both
|
|
167
|
+
// surfaces make the same choice, from the same function. Deadness is recomputed against
|
|
168
|
+
// these grants every time, so the policy re-enters the state the moment a grant revives it.
|
|
169
|
+
// …and neither is a SUBSUMED one: a permissive policy whose rule an identical PUBLIC
|
|
170
|
+
// policy already applies to every role admits no session the other does not. Both
|
|
171
|
+
// exclusions are the same idea — a policy that changes no access is not part of the
|
|
172
|
+
// state — and both are made in lockstep with the reconciler, from the same functions.
|
|
173
|
+
policies: canonicalPolicies(
|
|
174
|
+
contract.policies.filter((p) => !isPolicyDead(contract, p) && !isPolicySubsumed(contract, p)),
|
|
175
|
+
governed,
|
|
176
|
+
),
|
|
145
177
|
};
|
|
146
178
|
}
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import type
|
|
23
|
+
import { isColumnAbility, type ModelDescriptor, type Ability } from '@everystack/model';
|
|
24
24
|
import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract.js';
|
|
25
25
|
import { parenthesizeOnce } from './authz-contract.js';
|
|
26
26
|
|
|
@@ -197,17 +197,6 @@ function softDeleteColumn(model: ModelDescriptor): string | null {
|
|
|
197
197
|
return model.softDelete ? 'deleted_at' : null;
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
/**
|
|
201
|
-
* A column-scoped self read — `can('read', { owner, columns })`. It compiles to a COLUMN
|
|
202
|
-
* grant (`GRANT SELECT (cols)`) plus a `<table>_select_self` policy, NOT a table grant and
|
|
203
|
-
* NOT a `_select_own` policy: the row is scoped by the owner predicate, the fields by the
|
|
204
|
-
* column list (`auth.users` → id/email/role to the owner, never the password hash). It is
|
|
205
|
-
* excluded from the table-grant and owner-read paths so the two never double-emit.
|
|
206
|
-
*/
|
|
207
|
-
function isColumnRead(a: Ability): boolean {
|
|
208
|
-
return a.action === 'read' && Boolean(a.condition.owner) && Boolean(a.condition.columns?.length);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
200
|
/**
|
|
212
201
|
* `can(…, { role: 'public' })` names PostgreSQL's PUBLIC pseudo-role, and the two catalogs
|
|
213
202
|
* that describe it spell it differently — both correctly:
|
|
@@ -234,21 +223,25 @@ const granteeKey = (role: string): string => (isPseudoPublic(role) ? 'PUBLIC' :
|
|
|
234
223
|
const policyRole = (role: string): string => (isPseudoPublic(role) ? 'public' : role);
|
|
235
224
|
|
|
236
225
|
/**
|
|
237
|
-
* Column grants from
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* ability declares columns — an introspected contract omits the key rather than carry an empty
|
|
241
|
-
* object, so the compiled one must too (or the round-trip diff would false-fire).
|
|
226
|
+
* Column grants from column abilities. The action determines the PostgreSQL verb; the named role
|
|
227
|
+
* (or authenticated for an owner-only ability) is the grantee. Field keys are snake-cased and
|
|
228
|
+
* deduplicated, matching the introspected contract.
|
|
242
229
|
*/
|
|
243
230
|
function compileColumnGrants(abilities: readonly Ability[]): Record<string, Record<string, string[]>> | undefined {
|
|
244
|
-
const out: Record<string, Record<string, string
|
|
231
|
+
const out: Record<string, Record<string, Set<string>>> = {};
|
|
245
232
|
for (const a of abilities) {
|
|
246
|
-
if (!
|
|
233
|
+
if (!isColumnAbility(a)) continue;
|
|
247
234
|
const role = granteeKey(a.condition.role ?? 'authenticated');
|
|
248
|
-
const
|
|
249
|
-
(out[role] ??= {})
|
|
235
|
+
const verb = a.action === 'read' ? 'SELECT' : 'UPDATE';
|
|
236
|
+
const cols = ((out[role] ??= {})[verb] ??= new Set<string>());
|
|
237
|
+
for (const column of a.condition.columns!) cols.add(toSnakeCase(column));
|
|
238
|
+
}
|
|
239
|
+
const grants: Record<string, Record<string, string[]>> = {};
|
|
240
|
+
for (const [role, verbs] of Object.entries(out)) {
|
|
241
|
+
grants[role] = {};
|
|
242
|
+
for (const [verb, columns] of Object.entries(verbs)) grants[role][verb] = [...columns].sort();
|
|
250
243
|
}
|
|
251
|
-
return Object.keys(
|
|
244
|
+
return Object.keys(grants).length ? grants : undefined;
|
|
252
245
|
}
|
|
253
246
|
|
|
254
247
|
/**
|
|
@@ -335,13 +328,13 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
335
328
|
* every other role can see.
|
|
336
329
|
*/
|
|
337
330
|
const isRoleRead = (a: Ability): boolean =>
|
|
338
|
-
a.action === 'read' && Boolean(a.condition.role) && !
|
|
331
|
+
a.action === 'read' && Boolean(a.condition.role) && !isColumnAbility(a);
|
|
339
332
|
|
|
340
333
|
// Author-supplied predicates, per action. `manage` carries no predicate of its own —
|
|
341
334
|
// it is the admin bypass — so only the specific verbs are consulted.
|
|
342
335
|
const predFor = (action: Ability['action']): string | null => {
|
|
343
336
|
for (const a of model.abilities) {
|
|
344
|
-
if (a.action !== action || isRoleRead(a)) continue;
|
|
337
|
+
if (a.action !== action || isRoleRead(a) || isColumnAbility(a)) continue;
|
|
345
338
|
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('${action}')`);
|
|
346
339
|
if (p) return p;
|
|
347
340
|
}
|
|
@@ -367,7 +360,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
367
360
|
/** The predicate the OWNER read narrows ITSELF by — `can('read', { owner, sql })`. */
|
|
368
361
|
const ownerReadPred = (): string | null => {
|
|
369
362
|
for (const a of model.abilities) {
|
|
370
|
-
if (a.action !== 'read' || !rowScoped(a) ||
|
|
363
|
+
if (a.action !== 'read' || !rowScoped(a) || isColumnAbility(a)) continue;
|
|
371
364
|
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read', { owner })`);
|
|
372
365
|
if (p) return p;
|
|
373
366
|
}
|
|
@@ -387,12 +380,18 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
387
380
|
const abilities = model.abilities;
|
|
388
381
|
const hasAdminManage = abilities.some((a) => a.action === 'manage' && a.condition.role === 'admin');
|
|
389
382
|
const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !rowScoped(a));
|
|
383
|
+
// The audience is open when the public read says so. Read off the SAME abilities that make
|
|
384
|
+
// `hasPublicRead` true, so the two can never disagree about which read is being compiled.
|
|
385
|
+
const publicAudience = abilities.some(
|
|
386
|
+
(a) => a.action === 'read' && !a.condition.role && !rowScoped(a) && a.condition.policyRoles === 'public',
|
|
387
|
+
);
|
|
390
388
|
// A column-scoped read is row-scoped but emits a self policy + column grant, not the
|
|
391
389
|
// full-row owner read — so it is excluded here and handled by its own branch.
|
|
392
|
-
const hasOwnerRead = abilities.some((a) => a.action === 'read' && rowScoped(a) && !
|
|
393
|
-
const
|
|
390
|
+
const hasOwnerRead = abilities.some((a) => a.action === 'read' && rowScoped(a) && !isColumnAbility(a));
|
|
391
|
+
const hasColumnOwnerRead = abilities.some((a) => a.action === 'read' && isColumnAbility(a) && !a.condition.role && rowScoped(a));
|
|
394
392
|
const canCreate = abilities.some((a) => a.action === 'create');
|
|
395
|
-
const hasUpdateOwner = abilities.some((a) => a.action === 'update' && rowScoped(a));
|
|
393
|
+
const hasUpdateOwner = abilities.some((a) => a.action === 'update' && rowScoped(a) && !isColumnAbility(a));
|
|
394
|
+
const hasColumnUpdateOwner = abilities.some((a) => a.action === 'update' && isColumnAbility(a) && !a.condition.role && rowScoped(a));
|
|
396
395
|
const hasDeleteOwner = abilities.some((a) => a.action === 'delete' && rowScoped(a));
|
|
397
396
|
|
|
398
397
|
const t = model.table;
|
|
@@ -412,7 +411,6 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
412
411
|
// also see their own (soft-deleted) rows, hence the OR'd owner check.
|
|
413
412
|
const readPred = publicReadPred();
|
|
414
413
|
const anonUsing = andPredicates(sdGuard, readPred) ?? 'true';
|
|
415
|
-
policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
|
|
416
414
|
|
|
417
415
|
let authedUsing: string;
|
|
418
416
|
if (hasOwnerRead && rowPred && anonUsing !== 'true') {
|
|
@@ -441,12 +439,34 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
441
439
|
? andPredicates(`(${sdGuard} OR ${rowPred})`, readPred)!
|
|
442
440
|
: anonUsing;
|
|
443
441
|
}
|
|
444
|
-
policy
|
|
442
|
+
// ONE policy TO PUBLIC, when the model says the audience is open (B1).
|
|
443
|
+
//
|
|
444
|
+
// The two spellings are NOT equivalent and the difference is the whole point: PUBLIC is
|
|
445
|
+
// an open set, `{anon, authenticated}` is closed. A brownfield schema that wrote the
|
|
446
|
+
// ordinary Postgres idiom — one policy TO PUBLIC — had no way to say so, so every plan
|
|
447
|
+
// proposed dropping it for the role-scoped pair. That is a real narrowing: any role
|
|
448
|
+
// outside the pair loses the read.
|
|
449
|
+
//
|
|
450
|
+
// Emitted under the model's own name; identity-by-meaning adopts the live policy whose
|
|
451
|
+
// rule matches, whatever it is called, so a schema already carrying `<t>_read_public`
|
|
452
|
+
// reaches ZERO statements rather than a rename's DROP + CREATE.
|
|
453
|
+
//
|
|
454
|
+
// GRANTS ARE UNTOUCHED — they stay exactly as derived for anon/authenticated. An open
|
|
455
|
+
// audience over a closed grant set widens nothing: Postgres checks the GRANT first, so a
|
|
456
|
+
// role holding no SELECT still reads no rows no matter who the policy names.
|
|
457
|
+
if (publicAudience) {
|
|
458
|
+
policy(`${t}_select_public`, 'SELECT', ['public'], anonUsing, null);
|
|
459
|
+
} else {
|
|
460
|
+
policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
|
|
461
|
+
policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
|
|
462
|
+
}
|
|
445
463
|
} else if (hasOwnerRead && rowPred) {
|
|
446
464
|
// owner-scoped read — no anon visibility; you see only the rows you own
|
|
447
465
|
// (directly, or transitively through a `via:` parent).
|
|
448
466
|
policy(`${t}_select_own`, 'SELECT', ['authenticated'], andPredicates(rowPred, predFor('read'))!, null);
|
|
449
|
-
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (hasColumnOwnerRead && rowPred) {
|
|
450
470
|
// column-scoped self read — `authenticated` reads only its OWN row, and only the
|
|
451
471
|
// granted columns (the GRANT scopes fields; this policy scopes rows). Named
|
|
452
472
|
// `_select_self`, distinct from the full-row `_select_own`.
|
|
@@ -460,7 +480,9 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
460
480
|
// the model read as though it could see its own. The declared predicate was computed and
|
|
461
481
|
// discarded. That is the same failure `rawPredicate` throws over, one level up: a predicate
|
|
462
482
|
// that evaporates is an authorization hole wearing the costume of a working rule.
|
|
463
|
-
for (const a of abilities.filter(isRoleRead)
|
|
483
|
+
for (const a of abilities.filter((ability) => isRoleRead(ability) || (
|
|
484
|
+
isColumnAbility(ability) && ability.action === 'read' && Boolean(ability.condition.role)
|
|
485
|
+
))) {
|
|
464
486
|
// The policy-side spelling, so `role: 'PUBLIC'` still matches what pg_policies reports.
|
|
465
487
|
const role = policyRole(a.condition.role!);
|
|
466
488
|
const name = `${t}_select_${role}`;
|
|
@@ -471,7 +493,16 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
471
493
|
+ `Declare one or the other: permissive policies OR together, so the role-scoped rule could only ever widen — never the narrowing it reads as.`,
|
|
472
494
|
);
|
|
473
495
|
}
|
|
474
|
-
policy(
|
|
496
|
+
policy(
|
|
497
|
+
name,
|
|
498
|
+
'SELECT',
|
|
499
|
+
[role],
|
|
500
|
+
andPredicates(
|
|
501
|
+
rowScoped(a) ? rowPred : null,
|
|
502
|
+
rawPredicate(a.condition.sql ?? a.condition.where, where),
|
|
503
|
+
) ?? 'true',
|
|
504
|
+
null,
|
|
505
|
+
);
|
|
475
506
|
}
|
|
476
507
|
|
|
477
508
|
// owner-gated writes. INSERT checks ownership (you can only create rows you own);
|
|
@@ -491,6 +522,27 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
491
522
|
if ((hasUpdateOwner || predFor('update')) && updatePred) {
|
|
492
523
|
policy(`${t}_update_own`, 'UPDATE', ['authenticated'], updatePred, checkFor('update') ?? updatePred);
|
|
493
524
|
}
|
|
525
|
+
else if (hasColumnUpdateOwner && updatePred) {
|
|
526
|
+
policy(`${t}_update_own`, 'UPDATE', ['authenticated'], updatePred, updatePred);
|
|
527
|
+
}
|
|
528
|
+
for (const a of abilities.filter((ability) =>
|
|
529
|
+
isColumnAbility(ability) && ability.action === 'update' && Boolean(ability.condition.role),
|
|
530
|
+
)) {
|
|
531
|
+
const role = policyRole(a.condition.role!);
|
|
532
|
+
const name = `${t}_update_${role}`;
|
|
533
|
+
const where = `${table}: can('update', { role: '${role}' })`;
|
|
534
|
+
if (policies.some((p) => p.name === name)) {
|
|
535
|
+
throw new Error(
|
|
536
|
+
`${where} collides with the ${name} policy already compiled from this model. `
|
|
537
|
+
+ `Declare one update ability for that role: permissive policies OR together, so separate rules could only widen.`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
const using = andPredicates(
|
|
541
|
+
rowScoped(a) ? rowPred : null,
|
|
542
|
+
rawPredicate(a.condition.sql ?? a.condition.where, where),
|
|
543
|
+
) ?? 'true';
|
|
544
|
+
policy(name, 'UPDATE', [role], using, rawPredicate(a.condition.check, `${where}, { check }`) ?? using);
|
|
545
|
+
}
|
|
494
546
|
if ((hasDeleteOwner || predFor('delete')) && deletePred) {
|
|
495
547
|
policy(`${t}_delete_own`, 'DELETE', ['authenticated'], deletePred, null);
|
|
496
548
|
}
|
|
@@ -560,10 +612,8 @@ function compileGrants(
|
|
|
560
612
|
for (const v of verbs) set.add(v);
|
|
561
613
|
};
|
|
562
614
|
for (const a of abilities) {
|
|
563
|
-
// A column
|
|
564
|
-
|
|
565
|
-
// whole-table grant that would defeat the column scoping.
|
|
566
|
-
if (isColumnRead(a)) continue;
|
|
615
|
+
// A column ability grants only its listed columns, never a table-level privilege.
|
|
616
|
+
if (isColumnAbility(a)) continue;
|
|
567
617
|
const verbs = verbsFor(a.action);
|
|
568
618
|
if (a.condition.role) {
|
|
569
619
|
add(granteeKey(a.condition.role), verbs);
|