@everystack/cli 0.4.72 → 0.4.74
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/CHANGELOG.md +27 -0
- package/package.json +1 -1
- package/src/cli/adopt.ts +58 -0
- package/src/cli/apply-execute.ts +8 -4
- package/src/cli/authz-canonical.ts +146 -54
- package/src/cli/authz-contract.ts +151 -7
- package/src/cli/authz-reconcile.ts +58 -29
- package/src/cli/commands/db-adopt.ts +227 -0
- package/src/cli/commands/db-fingerprint.ts +24 -5
- package/src/cli/derived-grants.ts +20 -13
- package/src/cli/derived-plan.ts +15 -5
- package/src/cli/edge-plan.ts +24 -3
- package/src/cli/git-descent.ts +17 -4
- package/src/cli/index.ts +5 -0
- package/src/cli/live-models.ts +65 -0
- package/src/cli/schema-fingerprint-v10.ts +152 -0
- package/src/cli/schema-fingerprint.ts +150 -31
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,33 @@ All notable changes to `@everystack/cli` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.74] - 2026-09-06
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- P1.6 — bump fingerprint format to v11 (command fan + membership deadness + sequence ACLs)
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- P2a (core) — proveAdoption, the prove-don't-attest heart of db:adopt
|
|
16
|
+
- P2a-shell — db:adopt command (prove-don't-attest first adoption)
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- P1.1 — canonical authz fans the command dimension
|
|
21
|
+
- P1.3 — reconciler and drift gate agree with the fingerprint on the command fan
|
|
22
|
+
- P1.2 — deadness folds role membership on the live side (Condition A)
|
|
23
|
+
- P1.5 — serial-sequence ACLs enter the fingerprint (Bug 3)
|
|
24
|
+
- P1.4 — reconcile fallback orders narrowing before widening (Condition C)
|
|
25
|
+
- P2b — an ancestor with no models barrel declares nothing, not a compile failure
|
|
26
|
+
- P2c/P2d — drift refusal becomes the adopt runbook; base fingerprint excludes everystack schema
|
|
27
|
+
|
|
28
|
+
## [0.4.73] - 2026-09-04
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- db:reconcile converges an owned relation's self-grant
|
|
33
|
+
|
|
7
34
|
## [0.4.72] - 2026-09-04
|
|
8
35
|
|
|
9
36
|
### Fixed
|
package/package.json
CHANGED
package/src/cli/adopt.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adopt — the PROVE core of `db:adopt` (P2a). Pure: live introspection + compiled Models in, a
|
|
3
|
+
* verdict out. The command shell (commands/db-adopt.ts) does the IO — introspect, render, load,
|
|
4
|
+
* write files, print the commit.
|
|
5
|
+
*
|
|
6
|
+
* The doctrine (Fable-ruled 2026-09-05): PROVE, not attest. There is no bypass flag. db:adopt
|
|
7
|
+
* renders Models from a live database and may finalize them ONLY when the compiled Models declare
|
|
8
|
+
* that database EXACTLY — the same declares-test descent and db:check use (predicted == governed
|
|
9
|
+
* live). A commit that carries such Models legitimately declares the live state, so a later
|
|
10
|
+
* db:apply descends for real (proveDescent finds the adopt commit) instead of being forced past a
|
|
11
|
+
* drift refusal. When the round-trip is not exact, adoption REFUSES and names what diverged —
|
|
12
|
+
* because a baseline that silently papered over a difference is the bypass under another name.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
16
|
+
import type { SchemaSnapshot } from './schema-introspect';
|
|
17
|
+
import type { AuthzContract } from './authz-contract';
|
|
18
|
+
import { governedRolesForModels, fingerprintState, fingerprintLive, divergentSurfaces } from './schema-fingerprint';
|
|
19
|
+
import { predictLiveState, governedLiveFingerprint } from './edge-plan';
|
|
20
|
+
|
|
21
|
+
export interface AdoptionProof {
|
|
22
|
+
/** True when the compiled Models declare the live state exactly — safe to finalize the baseline. */
|
|
23
|
+
clean: boolean;
|
|
24
|
+
/** The RAW live fingerprint — the baseline id that names the adopt commit (`adopt live baseline <fp>`). */
|
|
25
|
+
fingerprint: string;
|
|
26
|
+
/** When not clean, the surfaces that still diverge (base schema / authorization / sequence ACLs,
|
|
27
|
+
* with the tables named), so the refusal says exactly what the render could not capture. Empty when clean. */
|
|
28
|
+
divergentSurfaces: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Does this compiled Models set declare the live state exactly? The declares-test, governed on
|
|
33
|
+
* both sides through the SAME set the Models imply — a governed prediction against a raw live
|
|
34
|
+
* hash can never converge on a brownfield database with foreign roles. Reuses the very functions
|
|
35
|
+
* descent (`evaluateTree`) and db:fingerprint compare with, so "adoption is clean" and "the plan
|
|
36
|
+
* is empty" are one answer, not two that can disagree.
|
|
37
|
+
*/
|
|
38
|
+
export function proveAdoption(
|
|
39
|
+
models: ModelDescriptor[],
|
|
40
|
+
snapshot: SchemaSnapshot,
|
|
41
|
+
contract: AuthzContract,
|
|
42
|
+
opts: { schema?: string } = {},
|
|
43
|
+
): AdoptionProof {
|
|
44
|
+
const governedRoles = governedRolesForModels(models);
|
|
45
|
+
const predicted = predictLiveState(models, snapshot, contract, { schema: opts.schema, governedRoles });
|
|
46
|
+
const governedLiveHash = governedLiveFingerprint(snapshot, contract, governedRoles);
|
|
47
|
+
const clean = predicted.hash === governedLiveHash;
|
|
48
|
+
return {
|
|
49
|
+
clean,
|
|
50
|
+
fingerprint: fingerprintLive(snapshot, contract).hash,
|
|
51
|
+
divergentSurfaces: clean
|
|
52
|
+
? []
|
|
53
|
+
: divergentSurfaces(
|
|
54
|
+
predicted.canonical,
|
|
55
|
+
fingerprintState(snapshot, contract.tables, { governedRoles, sequenceGrants: contract.sequenceGrants }).canonical,
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
}
|
package/src/cli/apply-execute.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
POLICIES_SQL,
|
|
37
37
|
GRANTS_SQL,
|
|
38
38
|
COLUMN_GRANTS_SQL,
|
|
39
|
+
EFFECTIVE_PRIVILEGES_SQL,
|
|
39
40
|
SCHEMA_ACL_SQL,
|
|
40
41
|
SEQUENCE_ACLS_SQL,
|
|
41
42
|
type QueryRunner,
|
|
@@ -245,7 +246,7 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
|
|
|
245
246
|
const results = await session(
|
|
246
247
|
[
|
|
247
248
|
COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL, EXTENSIONS_SQL,
|
|
248
|
-
RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, FUNCTIONS_SQL,
|
|
249
|
+
RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, EFFECTIVE_PRIVILEGES_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, FUNCTIONS_SQL,
|
|
249
250
|
{ sql: SCHEMA_LOG_WITNESS_SQL, allowFailure: true },
|
|
250
251
|
],
|
|
251
252
|
INTROSPECTION_SESSION,
|
|
@@ -254,12 +255,15 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
|
|
|
254
255
|
columns: results[0] as any[], constraints: results[1] as any[], enums: results[2] as any[],
|
|
255
256
|
indexes: results[3] as any[], sequences: results[4] as any[], extensions: results[5] as any[],
|
|
256
257
|
});
|
|
258
|
+
// The authz block gained EFFECTIVE_PRIVILEGES_SQL (index 10) so this fingerprint uses the SAME
|
|
259
|
+
// membership-folded deadness the plan's endpoints do — the concurrency lock compares them.
|
|
257
260
|
const contract = assembleContract({
|
|
258
261
|
rls: results[6] as any[], policies: results[7] as any[], grants: results[8] as any[],
|
|
259
|
-
columnGrants: results[9] as any[],
|
|
260
|
-
|
|
262
|
+
columnGrants: results[9] as any[], effectivePrivileges: results[10] as any[],
|
|
263
|
+
schemaAcls: results[11] as any[], sequenceAcls: results[12] as any[],
|
|
264
|
+
functions: (results[13] as any[]).map(contractFunctionRow),
|
|
261
265
|
});
|
|
262
|
-
const ledger = mapLedgerRows(rowsOrEmpty(results[
|
|
266
|
+
const ledger = mapLedgerRows(rowsOrEmpty(results[14]));
|
|
263
267
|
return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract, ledger };
|
|
264
268
|
}
|
|
265
269
|
|
|
@@ -32,41 +32,61 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import type { PolicyContract, TableContract } from './authz-contract';
|
|
35
|
-
import {
|
|
35
|
+
import { holdsPrivilegeEffective, policyCommands } from './authz-contract';
|
|
36
36
|
|
|
37
37
|
/** PostgreSQL's open role set, kept whole. */
|
|
38
38
|
const PUBLIC_ROLE = 'public';
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* One
|
|
42
|
-
*
|
|
41
|
+
* One (role, command) arm of a policy — the grain the canonical form compares at.
|
|
42
|
+
*
|
|
43
|
+
* A single `FOR ALL` policy governs four commands; the compiler that pulls it back writes one
|
|
44
|
+
* granular policy per command the role can exercise. Comparing the `command` field verbatim
|
|
45
|
+
* made those two states — the same authorization, written two ways — read as different, so a
|
|
46
|
+
* pulled brownfield table never round-tripped. Fanning the command dimension the way roles are
|
|
47
|
+
* already fanned is what makes them converge.
|
|
43
48
|
*/
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
interface PolicyArm {
|
|
50
|
+
/** Expanded role name, or the `public` sentinel. */
|
|
51
|
+
role: string;
|
|
52
|
+
/** The single command this arm governs — never `ALL`; `FOR ALL` fans to four arms. */
|
|
53
|
+
command: string;
|
|
54
|
+
permissive: boolean;
|
|
55
|
+
/** The USING predicate PG enforces FOR THIS COMMAND (`''` where the command has none). */
|
|
56
|
+
using: string;
|
|
57
|
+
/** The WITH CHECK predicate PG enforces FOR THIS COMMAND (`''` where the command has none). */
|
|
58
|
+
check: string;
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* carries the declared authorization under its own name read as a different state — so an
|
|
57
|
-
* adopter could reach zero statements and never reach MATCH.
|
|
62
|
+
* Fan one policy into its (role, command) arms, each carrying the predicate in exactly the slot
|
|
63
|
+
* PostgreSQL enforces for that command — the NAME deliberately absent (a brownfield tool chose
|
|
64
|
+
* it, so hashing it made a live policy carrying the declared authorization read as a different
|
|
65
|
+
* state) and `ALL` expanded across the four row-level commands (so a live `FOR ALL` and the
|
|
66
|
+
* granular per-command policies a pull produces look equal).
|
|
58
67
|
*
|
|
59
|
-
* The
|
|
60
|
-
*
|
|
61
|
-
*
|
|
68
|
+
* The per-command slots, and why each is what it is:
|
|
69
|
+
* - SELECT / DELETE have NO `WITH CHECK` — check is `''`.
|
|
70
|
+
* - INSERT has NO `USING` — using is `''`; its check reuses `USING` when the source is a
|
|
71
|
+
* `FOR ALL` with no explicit check (`p.check ?? p.using`), the server's own defaulting rule.
|
|
72
|
+
* - UPDATE carries both, its check defaulting to `USING` the same way.
|
|
73
|
+
* This makes a `FOR ALL USING (pred)` fan to an INSERT arm whose check is `pred` — identical to
|
|
74
|
+
* the granular `INSERT ... WITH CHECK (pred)` a pull emits.
|
|
62
75
|
*/
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
p.using ?? ''
|
|
68
|
-
|
|
69
|
-
|
|
76
|
+
function policyArms(p: PolicyContract): PolicyArm[] {
|
|
77
|
+
const roles = p.roles.includes(PUBLIC_ROLE) ? [PUBLIC_ROLE] : [...p.roles].sort();
|
|
78
|
+
const arms: PolicyArm[] = [];
|
|
79
|
+
for (const command of policyCommands(p)) {
|
|
80
|
+
const using = command === 'INSERT' ? '' : (p.using ?? '');
|
|
81
|
+
const check = command === 'SELECT' || command === 'DELETE' ? '' : ((p.check ?? p.using) ?? '');
|
|
82
|
+
for (const role of roles) arms.push({ role, command, permissive: p.permissive, using, check });
|
|
83
|
+
}
|
|
84
|
+
return arms;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The arm's canonical string — `role|[command,permissive,using,check]`, name and roles absent. */
|
|
88
|
+
function armKey(a: PolicyArm): string {
|
|
89
|
+
return `${a.role}|${JSON.stringify([a.command, a.permissive, a.using, a.check])}`;
|
|
70
90
|
}
|
|
71
91
|
|
|
72
92
|
/** Sorted multiset: every entry kept, duplicates counted, order irrelevant. */
|
|
@@ -76,28 +96,86 @@ function multiset(entries: string[]): Array<[string, number]> {
|
|
|
76
96
|
return [...counts.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
|
|
77
97
|
}
|
|
78
98
|
|
|
99
|
+
/** Keep only the arms whose role the models govern; PUBLIC is a governed sentinel, always kept. */
|
|
100
|
+
function governedArm(governed: ReadonlySet<string>, role: string): boolean {
|
|
101
|
+
return governed.has(role) || role === PUBLIC_ROLE || (role.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
|
|
102
|
+
}
|
|
103
|
+
|
|
79
104
|
/**
|
|
80
|
-
* The canonical policy form: every policy
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
105
|
+
* The canonical policy form: every policy fanned into its (role, command) arms, as a sorted
|
|
106
|
+
* multiset. NO deadness or subsumption is applied here — this is the pure fan, used where the
|
|
107
|
+
* grants are not in hand (predicate-normalization equivalence). {@link canonicalAuthz} carries
|
|
108
|
+
* the contract and does the per-arm deadness and subsumption the state needs.
|
|
109
|
+
*
|
|
110
|
+
* When a governed set is supplied (hashing a LIVE contract), arms for roles the models do not
|
|
111
|
+
* govern are dropped — the SAME choice the reconciler makes when it leaves an ungoverned role's
|
|
112
|
+
* policy alone (the `reconcilePolicies` ungoverned escape). Hashing them would keep MATCH
|
|
113
|
+
* unreachable on any brownfield database whose previous stack left a policy TO a migrator role:
|
|
114
|
+
* zero statements, mismatched hash, forever. PUBLIC is a governed sentinel and always kept.
|
|
88
115
|
*/
|
|
89
116
|
export function canonicalPolicies(
|
|
90
117
|
policies: readonly PolicyContract[],
|
|
91
118
|
governed?: ReadonlySet<string>,
|
|
92
119
|
): Array<[string, number]> {
|
|
93
|
-
const
|
|
94
|
-
const kept = governed
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
120
|
+
const arms = policies.flatMap(policyArms);
|
|
121
|
+
const kept = governed ? arms.filter((a) => governedArm(governed, a.role)) : arms;
|
|
122
|
+
return multiset(kept.map(armKey));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Is this arm DEAD — does its (role, command) pair authorize nothing?
|
|
127
|
+
*
|
|
128
|
+
* PostgreSQL checks the GRANT before the policy, so an arm governing a command the role does not
|
|
129
|
+
* hold is inert. Per ARM, not per policy: a `FOR ALL` on a role holding SELECT/INSERT/UPDATE but
|
|
130
|
+
* not DELETE keeps three live arms and drops the dead DELETE one — which is exactly what makes it
|
|
131
|
+
* converge with a 3-granular pull, while a role that DOES hold DELETE keeps four and stays
|
|
132
|
+
* distinct. A PUBLIC arm applies to every grantee, so it is dead only when NO grantee holds the
|
|
133
|
+
* command. `holdsPrivilegeEffective` folds role membership + PUBLIC + column grants on the live
|
|
134
|
+
* side (and the direct grants on a declared one), the same oracle the reconciler uses.
|
|
135
|
+
*/
|
|
136
|
+
function armDead(t: TableContract, a: PolicyArm): boolean {
|
|
137
|
+
if (a.role === PUBLIC_ROLE || a.role.toUpperCase() === 'PUBLIC') {
|
|
138
|
+
const grantees = t.effectivePrivileges
|
|
139
|
+
? Object.keys(t.effectivePrivileges)
|
|
140
|
+
: [...new Set([...Object.keys(t.grants), ...Object.keys(t.columnGrants ?? {})])];
|
|
141
|
+
return !grantees.some((r) => holdsPrivilegeEffective(t, r, a.command));
|
|
142
|
+
}
|
|
143
|
+
return !holdsPrivilegeEffective(t, a.role, a.command);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Is this arm SUBSUMED — does an identical PUBLIC arm already reach its role?
|
|
148
|
+
*
|
|
149
|
+
* Permissive arms OR together and a PUBLIC arm applies to every role, so a role-scoped arm whose
|
|
150
|
+
* (command, using, check) is character-for-character a live PUBLIC arm's admits no session the
|
|
151
|
+
* other does not. Only among ALIVE arms — a dead PUBLIC arm covers nothing. Restrictive arms
|
|
152
|
+
* are never subsumed (they AND, so removing one would WIDEN); a PUBLIC arm is never subsumed
|
|
153
|
+
* (it is the coverer).
|
|
154
|
+
*/
|
|
155
|
+
function armSubsumed(a: PolicyArm, alive: readonly PolicyArm[]): boolean {
|
|
156
|
+
if (!a.permissive || a.role === PUBLIC_ROLE || a.role.toUpperCase() === 'PUBLIC') return false;
|
|
157
|
+
return alive.some((o) =>
|
|
158
|
+
(o.role === PUBLIC_ROLE || o.role.toUpperCase() === 'PUBLIC')
|
|
159
|
+
&& o.permissive && o.command === a.command && o.using === a.using && o.check === a.check);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The canonical policy form WITH the state exclusions: fan to arms, drop the dead ones (an arm
|
|
164
|
+
* whose grant does not exist), drop the subsumed ones (a role-scoped arm a PUBLIC arm already
|
|
165
|
+
* covers), then filter to the governed roles. Both exclusions are the same idea the reconciler
|
|
166
|
+
* makes in lockstep — an arm that changes no access is not part of the state — now made per
|
|
167
|
+
* (role, command) instead of per whole policy, so a `FOR ALL` with one dead command arm and the
|
|
168
|
+
* granular pull that omits that command hash equal.
|
|
169
|
+
*/
|
|
170
|
+
function canonicalPolicyArms(
|
|
171
|
+
contract: TableContract,
|
|
172
|
+
governed?: ReadonlySet<string>,
|
|
173
|
+
): Array<[string, number]> {
|
|
174
|
+
const alive = contract.policies.flatMap(policyArms).filter((a) => !armDead(contract, a));
|
|
175
|
+
const kept = alive
|
|
176
|
+
.filter((a) => !armSubsumed(a, alive))
|
|
177
|
+
.filter((a) => (governed ? governedArm(governed, a.role) : true));
|
|
178
|
+
return multiset(kept.map(armKey));
|
|
101
179
|
}
|
|
102
180
|
|
|
103
181
|
/**
|
|
@@ -124,6 +202,25 @@ function governedOnly<T>(
|
|
|
124
202
|
);
|
|
125
203
|
}
|
|
126
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Are two tables' policies the SAME authorization under the canonical fan?
|
|
207
|
+
*
|
|
208
|
+
* The equivalence relation the fingerprint hashes, exposed as a boolean for the reconciler and
|
|
209
|
+
* the drift gate. Gating those two on THIS — rather than a per-command field compare — is what
|
|
210
|
+
* keeps all three surfaces from ever contradicting each other about the same database: a live
|
|
211
|
+
* `FOR ALL` with a dead DELETE arm and the granular pull that omits DELETE are equal here, so
|
|
212
|
+
* the reconciler emits nothing and the differ reports no drift, exactly as the fingerprint says
|
|
213
|
+
* MATCH. When it is false the callers fall back to the per-policy matcher, which can only ever
|
|
214
|
+
* over-emit (churn), never under-emit (a silent authorization difference).
|
|
215
|
+
*/
|
|
216
|
+
export function tablePoliciesEquivalent(
|
|
217
|
+
a: TableContract,
|
|
218
|
+
b: TableContract,
|
|
219
|
+
governed?: ReadonlySet<string>,
|
|
220
|
+
): boolean {
|
|
221
|
+
return JSON.stringify(canonicalPolicyArms(a, governed)) === JSON.stringify(canonicalPolicyArms(b, governed));
|
|
222
|
+
}
|
|
223
|
+
|
|
127
224
|
/**
|
|
128
225
|
* The canonical authorization form for one table.
|
|
129
226
|
*
|
|
@@ -160,19 +257,14 @@ export function canonicalAuthz(
|
|
|
160
257
|
),
|
|
161
258
|
}
|
|
162
259
|
: {}),
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
// state — and both are made in lockstep with the reconciler, from the same functions.
|
|
173
|
-
policies: canonicalPolicies(
|
|
174
|
-
contract.policies.filter((p) => !isPolicyDead(contract, p) && !isPolicySubsumed(contract, p)),
|
|
175
|
-
governed,
|
|
176
|
-
),
|
|
260
|
+
// Policies fan to (role, command) ARMS; a DEAD arm (its grant does not exist) and a
|
|
261
|
+
// SUBSUMED arm (a PUBLIC arm already covers it) are not part of the state, so both are
|
|
262
|
+
// dropped before hashing — the same choices the reconciler makes, now made per command
|
|
263
|
+
// instead of per whole policy. That per-command grain is what makes a live `FOR ALL` with
|
|
264
|
+
// one dead command arm hash equal to the granular pull that omits that command, while a
|
|
265
|
+
// `FOR ALL` whose every arm is alive stays distinct from a set that denies one command.
|
|
266
|
+
// Deadness is recomputed against these grants every time, so an arm re-enters the state the
|
|
267
|
+
// moment a grant revives it.
|
|
268
|
+
policies: canonicalPolicyArms(contract, governed),
|
|
177
269
|
};
|
|
178
270
|
}
|
|
@@ -25,6 +25,11 @@ import { parsePgArray } from './security-catalog';
|
|
|
25
25
|
import { INTROSPECTION_SESSION, type SessionRunner } from './session';
|
|
26
26
|
import { matchPolicies, roleSetEqual } from './authz-identity';
|
|
27
27
|
import { normalizeDeparsedExpr } from './deparse-normal';
|
|
28
|
+
// A function-scoped cycle: authz-canonical imports the small pure helpers below, and this module
|
|
29
|
+
// imports its equivalence relation. Both cross-references live inside function bodies, never at
|
|
30
|
+
// module-init, so CJS resolves them at call time. The alternative — a second copy of the
|
|
31
|
+
// equivalence relation here — is the exact drift this module system exists to prevent.
|
|
32
|
+
import { tablePoliciesEquivalent } from './authz-canonical';
|
|
28
33
|
|
|
29
34
|
// ---------------------------------------------------------------------------
|
|
30
35
|
// The contract format — the frozen, reviewable, version-controlled shape.
|
|
@@ -50,6 +55,21 @@ export interface TableContract {
|
|
|
50
55
|
* it is first-class in the contract — a table-level grant view alone would miss it.
|
|
51
56
|
*/
|
|
52
57
|
columnGrants?: Record<string, Record<string, string[]>>;
|
|
58
|
+
/**
|
|
59
|
+
* EFFECTIVE privileges per role — the folded, membership-and-PUBLIC-aware truth, measured on
|
|
60
|
+
* the LIVE database (`has_table_privilege` OR `has_any_column_privilege`, which Postgres
|
|
61
|
+
* evaluates through role membership and PUBLIC). Consulted by DEADNESS only; `grants` stays
|
|
62
|
+
* the DIRECT ACL because that is what the grant diff reconciles.
|
|
63
|
+
*
|
|
64
|
+
* Present ONLY on an introspected live contract. A declared contract (compiled from the Model)
|
|
65
|
+
* has none, and deadness falls back to its `grants` — which ARE its effective set, since the
|
|
66
|
+
* model grants to the same role it polices. That absence is the signal: "no measurement, use
|
|
67
|
+
* the direct grants", exactly as an older recorder that never ran the effective read behaves.
|
|
68
|
+
*
|
|
69
|
+
* `role -> the commands (SELECT/INSERT/UPDATE/DELETE) the role effectively holds`. A role that
|
|
70
|
+
* holds nothing is omitted, not stored empty.
|
|
71
|
+
*/
|
|
72
|
+
effectivePrivileges?: Record<string, string[]>;
|
|
53
73
|
policies: PolicyContract[];
|
|
54
74
|
/**
|
|
55
75
|
* Column exposure (hiddenColumns / `.private()`, protectedFields / `.readonly()`).
|
|
@@ -138,6 +158,24 @@ export function holdsPrivilege(t: TableContract, role: string, privilege: string
|
|
|
138
158
|
|| Boolean(cols.public?.[privilege]?.length);
|
|
139
159
|
}
|
|
140
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Does a role EFFECTIVELY hold a privilege? — the deadness question, folding role membership.
|
|
163
|
+
*
|
|
164
|
+
* The direct-ACL {@link holdsPrivilege} answers "does the catalog name this role as a grantee?"
|
|
165
|
+
* A brownfield role chain breaks that question: a role that is a MEMBER of a role holding the
|
|
166
|
+
* grant, or that holds it through PUBLIC, effectively has the privilege while the direct ACL
|
|
167
|
+
* never names it. Deadness computed from the direct ACL then calls a live, working policy dead.
|
|
168
|
+
*
|
|
169
|
+
* When a live contract carries {@link TableContract.effectivePrivileges} — the measured
|
|
170
|
+
* `has_table_privilege`/`has_any_column_privilege` matrix, which Postgres folds through
|
|
171
|
+
* membership and PUBLIC — this reads it. Absent (a declared contract, or an older recorder), it
|
|
172
|
+
* falls back to the direct grants, which for a declared contract ARE the effective set.
|
|
173
|
+
*/
|
|
174
|
+
export function holdsPrivilegeEffective(t: TableContract, role: string, privilege: string): boolean {
|
|
175
|
+
if (t.effectivePrivileges) return (t.effectivePrivileges[role] ?? []).includes(privilege);
|
|
176
|
+
return holdsPrivilege(t, role, privilege);
|
|
177
|
+
}
|
|
178
|
+
|
|
141
179
|
/** The four row-level commands a policy can govern. `FOR ALL` fans out across them. */
|
|
142
180
|
export const POLICY_COMMANDS = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
|
|
143
181
|
|
|
@@ -162,13 +200,17 @@ export function policyCommands(p: PolicyContract): string[] {
|
|
|
162
200
|
* policy RESURRECTS the moment a grant makes it effective.
|
|
163
201
|
*/
|
|
164
202
|
export function isPolicyDead(t: TableContract, p: PolicyContract): boolean {
|
|
203
|
+
// A PUBLIC policy applies to every grantee, so it is dead only when NO grantee holds any of
|
|
204
|
+
// its commands. Which grantees to test: the effective matrix's roles when measured (it names
|
|
205
|
+
// every role a membership could reach), else the direct grantees. Folding PUBLIC across the
|
|
206
|
+
// effective roles is what lets `has_table_privilege` decide a PUBLIC-arm's aliveness.
|
|
165
207
|
const roles = p.roles.includes('public') || p.roles.includes('PUBLIC')
|
|
166
|
-
? Object.keys(t.grants)
|
|
208
|
+
? Object.keys(t.effectivePrivileges ?? t.grants)
|
|
167
209
|
: p.roles;
|
|
168
210
|
if (!roles.length) return true;
|
|
169
|
-
// `
|
|
170
|
-
// policy governing
|
|
171
|
-
return !policyCommands(p).some((cmd) => roles.some((r) =>
|
|
211
|
+
// `holdsPrivilegeEffective`, not the direct one: a column-scoped grant is real access AND a
|
|
212
|
+
// membership-held privilege is real access, so a policy governing either is doing live work.
|
|
213
|
+
return !policyCommands(p).some((cmd) => roles.some((r) => holdsPrivilegeEffective(t, r, cmd)));
|
|
172
214
|
}
|
|
173
215
|
|
|
174
216
|
/** Does this policy name PostgreSQL's PUBLIC pseudo-role — the open set of every role? */
|
|
@@ -448,6 +490,79 @@ WHERE a.attacl IS NOT NULL
|
|
|
448
490
|
ORDER BY n.nspname, c.relname, grantee, privilege, a.attname;
|
|
449
491
|
`.trim();
|
|
450
492
|
|
|
493
|
+
/**
|
|
494
|
+
* The EFFECTIVE per-(table, role) privilege matrix — what a role can actually do, folding role
|
|
495
|
+
* membership and PUBLIC, which the direct `aclexplode(relacl)` of GRANTS_SQL cannot see.
|
|
496
|
+
*
|
|
497
|
+
* `has_table_privilege` / `has_any_column_privilege` answer the folded question Postgres itself
|
|
498
|
+
* enforces: a role inherits the grants of every role it is a (INHERIT) member of, and every
|
|
499
|
+
* PUBLIC grant. This feeds DEADNESS only — a policy governing a command its role holds only
|
|
500
|
+
* through a parent role is doing live work, and dropping it is a real authorization change.
|
|
501
|
+
*
|
|
502
|
+
* Scoped to the roles that matter: a role NAMED IN A POLICY (its deadness is the question) or a
|
|
503
|
+
* direct grantee (needed to answer a PUBLIC arm's "does anyone hold this?"). Probing every role
|
|
504
|
+
* in the cluster would be needless work and would name roles no policy ever consults. Column
|
|
505
|
+
* privileges fold in via `has_any_column_privilege` (SELECT/INSERT/UPDATE only — DELETE is never
|
|
506
|
+
* column-scoped). Extension tables excluded to match the other reads.
|
|
507
|
+
*/
|
|
508
|
+
export const EFFECTIVE_PRIVILEGES_SQL = `
|
|
509
|
+
WITH candidate AS (
|
|
510
|
+
SELECT DISTINCT r.oid, r.rolname
|
|
511
|
+
FROM pg_roles r
|
|
512
|
+
WHERE EXISTS (SELECT 1 FROM pg_policy p WHERE r.oid = ANY (p.polroles))
|
|
513
|
+
OR EXISTS (
|
|
514
|
+
SELECT 1 FROM pg_class c2
|
|
515
|
+
CROSS JOIN LATERAL aclexplode(c2.relacl) a2
|
|
516
|
+
WHERE a2.grantee = r.oid AND c2.relkind IN ('r', 'v', 'm')
|
|
517
|
+
)
|
|
518
|
+
OR EXISTS (
|
|
519
|
+
SELECT 1 FROM pg_attribute at2
|
|
520
|
+
CROSS JOIN LATERAL aclexplode(at2.attacl) a3
|
|
521
|
+
WHERE a3.grantee = r.oid AND at2.attnum > 0 AND NOT at2.attisdropped
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
SELECT
|
|
525
|
+
n.nspname AS schema,
|
|
526
|
+
c.relname AS "table",
|
|
527
|
+
cr.rolname AS role,
|
|
528
|
+
ARRAY(
|
|
529
|
+
SELECT p FROM unnest(ARRAY['SELECT', 'INSERT', 'UPDATE', 'DELETE']) AS p
|
|
530
|
+
WHERE has_table_privilege(cr.oid, c.oid, p)
|
|
531
|
+
OR (p <> 'DELETE' AND has_any_column_privilege(cr.oid, c.oid, p))
|
|
532
|
+
) AS privileges
|
|
533
|
+
FROM pg_class c
|
|
534
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
535
|
+
CROSS JOIN candidate cr
|
|
536
|
+
WHERE c.relkind = 'r'
|
|
537
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
538
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
539
|
+
AND NOT EXISTS (
|
|
540
|
+
SELECT 1 FROM pg_depend d
|
|
541
|
+
WHERE d.objid = c.oid AND d.deptype = 'e'
|
|
542
|
+
)
|
|
543
|
+
ORDER BY n.nspname, c.relname, cr.rolname;
|
|
544
|
+
`.trim();
|
|
545
|
+
|
|
546
|
+
export interface EffectivePrivilegeRow {
|
|
547
|
+
schema: string;
|
|
548
|
+
table: string;
|
|
549
|
+
role: unknown;
|
|
550
|
+
privileges: unknown;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Map one effective-privilege row to its schema-qualified table + role + folded privileges. */
|
|
554
|
+
export function effectivePrivilegeRowToDescriptor(row: EffectivePrivilegeRow): {
|
|
555
|
+
table: string;
|
|
556
|
+
role: string;
|
|
557
|
+
privileges: string[];
|
|
558
|
+
} {
|
|
559
|
+
return {
|
|
560
|
+
table: `${row.schema}.${row.table}`,
|
|
561
|
+
role: String(row.role),
|
|
562
|
+
privileges: (parsePgArray(row.privileges) ?? []).map((p) => p.toUpperCase()),
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
451
566
|
/**
|
|
452
567
|
* Every base table with its RLS posture — the table enumerator for the contract.
|
|
453
568
|
* `relrowsecurity` / `relforcerowsecurity` give enabled/forced. A table absent from
|
|
@@ -750,6 +865,9 @@ export interface ContractRows {
|
|
|
750
865
|
policies: PolicyRow[];
|
|
751
866
|
grants: GrantRow[];
|
|
752
867
|
columnGrants?: ColumnGrantRow[];
|
|
868
|
+
/** EFFECTIVE_PRIVILEGES_SQL rows — the membership-folded matrix deadness reads. Optional:
|
|
869
|
+
* absent means the effective read never ran, and deadness stays on the direct grants. */
|
|
870
|
+
effectivePrivileges?: EffectivePrivilegeRow[];
|
|
753
871
|
/** pg_namespace ACL rows (SCHEMA_ACL_SQL) — optional; absent means schema ACLs unknown. */
|
|
754
872
|
schemaAcls?: SchemaAclRow[];
|
|
755
873
|
/** SEQUENCE_ACLS_SQL. Optional — absent means not measured, never "no sequences". */
|
|
@@ -798,6 +916,19 @@ export function assembleContract(rows: ContractRows): AuthzContract {
|
|
|
798
916
|
(byPriv[privilege] ??= []).push(column);
|
|
799
917
|
}
|
|
800
918
|
|
|
919
|
+
// The effective matrix (membership + PUBLIC folded) — deadness only. A role holding nothing is
|
|
920
|
+
// omitted, so absence reads as "no effective privileges", never as "unmeasured". The map is
|
|
921
|
+
// attached to a table ONLY when the effective read ran, so a declared/older contract keeps
|
|
922
|
+
// deadness on its direct grants.
|
|
923
|
+
if (rows.effectivePrivileges) {
|
|
924
|
+
for (const row of rows.effectivePrivileges) {
|
|
925
|
+
const { table, role, privileges } = effectivePrivilegeRowToDescriptor(row);
|
|
926
|
+
const t = tables.get(table);
|
|
927
|
+
if (!t || privileges.length === 0) continue;
|
|
928
|
+
(t.effectivePrivileges ??= {})[role] = [...privileges].sort();
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
801
932
|
for (const t of tables.values()) {
|
|
802
933
|
t.policies.sort((a, b) => a.name.localeCompare(b.name));
|
|
803
934
|
for (const byPriv of Object.values(t.columnGrants ?? {})) {
|
|
@@ -844,11 +975,11 @@ export async function introspectContract(
|
|
|
844
975
|
mapFunctionRow: (row: any) => { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean },
|
|
845
976
|
functionsSql: string,
|
|
846
977
|
): Promise<AuthzContract> {
|
|
847
|
-
// ONE session: the
|
|
978
|
+
// ONE session: the queries describe one moment under one pinned search_path.
|
|
848
979
|
// Policy USING / WITH CHECK expressions deparse relative to that path, so a read spread
|
|
849
980
|
// across connections can report authz drift that does not exist.
|
|
850
|
-
const [rls, policies, grants, columnGrants, schemaAcls, sequenceAcls, fnRows] = await session(
|
|
851
|
-
[RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, functionsSql],
|
|
981
|
+
const [rls, policies, grants, columnGrants, effectivePrivileges, schemaAcls, sequenceAcls, fnRows] = await session(
|
|
982
|
+
[RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, EFFECTIVE_PRIVILEGES_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, functionsSql],
|
|
852
983
|
INTROSPECTION_SESSION,
|
|
853
984
|
);
|
|
854
985
|
return assembleContract({
|
|
@@ -856,6 +987,7 @@ export async function introspectContract(
|
|
|
856
987
|
policies: policies as PolicyRow[],
|
|
857
988
|
grants: grants as GrantRow[],
|
|
858
989
|
columnGrants: columnGrants as ColumnGrantRow[],
|
|
990
|
+
effectivePrivileges: effectivePrivileges as EffectivePrivilegeRow[],
|
|
859
991
|
schemaAcls: schemaAcls as SchemaAclRow[],
|
|
860
992
|
sequenceAcls: sequenceAcls as SequenceAclRow[],
|
|
861
993
|
functions: (fnRows as any[]).map(mapFunctionRow),
|
|
@@ -1029,6 +1161,8 @@ function diffPolicies(
|
|
|
1029
1161
|
): void {
|
|
1030
1162
|
const m = matchPolicies(d.policies, l.policies);
|
|
1031
1163
|
|
|
1164
|
+
// Adoptions are recorded whether or not the states are canonically equal \u2014 a name divergence
|
|
1165
|
+
// is agreement, not drift, and its mapping must stay visible even on an equivalent table.
|
|
1032
1166
|
for (const a of m.adopted) {
|
|
1033
1167
|
adoptions.push({ subject: table, declared: [a.declared], live: a.live, reason: 'same rule, different name' });
|
|
1034
1168
|
}
|
|
@@ -1036,6 +1170,16 @@ function diffPolicies(
|
|
|
1036
1170
|
adoptions.push({ subject: table, declared: g.declared, live: g.live, reason: 'one live policy covers the declared per-role group' });
|
|
1037
1171
|
}
|
|
1038
1172
|
|
|
1173
|
+
// Condition B \u2014 gate drift on the SAME canonical equivalence the reconciler and the fingerprint
|
|
1174
|
+
// use, so no surface can report drift the plan does not carry. A live `FOR ALL` with a dead
|
|
1175
|
+
// DELETE arm equals the granular pull that omits DELETE; the per-command matcher below would
|
|
1176
|
+
// call it "undeclared policy" + "declared but missing". No governed set is threaded through the
|
|
1177
|
+
// differ, so this compares ALL arms \u2014 a strictly HARDER equality than the reconciler's
|
|
1178
|
+
// governed-filtered one, which means the differ can only ever be MORE willing to report drift,
|
|
1179
|
+
// never silent where the reconciler emits. Only when the states genuinely differ do the
|
|
1180
|
+
// per-policy findings run.
|
|
1181
|
+
if (tablePoliciesEquivalent(d, l)) return;
|
|
1182
|
+
|
|
1039
1183
|
// A name on BOTH sides whose rule moved is a CHANGE, not a remove-and-add. Saying which
|
|
1040
1184
|
// field moved is the difference between a finding an operator can act on and one they have
|
|
1041
1185
|
// to go read the catalog to understand.
|