@everystack/cli 0.4.48 → 0.4.50
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-derive.ts +110 -9
- package/src/cli/authz-lint.ts +61 -0
- package/src/cli/commands/db-check.ts +12 -3
- package/src/cli/commands/db-pull.ts +16 -2
- package/src/cli/derived-compile.ts +81 -12
- package/src/cli/derived-grants.ts +10 -3
- package/src/cli/derived-introspect.ts +126 -11
- package/src/cli/derived-lint.ts +95 -5
- package/src/cli/derived-render.ts +203 -33
- package/src/cli/index.ts +1 -1
- package/src/cli/parse-flags.ts +21 -1
- package/src/plugin.ts +23 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.50",
|
|
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.13"
|
|
113
113
|
},
|
|
114
114
|
"peerDependencies": {
|
|
115
115
|
"@everystack/server": ">=0.4.0",
|
package/src/cli/authz-derive.ts
CHANGED
|
@@ -64,8 +64,10 @@ const DML: Record<string, 'read' | 'create' | 'update' | 'delete'> = {
|
|
|
64
64
|
DELETE: 'delete',
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
-
/** The roles the compiler itself emits policies for; anything else is app-specific.
|
|
68
|
-
|
|
67
|
+
/** The roles the compiler itself emits policies for; anything else is app-specific.
|
|
68
|
+
* Exported so the DERIVED renderer applies the identical rule — the views path once had
|
|
69
|
+
* its own idea of which grantees to render, and the two disagreed inside one pull. */
|
|
70
|
+
export const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
|
|
69
71
|
|
|
70
72
|
/**
|
|
71
73
|
* The grantees a rendered model GOVERNS — the three vocabulary roles plus PUBLIC, which is
|
|
@@ -75,7 +77,7 @@ const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
|
|
|
75
77
|
* an ungoverned grantee alone, so there is no REVOKE to prevent, and naming the role in a model
|
|
76
78
|
* would GOVERN it — turning a rendering decision into an access decision for every table.
|
|
77
79
|
*/
|
|
78
|
-
const GOVERNED_VOCABULARY = new Set([...KNOWN_ROLES, 'PUBLIC', 'public']);
|
|
80
|
+
export const GOVERNED_VOCABULARY = new Set([...KNOWN_ROLES, 'PUBLIC', 'public']);
|
|
79
81
|
|
|
80
82
|
/** The beyond-CRUD privileges a governed role holds live — what `can()` cannot say. */
|
|
81
83
|
function deriveExtraPrivileges(contract: TableContract): Record<string, string[]> {
|
|
@@ -232,6 +234,32 @@ function policiesFor(contract: TableContract, role: string, command: string): Po
|
|
|
232
234
|
);
|
|
233
235
|
}
|
|
234
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Does this grant actually give the role rows?
|
|
239
|
+
*
|
|
240
|
+
* **The rule, in one line: an ability needs BOTH a grant and a covering permissive policy.**
|
|
241
|
+
*
|
|
242
|
+
* Postgres checks the grant and THEN the policy. Either half alone gives nothing, and the two
|
|
243
|
+
* failures look identical in a diff:
|
|
244
|
+
*
|
|
245
|
+
* - policy, no grant → dead code. Declaring it ADDs a grant. (handled in deriveAbilities)
|
|
246
|
+
* - grant, no policy → dead rows. Declaring it ADDs a policy. (this predicate)
|
|
247
|
+
*
|
|
248
|
+
* The second half was fixed for `admin` first, because that is where a consumer hit it. The
|
|
249
|
+
* round-trip gate (integration/authz-roundtrip) then found the SAME defect on the read branch —
|
|
250
|
+
* `authenticated` holding SELECT with no policy rendered `can('read')`, compiling to
|
|
251
|
+
* `CREATE POLICY … USING (true)` against a database that has no such policy. Hence one predicate,
|
|
252
|
+
* used at every site that turns a grant into an ability, rather than a fix per role.
|
|
253
|
+
*
|
|
254
|
+
* A restrictive-only policy does not count: RESTRICTIVE policies subtract, so with no permissive
|
|
255
|
+
* policy to pass first the role still sees nothing.
|
|
256
|
+
*/
|
|
257
|
+
function effective(contract: TableContract, role: string, priv: string): boolean {
|
|
258
|
+
if (!granted(contract, role, priv)) return false;
|
|
259
|
+
if (!contract.rls.enabled) return true;
|
|
260
|
+
return policiesFor(contract, role, priv).some((p) => p.permissive);
|
|
261
|
+
}
|
|
262
|
+
|
|
235
263
|
/** A sql`…` fragment literal, or null when the predicate is vacuous. */
|
|
236
264
|
function sqlFragment(pred: string | null): string | null {
|
|
237
265
|
if (!pred) return null;
|
|
@@ -303,10 +331,72 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
303
331
|
const abilities: string[] = [];
|
|
304
332
|
const notes: string[] = [];
|
|
305
333
|
const table = contract.table;
|
|
334
|
+
/** Live CRUD grants no policy covers — declared as grants so the plan neither invents a
|
|
335
|
+
* policy (a widening) nor revokes a real privilege (a narrowing). */
|
|
336
|
+
const deadGrants: Record<string, string[]> = {};
|
|
306
337
|
|
|
307
338
|
// --- the admin bypass: one role holding every privilege ------------------------------
|
|
308
|
-
|
|
309
|
-
|
|
339
|
+
//
|
|
340
|
+
// The grant is only HALF the question. Postgres checks the grant and THEN the policy, so a
|
|
341
|
+
// role holding all four privileges with no policy covering it reads zero rows. Rendering
|
|
342
|
+
// `can('manage')` there compiles to a GRANT **plus** `CREATE POLICY … TO admin USING (true)`,
|
|
343
|
+
// handing admin a table the database currently withholds — a WIDENING emitted by the adoption
|
|
344
|
+
// path itself. That is the same trap the policy-without-grant branch below prevents, reached
|
|
345
|
+
// from the other side, and it shipped: three tables at one adopter, one of the three
|
|
346
|
+
// statements in their plan, refused by them.
|
|
347
|
+
//
|
|
348
|
+
// The grants are still declared — via `privileges` below — so the plan neither invents a
|
|
349
|
+
// policy nor revokes a real grant. Do NOT "simplify" this to dropping the grants: a grantee
|
|
350
|
+
// with rolbypassrls holds them live (our introspection checks ownership, not rolbypassrls),
|
|
351
|
+
// and they re-arm the day anyone runs ALTER TABLE … DISABLE ROW LEVEL SECURITY.
|
|
352
|
+
//
|
|
353
|
+
// No owner ambiguity: GRANTS_SQL excludes the table owner, so a role present in `grants` is a
|
|
354
|
+
// non-owner, and RLS binds non-owners whenever `enabled` is true. `forced` is irrelevant here.
|
|
355
|
+
const CRUD = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'];
|
|
356
|
+
const adminGrants = CRUD.filter((p) => granted(contract, 'admin', p));
|
|
357
|
+
const adminLive = adminGrants.filter((p) => effective(contract, 'admin', p));
|
|
358
|
+
const adminDead = adminGrants.filter((p) => !adminLive.includes(p));
|
|
359
|
+
|
|
360
|
+
if (adminLive.length === CRUD.length) {
|
|
361
|
+
abilities.push(`can('manage', { role: 'admin' })`);
|
|
362
|
+
} else {
|
|
363
|
+
// PARTIAL. `manage` means all four, so it would be a lie — but the privileges that ARE
|
|
364
|
+
// policied are live authorization, and rendering nothing for them makes the plan DROP the
|
|
365
|
+
// policy and REVOKE the grant. That narrowing is how the round-trip gate caught this case:
|
|
366
|
+
// an admin table policied for SELECT only lost both halves on the way through.
|
|
367
|
+
for (const priv of adminLive) {
|
|
368
|
+
const pol = policiesFor(contract, 'admin', priv).find((p) => p.permissive);
|
|
369
|
+
const frag = sqlFragment(pol?.using ?? null);
|
|
370
|
+
const verb = DML[priv];
|
|
371
|
+
abilities.push(
|
|
372
|
+
frag ? `can('${verb}', { role: 'admin', sql: ${frag} })` : `can('${verb}', { role: 'admin' })`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (adminDead.length) {
|
|
378
|
+
deadGrants.admin = adminDead;
|
|
379
|
+
notes.push(
|
|
380
|
+
`admin holds ${adminDead.join(', ')} but no policy covers admin —`,
|
|
381
|
+
);
|
|
382
|
+
notes.push(
|
|
383
|
+
` dead while RLS is on, unrestricted if RLS is ever disabled. Declared as a grant, not`,
|
|
384
|
+
);
|
|
385
|
+
notes.push(
|
|
386
|
+
` an ability: an ability would ADD the policy the database does not have.`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// The same accounting for the app roles. A grant they hold with no policy behind it is
|
|
391
|
+
// declared (so the plan does not REVOKE a live privilege) and never rendered as an ability
|
|
392
|
+
// (so the plan does not CREATE the policy the database is missing).
|
|
393
|
+
for (const role of ['authenticated', 'anon']) {
|
|
394
|
+
const dead = CRUD.filter((p) => granted(contract, role, p) && !effective(contract, role, p));
|
|
395
|
+
if (!dead.length) continue;
|
|
396
|
+
deadGrants[role] = dead;
|
|
397
|
+
notes.push(`${role} holds ${dead.join(', ')} but no policy covers ${role} —`);
|
|
398
|
+
notes.push(` the grant is live in the catalog and gives no rows; declared as a grant, not an ability.`);
|
|
399
|
+
}
|
|
310
400
|
|
|
311
401
|
// --- policed but not granted at the table level ---------------------------------------
|
|
312
402
|
//
|
|
@@ -340,8 +430,11 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
340
430
|
}
|
|
341
431
|
|
|
342
432
|
// --- reads ---------------------------------------------------------------------------
|
|
343
|
-
|
|
344
|
-
|
|
433
|
+
// `effective`, not `granted`: a SELECT grant with no policy behind it renders can('read'),
|
|
434
|
+
// which compiles to CREATE POLICY … USING (true) — manufacturing a policy the database does
|
|
435
|
+
// not have. Same defect as the admin branch above; the round-trip gate found it here.
|
|
436
|
+
const anonRead = effective(contract, 'anon', 'SELECT');
|
|
437
|
+
const authedRead = effective(contract, 'authenticated', 'SELECT');
|
|
345
438
|
|
|
346
439
|
if (anonRead) {
|
|
347
440
|
// Public read. If the live policy narrows it (a soft-delete guard, a published flag),
|
|
@@ -465,7 +558,7 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
465
558
|
// --- writes --------------------------------------------------------------------------
|
|
466
559
|
for (const [command, action] of Object.entries(DML)) {
|
|
467
560
|
if (action === 'read') continue;
|
|
468
|
-
if (!
|
|
561
|
+
if (!effective(contract, 'authenticated', command)) continue;
|
|
469
562
|
|
|
470
563
|
const pol = policiesFor(contract, 'authenticated', command).find((p) => p.command !== 'ALL')
|
|
471
564
|
?? policiesFor(contract, 'authenticated', command)[0];
|
|
@@ -535,7 +628,15 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
535
628
|
notes.push(`no grants found — this table is internal (nothing reaches it through the data API).`);
|
|
536
629
|
}
|
|
537
630
|
|
|
538
|
-
|
|
631
|
+
// The beyond-CRUD privileges plus any live CRUD grant no policy covers. Both are "grants
|
|
632
|
+
// can() cannot say", for the same reason: `can()` decides a grant AND a policy together, and
|
|
633
|
+
// these have no policy to decide.
|
|
634
|
+
const privileges = deriveExtraPrivileges(contract);
|
|
635
|
+
for (const [role, privs] of Object.entries(deadGrants)) {
|
|
636
|
+
privileges[role] = [...new Set([...(privileges[role] ?? []), ...privs])].sort();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return { abilities, notes, unmappedRoles, privileges };
|
|
539
640
|
}
|
|
540
641
|
|
|
541
642
|
/** An object key, quoted only when the role name is not a bare JS identifier. */
|
package/src/cli/authz-lint.ts
CHANGED
|
@@ -46,3 +46,64 @@ export function findReadAuthzGaps(models: readonly ModelDescriptor[]): ReadAuthz
|
|
|
46
46
|
}
|
|
47
47
|
return gaps;
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Naked DML grants — the safety that used to live in the parser.
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A CRUD grant declared without the policy that gates it.
|
|
56
|
+
*
|
|
57
|
+
* `privileges` used to REFUSE the DML four at model-definition time, so that a grant could never
|
|
58
|
+
* be declared without its policy. That throw was removed because the invariant was vacuous for
|
|
59
|
+
* adoption: a live table can hold `GRANT SELECT TO admin` with no policy covering admin, and a
|
|
60
|
+
* language that cannot spell it makes such a database a permanent MISMATCH against
|
|
61
|
+
* `db:fingerprint` — so adopting one required mutating it first. Expressibility is total over the
|
|
62
|
+
* fingerprint's domain; the opinion belongs here instead.
|
|
63
|
+
*
|
|
64
|
+
* Severity is a WARNING, never fatal, and that is a considered narrowing of the original ruling.
|
|
65
|
+
* The ruling asked for an ERROR on a table without RLS, where a naked grant is an unrestricted
|
|
66
|
+
* table-wide privilege. That case cannot arise from models: `compileTableContract` emits
|
|
67
|
+
* `rls: { enabled: true }` UNCONDITIONALLY for every modeled table (authz-compile.ts:558). So a
|
|
68
|
+
* declared naked grant is always dead-on-arrival and only becomes live if someone later disables
|
|
69
|
+
* RLS on that table — a real risk, but a future one, and failing CI over a faithful rendering of
|
|
70
|
+
* a database the adopter is trying to adopt would make `db:pull` unusable on exactly the schemas
|
|
71
|
+
* it exists for.
|
|
72
|
+
*/
|
|
73
|
+
export interface NakedGrant {
|
|
74
|
+
schema: string;
|
|
75
|
+
table: string;
|
|
76
|
+
role: string;
|
|
77
|
+
privileges: string[];
|
|
78
|
+
message: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DML = new Set(['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Grants declared in `privileges` that carry no policy — reported for hand-authored and pulled
|
|
85
|
+
* models alike, because the shape is equally wrong to author and equally true to find.
|
|
86
|
+
*/
|
|
87
|
+
export function findNakedGrants(models: readonly ModelDescriptor[]): NakedGrant[] {
|
|
88
|
+
const out: NakedGrant[] = [];
|
|
89
|
+
for (const m of models) {
|
|
90
|
+
for (const role of Object.keys(m.privileges ?? {}).sort()) {
|
|
91
|
+
const dml = (m.privileges[role] ?? []).filter((p) => DML.has(p)).sort();
|
|
92
|
+
if (!dml.length) continue;
|
|
93
|
+
out.push({
|
|
94
|
+
schema: m.schema,
|
|
95
|
+
table: m.table,
|
|
96
|
+
role,
|
|
97
|
+
privileges: dml,
|
|
98
|
+
message:
|
|
99
|
+
`${m.schema}.${m.table}: '${role}' holds ${dml.join(', ')} as a grant with no policy beside it. ` +
|
|
100
|
+
`It is dead while RLS is on — the role reads zero rows — and becomes an unrestricted ` +
|
|
101
|
+
`table-wide privilege the day RLS is disabled or a broad policy is added. If this came from ` +
|
|
102
|
+
`db:pull it is a faithful reading of the database; decide whether to give it a policy ` +
|
|
103
|
+
`(can(...)) or revoke it. If you wrote it by hand, you almost certainly want can() instead, ` +
|
|
104
|
+
`which decides the grant and the policy together.`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
@@ -41,9 +41,11 @@ import { loadModels } from './db-generate.js';
|
|
|
41
41
|
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
|
|
42
42
|
import type { SequenceDescriptor } from '@everystack/model';
|
|
43
43
|
import type { SourceObject } from '../derived-source.js';
|
|
44
|
-
import { findReadAuthzGaps } from '../authz-lint.js';
|
|
44
|
+
import { findReadAuthzGaps, findNakedGrants } from '../authz-lint.js';
|
|
45
45
|
import { parseBaseline, renderBaseline, BASELINE_FILE } from '../authz-baseline.js';
|
|
46
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings, findPublicExecutableSecdef,
|
|
48
|
+
} from '../derived-lint.js';
|
|
47
49
|
import { step, success, fail, info, warn } from '../output.js';
|
|
48
50
|
|
|
49
51
|
// The role derivation moved to the shared builder (db-build) with brick
|
|
@@ -160,6 +162,13 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
160
162
|
}
|
|
161
163
|
}
|
|
162
164
|
|
|
165
|
+
// A CRUD grant declared with no policy beside it. WARN, never fatal: this is what a faithful
|
|
166
|
+
// db:pull of a brownfield database looks like, and failing CI over it would make the pull
|
|
167
|
+
// unusable on the schemas it exists for. See findNakedGrants for why it is not an error.
|
|
168
|
+
for (const naked of findNakedGrants(input.models)) {
|
|
169
|
+
findings.push({ level: 'warn', area: 'authz', message: naked.message });
|
|
170
|
+
}
|
|
171
|
+
|
|
163
172
|
// The adoption baseline, ARTIFACT ONLY.
|
|
164
173
|
//
|
|
165
174
|
// This check cannot see the threat and must not pretend to. The foreign grantees it
|
|
@@ -209,7 +218,7 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
209
218
|
for (const gap of derivedGaps) {
|
|
210
219
|
findings.push({ level: 'fail', area: 'authz', message: gap.message });
|
|
211
220
|
}
|
|
212
|
-
for (const warning of findMatviewSnapshotWarnings(input.derived)) {
|
|
221
|
+
for (const warning of [...findMatviewSnapshotWarnings(input.derived), ...findPublicExecutableSecdef(input.derived)]) {
|
|
213
222
|
findings.push({ level: 'warn', area: 'authz', message: warning.message });
|
|
214
223
|
}
|
|
215
224
|
if (derivedGaps.length === 0) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
|
|
3
3
|
*
|
|
4
4
|
* db:pull [--stage <name>] [--database-url <url>] [--schema public] [--out <dir | file.ts>]
|
|
5
|
-
* [--derived-out <file.ts>] [--abilities public-read]
|
|
5
|
+
* [--derived-out <file.ts>] [--abilities live|public-read]
|
|
6
6
|
*
|
|
7
7
|
* `--stage` and `--database-url` COMPOSE: the URL picks the connection, the stage names the
|
|
8
8
|
* baseline entry (`db/authz-baseline.json` is per-stage — a local adoption pulls with
|
|
@@ -358,7 +358,21 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
358
358
|
const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK →|verbatim:)/g) ?? []).length;
|
|
359
359
|
if (flagged) caution(`${flagged} inline comment(s) flag things to review (verbatim types, checks, cross-schema FKs).`);
|
|
360
360
|
if (abilities === 'commented') {
|
|
361
|
-
|
|
361
|
+
// `live` FIRST, and named. It is the mode brownfield adoption needs — derive the authz
|
|
362
|
+
// from grants and policies the database already has — and it was advertised nowhere:
|
|
363
|
+
// not in the usage line, not in --help, not here. The only place an operator met the
|
|
364
|
+
// word was the error text for an unknown preset, so the discovery path for the right
|
|
365
|
+
// flag was to guess a wrong one. A consumer and their agent both walked past it in one
|
|
366
|
+
// session, and it cost a misdiagnosis plus a needless overwrite of 30 model files.
|
|
367
|
+
//
|
|
368
|
+
// `public-read` is deliberately no longer the headline: on an existing database it
|
|
369
|
+
// stamps can('read') on EVERY table, which on a real schema means public read of
|
|
370
|
+
// credit_cards, transactions, emails and users.
|
|
371
|
+
note(
|
|
372
|
+
`Each model scaffolds its authz decision as comments — db:check fails until every model declares. `
|
|
373
|
+
+ `For an EXISTING database, re-pull with --abilities live to derive them from the grants and policies `
|
|
374
|
+
+ `already there. --abilities public-read stamps public-read/admin-write on every table — greenfield only.`,
|
|
375
|
+
);
|
|
362
376
|
} else {
|
|
363
377
|
note(`Stamped '${abilities}' abilities into every model — review the generated stanzas; they are code, not defaults.`);
|
|
364
378
|
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import type {
|
|
21
21
|
ModelDescriptor, DerivedDescriptor, ViewDescriptor, MaterializedViewDescriptor,
|
|
22
|
-
FunctionDescriptor, SqlDescriptor, TriggerSpec, Ability, SetofReturn, DependsOnRef,
|
|
22
|
+
FunctionDescriptor, SqlDescriptor, TriggerSpec, TriggerDescriptor, Ability, SetofReturn, DependsOnRef,
|
|
23
23
|
} from '@everystack/model';
|
|
24
24
|
import { hashSourceContent, parseQualified, DECLARED_SOURCE_FILE, type Attachment, type SourceObject } from './derived-source.js';
|
|
25
25
|
import { isPlainGrantAttachment } from './derived-grants.js';
|
|
@@ -51,9 +51,21 @@ function refName(ref: SetofReturn['setof']): string {
|
|
|
51
51
|
// Grants — the same audience semantics as tables
|
|
52
52
|
// ---------------------------------------------------------------------------
|
|
53
53
|
|
|
54
|
-
/**
|
|
55
|
-
*
|
|
56
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Relation read grants: bare read → anon + authenticated (the table precedent);
|
|
56
|
+
* `{ role }` narrows; `{ columns }` scopes the SELECT to a column list.
|
|
57
|
+
*
|
|
58
|
+
* `privileges` is the second source — grants that EXIST, recorded by `db:pull` rather than
|
|
59
|
+
* chosen. It is what lets a brownfield view be rendered at all: the write grants a blanket
|
|
60
|
+
* `GRANT ALL ON ALL TABLES IN SCHEMA` left behind, and a `SELECT` to `PUBLIC`, are not
|
|
61
|
+
* expressible as read abilities, and emitting the object WITHOUT them would make the next
|
|
62
|
+
* generate plan a REVOKE of each one.
|
|
63
|
+
*/
|
|
64
|
+
function relationGrants(
|
|
65
|
+
target: string,
|
|
66
|
+
abilities: readonly Ability[],
|
|
67
|
+
privileges: Readonly<Record<string, string[]>> = {},
|
|
68
|
+
): Attachment[] {
|
|
57
69
|
const out: Attachment[] = [];
|
|
58
70
|
for (const a of abilities) {
|
|
59
71
|
const roles = a.condition.role ? a.condition.role : 'anon, authenticated';
|
|
@@ -62,17 +74,46 @@ function relationGrants(target: string, abilities: readonly Ability[]): Attachme
|
|
|
62
74
|
: '';
|
|
63
75
|
out.push({ kind: 'grant', sql: `GRANT SELECT${cols} ON ${target} TO ${roles}` });
|
|
64
76
|
}
|
|
77
|
+
// Grantee-sorted, privileges in the declared order the model already normalized, so a
|
|
78
|
+
// re-render of an unchanged database is byte-stable.
|
|
79
|
+
for (const grantee of Object.keys(privileges).sort()) {
|
|
80
|
+
out.push({ kind: 'grant', sql: `GRANT ${privileges[grantee].join(', ')} ON ${target} TO ${grantee}` });
|
|
81
|
+
}
|
|
65
82
|
return out;
|
|
66
83
|
}
|
|
67
84
|
|
|
68
|
-
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
|
|
72
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Function authz: `REVOKE ALL … FROM PUBLIC` (PostgreSQL grants EXECUTE to PUBLIC by
|
|
87
|
+
* default — the silent-open inverse of the RLS trap), then the declared grants.
|
|
88
|
+
*
|
|
89
|
+
* Two sources, and the distinction is the whole design: `abilities` are the INTENDED
|
|
90
|
+
* audience (`can('execute', { role })`), `privileges` are grants that EXIST, recorded by
|
|
91
|
+
* `db:pull` without claiming intent.
|
|
92
|
+
*
|
|
93
|
+
* **A declared `PUBLIC` privilege suppresses the revoke**, and that is not a loophole. The
|
|
94
|
+
* revoke is unconditional precisely because nobody could SAY "PUBLIC holds EXECUTE here";
|
|
95
|
+
* once a descriptor says it, revoking anyway would make a faithful pull of a live function
|
|
96
|
+
* plan a REVOKE the adopter never asked for — turning adoption into mutation. Declaring it
|
|
97
|
+
* is still a finding, raised where findings belong: db:check WARNs on a PUBLIC-executable
|
|
98
|
+
* SECURITY DEFINER function.
|
|
99
|
+
*/
|
|
100
|
+
function functionGrants(
|
|
101
|
+
signature: string,
|
|
102
|
+
abilities: readonly Ability[],
|
|
103
|
+
privileges: Readonly<Record<string, string[]>> = {},
|
|
104
|
+
): Attachment[] {
|
|
105
|
+
const declaresPublic = Object.keys(privileges).some((g) => g.toUpperCase() === 'PUBLIC');
|
|
106
|
+
const out: Attachment[] = declaresPublic
|
|
107
|
+
? []
|
|
108
|
+
: [{ kind: 'grant', sql: `REVOKE ALL ON FUNCTION ${signature} FROM PUBLIC` }];
|
|
73
109
|
for (const a of abilities) {
|
|
74
110
|
out.push({ kind: 'grant', sql: `GRANT EXECUTE ON FUNCTION ${signature} TO ${a.condition.role}` });
|
|
75
111
|
}
|
|
112
|
+
// Sorted so a re-render of an unchanged database is byte-stable — object key order is
|
|
113
|
+
// insertion order, and the introspection's row order is not a contract.
|
|
114
|
+
for (const grantee of Object.keys(privileges).sort()) {
|
|
115
|
+
out.push({ kind: 'grant', sql: `GRANT EXECUTE ON FUNCTION ${signature} TO ${grantee}` });
|
|
116
|
+
}
|
|
76
117
|
return out;
|
|
77
118
|
}
|
|
78
119
|
|
|
@@ -98,7 +139,7 @@ function renderView(v: ViewDescriptor): { sql: string; attachments: Attachment[]
|
|
|
98
139
|
sql: `CREATE VIEW ${target}${invoker} AS\n${v.as}`,
|
|
99
140
|
attachments: [
|
|
100
141
|
...commentAttachment('VIEW', target, v.comment),
|
|
101
|
-
...relationGrants(target, v.abilities),
|
|
142
|
+
...relationGrants(target, v.abilities, v.privileges ?? {}),
|
|
102
143
|
],
|
|
103
144
|
};
|
|
104
145
|
}
|
|
@@ -128,7 +169,7 @@ function renderMaterializedView(mv: MaterializedViewDescriptor): { sql: string;
|
|
|
128
169
|
attachments: [
|
|
129
170
|
...indexes,
|
|
130
171
|
...commentAttachment('MATERIALIZED VIEW', target, mv.comment),
|
|
131
|
-
...relationGrants(target, mv.abilities),
|
|
172
|
+
...relationGrants(target, mv.abilities, mv.privileges ?? {}),
|
|
132
173
|
],
|
|
133
174
|
};
|
|
134
175
|
}
|
|
@@ -166,7 +207,7 @@ function renderFunction(fn: FunctionDescriptor): { sql: string; attachments: Att
|
|
|
166
207
|
`AS ${tag}\n${fn.body}\n${tag}`,
|
|
167
208
|
attachments: [
|
|
168
209
|
...commentAttachment('FUNCTION', signature, fn.comment),
|
|
169
|
-
...functionGrants(signature, fn.abilities),
|
|
210
|
+
...functionGrants(signature, fn.abilities, fn.privileges ?? {}),
|
|
170
211
|
],
|
|
171
212
|
};
|
|
172
213
|
}
|
|
@@ -207,9 +248,22 @@ interface Node {
|
|
|
207
248
|
function derivedIdentity(d: DerivedDescriptor): string {
|
|
208
249
|
const { schema, name } = parseQualified(d.name);
|
|
209
250
|
if (d.kind === 'function') return functionIdentity(schema, name, d.args.map((a) => a.type));
|
|
251
|
+
// A trigger's name is unique only WITHIN its table — two tables may both carry
|
|
252
|
+
// `touch_updated_at` — so its identity is the owner's, plus the name.
|
|
253
|
+
if (d.kind === 'trigger') {
|
|
254
|
+
const o = triggerOwnerOf(d);
|
|
255
|
+
return `${o.schema}.${o.name}.${d.name}`;
|
|
256
|
+
}
|
|
210
257
|
return `${schema}.${name}`;
|
|
211
258
|
}
|
|
212
259
|
|
|
260
|
+
/** Where a standalone binding rides: a model is state (no derived identity), a view is not. */
|
|
261
|
+
function triggerOwnerOf(d: TriggerDescriptor): { schema: string; name: string; ownerIdentity: string | null } {
|
|
262
|
+
if ('table' in d.on) return { schema: d.on.schema, name: d.on.table, ownerIdentity: null };
|
|
263
|
+
const q = parseQualified(d.on.name);
|
|
264
|
+
return { schema: q.schema, name: q.name, ownerIdentity: `${q.schema}.${q.name}` };
|
|
265
|
+
}
|
|
266
|
+
|
|
213
267
|
/** What a bad ref IS, for the gate's message — null if the value is a usable object. */
|
|
214
268
|
function badRef(r: unknown): string | null {
|
|
215
269
|
if (r === undefined) return 'undefined';
|
|
@@ -233,6 +287,13 @@ function rejectBadRefs(models: readonly ModelDescriptor[], derived: readonly Der
|
|
|
233
287
|
hole('compileDerived', 'models', models);
|
|
234
288
|
hole('compileDerived', 'derived', derived);
|
|
235
289
|
for (const d of derived) {
|
|
290
|
+
// A trigger has no dependsOn — its two deps come free (the target, the execute
|
|
291
|
+
// function) — but its `on` is a ref like any other and a stale import must be loud.
|
|
292
|
+
if (d.kind === 'trigger') {
|
|
293
|
+
hole(`trigger '${d.name}'`, 'on', [d.on], false);
|
|
294
|
+
hole(`trigger '${d.name}'`, 'execute', [d.execute], false);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
236
297
|
hole(`${d.kind} '${d.name}'`, 'dependsOn', d.dependsOn ?? []);
|
|
237
298
|
if (d.kind === 'function' && typeof d.returns !== 'string') {
|
|
238
299
|
hole(`function '${d.name}'`, 'returns.setof', [d.returns.setof], false);
|
|
@@ -348,6 +409,14 @@ export function compileDerived(models: readonly ModelDescriptor[], derived: read
|
|
|
348
409
|
const q = parseQualified(v.name);
|
|
349
410
|
return { schema: q.schema, name: q.name, ownerIdentity: `${q.schema}.${q.name}`, triggers: v.triggers ?? [] };
|
|
350
411
|
}),
|
|
412
|
+
// Standalone bindings (defineTrigger) — the same unit, reached from the descriptor
|
|
413
|
+
// rather than through its target. Both spellings compile to an identical node, which
|
|
414
|
+
// is the point: the deprecated attached form keeps working while `db:pull` renders the
|
|
415
|
+
// standalone one (a binding on a model would make the generated module import the
|
|
416
|
+
// compute layer, which is a fatal cycle rather than a style preference).
|
|
417
|
+
...derived
|
|
418
|
+
.filter((d): d is TriggerDescriptor => d.kind === 'trigger')
|
|
419
|
+
.map((d) => ({ ...triggerOwnerOf(d), triggers: [d as TriggerSpec] })),
|
|
351
420
|
];
|
|
352
421
|
for (const owner of triggerOwners) {
|
|
353
422
|
for (const t of owner.triggers) {
|
|
@@ -38,7 +38,13 @@ export interface ParsedGrants {
|
|
|
38
38
|
// GRANT SELECT[ ("c1", "c2")] ON <target> TO r1[, r2…]
|
|
39
39
|
// GRANT EXECUTE ON FUNCTION <sig> TO r
|
|
40
40
|
// REVOKE ALL ON FUNCTION <sig> FROM PUBLIC
|
|
41
|
-
|
|
41
|
+
// The privilege slot is a LIST — `GRANT SELECT, INSERT ON x TO y` is one statement, and
|
|
42
|
+
// PostgreSQL's own grammar says so. It used to match a single word, so a multi-privilege
|
|
43
|
+
// grant parsed as no grant at all: the declared contract came back empty and the very next
|
|
44
|
+
// check planned a REVOKE of every privilege the object had just been given. That stayed
|
|
45
|
+
// latent only because the compiler happened to emit one privilege per statement; recording
|
|
46
|
+
// a relation's live ACL (which is naturally multi-privilege) walked straight into it.
|
|
47
|
+
const GRANT_RE = /^GRANT\s+([A-Z]+(?:\s*,\s*[A-Z]+)*)\s*(\([^)]*\))?\s+ON\s+(.+?)\s+TO\s+(.+)$/i;
|
|
42
48
|
const REVOKE_PUBLIC_RE = /^REVOKE\s+ALL\s+ON\s+(.+?)\s+FROM\s+PUBLIC$/i;
|
|
43
49
|
|
|
44
50
|
/**
|
|
@@ -72,14 +78,15 @@ export function parseGrantAttachments(attachments: readonly Attachment[]): Parse
|
|
|
72
78
|
}
|
|
73
79
|
const grant = a.sql.match(GRANT_RE);
|
|
74
80
|
if (!grant) continue;
|
|
75
|
-
const [,
|
|
81
|
+
const [, privilegeList, columns, onTarget, roleList] = grant;
|
|
76
82
|
if (columns) {
|
|
77
83
|
hasColumnGrants = true;
|
|
78
84
|
continue; // attacl territory — declared, applied at create, not drift-checked yet
|
|
79
85
|
}
|
|
80
86
|
target ??= onTarget.trim();
|
|
87
|
+
const privileges = privilegeList.split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
|
|
81
88
|
for (const role of roleList.split(',').map((r) => r.trim()).filter(Boolean)) {
|
|
82
|
-
(grants[role] ??= new Set()).add(privilege
|
|
89
|
+
for (const privilege of privileges) (grants[role] ??= new Set()).add(privilege);
|
|
83
90
|
}
|
|
84
91
|
}
|
|
85
92
|
|