@everystack/cli 0.4.41 → 0.4.44
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-compile.ts +128 -16
- package/src/cli/authz-contract.ts +48 -1
- package/src/cli/authz-derive.ts +156 -9
- package/src/cli/authz-reconcile.ts +10 -19
- package/src/cli/authz-redteam.ts +32 -10
- package/src/cli/commands/db-authz.ts +17 -1
- package/src/cli/commands/db-plan.ts +24 -0
- package/src/cli/commands/db-pull.ts +32 -3
- package/src/cli/edge-plan.ts +62 -4
- package/src/cli/model-render.ts +116 -32
- package/src/cli/schema-compile.ts +25 -2
- package/src/cli/schema-diff.ts +26 -1
- package/src/cli/state-apply.ts +88 -0
package/src/cli/schema-diff.ts
CHANGED
|
@@ -220,6 +220,20 @@ function argsWiden(from: number[], to: number[]): boolean {
|
|
|
220
220
|
return to.every((v, i) => from[i] == null || v >= from[i]);
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Types whose entire meaning survives a trip through `text` — scalars and strings whose
|
|
225
|
+
* printed form IS their value. Everything else (postgis geometry, tsvector, hstore, ltree,
|
|
226
|
+
* arrays, composites, ranges, and any extension type) carries structure the cast discards,
|
|
227
|
+
* so it is deliberately NOT listed: the default for an unknown base is "not free".
|
|
228
|
+
*/
|
|
229
|
+
const TEXT_ROUND_TRIPS = new Set([
|
|
230
|
+
'text', 'character varying', 'character', 'citext', 'name', 'uuid', 'boolean',
|
|
231
|
+
'smallint', 'integer', 'bigint', 'numeric', 'decimal', 'real', 'double precision',
|
|
232
|
+
'date', 'time', 'time without time zone', 'time with time zone',
|
|
233
|
+
'timestamp', 'timestamp without time zone', 'timestamp with time zone',
|
|
234
|
+
'json', 'jsonb', 'inet', 'cidr', 'macaddr', 'bytea', 'interval',
|
|
235
|
+
]);
|
|
236
|
+
|
|
223
237
|
/**
|
|
224
238
|
* Classify a column type change. Conservative by construction — anything not provably safe
|
|
225
239
|
* or merely lossy falls through to `risky`, so a dangerous conversion is never mistaken for
|
|
@@ -230,7 +244,18 @@ export function classifyTypeChange(from: string, to: string): TypeChangeRisk {
|
|
|
230
244
|
const f = parseType(from);
|
|
231
245
|
const t = parseType(to);
|
|
232
246
|
|
|
233
|
-
|
|
247
|
+
// `→ text` is only free from a type text can round-trip. It IS the universal sink for the
|
|
248
|
+
// printable VALUE — but a structured type loses its meaning, not its characters: postgis
|
|
249
|
+
// `geometry` → text takes every spatial index and `ST_*` call with it, `tsvector` → text
|
|
250
|
+
// kills the full-text index, `hstore` → text destroys the key/value structure. None of that
|
|
251
|
+
// is recoverable by casting back.
|
|
252
|
+
//
|
|
253
|
+
// This mattered far past taxonomy. A `safe` verdict emits a BARE `SET DATA TYPE` with no
|
|
254
|
+
// WARNING, and the plan classifier only counts a narrowing when it sees the `USING` cast a
|
|
255
|
+
// non-safe verdict adds — so 48 postgis/tsvector/hstore/`timestamp(6)` rewrites in a real
|
|
256
|
+
// adoption plan were reported as additive with `destructive: 0`, which is also the number
|
|
257
|
+
// that gates `--confirm` + snapshot. Calling these lossy is what arms the gate.
|
|
258
|
+
if (t.base === 'text') return TEXT_ROUND_TRIPS.has(f.base) ? 'safe' : 'lossy';
|
|
234
259
|
|
|
235
260
|
const fr = NUMERIC_RANK[f.base];
|
|
236
261
|
const tr = NUMERIC_RANK[t.base];
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -89,6 +89,94 @@ export interface DestructiveBreakdown {
|
|
|
89
89
|
strips: string[];
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Statements that positively ADD — the only ones a plan may call additive.
|
|
94
|
+
*
|
|
95
|
+
* `additive` used to be computed as `executable.length - destructive`, which made it the
|
|
96
|
+
* DEFAULT rather than a finding: any statement the classifier did not recognize was reported
|
|
97
|
+
* as safe. A real adoption plan carrying 38 DROP POLICY, 43 REVOKE and 48 column rewrites
|
|
98
|
+
* described itself as `additive: 158, destructive: 0`, and since `destructive > 0` is what
|
|
99
|
+
* gates `--confirm` + snapshot + the approver set, the false zero did not merely mislabel the
|
|
100
|
+
* plan — it disarmed the gate.
|
|
101
|
+
*
|
|
102
|
+
* So the default is inverted. A statement is additive only when it matches one of these; a
|
|
103
|
+
* statement nobody recognized is `unclassified`, and `unclassified` is never additive. This is
|
|
104
|
+
* the same rule the reconciler already applies to grants: absence of knowledge means LEAVE IT
|
|
105
|
+
* ALONE and say so, never "assume it is fine".
|
|
106
|
+
*/
|
|
107
|
+
const ADDITIVE_MATCHERS: RegExp[] = [
|
|
108
|
+
/^CREATE\s+(TABLE|POLICY|INDEX|UNIQUE\s+INDEX|SEQUENCE|EXTENSION|TYPE|SCHEMA)\b/,
|
|
109
|
+
/^GRANT\b/,
|
|
110
|
+
/^COMMENT\s+ON\b/,
|
|
111
|
+
/^ALTER\s+TABLE\s+[\s\S]+?\sADD\s+(COLUMN|CONSTRAINT|PRIMARY\s+KEY)\b/,
|
|
112
|
+
// Enabling/forcing RLS only ever RESTRICTS; it cannot widen access or lose a row.
|
|
113
|
+
/^ALTER\s+TABLE\s+[\s\S]+?\s(ENABLE|FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/,
|
|
114
|
+
// Relaxing nullability and setting a default add capability; they remove nothing.
|
|
115
|
+
/^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sDROP\s+NOT\s+NULL\b/,
|
|
116
|
+
/^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sSET\s+DEFAULT\b/,
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Statements that REMOVE an authorization without losing a row.
|
|
121
|
+
*
|
|
122
|
+
* Deliberately NOT folded into `destructive`, which means data loss and whose taxonomy is
|
|
123
|
+
* defended above — a dropped policy or grant re-declares from the models without touching a
|
|
124
|
+
* row. But it is emphatically not ADDITIVE either, and that was the lie: a `DROP POLICY` that
|
|
125
|
+
* removes a table's only read policy leaves the table returning zero rows, which is the single
|
|
126
|
+
* highest-consequence thing an adoption plan can carry and it was reported as an addition.
|
|
127
|
+
*/
|
|
128
|
+
const AUTHZ_REMOVAL_MATCHERS: RegExp[] = [
|
|
129
|
+
/^DROP\s+POLICY\b/,
|
|
130
|
+
/^REVOKE\b/,
|
|
131
|
+
/^ALTER\s+TABLE\s+[\s\S]+?\s(DISABLE|NO\s+FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/,
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
/** The full partition of an executable stream. Every statement lands in exactly one bucket. */
|
|
135
|
+
export interface StatementPartition {
|
|
136
|
+
additive: string[];
|
|
137
|
+
drops: string[];
|
|
138
|
+
narrowings: string[];
|
|
139
|
+
strips: string[];
|
|
140
|
+
/** Authorization removed, no data lost — visible, never additive. */
|
|
141
|
+
authzRemovals: string[];
|
|
142
|
+
/** Not positively recognized. NEVER additive; needs a human before this plan is applied. */
|
|
143
|
+
unclassified: string[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The first non-comment line, upper-cased — WARNING prologues must not hide the verb. */
|
|
147
|
+
function statementHead(statement: string): string {
|
|
148
|
+
return (statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? '')
|
|
149
|
+
.trim()
|
|
150
|
+
.toUpperCase();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Partition an executable stream into exactly one bucket per statement.
|
|
155
|
+
*
|
|
156
|
+
* Order matters: the data-loss buckets are consulted FIRST so a statement that both drops and
|
|
157
|
+
* revokes is counted at its most severe reading, and `unclassified` is the fallthrough rather
|
|
158
|
+
* than `additive`.
|
|
159
|
+
*/
|
|
160
|
+
export function partitionStatements(
|
|
161
|
+
executable: string[],
|
|
162
|
+
opts: { declaredGrantees?: ReadonlySet<string> } = {},
|
|
163
|
+
): StatementPartition {
|
|
164
|
+
const { drops, narrowings, strips } = classifyDestructive(executable, opts);
|
|
165
|
+
const destructive = new Set([...drops, ...narrowings, ...strips]);
|
|
166
|
+
const additive: string[] = [];
|
|
167
|
+
const authzRemovals: string[] = [];
|
|
168
|
+
const unclassified: string[] = [];
|
|
169
|
+
|
|
170
|
+
for (const statement of executable) {
|
|
171
|
+
if (destructive.has(statement)) continue;
|
|
172
|
+
const head = statementHead(statement);
|
|
173
|
+
if (AUTHZ_REMOVAL_MATCHERS.some((re) => re.test(head))) authzRemovals.push(statement);
|
|
174
|
+
else if (ADDITIVE_MATCHERS.some((re) => re.test(head))) additive.push(statement);
|
|
175
|
+
else unclassified.push(statement);
|
|
176
|
+
}
|
|
177
|
+
return { additive, drops, narrowings, strips, authzRemovals, unclassified };
|
|
178
|
+
}
|
|
179
|
+
|
|
92
180
|
/** `REVOKE … ON <table> FROM <grantee>;` → the grantee, or null when it is not a revoke. */
|
|
93
181
|
export function revokeTarget(statement: string): string | null {
|
|
94
182
|
// A quoted identifier can hold anything (`"Odd-Role"`, a reserved word, mixed case), and
|