@everystack/cli 0.4.40 → 0.4.43

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.40",
3
+ "version": "0.4.43",
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.6"
112
+ "@everystack/model": "0.4.8"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "@everystack/server": ">=0.4.0",
@@ -70,12 +70,20 @@ export interface ApplyPlanOptions {
70
70
  * attest their own --snapshot-ref keep the ceremony the command shell enforces).
71
71
  */
72
72
  verifySnapshot?: (ctx: { planFrom: string }) => Promise<{ ok: true } | { ok: false; reason: string }>;
73
+ /**
74
+ * The ungoverned-grantee gate, re-verified AT APPLY (B4). db:plan already refuses to mint
75
+ * against a stage holding a foreign grantee the baseline does not record — but a plan is
76
+ * an artifact with a lifetime, and a role minted between minting and applying would
77
+ * otherwise ride through unnoticed. Same contract as the other gates: refusal recorded in
78
+ * schema_log. Omitted = no gate (callers with no baseline to check against).
79
+ */
80
+ verifyAuthzBaseline?: (ctx: { contract: AuthzContract }) => Promise<{ ok: true } | { ok: false; reason: string }>;
73
81
  /** Injectable clock for tests. */
74
82
  now?: () => number;
75
83
  }
76
84
 
77
85
  export interface ApplyPlanResult {
78
- status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'snapshot-refused' | 'verify-failed';
86
+ status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'snapshot-refused' | 'authz-refused' | 'verify-failed';
79
87
  liveFingerprint: string;
80
88
  reason?: string;
81
89
  logId?: number;
@@ -157,6 +165,17 @@ export async function executeApplyPlan(
157
165
  }
158
166
  }
159
167
 
168
+ // Before ANY write: a foreign grantee that arrived after the plan was minted must stop
169
+ // the apply, not ride through it. The plan's own gates check the SCHEMA has not moved;
170
+ // this checks who can reach it.
171
+ if (options.verifyAuthzBaseline) {
172
+ const authz = await options.verifyAuthzBaseline({ contract: beforeAuthz });
173
+ if (!authz.ok) {
174
+ await recordRefusal(runner, plan, live, 'ungoverned grantee', authz.reason, options);
175
+ return { status: 'authz-refused', liveFingerprint: live, reason: authz.reason };
176
+ }
177
+ }
178
+
160
179
  if (plan.destructive > 0 && options.verifyAuthority) {
161
180
  const authority = await options.verifyAuthority();
162
181
  if (!authority.ok) {
@@ -0,0 +1,227 @@
1
+ /**
2
+ * authz-baseline — the foreign grantees that were already there, and the gate that makes a
3
+ * NEW one fail.
4
+ *
5
+ * The reconciler exempts a grantee the models do not govern rather than revoking it
6
+ * (authz-reconcile's `governedRoleSet` — a migrator or BI role must not have its access
7
+ * destroyed by a plan nobody read). That exemption is only half a design. Without the other
8
+ * half, an attacker-minted role holding SELECT on every table is reported forever and fails
9
+ * nothing, which is the failure mode the exemption was rejected for in the first place.
10
+ *
11
+ * So: record the foreign grantees present AT ADOPTION, per stage. After that, an ungoverned
12
+ * grantee that is not in the stage's baseline — or a baselined one that has GROWN — refuses
13
+ * the plan. A greenfield app's baseline is empty, so any foreign grantee is a failure from
14
+ * day one; a brownfield app's migrator is in it, so adoption proceeds with a warning that
15
+ * never goes away.
16
+ *
17
+ * Two deliberate decisions, both adjudicated:
18
+ *
19
+ * - The baseline lives in the REPO, not the database. Storing it in the database looks
20
+ * tamper-evident and is not: the attacker who minted the role owns the database and can
21
+ * write the baseline row in the same session. More importantly it would hide the
22
+ * exemption from the one human checkpoint that exists — a pull request. Adding a
23
+ * grantee is a diff line someone merges. The database keeps a `schema_log` memoir of
24
+ * the adoption event so a reviewer can check the claim against the observation.
25
+ * - It is FROZEN at adoption. There is no "it showed up in production, baseline it"
26
+ * path — that is the whole gate. Admitting a new role costs a PR line, on purpose.
27
+ *
28
+ * Per-stage, because stages legitimately differ (a BI role in production and nowhere else) —
29
+ * and scoped, so a production entry excuses that grantee in production only. The same role
30
+ * appearing in dev still fails there.
31
+ *
32
+ * Pure, with one exception named at the bottom: `readBaselineFile`, which every gate needs
33
+ * to agree on (including "the file is absent", which must behave exactly like an empty
34
+ * baseline rather than like a skipped check).
35
+ */
36
+
37
+ import fs from 'node:fs/promises';
38
+ import path from 'node:path';
39
+ import type { GrantExemption } from './authz-reconcile.js';
40
+
41
+ /** The repo-relative artifact. Generated — regenerating it must reproduce it byte for byte. */
42
+ export const BASELINE_FILE = 'db/authz-baseline.json';
43
+
44
+ /** Privileges that change data or structure — flagged loudly, because a reviewer skims. */
45
+ const WRITE_PRIVILEGES = new Set(['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER']);
46
+
47
+ export interface BaselineGrantee {
48
+ /** The union of table-level privileges held at adoption, sorted. */
49
+ privileges: string[];
50
+ /** The tables it held them on, sorted. */
51
+ tables: string[];
52
+ /** True when any privilege writes. Surfaced so a reviewer sees it without reading the list. */
53
+ write: boolean;
54
+ }
55
+
56
+ export interface BaselineStage {
57
+ /** When the observation was taken — provenance for the claim, not used by the gate. */
58
+ observedAt: string;
59
+ /** The live base fingerprint at observation time — what the claim was true OF. */
60
+ fingerprint: string;
61
+ grantees: Record<string, BaselineGrantee>;
62
+ }
63
+
64
+ export interface AuthzBaseline {
65
+ version: 1;
66
+ stages: Record<string, BaselineStage>;
67
+ }
68
+
69
+ export const EMPTY_BASELINE: AuthzBaseline = { version: 1, stages: {} };
70
+
71
+ /** Fold the observed exemptions into one entry per grantee. */
72
+ export function buildStageBaseline(
73
+ exemptions: readonly GrantExemption[],
74
+ meta: { observedAt: string; fingerprint: string },
75
+ ): BaselineStage {
76
+ const grantees: Record<string, BaselineGrantee> = {};
77
+ for (const e of exemptions) {
78
+ const g = (grantees[e.grantee] ??= { privileges: [], tables: [], write: false });
79
+ g.privileges = [...new Set([...g.privileges, ...e.privileges])].sort();
80
+ g.tables = [...new Set([...g.tables, e.table])].sort();
81
+ // A column-scoped privilege is still that privilege — it must count toward `write`,
82
+ // or a column-scoped UPDATE would read as a harmless reader in review.
83
+ const all = [...e.privileges, ...Object.keys(e.columnPrivileges ?? {})];
84
+ g.write ||= all.some((p) => WRITE_PRIVILEGES.has(p.toUpperCase()));
85
+ }
86
+ return { observedAt: meta.observedAt, fingerprint: meta.fingerprint, grantees };
87
+ }
88
+
89
+ /** Write one stage's entry into the file, leaving every other stage untouched. */
90
+ export function mergeBaseline(existing: AuthzBaseline | null, stage: string, entry: BaselineStage): AuthzBaseline {
91
+ return { version: 1, stages: { ...(existing?.stages ?? {}), [stage]: entry } };
92
+ }
93
+
94
+ /**
95
+ * The artifact's canonical text. Keys sorted at every level so regenerating an unchanged
96
+ * baseline is byte-identical — that equality IS the offline check, and a diff that reorders
97
+ * itself would make the review signal worthless.
98
+ */
99
+ export function renderBaseline(baseline: AuthzBaseline): string {
100
+ const stages: Record<string, BaselineStage> = {};
101
+ for (const stage of Object.keys(baseline.stages).sort()) {
102
+ const s = baseline.stages[stage];
103
+ const grantees: Record<string, BaselineGrantee> = {};
104
+ for (const g of Object.keys(s.grantees).sort()) {
105
+ const { privileges, tables, write } = s.grantees[g];
106
+ grantees[g] = { privileges: [...privileges].sort(), tables: [...tables].sort(), write };
107
+ }
108
+ stages[stage] = { observedAt: s.observedAt, fingerprint: s.fingerprint, grantees };
109
+ }
110
+ return JSON.stringify({ version: 1, stages }, null, 2) + '\n';
111
+ }
112
+
113
+ /** Parse the artifact, refusing anything malformed by name — a baseline that silently reads
114
+ * as empty would disable the gate, which is the one outcome worse than failing. */
115
+ export function parseBaseline(text: string, where = BASELINE_FILE): AuthzBaseline {
116
+ let raw: unknown;
117
+ try {
118
+ raw = JSON.parse(text);
119
+ } catch (err: any) {
120
+ throw new Error(`${where} is not valid JSON: ${err.message}`);
121
+ }
122
+ const b = raw as Partial<AuthzBaseline>;
123
+ if (b?.version !== 1) throw new Error(`${where}: unsupported version ${String(b?.version)} — expected 1.`);
124
+ if (b.stages == null || typeof b.stages !== 'object') throw new Error(`${where}: missing a "stages" object.`);
125
+ for (const [stage, s] of Object.entries(b.stages)) {
126
+ if (s?.grantees == null || typeof s.grantees !== 'object') {
127
+ throw new Error(`${where}: stage "${stage}" has no "grantees" object.`);
128
+ }
129
+ }
130
+ return b as AuthzBaseline;
131
+ }
132
+
133
+ /** One reason a live grantee fails the gate. */
134
+ export interface BaselineViolation {
135
+ grantee: string;
136
+ kind: 'unbaselined' | 'widened';
137
+ detail: string;
138
+ }
139
+
140
+ /**
141
+ * The gate. A stage passes when every ungoverned grantee is in its baseline AND holds
142
+ * nothing beyond what the baseline recorded.
143
+ *
144
+ * `widened` matters as much as `unbaselined`: a role baselined with SELECT that has since
145
+ * acquired UPDATE, or spread to new tables, is not the role that was reviewed. Freezing the
146
+ * NAME while letting the privileges grow would leave the gate defending nothing.
147
+ *
148
+ * An ABSENT stage entry is not an empty one — it means the stage was never adopted, and any
149
+ * foreign grantee there fails. That is deliberate: the safe reading of "no record" is "not
150
+ * reviewed", never "nothing to see".
151
+ */
152
+ export function checkAgainstBaseline(
153
+ baseline: AuthzBaseline | null,
154
+ stage: string,
155
+ exemptions: readonly GrantExemption[],
156
+ ): BaselineViolation[] {
157
+ const observed = buildStageBaseline(exemptions, { observedAt: '', fingerprint: '' }).grantees;
158
+ const recorded = baseline?.stages[stage]?.grantees ?? {};
159
+ const violations: BaselineViolation[] = [];
160
+
161
+ for (const grantee of Object.keys(observed).sort()) {
162
+ const now = observed[grantee];
163
+ const then = recorded[grantee];
164
+ if (!then) {
165
+ violations.push({
166
+ grantee,
167
+ kind: 'unbaselined',
168
+ detail: `holds ${now.privileges.join(', ') || 'column-scoped privileges'} on ${now.tables.length} table(s) and is not in the ${stage} baseline`,
169
+ });
170
+ continue;
171
+ }
172
+ const newPrivileges = now.privileges.filter((p) => !then.privileges.includes(p));
173
+ const newTables = now.tables.filter((t) => !then.tables.includes(t));
174
+ if (newPrivileges.length > 0 || newTables.length > 0) {
175
+ violations.push({
176
+ grantee,
177
+ kind: 'widened',
178
+ detail: [
179
+ newPrivileges.length ? `gained ${newPrivileges.join(', ')}` : null,
180
+ newTables.length ? `spread to ${newTables.join(', ')}` : null,
181
+ ].filter(Boolean).join('; ') + ` since the ${stage} baseline`,
182
+ });
183
+ }
184
+ }
185
+ return violations;
186
+ }
187
+
188
+ /** The refusal an operator reads. Names every violation — a count would not be actionable. */
189
+ export function baselineRefusal(stage: string, violations: readonly BaselineViolation[]): string {
190
+ const lines = violations.map((v) => ` - ${v.grantee} ${v.detail}`);
191
+ return [
192
+ `${violations.length} role(s) hold privileges on ${stage} that the models do not govern and the baseline does not record:`,
193
+ ...lines,
194
+ '',
195
+ `A role the models cannot name is left alone by the reconciler, so it must be reviewed instead of reconciled.`,
196
+ `If these are legitimate, add them deliberately: re-run \`db:pull --abilities live --stage ${stage}\` to regenerate ${BASELINE_FILE},`,
197
+ `review the diff (write privileges are flagged), and commit it. If they are NOT legitimate, revoke them in the database first.`,
198
+ ].join('\n');
199
+ }
200
+
201
+ /**
202
+ * Read the baseline artifact, or null when there is none.
203
+ *
204
+ * ABSENT is not an error and not a skip — it is an EMPTY baseline, so every foreign grantee
205
+ * fails the gate. That is the greenfield default and it is the safe direction: a project
206
+ * that never adopted a baseline has never reviewed a foreign role. A file that exists but
207
+ * cannot be parsed THROWS (see parseBaseline) — silently continuing past a corrupt baseline
208
+ * would disable the gate exactly when someone has been editing it.
209
+ */
210
+ export async function readBaselineFile(cwd = process.cwd()): Promise<AuthzBaseline | null> {
211
+ const file = path.resolve(cwd, BASELINE_FILE);
212
+ let text: string;
213
+ try {
214
+ text = await fs.readFile(file, 'utf8');
215
+ } catch {
216
+ return null;
217
+ }
218
+ return parseBaseline(text, BASELINE_FILE);
219
+ }
220
+
221
+ /** Write the artifact, creating `db/` if this is the first adoption. */
222
+ export async function writeBaselineFile(baseline: AuthzBaseline, cwd = process.cwd()): Promise<string> {
223
+ const file = path.resolve(cwd, BASELINE_FILE);
224
+ await fs.mkdir(path.dirname(file), { recursive: true });
225
+ await fs.writeFile(file, renderBaseline(baseline), 'utf8');
226
+ return file;
227
+ }
@@ -22,6 +22,7 @@
22
22
 
23
23
  import type { ModelDescriptor, Ability } from '@everystack/model';
24
24
  import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract.js';
25
+ import { parenthesizeOnce } from './authz-contract.js';
25
26
 
26
27
  export interface CompileOptions {
27
28
  /** Schema the table lives in. Default: `public`. */
@@ -176,9 +177,17 @@ function resolveVia(model: ModelDescriptor): ViaRef | null {
176
177
  };
177
178
  }
178
179
 
179
- /** The conventional soft-delete column SQL name, when the model has one. */
180
+ /**
181
+ * The soft-delete column SQL name, when the model DECLARES one.
182
+ *
183
+ * Keyed on `softDelete`, never on the field's presence. A column named `deleted_at` is not a
184
+ * statement of visibility intent, and inferring the guard from it made the compiler author a
185
+ * security predicate nobody wrote — one that `db:pull` then handed to every brownfield model
186
+ * automatically, so the first plan narrowed a policy the adopter had written. `defineModel`
187
+ * refuses to guess: a model with the field and a public read must say which it means.
188
+ */
180
189
  function softDeleteColumn(model: ModelDescriptor): string | null {
181
- return 'deletedAt' in model.fields ? 'deleted_at' : null;
190
+ return model.softDelete ? 'deleted_at' : null;
182
191
  }
183
192
 
184
193
  /**
@@ -228,7 +237,11 @@ function rawPredicate(value: unknown, where: string): string | null {
228
237
  `${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — an RLS predicate is authored, never stringly assembled.`,
229
238
  );
230
239
  }
231
- return `(${text.trim()})`;
240
+ // Parenthesized exactly once. A pulled predicate arrives already fully parenthesized
241
+ // (that is how pg_get_expr deparses it), and wrapping it again made the compiled policy
242
+ // differ from the live one by a single layer of parens — enough for the reconciler to
243
+ // plan a DROP + CREATE that changed nothing.
244
+ return parenthesizeOnce(text.trim());
232
245
  }
233
246
 
234
247
  /** AND-compose predicates, dropping the vacuous ones. A condition only ever NARROWS. */
@@ -266,11 +279,20 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
266
279
  // A "row-scoped" condition is either a direct owner or a transitive via.
267
280
  const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
268
281
 
282
+ /**
283
+ * A read scoped to a named role — `can('read', { role })`. It compiles to its own
284
+ * `<table>_select_<role>` policy, so its predicate must NOT also be folded into the
285
+ * public read's predicate: a rule written for one role would otherwise narrow what
286
+ * every other role can see.
287
+ */
288
+ const isRoleRead = (a: Ability): boolean =>
289
+ a.action === 'read' && Boolean(a.condition.role) && !isColumnRead(a);
290
+
269
291
  // Author-supplied predicates, per action. `manage` carries no predicate of its own —
270
292
  // it is the admin bypass — so only the specific verbs are consulted.
271
293
  const predFor = (action: Ability['action']): string | null => {
272
294
  for (const a of model.abilities) {
273
- if (a.action !== action) continue;
295
+ if (a.action !== action || isRoleRead(a)) continue;
274
296
  const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('${action}')`);
275
297
  if (p) return p;
276
298
  }
@@ -336,6 +358,26 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
336
358
  policy(`${t}_select_self`, 'SELECT', ['authenticated'], rowPred, null);
337
359
  }
338
360
 
361
+ // Role-scoped reads — `can('read', { role })`, with or without a predicate. These match
362
+ // none of the branches above: the public read requires NO role, and the owner/column reads
363
+ // require a row-scoping condition. Before this they compiled to a SELECT grant and no
364
+ // policy at all — and since `rls.enabled` is unconditional, the role saw ZERO rows while
365
+ // the model read as though it could see its own. The declared predicate was computed and
366
+ // discarded. That is the same failure `rawPredicate` throws over, one level up: a predicate
367
+ // that evaporates is an authorization hole wearing the costume of a working rule.
368
+ for (const a of abilities.filter(isRoleRead)) {
369
+ const role = a.condition.role!;
370
+ const name = `${t}_select_${role}`;
371
+ const where = `${table}: can('read', { role: '${role}' })`;
372
+ if (policies.some((p) => p.name === name)) {
373
+ throw new Error(
374
+ `${where} collides with the ${name} policy already compiled from this model's public read. `
375
+ + `Declare one or the other: permissive policies OR together, so the role-scoped rule could only ever widen — never the narrowing it reads as.`,
376
+ );
377
+ }
378
+ policy(name, 'SELECT', [role], rawPredicate(a.condition.sql ?? a.condition.where, where) ?? 'true', null);
379
+ }
380
+
339
381
  // owner-gated writes. INSERT checks ownership (you can only create rows you own);
340
382
  // UPDATE gates + checks; DELETE gates. `rowPred` is the direct-owner or `via:` predicate.
341
383
  //
@@ -82,6 +82,51 @@ export interface PolicyContract {
82
82
  check: string | null;
83
83
  }
84
84
 
85
+ /**
86
+ * The WITH CHECK expression PostgreSQL will actually enforce.
87
+ *
88
+ * For `ALL` and `UPDATE`, an omitted `WITH CHECK` is not "no check" — the server reuses the
89
+ * `USING` expression. So a live `FOR ALL USING (true)` (which is what `CREATE POLICY … USING
90
+ * (true)` records, with `pg_policies.with_check` NULL) and a compiled `FOR ALL USING (true)
91
+ * WITH CHECK (true)` are the SAME authorization. Comparing the raw fields calls them
92
+ * different and plans a DROP + CREATE — the loudest possible way to say "no change", and it
93
+ * defeats a zero-statement bar even when the SQL is identical.
94
+ *
95
+ * `INSERT` has only a check, `SELECT`/`DELETE` have none — for those the field stands alone
96
+ * and no defaulting applies.
97
+ */
98
+ export function effectivePolicyCheck(p: PolicyContract): string | null {
99
+ if (p.command === 'ALL' || p.command === 'UPDATE') return p.check ?? p.using;
100
+ return p.check;
101
+ }
102
+
103
+ /** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
104
+ export function isWrappedExpression(s: string): boolean {
105
+ if (!s.startsWith('(') || !s.endsWith(')')) return false;
106
+ let depth = 0;
107
+ for (let i = 0; i < s.length; i++) {
108
+ if (s[i] === '(') depth++;
109
+ else if (s[i] === ')') {
110
+ depth--;
111
+ if (depth === 0 && i < s.length - 1) return false;
112
+ }
113
+ }
114
+ return depth === 0;
115
+ }
116
+
117
+ /**
118
+ * A predicate parenthesized exactly once — the normal form both producers must agree on.
119
+ *
120
+ * `pg_get_expr` already deparses a policy predicate fully parenthesized, so `db:pull` renders
121
+ * one into a model verbatim. Wrapping it again produced a compiled predicate identical to
122
+ * live but for one layer of parens, which the equality check called a difference and turned
123
+ * into a same-name DROP + CREATE that changed nothing. Emission already knew this rule; the
124
+ * comparison did not, so both go through this.
125
+ */
126
+ export function parenthesizeOnce(expr: string): string {
127
+ return isWrappedExpression(expr) ? expr : `(${expr})`;
128
+ }
129
+
85
130
  /**
86
131
  * One function, enumerated. The cross-table authorization that RLS does not hold lives
87
132
  * in SECURITY DEFINER functions as imperative gates; the contract records that they
@@ -581,7 +626,9 @@ function diffPolicies(table: string, d: TableContract, l: TableContract, out: Dr
581
626
  if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}→${lp.permissive}`);
582
627
  if (dp.roles.join(',') !== lp.roles.join(',')) changes.push(`roles [${dp.roles}]→[${lp.roles}]`);
583
628
  if ((dp.using ?? '') !== (lp.using ?? '')) changes.push(`USING changed`);
584
- if ((dp.check ?? '') !== (lp.check ?? '')) changes.push(`WITH CHECK changed`);
629
+ // Compared through the server's own defaulting rule, so this agrees with
630
+ // emitReconcileSql — otherwise db:check reports drift the plan does not carry.
631
+ if ((effectivePolicyCheck(dp) ?? '') !== (effectivePolicyCheck(lp) ?? '')) changes.push(`WITH CHECK changed`);
585
632
  if (changes.length) {
586
633
  out.push({ subject: table, kind: 'policy', detail: `policy "${name}": ${changes.join(', ')}` });
587
634
  }
@@ -16,27 +16,110 @@
16
16
  */
17
17
 
18
18
  import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
19
+ import { effectivePolicyCheck, parenthesizeOnce } from './authz-contract.js';
19
20
  import { quoteQualified } from './pg-ident.js';
20
21
 
21
- /** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
22
- function isWrapped(s: string): boolean {
23
- if (!s.startsWith('(') || !s.endsWith(')')) return false;
24
- let depth = 0;
25
- for (let i = 0; i < s.length; i++) {
26
- if (s[i] === '(') depth++;
27
- else if (s[i] === ')') {
28
- depth--;
29
- if (depth === 0 && i < s.length - 1) return false;
22
+ /**
23
+ * Always governed, whatever the models say. `PUBLIC` because a grant to PUBLIC is the
24
+ * broadest privilege the database can express and must never be exemptible; the three
25
+ * vocabulary roles because they ARE the model's authz surface — letting a module drop one
26
+ * would be a way to hide a grant from the thing that audits it.
27
+ */
28
+ export const ALWAYS_GOVERNED = ['PUBLIC', 'admin', 'anon', 'authenticated'] as const;
29
+
30
+ /** One grantee the declared contract does not govern, with what it actually holds. */
31
+ export interface GrantExemption {
32
+ grantee: string;
33
+ table: string;
34
+ /** Table-level privileges. */
35
+ privileges: string[];
36
+ /** Column-scoped privileges, `privilege → columns`. Present only when there are any. */
37
+ columnPrivileges?: Record<string, string[]>;
38
+ }
39
+
40
+ /**
41
+ * The roles the declared authz governs: everything the models name, plus the always-governed
42
+ * set, plus whatever the modules widen it with. WIDEN-ONLY by construction — `declared` and
43
+ * {@link ALWAYS_GOVERNED} go in unconditionally, so `extra` can add and can never subtract.
44
+ *
45
+ * Grantee comparison is case-insensitive on `PUBLIC` only (PostgreSQL's pseudo-role, which
46
+ * the catalog reports uppercase and the models spell either way); real role names are
47
+ * case-sensitive because PostgreSQL's are.
48
+ */
49
+ export function governedRoleSet(declared: AuthzContract, extra: readonly string[] = []): Set<string> {
50
+ const governed = new Set<string>(ALWAYS_GOVERNED);
51
+ for (const t of declared.tables) {
52
+ for (const grantee of Object.keys(t.grants)) governed.add(grantee);
53
+ for (const grantee of Object.keys(t.columnGrants ?? {})) governed.add(grantee);
54
+ }
55
+ for (const role of extra) governed.add(role);
56
+ return governed;
57
+ }
58
+
59
+ const isGoverned = (governed: ReadonlySet<string>, grantee: string): boolean =>
60
+ governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
61
+
62
+ /**
63
+ * Every live grantee the declared contract does not govern, with its exact privileges —
64
+ * the enumeration that makes exempting them acceptable at all.
65
+ *
66
+ * The reconciler leaves these alone (a live migrator/ETL/BI role the model vocabulary
67
+ * cannot name must not have its access destroyed by a plan nobody read). That is only
68
+ * defensible because every artifact claiming to describe authz state reprints this list:
69
+ * exempt from reconciliation, never exempt from the report. Returns `grantee → table →
70
+ * privileges`, sorted, because a COUNT is not auditable.
71
+ */
72
+ export function ungovernedGrants(live: AuthzContract, governed: ReadonlySet<string>): GrantExemption[] {
73
+ const out: GrantExemption[] = [];
74
+ for (const t of live.tables) {
75
+ const grantees = new Set([...Object.keys(t.grants), ...Object.keys(t.columnGrants ?? {})]);
76
+ for (const grantee of [...grantees].sort()) {
77
+ if (isGoverned(governed, grantee)) continue;
78
+ const columnPrivileges = t.columnGrants?.[grantee];
79
+ out.push({
80
+ grantee,
81
+ table: t.table,
82
+ privileges: [...(t.grants[grantee] ?? [])].sort(),
83
+ ...(columnPrivileges && Object.keys(columnPrivileges).length > 0 ? { columnPrivileges } : {}),
84
+ });
30
85
  }
31
86
  }
32
- return depth === 0;
87
+ return out.sort((a, b) => a.grantee.localeCompare(b.grantee) || a.table.localeCompare(b.table));
33
88
  }
34
89
 
35
- /** A USING/WITH CHECK clause expression, parenthesized exactly once. */
36
- function clause(expr: string): string {
37
- return isWrapped(expr) ? expr : `(${expr})`;
90
+ /**
91
+ * The exemption report, one line per grantee — the shared rendering every surface prints.
92
+ *
93
+ * Grouped by grantee (an operator asks "what does this role have?", not "what does this
94
+ * table give?") and always EXACT: role, privileges, and the tables. `3 ungoverned roles`
95
+ * would not be auditable, which is the whole reason the exemption is allowed to exist.
96
+ */
97
+ export function renderGrantExemptions(exemptions: readonly GrantExemption[]): string[] {
98
+ const byGrantee = new Map<string, GrantExemption[]>();
99
+ for (const e of exemptions) byGrantee.set(e.grantee, [...(byGrantee.get(e.grantee) ?? []), e]);
100
+
101
+ const lines: string[] = [];
102
+ for (const [grantee, entries] of [...byGrantee.entries()].sort(([a], [b]) => a.localeCompare(b))) {
103
+ // The privilege set is usually uniform across the tables; say it once and list the
104
+ // tables, and only fall back to per-table lines when it genuinely differs.
105
+ const shapes = new Set(entries.map((e) => e.privileges.join(',')));
106
+ if (shapes.size === 1 && entries.length > 1) {
107
+ lines.push(`${grantee}: ${entries[0].privileges.join(', ')} on ${entries.length} table(s) — ${entries.map((e) => e.table).sort().join(', ')}`);
108
+ } else {
109
+ for (const e of entries.sort((a, b) => a.table.localeCompare(b.table))) {
110
+ const cols = e.columnPrivileges
111
+ ? `; column-scoped ${Object.entries(e.columnPrivileges).map(([p, c]) => `${p} (${c.join(', ')})`).join(', ')}`
112
+ : '';
113
+ lines.push(`${grantee}: ${e.privileges.join(', ') || 'no table-level privileges'} on ${e.table}${cols}`);
114
+ }
115
+ }
116
+ }
117
+ return lines;
38
118
  }
39
119
 
120
+ /** A USING/WITH CHECK clause expression, parenthesized exactly once. */
121
+ const clause = parenthesizeOnce;
122
+
40
123
  /** A CREATE POLICY statement from a policy descriptor. */
41
124
  function policyCreateSql(table: string, p: PolicyContract): string {
42
125
  const parts = [`CREATE POLICY ${p.name} ON ${table}`];
@@ -52,13 +135,19 @@ function policyDropSql(table: string, name: string): string {
52
135
  return `DROP POLICY IF EXISTS ${name} ON ${table};`;
53
136
  }
54
137
 
55
- /** Two policies are the same authorization when every diffed field matches. */
138
+ /**
139
+ * Two policies are the same authorization when every diffed field matches. The check is
140
+ * compared through {@link effectivePolicyCheck}, i.e. the server's own defaulting rule —
141
+ * a live `FOR ALL USING (true)` and a compiled `FOR ALL USING (true) WITH CHECK (true)`
142
+ * authorize identically, and reconciling them would emit a DROP + CREATE that changes
143
+ * nothing.
144
+ */
56
145
  function policiesEqual(a: PolicyContract, b: PolicyContract): boolean {
57
146
  return a.command === b.command
58
147
  && a.permissive === b.permissive
59
148
  && a.roles.join(',') === b.roles.join(',')
60
149
  && (a.using ?? '') === (b.using ?? '')
61
- && (a.check ?? '') === (b.check ?? '');
150
+ && (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
62
151
  }
63
152
 
64
153
  function tableMap(c: AuthzContract): Map<string, TableContract> {
@@ -72,9 +161,17 @@ function tableMap(c: AuthzContract): Map<string, TableContract> {
72
161
  * revoke/grant — so a replaced policy never briefly co-exists with its old form
73
162
  * and policies exist before grants reference the table.
74
163
  */
75
- export function emitReconcileSql(declared: AuthzContract, live: AuthzContract): string[] {
164
+ export function emitReconcileSql(
165
+ declared: AuthzContract,
166
+ live: AuthzContract,
167
+ opts: { governedRoles?: ReadonlySet<string> } = {},
168
+ ): string[] {
76
169
  const sql: string[] = [];
77
170
  const lTables = tableMap(live);
171
+ // Default: govern exactly what the models name (plus the always-governed set). A caller
172
+ // that passes nothing gets the safe-for-greenfield behaviour; a brownfield caller widens
173
+ // it with the modules' declared governedRoles.
174
+ const governed = opts.governedRoles ?? governedRoleSet(declared);
78
175
 
79
176
  for (const d of declared.tables) {
80
177
  const l = lTables.get(d.table);
@@ -83,8 +180,8 @@ export function emitReconcileSql(declared: AuthzContract, live: AuthzContract):
83
180
  const table = quoteQualified(d.table);
84
181
  reconcileRls(table, d, l, sql);
85
182
  reconcilePolicies(table, d, l, sql);
86
- reconcileGrants(table, d, l, sql);
87
- reconcileColumnGrants(table, d, l, sql);
183
+ reconcileGrants(table, d, l, sql, governed);
184
+ reconcileColumnGrants(table, d, l, sql, governed);
88
185
  }
89
186
 
90
187
  return sql;
@@ -108,6 +205,7 @@ export function emitReconcileSql(declared: AuthzContract, live: AuthzContract):
108
205
  */
109
206
  export function emitSwapAuthzSql(declared: AuthzContract): string[] {
110
207
  const sql: string[] = [];
208
+ const governed = governedRoleSet(declared);
111
209
  for (const d of declared.tables) {
112
210
  const table = quoteQualified(d.table);
113
211
  if (d.rls.enabled) sql.push(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
@@ -116,8 +214,10 @@ export function emitSwapAuthzSql(declared: AuthzContract): string[] {
116
214
  sql.push(policyDropSql(table, p.name)); // idempotent against the policy the dump restored
117
215
  sql.push(policyCreateSql(table, p));
118
216
  }
119
- reconcileGrants(table, d, undefined, sql);
120
- reconcileColumnGrants(table, d, undefined, sql);
217
+ // No live side here, so every grantee comes from the DECLARED contract and is
218
+ // governed by definition — the exemption can never fire on this path.
219
+ reconcileGrants(table, d, undefined, sql, governed);
220
+ reconcileColumnGrants(table, d, undefined, sql, governed);
121
221
  }
122
222
  return sql;
123
223
  }
@@ -145,9 +245,15 @@ function reconcilePolicies(table: string, d: TableContract, l: TableContract | u
145
245
  }
146
246
  }
147
247
 
148
- function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
248
+ function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
149
249
  const grantees = new Set([...Object.keys(d.grants), ...Object.keys(l?.grants ?? {})]);
150
250
  for (const grantee of [...grantees].sort()) {
251
+ // A live grantee the declared authz does not govern is LEFT ALONE. The union below
252
+ // would otherwise revoke every privilege it holds, because "no declaration" and "no
253
+ // privileges" are the same thing to a set difference — which is how a plan came to
254
+ // carry 32 revokes against a brownfield adopter's migration role. It is reported
255
+ // instead, by ungovernedGrants(), on every surface that describes authz state.
256
+ if (!isGoverned(governed, grantee)) continue;
151
257
  const declared = new Set(d.grants[grantee] ?? []);
152
258
  const live = new Set(l?.grants?.[grantee] ?? []);
153
259
  const target = grantee.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : grantee;
@@ -165,10 +271,13 @@ function reconcileGrants(table: string, d: TableContract, l: TableContract | und
165
271
  * those live-but-not-declared. This is how `auth.users` exposes id/email/role (and only those)
166
272
  * to `authenticated` without a whole-table grant. Columns are sorted, so a re-pull is a no-op.
167
273
  */
168
- function reconcileColumnGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
274
+ function reconcileColumnGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
169
275
  const dcg = d.columnGrants ?? {};
170
276
  const lcg = l?.columnGrants ?? {};
171
277
  for (const grantee of [...new Set([...Object.keys(dcg), ...Object.keys(lcg)])].sort()) {
278
+ // Same exemption as the table-level grants — a column grant to an ungoverned role is
279
+ // still that role's access, and revoking it breaks the same pipeline.
280
+ if (!isGoverned(governed, grantee)) continue;
172
281
  const target = grantee.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : grantee;
173
282
  const dPriv = dcg[grantee] ?? {};
174
283
  const lPriv = lcg[grantee] ?? {};