@everystack/cli 0.4.55 → 0.4.57
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-derive.ts +46 -5
- package/src/cli/authz-reconcile.ts +146 -1
- package/src/cli/authz-render.ts +26 -4
- package/src/cli/commands/db-apply.ts +11 -0
- package/src/cli/commands/db-check.ts +4 -1
- package/src/cli/commands/db-fingerprint.ts +16 -2
- package/src/cli/commands/db-plan.ts +20 -3
- package/src/cli/commands/db-pull.ts +6 -1
- package/src/cli/commands/db-reconcile.ts +41 -2
- package/src/cli/commands/security.ts +24 -3
- package/src/cli/db-build.ts +36 -1
- 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/git-descent.ts +91 -19
- package/src/cli/migration-generate.ts +3 -0
- package/src/cli/model-api.ts +24 -0
- package/src/cli/model-render.ts +100 -11
- package/src/cli/schema-compile.ts +66 -9
- package/src/cli/schema-diff.ts +133 -14
- package/src/cli/schema-fingerprint.ts +58 -5
- package/src/cli/schema-introspect.ts +40 -0
- package/src/cli/schema-source.ts +19 -1
- package/src/cli/security-audit.ts +131 -2
- package/src/cli/security-catalog.ts +18 -3
- package/src/cli/state-apply.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.57",
|
|
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.16"
|
|
113
113
|
},
|
|
114
114
|
"peerDependencies": {
|
|
115
115
|
"@everystack/server": ">=0.4.0",
|
|
@@ -159,7 +159,8 @@
|
|
|
159
159
|
"drizzle-orm": "0.41.0",
|
|
160
160
|
"jest": "29.7.0",
|
|
161
161
|
"react": "19.2.0",
|
|
162
|
-
"ts-jest": "29.4.9"
|
|
162
|
+
"ts-jest": "29.4.9",
|
|
163
|
+
"@everystack/server": "0.4.20"
|
|
163
164
|
},
|
|
164
165
|
"scripts": {
|
|
165
166
|
"test": "jest",
|
|
@@ -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)) {
|
package/src/cli/authz-derive.ts
CHANGED
|
@@ -260,6 +260,44 @@ function effective(contract: TableContract, role: string, priv: string): boolean
|
|
|
260
260
|
return policiesFor(contract, role, priv).some((p) => p.permissive);
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
/** The four CRUD privileges, in the order the deriver reports them. */
|
|
264
|
+
const CRUD_PRIVS = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] as const;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Classify every (role, privilege) surface ONCE: may pull render it as an ABILITY, or must it
|
|
268
|
+
* be transcribed as a grant?
|
|
269
|
+
*
|
|
270
|
+
* A DIFFERENT question from {@link effective}, and conflating them is what broke the RLS-off
|
|
271
|
+
* shape. `effective` answers "does this grant reach rows?" — with RLS off the honest answer is
|
|
272
|
+
* YES, unrestricted. But the RENDERER is asking whether it may say `can(...)`, and an ability
|
|
273
|
+
* compiles to a policy. On a table with row security disabled, rendering one makes the model
|
|
274
|
+
* propose `ALTER TABLE … ENABLE ROW LEVEL SECURITY` plus `CREATE POLICY …` against a database
|
|
275
|
+
* that has neither, and REVOKE whatever privilege the ability has no room for — three changes
|
|
276
|
+
* emitted by a command that only READ the database. That is the G1 class (a manufactured
|
|
277
|
+
* policy) reached from the adoption side, and the round-trip gate refuses it.
|
|
278
|
+
*
|
|
279
|
+
* So with RLS OFF nothing is an ability; the grants are still declared, through `privileges`,
|
|
280
|
+
* which is precisely what the governed-role path already did. That asymmetry — foreign roles
|
|
281
|
+
* transcribed, vocabulary roles turned into abilities — WAS the bug; there is one rule now.
|
|
282
|
+
*
|
|
283
|
+
* Computed as one pass returning a tag per surface, rather than a predicate each site calls,
|
|
284
|
+
* so a future ability-emitting site cannot quietly skip the question (Fable, 2026-08-11).
|
|
285
|
+
*
|
|
286
|
+
* Lives here, in the pull/derive direction only. The COMPILER must never consult it: compile
|
|
287
|
+
* turns declarations into SQL and has no business knowing how they were rendered.
|
|
288
|
+
*/
|
|
289
|
+
function classifyAbilitySurfaces(contract: TableContract): ReadonlySet<string> {
|
|
290
|
+
const surfaces = new Set<string>();
|
|
291
|
+
// Grants-only table: every surface is a grant, none is an ability.
|
|
292
|
+
if (!contract.rls.enabled) return surfaces;
|
|
293
|
+
for (const role of Object.keys(contract.grants)) {
|
|
294
|
+
for (const priv of CRUD_PRIVS) {
|
|
295
|
+
if (effective(contract, role, priv)) surfaces.add(`${role}:${priv}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return surfaces;
|
|
299
|
+
}
|
|
300
|
+
|
|
263
301
|
/** A sql`…` fragment literal, or null when the predicate is vacuous. */
|
|
264
302
|
function sqlFragment(pred: string | null): string | null {
|
|
265
303
|
if (!pred) return null;
|
|
@@ -339,6 +377,9 @@ export function deriveAbilities(
|
|
|
339
377
|
opts: { governRoles?: ReadonlySet<string> } = {},
|
|
340
378
|
): DerivedAbilities {
|
|
341
379
|
const govern = opts.governRoles ?? new Set<string>();
|
|
380
|
+
/** One classification pass; every ability-vs-grant decision below reads this tag. */
|
|
381
|
+
const abilitySurfaces = classifyAbilitySurfaces(contract);
|
|
382
|
+
const isAbilitySurface = (role: string, priv: string): boolean => abilitySurfaces.has(`${role}:${priv}`);
|
|
342
383
|
const abilities: string[] = [];
|
|
343
384
|
const notes: string[] = [];
|
|
344
385
|
const table = contract.table;
|
|
@@ -365,7 +406,7 @@ export function deriveAbilities(
|
|
|
365
406
|
// non-owner, and RLS binds non-owners whenever `enabled` is true. `forced` is irrelevant here.
|
|
366
407
|
const CRUD = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'];
|
|
367
408
|
const adminGrants = CRUD.filter((p) => granted(contract, 'admin', p));
|
|
368
|
-
const adminLive = adminGrants.filter((p) =>
|
|
409
|
+
const adminLive = adminGrants.filter((p) => isAbilitySurface('admin', p));
|
|
369
410
|
const adminDead = adminGrants.filter((p) => !adminLive.includes(p));
|
|
370
411
|
|
|
371
412
|
if (adminLive.length === CRUD.length) {
|
|
@@ -402,7 +443,7 @@ export function deriveAbilities(
|
|
|
402
443
|
// declared (so the plan does not REVOKE a live privilege) and never rendered as an ability
|
|
403
444
|
// (so the plan does not CREATE the policy the database is missing).
|
|
404
445
|
for (const role of ['authenticated', 'anon']) {
|
|
405
|
-
const dead = CRUD.filter((p) => granted(contract, role, p) && !
|
|
446
|
+
const dead = CRUD.filter((p) => granted(contract, role, p) && !isAbilitySurface(role, p));
|
|
406
447
|
if (!dead.length) continue;
|
|
407
448
|
deadGrants[role] = dead;
|
|
408
449
|
notes.push(`${role} holds ${dead.join(', ')} but no policy covers ${role} —`);
|
|
@@ -444,8 +485,8 @@ export function deriveAbilities(
|
|
|
444
485
|
// `effective`, not `granted`: a SELECT grant with no policy behind it renders can('read'),
|
|
445
486
|
// which compiles to CREATE POLICY … USING (true) — manufacturing a policy the database does
|
|
446
487
|
// not have. Same defect as the admin branch above; the round-trip gate found it here.
|
|
447
|
-
const anonRead =
|
|
448
|
-
const authedRead =
|
|
488
|
+
const anonRead = isAbilitySurface('anon', 'SELECT');
|
|
489
|
+
const authedRead = isAbilitySurface('authenticated', 'SELECT');
|
|
449
490
|
|
|
450
491
|
if (anonRead) {
|
|
451
492
|
// Public read. If the live policy narrows it (a soft-delete guard, a published flag),
|
|
@@ -569,7 +610,7 @@ export function deriveAbilities(
|
|
|
569
610
|
// --- writes --------------------------------------------------------------------------
|
|
570
611
|
for (const [command, action] of Object.entries(DML)) {
|
|
571
612
|
if (action === 'read') continue;
|
|
572
|
-
if (!
|
|
613
|
+
if (!isAbilitySurface('authenticated', command)) continue;
|
|
573
614
|
|
|
574
615
|
const pol = policiesFor(contract, 'authenticated', command).find((p) => p.command !== 'ALL')
|
|
575
616
|
?? policiesFor(contract, 'authenticated', command)[0];
|
|
@@ -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
|
}
|
|
@@ -75,6 +75,10 @@ export function descentVerdictForApply(verdict: DescentVerdict): { ok: true } |
|
|
|
75
75
|
return { ok: true };
|
|
76
76
|
case 'diverged':
|
|
77
77
|
case 'drift':
|
|
78
|
+
// `indeterminate` refuses like the others, and must: the search was not exhaustive, so
|
|
79
|
+
// the fast-forward rule is unproven. Its REASON is the difference — it points at this
|
|
80
|
+
// run, not at the database. Never fold it into `drift`.
|
|
81
|
+
case 'indeterminate':
|
|
78
82
|
return { ok: false, reason: verdict.reason };
|
|
79
83
|
}
|
|
80
84
|
}
|
|
@@ -178,6 +182,12 @@ async function applyPlanViaStage(
|
|
|
178
182
|
case 'diverged':
|
|
179
183
|
case 'drift':
|
|
180
184
|
break; // the reason travels to the action's refusal + the memoir
|
|
185
|
+
case 'indeterminate':
|
|
186
|
+
// Said out loud here because the operator's next move is "run it again", not
|
|
187
|
+
// "reconcile your models" — and a refusal that reads like drift sends them the
|
|
188
|
+
// wrong way. The reason still travels to the refusal.
|
|
189
|
+
warn(`descent: ${verdict.unevaluated.length} historical tree(s) could not be extracted — the search was not exhaustive.`);
|
|
190
|
+
break;
|
|
181
191
|
}
|
|
182
192
|
descentVerdict = descentVerdictForApply(verdict);
|
|
183
193
|
}
|
|
@@ -437,6 +447,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
437
447
|
return { ok: true };
|
|
438
448
|
case 'diverged':
|
|
439
449
|
case 'drift':
|
|
450
|
+
case 'indeterminate':
|
|
440
451
|
return { ok: false, reason: verdict.reason };
|
|
441
452
|
}
|
|
442
453
|
},
|
|
@@ -47,6 +47,7 @@ import { findReadAuthzGaps, findNakedGrants } from '../authz-lint.js';
|
|
|
47
47
|
import { parseBaseline, renderBaseline, BASELINE_FILE } from '../authz-baseline.js';
|
|
48
48
|
import {
|
|
49
49
|
findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings, findPublicExecutableSecdef,
|
|
50
|
+
findUnpinnedDefiners,
|
|
50
51
|
} from '../derived-lint.js';
|
|
51
52
|
import { step, success, fail, info, warn } from '../output.js';
|
|
52
53
|
|
|
@@ -235,7 +236,9 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
235
236
|
for (const gap of derivedGaps) {
|
|
236
237
|
findings.push({ level: 'fail', area: 'authz', message: gap.message });
|
|
237
238
|
}
|
|
238
|
-
|
|
239
|
+
// findUnpinnedDefiners is the advisory half of A3: db:pull stopped inventing a pin, so the
|
|
240
|
+
// opinion moved here. It SUGGESTS one and never applies it.
|
|
241
|
+
for (const warning of [...findMatviewSnapshotWarnings(input.derived), ...findPublicExecutableSecdef(input.derived), ...findUnpinnedDefiners(input.derived)]) {
|
|
239
242
|
findings.push({ level: 'warn', area: 'authz', message: warning.message });
|
|
240
243
|
}
|
|
241
244
|
if (derivedGaps.length === 0) {
|