@everystack/cli 0.4.44 → 0.4.46

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.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/src/cli/alter-type-dependents.ts +96 -0
  3. package/src/cli/apply-execute.ts +22 -8
  4. package/src/cli/authz-adoption-class.ts +314 -0
  5. package/src/cli/authz-baseline.ts +25 -3
  6. package/src/cli/authz-canonical.ts +178 -0
  7. package/src/cli/authz-compile.ts +130 -40
  8. package/src/cli/authz-contract.ts +212 -44
  9. package/src/cli/authz-derive.ts +244 -34
  10. package/src/cli/authz-identity.ts +222 -0
  11. package/src/cli/authz-ownership.ts +193 -0
  12. package/src/cli/authz-reconcile.ts +61 -27
  13. package/src/cli/aws.ts +32 -0
  14. package/src/cli/commands/db-apply.ts +60 -14
  15. package/src/cli/commands/db-authz.ts +9 -14
  16. package/src/cli/commands/db-fingerprint.ts +54 -18
  17. package/src/cli/commands/db-generate.ts +59 -15
  18. package/src/cli/commands/db-plan.ts +89 -9
  19. package/src/cli/commands/db-pull.ts +36 -19
  20. package/src/cli/commands/db-reconcile.ts +18 -20
  21. package/src/cli/commands/db-swap.ts +5 -4
  22. package/src/cli/commands/db-sync.ts +8 -5
  23. package/src/cli/db-build.ts +2 -2
  24. package/src/cli/db-source.ts +56 -0
  25. package/src/cli/derived-introspect.ts +27 -26
  26. package/src/cli/derived-lint.ts +7 -8
  27. package/src/cli/edge-plan.ts +125 -17
  28. package/src/cli/git-descent.ts +16 -9
  29. package/src/cli/index.ts +2 -18
  30. package/src/cli/model-render.ts +75 -52
  31. package/src/cli/output.ts +25 -3
  32. package/src/cli/parse-flags.ts +39 -0
  33. package/src/cli/schema-compile.ts +6 -1
  34. package/src/cli/schema-diff.ts +1 -1
  35. package/src/cli/schema-fingerprint.ts +154 -42
  36. package/src/cli/schema-introspect.ts +44 -17
  37. package/src/cli/schema-source.ts +9 -0
  38. package/src/cli/session.ts +184 -0
  39. package/src/cli/stage-read-consistency.ts +128 -0
  40. package/src/cli/state-apply.ts +4 -2
  41. package/src/cli/swap-execute.ts +4 -3
  42. package/src/cli/search-path.ts +0 -51
@@ -22,7 +22,8 @@
22
22
  */
23
23
 
24
24
  import { parsePgArray } from './security-catalog.js';
25
- import { withCanonicalSearchPath } from './search-path.js';
25
+ import { INTROSPECTION_SESSION, type SessionRunner } from './session.js';
26
+ import { matchPolicies, roleSetEqual } from './authz-identity.js';
26
27
 
27
28
  // ---------------------------------------------------------------------------
28
29
  // The contract format — the frozen, reviewable, version-controlled shape.
@@ -100,6 +101,119 @@ export function effectivePolicyCheck(p: PolicyContract): string | null {
100
101
  return p.check;
101
102
  }
102
103
 
104
+ /**
105
+ * Does a role hold a privilege at the TABLE level? — the question "may we emit an ability?"
106
+ *
107
+ * `can('update')` compiles to a policy PLUS a table-wide `GRANT UPDATE`. So a role that holds
108
+ * UPDATE only on three columns does NOT satisfy this: rendering the ability would widen a
109
+ * column grant into a whole-table one. PUBLIC counts, in both catalog spellings, because a
110
+ * grant to PUBLIC really is held by every role.
111
+ */
112
+ export function holdsTablePrivilege(t: TableContract, role: string, privilege: string): boolean {
113
+ const direct = t.grants[role] ?? [];
114
+ const pub = t.grants.PUBLIC ?? t.grants.public ?? [];
115
+ return direct.includes(privilege) || pub.includes(privilege);
116
+ }
117
+
118
+ /**
119
+ * Does a role hold a privilege AT ALL? — the question "does this policy authorize anything?"
120
+ *
121
+ * The same question one layer wider: a COLUMN-scoped grant counts. `GRANT UPDATE (body) ON t
122
+ * TO authenticated` really does let that role update, so a policy governing UPDATE is doing
123
+ * live work even though the role holds no table-level UPDATE.
124
+ *
125
+ * TWO PREDICATES ON PURPOSE, and they answer different questions — that is why they are here
126
+ * together rather than one being written twice. `db:pull` used the narrow one to answer the
127
+ * wide question and told adopters a live, column-granted UPDATE policy was "dead code today,
128
+ * declaring it would ADD a privilege the database does not give". Both halves were false, and
129
+ * it was printed beside the model they were about to trust. Use {@link holdsTablePrivilege} to
130
+ * decide what to EMIT; use this to decide what is DEAD.
131
+ */
132
+ export function holdsPrivilege(t: TableContract, role: string, privilege: string): boolean {
133
+ if (holdsTablePrivilege(t, role, privilege)) return true;
134
+ const cols = t.columnGrants ?? {};
135
+ return Boolean(cols[role]?.[privilege]?.length)
136
+ || Boolean(cols.PUBLIC?.[privilege]?.length)
137
+ || Boolean(cols.public?.[privilege]?.length);
138
+ }
139
+
140
+ /** The four row-level commands a policy can govern. `FOR ALL` fans out across them. */
141
+ export const POLICY_COMMANDS = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
142
+
143
+ /** Every command a policy governs — `ALL` fans out, so a live `FOR ALL` is compared per command. */
144
+ export function policyCommands(p: PolicyContract): string[] {
145
+ return p.command === 'ALL' ? [...POLICY_COMMANDS] : [p.command];
146
+ }
147
+
148
+ /**
149
+ * Is this policy DEAD — does it authorize nothing at all?
150
+ *
151
+ * PostgreSQL checks the GRANT before the policy, so a policy governing a privilege none of
152
+ * its roles holds is inert: it cannot admit a single row today, and removing it takes away
153
+ * access that was never there. A PUBLIC policy applies to every grantee, so it is dead only
154
+ * when NO grantee holds the privilege.
155
+ *
156
+ * ONE definition, four callers — the classifier (what class is this statement?), the
157
+ * reconciler (should I drop it?), the canonical form (is it part of the state?), and db:pull
158
+ * (what do I tell the adopter?). They were drifting apart, which is this milestone's recurring
159
+ * bug: two surfaces answering the same question differently. Note the deadness is a property
160
+ * of the CONTRACT, not the policy — it is recomputed against live grants every time, so a
161
+ * policy RESURRECTS the moment a grant makes it effective.
162
+ */
163
+ export function isPolicyDead(t: TableContract, p: PolicyContract): boolean {
164
+ const roles = p.roles.includes('public') || p.roles.includes('PUBLIC')
165
+ ? Object.keys(t.grants)
166
+ : p.roles;
167
+ if (!roles.length) return true;
168
+ // `holdsPrivilege`, not the table-level one: a column-scoped grant is real access, so a
169
+ // policy governing it is doing live work. See the two-predicate note above.
170
+ return !policyCommands(p).some((cmd) => roles.some((r) => holdsPrivilege(t, r, cmd)));
171
+ }
172
+
173
+ /** Does this policy name PostgreSQL's PUBLIC pseudo-role — the open set of every role? */
174
+ export function isPublicPolicy(p: PolicyContract): boolean {
175
+ return p.roles.some((r) => r.toUpperCase() === 'PUBLIC');
176
+ }
177
+
178
+ /**
179
+ * A policy's RULE, with the roles and the NAME deliberately absent — what it authorizes, not
180
+ * whom it names or what it is called. The same normalization the canonical form hashes by, so
181
+ * "these two policies say the same thing" has one answer.
182
+ */
183
+ export function policyRuleKey(p: PolicyContract): string {
184
+ return JSON.stringify([p.command, p.permissive, p.using ?? '', effectivePolicyCheck(p) ?? '']);
185
+ }
186
+
187
+ /**
188
+ * Is this policy SUBSUMED — does an identical rule already reach every role it names?
189
+ *
190
+ * PERMISSIVE policies OR together, and a policy TO PUBLIC applies to every role there is. So a
191
+ * role-scoped policy whose rule is character-for-character a PUBLIC policy's rule contributes
192
+ * nothing: every session it would admit is already admitted. Dropping it changes no access,
193
+ * which is why it is churn rather than a change — a brownfield database often carries both,
194
+ * one from the framework and one from a migration written years earlier.
195
+ *
196
+ * Two guards, and they are the whole safety of this:
197
+ *
198
+ * - **RESTRICTIVE policies are never subsumed.** They AND rather than OR, so a restrictive
199
+ * policy is the thing NARROWING access; removing it would widen. Only permissive policies
200
+ * can be redundant, and only a permissive PUBLIC policy can make them so.
201
+ * - **The rule must match exactly**, including the effective WITH CHECK. A PUBLIC policy with
202
+ * a laxer predicate does not subsume a stricter role-scoped one — under OR the lax rule
203
+ * already wins, but the two are not the same state, and hashing them equal would report
204
+ * MATCH while the reconciler still had work.
205
+ */
206
+ export function isPolicySubsumed(t: TableContract, p: PolicyContract): boolean {
207
+ if (!p.permissive || isPublicPolicy(p)) return false;
208
+ const key = policyRuleKey(p);
209
+ // No self-exclusion by NAME: `p` is asked about against BOTH the live and the declared
210
+ // contract (subsumed before and after), and the compiler routinely picks the same policy
211
+ // name the brownfield database already uses — so a name guard here would silently exclude
212
+ // the very PUBLIC policy doing the covering. It is not needed: `p` is non-PUBLIC by the
213
+ // line above and every candidate is PUBLIC, so `p` can never match itself.
214
+ return t.policies.some((o) => o.permissive && isPublicPolicy(o) && policyRuleKey(o) === key);
215
+ }
216
+
103
217
  /** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
104
218
  export function isWrappedExpression(s: string): boolean {
105
219
  if (!s.startsWith('(') || !s.endsWith(')')) return false;
@@ -479,27 +593,23 @@ export function assembleContract(rows: ContractRows): AuthzContract {
479
593
 
480
594
  /** Run every introspection query through one runner and assemble the contract. */
481
595
  export async function introspectContract(
482
- run: QueryRunner,
596
+ session: SessionRunner,
483
597
  mapFunctionRow: (row: any) => { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean },
484
598
  functionsSql: string,
485
599
  ): Promise<AuthzContract> {
486
- // Policy USING/WITH CHECK expressions deparse relative to the search_path pin the
487
- // canonical baseline so an ambient non-default path never reads as false authz drift.
488
- return withCanonicalSearchPath(run, async () => {
489
- const [rls, policies, grants, columnGrants, fnRows] = await Promise.all([
490
- run(RLS_SQL),
491
- run(POLICIES_SQL),
492
- run(GRANTS_SQL),
493
- run(COLUMN_GRANTS_SQL),
494
- run(functionsSql),
495
- ]);
496
- return assembleContract({
497
- rls: rls as RlsRow[],
498
- policies: policies as PolicyRow[],
499
- grants: grants as GrantRow[],
500
- columnGrants: columnGrants as ColumnGrantRow[],
501
- functions: (fnRows as any[]).map(mapFunctionRow),
502
- });
600
+ // ONE session: the five queries describe one moment under one pinned search_path.
601
+ // Policy USING / WITH CHECK expressions deparse relative to that path, so a read spread
602
+ // 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],
605
+ INTROSPECTION_SESSION,
606
+ );
607
+ return assembleContract({
608
+ rls: rls as RlsRow[],
609
+ policies: policies as PolicyRow[],
610
+ grants: grants as GrantRow[],
611
+ columnGrants: columnGrants as ColumnGrantRow[],
612
+ functions: (fnRows as any[]).map(mapFunctionRow),
503
613
  });
504
614
  }
505
615
 
@@ -509,6 +619,24 @@ export async function introspectContract(
509
619
 
510
620
  export type DriftSeverity = 'drift';
511
621
 
622
+ /** A live policy kept under its own name because it already carries the declared rule. */
623
+ export interface PolicyAdoption {
624
+ /** Schema-qualified table. */
625
+ subject: string;
626
+ /** The policy name(s) the model would have created. */
627
+ declared: string[];
628
+ /** The name the database uses, and keeps. */
629
+ live: string;
630
+ /** Why it was accepted, in plain words. */
631
+ reason: string;
632
+ }
633
+
634
+ /** Drift, plus the adoptions that are deliberately NOT drift. */
635
+ export interface ContractComparison {
636
+ findings: DriftFinding[];
637
+ adoptions: PolicyAdoption[];
638
+ }
639
+
512
640
  export interface DriftFinding {
513
641
  /** Schema-qualified table, or `fn:<name>` for a function finding. */
514
642
  subject: string;
@@ -528,7 +656,20 @@ function tableMap(c: AuthzContract): Map<string, TableContract> {
528
656
  * Returns every discrepancy; an empty list means the live DB matches the declaration.
529
657
  */
530
658
  export function diffContracts(declared: AuthzContract, live: AuthzContract): DriftFinding[] {
659
+ return compareContracts(declared, live).findings;
660
+ }
661
+
662
+ /**
663
+ * The full comparison: drift AND the name divergences accepted by rule.
664
+ *
665
+ * `diffContracts` returns only the findings, because every caller treats a finding as drift
666
+ * and exits non-zero. An adoption is the opposite of drift, so it cannot travel in that list —
667
+ * but it must still be SHOWN, or the mapping between the model's name and the database's name
668
+ * becomes tribal knowledge.
669
+ */
670
+ export function compareContracts(declared: AuthzContract, live: AuthzContract): ContractComparison {
531
671
  const findings: DriftFinding[] = [];
672
+ const adoptions: PolicyAdoption[] = [];
532
673
  const dTables = tableMap(declared);
533
674
  const lTables = tableMap(live);
534
675
 
@@ -546,7 +687,7 @@ export function diffContracts(declared: AuthzContract, live: AuthzContract): Dri
546
687
  }
547
688
  diffGrants(name, d, l, findings);
548
689
  diffColumnGrants(name, d, l, findings);
549
- diffPolicies(name, d, l, findings);
690
+ diffPolicies(name, d, l, findings, adoptions);
550
691
  }
551
692
  for (const name of lTables.keys()) {
552
693
  if (!dTables.has(name)) {
@@ -575,7 +716,7 @@ export function diffContracts(declared: AuthzContract, live: AuthzContract): Dri
575
716
  }
576
717
  }
577
718
 
578
- return findings;
719
+ return { findings, adoptions };
579
720
  }
580
721
 
581
722
  function diffGrants(table: string, d: TableContract, l: TableContract, out: DriftFinding[]): void {
@@ -612,31 +753,58 @@ function diffColumnGrants(table: string, d: TableContract, l: TableContract, out
612
753
  }
613
754
  }
614
755
 
615
- function diffPolicies(table: string, d: TableContract, l: TableContract, out: DriftFinding[]): void {
616
- const dPol = new Map(d.policies.map((p) => [p.name, p]));
617
- const lPol = new Map(l.policies.map((p) => [p.name, p]));
618
- for (const [name, dp] of dPol) {
619
- const lp = lPol.get(name);
620
- if (!lp) {
621
- out.push({ subject: table, kind: 'policy', detail: `policy "${name}" declared but missing from the live database` });
622
- continue;
623
- }
756
+ /**
757
+ * Diff policies through the SHARED matcher — the same equivalence `emitReconcileSql` and the
758
+ * fingerprint use. A rule-identical live policy under a different name is not drift: the
759
+ * database already authorizes exactly what the model declares.
760
+ *
761
+ * Adoptions travel on their own channel, not as findings. A finding means drift and drift
762
+ * means exit 1, while an adoption is agreement. It is never SILENT though — once a live policy
763
+ * keeps its own name, the name the model shows and the name a human greps for have diverged,
764
+ * and that mapping has to stay machine-derived and visible.
765
+ */
766
+ function diffPolicies(
767
+ table: string,
768
+ d: TableContract,
769
+ l: TableContract,
770
+ out: DriftFinding[],
771
+ adoptions: PolicyAdoption[],
772
+ ): void {
773
+ const m = matchPolicies(d.policies, l.policies);
774
+
775
+ for (const a of m.adopted) {
776
+ adoptions.push({ subject: table, declared: [a.declared], live: a.live, reason: 'same rule, different name' });
777
+ }
778
+ for (const g of m.adoptedGroups) {
779
+ adoptions.push({ subject: table, declared: g.declared, live: g.live, reason: 'one live policy covers the declared per-role group' });
780
+ }
781
+
782
+ // A name on BOTH sides whose rule moved is a CHANGE, not a remove-and-add. Saying which
783
+ // field moved is the difference between a finding an operator can act on and one they have
784
+ // to go read the catalog to understand.
785
+ const createdByName = new Map(m.toCreate.map((p) => [p.name, p]));
786
+ const changed = new Set<string>();
787
+ for (const name of m.toDrop) {
788
+ const dp = createdByName.get(name);
789
+ if (!dp) continue;
790
+ const lp = l.policies.find((p) => p.name === name)!;
791
+ changed.add(name);
624
792
  const changes: string[] = [];
625
- if (dp.command !== lp.command) changes.push(`command ${dp.command}→${lp.command}`);
626
- if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}→${lp.permissive}`);
627
- if (dp.roles.join(',') !== lp.roles.join(',')) changes.push(`roles [${dp.roles}][${lp.roles}]`);
628
- if ((dp.using ?? '') !== (lp.using ?? '')) changes.push(`USING 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`);
632
- if (changes.length) {
633
- out.push({ subject: table, kind: 'policy', detail: `policy "${name}": ${changes.join(', ')}` });
634
- }
793
+ if (dp.command !== lp.command) changes.push(`command ${dp.command}\u2192${lp.command}`);
794
+ if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}\u2192${lp.permissive}`);
795
+ if (!roleSetEqual(dp.roles, lp.roles)) changes.push(`roles [${dp.roles}]\u2192[${lp.roles}]`);
796
+ if ((dp.using ?? '') !== (lp.using ?? '')) changes.push('USING changed');
797
+ if ((effectivePolicyCheck(dp) ?? '') !== (effectivePolicyCheck(lp) ?? '')) changes.push('WITH CHECK changed');
798
+ out.push({ subject: table, kind: 'policy', detail: `policy "${name}": ${changes.join(', ')}` });
635
799
  }
636
- for (const name of lPol.keys()) {
637
- if (!dPol.has(name)) {
638
- out.push({ subject: table, kind: 'policy', detail: `policy "${name}" exists live but is not declared (undeclared policy)` });
639
- }
800
+
801
+ for (const p of m.toCreate) {
802
+ if (changed.has(p.name)) continue;
803
+ out.push({ subject: table, kind: 'policy', detail: `policy "${p.name}" declared but missing from the live database` });
804
+ }
805
+ for (const name of m.toDrop) {
806
+ if (changed.has(name)) continue;
807
+ out.push({ subject: table, kind: 'policy', detail: `policy "${name}" exists live but is not declared (undeclared policy)` });
640
808
  }
641
809
  }
642
810