@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.
- package/package.json +2 -2
- package/src/cli/alter-type-dependents.ts +96 -0
- package/src/cli/apply-execute.ts +22 -8
- package/src/cli/authz-adoption-class.ts +314 -0
- package/src/cli/authz-baseline.ts +25 -3
- package/src/cli/authz-canonical.ts +178 -0
- package/src/cli/authz-compile.ts +130 -40
- package/src/cli/authz-contract.ts +212 -44
- package/src/cli/authz-derive.ts +244 -34
- package/src/cli/authz-identity.ts +222 -0
- package/src/cli/authz-ownership.ts +193 -0
- package/src/cli/authz-reconcile.ts +61 -27
- package/src/cli/aws.ts +32 -0
- package/src/cli/commands/db-apply.ts +60 -14
- package/src/cli/commands/db-authz.ts +9 -14
- package/src/cli/commands/db-fingerprint.ts +54 -18
- package/src/cli/commands/db-generate.ts +59 -15
- package/src/cli/commands/db-plan.ts +89 -9
- package/src/cli/commands/db-pull.ts +36 -19
- package/src/cli/commands/db-reconcile.ts +18 -20
- package/src/cli/commands/db-swap.ts +5 -4
- package/src/cli/commands/db-sync.ts +8 -5
- package/src/cli/db-build.ts +2 -2
- package/src/cli/db-source.ts +56 -0
- package/src/cli/derived-introspect.ts +27 -26
- package/src/cli/derived-lint.ts +7 -8
- package/src/cli/edge-plan.ts +125 -17
- package/src/cli/git-descent.ts +16 -9
- package/src/cli/index.ts +2 -18
- package/src/cli/model-render.ts +75 -52
- package/src/cli/output.ts +25 -3
- package/src/cli/parse-flags.ts +39 -0
- package/src/cli/schema-compile.ts +6 -1
- package/src/cli/schema-diff.ts +1 -1
- package/src/cli/schema-fingerprint.ts +154 -42
- package/src/cli/schema-introspect.ts +44 -17
- package/src/cli/schema-source.ts +9 -0
- package/src/cli/session.ts +184 -0
- package/src/cli/stage-read-consistency.ts +128 -0
- package/src/cli/state-apply.ts +4 -2
- package/src/cli/swap-execute.ts +4 -3
- 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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
]
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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}
|
|
626
|
-
if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}
|
|
627
|
-
if (dp.roles
|
|
628
|
-
if ((dp.using ?? '') !== (lp.using ?? '')) changes.push(
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
|