@everystack/cli 0.4.38 → 0.4.40
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 +71 -7
- package/src/cli/authz-derive.ts +210 -0
- package/src/cli/authz-redteam.ts +132 -9
- package/src/cli/commands/db-authz.ts +19 -3
- package/src/cli/commands/db-fingerprint.ts +19 -2
- package/src/cli/commands/db-pull.ts +30 -5
- package/src/cli/commands/db-swap.ts +7 -2
- package/src/cli/commands/db.ts +11 -0
- package/src/cli/derived-apply.ts +15 -1
- package/src/cli/derived-compile.ts +11 -1
- package/src/cli/derived-introspect.ts +36 -8
- package/src/cli/derived-plan.ts +53 -0
- package/src/cli/derived-render.ts +65 -5
- package/src/cli/model-render.ts +33 -6
- package/src/cli/pg-argtypes.ts +52 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.40",
|
|
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.6"
|
|
113
113
|
},
|
|
114
114
|
"peerDependencies": {
|
|
115
115
|
"@everystack/server": ">=0.4.0",
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -210,6 +210,34 @@ function compileColumnGrants(abilities: readonly Ability[]): Record<string, Reco
|
|
|
210
210
|
return Object.keys(out).length ? out : undefined;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Unwrap a `sql` fragment into predicate text.
|
|
215
|
+
*
|
|
216
|
+
* `where` / `sql` / `check` are typed `unknown` because the model package brands its
|
|
217
|
+
* fragments (`RawSql`) rather than accepting bare strings — a predicate must be written
|
|
218
|
+
* deliberately, never assembled by accident. Anything else THROWS: these conditions were
|
|
219
|
+
* declared and documented long before they were compiled, so a model could carry a
|
|
220
|
+
* `sql:` predicate that silently did nothing. Failing loudly is the fix; a predicate that
|
|
221
|
+
* evaporates is an authorization hole wearing the costume of a working rule.
|
|
222
|
+
*/
|
|
223
|
+
function rawPredicate(value: unknown, where: string): string | null {
|
|
224
|
+
if (value == null) return null;
|
|
225
|
+
const text = (value as { sql?: unknown }).sql;
|
|
226
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — an RLS predicate is authored, never stringly assembled.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return `(${text.trim()})`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** AND-compose predicates, dropping the vacuous ones. A condition only ever NARROWS. */
|
|
235
|
+
function andPredicates(...parts: (string | null)[]): string | null {
|
|
236
|
+
const real = parts.filter((p): p is string => Boolean(p) && p !== '(true)' && p !== 'true');
|
|
237
|
+
if (real.length === 0) return null;
|
|
238
|
+
return real.length === 1 ? real[0] : `(${real.join(' AND ')})`;
|
|
239
|
+
}
|
|
240
|
+
|
|
213
241
|
// ---------------------------------------------------------------------------
|
|
214
242
|
// Compile.
|
|
215
243
|
// ---------------------------------------------------------------------------
|
|
@@ -238,6 +266,26 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
238
266
|
// A "row-scoped" condition is either a direct owner or a transitive via.
|
|
239
267
|
const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
|
|
240
268
|
|
|
269
|
+
// Author-supplied predicates, per action. `manage` carries no predicate of its own —
|
|
270
|
+
// it is the admin bypass — so only the specific verbs are consulted.
|
|
271
|
+
const predFor = (action: Ability['action']): string | null => {
|
|
272
|
+
for (const a of model.abilities) {
|
|
273
|
+
if (a.action !== action) continue;
|
|
274
|
+
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('${action}')`);
|
|
275
|
+
if (p) return p;
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
};
|
|
279
|
+
/** The WRITE half, when the author declared one distinct from the read half. */
|
|
280
|
+
const checkFor = (action: Ability['action']): string | null => {
|
|
281
|
+
for (const a of model.abilities) {
|
|
282
|
+
if (a.action !== action) continue;
|
|
283
|
+
const p = rawPredicate(a.condition.check, `${table}: can('${action}', { check })`);
|
|
284
|
+
if (p) return p;
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
};
|
|
288
|
+
|
|
241
289
|
const abilities = model.abilities;
|
|
242
290
|
const hasAdminManage = abilities.some((a) => a.action === 'manage' && a.condition.role === 'admin');
|
|
243
291
|
const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !rowScoped(a));
|
|
@@ -264,7 +312,8 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
264
312
|
if (hasPublicRead) {
|
|
265
313
|
// public read on a soft-delete table — split per role; authenticated owners
|
|
266
314
|
// also see their own (soft-deleted) rows, hence the OR'd owner check.
|
|
267
|
-
const
|
|
315
|
+
const readPred = predFor('read');
|
|
316
|
+
const anonUsing = andPredicates(sdGuard, readPred) ?? 'true';
|
|
268
317
|
policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
|
|
269
318
|
|
|
270
319
|
// The owner predicate may only ever WIDEN a public read (the soft-delete OR:
|
|
@@ -272,12 +321,14 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
272
321
|
// condition on a WRITE ability used to narrow the authenticated SELECT to
|
|
273
322
|
// own-rows-only, so anon saw the whole table and a signed-in user lost it.
|
|
274
323
|
// Without a soft-delete guard, `true OR owner` is just `true`.
|
|
275
|
-
const authedUsing = sdGuard && rowPred
|
|
324
|
+
const authedUsing = sdGuard && rowPred
|
|
325
|
+
? andPredicates(`(${sdGuard} OR ${rowPred})`, readPred)!
|
|
326
|
+
: anonUsing;
|
|
276
327
|
policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
|
|
277
328
|
} else if (hasOwnerRead && rowPred) {
|
|
278
329
|
// owner-scoped read — no anon visibility; you see only the rows you own
|
|
279
330
|
// (directly, or transitively through a `via:` parent).
|
|
280
|
-
policy(`${t}_select_own`, 'SELECT', ['authenticated'], rowPred, null);
|
|
331
|
+
policy(`${t}_select_own`, 'SELECT', ['authenticated'], andPredicates(rowPred, predFor('read'))!, null);
|
|
281
332
|
} else if (hasColumnRead && rowPred) {
|
|
282
333
|
// column-scoped self read — `authenticated` reads only its OWN row, and only the
|
|
283
334
|
// granted columns (the GRANT scopes fields; this policy scopes rows). Named
|
|
@@ -287,10 +338,23 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
287
338
|
|
|
288
339
|
// owner-gated writes. INSERT checks ownership (you can only create rows you own);
|
|
289
340
|
// UPDATE gates + checks; DELETE gates. `rowPred` is the direct-owner or `via:` predicate.
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
341
|
+
//
|
|
342
|
+
// A declared `check` is the WITH CHECK half; without one the read half governs both
|
|
343
|
+
// (Postgres' own default). The two differ when a row may be written into a shape its
|
|
344
|
+
// author could not have selected by — the direction that matters is a check WEAKER than
|
|
345
|
+
// the using, which lets an owner move their own row past whatever the using guarded.
|
|
346
|
+
const createPred = andPredicates(rowPred, predFor('create'));
|
|
347
|
+
const updatePred = andPredicates(rowPred, predFor('update'));
|
|
348
|
+
const deletePred = andPredicates(rowPred, predFor('delete'));
|
|
349
|
+
|
|
350
|
+
if (canCreate && createPred) {
|
|
351
|
+
policy(`${t}_insert_own`, 'INSERT', ['authenticated'], null, checkFor('create') ?? createPred);
|
|
352
|
+
}
|
|
353
|
+
if ((hasUpdateOwner || predFor('update')) && updatePred) {
|
|
354
|
+
policy(`${t}_update_own`, 'UPDATE', ['authenticated'], updatePred, checkFor('update') ?? updatePred);
|
|
355
|
+
}
|
|
356
|
+
if ((hasDeleteOwner || predFor('delete')) && deletePred) {
|
|
357
|
+
policy(`${t}_delete_own`, 'DELETE', ['authenticated'], deletePred, null);
|
|
294
358
|
}
|
|
295
359
|
|
|
296
360
|
policies.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authz-derive — live authorization → declared `can()` abilities. The brownfield on-ramp's
|
|
3
|
+
* missing half.
|
|
4
|
+
*
|
|
5
|
+
* `db:pull` already renders fields, indexes and relations from a live database, but left
|
|
6
|
+
* authz as commented scaffolding: every adopter hand-transcribed rules that were sitting in
|
|
7
|
+
* the catalog, which is the single highest-risk manual step in a migration and the one
|
|
8
|
+
* place a human introduces drift.
|
|
9
|
+
*
|
|
10
|
+
* The rule that governs every decision here:
|
|
11
|
+
*
|
|
12
|
+
* **policy presence ≠ effective privilege.**
|
|
13
|
+
*
|
|
14
|
+
* Postgres checks the GRANT before it checks the policy. A table can carry a perfectly
|
|
15
|
+
* good UPDATE policy and still be un-updatable, because no role was ever granted UPDATE —
|
|
16
|
+
* the policy is dead code. Rendering that policy as `can('update', { owner })` would
|
|
17
|
+
* compile to a policy PLUS a table-wide `GRANT UPDATE`, manufacturing a privilege the
|
|
18
|
+
* database does not currently give. On a real schema that path was a moderation bypass:
|
|
19
|
+
* the live USING carried a state guard the WITH CHECK did not, so handing out the grant
|
|
20
|
+
* would have let owners publish their own drafts.
|
|
21
|
+
*
|
|
22
|
+
* So an ability is emitted only where the grant and the policy AGREE. Everything else
|
|
23
|
+
* becomes a comment that names what was found and why it was not rendered — the same
|
|
24
|
+
* honesty the view-skip message already practices. A false ability is worse than a
|
|
25
|
+
* comment: the comment costs a human five minutes, the ability costs them a privilege.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { TableContract, PolicyContract } from './authz-contract.js';
|
|
29
|
+
|
|
30
|
+
/** One rendered decision: either an ability line, or a comment explaining the omission. */
|
|
31
|
+
export interface DerivedAbilities {
|
|
32
|
+
/** `can(...)` lines, ready to sit inside `abilities: [ ... ]`. */
|
|
33
|
+
abilities: string[];
|
|
34
|
+
/** `//` comment lines naming what could not be rendered, and why. */
|
|
35
|
+
notes: string[];
|
|
36
|
+
/**
|
|
37
|
+
* Roles holding grants that the model vocabulary has no name for (anything outside
|
|
38
|
+
* anon/authenticated/admin) — an operator or reporting role, typically.
|
|
39
|
+
*
|
|
40
|
+
* Returned rather than written into `notes` because it is usually the SAME role on every
|
|
41
|
+
* table: a migrator or ops account granted across the schema. Repeating that fact once
|
|
42
|
+
* per model buries the per-table findings that are actually specific. The caller states
|
|
43
|
+
* it once, for the whole pull.
|
|
44
|
+
*/
|
|
45
|
+
unmappedRoles: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DML: Record<string, 'read' | 'create' | 'update' | 'delete'> = {
|
|
49
|
+
SELECT: 'read',
|
|
50
|
+
INSERT: 'create',
|
|
51
|
+
UPDATE: 'update',
|
|
52
|
+
DELETE: 'delete',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The roles the compiler itself emits policies for; anything else is app-specific. */
|
|
56
|
+
const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The deparsed owner predicate the compiler produces, in both its casts:
|
|
60
|
+
* (col = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text)))
|
|
61
|
+
* Recognising it turns a predicate back into `{ owner: 'col' }` — the structured shape —
|
|
62
|
+
* instead of an opaque `sql:` fragment. Anything unrecognised stays verbatim rather than
|
|
63
|
+
* being guessed at.
|
|
64
|
+
*/
|
|
65
|
+
const OWNER_RE =
|
|
66
|
+
/^\(?([a-z_][a-z0-9_]*)\s*=\s*\(*\(current_setting\('request\.jwt\.claims'::text,\s*true\)\)::jsonb\s*->>\s*'([a-z_]+)'::text\)*(?:::[a-z]+)?\)?$/i;
|
|
67
|
+
|
|
68
|
+
/** Does this predicate say "the row is mine"? Returns the column and the claim if so. */
|
|
69
|
+
export function parseOwnerPredicate(pred: string | null): { column: string; claim: string } | null {
|
|
70
|
+
if (!pred) return null;
|
|
71
|
+
const m = OWNER_RE.exec(pred.trim());
|
|
72
|
+
return m ? { column: m[1], claim: m[2] } : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Is a privilege EFFECTIVE for a role — i.e. actually granted, not merely policed? */
|
|
76
|
+
function granted(contract: TableContract, role: string, privilege: string): boolean {
|
|
77
|
+
const direct = contract.grants[role] ?? [];
|
|
78
|
+
const publicGrant = contract.grants.PUBLIC ?? contract.grants.public ?? [];
|
|
79
|
+
return direct.includes(privilege) || publicGrant.includes(privilege);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The policies that apply to a role for a command (an `ALL` policy covers every command). */
|
|
83
|
+
function policiesFor(contract: TableContract, role: string, command: string): PolicyContract[] {
|
|
84
|
+
return contract.policies.filter(
|
|
85
|
+
(p) => (p.command === command || p.command === 'ALL') && (p.roles.includes(role) || p.roles.includes('public')),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A sql`…` fragment literal, or null when the predicate is vacuous. */
|
|
90
|
+
function sqlFragment(pred: string | null): string | null {
|
|
91
|
+
if (!pred) return null;
|
|
92
|
+
const t = pred.trim();
|
|
93
|
+
if (t === 'true' || t === '(true)') return null;
|
|
94
|
+
return `sql\`${t.replace(/`/g, '\\`')}\``;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Derive the ability stanza for ONE table from its live contract.
|
|
99
|
+
*
|
|
100
|
+
* Deliberately conservative. Every branch that cannot prove what it would emit falls
|
|
101
|
+
* through to `notes` rather than guessing — the caller renders those as comments beside
|
|
102
|
+
* the model, so the human sees the real rule and decides.
|
|
103
|
+
*/
|
|
104
|
+
export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
105
|
+
const abilities: string[] = [];
|
|
106
|
+
const notes: string[] = [];
|
|
107
|
+
const table = contract.table;
|
|
108
|
+
|
|
109
|
+
// --- the admin bypass: one role holding every privilege ------------------------------
|
|
110
|
+
const adminAll = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'].every((p) => granted(contract, 'admin', p));
|
|
111
|
+
if (adminAll) abilities.push(`can('manage', { role: 'admin' })`);
|
|
112
|
+
|
|
113
|
+
// --- dead policies: policed but never granted ----------------------------------------
|
|
114
|
+
// The trap. Rendering one of these would ADD a privilege that does not exist today.
|
|
115
|
+
for (const p of contract.policies) {
|
|
116
|
+
const commands = p.command === 'ALL' ? ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] : [p.command];
|
|
117
|
+
for (const cmd of commands) {
|
|
118
|
+
const priv = cmd;
|
|
119
|
+
const live = p.roles.filter((r) => r !== 'public' && r !== 'admin' && !granted(contract, r, priv));
|
|
120
|
+
if (live.length && p.roles.some((r) => r !== 'admin')) {
|
|
121
|
+
notes.push(
|
|
122
|
+
`policy "${p.name}" (${cmd}) applies to ${live.join(', ')} but no ${priv} grant exists —`,
|
|
123
|
+
);
|
|
124
|
+
notes.push(
|
|
125
|
+
` it is dead code today. Declaring it would ADD a privilege the database does not give.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- reads ---------------------------------------------------------------------------
|
|
132
|
+
const anonRead = granted(contract, 'anon', 'SELECT');
|
|
133
|
+
const authedRead = granted(contract, 'authenticated', 'SELECT');
|
|
134
|
+
|
|
135
|
+
if (anonRead) {
|
|
136
|
+
// Public read. If the live policy narrows it (a soft-delete guard, a published flag),
|
|
137
|
+
// carry that predicate — a public read with a filter is still a public read.
|
|
138
|
+
const pol = policiesFor(contract, 'anon', 'SELECT')[0];
|
|
139
|
+
const frag = sqlFragment(pol?.using ?? null);
|
|
140
|
+
abilities.push(frag ? `can('read', { sql: ${frag} })` : `can('read')`);
|
|
141
|
+
} else if (authedRead) {
|
|
142
|
+
const pol = policiesFor(contract, 'authenticated', 'SELECT')[0];
|
|
143
|
+
const owner = parseOwnerPredicate(pol?.using ?? null);
|
|
144
|
+
if (owner) {
|
|
145
|
+
const claim = owner.claim === 'sub' ? '' : `, userField: '${owner.claim}'`;
|
|
146
|
+
abilities.push(`can('read', { owner: '${owner.column}'${claim} })`);
|
|
147
|
+
} else {
|
|
148
|
+
const frag = sqlFragment(pol?.using ?? null);
|
|
149
|
+
abilities.push(frag ? `can('read', { role: 'authenticated', sql: ${frag} })` : `can('read', { role: 'authenticated' })`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- writes --------------------------------------------------------------------------
|
|
154
|
+
for (const [command, action] of Object.entries(DML)) {
|
|
155
|
+
if (action === 'read') continue;
|
|
156
|
+
if (!granted(contract, 'authenticated', command)) continue;
|
|
157
|
+
|
|
158
|
+
const pol = policiesFor(contract, 'authenticated', command).find((p) => p.command !== 'ALL')
|
|
159
|
+
?? policiesFor(contract, 'authenticated', command)[0];
|
|
160
|
+
const using = pol?.using ?? null;
|
|
161
|
+
const check = pol?.check ?? null;
|
|
162
|
+
const owner = parseOwnerPredicate(using) ?? parseOwnerPredicate(check);
|
|
163
|
+
|
|
164
|
+
const parts: string[] = [];
|
|
165
|
+
if (owner) {
|
|
166
|
+
parts.push(`owner: '${owner.column}'`);
|
|
167
|
+
if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
|
|
168
|
+
}
|
|
169
|
+
// A predicate beyond the owner gate (a state guard, a tenant filter) rides as `sql`.
|
|
170
|
+
const usingFrag = owner && parseOwnerPredicate(using) ? null : sqlFragment(using);
|
|
171
|
+
if (usingFrag) parts.push(`sql: ${usingFrag}`);
|
|
172
|
+
// The WRITE half, only when it genuinely differs. Postgres defaults it to USING, and
|
|
173
|
+
// emitting it unconditionally would be noise that also defeats the round-trip diff.
|
|
174
|
+
if (check && check !== using) {
|
|
175
|
+
const checkFrag = sqlFragment(check);
|
|
176
|
+
if (checkFrag) parts.push(`check: ${checkFrag}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!parts.length && !pol) {
|
|
180
|
+
// Granted with no policy at all. Under RLS that is a table nobody can actually
|
|
181
|
+
// reach; without RLS it is a wide-open privilege. Either way, say so.
|
|
182
|
+
notes.push(`${command} is granted to authenticated with no policy — ${contract.rls.enabled ? 'RLS is on, so no row qualifies' : 'RLS is OFF: this is unrestricted'}.`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
abilities.push(`can('${action}'${parts.length ? `, { ${parts.join(', ')} }` : ''})`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- roles the compiler has no vocabulary for ----------------------------------------
|
|
189
|
+
const unmappedRoles = Object.keys(contract.grants).filter(
|
|
190
|
+
(r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public',
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
if (!abilities.length && !notes.length && !unmappedRoles.length) {
|
|
194
|
+
notes.push(`no grants found — this table is internal (nothing reaches it through the data API).`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { abilities, notes, unmappedRoles };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Render one table's derived stanza as the lines that sit inside a model literal. */
|
|
201
|
+
export function renderDerivedAbilities(d: DerivedAbilities): string {
|
|
202
|
+
const out: string[] = [];
|
|
203
|
+
for (const n of d.notes) out.push(` // ${n}`);
|
|
204
|
+
if (d.abilities.length) {
|
|
205
|
+
out.push(` abilities: [${d.abilities.join(', ')}],`);
|
|
206
|
+
} else {
|
|
207
|
+
out.push(` private: true, // no effective privilege found — not part of the data API`);
|
|
208
|
+
}
|
|
209
|
+
return out.join('\n');
|
|
210
|
+
}
|
package/src/cli/authz-redteam.ts
CHANGED
|
@@ -29,7 +29,8 @@ export type ProbeOutcome =
|
|
|
29
29
|
| 'allowed' // the privilege is present (RLS may still filter rows)
|
|
30
30
|
| 'denied-privilege' // no grant: "permission denied for table"
|
|
31
31
|
| 'denied-rls' // grant present, RLS blocked the row: privilege IS present
|
|
32
|
-
| 'no-impersonate'
|
|
32
|
+
| 'no-impersonate' // the probing role could not SET ROLE into this role
|
|
33
|
+
| 'inconclusive'; // the statement failed BEFORE the privilege check — proves nothing
|
|
33
34
|
|
|
34
35
|
export interface ProbeResult {
|
|
35
36
|
role: string;
|
|
@@ -39,7 +40,7 @@ export interface ProbeResult {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export interface RedTeamFinding {
|
|
42
|
-
severity: 'hole' | 'broken' | 'unprobed';
|
|
43
|
+
severity: 'hole' | 'broken' | 'unprobed' | 'inconclusive' | 'inherited';
|
|
43
44
|
role: string;
|
|
44
45
|
table: string;
|
|
45
46
|
command: ProbeCommand;
|
|
@@ -85,8 +86,15 @@ function pgArrayLiteral(values: string[]): string {
|
|
|
85
86
|
* INSERT INTO t DEFAULT VALUES — INSERT privilege (rolled back; NOT NULL = allowed)
|
|
86
87
|
* UPDATE t SET col = col WHERE false — UPDATE privilege, touches no rows
|
|
87
88
|
* DELETE FROM t WHERE false — DELETE privilege, touches no rows
|
|
88
|
-
* "permission denied" -> denied-privilege; "row-level security" -> denied-rls
|
|
89
|
-
*
|
|
89
|
+
* "permission denied" -> denied-privilege; "row-level security" -> denied-rls.
|
|
90
|
+
*
|
|
91
|
+
* Any OTHER error needs care, and getting this wrong is how the tool cries wolf. A
|
|
92
|
+
* constraint or NOT NULL violation happens AFTER the permission check, so it proves the
|
|
93
|
+
* privilege was present -> allowed. But a statement rejected at parse/analysis time never
|
|
94
|
+
* reached the permission check at all and proves NOTHING -> inconclusive. Classifying the
|
|
95
|
+
* second kind as `allowed` is what reported `anon` as able to UPDATE a table it cannot
|
|
96
|
+
* touch: the probe's `SET pk = pk` hit 428C9 ("can only be updated to DEFAULT") on an
|
|
97
|
+
* identity PK, and a non-grant then read as "enforcement exceeds declaration".
|
|
90
98
|
*/
|
|
91
99
|
export function buildProbeSql(roles: string[], tables: string[]): string {
|
|
92
100
|
return `
|
|
@@ -111,9 +119,20 @@ BEGIN
|
|
|
111
119
|
WHEN 'UPDATE' THEN NULL
|
|
112
120
|
END;
|
|
113
121
|
IF v_cmd = 'UPDATE' THEN
|
|
122
|
+
-- Prefer a column the statement can actually SET. An identity or generated
|
|
123
|
+
-- column rejects "SET col = col" at analysis time (428C9), before the permission
|
|
124
|
+
-- check — which makes the probe prove nothing. Fall back to the first column
|
|
125
|
+
-- only when every column is generated; that run reports inconclusive.
|
|
114
126
|
SELECT quote_ident(attname) INTO v_col FROM pg_attribute
|
|
115
127
|
WHERE attrelid = format('%I.%I', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2))::regclass
|
|
116
|
-
AND attnum > 0 AND NOT attisdropped
|
|
128
|
+
AND attnum > 0 AND NOT attisdropped
|
|
129
|
+
AND attidentity = '' AND attgenerated = ''
|
|
130
|
+
ORDER BY attnum LIMIT 1;
|
|
131
|
+
IF v_col IS NULL THEN
|
|
132
|
+
SELECT quote_ident(attname) INTO v_col FROM pg_attribute
|
|
133
|
+
WHERE attrelid = format('%I.%I', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2))::regclass
|
|
134
|
+
AND attnum > 0 AND NOT attisdropped ORDER BY attnum LIMIT 1;
|
|
135
|
+
END IF;
|
|
117
136
|
v_stmt := format('UPDATE %I.%I SET %s = %s WHERE false', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2), v_col, v_col);
|
|
118
137
|
END IF;
|
|
119
138
|
|
|
@@ -126,7 +145,21 @@ BEGIN
|
|
|
126
145
|
WHEN insufficient_privilege THEN
|
|
127
146
|
v_outcome := CASE WHEN SQLERRM ILIKE '%row-level security%' THEN 'denied-rls' ELSE 'denied-privilege' END;
|
|
128
147
|
WHEN OTHERS THEN
|
|
129
|
-
|
|
148
|
+
-- Did this error happen BEFORE the permission check, or after it?
|
|
149
|
+
-- Before (parse/analysis): the probe proves nothing about the privilege.
|
|
150
|
+
-- After (data/constraint): the privilege was present.
|
|
151
|
+
v_outcome := CASE
|
|
152
|
+
WHEN SQLSTATE IN (
|
|
153
|
+
'428C9', -- generated_always: identity/generated column (the probe's own SET pk = pk)
|
|
154
|
+
'42703', -- undefined_column
|
|
155
|
+
'42P01', -- undefined_table
|
|
156
|
+
'42601', -- syntax_error
|
|
157
|
+
'42P10', -- invalid_column_reference
|
|
158
|
+
'42804', -- datatype_mismatch
|
|
159
|
+
'0A000' -- feature_not_supported (e.g. writable-view restrictions)
|
|
160
|
+
) THEN 'inconclusive'
|
|
161
|
+
ELSE 'allowed'
|
|
162
|
+
END;
|
|
130
163
|
END;
|
|
131
164
|
RESET ROLE;
|
|
132
165
|
EXCEPTION WHEN insufficient_privilege THEN
|
|
@@ -205,6 +238,67 @@ export function toGrantGap(row: any): GrantGap {
|
|
|
205
238
|
}
|
|
206
239
|
|
|
207
240
|
/** Map a probe result row (from the SELECT) into a typed ProbeResult. */
|
|
241
|
+
/** A privilege a role holds only by MEMBERSHIP in another role, not by a direct grant. */
|
|
242
|
+
export interface InheritedGrant {
|
|
243
|
+
role: string;
|
|
244
|
+
table: string; // schema-qualified
|
|
245
|
+
command: ProbeCommand;
|
|
246
|
+
via: string; // the ancestor role(s) supplying it
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Why does a role hold a privilege the contract never granted it?
|
|
251
|
+
*
|
|
252
|
+
* The contract's grants come from the table ACL, which records DIRECT grants. Effective
|
|
253
|
+
* privilege also flows through role membership (`GRANT parent TO child` with INHERIT), and
|
|
254
|
+
* the probe measures the effective answer. The gap is real but reads as noise: on a real
|
|
255
|
+
* brownfield schema a migrator role that inherits `postgres` reported 20 undeclared
|
|
256
|
+
* privileges — 5 tables × 4 commands — when the whole story is one line about the role.
|
|
257
|
+
*
|
|
258
|
+
* This attributes each such privilege to the ancestor that supplies it, so the finding can
|
|
259
|
+
* be stated once per role instead of once per table × command.
|
|
260
|
+
*/
|
|
261
|
+
export function buildInheritedPrivilegeSql(roles: string[], tables: string[]): string {
|
|
262
|
+
return `
|
|
263
|
+
WITH RECURSIVE anc AS (
|
|
264
|
+
SELECT am.member AS member, am.roleid AS ancestor
|
|
265
|
+
FROM pg_auth_members am
|
|
266
|
+
JOIN pg_roles child ON child.oid = am.member AND child.rolinherit
|
|
267
|
+
UNION
|
|
268
|
+
SELECT a.member, am2.roleid
|
|
269
|
+
FROM anc a
|
|
270
|
+
JOIN pg_auth_members am2 ON am2.member = a.ancestor
|
|
271
|
+
JOIN pg_roles mid ON mid.oid = a.ancestor AND mid.rolinherit
|
|
272
|
+
)
|
|
273
|
+
SELECT r.rolname AS role,
|
|
274
|
+
(c.relnamespace::regnamespace::text || '.' || c.relname) AS "table",
|
|
275
|
+
p.priv AS command,
|
|
276
|
+
string_agg(DISTINCT ar.rolname, ', ' ORDER BY ar.rolname) AS via
|
|
277
|
+
FROM pg_class c
|
|
278
|
+
CROSS JOIN (VALUES ('SELECT'),('INSERT'),('UPDATE'),('DELETE')) AS p(priv)
|
|
279
|
+
JOIN pg_roles r ON r.rolname = ANY(${pgArrayLiteral(roles)})
|
|
280
|
+
JOIN anc ON anc.member = r.oid
|
|
281
|
+
JOIN pg_roles ar ON ar.oid = anc.ancestor
|
|
282
|
+
WHERE (c.relnamespace::regnamespace::text || '.' || c.relname) = ANY(${pgArrayLiteral(tables)})
|
|
283
|
+
AND has_table_privilege(r.oid, c.oid, p.priv)
|
|
284
|
+
AND has_table_privilege(ar.oid, c.oid, p.priv)
|
|
285
|
+
AND NOT EXISTS (
|
|
286
|
+
SELECT 1 FROM aclexplode(c.relacl) a
|
|
287
|
+
WHERE a.privilege_type = p.priv AND a.grantee IN (r.oid, 0)
|
|
288
|
+
)
|
|
289
|
+
GROUP BY 1, 2, 3
|
|
290
|
+
`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function toInheritedGrant(row: any): InheritedGrant {
|
|
294
|
+
return {
|
|
295
|
+
role: String(row.role),
|
|
296
|
+
table: String(row.table),
|
|
297
|
+
command: String(row.command).toUpperCase() as ProbeCommand,
|
|
298
|
+
via: String(row.via),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
208
302
|
export function toProbeResult(row: any): ProbeResult {
|
|
209
303
|
return {
|
|
210
304
|
role: String(row.role),
|
|
@@ -226,9 +320,17 @@ export function toProbeResult(row: any): ProbeResult {
|
|
|
226
320
|
* denied-rls counts as the privilege being present (RLS filtering rows is the policy's
|
|
227
321
|
* job, exercised behaviourally by the RPC layer, not a grant-level finding).
|
|
228
322
|
*/
|
|
229
|
-
export function evaluateRedTeam(
|
|
323
|
+
export function evaluateRedTeam(
|
|
324
|
+
contract: AuthzContract,
|
|
325
|
+
results: ProbeResult[],
|
|
326
|
+
inherited: InheritedGrant[] = [],
|
|
327
|
+
): RedTeamFinding[] {
|
|
230
328
|
const tablesByName = new Map(contract.tables.map((t) => [t.table, t]));
|
|
231
329
|
const findings: RedTeamFinding[] = [];
|
|
330
|
+
// Privileges explained by role membership rather than a direct grant. Keyed so the
|
|
331
|
+
// per-result lookup is exact; reported once per role, not once per table × command.
|
|
332
|
+
const inheritedBy = new Map(inherited.map((g) => [`${g.role}${g.table}${g.command}`, g]));
|
|
333
|
+
const inheritedRoles = new Map<string, { via: string; count: number }>();
|
|
232
334
|
|
|
233
335
|
for (const r of results) {
|
|
234
336
|
const table = tablesByName.get(r.table);
|
|
@@ -239,13 +341,34 @@ export function evaluateRedTeam(contract: AuthzContract, results: ProbeResult[])
|
|
|
239
341
|
if (r.outcome === 'no-impersonate') {
|
|
240
342
|
findings.push({ severity: 'unprobed', role: r.role, table: r.table, command: r.command,
|
|
241
343
|
detail: `could not SET ROLE into ${r.role} — grant the probe connection membership to cover it` });
|
|
344
|
+
} else if (r.outcome === 'inconclusive') {
|
|
345
|
+
// The statement never reached the permission check, so this proves nothing either
|
|
346
|
+
// way. Reported so it is visible, never counted as a pass or a hole.
|
|
347
|
+
findings.push({ severity: 'inconclusive', role: r.role, table: r.table, command: r.command,
|
|
348
|
+
detail: `${r.command} on ${r.table} failed before the privilege check — this probe proves nothing about ${r.role}` });
|
|
242
349
|
} else if (allowed && !granted) {
|
|
243
|
-
|
|
244
|
-
|
|
350
|
+
const via = inheritedBy.get(`${r.role}${r.table}${r.command}`);
|
|
351
|
+
if (via) {
|
|
352
|
+
// Real, but it is one fact about a role, not N facts about N tables.
|
|
353
|
+
const seen = inheritedRoles.get(r.role);
|
|
354
|
+
if (seen) seen.count += 1;
|
|
355
|
+
else inheritedRoles.set(r.role, { via: via.via, count: 1 });
|
|
356
|
+
} else {
|
|
357
|
+
findings.push({ severity: 'hole', role: r.role, table: r.table, command: r.command,
|
|
358
|
+
detail: `${r.role} can ${r.command} ${r.table} but the contract grants no such privilege (enforcement exceeds declaration)` });
|
|
359
|
+
}
|
|
245
360
|
} else if (!allowed && granted) {
|
|
246
361
|
findings.push({ severity: 'broken', role: r.role, table: r.table, command: r.command,
|
|
247
362
|
detail: `contract grants ${r.role} ${r.command} on ${r.table} but the database denied it (declared but not enforced)` });
|
|
248
363
|
}
|
|
249
364
|
}
|
|
365
|
+
|
|
366
|
+
// One line per inheriting role. The membership IS the finding — a role that reaches
|
|
367
|
+
// tables through `GRANT parent TO child` is worth stating out loud once, and worth not
|
|
368
|
+
// stating N times.
|
|
369
|
+
for (const [role, { via, count }] of inheritedRoles) {
|
|
370
|
+
findings.push({ severity: 'inherited', role, table: '', command: 'SELECT',
|
|
371
|
+
detail: `${role} holds ${count} undeclared privilege(s) INHERITED via membership in ${via} — not a direct grant, so the contract cannot see them. Intentional for a migrator/owner role; a finding if it is an application role` });
|
|
372
|
+
}
|
|
250
373
|
return findings;
|
|
251
374
|
}
|
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
evaluateRedTeam,
|
|
41
41
|
GRANT_COMPLETENESS_SQL,
|
|
42
42
|
toGrantGap,
|
|
43
|
+
buildInheritedPrivilegeSql,
|
|
44
|
+
toInheritedGrant,
|
|
43
45
|
} from '../authz-redteam.js';
|
|
44
46
|
import {
|
|
45
47
|
buildOwnerProbeSql,
|
|
@@ -221,14 +223,18 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
|
|
|
221
223
|
|
|
222
224
|
let rows: any[];
|
|
223
225
|
let gapRows: any[];
|
|
226
|
+
let inheritedRows: any[];
|
|
224
227
|
let venueLabel: string;
|
|
225
228
|
let venue: AuthzVenue | undefined;
|
|
226
229
|
try {
|
|
227
230
|
venue = await resolveVenue(flags);
|
|
228
231
|
venueLabel = venue.label;
|
|
232
|
+
const roles = probeRoles(contract);
|
|
233
|
+
const tables = contract.tables.map((t) => t.table);
|
|
229
234
|
step('Red-teaming enforcement (SET ROLE + attempt per role/table/command, rolled back)...');
|
|
230
|
-
|
|
231
|
-
|
|
235
|
+
rows = await venue.probe(buildProbeSql(roles, tables), PROBE_SELECT_SQL);
|
|
236
|
+
step('Attributing undeclared privileges to role membership...');
|
|
237
|
+
inheritedRows = await venue.runner(buildInheritedPrivilegeSql(roles, tables));
|
|
232
238
|
step('Checking SECDEF grant-completeness (owner can EXECUTE every helper it calls)...');
|
|
233
239
|
gapRows = await venue.runner(GRANT_COMPLETENESS_SQL);
|
|
234
240
|
} catch (err: any) {
|
|
@@ -240,16 +246,26 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
|
|
|
240
246
|
await venue?.end();
|
|
241
247
|
}
|
|
242
248
|
|
|
243
|
-
const findings = evaluateRedTeam(contract, rows.map(toProbeResult));
|
|
249
|
+
const findings = evaluateRedTeam(contract, rows.map(toProbeResult), inheritedRows.map(toInheritedGrant));
|
|
244
250
|
const holes = findings.filter((f) => f.severity === 'hole');
|
|
245
251
|
const broken = findings.filter((f) => f.severity === 'broken');
|
|
246
252
|
const unprobed = findings.filter((f) => f.severity === 'unprobed');
|
|
253
|
+
const inconclusive = findings.filter((f) => f.severity === 'inconclusive');
|
|
254
|
+
const inheritedFindings = findings.filter((f) => f.severity === 'inherited');
|
|
247
255
|
const gaps = gapRows.map(toGrantGap);
|
|
248
256
|
|
|
249
257
|
console.log('');
|
|
250
258
|
for (const f of holes) fail(`[HOLE] ${f.detail}`);
|
|
251
259
|
for (const f of broken) warn(`[BROKEN] ${f.detail}`);
|
|
252
260
|
for (const g of gaps) fail(`[GRANT-GAP] ${g.secdef} calls ${g.helper} but its owner cannot EXECUTE it (42501 in prod once ownership is normalized)`);
|
|
261
|
+
for (const f of inheritedFindings) info(`[inherited] ${f.detail}`);
|
|
262
|
+
if (inconclusive.length) {
|
|
263
|
+
// Never a pass and never a failure — a probe that proved nothing, said out loud so it
|
|
264
|
+
// can be fixed rather than silently counted as clean.
|
|
265
|
+
info(`[inconclusive] ${inconclusive.length} probe(s) failed before the privilege check and prove nothing:`);
|
|
266
|
+
for (const f of inconclusive.slice(0, 5)) info(` ${f.command} ${f.table} (${f.role})`);
|
|
267
|
+
if (inconclusive.length > 5) info(` …and ${inconclusive.length - 5} more`);
|
|
268
|
+
}
|
|
253
269
|
if (unprobed.length) {
|
|
254
270
|
const roles = [...new Set(unprobed.map((f) => f.role))].join(', ');
|
|
255
271
|
info(`[unprobed] could not SET ROLE into: ${roles} — grant the operator membership to cover them.`);
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* is deliberately absent here — `db:reconcile --check` is its verifier.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
15
16
|
import type { ModelDescriptor } from '@everystack/model';
|
|
16
17
|
import type { QueryRunner } from '../authz-contract.js';
|
|
17
18
|
import { introspectSchema } from '../schema-introspect.js';
|
|
@@ -78,8 +79,24 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
78
79
|
try {
|
|
79
80
|
models = await loadModels(modelsPath);
|
|
80
81
|
sequences = (await loadDeclaredDerived(flags.models))?.sequences;
|
|
81
|
-
} catch {
|
|
82
|
-
//
|
|
82
|
+
} catch (err: any) {
|
|
83
|
+
// Two very different situations used to land here identically.
|
|
84
|
+
//
|
|
85
|
+
// No barrel at all is legitimate live-only mode — "what fingerprint is this database
|
|
86
|
+
// at?" is a useful question on its own.
|
|
87
|
+
//
|
|
88
|
+
// A barrel that EXISTS but will not load is not. Reporting "(live-only)" for it turns
|
|
89
|
+
// a broken measurement into a passing one: the operator asked whether the database
|
|
90
|
+
// matches their models, and got an answer that never looked at the models. That is
|
|
91
|
+
// how someone concludes a round-trip closed when nothing was compared.
|
|
92
|
+
if (existsSync(modelsPath)) {
|
|
93
|
+
fail(`Could not load the models barrel at ${modelsPath}`);
|
|
94
|
+
info(String(err?.message ?? err).split('\n').slice(0, 6).join('\n'));
|
|
95
|
+
info('');
|
|
96
|
+
info('Fix the barrel and re-run. (Refusing rather than falling back to live-only —');
|
|
97
|
+
info('a comparison that silently compares nothing is worse than an error.)');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
83
100
|
}
|
|
84
101
|
|
|
85
102
|
let runner: QueryRunner;
|