@everystack/cli 0.4.55 → 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 +2 -2
- 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-check.ts +4 -1
- package/src/cli/commands/db-fingerprint.ts +16 -2
- 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/migration-generate.ts +3 -0
- package/src/cli/model-render.ts +28 -3
- 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.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",
|
|
@@ -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
|
}
|
|
@@ -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) {
|
|
@@ -65,6 +65,8 @@ export async function computeFingerprintStatus(
|
|
|
65
65
|
models: ModelDescriptor[] | null,
|
|
66
66
|
sequences?: SequenceDescriptor[],
|
|
67
67
|
governedExtras?: readonly string[],
|
|
68
|
+
/** Declared derived objects — used to drop the `function owner` rows that ARE fingerprinted. */
|
|
69
|
+
declaredDerived?: readonly { kind: string; identity: string; owner?: string }[],
|
|
68
70
|
): Promise<FingerprintStatus> {
|
|
69
71
|
const snapshot = await introspectSchema(session);
|
|
70
72
|
const contract = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
@@ -77,7 +79,16 @@ export async function computeFingerprintStatus(
|
|
|
77
79
|
const governedRoles = models ? governedRolesForModels(models, governedExtras) : undefined;
|
|
78
80
|
const governedLive = governedRoles ? governedLiveFingerprint(snapshot, contract, governedRoles) : undefined;
|
|
79
81
|
const predicted = models ? predictLiveFingerprint(models, snapshot, contract, { governedRoles }) : undefined;
|
|
80
|
-
|
|
82
|
+
// B5 — a declared owner IS hashed (derived-source prefixes it), so reporting it as
|
|
83
|
+
// unfingerprinted would be a false claim in the one report whose whole job is honesty about
|
|
84
|
+
// coverage. SQL cannot know what the models declare; the filter belongs here.
|
|
85
|
+
const ownerDeclared = new Set(
|
|
86
|
+
(declaredDerived ?? [])
|
|
87
|
+
.filter((o) => o.kind === 'function' && o.owner !== undefined)
|
|
88
|
+
.map((o) => o.identity.replace(/\(.*\)$/, '')),
|
|
89
|
+
);
|
|
90
|
+
const unfingerprinted = mapUnfingerprintedRows(unfingerprintedRows as any[])
|
|
91
|
+
.filter((u) => u.kind !== 'function owner' || !ownerDeclared.has(u.identity.split(' → ')[0]));
|
|
81
92
|
return {
|
|
82
93
|
live,
|
|
83
94
|
...(governedLive !== undefined ? { governedLive } : {}),
|
|
@@ -101,11 +112,14 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
101
112
|
let models: ModelDescriptor[] | null = null;
|
|
102
113
|
let sequences: SequenceDescriptor[] | undefined;
|
|
103
114
|
let governedExtras: string[] | undefined;
|
|
115
|
+
let declaredDerived: readonly { kind: string; identity: string; owner?: string }[] | undefined;
|
|
104
116
|
try {
|
|
105
117
|
models = await loadModels(modelsPath);
|
|
106
118
|
const declared = await loadDeclaredDerived(flags.models);
|
|
107
119
|
sequences = declared?.sequences;
|
|
108
120
|
governedExtras = declared?.governedRoles;
|
|
121
|
+
// B5 — needed to tell a function whose owner IS hashed (declared) from one whose is not.
|
|
122
|
+
declaredDerived = declared?.objects;
|
|
109
123
|
} catch (err: any) {
|
|
110
124
|
// Two very different situations used to land here identically.
|
|
111
125
|
//
|
|
@@ -141,7 +155,7 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
141
155
|
}
|
|
142
156
|
|
|
143
157
|
try {
|
|
144
|
-
const status = await computeFingerprintStatus(session, models, sequences, governedExtras);
|
|
158
|
+
const status = await computeFingerprintStatus(session, models, sequences, governedExtras, declaredDerived);
|
|
145
159
|
|
|
146
160
|
if (flags.json === 'true') {
|
|
147
161
|
console.log(JSON.stringify(status, null, 2));
|
|
@@ -35,7 +35,7 @@ import fs from 'node:fs/promises';
|
|
|
35
35
|
import path from 'node:path';
|
|
36
36
|
import { introspectSchema, MATVIEW_COLUMNS_SQL, matviewColumnsByIdentity, type ColumnRow, type ColumnSchema } from '../schema-introspect.js';
|
|
37
37
|
import { fingerprintLive } from '../schema-fingerprint.js';
|
|
38
|
-
import { ungovernedGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
|
|
38
|
+
import { ungovernedGrants, vestigialSequenceGrants, renderVestigialSequenceGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile.js';
|
|
39
39
|
import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline.js';
|
|
40
40
|
import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
|
|
41
41
|
import { introspectContract, type TableContract, type AuthzContract } from '../authz-contract.js';
|
|
@@ -235,6 +235,11 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
235
235
|
// governed role's grants are DECLARED (transcribed as privileges), so recording them
|
|
236
236
|
// as exemptions too would double-book them — declared and exempted at once.
|
|
237
237
|
pulledExemptions = ungovernedGrants(contract, new Set([...ALWAYS_GOVERNED, ...governRoles]));
|
|
238
|
+
// A6's advisory. A blanket `GRANT USAGE ON ALL SEQUENCES` beside a blanket SELECT is a
|
|
239
|
+
// common brownfield shape, and the inheritance rule never touches it — the rule only ADDS
|
|
240
|
+
// what an INSERT needs — so it would sit unnoticed forever. Named, never revoked: revoking
|
|
241
|
+
// it would be the tool deciding a security property from an inference.
|
|
242
|
+
for (const line of renderVestigialSequenceGrants(vestigialSequenceGrants(contract))) caution(line);
|
|
238
243
|
pulledFingerprint = fingerprintLive(current, contract).hash;
|
|
239
244
|
}
|
|
240
245
|
// --matviews-as-tables: the flip needs real fields — one extra catalog read for the
|
|
@@ -43,6 +43,11 @@ import {
|
|
|
43
43
|
derivedSearchPath,
|
|
44
44
|
renderSetSearchPath,
|
|
45
45
|
renderEnsureObjectSchemas,
|
|
46
|
+
ownerRequirements,
|
|
47
|
+
ownerPreflightSql,
|
|
48
|
+
ownerPreflightRefusal,
|
|
49
|
+
builderOwnerMode,
|
|
50
|
+
type OwnerApplyMode,
|
|
46
51
|
ENSURE_RECONCILER_SQL,
|
|
47
52
|
} from '../derived-apply.js';
|
|
48
53
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
@@ -78,6 +83,13 @@ export interface ReconcileRun {
|
|
|
78
83
|
statements: string[];
|
|
79
84
|
/** Why apply was refused, when it was. */
|
|
80
85
|
refusal?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Which mechanism enacted the declared owners, when any were enacted. Reported because the two
|
|
88
|
+
* make different demands of the operator: `set-role` needs membership plus CREATE-on-schema for
|
|
89
|
+
* the owner, `alter-owner` needs a superuser builder and nothing of the owner at all. An
|
|
90
|
+
* operator debugging a permission error should not have to guess which one ran.
|
|
91
|
+
*/
|
|
92
|
+
ownerMode?: OwnerApplyMode;
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
/**
|
|
@@ -118,7 +130,10 @@ export async function executeReconcile(
|
|
|
118
130
|
const live = await introspectDerived(session);
|
|
119
131
|
const parsed = { objects: options.declared ?? [], warnings: [] as string[] };
|
|
120
132
|
const plan = planReconcile(parsed, live, options);
|
|
121
|
-
|
|
133
|
+
// Rendered with the mechanism that works for EVERY builder. The preflight below reads the
|
|
134
|
+
// catalog and re-renders if this builder can use the cheaper one; a plan that is never applied
|
|
135
|
+
// (or is refused) shows the conservative form, which is the honest default.
|
|
136
|
+
let rendered = renderReconcileSql(plan, parsed.objects);
|
|
122
137
|
|
|
123
138
|
if (!options.apply) return { plan, applied: false, statements: rendered.statements };
|
|
124
139
|
|
|
@@ -138,6 +153,23 @@ export async function executeReconcile(
|
|
|
138
153
|
return { plan, applied: false, statements: [] };
|
|
139
154
|
}
|
|
140
155
|
|
|
156
|
+
// DECLARED-OWNER PREFLIGHT — the last thing before any DDL, bookkeeping included.
|
|
157
|
+
//
|
|
158
|
+
// Two jobs, one read. It refuses what this builder cannot enact, naming every owner at once
|
|
159
|
+
// rather than one per re-run, so the operator's first news is not a raw Postgres error from the
|
|
160
|
+
// middle of a batch. And it CHOOSES the mechanism: a superuser builder hands the object over
|
|
161
|
+
// with ALTER … OWNER TO, which asks nothing of the owner role; everyone else creates AS the
|
|
162
|
+
// owner, which needs membership and CREATE on the schema.
|
|
163
|
+
const requirements = ownerRequirements(plan, parsed.objects);
|
|
164
|
+
let ownerMode: OwnerApplyMode | undefined;
|
|
165
|
+
if (requirements.length > 0) {
|
|
166
|
+
const rows = (await runner(ownerPreflightSql(requirements))) as any[];
|
|
167
|
+
const refusal = ownerPreflightRefusal(rows);
|
|
168
|
+
if (refusal) return { plan, applied: false, statements: rendered.statements, refusal };
|
|
169
|
+
ownerMode = builderOwnerMode(rows);
|
|
170
|
+
if (ownerMode !== 'set-role') rendered = renderReconcileSql(plan, parsed.objects, ownerMode);
|
|
171
|
+
}
|
|
172
|
+
|
|
141
173
|
const now = options.now ?? Date.now;
|
|
142
174
|
await runner(ENSURE_RECONCILER_SQL.join(';\n'));
|
|
143
175
|
|
|
@@ -263,7 +295,7 @@ export async function executeReconcile(
|
|
|
263
295
|
throw explainReconcileError(err);
|
|
264
296
|
}
|
|
265
297
|
|
|
266
|
-
return { plan, applied: true, statements: rendered.statements };
|
|
298
|
+
return { plan, applied: true, statements: rendered.statements, ...(ownerMode ? { ownerMode } : {}) };
|
|
267
299
|
}
|
|
268
300
|
|
|
269
301
|
/**
|
|
@@ -520,6 +552,13 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
520
552
|
fail(`not applied: ${run.refusal}`);
|
|
521
553
|
} else if (run.applied) {
|
|
522
554
|
success(`Applied ${run.statements.length} statement(s); provenance and schema_log recorded.`);
|
|
555
|
+
// Which ownership mechanism ran. The two make different demands, so an operator
|
|
556
|
+
// debugging a permission error should not have to guess which one they hit.
|
|
557
|
+
if (run.ownerMode === 'alter-owner') {
|
|
558
|
+
info('Declared owners applied with ALTER … OWNER TO (this builder is a superuser) — the owner roles needed no membership grant and no CREATE on their schemas.');
|
|
559
|
+
} else if (run.ownerMode === 'set-role') {
|
|
560
|
+
info('Declared owners applied under SET ROLE (this builder is not a superuser) — each owner must be assumable by the builder and hold CREATE on its schema.');
|
|
561
|
+
}
|
|
523
562
|
if (run.plan.actions.some((a) => a.action === 'baseline')) {
|
|
524
563
|
warn('baseline recorded trust WITHOUT verifying live matches source — on first contact it CANNOT compare a source hash to a live deparse, so it trusts your assertion, it does not check. If you need a guarantee that live == the declared source, drop the object and let reconcile recreate it (or --overwrite-drift when the plan reports drift).');
|
|
525
564
|
}
|