@everystack/cli 0.4.53 → 0.4.56
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 +4 -3
- package/src/cli/authz-contract.ts +155 -3
- package/src/cli/authz-reconcile.ts +146 -1
- package/src/cli/authz-render.ts +26 -4
- package/src/cli/commands/db-build.ts +153 -0
- package/src/cli/commands/db-check.ts +20 -3
- package/src/cli/commands/db-fingerprint.ts +16 -2
- package/src/cli/commands/db-generate.ts +3 -3
- package/src/cli/commands/db-plan.ts +7 -1
- package/src/cli/commands/db-pull.ts +6 -1
- package/src/cli/commands/db-reconcile.ts +41 -2
- package/src/cli/commands/db-sync.ts +5 -2
- package/src/cli/commands/security.ts +24 -3
- package/src/cli/db-build.ts +36 -1
- package/src/cli/declared-derived.ts +11 -0
- package/src/cli/derived-apply.ts +249 -18
- package/src/cli/derived-compile.ts +25 -7
- package/src/cli/derived-grants.ts +96 -3
- package/src/cli/derived-introspect.ts +98 -1
- package/src/cli/derived-lint.ts +56 -0
- package/src/cli/derived-plan.ts +48 -1
- package/src/cli/derived-render.ts +153 -3
- package/src/cli/derived-source.ts +32 -2
- package/src/cli/edge-plan.ts +7 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/migration-generate.ts +24 -1
- package/src/cli/model-render.ts +28 -3
- package/src/cli/schema-compile.ts +75 -12
- package/src/cli/schema-diff.ts +133 -14
- package/src/cli/schema-fingerprint.ts +59 -6
- package/src/cli/schema-introspect.ts +53 -3
- package/src/cli/schema-source.ts +54 -8
- package/src/cli/security-audit.ts +131 -2
- package/src/cli/security-catalog.ts +18 -3
- package/src/cli/state-apply.ts +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.56",
|
|
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.15"
|
|
113
113
|
},
|
|
114
114
|
"peerDependencies": {
|
|
115
115
|
"@everystack/server": ">=0.4.0",
|
|
@@ -164,6 +164,7 @@
|
|
|
164
164
|
"scripts": {
|
|
165
165
|
"test": "jest",
|
|
166
166
|
"build": "tsc --build",
|
|
167
|
-
"lint": "tsc --noEmit"
|
|
167
|
+
"lint": "tsc --noEmit",
|
|
168
|
+
"check:artifact": "tsx scripts/check-generated-artifact.ts"
|
|
168
169
|
}
|
|
169
170
|
}
|
|
@@ -265,6 +265,27 @@ export interface FunctionContract {
|
|
|
265
265
|
* operator. Pinned search_path is necessary but not sufficient.
|
|
266
266
|
*/
|
|
267
267
|
ownerBypassesRls?: boolean;
|
|
268
|
+
/**
|
|
269
|
+
* SUPERUSER (or rds_superuser member) specifically — DIFFED.
|
|
270
|
+
*
|
|
271
|
+
* A deliberately-scoped operator role is commonly BYPASSRLS by design, so
|
|
272
|
+
* `ownerBypassesRls` reads `true` both for that correct design and for a function left
|
|
273
|
+
* owned by whoever ran the build. Identical value, wildly different blast radius: a
|
|
274
|
+
* consumer with 60 correctly-owned SECDEF functions saw the same `true` they would see if
|
|
275
|
+
* every one of them had silently moved to a superuser. This separates the two, and it is
|
|
276
|
+
* portable — no role name is compared.
|
|
277
|
+
*/
|
|
278
|
+
ownerIsSuperuser?: boolean;
|
|
279
|
+
/**
|
|
280
|
+
* The owning role's NAME — RECORDED AND RENDERED, NEVER DIFFED.
|
|
281
|
+
*
|
|
282
|
+
* The name legitimately differs between a dev machine, a deployed master and a normalized
|
|
283
|
+
* operator, so comparing it would false-fire on every environment — that is why it was
|
|
284
|
+
* originally left out entirely. But omitting it also left a reader unable to tell
|
|
285
|
+
* "owned by our scoped operator" from "owned by a superuser" at a glance. It is carried
|
|
286
|
+
* for reading; `ownerIsSuperuser` is what the gate compares.
|
|
287
|
+
*/
|
|
288
|
+
owner?: string;
|
|
268
289
|
}
|
|
269
290
|
|
|
270
291
|
/** The whole contract: every table and every function, as introspected. */
|
|
@@ -280,6 +301,50 @@ export interface AuthzContract {
|
|
|
280
301
|
* that as "unknown", never as "no grants".
|
|
281
302
|
*/
|
|
282
303
|
schemaAcls?: Record<string, Record<string, string[]>>;
|
|
304
|
+
/**
|
|
305
|
+
* The sequences behind serial/identity columns, with their live ACLs.
|
|
306
|
+
*
|
|
307
|
+
* These are INVISIBLE to every other read here: `schema-introspect`'s SEQUENCES_SQL
|
|
308
|
+
* deliberately excludes column-owned sequences (they are an implementation detail of the
|
|
309
|
+
* column, not a declarable object), and GRANTS_SQL covers relkind r/v/m only. So nothing
|
|
310
|
+
* carried their privileges, and a role that could INSERT into a serial-PK table could not
|
|
311
|
+
* draw its sequence value: every INSERT failed `permission denied for sequence`.
|
|
312
|
+
*
|
|
313
|
+
* Absent means NOT MEASURED (older recorder, hand-built fixture) and must never be read as
|
|
314
|
+
* "no sequences" — the emitter skips the whole pass rather than plan revokes against a set
|
|
315
|
+
* it did not read.
|
|
316
|
+
*/
|
|
317
|
+
sequenceGrants?: SequenceGrantContract[];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* One serial/identity sequence and the privileges it actually holds.
|
|
322
|
+
*
|
|
323
|
+
* The sequence is not declared anywhere — there is no `sequence:` key in the model and nobody
|
|
324
|
+
* wants one. Its privileges are DERIVED: they follow the owning table's declared grants. So
|
|
325
|
+
* this carries the live half plus the ownership edge that says which table to derive from.
|
|
326
|
+
*/
|
|
327
|
+
export interface SequenceGrantContract {
|
|
328
|
+
/** `schema.sequence_name`. */
|
|
329
|
+
sequence: string;
|
|
330
|
+
/** `schema.table` of the column that owns it — whose grants this sequence inherits. */
|
|
331
|
+
table: string;
|
|
332
|
+
/** Grantee → sorted privileges (USAGE | SELECT | UPDATE). Owner excluded, as in GRANTS_SQL. */
|
|
333
|
+
grants: Record<string, string[]>;
|
|
334
|
+
/**
|
|
335
|
+
* The owning column is `GENERATED … AS IDENTITY`, not `serial` — and that changes who needs
|
|
336
|
+
* what. A serial column's value comes from a DEFAULT `nextval(...)` evaluated with the
|
|
337
|
+
* CALLER's privileges, so an INSERTing role must hold USAGE/SELECT/UPDATE on the sequence or
|
|
338
|
+
* every insert fails `permission denied for sequence`. An identity column's value is supplied
|
|
339
|
+
* by the system as part of the column definition; no caller privilege is consulted at all.
|
|
340
|
+
*
|
|
341
|
+
* Measured, not assumed: a role with INSERT on the table and NOTHING on the sequence inserts
|
|
342
|
+
* successfully into an identity table and fails on a serial one. So the A6 inheritance rule
|
|
343
|
+
* applies to serial sequences only — see `reconcileSequenceGrants`.
|
|
344
|
+
*
|
|
345
|
+
* Absent on a read that predates this field (treated as serial, the prior behaviour).
|
|
346
|
+
*/
|
|
347
|
+
identity?: boolean;
|
|
283
348
|
}
|
|
284
349
|
|
|
285
350
|
export type PolicyCommand = 'ALL' | 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE';
|
|
@@ -430,6 +495,78 @@ export interface SchemaAclRow {
|
|
|
430
495
|
acl: unknown;
|
|
431
496
|
}
|
|
432
497
|
|
|
498
|
+
/**
|
|
499
|
+
* The sequences behind serial/identity columns, with their owning table and their ACL.
|
|
500
|
+
*
|
|
501
|
+
* `pg_depend` with `deptype IN ('a','i')` and `refobjsubid > 0` is the column-ownership edge —
|
|
502
|
+
* the same predicate `schema-introspect`'s SEQUENCES_SQL uses to EXCLUDE these, inverted. 'a'
|
|
503
|
+
* is a serial's auto dependency, 'i' an identity column's internal one; both are "this sequence
|
|
504
|
+
* belongs to that column", and missing either would silently drop half the sequences.
|
|
505
|
+
*
|
|
506
|
+
* LEFT JOIN LATERAL, not CROSS JOIN: a sequence with a NULL acl (owner-only, the common case
|
|
507
|
+
* before this fix) must still come back as a row. Otherwise "no grants" and "no such sequence"
|
|
508
|
+
* would be the same answer, and the emitter could not tell an ungranted sequence from an
|
|
509
|
+
* unmeasured one. `grantee IS NULL` marks the no-ACL row; a real PUBLIC grant has grantee 0,
|
|
510
|
+
* which `aclexplode` reports and the CASE turns into the literal 'PUBLIC'.
|
|
511
|
+
*/
|
|
512
|
+
export const SEQUENCE_ACLS_SQL = `
|
|
513
|
+
SELECT
|
|
514
|
+
n.nspname || '.' || c.relname AS sequence,
|
|
515
|
+
tn.nspname || '.' || t.relname AS "table",
|
|
516
|
+
CASE WHEN a.grantee IS NULL THEN NULL ELSE COALESCE(r.rolname, 'PUBLIC') END AS grantee,
|
|
517
|
+
a.privilege_type AS privilege,
|
|
518
|
+
-- 'i' = an IDENTITY column's internal dependency, 'a' = a serial's auto one. Carried, not
|
|
519
|
+
-- just filtered on, because the two need OPPOSITE treatment from the inheritance rule.
|
|
520
|
+
(d.deptype = 'i') AS is_identity
|
|
521
|
+
FROM pg_class c
|
|
522
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
523
|
+
JOIN pg_depend d
|
|
524
|
+
ON d.classid = 'pg_class'::regclass AND d.objid = c.oid
|
|
525
|
+
AND d.deptype IN ('a', 'i') AND d.refobjsubid > 0
|
|
526
|
+
JOIN pg_class t ON t.oid = d.refobjid
|
|
527
|
+
JOIN pg_namespace tn ON tn.oid = t.relnamespace
|
|
528
|
+
LEFT JOIN LATERAL aclexplode(c.relacl) a ON a.grantee <> c.relowner
|
|
529
|
+
LEFT JOIN pg_roles r ON r.oid = a.grantee
|
|
530
|
+
WHERE c.relkind = 'S'
|
|
531
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
532
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
533
|
+
AND NOT EXISTS (
|
|
534
|
+
SELECT 1 FROM pg_depend e
|
|
535
|
+
WHERE e.objid = c.oid AND e.deptype = 'e'
|
|
536
|
+
)
|
|
537
|
+
ORDER BY 1, 3, 4;
|
|
538
|
+
`.trim();
|
|
539
|
+
|
|
540
|
+
export interface SequenceAclRow {
|
|
541
|
+
sequence: string;
|
|
542
|
+
table: string;
|
|
543
|
+
/** NULL when the sequence has no ACL at all — the row still exists. */
|
|
544
|
+
grantee: unknown;
|
|
545
|
+
privilege: unknown;
|
|
546
|
+
/** `d.deptype = 'i'` — the owning column is an identity one. Absent on an older read. */
|
|
547
|
+
is_identity?: unknown;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Fold the per-privilege rows into one entry per sequence, grants sorted for stability. */
|
|
551
|
+
export function assembleSequenceGrants(rows: readonly SequenceAclRow[]): SequenceGrantContract[] {
|
|
552
|
+
const bySequence = new Map<string, SequenceGrantContract>();
|
|
553
|
+
for (const row of rows) {
|
|
554
|
+
const entry = bySequence.get(row.sequence)
|
|
555
|
+
?? {
|
|
556
|
+
sequence: row.sequence, table: row.table, grants: {} as Record<string, string[]>,
|
|
557
|
+
// Omitted rather than `false` on a serial sequence, so a contract read before this
|
|
558
|
+
// field existed and one read after compare structurally equal.
|
|
559
|
+
...(truthy(row.is_identity) ? { identity: true } : {}),
|
|
560
|
+
};
|
|
561
|
+
if (row.grantee != null && row.privilege != null) {
|
|
562
|
+
const grantee = String(row.grantee);
|
|
563
|
+
entry.grants[grantee] = [...new Set([...(entry.grants[grantee] ?? []), String(row.privilege)])].sort();
|
|
564
|
+
}
|
|
565
|
+
bySequence.set(row.sequence, entry);
|
|
566
|
+
}
|
|
567
|
+
return [...bySequence.values()].sort((a, b) => a.sequence.localeCompare(b.sequence));
|
|
568
|
+
}
|
|
569
|
+
|
|
433
570
|
/** The two privileges a schema ACL can carry, in aclitem letter form. */
|
|
434
571
|
const SCHEMA_PRIV_LETTERS: Record<string, string> = { U: 'USAGE', C: 'CREATE' };
|
|
435
572
|
|
|
@@ -615,8 +752,10 @@ export interface ContractRows {
|
|
|
615
752
|
columnGrants?: ColumnGrantRow[];
|
|
616
753
|
/** pg_namespace ACL rows (SCHEMA_ACL_SQL) — optional; absent means schema ACLs unknown. */
|
|
617
754
|
schemaAcls?: SchemaAclRow[];
|
|
755
|
+
/** SEQUENCE_ACLS_SQL. Optional — absent means not measured, never "no sequences". */
|
|
756
|
+
sequenceAcls?: SequenceAclRow[];
|
|
618
757
|
/** Already-mapped function descriptors (from security-catalog's FUNCTIONS_SQL). */
|
|
619
|
-
functions: { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean }[];
|
|
758
|
+
functions: { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean; ownerIsSuperuser?: boolean }[];
|
|
620
759
|
}
|
|
621
760
|
|
|
622
761
|
/**
|
|
@@ -673,6 +812,8 @@ export function assembleContract(rows: ContractRows): AuthzContract {
|
|
|
673
812
|
securityDefiner: true,
|
|
674
813
|
hasSearchPath: f.hasSearchPath,
|
|
675
814
|
...(f.ownerBypassesRls !== undefined ? { ownerBypassesRls: f.ownerBypassesRls } : {}),
|
|
815
|
+
...(f.ownerIsSuperuser !== undefined ? { ownerIsSuperuser: f.ownerIsSuperuser } : {}),
|
|
816
|
+
...(f.owner !== undefined ? { owner: f.owner } : {}),
|
|
676
817
|
}))
|
|
677
818
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
678
819
|
|
|
@@ -692,6 +833,8 @@ export function assembleContract(rows: ContractRows): AuthzContract {
|
|
|
692
833
|
tables: [...tables.values()].sort((a, b) => a.table.localeCompare(b.table)),
|
|
693
834
|
functions,
|
|
694
835
|
...(schemaAcls ? { schemaAcls } : {}),
|
|
836
|
+
// Same absent-means-unmeasured contract as schemaAcls above.
|
|
837
|
+
...(rows.sequenceAcls ? { sequenceGrants: assembleSequenceGrants(rows.sequenceAcls) } : {}),
|
|
695
838
|
};
|
|
696
839
|
}
|
|
697
840
|
|
|
@@ -704,8 +847,8 @@ export async function introspectContract(
|
|
|
704
847
|
// ONE session: the six queries describe one moment under one pinned search_path.
|
|
705
848
|
// Policy USING / WITH CHECK expressions deparse relative to that path, so a read spread
|
|
706
849
|
// across connections can report authz drift that does not exist.
|
|
707
|
-
const [rls, policies, grants, columnGrants, schemaAcls, fnRows] = await session(
|
|
708
|
-
[RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, SCHEMA_ACL_SQL, functionsSql],
|
|
850
|
+
const [rls, policies, grants, columnGrants, schemaAcls, sequenceAcls, fnRows] = await session(
|
|
851
|
+
[RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, functionsSql],
|
|
709
852
|
INTROSPECTION_SESSION,
|
|
710
853
|
);
|
|
711
854
|
return assembleContract({
|
|
@@ -714,6 +857,7 @@ export async function introspectContract(
|
|
|
714
857
|
grants: grants as GrantRow[],
|
|
715
858
|
columnGrants: columnGrants as ColumnGrantRow[],
|
|
716
859
|
schemaAcls: schemaAcls as SchemaAclRow[],
|
|
860
|
+
sequenceAcls: sequenceAcls as SequenceAclRow[],
|
|
717
861
|
functions: (fnRows as any[]).map(mapFunctionRow),
|
|
718
862
|
});
|
|
719
863
|
}
|
|
@@ -814,6 +958,14 @@ export function compareContracts(declared: AuthzContract, live: AuthzContract):
|
|
|
814
958
|
if (d.ownerBypassesRls !== undefined && l.ownerBypassesRls !== undefined && d.ownerBypassesRls !== l.ownerBypassesRls) {
|
|
815
959
|
findings.push({ subject: `fn:${name}`, kind: 'function', detail: `owner bypasses RLS: declared ${d.ownerBypassesRls}, live ${l.ownerBypassesRls}` });
|
|
816
960
|
}
|
|
961
|
+
// The name is recorded but NEVER compared — it legitimately differs between a dev
|
|
962
|
+
// machine, a deployed master and a normalized operator, and diffing it would false-fire
|
|
963
|
+
// on every environment. This boolean is the portable half, and it is the one that
|
|
964
|
+
// separates a deliberately-scoped BYPASSRLS operator from a superuser: both read `true`
|
|
965
|
+
// for ownerBypassesRls, and only one of them is a catastrophe.
|
|
966
|
+
if (d.ownerIsSuperuser !== undefined && l.ownerIsSuperuser !== undefined && d.ownerIsSuperuser !== l.ownerIsSuperuser) {
|
|
967
|
+
findings.push({ subject: `fn:${name}`, kind: 'function', detail: `owner is SUPERUSER: declared ${d.ownerIsSuperuser}, live ${l.ownerIsSuperuser}${l.ownerIsSuperuser ? ` (live owner '${l.owner ?? 'unknown'}') — a SECURITY DEFINER function owned by a superuser runs above RLS as that superuser on every call` : ''}` });
|
|
968
|
+
}
|
|
817
969
|
}
|
|
818
970
|
for (const name of lFns.keys()) {
|
|
819
971
|
if (!dFns.has(name)) {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* this layer reconciles without a migration file (unlike the data layer).
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
|
|
18
|
+
import type { AuthzContract, TableContract, PolicyContract, SequenceGrantContract } from './authz-contract.js';
|
|
19
19
|
import { effectivePolicyCheck, parenthesizeOnce, isPolicyDead, isPolicySubsumed } from './authz-contract.js';
|
|
20
20
|
import { matchPolicies } from './authz-identity.js';
|
|
21
21
|
import { quoteQualified } from './pg-ident.js';
|
|
@@ -118,6 +118,58 @@ export function renderGrantExemptions(exemptions: readonly GrantExemption[]): st
|
|
|
118
118
|
return lines;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/** A grantee holding sequence privileges its table grants do not justify. */
|
|
122
|
+
export interface VestigialSequenceGrant {
|
|
123
|
+
grantee: string;
|
|
124
|
+
/** The sequences it holds privileges on while holding no INSERT on their tables. */
|
|
125
|
+
sequences: string[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Sequence grants with no corresponding INSERT — an ADVISORY, never a revoke.
|
|
130
|
+
*
|
|
131
|
+
* The shape a brownfield database arrives in: a blanket `GRANT USAGE ON ALL SEQUENCES IN
|
|
132
|
+
* SCHEMA public` sitting beside a blanket `GRANT SELECT ON ALL TABLES`, years old, granted
|
|
133
|
+
* to a role that can INSERT into nothing. One consumer had exactly this — `authenticated`
|
|
134
|
+
* holding USAGE on all 27 sequences with INSERT on zero tables — and it was the single
|
|
135
|
+
* divergence between their database and the inheritance rule.
|
|
136
|
+
*
|
|
137
|
+
* Reported rather than revoked, and the distinction is the whole point: the inheritance rule
|
|
138
|
+
* only ever ADDS what an INSERT needs, so a vestigial grant is untouched by reconciliation and
|
|
139
|
+
* would otherwise sit there unnoticed forever. Naming it lets the owner decide. Revoking it
|
|
140
|
+
* would be the tool making a security decision from an inference — the same move we refused for
|
|
141
|
+
* the invented search_path.
|
|
142
|
+
*/
|
|
143
|
+
export function vestigialSequenceGrants(live: AuthzContract): VestigialSequenceGrant[] {
|
|
144
|
+
if (!live.sequenceGrants) return []; // not measured
|
|
145
|
+
const insertsFor = new Map<string, Set<string>>();
|
|
146
|
+
for (const t of live.tables) {
|
|
147
|
+
for (const [grantee, privileges] of Object.entries(t.grants)) {
|
|
148
|
+
if (privileges.includes('INSERT')) {
|
|
149
|
+
(insertsFor.get(grantee) ?? insertsFor.set(grantee, new Set()).get(grantee)!).add(t.table);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const byGrantee = new Map<string, string[]>();
|
|
154
|
+
for (const seq of live.sequenceGrants) {
|
|
155
|
+
for (const grantee of Object.keys(seq.grants)) {
|
|
156
|
+
if (insertsFor.get(grantee)?.has(seq.table)) continue;
|
|
157
|
+
byGrantee.set(grantee, [...(byGrantee.get(grantee) ?? []), seq.sequence]);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return [...byGrantee.entries()]
|
|
161
|
+
.map(([grantee, sequences]) => ({ grantee, sequences: sequences.sort() }))
|
|
162
|
+
.sort((a, b) => a.grantee.localeCompare(b.grantee));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** One line per grantee — the shared rendering, same idiom as renderGrantExemptions. */
|
|
166
|
+
export function renderVestigialSequenceGrants(findings: readonly VestigialSequenceGrant[]): string[] {
|
|
167
|
+
return findings.map(({ grantee, sequences }) =>
|
|
168
|
+
`${grantee} holds privileges on ${sequences.length} sequence(s) but can INSERT into none of their tables — `
|
|
169
|
+
+ `probably a vestigial blanket GRANT ... ON ALL SEQUENCES. Nothing revokes it (the inheritance rule only adds); `
|
|
170
|
+
+ `review and revoke by hand if it is dead: ${sequences.slice(0, 6).join(', ')}${sequences.length > 6 ? `, +${sequences.length - 6} more` : ''}.`);
|
|
171
|
+
}
|
|
172
|
+
|
|
121
173
|
/** A USING/WITH CHECK clause expression, parenthesized exactly once. */
|
|
122
174
|
const clause = parenthesizeOnce;
|
|
123
175
|
|
|
@@ -144,6 +196,91 @@ function tableMap(c: AuthzContract): Map<string, TableContract> {
|
|
|
144
196
|
return new Map(c.tables.map((t) => [t.table, t]));
|
|
145
197
|
}
|
|
146
198
|
|
|
199
|
+
/**
|
|
200
|
+
* A6 — the privileges a SERIAL sequence INHERITS from its table's declared grants.
|
|
201
|
+
*
|
|
202
|
+
* SERIAL only. An identity column's sequence is exempt and is skipped before this is
|
|
203
|
+
* consulted — see `reconcileSequenceGrants` for the measurement and why it matters.
|
|
204
|
+
*
|
|
205
|
+
* A role that may INSERT into a table but cannot draw its sequence value is not a state anyone
|
|
206
|
+
* declares deliberately; it is a state that happens when nobody carries the privilege across.
|
|
207
|
+
* Measured on a consumer's database: every INSERT into a serial-PK table failed
|
|
208
|
+
* `permission denied for sequence`, and two of their 563 tests were exactly that.
|
|
209
|
+
*
|
|
210
|
+
* The rule is INSERT on the table → USAGE + SELECT + UPDATE on its sequence, and all three
|
|
211
|
+
* matter. USAGE alone permits `nextval`, but `setval` needs UPDATE and reading `last_value`
|
|
212
|
+
* needs SELECT, so the common `rU` shape breaks the moment anything reseeds the counter.
|
|
213
|
+
*
|
|
214
|
+
* Signed off after measurement, not assumed: the consumer checked every role against every
|
|
215
|
+
* serial/identity sequence and found zero cases of INSERT-without-sequence-USAGE and zero of
|
|
216
|
+
* USAGE-without-UPDATE, so this under-grants nobody. Their one divergence — `authenticated`
|
|
217
|
+
* holding USAGE on all 27 sequences while able to INSERT into none — was a vestigial 2023
|
|
218
|
+
* blanket grant they are revoking, so their database converges to this rule rather than the
|
|
219
|
+
* reverse. The pull ADVISORY names that shape wherever it appears.
|
|
220
|
+
*
|
|
221
|
+
* Derived, never declared: there is no `sequence:` key in the model and nobody wants one.
|
|
222
|
+
*/
|
|
223
|
+
export const SEQUENCE_PRIVILEGES_FOR_INSERT = ['SELECT', 'UPDATE', 'USAGE'] as const;
|
|
224
|
+
|
|
225
|
+
export function inheritedSequenceGrants(table: TableContract): Record<string, string[]> {
|
|
226
|
+
const out: Record<string, string[]> = {};
|
|
227
|
+
for (const [grantee, privileges] of Object.entries(table.grants)) {
|
|
228
|
+
if (privileges.includes('INSERT')) out[grantee] = [...SEQUENCE_PRIVILEGES_FOR_INSERT];
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Reconcile one table's SERIAL sequences against the grants they inherit. Identity sequences
|
|
235
|
+
* are read the same way but exempt from the rule — the skip below says why.
|
|
236
|
+
*
|
|
237
|
+
* Same per-grantee shape as `reconcileGrants`, and the same ungoverned-role exemption: a live
|
|
238
|
+
* grantee the declared authz does not govern is LEFT ALONE, because "no declaration" and "no
|
|
239
|
+
* privileges" are identical to a set difference, and treating them alike is what produced 32
|
|
240
|
+
* revokes against a brownfield adopter's migration role.
|
|
241
|
+
*
|
|
242
|
+
* The live side must have been READ for this to run at all. `sequenceGrants` absent means the
|
|
243
|
+
* recorder never looked, and planning revokes against a set nobody read is how a phantom
|
|
244
|
+
* statement gets minted on every run forever — a bug class that has already bitten this
|
|
245
|
+
* codebase twice.
|
|
246
|
+
*/
|
|
247
|
+
function reconcileSequenceGrants(
|
|
248
|
+
d: TableContract,
|
|
249
|
+
liveSequences: readonly SequenceGrantContract[],
|
|
250
|
+
out: string[],
|
|
251
|
+
governed: ReadonlySet<string>,
|
|
252
|
+
): void {
|
|
253
|
+
const declared = inheritedSequenceGrants(d);
|
|
254
|
+
for (const seq of liveSequences) {
|
|
255
|
+
// An IDENTITY sequence inherits NOTHING, and is neither granted nor revoked here.
|
|
256
|
+
//
|
|
257
|
+
// The A6 rule exists because a serial column's value comes from a DEFAULT `nextval(...)`
|
|
258
|
+
// that Postgres evaluates with the CALLER's privileges — no sequence grant, no INSERT. An
|
|
259
|
+
// identity column's value is supplied by the system as part of the column definition and
|
|
260
|
+
// consults no caller privilege at all. Measured: a role holding INSERT on the table and
|
|
261
|
+
// NOTHING on the sequence inserts fine into an identity table and fails on a serial one.
|
|
262
|
+
//
|
|
263
|
+
// Applying the rule here was a real WIDENING, caught by the round-trip oracle the day
|
|
264
|
+
// identity columns became declarable: the rebuild handed an INSERTing role SELECT + UPDATE
|
|
265
|
+
// + USAGE — direct `setval` control of a surrogate key — that the original database never
|
|
266
|
+
// granted and never needed. Nothing is revoked either: a grant somebody made deliberately
|
|
267
|
+
// is not ours to remove, and no declaration says it should not be there.
|
|
268
|
+
if (seq.identity) continue;
|
|
269
|
+
const target = quoteQualified(seq.sequence);
|
|
270
|
+
const grantees = new Set([...Object.keys(declared), ...Object.keys(seq.grants)]);
|
|
271
|
+
for (const grantee of [...grantees].sort()) {
|
|
272
|
+
if (!isGoverned(governed, grantee)) continue;
|
|
273
|
+
const want = new Set(declared[grantee] ?? []);
|
|
274
|
+
const have = new Set(seq.grants[grantee] ?? []);
|
|
275
|
+
const role = grantee.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : grantee;
|
|
276
|
+
const toRevoke = [...have].filter((p) => !want.has(p)).sort();
|
|
277
|
+
const toGrant = [...want].filter((p) => !have.has(p)).sort();
|
|
278
|
+
if (toRevoke.length) out.push(`REVOKE ${toRevoke.join(', ')} ON SEQUENCE ${target} FROM ${role};`);
|
|
279
|
+
if (toGrant.length) out.push(`GRANT ${toGrant.join(', ')} ON SEQUENCE ${target} TO ${role};`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
147
284
|
/**
|
|
148
285
|
* Emit the ordered SQL that transforms the live database into the declared
|
|
149
286
|
* contract. Empty when they already match (the reconcile no-op). Order per table:
|
|
@@ -162,6 +299,12 @@ export function emitReconcileSql(
|
|
|
162
299
|
// that passes nothing gets the safe-for-greenfield behaviour; a brownfield caller widens
|
|
163
300
|
// it with the modules' declared governedRoles.
|
|
164
301
|
const governed = opts.governedRoles ?? governedRoleSet(declared);
|
|
302
|
+
// Absent = the recorder never read sequences; skip the pass entirely rather than diff
|
|
303
|
+
// against a set nobody measured. Grouped once, not re-scanned per table.
|
|
304
|
+
const sequencesByTable = new Map<string, SequenceGrantContract[]>();
|
|
305
|
+
for (const seq of live.sequenceGrants ?? []) {
|
|
306
|
+
(sequencesByTable.get(seq.table) ?? sequencesByTable.set(seq.table, []).get(seq.table)!).push(seq);
|
|
307
|
+
}
|
|
165
308
|
|
|
166
309
|
for (const d of declared.tables) {
|
|
167
310
|
const l = lTables.get(d.table);
|
|
@@ -172,6 +315,8 @@ export function emitReconcileSql(
|
|
|
172
315
|
reconcilePolicies(table, d, l, sql, governed);
|
|
173
316
|
reconcileGrants(table, d, l, sql, governed);
|
|
174
317
|
reconcileColumnGrants(table, d, l, sql, governed);
|
|
318
|
+
// A6: the sequences behind this table's SERIAL columns inherit its grants (identity ones do not).
|
|
319
|
+
reconcileSequenceGrants(d, sequencesByTable.get(d.table) ?? [], sql, governed);
|
|
175
320
|
}
|
|
176
321
|
|
|
177
322
|
return sql;
|
package/src/cli/authz-render.ts
CHANGED
|
@@ -231,15 +231,37 @@ export function renderContractMarkdown(contract: AuthzContract): string {
|
|
|
231
231
|
lines.push('');
|
|
232
232
|
lines.push('These run with their owner\'s privileges, **above row security** — their own bodies enforce access. Review each one deliberately. A missing `search_path` is a hijack vector; an owner that bypasses RLS (superuser / BYPASSRLS) makes the function run above RLS as that role — the maximum blast radius. `db:migrate` often leaves functions superuser-owned; normalize ownership to a non-superuser operator.');
|
|
233
233
|
lines.push('');
|
|
234
|
-
|
|
234
|
+
// A correct design lights this whole report up: a deliberately-scoped operator is
|
|
235
|
+
// usually BYPASSRLS, so every SECDEF function it owns is "elevated". Splitting the
|
|
236
|
+
// superuser case out is what lets a reader tell correct from catastrophic at a glance —
|
|
237
|
+
// a consumer with 60 correctly-owned functions was reading the same warning they would
|
|
238
|
+
// have seen if all 60 had silently moved to a superuser.
|
|
239
|
+
// Three states, not two. `ownerIsSuperuser === undefined` means the contract predates
|
|
240
|
+
// the field — we do NOT know, and must not claim "non-superuser" for it.
|
|
241
|
+
const superuserOwned = contract.functions.filter((f) => f.ownerIsSuperuser === true);
|
|
242
|
+
const elevated = contract.functions.filter((f) => f.ownerBypassesRls && f.ownerIsSuperuser === false);
|
|
243
|
+
const unknownOwner = contract.functions.filter((f) => f.ownerBypassesRls && f.ownerIsSuperuser === undefined);
|
|
244
|
+
if (unknownOwner.length) {
|
|
245
|
+
lines.push(`> **⚠ ${unknownOwner.length} function(s) are owned by a role that bypasses RLS** — they run above row security regardless of \`search_path\`. Re-run \`db:authz:pull\` to record whether the owner is a SUPERUSER, which is the difference between a scoped operator and the maximum blast radius.`);
|
|
246
|
+
lines.push('');
|
|
247
|
+
}
|
|
248
|
+
if (superuserOwned.length) {
|
|
249
|
+
lines.push(`> **🛑 ${superuserOwned.length} function(s) are owned by a SUPERUSER** — each runs above row security as that superuser on every call. This is the maximum blast radius and is almost never intended; it is what \`db:migrate\` and a models-only build both leave behind by default. Normalize ownership to a scoped operator.`);
|
|
250
|
+
lines.push('');
|
|
251
|
+
}
|
|
235
252
|
if (elevated.length) {
|
|
236
|
-
lines.push(`> **⚠ ${elevated.length} function(s) are owned by a role that bypasses RLS
|
|
253
|
+
lines.push(`> **⚠ ${elevated.length} function(s) are owned by a non-superuser role that bypasses RLS.** If that role is your intended scoped operator, this is expected and the bodies are what enforce access. Confirm the owner below is the role you meant.`);
|
|
237
254
|
lines.push('');
|
|
238
255
|
}
|
|
239
256
|
for (const f of contract.functions) {
|
|
240
257
|
const sp = f.hasSearchPath ? 'search_path pinned ✓' : '**search_path NOT pinned ⚠**';
|
|
241
|
-
const
|
|
242
|
-
|
|
258
|
+
const owner = f.owner ? ` · owner \`${f.owner}\`` : '';
|
|
259
|
+
const own = f.ownerIsSuperuser === true
|
|
260
|
+
? ' · **owned by SUPERUSER 🛑**'
|
|
261
|
+
: f.ownerBypassesRls
|
|
262
|
+
? (f.ownerIsSuperuser === false ? ' · owner bypasses RLS' : ' · **owner bypasses RLS ⚠**')
|
|
263
|
+
: '';
|
|
264
|
+
lines.push(`- \`${f.name}\` — ${sp}${owner}${own}`);
|
|
243
265
|
}
|
|
244
266
|
lines.push('');
|
|
245
267
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack db:build --database-url <url>` — build a database FROM the models, and KEEP it.
|
|
3
|
+
*
|
|
4
|
+
* db:build --database-url postgres://…/mydb [--models db/models]
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS. Deleting a migration folder is only safe once the models alone can rebuild
|
|
7
|
+
* an identical database — local dev, the test DB, a new stage. Every other verb failed that:
|
|
8
|
+
* `db:check` proved buildability on an ephemeral database and then DROPPED it, while
|
|
9
|
+
* `db:sync` / `db:generate --apply` run the DIFF builder, which has no bootstrap phase and
|
|
10
|
+
* cannot start from nothing. A consumer tested our own documentation sentence ("a fresh
|
|
11
|
+
* database is db:check's compose") and found no verb behind it. This is that verb.
|
|
12
|
+
*
|
|
13
|
+
* It is deliberately THIN: `buildIntoDatabase` is the same core `db:check`'s compose, the dev
|
|
14
|
+
* template and `createEphemeralDatabase` already run — extensions, schemas, contract roles,
|
|
15
|
+
* declared functions before the policies that call them, then state and the derived layer.
|
|
16
|
+
* A second implementation of that ordering would be a second thing to keep true.
|
|
17
|
+
*
|
|
18
|
+
* The venue is EXPLICIT (`--database-url` only, never the ambient env, same rule as db:swap
|
|
19
|
+
* and db:export): this command writes a whole schema, so the target must be named on the
|
|
20
|
+
* command line and never inherited from a shell that happens to point at production.
|
|
21
|
+
*
|
|
22
|
+
* It REFUSES a database that already holds declared objects. Building into an existing
|
|
23
|
+
* database is `db:sync`'s job (dev) or `db:plan`/`db:apply`'s (a stage); a verb that silently
|
|
24
|
+
* did both is how a populated database gets clobbered by a command whose name says "build".
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
28
|
+
import { buildIntoDatabase } from '../db-build.js';
|
|
29
|
+
import { createUrlRunner } from '../db-source.js';
|
|
30
|
+
import { resolveModelsPath } from '../models-path.js';
|
|
31
|
+
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
|
|
32
|
+
import { loadModels } from './db-generate.js';
|
|
33
|
+
import { currentGitRef } from '../state-apply.js';
|
|
34
|
+
import { step, info, success, fail, warn } from '../output.js';
|
|
35
|
+
|
|
36
|
+
/** Tables already in the target, outside the schemas PostgreSQL owns. */
|
|
37
|
+
const OCCUPANCY_SQL = `
|
|
38
|
+
SELECT n.nspname || '.' || c.relname AS identity
|
|
39
|
+
FROM pg_class c
|
|
40
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
41
|
+
WHERE c.relkind IN ('r', 'v', 'm')
|
|
42
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
43
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
44
|
+
ORDER BY 1
|
|
45
|
+
LIMIT 20;
|
|
46
|
+
`.trim();
|
|
47
|
+
|
|
48
|
+
export async function dbBuildCommand(flags: Record<string, string>): Promise<void> {
|
|
49
|
+
// Flag-only venue. The env is never consulted: `db:build` writes a whole schema, and an
|
|
50
|
+
// ambient DATABASE_URL must not be able to choose which database that happens to.
|
|
51
|
+
const url = flags['database-url'];
|
|
52
|
+
if (!url || url === 'true') {
|
|
53
|
+
fail('db:build needs --database-url <url> — the venue is explicit by design (the ambient environment never picks the target for a command that writes a whole schema).');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
if (flags.stage) {
|
|
57
|
+
fail('db:build has no --stage lane: building a fresh database is a local/dev operation. Evolve a deployed stage with db:plan → db:apply.');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const modelsPath = resolveModelsPath(flags.models);
|
|
62
|
+
let models: ModelDescriptor[];
|
|
63
|
+
try {
|
|
64
|
+
step(`Loading models from ${modelsPath}...`);
|
|
65
|
+
models = await loadModels(modelsPath);
|
|
66
|
+
info(`${models.length} model(s).`);
|
|
67
|
+
} catch (err: any) {
|
|
68
|
+
fail(err.message);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
if (flags['sql-dir']) {
|
|
74
|
+
fail(await retiredSqlDirFlagRefusal(flags['sql-dir']));
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
const retired = await retiredSqlDirAnywhere(flags.models);
|
|
78
|
+
if (retired) {
|
|
79
|
+
fail(retired);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
} catch (err: any) {
|
|
83
|
+
fail(err.message);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The modules carry what `models` alone cannot: the derived layer, standalone sequences,
|
|
88
|
+
// and the extensions whose types the columns are declared in.
|
|
89
|
+
let declaredDb: DeclaredDerived | null = null;
|
|
90
|
+
try {
|
|
91
|
+
declaredDb = await loadDeclaredDerived(flags.models);
|
|
92
|
+
} catch (err: any) {
|
|
93
|
+
fail(err.message);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Refuse a target that already holds objects — see the header. Read on its own connection,
|
|
98
|
+
// closed before the build opens its own, so no session outlives the check it performed.
|
|
99
|
+
try {
|
|
100
|
+
const { runner, end } = await createUrlRunner(url);
|
|
101
|
+
let occupied: string[];
|
|
102
|
+
try {
|
|
103
|
+
occupied = (await runner(OCCUPANCY_SQL)).map((r: any) => String(r.identity));
|
|
104
|
+
} finally {
|
|
105
|
+
await end?.();
|
|
106
|
+
}
|
|
107
|
+
if (occupied.length > 0) {
|
|
108
|
+
fail(
|
|
109
|
+
`db:build refuses a database that already holds objects — it found ${occupied.length}: ${occupied.slice(0, 8).join(', ')}${occupied.length > 8 ? ', …' : ''}.\n`
|
|
110
|
+
+ ` This verb builds a FRESH database from the models. To evolve an existing one: db:sync (a dev database) or db:plan → db:apply (a stage).`,
|
|
111
|
+
);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
} catch (err: any) {
|
|
115
|
+
fail(`Could not read the target: ${err.message}`);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
step('Building the declared state (extensions → schemas → roles → functions → state → derived)...');
|
|
120
|
+
let built;
|
|
121
|
+
try {
|
|
122
|
+
built = await buildIntoDatabase(url, models, {
|
|
123
|
+
declared: declaredDb?.objects,
|
|
124
|
+
sequences: declaredDb?.sequences,
|
|
125
|
+
extensions: declaredDb?.extensions,
|
|
126
|
+
actor: process.env.USER ?? null,
|
|
127
|
+
gitRef: currentGitRef(),
|
|
128
|
+
});
|
|
129
|
+
} catch (err: any) {
|
|
130
|
+
fail(`Build failed: ${err.message}`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const line of built.report) info(line);
|
|
135
|
+
if (built.createdRoles.length) {
|
|
136
|
+
info(`Created ${built.createdRoles.length} contract role(s) (NOLOGIN, cluster-level): ${built.createdRoles.join(', ')}.`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The bar is the same one db:check reports, and it is stated as a fact or not at all:
|
|
140
|
+
// a database that did not land on the models' fingerprint is not the declared state,
|
|
141
|
+
// however few statements were left over.
|
|
142
|
+
if (!built.converged || !built.fingerprintMatch) {
|
|
143
|
+
fail(
|
|
144
|
+
`The database was built but does NOT match the declared state (fingerprint ${built.fingerprint.slice(0, 12)}). `
|
|
145
|
+
+ `It is left in place for inspection — run db:generate --dry-run against it to see what differs.`,
|
|
146
|
+
);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
success(`Built and kept — the database IS the declared state at ${built.fingerprint.slice(0, 12)}.`);
|
|
151
|
+
warn('Roles are cluster-level: a role this build created is visible to every database in the cluster.');
|
|
152
|
+
info('Verify independently: everystack db:fingerprint --database-url <url> (expect MATCH), db:reconcile --check.');
|
|
153
|
+
}
|