@everystack/cli 0.4.52 → 0.4.53
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
CHANGED
package/src/cli/authz-compile.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { isColumnAbility, type ModelDescriptor, type Ability } from '@everystack/model';
|
|
24
24
|
import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract.js';
|
|
25
25
|
import { parenthesizeOnce } from './authz-contract.js';
|
|
26
|
+
import { normalizeDeparsedExpr } from './deparse-normal.js';
|
|
26
27
|
|
|
27
28
|
export interface CompileOptions {
|
|
28
29
|
/** Schema the table lives in. Default: `public`. */
|
|
@@ -400,7 +401,14 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
400
401
|
name: string, command: PolicyCommand, roles: string[],
|
|
401
402
|
using: string | null, check: string | null,
|
|
402
403
|
): void => {
|
|
403
|
-
|
|
404
|
+
// Same normalization the live producer applies (authz-contract's `predicate`): a model
|
|
405
|
+
// pulled on one PostgreSQL major carries that major's deparse spelling in its predicates,
|
|
406
|
+
// and it must compare equal against a stage on another. One rule table, two producers.
|
|
407
|
+
policies.push({
|
|
408
|
+
name, command, roles, permissive: true,
|
|
409
|
+
using: using == null ? null : normalizeDeparsedExpr(using),
|
|
410
|
+
check: check == null ? null : normalizeDeparsedExpr(check),
|
|
411
|
+
});
|
|
404
412
|
};
|
|
405
413
|
|
|
406
414
|
// admin-bypass — one ALL policy, true/true.
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { parsePgArray } from './security-catalog.js';
|
|
25
25
|
import { INTROSPECTION_SESSION, type SessionRunner } from './session.js';
|
|
26
26
|
import { matchPolicies, roleSetEqual } from './authz-identity.js';
|
|
27
|
+
import { normalizeDeparsedExpr } from './deparse-normal.js';
|
|
27
28
|
|
|
28
29
|
// ---------------------------------------------------------------------------
|
|
29
30
|
// The contract format — the frozen, reviewable, version-controlled shape.
|
|
@@ -270,6 +271,15 @@ export interface FunctionContract {
|
|
|
270
271
|
export interface AuthzContract {
|
|
271
272
|
tables: TableContract[];
|
|
272
273
|
functions: FunctionContract[];
|
|
274
|
+
/**
|
|
275
|
+
* Schema-level ACLs (pg_namespace.nspacl), schema → grantee → sorted privileges
|
|
276
|
+
* (USAGE | CREATE). `PUBLIC` is the pseudo-role entry. A schema with a NULL acl is
|
|
277
|
+
* ABSENT — owner-default, no explicit grants — so absence means "the grant does not
|
|
278
|
+
* exist" and an emitter may emit it. Optional: contracts assembled from older recorders
|
|
279
|
+
* or hand-built fixtures simply carry no schema knowledge, and consumers must treat
|
|
280
|
+
* that as "unknown", never as "no grants".
|
|
281
|
+
*/
|
|
282
|
+
schemaAcls?: Record<string, Record<string, string[]>>;
|
|
273
283
|
}
|
|
274
284
|
|
|
275
285
|
export type PolicyCommand = 'ALL' | 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE';
|
|
@@ -398,6 +408,82 @@ WHERE c.relkind = 'r'
|
|
|
398
408
|
ORDER BY n.nspname, c.relname;
|
|
399
409
|
`.trim();
|
|
400
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Schema ACLs, for the emitters that grant schema USAGE. The consumer bug this closes: the
|
|
413
|
+
* usage phase emitted `GRANT USAGE ON SCHEMA` with no live read at all, so every plan with
|
|
414
|
+
* any authz statement re-granted what the database already held — two false statements on
|
|
415
|
+
* every stage plan, forever. `nspacl` casts to its array-literal text; NULL stays NULL
|
|
416
|
+
* (owner default — no explicit grants — which the parser must NOT read as an empty grant
|
|
417
|
+
* list on purpose: for nspacl the two mean the same emittable thing, but the distinction
|
|
418
|
+
* is kept so the contract says what the catalog said).
|
|
419
|
+
*/
|
|
420
|
+
export const SCHEMA_ACL_SQL = `
|
|
421
|
+
SELECT n.nspname AS schema, n.nspacl::text AS acl
|
|
422
|
+
FROM pg_namespace n
|
|
423
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
424
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
425
|
+
ORDER BY n.nspname;
|
|
426
|
+
`.trim();
|
|
427
|
+
|
|
428
|
+
export interface SchemaAclRow {
|
|
429
|
+
schema: string;
|
|
430
|
+
acl: unknown;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The two privileges a schema ACL can carry, in aclitem letter form. */
|
|
434
|
+
const SCHEMA_PRIV_LETTERS: Record<string, string> = { U: 'USAGE', C: 'CREATE' };
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Parse one `aclitem[]::text` literal — `{postgres=UC/postgres,authenticator=U/postgres}` —
|
|
438
|
+
* into grantee → privileges. An empty grantee (`=U/postgres`) is PUBLIC. A quoted grantee
|
|
439
|
+
* (`"odd,role"=U/postgres`) is unwrapped with its doubled-quote escapes. A `*` (grant
|
|
440
|
+
* option) rides the letter before it and is dropped — holding WITH GRANT OPTION still
|
|
441
|
+
* holds the privilege. Unparseable input returns null: the caller treats it as unknown,
|
|
442
|
+
* never as "no grants".
|
|
443
|
+
*/
|
|
444
|
+
export function parseSchemaAcl(text: string | null | undefined): Record<string, string[]> | null {
|
|
445
|
+
if (text == null) return null;
|
|
446
|
+
const s = String(text).trim();
|
|
447
|
+
if (!s.startsWith('{') || !s.endsWith('}')) return null;
|
|
448
|
+
const body = s.slice(1, -1);
|
|
449
|
+
if (body.trim() === '') return {};
|
|
450
|
+
const out: Record<string, string[]> = {};
|
|
451
|
+
// Split items at top-level commas — a quoted region may contain commas.
|
|
452
|
+
const items: string[] = [];
|
|
453
|
+
let start = 0;
|
|
454
|
+
for (let j = 0; j < body.length; j++) {
|
|
455
|
+
if (body[j] === '"') {
|
|
456
|
+
for (j++; j < body.length; j++) {
|
|
457
|
+
if (body[j] !== '"') continue;
|
|
458
|
+
if (body[j + 1] === '"') { j++; continue; }
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
} else if (body[j] === ',') {
|
|
462
|
+
items.push(body.slice(start, j));
|
|
463
|
+
start = j + 1;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
items.push(body.slice(start));
|
|
467
|
+
for (const item of items) {
|
|
468
|
+
// grantee=letters/grantor — grantee may be quoted; the grantor half is irrelevant here.
|
|
469
|
+
const eq = item.indexOf('=', item.startsWith('"') ? item.indexOf('"', 1) + 1 : 0);
|
|
470
|
+
if (eq === -1) return null;
|
|
471
|
+
let grantee = item.slice(0, eq);
|
|
472
|
+
if (grantee.startsWith('"') && grantee.endsWith('"')) grantee = grantee.slice(1, -1).replace(/""/g, '"');
|
|
473
|
+
if (grantee === '') grantee = 'PUBLIC';
|
|
474
|
+
const slash = item.indexOf('/', eq);
|
|
475
|
+
const letters = item.slice(eq + 1, slash === -1 ? undefined : slash);
|
|
476
|
+
const privs = new Set<string>();
|
|
477
|
+
for (const ch of letters) {
|
|
478
|
+
if (ch === '*') continue;
|
|
479
|
+
const p = SCHEMA_PRIV_LETTERS[ch];
|
|
480
|
+
if (p) privs.add(p);
|
|
481
|
+
}
|
|
482
|
+
if (privs.size) out[grantee] = [...privs].sort();
|
|
483
|
+
}
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
|
|
401
487
|
// ---------------------------------------------------------------------------
|
|
402
488
|
// Pure mappers — one catalog row -> one descriptor.
|
|
403
489
|
// ---------------------------------------------------------------------------
|
|
@@ -411,7 +497,10 @@ function truthy(v: unknown): boolean {
|
|
|
411
497
|
function predicate(v: unknown): string | null {
|
|
412
498
|
if (v == null) return null;
|
|
413
499
|
const s = String(v).trim();
|
|
414
|
-
|
|
500
|
+
// Normalized at the PRODUCER, so the matcher, the canonical hash, and the drift detail
|
|
501
|
+
// all see one spelling — a live tree deparsed by an older major must compare equal to
|
|
502
|
+
// the same predicate parsed on a newer one. See deparse-normal.ts for the rule table.
|
|
503
|
+
return s.length > 0 ? normalizeDeparsedExpr(s) : null;
|
|
415
504
|
}
|
|
416
505
|
|
|
417
506
|
export interface PolicyRow {
|
|
@@ -524,6 +613,8 @@ export interface ContractRows {
|
|
|
524
613
|
policies: PolicyRow[];
|
|
525
614
|
grants: GrantRow[];
|
|
526
615
|
columnGrants?: ColumnGrantRow[];
|
|
616
|
+
/** pg_namespace ACL rows (SCHEMA_ACL_SQL) — optional; absent means schema ACLs unknown. */
|
|
617
|
+
schemaAcls?: SchemaAclRow[];
|
|
527
618
|
/** Already-mapped function descriptors (from security-catalog's FUNCTIONS_SQL). */
|
|
528
619
|
functions: { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean }[];
|
|
529
620
|
}
|
|
@@ -585,9 +676,22 @@ export function assembleContract(rows: ContractRows): AuthzContract {
|
|
|
585
676
|
}))
|
|
586
677
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
587
678
|
|
|
679
|
+
// Schema ACLs, keyed only when the read ran (absent = unknown, per the contract's doc).
|
|
680
|
+
// A NULL acl (owner default) contributes an empty entry so "we looked, nothing granted"
|
|
681
|
+
// is distinguishable from "we never looked".
|
|
682
|
+
let schemaAcls: Record<string, Record<string, string[]>> | undefined;
|
|
683
|
+
if (rows.schemaAcls) {
|
|
684
|
+
schemaAcls = {};
|
|
685
|
+
for (const row of rows.schemaAcls) {
|
|
686
|
+
if (IGNORED_SCHEMAS.has(row.schema)) continue;
|
|
687
|
+
schemaAcls[row.schema] = parseSchemaAcl(row.acl == null ? '{}' : String(row.acl)) ?? {};
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
588
691
|
return {
|
|
589
692
|
tables: [...tables.values()].sort((a, b) => a.table.localeCompare(b.table)),
|
|
590
693
|
functions,
|
|
694
|
+
...(schemaAcls ? { schemaAcls } : {}),
|
|
591
695
|
};
|
|
592
696
|
}
|
|
593
697
|
|
|
@@ -597,11 +701,11 @@ export async function introspectContract(
|
|
|
597
701
|
mapFunctionRow: (row: any) => { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean },
|
|
598
702
|
functionsSql: string,
|
|
599
703
|
): Promise<AuthzContract> {
|
|
600
|
-
// ONE session: the
|
|
704
|
+
// ONE session: the six queries describe one moment under one pinned search_path.
|
|
601
705
|
// Policy USING / WITH CHECK expressions deparse relative to that path, so a read spread
|
|
602
706
|
// across connections can report authz drift that does not exist.
|
|
603
|
-
const [rls, policies, grants, columnGrants, fnRows] = await session(
|
|
604
|
-
[RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, functionsSql],
|
|
707
|
+
const [rls, policies, grants, columnGrants, schemaAcls, fnRows] = await session(
|
|
708
|
+
[RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, SCHEMA_ACL_SQL, functionsSql],
|
|
605
709
|
INTROSPECTION_SESSION,
|
|
606
710
|
);
|
|
607
711
|
return assembleContract({
|
|
@@ -609,6 +713,7 @@ export async function introspectContract(
|
|
|
609
713
|
policies: policies as PolicyRow[],
|
|
610
714
|
grants: grants as GrantRow[],
|
|
611
715
|
columnGrants: columnGrants as ColumnGrantRow[],
|
|
716
|
+
schemaAcls: schemaAcls as SchemaAclRow[],
|
|
612
717
|
functions: (fnRows as any[]).map(mapFunctionRow),
|
|
613
718
|
});
|
|
614
719
|
}
|
|
@@ -440,6 +440,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
440
440
|
const stageName = flags.stage;
|
|
441
441
|
if (!stageName) {
|
|
442
442
|
note(`No --stage, so ${BASELINE_FILE} was not written. The foreign grantees above are recorded per stage; re-run with --stage <name> to adopt them, or db:plan will refuse until you do.`);
|
|
443
|
+
note(`--stage composes with --database-url: the URL stays the connection, the stage only LABELS the baseline entry — a local clone can adopt for a deployed stage without touching it.`);
|
|
443
444
|
} else {
|
|
444
445
|
const entry = buildStageBaseline(pulledExemptions, { observedAt: new Date().toISOString(), fingerprint: pulledFingerprint });
|
|
445
446
|
const merged = mergeBaseline(await readBaselineFile(), stageName, entry);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deparse-normal — cross-version normalization of `pg_get_expr` output.
|
|
3
|
+
*
|
|
4
|
+
* A policy predicate's stored tree is built by whichever server PARSED the DDL, and
|
|
5
|
+
* different PostgreSQL majors const-fold the same source differently. The measured case
|
|
6
|
+
* (a consumer's stage, 2026-08-08): an older major stores an array cast OUTSIDE the array,
|
|
7
|
+
*
|
|
8
|
+
* (ARRAY['a'::character varying, 'b'::character varying])::text[]
|
|
9
|
+
*
|
|
10
|
+
* while PG17 distributes it over the elements at parse time,
|
|
11
|
+
*
|
|
12
|
+
* ARRAY[('a'::character varying)::text, ('b'::character varying)::text]
|
|
13
|
+
*
|
|
14
|
+
* Text-identity across venues is then unreachable: models pulled on one major can never
|
|
15
|
+
* equal a stage on another, the differ plans DROP+CREATE forever (the stage re-deparses in
|
|
16
|
+
* its own spelling), and the fingerprint gate cannot MATCH. Identity must not depend on the
|
|
17
|
+
* two venues sharing a parser.
|
|
18
|
+
*
|
|
19
|
+
* THE RULE TABLE IS DELIBERATELY SMALL. Each rewrite must be provably semantics-preserving,
|
|
20
|
+
* because this text feeds the policy matcher, whose safety property is "misses are safe,
|
|
21
|
+
* false matches are the disaster". One rule today:
|
|
22
|
+
*
|
|
23
|
+
* ARRAY-CAST DISTRIBUTION: `(ARRAY[e1, …, en])::T[] == ARRAY[(e1)::T, …, (en)::T]`
|
|
24
|
+
* for n ≥ 1. PostgreSQL defines an array-to-array cast element-wise (parse_coerce), so
|
|
25
|
+
* the two expressions denote the same value for every input; wrapping an element in
|
|
26
|
+
* parentheses is parse-neutral. The empty array is EXCLUDED: `ARRAY[]` without a cast has
|
|
27
|
+
* no type, so its cast is load-bearing and stays.
|
|
28
|
+
*
|
|
29
|
+
* Anything the scanner does not positively recognize is left byte-for-byte unchanged — an
|
|
30
|
+
* unrecognized spelling is a MISS (drop + create, the pre-existing behavior), never a guess.
|
|
31
|
+
* Escalation is named, not implied: the SECOND cross-version variant found in the field is
|
|
32
|
+
* the trigger to adopt libpg_query and move identity onto normalized ASTs (Ty, 2026-08-09) —
|
|
33
|
+
* a growing rule table over raw text is where scanning stops being defensible.
|
|
34
|
+
*
|
|
35
|
+
* Applied at the PRODUCERS (live introspection's `predicate()`, the compiler's policy
|
|
36
|
+
* assembly), so every downstream comparison — the identity matcher, the canonical hash, the
|
|
37
|
+
* drift detail — sees normalized text without holding its own copy of this rule.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** `::text[]` / `::character varying[]` — the cast suffix after `(ARRAY[…])`. */
|
|
41
|
+
const ARRAY_CAST_SUFFIX = /^::([A-Za-z_][A-Za-z0-9_]*(?:\s[A-Za-z_][A-Za-z0-9_]*)*)\[\]/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* From an opening single or double quote, the index of its closing quote.
|
|
45
|
+
* SQL escapes a quote by doubling it; a doubled quote is content, not a close.
|
|
46
|
+
* Returns -1 on an unterminated literal (the caller then leaves the text alone).
|
|
47
|
+
*/
|
|
48
|
+
function skipQuoted(s: string, at: number): number {
|
|
49
|
+
const q = s[at];
|
|
50
|
+
for (let j = at + 1; j < s.length; j++) {
|
|
51
|
+
if (s[j] !== q) continue;
|
|
52
|
+
if (s[j + 1] === q) { j++; continue; }
|
|
53
|
+
return j;
|
|
54
|
+
}
|
|
55
|
+
return -1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The index of the `]` closing the `[` at `open`, honoring nesting and quoted regions. */
|
|
59
|
+
function matchBracket(s: string, open: number): number {
|
|
60
|
+
let sq = 0;
|
|
61
|
+
let par = 0;
|
|
62
|
+
for (let j = open; j < s.length; j++) {
|
|
63
|
+
const c = s[j];
|
|
64
|
+
if (c === "'" || c === '"') {
|
|
65
|
+
j = skipQuoted(s, j);
|
|
66
|
+
if (j < 0) return -1;
|
|
67
|
+
} else if (c === '[') sq++;
|
|
68
|
+
else if (c === ']') { sq--; if (sq === 0 && par === 0) return j; }
|
|
69
|
+
else if (c === '(') par++;
|
|
70
|
+
else if (c === ')') par--;
|
|
71
|
+
}
|
|
72
|
+
return -1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Split on top-level commas (both nesting depths zero), or null on an unterminated literal. */
|
|
76
|
+
function splitTopLevel(s: string): string[] | null {
|
|
77
|
+
const out: string[] = [];
|
|
78
|
+
let start = 0;
|
|
79
|
+
let sq = 0;
|
|
80
|
+
let par = 0;
|
|
81
|
+
for (let j = 0; j < s.length; j++) {
|
|
82
|
+
const c = s[j];
|
|
83
|
+
if (c === "'" || c === '"') {
|
|
84
|
+
j = skipQuoted(s, j);
|
|
85
|
+
if (j < 0) return null;
|
|
86
|
+
} else if (c === '[') sq++;
|
|
87
|
+
else if (c === ']') sq--;
|
|
88
|
+
else if (c === '(') par++;
|
|
89
|
+
else if (c === ')') par--;
|
|
90
|
+
else if (c === ',' && sq === 0 && par === 0) {
|
|
91
|
+
out.push(s.slice(start, j));
|
|
92
|
+
start = j + 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
out.push(s.slice(start));
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One left-to-right pass: rewrite the first recognized `(ARRAY[…])::T[]`, or null if none. */
|
|
100
|
+
function distributeOnce(s: string): string | null {
|
|
101
|
+
let i = 0;
|
|
102
|
+
while ((i = s.indexOf('(ARRAY[', i)) !== -1) {
|
|
103
|
+
const open = i + 6; // the '['
|
|
104
|
+
const close = matchBracket(s, open);
|
|
105
|
+
if (close === -1) return null; // unparseable text — leave everything alone
|
|
106
|
+
if (s[close + 1] === ')') {
|
|
107
|
+
const m = ARRAY_CAST_SUFFIX.exec(s.slice(close + 2));
|
|
108
|
+
if (m) {
|
|
109
|
+
const inner = s.slice(open + 1, close);
|
|
110
|
+
const elements = splitTopLevel(inner);
|
|
111
|
+
// Empty array excluded: its cast carries the type. A failed split leaves the text alone.
|
|
112
|
+
if (elements !== null && inner.trim().length > 0) {
|
|
113
|
+
const t = m[1];
|
|
114
|
+
const rewritten = `ARRAY[${elements.map((e) => `(${e.trim()})::${t}`).join(', ')}]`;
|
|
115
|
+
return s.slice(0, i) + rewritten + s.slice(close + 2 + m[0].length);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
i = open;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Normalize one deparsed expression. Idempotent; unrecognized text returns unchanged.
|
|
126
|
+
* Fixpoint-bounded: nesting deeper than the cap returns its progress, which is still
|
|
127
|
+
* deterministic — both sides of every comparison run this same function.
|
|
128
|
+
*/
|
|
129
|
+
export function normalizeDeparsedExpr(text: string): string {
|
|
130
|
+
let s = text;
|
|
131
|
+
for (let n = 0; n < 64; n++) {
|
|
132
|
+
const next = distributeOnce(s);
|
|
133
|
+
if (next === null) return s;
|
|
134
|
+
s = next;
|
|
135
|
+
}
|
|
136
|
+
return s;
|
|
137
|
+
}
|
|
@@ -90,7 +90,11 @@ function holdDrop(sql: string): string {
|
|
|
90
90
|
*/
|
|
91
91
|
export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
|
|
92
92
|
const schema = opts.schema ?? 'public';
|
|
93
|
-
|
|
93
|
+
// The model's OWN schema first — the same rule the compiler applies. Qualifying every
|
|
94
|
+
// model with the call default read a multi-schema checkout's non-public models as
|
|
95
|
+
// unmodeled: a consumer's plan listed three modeled tables as riding through untouched,
|
|
96
|
+
// which is a false statement about what the plan governs.
|
|
97
|
+
const declared = new Set(models.map((m) => `${m.schema ?? schema}.${m.table}`));
|
|
94
98
|
// A pending table rename's OR move's source is ours — declared under its new (qualified)
|
|
95
99
|
// name; without this it reads as an undeclared orphan (F1) and the move/rename silently
|
|
96
100
|
// degrades to CREATE + leave-behind, the exact bug the markers exist to prevent.
|
|
@@ -233,6 +237,20 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
233
237
|
// The from-scratch path had this from the start; the diff path did not, so a non-public
|
|
234
238
|
// model reached through db:sync/db:generate was unreachable by every role that did not
|
|
235
239
|
// pick up USAGE some other way. The example app's analytics schema is what surfaced it.
|
|
240
|
+
// Diffed against the LIVE schema ACLs when the contract carries them. This phase used to
|
|
241
|
+
// emit unconditionally, so any plan with an authz statement re-granted USAGE the database
|
|
242
|
+
// already held — two false statements on every one of a consumer's stage plans (measured,
|
|
243
|
+
// catalog-verified, 2026-08-08). A role holds USAGE when its own nspacl entry says so or
|
|
244
|
+
// when PUBLIC's does (PUBLIC reaches every role). Membership-derived usage is invisible
|
|
245
|
+
// here and stays emitted — idempotent, and rarer than the direct grants that were the bug.
|
|
246
|
+
// No schemaAcls (fresh compose, older recorder) = unknown = emit, exactly as before.
|
|
247
|
+
const liveSchemaAcls = liveAuthzRenamed?.schemaAcls;
|
|
248
|
+
const hasLiveUsage = (s: string, role: string): boolean => {
|
|
249
|
+
const acl = liveSchemaAcls?.[s];
|
|
250
|
+
if (!acl) return false;
|
|
251
|
+
const key = role.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : role;
|
|
252
|
+
return (acl[key] ?? []).includes('USAGE') || (acl.PUBLIC ?? []).includes('USAGE');
|
|
253
|
+
};
|
|
236
254
|
const usagePhase = authzPhase.length
|
|
237
255
|
? [...new Set(desiredContracts.map((c) => c.table.split('.')[0]))]
|
|
238
256
|
.filter((s) => s !== 'public')
|
|
@@ -244,8 +262,9 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
244
262
|
for (const r of Object.keys(c.grants)) roles.add(r);
|
|
245
263
|
for (const r of Object.keys(c.columnGrants ?? {})) roles.add(r);
|
|
246
264
|
}
|
|
247
|
-
|
|
248
|
-
|
|
265
|
+
const missing = [...roles].filter((r) => !hasLiveUsage(s, r));
|
|
266
|
+
if (!missing.length) return [];
|
|
267
|
+
const targets = missing.sort().map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : r)).join(', ');
|
|
249
268
|
return [`GRANT USAGE ON SCHEMA "${s}" TO ${targets}`];
|
|
250
269
|
})
|
|
251
270
|
: [];
|
|
@@ -110,7 +110,7 @@ import { normalizeDefault, normalizeCheck, indexKey } from './schema-diff.js';
|
|
|
110
110
|
// it from the canonical form AND the reconciler leaves it alone, before and after. Restrictive
|
|
111
111
|
// policies are never subsumed (they AND, so removing one would WIDEN), and the rule must match
|
|
112
112
|
// exactly including the effective WITH CHECK.
|
|
113
|
-
export const FINGERPRINT_VERSION =
|
|
113
|
+
export const FINGERPRINT_VERSION = 9;
|
|
114
114
|
|
|
115
115
|
// ---------------------------------------------------------------------------
|
|
116
116
|
// Canonical form.
|