@everystack/cli 0.4.44 → 0.4.45
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/authz-adoption-class.ts +265 -0
- package/src/cli/authz-baseline.ts +25 -3
- package/src/cli/authz-canonical.ts +146 -0
- package/src/cli/authz-compile.ts +46 -6
- package/src/cli/authz-contract.ts +120 -25
- package/src/cli/authz-derive.ts +99 -14
- package/src/cli/authz-identity.ts +222 -0
- package/src/cli/authz-ownership.ts +193 -0
- package/src/cli/authz-reconcile.ts +19 -27
- package/src/cli/commands/db-generate.ts +50 -0
- package/src/cli/commands/db-plan.ts +34 -2
- package/src/cli/commands/db-pull.ts +20 -1
- package/src/cli/edge-plan.ts +14 -2
- package/src/cli/index.ts +1 -17
- package/src/cli/model-render.ts +19 -2
- package/src/cli/output.ts +25 -3
- package/src/cli/parse-flags.ts +39 -0
- package/src/cli/schema-fingerprint.ts +88 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.45",
|
|
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.
|
|
112
|
+
"@everystack/model": "0.4.10"
|
|
113
113
|
},
|
|
114
114
|
"peerDependencies": {
|
|
115
115
|
"@everystack/server": ">=0.4.0",
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authz-adoption-class — WHY does this statement exist?
|
|
3
|
+
*
|
|
4
|
+
* A brownfield adopter's first plan is long, and the length is not the problem. The problem is
|
|
5
|
+
* that nobody can tell which statements are the tool imposing its own spelling and which are
|
|
6
|
+
* real differences between the model and the database. This classifier answers that, per
|
|
7
|
+
* statement, mechanically.
|
|
8
|
+
*
|
|
9
|
+
* FOUR CLASSES, and the whole point is that the first one should reach zero:
|
|
10
|
+
*
|
|
11
|
+
* - `convention` — exists ONLY because we spell a semantically identical rule differently.
|
|
12
|
+
* Every one of these is our bug. Phase A's success criterion is zero.
|
|
13
|
+
* - `capability` — the model cannot express what is live, so the plan would remove it.
|
|
14
|
+
* Real, and a gap in everystack, not in their schema.
|
|
15
|
+
* - `narrowing` — a genuine reduction of access. Must ship, labeled, and be approved by a
|
|
16
|
+
* human before it is applied.
|
|
17
|
+
* - `dead` — the live object is dead code: policed but never granted, so it authorizes
|
|
18
|
+
* nothing today.
|
|
19
|
+
*
|
|
20
|
+
* NAME COLLISION, stated once so nobody conflates them. `edge-plan.ts` already has
|
|
21
|
+
* `PlanClassification.narrowings`, which means a DATA-LOSSY `ALTER COLUMN … TYPE`. This
|
|
22
|
+
* `narrowing` means an ACCESS reduction. They are two orthogonal axes on the same statement —
|
|
23
|
+
* one asks "what does it cost?", the other asks "why is it here?" — and both are correct in
|
|
24
|
+
* their own frame. This module deliberately does NOT extend `PlanClassification`.
|
|
25
|
+
*
|
|
26
|
+
* READ-ONLY. Nothing here changes what is emitted. It reasons over the same declared and live
|
|
27
|
+
* contracts the emitter does, and a test pins its statement list to the emitter's actual
|
|
28
|
+
* output so the two can never drift apart.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { matchPolicies, roleSetEqual } from './authz-identity.js';
|
|
32
|
+
import { governedRoleSet } from './authz-reconcile.js';
|
|
33
|
+
import { effectivePolicyCheck, holdsPrivilege } from './authz-contract.js';
|
|
34
|
+
import type { AuthzContract, PolicyContract, TableContract } from './authz-contract.js';
|
|
35
|
+
|
|
36
|
+
export type AdoptionClass = 'convention' | 'capability' | 'narrowing' | 'dead' | 'unclassified';
|
|
37
|
+
|
|
38
|
+
export interface ClassifiedStatement {
|
|
39
|
+
table: string;
|
|
40
|
+
/** The statement's subject — a policy name, or `grantee` for a privilege change. */
|
|
41
|
+
subject: string;
|
|
42
|
+
cls: AdoptionClass;
|
|
43
|
+
/** One line, in plain words, for the plan's report. */
|
|
44
|
+
why: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AdoptionCounts {
|
|
48
|
+
convention: number;
|
|
49
|
+
capability: number;
|
|
50
|
+
narrowing: number;
|
|
51
|
+
dead: number;
|
|
52
|
+
unclassified: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The reconciler's own exemption rule, mirrored: a grantee the declared authz does not govern
|
|
57
|
+
* is left alone. `PUBLIC` compares case-insensitively because it is a pseudo-role with two
|
|
58
|
+
* catalog spellings; every other name compares exactly.
|
|
59
|
+
*/
|
|
60
|
+
function isGovernedGrantee(governed: ReadonlySet<string>, grantee: string): boolean {
|
|
61
|
+
return governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const CRUD = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Does `outer` cover every role in `inner`?
|
|
68
|
+
*
|
|
69
|
+
* PUBLIC (`['public']`) covers everything — it is an open set. That fact is used HERE ONLY, to
|
|
70
|
+
* decide whether a live policy's replacement is narrower (a label). It is deliberately NOT
|
|
71
|
+
* available to the matcher: letting PUBLIC compare equal to an enumerated set there would be
|
|
72
|
+
* the false match the whole phase exists to prevent. Labeling cannot widen access; matching
|
|
73
|
+
* can.
|
|
74
|
+
*/
|
|
75
|
+
function covers(outer: readonly string[], inner: readonly string[]): boolean {
|
|
76
|
+
if (outer.includes('public')) return true;
|
|
77
|
+
const o = new Set(outer);
|
|
78
|
+
return inner.every((r) => o.has(r));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Same authorization on every axis EXCEPT which roles it names — the role-split test. */
|
|
82
|
+
function sameRule(a: PolicyContract, b: PolicyContract): boolean {
|
|
83
|
+
return a.command === b.command
|
|
84
|
+
&& a.permissive === b.permissive
|
|
85
|
+
&& (a.using ?? '') === (b.using ?? '')
|
|
86
|
+
&& (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Every command a policy governs — `ALL` fans out, so a live `FOR ALL` is compared per command. */
|
|
90
|
+
function commandsOf(p: PolicyContract): string[] {
|
|
91
|
+
return p.command === 'ALL' ? [...CRUD] : [p.command];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A policy is DEAD when no role it applies to holds the privilege it polices. Postgres checks
|
|
96
|
+
* the GRANT before the policy, so such a policy authorizes nothing — removing it takes away
|
|
97
|
+
* access that was never there. A PUBLIC policy applies to every grantee, so it is dead only if
|
|
98
|
+
* NO grantee holds the privilege at all.
|
|
99
|
+
*/
|
|
100
|
+
function isDead(t: TableContract, p: PolicyContract): boolean {
|
|
101
|
+
const roles = p.roles.includes('public') ? Object.keys(t.grants) : p.roles;
|
|
102
|
+
if (!roles.length) return true;
|
|
103
|
+
// `holdsPrivilege`, not the table-level one: a column-scoped grant is real access, and
|
|
104
|
+
// "dead" tells an operator the drop takes away nothing. Shared with db:pull so the two can
|
|
105
|
+
// never answer this differently about one database.
|
|
106
|
+
return !commandsOf(p).some((cmd) => roles.some((r) => holdsPrivilege(t, r, cmd)));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Classify the policy statements one table would emit. */
|
|
110
|
+
function classifyPolicies(d: TableContract, l: TableContract, out: ClassifiedStatement[]): void {
|
|
111
|
+
const table = d.table;
|
|
112
|
+
const m = matchPolicies(d.policies, l.policies);
|
|
113
|
+
|
|
114
|
+
// Adopted shapes — a rule-identical policy under another name, or a live multi-role policy
|
|
115
|
+
// covering the declared per-role group — emit NOTHING, so there is nothing here to classify.
|
|
116
|
+
// This is what driving `convention` toward zero looks like: the statements stop existing
|
|
117
|
+
// rather than getting a nicer label.
|
|
118
|
+
|
|
119
|
+
const declaredByName = new Map(m.toCreate.map((p) => [p.name, p]));
|
|
120
|
+
const consumed = new Set<string>();
|
|
121
|
+
|
|
122
|
+
for (const name of m.toDrop) {
|
|
123
|
+
const live = l.policies.find((p) => p.name === name)!;
|
|
124
|
+
|
|
125
|
+
if (isDead(l, live)) {
|
|
126
|
+
out.push({ table, subject: name, cls: 'dead', why: `policed but never granted — it authorizes nothing today` });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// A replacement exists when some declared policy governs the same command. If those
|
|
131
|
+
// replacements together cover FEWER roles than the live policy did, the plan is reducing
|
|
132
|
+
// access — a narrowing, which must be approved rather than merely applied.
|
|
133
|
+
const replacements = m.toCreate.filter(
|
|
134
|
+
(c) => commandsOf(c).some((cmd) => commandsOf(live).includes(cmd)),
|
|
135
|
+
);
|
|
136
|
+
if (!replacements.length) {
|
|
137
|
+
out.push({ table, subject: name, cls: 'capability', why: `no declared policy governs ${commandsOf(live).join('/')} — the model cannot express this rule` });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const replacementRoles = [...new Set(replacements.flatMap((r) => r.roles))];
|
|
142
|
+
|
|
143
|
+
// The role split: one live policy covering several roles, against the per-role policies the
|
|
144
|
+
// compiler emits. Same predicate, and the declared roles union to EXACTLY the live role set,
|
|
145
|
+
// so the two authorize identically for every session. Pure spelling — ours to fix (A5).
|
|
146
|
+
// Note this is recognized here for LABELING before the matcher can act on it; the two must
|
|
147
|
+
// agree once A5 lands, which is what the emitter-parity test pins.
|
|
148
|
+
if (roleSetEqual(live.roles, replacementRoles) && replacements.every((r) => sameRule(r, live))) {
|
|
149
|
+
out.push({ table, subject: name, cls: 'convention', why: `live covers ${live.roles.join(' + ')} in one policy; we split it per role with the same predicate` });
|
|
150
|
+
for (const r of replacements) {
|
|
151
|
+
if (consumed.has(r.name)) continue;
|
|
152
|
+
consumed.add(r.name);
|
|
153
|
+
out.push({ table, subject: r.name, cls: 'convention', why: `the per-role half of "${name}"` });
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (covers(live.roles, replacementRoles) && !roleSetEqual(live.roles, replacementRoles)) {
|
|
159
|
+
out.push({ table, subject: name, cls: 'narrowing', why: `live applies to ${live.roles.join(', ')}; the declared replacement covers only ${replacementRoles.join(', ')}` });
|
|
160
|
+
for (const r of replacements) {
|
|
161
|
+
if (consumed.has(r.name)) continue;
|
|
162
|
+
consumed.add(r.name);
|
|
163
|
+
out.push({ table, subject: r.name, cls: 'narrowing', why: `the narrower replacement for "${name}"` });
|
|
164
|
+
}
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
out.push({ table, subject: name, cls: 'unclassified', why: `dropped with a replacement that is neither the same rule nor narrower` });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const [name] of declaredByName) {
|
|
172
|
+
if (consumed.has(name)) continue;
|
|
173
|
+
out.push({ table, subject: name, cls: 'unclassified', why: `created with no live counterpart` });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Classify the privilege statements one table would emit. */
|
|
178
|
+
function classifyGrants(
|
|
179
|
+
d: TableContract,
|
|
180
|
+
l: TableContract,
|
|
181
|
+
governed: ReadonlySet<string>,
|
|
182
|
+
out: ClassifiedStatement[],
|
|
183
|
+
): void {
|
|
184
|
+
const table = d.table;
|
|
185
|
+
|
|
186
|
+
for (const grantee of Object.keys(l.grants).sort()) {
|
|
187
|
+
// The reconciler LEAVES ungoverned grantees alone — a migrator or BI reader the model
|
|
188
|
+
// vocabulary cannot name keeps its access, reported instead of revoked. It emits nothing
|
|
189
|
+
// for them, so neither may we: counting statements that are never emitted would describe
|
|
190
|
+
// a plan nobody is going to apply.
|
|
191
|
+
if (!isGovernedGrantee(governed, grantee)) continue;
|
|
192
|
+
const declared = new Set(d.grants[grantee] ?? []);
|
|
193
|
+
const live = l.grants[grantee] ?? [];
|
|
194
|
+
const revoked = live.filter((p) => !declared.has(p));
|
|
195
|
+
if (!revoked.length) continue;
|
|
196
|
+
|
|
197
|
+
// Every revoke here is now a NARROWING, including the beyond-CRUD trio.
|
|
198
|
+
//
|
|
199
|
+
// It used to be `convention` for REFERENCES/TRIGGER/TRUNCATE: `manage` means exactly CRUD,
|
|
200
|
+
// so live admin holding those three read as drift and the plan revoked them — 28 statements
|
|
201
|
+
// on the reference schema, none of which anybody asked for. The model has a word for them
|
|
202
|
+
// now (`privileges`), and `db:pull` transcribes what is live, so a pulled model emits
|
|
203
|
+
// nothing. A model that does NOT declare them is choosing to remove them, which is a real
|
|
204
|
+
// access reduction and gets labeled as one. The fix is at the source, not in the label.
|
|
205
|
+
out.push({ table, subject: grantee, cls: 'narrowing', why: `revokes ${revoked.join(', ')} from ${grantee}` });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Column grants: the model expresses column scoping for READ only, so a live column-scoped
|
|
209
|
+
// UPDATE has no declaration and the plan revokes it. That is a gap in everystack.
|
|
210
|
+
const lcg = l.columnGrants ?? {};
|
|
211
|
+
const dcg = d.columnGrants ?? {};
|
|
212
|
+
for (const grantee of Object.keys(lcg).sort()) {
|
|
213
|
+
if (!isGovernedGrantee(governed, grantee)) continue;
|
|
214
|
+
for (const priv of Object.keys(lcg[grantee]).sort()) {
|
|
215
|
+
const declaredCols = new Set(dcg[grantee]?.[priv] ?? []);
|
|
216
|
+
const revoked = (lcg[grantee][priv] ?? []).filter((c) => !declaredCols.has(c));
|
|
217
|
+
if (!revoked.length) continue;
|
|
218
|
+
out.push({ table, subject: `${grantee}/${priv}`, cls: 'capability', why: `column-scoped ${priv} on ${revoked.length} column(s) — the model declares columns for read only` });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Classify every authorization statement the plan would emit, per table.
|
|
225
|
+
*
|
|
226
|
+
* Live tables no model declares are skipped: the plan does not touch them, so they contribute
|
|
227
|
+
* no statements to classify.
|
|
228
|
+
*/
|
|
229
|
+
export function classifyAdoption(
|
|
230
|
+
declared: AuthzContract,
|
|
231
|
+
live: AuthzContract,
|
|
232
|
+
opts: { governedRoles?: ReadonlySet<string> } = {},
|
|
233
|
+
): {
|
|
234
|
+
statements: ClassifiedStatement[];
|
|
235
|
+
counts: AdoptionCounts;
|
|
236
|
+
} {
|
|
237
|
+
const governed = opts.governedRoles ?? governedRoleSet(declared);
|
|
238
|
+
const liveByTable = new Map(live.tables.map((t) => [t.table, t]));
|
|
239
|
+
const statements: ClassifiedStatement[] = [];
|
|
240
|
+
|
|
241
|
+
for (const d of declared.tables) {
|
|
242
|
+
const l = liveByTable.get(d.table);
|
|
243
|
+
if (!l) continue; // a brand-new table adds; nothing here is an adoption decision
|
|
244
|
+
classifyPolicies(d, l, statements);
|
|
245
|
+
classifyGrants(d, l, governed, statements);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const counts: AdoptionCounts = { convention: 0, capability: 0, narrowing: 0, dead: 0, unclassified: 0 };
|
|
249
|
+
for (const s of statements) counts[s.cls]++;
|
|
250
|
+
return { statements, counts };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The report block for the plan surface. Terse: one line per class, then the offenders. */
|
|
254
|
+
export function renderAdoptionReport(counts: AdoptionCounts): string[] {
|
|
255
|
+
const lines = [
|
|
256
|
+
` convention ${counts.convention} — our spelling, not their schema (this must reach 0)`,
|
|
257
|
+
` capability ${counts.capability} — the model cannot express what is live`,
|
|
258
|
+
` narrowing ${counts.narrowing} — real access reduction, approve before applying`,
|
|
259
|
+
` dead ${counts.dead} — policed but never granted`,
|
|
260
|
+
];
|
|
261
|
+
if (counts.unclassified) {
|
|
262
|
+
lines.push(` unclassified ${counts.unclassified} — NOT described; read these before applying`);
|
|
263
|
+
}
|
|
264
|
+
return lines;
|
|
265
|
+
}
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
import fs from 'node:fs/promises';
|
|
38
38
|
import path from 'node:path';
|
|
39
|
+
import { FINGERPRINT_VERSION } from './schema-fingerprint.js';
|
|
39
40
|
import type { GrantExemption } from './authz-reconcile.js';
|
|
40
41
|
|
|
41
42
|
/** The repo-relative artifact. Generated — regenerating it must reproduce it byte for byte. */
|
|
@@ -58,6 +59,15 @@ export interface BaselineStage {
|
|
|
58
59
|
observedAt: string;
|
|
59
60
|
/** The live base fingerprint at observation time — what the claim was true OF. */
|
|
60
61
|
fingerprint: string;
|
|
62
|
+
/**
|
|
63
|
+
* The FINGERPRINT_VERSION that hash was computed under.
|
|
64
|
+
*
|
|
65
|
+
* Stored beside the hash rather than only mixed into it, because a bare hash cannot say WHY
|
|
66
|
+
* it differs. Without this a baseline recorded under an older canonical form reads as drift,
|
|
67
|
+
* and an operator goes looking for a change nobody made instead of running one re-baseline.
|
|
68
|
+
* Absent on baselines written before v4.
|
|
69
|
+
*/
|
|
70
|
+
fpVersion?: number;
|
|
61
71
|
grantees: Record<string, BaselineGrantee>;
|
|
62
72
|
}
|
|
63
73
|
|
|
@@ -71,7 +81,7 @@ export const EMPTY_BASELINE: AuthzBaseline = { version: 1, stages: {} };
|
|
|
71
81
|
/** Fold the observed exemptions into one entry per grantee. */
|
|
72
82
|
export function buildStageBaseline(
|
|
73
83
|
exemptions: readonly GrantExemption[],
|
|
74
|
-
meta: { observedAt: string; fingerprint: string },
|
|
84
|
+
meta: { observedAt: string; fingerprint: string; fpVersion?: number },
|
|
75
85
|
): BaselineStage {
|
|
76
86
|
const grantees: Record<string, BaselineGrantee> = {};
|
|
77
87
|
for (const e of exemptions) {
|
|
@@ -83,7 +93,12 @@ export function buildStageBaseline(
|
|
|
83
93
|
const all = [...e.privileges, ...Object.keys(e.columnPrivileges ?? {})];
|
|
84
94
|
g.write ||= all.some((p) => WRITE_PRIVILEGES.has(p.toUpperCase()));
|
|
85
95
|
}
|
|
86
|
-
return {
|
|
96
|
+
return {
|
|
97
|
+
observedAt: meta.observedAt,
|
|
98
|
+
fingerprint: meta.fingerprint,
|
|
99
|
+
fpVersion: meta.fpVersion ?? FINGERPRINT_VERSION,
|
|
100
|
+
grantees,
|
|
101
|
+
};
|
|
87
102
|
}
|
|
88
103
|
|
|
89
104
|
/** Write one stage's entry into the file, leaving every other stage untouched. */
|
|
@@ -105,7 +120,14 @@ export function renderBaseline(baseline: AuthzBaseline): string {
|
|
|
105
120
|
const { privileges, tables, write } = s.grantees[g];
|
|
106
121
|
grantees[g] = { privileges: [...privileges].sort(), tables: [...tables].sort(), write };
|
|
107
122
|
}
|
|
108
|
-
|
|
123
|
+
// `fpVersion` rides through the render, or the stamp is lost on the first rewrite and the
|
|
124
|
+
// baseline silently becomes unstamped — which reads as a format change forever after.
|
|
125
|
+
stages[stage] = {
|
|
126
|
+
observedAt: s.observedAt,
|
|
127
|
+
fingerprint: s.fingerprint,
|
|
128
|
+
...(s.fpVersion != null ? { fpVersion: s.fpVersion } : {}),
|
|
129
|
+
grantees,
|
|
130
|
+
};
|
|
109
131
|
}
|
|
110
132
|
return JSON.stringify({ version: 1, stages }, null, 2) + '\n';
|
|
111
133
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authz-canonical — ONE definition of "these two authorization states are the same".
|
|
3
|
+
*
|
|
4
|
+
* Three surfaces answer that question and they must never answer it differently:
|
|
5
|
+
*
|
|
6
|
+
* - `authz-reconcile` — emits the SQL that closes the gap (statements == 0 means same)
|
|
7
|
+
* - `authz-contract` — reports drift (no findings means same)
|
|
8
|
+
* - `schema-fingerprint` — content-addresses the state (equal hashes means same)
|
|
9
|
+
*
|
|
10
|
+
* They were two-and-a-half hand-mirrored copies, and that is exactly how the identity broke:
|
|
11
|
+
* the reconciler learned to exempt an ungoverned grantee, the fingerprint did not, and every
|
|
12
|
+
* brownfield adopter landed on "nothing to do" and "you have drifted" at the same time. The
|
|
13
|
+
* fingerprint is not a third opinion — it is this equivalence relation, cached. When the
|
|
14
|
+
* relation changes, the cache format changes with it.
|
|
15
|
+
*
|
|
16
|
+
* Two normalizations live here, and both are load-bearing:
|
|
17
|
+
*
|
|
18
|
+
* ROLE EXPANSION. A policy `TO a, b` and two identical-predicate policies `TO a` and `TO b`
|
|
19
|
+
* are applicable to precisely the same sessions, because Postgres selects policies per session
|
|
20
|
+
* role by membership. Expanding every policy to one entry per role makes those two states hash
|
|
21
|
+
* equal, which is what makes the reconciler's adoption of a role split legible to the gate.
|
|
22
|
+
*
|
|
23
|
+
* PUBLIC NEVER EXPANDS. It is an open set — every role that exists or ever will. Enumerating
|
|
24
|
+
* it at hash time against today's roles would rebuild, inside the fingerprint, exactly the
|
|
25
|
+
* contingent equivalence the matcher refuses: equal today, silently wrong the moment a role is
|
|
26
|
+
* created. It stays a single sentinel entry.
|
|
27
|
+
*
|
|
28
|
+
* MULTISET, NEVER A SET. Entries carry counts. If duplicates collapsed, two live policies with
|
|
29
|
+
* the same rule and different names would hash as one, the fingerprint would report MATCH, and
|
|
30
|
+
* the matcher — which adopts one and drops the other — would still emit statements. That is
|
|
31
|
+
* the same identity break in the opposite direction, and it is the harder one to notice.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { PolicyContract, TableContract } from './authz-contract.js';
|
|
35
|
+
import { effectivePolicyCheck } from './authz-contract.js';
|
|
36
|
+
|
|
37
|
+
/** PostgreSQL's open role set, kept whole. */
|
|
38
|
+
const PUBLIC_ROLE = 'public';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One policy as (role, rule) entries — the form in which two equivalent states look equal.
|
|
42
|
+
* A PUBLIC policy yields exactly one entry; every other policy yields one per named role.
|
|
43
|
+
*/
|
|
44
|
+
function expandPolicy(p: PolicyContract): string[] {
|
|
45
|
+
const rule = stableRule(p);
|
|
46
|
+
if (p.roles.includes(PUBLIC_ROLE)) return [`${PUBLIC_ROLE}|${rule}`];
|
|
47
|
+
return [...p.roles].sort().map((r) => `${r}|${rule}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The rule, with the NAME deliberately absent.
|
|
52
|
+
*
|
|
53
|
+
* Names were the policy's identity in the v3 format, on the reasoning that the compiler emits
|
|
54
|
+
* them deterministically. That is true of a greenfield database and false of every brownfield
|
|
55
|
+
* one, where the previous migration tool chose the names. Hashing them made a live policy that
|
|
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.
|
|
58
|
+
*
|
|
59
|
+
* The check goes through the server's own defaulting rule, so a live `FOR ALL USING (true)`
|
|
60
|
+
* and a compiled `FOR ALL USING (true) WITH CHECK (true)` — the same authorization, written
|
|
61
|
+
* two ways — hash the same.
|
|
62
|
+
*/
|
|
63
|
+
function stableRule(p: PolicyContract): string {
|
|
64
|
+
return JSON.stringify([
|
|
65
|
+
p.command,
|
|
66
|
+
p.permissive,
|
|
67
|
+
p.using ?? '',
|
|
68
|
+
effectivePolicyCheck(p) ?? '',
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Sorted multiset: every entry kept, duplicates counted, order irrelevant. */
|
|
73
|
+
function multiset(entries: string[]): Array<[string, number]> {
|
|
74
|
+
const counts = new Map<string, number>();
|
|
75
|
+
for (const e of entries) counts.set(e, (counts.get(e) ?? 0) + 1);
|
|
76
|
+
return [...counts.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The canonical policy form: every policy expanded across its roles, as a sorted multiset. */
|
|
80
|
+
export function canonicalPolicies(policies: readonly PolicyContract[]): Array<[string, number]> {
|
|
81
|
+
return multiset(policies.flatMap(expandPolicy));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Keep only the grantees the models actually govern.
|
|
86
|
+
*
|
|
87
|
+
* The reconciler leaves an ungoverned grantee alone — a migrator, ETL or BI role the model
|
|
88
|
+
* vocabulary cannot name must not have its access destroyed by a plan nobody read. The hash
|
|
89
|
+
* has to make the same choice or it contradicts the plan: a live grant nothing will ever
|
|
90
|
+
* reconcile is not part of the state the models describe.
|
|
91
|
+
*
|
|
92
|
+
* `PUBLIC` compares case-insensitively (a pseudo-role with two catalog spellings); every other
|
|
93
|
+
* name compares exactly, because PostgreSQL role names are case-sensitive and `CREATE ROLE
|
|
94
|
+
* "Public"` is a real, distinct role.
|
|
95
|
+
*/
|
|
96
|
+
function governedOnly<T>(
|
|
97
|
+
map: Record<string, T> | undefined,
|
|
98
|
+
governed: ReadonlySet<string> | undefined,
|
|
99
|
+
): Record<string, T> {
|
|
100
|
+
if (!map) return {};
|
|
101
|
+
if (!governed) return map;
|
|
102
|
+
return Object.fromEntries(
|
|
103
|
+
Object.entries(map).filter(([grantee]) =>
|
|
104
|
+
governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'))),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The canonical authorization form for one table.
|
|
110
|
+
*
|
|
111
|
+
* `governed` is the set the models declare. Pass it whenever hashing a LIVE contract, or the
|
|
112
|
+
* hash counts grants the reconciler will never touch and the state can never converge.
|
|
113
|
+
*/
|
|
114
|
+
export function canonicalAuthz(
|
|
115
|
+
contract: TableContract,
|
|
116
|
+
governed?: ReadonlySet<string>,
|
|
117
|
+
): Record<string, unknown> {
|
|
118
|
+
const grants = governedOnly(contract.grants, governed);
|
|
119
|
+
const columnGrants = governedOnly(contract.columnGrants, governed);
|
|
120
|
+
return {
|
|
121
|
+
table: contract.table,
|
|
122
|
+
rls: { enabled: contract.rls.enabled, forced: contract.rls.forced },
|
|
123
|
+
grants: Object.fromEntries(
|
|
124
|
+
Object.entries(grants)
|
|
125
|
+
.map(([role, privs]) => [role, [...(privs as string[])].sort()] as const)
|
|
126
|
+
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
127
|
+
),
|
|
128
|
+
...(Object.keys(columnGrants).length > 0
|
|
129
|
+
? {
|
|
130
|
+
columnGrants: Object.fromEntries(
|
|
131
|
+
Object.entries(columnGrants)
|
|
132
|
+
.map(([role, byPriv]) => [
|
|
133
|
+
role,
|
|
134
|
+
Object.fromEntries(
|
|
135
|
+
Object.entries(byPriv as Record<string, string[]>)
|
|
136
|
+
.map(([priv, cols]) => [priv, [...cols].sort()] as const)
|
|
137
|
+
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
138
|
+
),
|
|
139
|
+
] as const)
|
|
140
|
+
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
141
|
+
),
|
|
142
|
+
}
|
|
143
|
+
: {}),
|
|
144
|
+
policies: canonicalPolicies(contract.policies),
|
|
145
|
+
};
|
|
146
|
+
}
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -208,6 +208,31 @@ function isColumnRead(a: Ability): boolean {
|
|
|
208
208
|
return a.action === 'read' && Boolean(a.condition.owner) && Boolean(a.condition.columns?.length);
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* `can(…, { role: 'public' })` names PostgreSQL's PUBLIC pseudo-role, and the two catalogs
|
|
213
|
+
* that describe it spell it differently — both correctly:
|
|
214
|
+
*
|
|
215
|
+
* - `pg_policies.roles` renders it as the literal `{public}` (lowercase)
|
|
216
|
+
* - `aclexplode()` yields grantee OID 0, which GRANTS_SQL renders as `'PUBLIC'`
|
|
217
|
+
*
|
|
218
|
+
* So the compiler speaks each catalog's spelling on its own axis. Saying `public` on both
|
|
219
|
+
* made the grant set-difference see `public` and `PUBLIC` as two grantees and emit a REVOKE
|
|
220
|
+
* and a GRANT that cancel out — on every run, converging never.
|
|
221
|
+
*
|
|
222
|
+
* The fold is COMPILE-TIME ONLY, applied to the author's declared role. Nothing folds at
|
|
223
|
+
* comparison time: `CREATE ROLE "Public"` is a legal, distinct role, and a case-insensitive
|
|
224
|
+
* compare against a live grantee would silently conflate it with the pseudo-role. (The cost
|
|
225
|
+
* of that choice: a real role named `Public` cannot be named by `role:`, because a model
|
|
226
|
+
* string carries no way to say "quoted identifier".)
|
|
227
|
+
*/
|
|
228
|
+
const isPseudoPublic = (role: string): boolean => role.toLowerCase() === 'public';
|
|
229
|
+
|
|
230
|
+
/** The grant-side spelling of a declared role — what `aclexplode` would report. */
|
|
231
|
+
const granteeKey = (role: string): string => (isPseudoPublic(role) ? 'PUBLIC' : role);
|
|
232
|
+
|
|
233
|
+
/** The policy-side spelling of a declared role — what `pg_policies` would report. */
|
|
234
|
+
const policyRole = (role: string): string => (isPseudoPublic(role) ? 'public' : role);
|
|
235
|
+
|
|
211
236
|
/**
|
|
212
237
|
* Column grants from the column-scoped read abilities: the read role (`authenticated`, since
|
|
213
238
|
* the ability is owner-scoped, not role-gated) gets `SELECT (cols)`. Field keys are snake_cased
|
|
@@ -219,7 +244,7 @@ function compileColumnGrants(abilities: readonly Ability[]): Record<string, Reco
|
|
|
219
244
|
const out: Record<string, Record<string, string[]>> = {};
|
|
220
245
|
for (const a of abilities) {
|
|
221
246
|
if (!isColumnRead(a)) continue;
|
|
222
|
-
const role = a.condition.role ?? 'authenticated';
|
|
247
|
+
const role = granteeKey(a.condition.role ?? 'authenticated');
|
|
223
248
|
const cols = [...a.condition.columns!].map(toSnakeCase).sort();
|
|
224
249
|
(out[role] ??= {}).SELECT = cols;
|
|
225
250
|
}
|
|
@@ -436,7 +461,8 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
436
461
|
// discarded. That is the same failure `rawPredicate` throws over, one level up: a predicate
|
|
437
462
|
// that evaporates is an authorization hole wearing the costume of a working rule.
|
|
438
463
|
for (const a of abilities.filter(isRoleRead)) {
|
|
439
|
-
|
|
464
|
+
// The policy-side spelling, so `role: 'PUBLIC'` still matches what pg_policies reports.
|
|
465
|
+
const role = policyRole(a.condition.role!);
|
|
440
466
|
const name = `${t}_select_${role}`;
|
|
441
467
|
const where = `${table}: can('read', { role: '${role}' })`;
|
|
442
468
|
if (policies.some((p) => p.name === name)) {
|
|
@@ -478,7 +504,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
478
504
|
// write on the owner connection and bypass RLS -> ENABLE-not-FORCE (on RDS the
|
|
479
505
|
// owner is not a superuser, so a FORCEd table would block the owner's own writes).
|
|
480
506
|
rls: { enabled: true, forced: model.writtenBy === 'app' },
|
|
481
|
-
grants: compileGrants(abilities),
|
|
507
|
+
grants: compileGrants(abilities, model.privileges),
|
|
482
508
|
...(compileColumnGrants(abilities) ? { columnGrants: compileColumnGrants(abilities) } : {}),
|
|
483
509
|
policies,
|
|
484
510
|
};
|
|
@@ -517,10 +543,19 @@ function verbsFor(action: Ability['action']): string[] {
|
|
|
517
543
|
* table grants nothing, an owner-only table grants no anon SELECT, an admin-managed
|
|
518
544
|
* table grants admin CRUD only. Deterministic: roles and privilege lists sorted,
|
|
519
545
|
* so the output is byte-comparable with an introspected contract.
|
|
546
|
+
*
|
|
547
|
+
* `privileges` is the model's beyond-CRUD key — REFERENCES/TRIGGER/TRUNCATE, the table
|
|
548
|
+
* privileges `can()` has no verb for. They are UNIONED in, never subtracted: an admin that
|
|
549
|
+
* `can('manage')` and holds TRUNCATE keeps both. Without it, `manage` meant exactly CRUD and
|
|
550
|
+
* every live REFERENCES/TRIGGER/TRUNCATE read as drift, so the first plan against an existing
|
|
551
|
+
* schema revoked all three on every table — our spelling, not their schema.
|
|
520
552
|
*/
|
|
521
|
-
function compileGrants(
|
|
553
|
+
function compileGrants(
|
|
554
|
+
abilities: readonly Ability[],
|
|
555
|
+
privileges: Record<string, readonly string[]> = {},
|
|
556
|
+
): Record<string, string[]> {
|
|
522
557
|
const grants: Record<string, Set<string>> = {};
|
|
523
|
-
const add = (role: string, verbs: string[]): void => {
|
|
558
|
+
const add = (role: string, verbs: readonly string[]): void => {
|
|
524
559
|
const set = (grants[role] ??= new Set<string>());
|
|
525
560
|
for (const v of verbs) set.add(v);
|
|
526
561
|
};
|
|
@@ -531,13 +566,18 @@ function compileGrants(abilities: readonly Ability[]): Record<string, string[]>
|
|
|
531
566
|
if (isColumnRead(a)) continue;
|
|
532
567
|
const verbs = verbsFor(a.action);
|
|
533
568
|
if (a.condition.role) {
|
|
534
|
-
add(a.condition.role, verbs);
|
|
569
|
+
add(granteeKey(a.condition.role), verbs);
|
|
535
570
|
} else {
|
|
536
571
|
add('authenticated', verbs);
|
|
537
572
|
// A public read (no role, not owner/via-scoped) is anon-visible.
|
|
538
573
|
if (a.action === 'read' && !a.condition.owner && !a.condition.via) add('anon', ['SELECT']);
|
|
539
574
|
}
|
|
540
575
|
}
|
|
576
|
+
// The role is spelled on the GRANT axis, so `privileges: { public: [...] }` reaches the
|
|
577
|
+
// same grantee `aclexplode` reports as `PUBLIC` — the fold the ability path already does.
|
|
578
|
+
for (const [role, privs] of Object.entries(privileges)) {
|
|
579
|
+
if (privs.length) add(granteeKey(role), privs);
|
|
580
|
+
}
|
|
541
581
|
const out: Record<string, string[]> = {};
|
|
542
582
|
for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
|
|
543
583
|
return out;
|